diff --git a/updater_tools/lib/src/commands/diff_command.dart b/updater_tools/lib/src/commands/diff_command.dart new file mode 100644 index 0000000..9b90ff9 --- /dev/null +++ b/updater_tools/lib/src/commands/diff_command.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:updater_tools/src/commands/updater_tool_command.dart'; +import 'package:updater_tools/src/extensions/arg_results.dart'; +import 'package:updater_tools/src/logger.dart'; +import 'package:updater_tools/src/packager/patch_packager.dart'; + +/// The arg name to specify the path to the release binary. +const releaseCliArg = 'release'; + +/// The arg name to specify the path to the patch binary. +const patchCliArg = 'patch'; + +/// The arg name to specify the path to the patch executable. +const patchExecutableCliArg = 'patch-executable'; + +/// The arg name to specify the output file. +const outputCliArg = 'output'; + +/// {@template diff_command} +/// A wrapper around the patch executable +/// {@endtemplate} +class DiffCommand extends UpdaterToolCommand { + /// {@macro diff_command} + DiffCommand([MakePatchPackager? makePatchPackager]) + : _makePatchPackagerOverride = makePatchPackager, + super() { + argParser + ..addOption( + releaseCliArg, + abbr: 'r', + mandatory: true, + help: 'The path to the release artifact which will be patched', + ) + ..addOption( + patchCliArg, + abbr: 'p', + mandatory: true, + help: 'The path to the patch artifact which will be packaged', + ) + ..addOption( + patchExecutableCliArg, + mandatory: true, + help: + '''The path to the patch executable that creates a binary diff between two files''', + ) + ..addOption( + outputCliArg, + abbr: 'o', + mandatory: true, + help: ''' +Where to write the packaged patch archives. + +This should be a directory, and will contain patch archives for each architecture.''', + ); + } + + final MakePatchPackager? _makePatchPackagerOverride; + + @override + String get name => 'diff'; + + @override + String get description => + '''Outputs a binary diff of the provided release and patch files, using release as a base.'''; + + @override + Future run() async { + final File releaseFile; + final File patchFile; + final File patchExecutable; + try { + releaseFile = results.asExistingFile(releaseCliArg); + patchFile = results.asExistingFile(patchCliArg); + patchExecutable = results.asExistingFile(patchExecutableCliArg); + } catch (e) { + logger.err('$e'); + return ExitCode.usage.code; + } + + final patchPackager = (_makePatchPackagerOverride ?? PatchPackager.new)( + patchExecutable: patchExecutable, + ); + await patchPackager.makeDiff( + base: releaseFile, + patch: patchFile, + outFile: File(results[outputCliArg] as String), + ); + + return ExitCode.success.code; + } +} diff --git a/updater_tools/lib/src/commands/package_patch_command.dart b/updater_tools/lib/src/commands/package_patch_command.dart index 619c852..f6ae38f 100644 --- a/updater_tools/lib/src/commands/package_patch_command.dart +++ b/updater_tools/lib/src/commands/package_patch_command.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:mason_logger/mason_logger.dart'; import 'package:updater_tools/src/artifact_type.dart'; import 'package:updater_tools/src/commands/updater_tool_command.dart'; +import 'package:updater_tools/src/extensions/arg_results.dart'; import 'package:updater_tools/src/logger.dart'; import 'package:updater_tools/src/packager/patch_packager.dart'; @@ -21,11 +22,6 @@ const patchExecutableCliArg = 'patch-executable'; /// The arg name to specify the output directory. const outputCliArg = 'output'; -/// Function signature for the [PatchPackager] constructor. -typedef MakePatchPackager = PatchPackager Function({ - required File patchExecutable, -}); - /// {@template package_patch_command} /// A command to package patch artifacts. /// {@endtemplate} @@ -81,16 +77,18 @@ This should be a directory, and will contain patch archives for each architectur @override Future run() async { - final releaseFile = File(results[releaseCliArg] as String); - final patchFile = File(results[patchCliArg] as String); - final patchExecutable = File(results[patchExecutableCliArg] as String); final outputDirectory = Directory(results[outputCliArg] as String); final archiveType = ArchiveType.values.byName( results[archiveTypeCliArg] as String, ); + final File releaseFile; + final File patchFile; + final File patchExecutable; try { - _assertCliArgsValid(); + releaseFile = results.asExistingFile(releaseCliArg); + patchFile = results.asExistingFile(patchCliArg); + patchExecutable = results.asExistingFile(patchExecutableCliArg); } catch (e) { logger.err('$e'); return ExitCode.usage.code; @@ -115,27 +113,4 @@ This should be a directory, and will contain patch archives for each architectur return ExitCode.success.code; } - - /// Verifies that CLI arguments point to existing files. Throws an - /// [ArgumentError] if any of the args are not valid. - void _assertCliArgsValid() { - final releaseFilePath = results[releaseCliArg] as String; - final patchFilePath = results[patchCliArg] as String; - final patchExecutablePath = results[patchExecutableCliArg] as String; - - _verifyFileExists(releaseFilePath, releaseCliArg); - _verifyFileExists(patchFilePath, patchCliArg); - _verifyFileExists(patchExecutablePath, patchExecutableCliArg); - } - - /// Throws an [ArgumentError] if a file at [path] does not exist. - void _verifyFileExists(String path, String name) { - if (!File(path).existsSync()) { - throw ArgumentError.value( - path, - name, - 'The $name file does not exist', - ); - } - } } diff --git a/updater_tools/lib/src/extensions/arg_results.dart b/updater_tools/lib/src/extensions/arg_results.dart new file mode 100644 index 0000000..cb55fb9 --- /dev/null +++ b/updater_tools/lib/src/extensions/arg_results.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:args/args.dart'; + +/// Extension methods for validating options provided to [ArgResults]. +extension ArgResultsValidation on ArgResults { + File asExistingFile(String name) { + final file = File(this[name] as String); + if (!file.existsSync()) { + throw ArgumentError.value( + file.path, + name, + 'The $name file does not exist', + ); + } + + return file; + } +} diff --git a/updater_tools/lib/src/packager/patch_packager.dart b/updater_tools/lib/src/packager/patch_packager.dart index 9d148e4..e439691 100644 --- a/updater_tools/lib/src/packager/patch_packager.dart +++ b/updater_tools/lib/src/packager/patch_packager.dart @@ -22,6 +22,11 @@ class PackagingException implements Exception { String toString() => 'PackagingException: $message'; } +/// Function signature for the [PatchPackager] constructor. +typedef MakePatchPackager = PatchPackager Function({ + required File patchExecutable, +}); + /// {@template patch_packager} /// Creates and packages patch artifacts. /// {@endtemplate } @@ -120,7 +125,7 @@ class PatchPackager { final diffArchDir = Directory(p.join(outDir.path, archName)) ..createSync(recursive: true); final diffFile = File(p.join(diffArchDir.path, 'dlc.vmcode')); - await _makeDiff( + await makeDiff( base: releaseElf, patch: patchElf, outFile: diffFile, @@ -149,7 +154,7 @@ class PatchPackager { /// Create a binary diff between [base] and [patch]. Returns the path to the /// diff file. - Future _makeDiff({ + Future makeDiff({ required File base, required File patch, required File outFile, diff --git a/updater_tools/lib/src/updater_tools_command_runner.dart b/updater_tools/lib/src/updater_tools_command_runner.dart index 87fc028..583d122 100644 --- a/updater_tools/lib/src/updater_tools_command_runner.dart +++ b/updater_tools/lib/src/updater_tools_command_runner.dart @@ -2,6 +2,7 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:updater_tools/src/commands/commands.dart'; +import 'package:updater_tools/src/commands/diff_command.dart'; import 'package:updater_tools/src/logger.dart'; import 'package:updater_tools/version.dart'; @@ -30,7 +31,7 @@ class UpdaterToolsCommandRunner extends CommandRunner { help: 'Noisy logging, including all shell commands executed.', ); - // Add sub commands + addCommand(DiffCommand()); addCommand(PackagePatchCommand()); } diff --git a/updater_tools/test/src/commands/diff_command_test.dart b/updater_tools/test/src/commands/diff_command_test.dart new file mode 100644 index 0000000..63b995b --- /dev/null +++ b/updater_tools/test/src/commands/diff_command_test.dart @@ -0,0 +1,174 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:test/test.dart'; +import 'package:updater_tools/src/commands/diff_command.dart'; +import 'package:updater_tools/src/logger.dart'; +import 'package:updater_tools/src/packager/patch_packager.dart'; + +import '../../matchers/matchers.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockLogger extends Mock implements Logger {} + +class _MockPatchPackager extends Mock implements PatchPackager {} + +void main() { + group(DiffCommand, () { + late ArgResults argResults; + late Logger logger; + late PatchPackager patchPackager; + late DiffCommand command; + + late File releaseFile; + late File patchFile; + late File patchExecutable; + late File outputFile; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + loggerRef.overrideWith(() => logger), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + }); + + setUp(() { + argResults = _MockArgResults(); + logger = _MockLogger(); + patchPackager = _MockPatchPackager(); + + final tempDir = Directory.systemTemp.createTempSync(); + releaseFile = File(p.join(tempDir.path, 'release')) + ..createSync(recursive: true); + patchFile = File(p.join(tempDir.path, 'patch')) + ..createSync(recursive: true); + patchExecutable = File(p.join(tempDir.path, 'patch.exe')) + ..createSync(recursive: true); + outputFile = File(p.join(tempDir.path, 'output')); + when(() => argResults[releaseCliArg]).thenReturn(releaseFile.path); + when(() => argResults[patchCliArg]).thenReturn(patchFile.path); + when(() => argResults[patchExecutableCliArg]) + .thenReturn(patchExecutable.path); + when(() => argResults[outputCliArg]).thenReturn(outputFile.path); + + command = DiffCommand( + ({required File patchExecutable}) => patchPackager, + )..testArgResults = argResults; + }); + + test('has a non-empty name', () { + expect(command.name, isNotEmpty); + }); + + test('has a non-empty description', () { + expect(command.description, isNotEmpty); + }); + + group('arg validation', () { + group('when release file does not exist', () { + setUp(() { + releaseFile.deleteSync(); + }); + + test('logs error and exits with code 64', () async { + expect( + await runWithOverrides(command.run), + equals(ExitCode.usage.code), + ); + + verify( + () => logger.err( + any(that: contains('The release file does not exist')), + ), + ); + }); + }); + + group('when patch file does not exist', () { + setUp(() { + patchFile.deleteSync(); + }); + + test('logs error and exits with code 64', () async { + expect( + await runWithOverrides(command.run), + equals(ExitCode.usage.code), + ); + + verify( + () => logger.err( + any(that: contains('The patch file does not exist')), + ), + ); + }); + }); + + group('when patch executable does not exist', () { + setUp(() { + patchExecutable.deleteSync(); + }); + + test('logs error and exits with code 64', () async { + expect( + await runWithOverrides(command.run), + equals(ExitCode.usage.code), + ); + + verify( + () => logger.err( + any(that: contains('The patch-executable file does not exist')), + ), + ); + }); + }); + }); + + group('when args are valid', () { + setUp(() { + when( + () => patchPackager.makeDiff( + base: any(named: 'base'), + patch: any(named: 'patch'), + outFile: any(named: 'outFile'), + ), + ).thenAnswer((_) async {}); + }); + + test('forwards values to patchPackager', () { + expect( + runWithOverrides(command.run), + completion(ExitCode.success.code), + ); + + verify( + () => patchPackager.makeDiff( + base: any( + named: 'base', + that: equalsFileSystemEntity(releaseFile), + ), + patch: any( + named: 'patch', + that: equalsFileSystemEntity(patchFile), + ), + outFile: any( + named: 'outFile', + that: equalsFileSystemEntity(outputFile), + ), + ), + ).called(1); + }); + }); + }); +}