diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index dcf3313..42fa11a 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.13.1", + "flutterSdkVersion": "3.13.5", "flavors": {} } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b8af374..e7ded60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## Version 0.16.3 +- adds more diff result reporting options (cli, json, markdown) + ## Version 0.16.2 - fixes relative path handling in package config (leading to unresolvable types) diff --git a/README.md b/README.md index 97f4633..dd1a4e8 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,9 @@ Usage: dart-apitool diff [arguments] --[no-]remove-example Removes examples from the package to analyze. (defaults to on) --[no-]ignore-requiredness Whether to ignore the required aspect of interfaces (yielding less strict version bump requirements) + --report-format Which output format should be used + [cli (default), markdown, json] + --report-file-path Where to store the report file (no effect on cli option) ``` ## Integration diff --git a/bin/main.dart b/bin/main.dart index a09801e..e1df0b2 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -7,41 +7,11 @@ import 'package:colorize/colorize.dart'; import 'package:colorize_lumberdash/colorize_lumberdash.dart'; import 'package:dart_apitool/api_tool_cli.dart'; import 'package:lumberdash/lumberdash.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -Future _getOwnVersion() async { - String? result; - final mainFilePath = Platform.script.toFilePath(); - final pubspecFile = - File(p.join(p.dirname(mainFilePath), '..', 'pubspec.yaml')); - if (await pubspecFile.exists()) { - final yamlContent = await pubspecFile.readAsString(); - final pubSpec = Pubspec.parse(yamlContent); - result = pubSpec.version?.canonicalizedVersion; - } - if (result == null) { - // if we are in a pub global environment we have to read our version from the pubspec.lock file - final pubspecLockFile = - File(p.join(p.dirname(mainFilePath), '..', 'pubspec.lock')); - if (await pubspecLockFile.exists()) { - final pubspecLockContent = await pubspecLockFile.readAsString(); - final pubspecLockDom = loadYaml(pubspecLockContent); - result = pubspecLockDom['packages']['dart_apitool']['version']; - } - } - if (result == null) { - return 'UNKNOWN VERSION'; - } - return result; -} void main(List arguments) async { putLumberdashToWork(withClients: [ColorizeLumberdash()]); final runner = CommandRunner('dart-apitool', ''' -dart-apitool (${Colorize(await _getOwnVersion()).bold()}) +dart-apitool (${Colorize(await getOwnVersion()).bold()}) A set of utilities for Package APIs. ''') @@ -52,7 +22,7 @@ A set of utilities for Package APIs. try { final argParseResult = runner.argParser.parse(arguments); if (argParseResult['version']) { - print(await _getOwnVersion()); + print(await getOwnVersion()); exit(0); } final exitCode = await runner.run(arguments); diff --git a/lib/src/cli/commands/diff_command.dart b/lib/src/cli/commands/diff_command.dart index f7ce334..1127816 100644 --- a/lib/src/cli/commands/diff_command.dart +++ b/lib/src/cli/commands/diff_command.dart @@ -1,10 +1,13 @@ import 'dart:io'; import 'package:args/command_runner.dart'; -import 'package:colorize/colorize.dart'; -import 'package:console/console.dart'; import 'package:dart_apitool/api_tool.dart'; +import 'package:dart_apitool/src/diff/report/diff_reporter.dart'; +import 'package:dart_apitool/src/diff/report/json_diff_reporter.dart'; +import 'package:dart_apitool/src/diff/report/report_format.dart'; +import '../../diff/report/console_diff_reporter.dart'; +import '../../diff/report/markdown_diff_reporter.dart'; import '../package_ref.dart'; import 'command_mixin.dart'; import 'version_check.dart'; @@ -20,6 +23,8 @@ String _optionNameCheckSdkVersion = 'check-sdk-version'; String _optionNameDependencyCheckMode = 'dependency-check-mode'; String _optionNameRemoveExample = 'remove-example'; String _optionNameIgnoreRequiredness = 'ignore-requiredness'; +String _optionReportFormat = 'report-format'; +String _optionReportPath = 'report-file-path'; /// command for diffing two packages class DiffCommand extends Command with CommandMixin { @@ -97,12 +102,37 @@ You may want to do this if you want to make sure defaultsTo: false, negatable: true, ); + argParser.addOption( + _optionReportFormat, + help: 'Which output format should be used', + defaultsTo: ReportFormat.cli.name, + allowed: ReportFormat.values.map((e) => e.name), + mandatory: false, + ); + argParser.addOption( + _optionReportPath, + help: 'Where to store the report file (no effect on cli option)', + mandatory: false, + ); } @override Future run() async { final oldPackageRef = PackageRef(argResults![_optionNameOld]); final newPackageRef = PackageRef(argResults![_optionNameNew]); + final outputFormatter = ReportFormat.values.firstWhere( + (element) => element.name == argResults![_optionReportFormat]); + final outputFile = argResults![_optionReportPath]; + + if (outputFormatter != ReportFormat.cli && outputFile == null) { + throw 'You need to define an output file using the $_optionReportPath parameter when not using the cli option'; + } + + if (outputFormatter == ReportFormat.cli && outputFile != null) { + stdout.writeln( + 'WARNING: $_optionReportPath has no effect because $_optionReportFormat is set to cli'); + } + final versionCheckMode = VersionCheckMode.values.firstWhere( (element) => element.name == argResults![_optionNameVersionCheckMode]); final ignorePrerelease = argResults![_optionNameIgnorePrerelease] as bool; @@ -149,29 +179,27 @@ You may want to do this if you want to make sure final diffResult = differ.diff(oldApi: oldPackageApi, newApi: newPackageApi); - stdout.writeln(); - - // print the diffs - if (diffResult.hasChanges) { - final breakingChanges = _printApiChangeNode(diffResult.rootNode, true); - if (breakingChanges == null) { - stdout.writeln('No breaking changes!'); - } else { - stdout.write(breakingChanges); + DiffReporter reporter = (() { + switch (outputFormatter) { + case ReportFormat.cli: + return ConsoleDiffReporter(); + case ReportFormat.markdown: + return MarkdownDiffReporter( + oldPackageRef: oldPackageRef, + newPackageRef: newPackageRef, + outputFile: File(outputFile)); + case ReportFormat.json: + return JsonDiffReporter( + oldPackageRef: oldPackageRef, + newPackageRef: newPackageRef, + outputFile: File(outputFile)); + default: + throw 'Unknown format speicified $outputFormatter'; } - final nonBreakingChanges = - _printApiChangeNode(diffResult.rootNode, false); - if (nonBreakingChanges == null) { - stdout.writeln('No non-breaking changes!'); - } else { - stdout.write(nonBreakingChanges); - } - stdout.writeln(); - stdout.writeln( - 'To learn more about the detected changes visit: https://github.com/bmw-tech/dart_apitool/blob/main/readme/change_codes.md'); - } else { - stdout.writeln('No changes detected!'); - } + })(); + + stdout.writeln('-- Generating report using: ${reporter.reporterName} --'); + await reporter.generateReport(diffResult); if (versionCheckMode != VersionCheckMode.none && !VersionCheck.versionChangeMatchesChanges( @@ -185,59 +213,4 @@ You may want to do this if you want to make sure return 0; } - - String _getDeclarationNodeHeadline(Declaration declaration) { - var prefix = ''; - if (declaration is ExecutableDeclaration) { - switch (declaration.type) { - case ExecutableType.constructor: - prefix = 'Constructor '; - break; - case ExecutableType.method: - prefix = 'Method '; - break; - } - } else if (declaration is FieldDeclaration) { - prefix = 'Field '; - } else if (declaration is InterfaceDeclaration) { - prefix = 'Class '; - } - return prefix + declaration.name; - } - - String? _printApiChangeNode(ApiChangeTreeNode node, bool breaking) { - Map nodeToTree(ApiChangeTreeNode n, {String? labelOverride}) { - final relevantChanges = n.changes.where((c) => c.isBreaking == breaking); - final changeNodes = relevantChanges.map((c) => - '${Colorize(c.changeDescription).italic()} (${c.changeCode.code})${c.isBreaking ? '' : c.type.requiresMinorBump ? ' (minor)' : ' (patch)'}'); - final childNodes = n.children.values - .map((value) => nodeToTree(value)) - .where((element) => element.isNotEmpty); - final allChildren = [ - ...changeNodes, - ...childNodes, - ]; - if (allChildren.isEmpty) { - return {}; - } - return { - 'label': Colorize(labelOverride ?? - (n.nodeDeclaration == null - ? '' - : _getDeclarationNodeHeadline(n.nodeDeclaration!))) - .bold() - .toString(), - 'nodes': allChildren, - }; - } - - final nodes = nodeToTree(node, - labelOverride: breaking ? 'BREAKING CHANGES' : 'Non-Breaking changes'); - - if (nodes.isEmpty) { - return null; - } - - return createTree(nodes); - } } diff --git a/lib/src/diff/api_change_code.dart b/lib/src/diff/api_change_code.dart index b364d88..1bcb2b7 100644 --- a/lib/src/diff/api_change_code.dart +++ b/lib/src/diff/api_change_code.dart @@ -1,57 +1,165 @@ enum ApiChangeCode { + /// interface removed ci01._('CI01', 'interface removed'), + + /// interface added ci02._('CI02', 'interface added'), + + /// interface renamed ci03._('CI03', 'interface renamed'), + + /// supertype added ci04._('CI04', 'supertype added'), + + /// supertype removed ci05._('CI05', 'supertype removed'), + + /// type parameters changed ci06._('CI06', 'type parameters changed'), + + /// type parameter added ci07._('CI07', 'type parameter added'), + + /// type parameter removed ci08._('CI08', 'type parameter removed'), + + /// deprecated status changed ci09._('CI09', 'deprecated status changed'), + + /// experimental status changed ci10._('CI10', 'experimental status changed'), + + /// sealed status changed ci11._('CI11', 'sealed status changed'), + + /// executable parameters removed ce01._('CE01', 'executable parameters removed'), + + /// executable parameters added ce02._('CE02', 'executable parameters added'), + + /// executable parameters renamed ce03._('CE03', 'executable parameters renamed'), + + /// executable parameters reordered ce04._('CE04', 'executable parameters reordered'), + + /// executable parameter requiredness changed + ce05._('CE05', 'executable parameter requiredness changed'), + + /// executable parameter deprecated status changed ce06._('CE06', 'executable parameter deprecated status changed'), + + /// executable parameter named status changed ce07._('CE07', 'executable parameter named status changed'), + + /// executable parameter type changed ce08._('CE08', 'executable parameter type changed'), + + /// executable return type changed ce09._('CE09', 'executable return type changed'), + + /// executable removed ce10._('CE10', 'executable removed'), + + /// executable added ce11._('CE11', 'executable added'), + + /// executable renamed ce12._('CE12', 'executable renamed'), + + /// executable deprecated status changed ce13._('CE13', 'executable deprecated status changed'), + + /// executable changed from/to static/non-static ce14._('CE14', 'executable changed from/to static/non-static'), + + /// executable experimental status changed ce15._('CE15', 'executable experimental status changed'), + + /// executable parameter experimental status changed ce16._('CE16', 'executable parameter experimental status changed'), + + /// new entry point cp01._('CP01', 'new entry point'), + + /// entry point removed cp02._('CP02', 'entry point removed'), + + /// field removed cf01._('CF01', 'field removed'), + + /// field added cf02._('CF02', 'field added'), + + /// field deprecated status changed cf03._('CF03', 'field deprecated status changed'), + + /// field type changed cf04._('CF04', 'field type changed'), + + /// field static status changed cf05._('CF05', 'field static status changed'), + + /// field experimental status changed cf06._('CF06', 'field experimental status changed'), + + /// iOS platform added cpi01._('CPI01', 'iOS platform added'), + + /// iOS platform removed cpi02._('CPI02', 'iOS platform removed'), + + /// iOS platform constraint changed cpi03._('CPI03', 'iOS platform constraint changed'), + + /// Android platform added cpa01._('CPA01', 'Android platform added'), + + /// Android platform removed cpa02._('CPA02', 'Android platform removed'), + + /// Android platform min SDK added cpa03._('CPA03', 'Android platform min SDK added'), + + /// Android platform min SDK removed cpa04._('CPA04', 'Android platform min SDK removed'), + + /// Android platform min SDK changed cpa05._('CPA05', 'Android platform min SDK changed'), + + /// Android platform target SDK added cpa06._('CPA06', 'Android platform target SDK added'), + + /// Android platform target SDK removed cpa07._('CPA07', 'Android platform target SDK removed'), + + /// Android platform target SDK changed cpa08._('CPA08', 'Android platform target SDK changed'), + + /// Android platform compile SDK added cpa09._('CPA09', 'Android platform compile SDK added'), + + /// Android platform compile SDK removed cpa10._('CPA10', 'Android platform compile SDK removed'), + + /// Android platform compile SDK changed cpa11._('CPA11', 'Android platform compile SDK changed'), + + /// Type of SDK changed csdk01._('CSDK01', 'Type of SDK changed'), + + /// Min SDK version raised csdk02._('CSDK02', 'Min SDK version raised'), + + /// Dependency added cd01._('CD01', 'Dependency added'), + + /// Dependency removed cd02._('CD02', 'Dependency removed'), + + /// Dependency version changed cd03._('CD03', 'Dependency version changed'), ; diff --git a/lib/src/diff/report/console_diff_reporter.dart b/lib/src/diff/report/console_diff_reporter.dart new file mode 100644 index 0000000..473992e --- /dev/null +++ b/lib/src/diff/report/console_diff_reporter.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:colorize/colorize.dart'; +import 'package:console/console.dart'; + +import '../../../api_tool.dart'; +import 'diff_reporter.dart'; + +class ConsoleDiffReporter extends DiffReporter { + @override + final String reporterName = 'Console Reporter'; + + @override + Future generateReport(PackageApiDiffResult diffResult) async { + void printChanges(bool breaking) { + final changes = _printApiChangeNode(diffResult.rootNode, breaking); + if (changes == null) { + stdout.writeln( + breaking ? 'No breaking changes!' : 'No non-breaking changes!'); + } else { + stdout.write(changes); + } + } + + // Print the diffs + if (diffResult.hasChanges) { + printChanges(true); // Breaking changes + printChanges(false); // Non-breaking changes + stdout.writeln( + 'To learn more about the detected changes visit: https://github.com/bmw-tech/dart_apitool/blob/main/readme/change_codes.md', + ); + } else { + stdout.writeln('No changes detected!'); + } + } + + String? _printApiChangeNode(ApiChangeTreeNode node, bool breaking) { + Map nodeToTree(ApiChangeTreeNode n, {String? labelOverride}) { + final relevantChanges = n.changes.where((c) => c.isBreaking == breaking); + final changeNodes = relevantChanges.map((c) => + '${Colorize(c.changeDescription).italic()} (${c.changeCode.code})${c.isBreaking ? '' : c.type.requiresMinorBump ? ' (minor)' : ' (patch)'}'); + final childNodes = n.children.values + .map((value) => nodeToTree(value)) + .where((element) => element.isNotEmpty); + final allChildren = [...changeNodes, ...childNodes]; + return allChildren.isEmpty + ? {} + : { + 'label': Colorize(labelOverride ?? + (n.nodeDeclaration == null + ? '' + : getDeclarationNodeHeadline(n.nodeDeclaration!))) + .bold() + .toString(), + 'nodes': allChildren, + }; + } + + final nodes = nodeToTree(node, + labelOverride: breaking ? 'BREAKING CHANGES' : 'Non-Breaking changes'); + + return nodes.isEmpty ? null : createTree(nodes); + } +} diff --git a/lib/src/diff/report/diff_reporter.dart b/lib/src/diff/report/diff_reporter.dart new file mode 100644 index 0000000..2e43820 --- /dev/null +++ b/lib/src/diff/report/diff_reporter.dart @@ -0,0 +1,7 @@ +import '../../../api_tool.dart'; + +abstract class DiffReporter { + String get reporterName; + + Future generateReport(PackageApiDiffResult diffResult); +} diff --git a/lib/src/diff/report/json_diff_reporter.dart b/lib/src/diff/report/json_diff_reporter.dart new file mode 100644 index 0000000..80439cc --- /dev/null +++ b/lib/src/diff/report/json_diff_reporter.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../../../api_tool_cli.dart'; +import 'diff_reporter.dart'; + +class JsonDiffReporter extends DiffReporter { + @override + final String reporterName = 'JSON Reporter'; + final PackageRef oldPackageRef, newPackageRef; + final File outputFile; + + JsonDiffReporter({ + required this.oldPackageRef, + required this.newPackageRef, + required this.outputFile, + }); + + @override + Future generateReport(PackageApiDiffResult diffResult) async { + final jsonReport = { + 'reportName': 'API Changes Report', + 'apiToolInfo': { + 'toolName': 'dart_apitool', + 'toolVersion': await getOwnVersion(), + 'toolAuthor': 'BMW Tech', + 'generatedAt': DateTime.now().toUtc().toLocal().toString(), + 'oldRef': oldPackageRef.ref, + 'newRef': newPackageRef.ref + }, + 'report': {}, + }; + + void addChanges(bool breaking) { + final changes = _printApiChangeNode(diffResult.rootNode, breaking); + if (changes != null) { + jsonReport['report'] + [breaking ? 'breakingChanges' : 'nonBreakingChanges'] = changes; + } + } + + // Add the API changes + if (diffResult.hasChanges) { + addChanges(true); // Breaking changes + addChanges(false); // Non-breaking changes + } else { + jsonReport['report']['noChangesDetected'] = true; + } + + // Write the JSON report to a file + await outputFile.writeAsString(jsonEncode(jsonReport)); + + print('JSON report generated at ${outputFile.path}'); + } + + Map? _printApiChangeNode( + ApiChangeTreeNode node, bool breaking) { + Map nodeToMap(ApiChangeTreeNode n, + {String? labelOverride}) { + final relevantChanges = n.changes.where((c) => c.isBreaking == breaking); + final changeList = relevantChanges + .map((c) => { + 'changeDescription': c.changeDescription, + 'changeCode': c.changeCode.code, + 'isBreaking': c.isBreaking, + 'type': c.type.requiresMinorBump ? 'minor' : 'patch', + }) + .toList(); + final childNodes = n.children.values + .map((value) => nodeToMap(value)) + .where((element) => element.isNotEmpty); + final allChildren = [...changeList, ...childNodes]; + return allChildren.isEmpty + ? {} + : { + 'label': labelOverride ?? + (n.nodeDeclaration == null + ? '' + : getDeclarationNodeHeadline(n.nodeDeclaration!)), + 'children': allChildren, + }; + } + + final nodes = nodeToMap(node, + labelOverride: breaking ? 'BREAKING CHANGES' : 'Non-Breaking changes'); + + return nodes.isEmpty ? null : nodes; + } +} diff --git a/lib/src/diff/report/markdown_diff_reporter.dart b/lib/src/diff/report/markdown_diff_reporter.dart new file mode 100644 index 0000000..4b53401 --- /dev/null +++ b/lib/src/diff/report/markdown_diff_reporter.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:dart_apitool/api_tool_cli.dart'; + +import 'diff_reporter.dart'; + +const changeCodesReadMe = + "https://github.com/bmw-tech/dart_apitool/blob/main/readme/change_codes.md"; + +class MarkdownDiffReporter extends DiffReporter { + @override + final String reporterName = 'Markdown Reporter'; + final PackageRef oldPackageRef, newPackageRef; + final File outputFile; + + MarkdownDiffReporter({ + required this.oldPackageRef, + required this.newPackageRef, + required this.outputFile, + }); + + @override + Future generateReport(PackageApiDiffResult diffResult) async { + final markdownReport = StringBuffer(); + markdownReport + ..writeln('# API Changes Report') + ..writeln() + ..writeln('## ℹī¸ API-Tool Info') + ..writeln('Comparing $oldPackageRef vs $newPackageRef ') + ..writeln( + 'This report was generated using [dart_apitool](https://github.com/bmw-tech/dart_apitool) v${await getOwnVersion()} by [BMW Tech](https://github.com/bmw-tech). ') + ..writeln( + 'This report was created on ${DateTime.now().toUtc().toLocal()} UTC.') + ..writeln() + ..writeln('## Report') + ..writeln( + 'To learn more about the detected changes, visit [GitHub Change Codes]($changeCodesReadMe)') + ..writeln(); + + void printChanges(bool breaking) { + markdownReport + ..writeln( + '### ${breaking ? '🚨 BREAKING CHANGES' : '🆕 Non-Breaking Changes'}') + ..writeln(); + + final changes = _printApiChangeNode(diffResult.rootNode, breaking); + markdownReport.writeln(changes ?? + (breaking + ? 'No breaking changes! 🎉' + : 'No non-breaking changes! 🎉')); + + markdownReport.writeln(); + } + + // Print the diffs + if (diffResult.hasChanges) { + printChanges(true); // Breaking changes + printChanges(false); // Non-breaking changes + } else { + markdownReport + ..writeln('### No Changes Detected') + ..writeln('No changes detected!'); + } + + // Write the Markdown report to a file + await outputFile.writeAsString(markdownReport.toString()); + + print('Markdown report generated at ${outputFile.path}'); + } + + String? _printApiChangeNode(ApiChangeTreeNode node, bool breaking) { + Map nodeToTree(ApiChangeTreeNode n, + {String? labelOverride}) { + final relevantChanges = n.changes.where((c) => c.isBreaking == breaking); + final childNodes = n.children.values + .map((value) => nodeToTree(value)) + .where((element) => element.isNotEmpty); + final allChildren = [...relevantChanges, ...childNodes]; + return allChildren.isEmpty + ? {} + : { + 'label': labelOverride ?? + (n.nodeDeclaration == null + ? '' + : getDeclarationNodeHeadline(n.nodeDeclaration!)), + 'children': allChildren + }; + } + + final nodes = nodeToTree(node, labelOverride: 'Changelist:'); + + if (nodes.isEmpty) { + return null; + } + + String generateMarkdownTree(Map node, + {int currentLevel = 0}) { + final parentIndentation = ' ' * currentLevel; + final childIndentation = ' ' * (currentLevel + 1); + final buffer = StringBuffer(); + + buffer.writeln('$parentIndentation * ${node['label']}'); + + for (final change in node['children']) { + if (change is Map && change.containsKey('children')) { + buffer.write( + generateMarkdownTree(change, currentLevel: currentLevel + 1)); + } else { + final changeEntry = + '$childIndentation * **${change.changeDescription}** [(${change.changeCode.code})]($changeCodesReadMe#${change.changeCode.code.toLowerCase()})${change.isBreaking ? '' : change.type.requiresMinorBump ? ' (minor)' : ' (patch)'}'; + buffer.writeln(changeEntry); + } + } + return buffer.toString(); + } + + return generateMarkdownTree(nodes); + } +} diff --git a/lib/src/diff/report/report_format.dart b/lib/src/diff/report/report_format.dart new file mode 100644 index 0000000..fbda58a --- /dev/null +++ b/lib/src/diff/report/report_format.dart @@ -0,0 +1 @@ +enum ReportFormat { cli, markdown, json } diff --git a/lib/src/utils/declaration_utils.dart b/lib/src/utils/declaration_utils.dart new file mode 100644 index 0000000..852aa4c --- /dev/null +++ b/lib/src/utils/declaration_utils.dart @@ -0,0 +1,14 @@ +import '../../api_tool.dart'; + +String getDeclarationNodeHeadline(Declaration declaration) { + var prefix = ''; + if (declaration is ExecutableDeclaration) { + prefix = declaration.type.toString().split('.').last; + prefix = '${prefix[0].toUpperCase()}${prefix.substring(1)} '; + } else if (declaration is FieldDeclaration) { + prefix = 'Field '; + } else if (declaration is InterfaceDeclaration) { + prefix = 'Class '; + } + return prefix + declaration.name; +} diff --git a/lib/src/utils/utils.dart b/lib/src/utils/utils.dart index 7dbea7d..52568d7 100644 --- a/lib/src/utils/utils.dart +++ b/lib/src/utils/utils.dart @@ -1,4 +1,6 @@ +export 'declaration_utils.dart'; export 'naming_utils.dart'; export 'process_utils.dart'; export 'stdout_session.dart'; export 'string_utils.dart'; +export 'version_utils.dart'; diff --git a/lib/src/utils/version_utils.dart b/lib/src/utils/version_utils.dart new file mode 100644 index 0000000..2f636d0 --- /dev/null +++ b/lib/src/utils/version_utils.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:pubspec_parse/pubspec_parse.dart'; +import 'package:yaml/yaml.dart'; + +Future getOwnVersion() async { + String? result; + final mainFilePath = Platform.script.toFilePath(); + final pubspecFile = + File(p.join(p.dirname(mainFilePath), '..', 'pubspec.yaml')); + if (await pubspecFile.exists()) { + final yamlContent = await pubspecFile.readAsString(); + final pubSpec = Pubspec.parse(yamlContent); + result = pubSpec.version?.canonicalizedVersion; + } + if (result == null) { + // if we are in a pub global environment we have to read our version from the pubspec.lock file + final pubspecLockFile = + File(p.join(p.dirname(mainFilePath), '..', 'pubspec.lock')); + if (await pubspecLockFile.exists()) { + final pubspecLockContent = await pubspecLockFile.readAsString(); + final pubspecLockDom = loadYaml(pubspecLockContent); + result = pubspecLockDom['packages']['dart_apitool']['version']; + } + } + if (result == null) { + return 'UNKNOWN VERSION'; + } + return result; +} diff --git a/readme/change_codes.md b/readme/change_codes.md index 5fe4e3f..4c84a76 100644 --- a/readme/change_codes.md +++ b/readme/change_codes.md @@ -1,192 +1,360 @@ # Reasons for detected changes -dart-apitool has a set of rules it applies in order to detect (breaking) changes. +dart-apitool has a set of rules it applies in order to detect (breaking) changes. Those are often not clear at the first glance this is why this page tries to explain the different situations that lead to (breaking) changes and dart-apitool complaining about the selected version. -The general rule of thumb: If a change can break a user's code without the user changing something (but using semver) then we have a breaking change. +The general rule of thumb: If a change can break a user's code without the user changing something (but using semver) then we have a breaking change. Therefore, we only consider changes in the public API of a package (meaning: things reachable without including stuff from the src directory). ## Interfaces -Dart-apitool treats classes, interfaces (abstract classes), enums, mixins and extension classes as interfaces. -### An interface is removed (CI01) +Dart-apitool treats classes, interfaces (abstract classes), enums, mixins, and extension classes as interfaces. + +### CI01 + +> An interface is removed + This is a breaking change because it is possible that a user of the library is using the interface. If the interface is removed, the user will get a compile error. -### An interface is added (CI02) +### CI02 + +> An interface is added + Adding a new interface is an API change but a non-breaking one. -### An interface is renamed (CI03) +### CI03 + +> An interface is renamed + dart-apitool is treating this as removal of the old interface and addition of the new interface. So a breaking change (remove) and a non-breaking change (add). -### Supertype added (CI04) +### CI04 + +> Supertype added + Adding a supertype extends the API of the interface and therefore is a non-breaking change. -### Supertype removed (CI05) +### CI05 + +> Supertype removed + Removing a supertype potentially reduces the API of an interface and therefore is a breaking change. -### The type parameters of an interface are changed (generic) (CI06) -This is a breaking change because it is possible that a user of the library is using the interface. If the type parameters are changed, the user will get a compile error. -This is a very generic error message that gets used if the name of the type parameters are ignored (number of type parameters changed) +### CI06 + +> The type parameters of an interface are changed (generic) + +This is a breaking change because it is possible that a user of the library is using the interface. If the type parameters are changed, the user will get a compile error. +This is a very generic error message that gets used if the name of the type parameters are ignored (number of type parameters changed). + +### CI07 + +> Type parameter added -### Type parameter added (CI07) A type parameter has been added to the interface. This is a breaking change. -### Type parameter removed (CI08) +### CI08 + +> Type parameter removed + A type parameter has been removed from the interface. This is a breaking change. -### The deprecated status of an interface is changed (CI09) +### CI09 + +> The deprecated status of an interface is changed + This is a non-breaking change. -### The experimental status of an interface is changed (CI10) +### CI10 + +> The experimental status of an interface is changed + If the flag got removed then this change is non-breaking. Adding an experimental flag is considered a breaking change. -### The sealed status of an interface is changed (CI11) +### CI11 + +> The sealed status of an interface is changed + If the flag got removed then this change is non-breaking. Adding a sealed flag is considered a breaking change. -## Executables are constructors, methods and functions. They are all treated the same way. +## Executables are constructors, methods, and functions. They are all treated the same way. + +### CE01 + +> Executable parameter(s) are removed -### Executable parameter(s) are removed (CE01) Removing a parameter of an executable is always a breaking change as a library user might be using the parameter. -### Executable parameter(s) are added (CE02) +### CE02 + +> Executable parameter(s) are added + Here it depends. If the parameter is an optional one then we have a non-breaking change. If it is a required parameter then this change is breaking. -### Executable parameter(s) are renamed (CE03) +### CE03 + +> Executable parameter(s) are renamed + dart-apitool tries to detect renames (by using type / order of parameters). If it can detect a renaming then it depends on the fact if the parameter is named or not. If a renaming of a named parameter is detected, then this change is breaking. A renaming of a positional parameter is not breaking. -### Executable parameters are reordered (CE04) +### CE04 + +> Executable parameters are reordered + That's a tricky one. Dart-apitool tries to match the old and the new parameters based on name and type to try to follow the reordering. If a reordering happened with named parameters and dart-apitool is able to match them then we have a non-breaking change. If it has (or dart-apitool things that it has) any effect on the user's code then we have a breaking change. -### Executable parameter requiredness is changed (CE05) +### CE05 + +> Executable parameter requiredness is changed + If a parameter is made optional then this is a non-breaking change. If it is made required then this is a breaking change. -### Executable parameter deprecated status changed (CE06) -this is a non-breaking change. +### CE06 + +> Executable parameter deprecated status changed -### Executable parameter named status changed (CE07) -this is a breaking change. +This is a non-breaking change. + +### CE07 + +> Executable parameter named status changed + +This is a breaking change. + +### CE08 + +> Executable parameter type is changed -### Executable parameter type is changed (CE08) This change is considered breaking if the new type of the parameter is narrower than or completely unrelated to the old type. If the new type is wider (like making it nullable or using a supertype) then the change is non-breaking. -### Executable return type is changed (CE09) +### CE09 + +> Executable return type is changed + This is always a breaking change. -### Executable is removed (CE10) +### CE10 + +> Executable is removed + This is always a breaking change. -### Executable is added (CE11) +### CE11 + +> Executable is added + This is probably a non-breaking change. For exceptions to this see "Required interfaces" -### Executable is renamed (CE12) +### CE12 + +> Executable is renamed + This is treated as removal of the old executable and addition of the new one. So a breaking change (remove) and a (non-)breaking change (add). -### The deprecated status of an executable is changed (CE13) +### CE13 + +> The deprecated status of an executable is changed + This is a non-breaking change. -### The declaration of an executable changed from/to non-static/static (CE14) +### CE14 + +> The declaration of an executable changed from/to non-static/static + This is a breaking change. -### The experimental status of an executable is changed (CE15) +### CE15 + +> The experimental status of an executable is changed + If the flag got removed then this change is non-breaking. Adding an experimental flag is considered a breaking change. -### Executable parameter experimental status is changed (CE16) +### CE16 + +> Executable parameter experimental status is changed + If the flag got removed then this change is non-breaking. Adding an experimental flag is considered a breaking change. ## Fields -### Field is removed (CF01) + +### CF01 + +> Field is removed + This is always a breaking change. -### Field added (CF02) +### CF02 + +> Field added + Adding a field extends the API and therefore is non-breaking (Exception: required interfaces) -### Field deprecated status changed (CF03) -this is a non-breaking change. +### CF03 + +> Field deprecated status changed + +This is a non-breaking change. + +### CF04 + +> Field type changed -### Field type changed (CF04) This is a breaking change. -### Field declaration changed from/to non-static/static (CF05) +### CF05 + +> Field declaration changed from/to non-static/static + This is a breaking change. -### Field experimental status changed (CF06) +### CF06 + +> Field experimental status changed + If the flag got removed then this change is non-breaking. Adding an experimental flag is considered a breaking change. ## Entry points + Entry points are the imports that lead to this type being usable. Any change in those entry points is an API change. -### New entry point (CP01) +### CP01 + +> New entry point + Means a type is accessible through an additional entry point. Non-breaking change -### Entry point removed (CP02) +### CP02 + +> Entry point removed + Means a type is no longer accessible through an entry point. This is a breaking change as it can break users code ## Dependencies -### A dependency is added (CD01) + +### CD01 + +> A dependency is added + This is a breaking change. This might not be obvious but adding a dependency can lead to a broken build of the user's code. For example, if the user is using a dependency that is not compatible with the new dependency then the user's code will not build. -### A dependency is removed (CD02) +### CD02 + +> A dependency is removed + This is a non-breaking change. The user's code might rely on that dependency transitively, but this is bad practice anyway ;) -### A dependency version is changed (CD03) -This depends on the kind of version change. If the change is a patch or minor upgrade (so still in semver range) then the change is non-breaking. If the upgrade is a major upgrade then the change is breaking. +### CD03 + +> A dependency version is changed + +This depends on the kind of version change. If the change is a patch or minor upgrade (so still in semver range) then the change is non-breaking. If the upgrade is a major upgrade then the change is breaking. Changes here only require a patch level version bump. ## Platform + Changes to the platform and its constraints are breaking if they are stricter than before. This means if the user's code is not compatible with the new constraints then the user's code will not build. -### iOS platform added (CPI01) +### CPI01 + +> iOS platform added + Platform got added. This is a breaking change. -### iOS platform removed (CPI02) +### CPI02 + +> iOS platform removed + Platform got removed. This is a breaking change. -### iOS platform constraint changed (CPI03) +### CPI03 + +> iOS platform constraint changed + The version of the iOS platform constraint changed. This is a breaking change if the new constraint is stricter than the old one. -### Android platform added (CPA01) +### CPA01 + +> Android platform added + Platform got added. This is a breaking change. -### Android platform removed (CPA02) +### CPA02 + +> Android platform removed + Platform got removed. This is a breaking change. -### Android platform min SDK version added (CPA03) +### CPA03 + +> Android platform min SDK version added + Min SDK Version for Android got added. This is a breaking change. -### Android platform min SDK version removed (CPA04) +### CPA04 + +> Android platform min SDK version removed + Min SDK Version for Android got removed. This is a non-breaking change. -### Android platform min SDK version changed (CPA05) +### CPA05 + +> Android platform min SDK version changed + Min SDK Version for Android got changed. This is a breaking change if the new constraint is stricter than the old one. -### Android platform target SDK version added (CPA06) +### CPA06 + +> Android platform target SDK version added + Target SDK Version for Android got added. This is a breaking change. -### Android platform target SDK version removed (CPA07) +### CPA07 + +> Android platform target SDK version removed + Target SDK Version for Android got removed. This is a non-breaking change. -### Android platform target SDK version changed (CPA08) +### CPA08 + +> Android platform target SDK version changed + Target SDK Version for Android got changed. This is a breaking change if the new constraint is stricter than the old one. -### Android platform compile SDK version added (CPA09) +### CPA09 + +> Android platform compile SDK version added + Compile SDK Version for Android got added. This is a breaking change. -### Android platform compile SDK version removed (CPA10) +### CPA10 + +> Android platform compile SDK version removed + Compile SDK Version for Android got removed. This is a non-breaking change. -### Android platform compile SDK version changed (CPA11) -Compile SDK Version for Android got changed. This is a breaking change if the new constraint is stricter than the old one. +### CPA11 + +> Android platform compile SDK version changed +Compile SDK Version for Android got changed. This is a breaking change if the new constraint is stricter than the old one. ## SDK constraints -### SDK Type changed (CSDK01) + +### CSDK01 + +> SDK Type changed + This is a breaking change. (e.g. Dart → Flutter) -### Min SDK version got raised (CSDK02) +### CSDK02 + +> Min SDK version got raised + This is a breaking change. ## Required interfaces -Required interfaces are interfaces that are meant to be implemented by the user of the library. Dart-apitool detects those interfaces if they are abstract classes and passed as a parameter to an executable or are used as the type of a setter. + +Required interfaces are interfaces that are meant to be implemented by the user of the library. Dart-apitool detects those interfaces if they are abstract classes and passed as a parameter to an executable or are used as the type of a setter. This means if an interface is required then adding executables or fields to it is a breaking change as it may affect the user's code. diff --git a/test/integration_tests/cli/diff_command_test.dart b/test/integration_tests/cli/diff_command_test.dart index 3a633b3..280c3ea 100644 --- a/test/integration_tests/cli/diff_command_test.dart +++ b/test/integration_tests/cli/diff_command_test.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:args/command_runner.dart'; import 'package:dart_apitool/api_tool_cli.dart'; -import 'package:test/test.dart'; import 'package:path/path.dart' as path; +import 'package:test/test.dart'; import '../helper/integration_test_helper.dart'; @@ -69,5 +72,76 @@ void main() { }, timeout: integrationTestTimeout, ); + + test( + 'diffing cloud_firestore 4.3.1 to 4.3.2 and producing a markdown report works', + () async { + final tempDir = await Directory.systemTemp.createTemp(); + final reportFilePath = path.join(tempDir.path, 'markdown_report.md'); + // just some random package for testing the diff command for pub refs + final diffCommand = DiffCommand(); + final runner = + CommandRunner('dart_apitool_tests', 'Test for dart_apitool') + ..addCommand(diffCommand); + final exitCode = await runner.run([ + 'diff', + '--old', + 'pub://cloud_firestore/4.3.1', + '--new', + 'pub://cloud_firestore/4.3.2', + '--report-format', + 'markdown', + '--report-file-path', + reportFilePath + ]); + expect(exitCode, 0); + expect(File(reportFilePath).existsSync(), isTrue); + + final markdownContent = File(reportFilePath).readAsStringSync(); + tempDir.deleteSync(recursive: true); + + // just some random probes + expect(markdownContent, contains('No breaking changes!')); + expect(markdownContent, contains('readme/change_codes.md')); + }, + timeout: integrationTestTimeout, + ); + + test( + 'diffing cloud_firestore 4.3.1 to 4.3.2 and producing a json report works', + () async { + final tempDir = await Directory.systemTemp.createTemp(); + final reportFilePath = path.join(tempDir.path, 'json_report.json'); + // just some random package for testing the diff command for pub refs + final diffCommand = DiffCommand(); + final runner = + CommandRunner('dart_apitool_tests', 'Test for dart_apitool') + ..addCommand(diffCommand); + final exitCode = await runner.run([ + 'diff', + '--old', + 'pub://cloud_firestore/4.3.1', + '--new', + 'pub://cloud_firestore/4.3.2', + '--report-format', + 'json', + '--report-file-path', + reportFilePath + ]); + expect(exitCode, 0); + expect(File(reportFilePath).existsSync(), isTrue); + + final jsonContent = File(reportFilePath).readAsStringSync(); + tempDir.deleteSync(recursive: true); + + // just some random probes + final parsedJson = jsonDecode(jsonContent); + expect(parsedJson, isNotNull); + expect(parsedJson['apiToolInfo'], isNotNull); + expect(parsedJson['reportName'], isNotNull); + expect(parsedJson['report'], isNotNull); + }, + timeout: integrationTestTimeout, + ); }); } diff --git a/test/unit_tests/diff/report/console_diff_reporter_test.dart b/test/unit_tests/diff/report/console_diff_reporter_test.dart new file mode 100644 index 0000000..01aba42 --- /dev/null +++ b/test/unit_tests/diff/report/console_diff_reporter_test.dart @@ -0,0 +1,83 @@ +import 'package:dart_apitool/api_tool.dart'; +import 'package:dart_apitool/src/diff/report/console_diff_reporter.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConsoleDiffReporter', () { + late ConsoleDiffReporter reporter; + + setUp(() { + reporter = ConsoleDiffReporter(); + }); + + PackageApiDiffResult createEmptyDiffResult() { + return PackageApiDiffResult(); + } + + void addBreakingChange( + PackageApiDiffResult diffResult, { + ApiChangeCode changeCode = ApiChangeCode.ci01, + }) { + diffResult.addApiChange(ApiChange( + changeCode: changeCode, + changeDescription: 'Test breaking change: ${changeCode.name}', + contextTrace: [], + isExperimental: false, + type: ApiChangeType.remove, + )); + } + + void addNonBreakingChange( + PackageApiDiffResult diffResult, { + ApiChangeCode changeCode = ApiChangeCode.ci02, + }) { + diffResult.addApiChange(ApiChange( + changeCode: changeCode, + changeDescription: 'Test non-breaking change: ${changeCode.name}', + contextTrace: [], + isExperimental: false, + type: ApiChangeType.addCompatiblePatch, + )); + } + + test('Can be instantiated', () { + // the setup would have failed already + expect(reporter, isA()); + }); + + test('Can handle empty diff report', () { + final diffResult = createEmptyDiffResult(); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + test('Can handle diff report with only one breaking change', () { + final diffResult = createEmptyDiffResult(); + addBreakingChange(diffResult, changeCode: ApiChangeCode.ci01); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + test('Can handle diff report with multiple breaking changes', () { + final diffResult = createEmptyDiffResult(); + addBreakingChange(diffResult, changeCode: ApiChangeCode.ci01); + addBreakingChange(diffResult, changeCode: ApiChangeCode.ci04); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + test('Can handle diff report with only one non-breaking change', () { + final diffResult = createEmptyDiffResult(); + addNonBreakingChange(diffResult, changeCode: ApiChangeCode.ci02); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + test('Can handle diff report with multiple non-breaking changes', () { + final diffResult = createEmptyDiffResult(); + addNonBreakingChange(diffResult, changeCode: ApiChangeCode.ci02); + addNonBreakingChange(diffResult, changeCode: ApiChangeCode.ci05); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + test('Can handle diff report with breaking and non-breaking changes', () { + final diffResult = createEmptyDiffResult(); + addBreakingChange(diffResult, changeCode: ApiChangeCode.ci01); + addNonBreakingChange(diffResult, changeCode: ApiChangeCode.ci02); + addBreakingChange(diffResult, changeCode: ApiChangeCode.ci04); + addNonBreakingChange(diffResult, changeCode: ApiChangeCode.ci05); + expect(() => reporter.generateReport(diffResult), returnsNormally); + }); + }); +}