diff --git a/lib/src/command_runner/commands/install_completion_files_command.dart b/lib/src/command_runner/commands/install_completion_files_command.dart index 79e8d76..76c0e53 100644 --- a/lib/src/command_runner/commands/install_completion_files_command.dart +++ b/lib/src/command_runner/commands/install_completion_files_command.dart @@ -49,7 +49,7 @@ class InstallCompletionFilesCommand extends Command { FutureOr? run() { final verbose = argResults!['verbose'] as bool; final level = verbose ? Level.verbose : Level.info; - runner.tryInstallCompletionFiles(level); + runner.tryInstallCompletionFiles(level, force: true); return null; } } diff --git a/lib/src/command_runner/completion_command_runner.dart b/lib/src/command_runner/completion_command_runner.dart index cd7a860..f333bbf 100644 --- a/lib/src/command_runner/completion_command_runner.dart +++ b/lib/src/command_runner/completion_command_runner.dart @@ -65,16 +65,18 @@ abstract class CompletionCommandRunner extends CommandRunner { return _completionInstallation = completionInstallation; } + /// The list of commands that should not trigger the auto installation. + static const _reservedCommands = { + HandleCompletionRequestCommand.commandName, + InstallCompletionFilesCommand.commandName, + UnistallCompletionFilesCommand.commandName, + }; + @override @mustCallSuper Future runCommand(ArgResults topLevelResults) async { - final reservedCommands = [ - HandleCompletionRequestCommand.commandName, - InstallCompletionFilesCommand.commandName, - ]; - if (enableAutoInstall && - !reservedCommands.contains(topLevelResults.command?.name)) { + !_reservedCommands.contains(topLevelResults.command?.name)) { // When auto installing, use error level to display messages. tryInstallCompletionFiles(Level.error); } @@ -84,10 +86,10 @@ abstract class CompletionCommandRunner extends CommandRunner { /// Tries to install completion files for the current shell. @internal - void tryInstallCompletionFiles(Level level) { + void tryInstallCompletionFiles(Level level, {bool force = false}) { try { completionInstallationLogger.level = level; - completionInstallation.install(executableName); + completionInstallation.install(executableName, force: force); } on CompletionInstallationException catch (e) { completionInstallationLogger.warn(e.toString()); } on Exception catch (e) { diff --git a/lib/src/installer/completion_configuration.dart b/lib/src/installer/completion_configuration.dart index 7a10957..33e116a 100644 --- a/lib/src/installer/completion_configuration.dart +++ b/lib/src/installer/completion_configuration.dart @@ -137,3 +137,54 @@ String _jsonEncodeUninstalls(Uninstalls uninstalls) { entry.key.toString(): entry.value.toList(), }); } + +/// Provides convinience methods for [Uninstalls]. +extension UninstallsExtension on Uninstalls { + /// Returns a new [Uninstalls] with the given [command] added to + /// [systemShell]. + Uninstalls include({ + required String command, + required SystemShell systemShell, + }) { + final modifiable = _modifiable(); + + if (modifiable.containsKey(systemShell)) { + modifiable[systemShell]!.add(command); + } else { + modifiable[systemShell] = {command}; + } + + return UnmodifiableMapView( + modifiable.map((key, value) => MapEntry(key, UnmodifiableSetView(value))), + ); + } + + /// Returns a new [Uninstalls] with the given [command] removed from + /// [systemShell]. + Uninstalls exclude({ + required String command, + required SystemShell systemShell, + }) { + final modifiable = _modifiable(); + + if (modifiable.containsKey(systemShell)) { + modifiable[systemShell]!.remove(command); + } + + return UnmodifiableMapView( + modifiable.map((key, value) => MapEntry(key, UnmodifiableSetView(value))), + ); + } + + /// Whether the [command] is contained in [systemShell]. + bool contains({required String command, required SystemShell systemShell}) { + if (containsKey(systemShell)) { + return this[systemShell]!.contains(command); + } + return false; + } + + Map> _modifiable() { + return map((key, value) => MapEntry(key, value.toSet())); + } +} diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index 9904280..d01c075 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -90,6 +90,12 @@ class CompletionInstallation { } } + /// Define the [File] in which the completion configuration is stored. + @visibleForTesting + File get completionConfigurationFile { + return File(path.join(completionConfigDir.path, 'config.json')); + } + /// Install completion configuration files for a [rootCommand] in the /// current shell. /// @@ -101,7 +107,11 @@ class CompletionInstallation { /// completion script file. /// - A line in the shell config file (e.g. `.bash_profile`) that sources /// the aforementioned config file. - void install(String rootCommand) { + /// + /// If [force] is true, it will overwrite the command's completion files even + /// if they already exist. If false, it will check if it has been explicitly + /// uninstalled before installing it. + void install(String rootCommand, {bool force = false}) { final configuration = this.configuration; if (configuration == null) { @@ -111,6 +121,10 @@ class CompletionInstallation { ); } + if (!force && !_shouldInstall(rootCommand)) { + return; + } + logger.detail( 'Installing completion for the command $rootCommand ' 'on ${configuration.shell.name}', @@ -124,6 +138,33 @@ class CompletionInstallation { if (completionFileCreated) { _logSourceInstructions(rootCommand); } + + final completionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + completionConfiguration + .copyWith( + uninstalls: completionConfiguration.uninstalls.exclude( + command: rootCommand, + systemShell: configuration.shell, + ), + ) + .writeTo(completionConfigurationFile); + } + + /// Wether the completion configuration files for a [rootCommand] should be + /// installed or not. + /// + /// It will return false if the root command has been explicitly uninstalled. + bool _shouldInstall(String rootCommand) { + final completionConfiguration = CompletionConfiguration.fromFile( + completionConfigurationFile, + ); + final systemShell = configuration!.shell; + final isUninstalled = completionConfiguration.uninstalls.contains( + command: rootCommand, + systemShell: systemShell, + ); + return !isUninstalled; } /// Create a directory in which the completion config files shall be saved. @@ -378,9 +419,23 @@ ${configuration!.sourceLineTemplate(scriptPath)}'''; if (!shellCompletionConfigurationFile.existsSync()) { completionEntry.removeFrom(shellRCFile); } - - if (completionConfigDir.listSync().isEmpty) { - completionConfigDir.deleteSync(); + final completionConfigDirContent = completionConfigDir.listSync(); + final onlyHasConfigurationFile = completionConfigDirContent.length == 1 && + path.absolute(completionConfigDirContent.first.path) == + path.absolute(completionConfigurationFile.path); + if (completionConfigDirContent.isEmpty || onlyHasConfigurationFile) { + completionConfigDir.deleteSync(recursive: true); + } else { + final completionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + completionConfiguration + .copyWith( + uninstalls: completionConfiguration.uninstalls.include( + command: rootCommand, + systemShell: configuration.shell, + ), + ) + .writeTo(completionConfigurationFile); } } } diff --git a/test/src/command_runner/commands/install_completion_files_command_test.dart b/test/src/command_runner/commands/install_completion_files_command_test.dart index 399a0b5..d2f1346 100644 --- a/test/src/command_runner/commands/install_completion_files_command_test.dart +++ b/test/src/command_runner/commands/install_completion_files_command_test.dart @@ -4,9 +4,9 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:test/test.dart'; -class MockLogger extends Mock implements Logger {} +class _MockLogger extends Mock implements Logger {} -class MockCompletionInstallation extends Mock +class _MockCompletionInstallation extends Mock implements CompletionInstallation {} class _TestCompletionCommandRunner extends CompletionCommandRunner { @@ -14,11 +14,11 @@ class _TestCompletionCommandRunner extends CompletionCommandRunner { @override // ignore: overridden_fields - final Logger completionInstallationLogger = MockLogger(); + final Logger completionInstallationLogger = _MockLogger(); @override final CompletionInstallation completionInstallation = - MockCompletionInstallation(); + _MockCompletionInstallation(); } void main() { @@ -45,6 +45,15 @@ void main() { }); group('install completion files', () { + test('forces install', () async { + await commandRunner.run(['install-completion-files']); + + verify( + () => commandRunner.completionInstallation + .install(commandRunner.executableName, force: true), + ).called(1); + }); + test('when normal', () async { await commandRunner.run(['install-completion-files']); diff --git a/test/src/command_runner/completion_command_runner_test.dart b/test/src/command_runner/completion_command_runner_test.dart index eb65636..7e6e758 100644 --- a/test/src/command_runner/completion_command_runner_test.dart +++ b/test/src/command_runner/completion_command_runner_test.dart @@ -8,7 +8,7 @@ import 'package:test/test.dart'; class MockLogger extends Mock implements Logger {} -class MockCompletionInstallation extends Mock +class _MockCompletionInstallation extends Mock implements CompletionInstallation {} class _TestCompletionCommandRunner extends CompletionCommandRunner { @@ -116,13 +116,13 @@ void main() { test('Tries to install completion files on test subcommand', () async { final commandRunner = _TestCompletionCommandRunner() ..addCommand(_TestUserCommand()) - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); await commandRunner.run(['ahoy']); - verify(() => commandRunner.completionInstallation.install('test')) - .called(1); - + verify( + () => commandRunner.completionInstallation.install('test'), + ).called(1); verify( () => commandRunner.completionInstallationLogger.level = Level.error, ).called(1); @@ -132,7 +132,7 @@ void main() { final commandRunner = _TestCompletionCommandRunner() ..enableAutoInstall = false ..addCommand(_TestUserCommand()) - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); await commandRunner.run(['ahoy']); @@ -142,6 +142,24 @@ void main() { () => commandRunner.completionInstallationLogger.level = any(), ); }); + + test('softly tries to install when enabled', () async { + final commandRunner = _TestCompletionCommandRunner() + ..enableAutoInstall = true + ..addCommand(_TestUserCommand()) + ..mockCompletionInstallation = _MockCompletionInstallation() + ..environmentOverride = { + 'SHELL': '/foo/bar/zsh', + }; + + await commandRunner.run(['ahoy']); + + verify( + () => commandRunner.completionInstallation.install( + commandRunner.executableName, + ), + ).called(1); + }); }); test( @@ -149,7 +167,7 @@ void main() { () async { final commandRunner = _TestCompletionCommandRunner() ..addCommand(_TestUserCommand()) - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); when( () => commandRunner.completionInstallation.install('test'), @@ -168,7 +186,7 @@ void main() { () async { final commandRunner = _TestCompletionCommandRunner() ..addCommand(_TestUserCommand()) - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); when( () => commandRunner.completionInstallation.install('test'), @@ -185,7 +203,7 @@ void main() { 'logs a warning wen it throws $CompletionUninstallationException', () async { final commandRunner = _TestCompletionCommandRunner() - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); when( () => commandRunner.completionInstallation.uninstall('test'), @@ -207,7 +225,7 @@ void main() { 'logs an error when an unknown exception happens during a install', () async { final commandRunner = _TestCompletionCommandRunner() - ..mockCompletionInstallation = MockCompletionInstallation(); + ..mockCompletionInstallation = _MockCompletionInstallation(); when( () => commandRunner.completionInstallation.uninstall('test'), diff --git a/test/src/installer/completion_configuration_test.dart b/test/src/installer/completion_configuration_test.dart index ae158e8..3e48821 100644 --- a/test/src/installer/completion_configuration_test.dart +++ b/test/src/installer/completion_configuration_test.dart @@ -193,4 +193,148 @@ void main() { }); }); }); + + group('UninstallsExtension', () { + group('include', () { + test('adds command to $Uninstalls when not already in', () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({}); + + final newUninstalls = + uninstalls.include(command: testCommand, systemShell: testShell); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isTrue, + ); + }); + + test('does nothing when $Uninstalls already has command', () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + final newUninstalls = + uninstalls.include(command: testCommand, systemShell: testShell); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isTrue, + ); + }); + + test('adds command $Uninstalls when on a different shell', () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + const anotherShell = SystemShell.zsh; + final newUninstalls = uninstalls.include( + command: testCommand, + systemShell: anotherShell, + ); + expect(testShell, isNot(equals(anotherShell))); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isTrue, + ); + expect( + newUninstalls.contains( + command: testCommand, + systemShell: anotherShell, + ), + isTrue, + ); + }); + }); + + group('exclude', () { + test('removes command when in $Uninstalls', () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + final newUninstalls = + uninstalls.exclude(command: testCommand, systemShell: testShell); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isFalse, + ); + }); + + test('does nothing when command not in $Uninstalls', () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({}); + + final newUninstalls = + uninstalls.exclude(command: testCommand, systemShell: testShell); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isFalse, + ); + }); + + test('does nothing when command in $Uninstalls is on a different shell', + () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + const anotherShell = SystemShell.zsh; + final newUninstalls = + uninstalls.exclude(command: testCommand, systemShell: anotherShell); + + expect( + newUninstalls.contains(command: testCommand, systemShell: testShell), + isTrue, + ); + }); + }); + + group('contains', () { + test('returns true when command is in $Uninstalls for the given shell', + () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + expect( + uninstalls.contains(command: testCommand, systemShell: testShell), + isTrue, + ); + }); + + test('returns false when command is in $Uninstalls for another shell', + () { + const testCommand = 'test_command'; + const testShell = SystemShell.bash; + final uninstalls = Uninstalls({ + testShell: UnmodifiableSetView({testCommand}), + }); + + const anotherShell = SystemShell.zsh; + expect(testShell, isNot(equals(anotherShell))); + + expect( + uninstalls.contains(command: testCommand, systemShell: anotherShell), + isFalse, + ); + }); + }); + }); } diff --git a/test/src/installer/completion_installation_test.dart b/test/src/installer/completion_installation_test.dart index 60a3def..2e6addb 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:io'; import 'package:cli_completion/installer.dart'; @@ -426,6 +427,7 @@ void main() { 'not_good.zsh', 'very_good.zsh', 'zsh-config.zsh', + 'config.json', ]), ); @@ -462,7 +464,8 @@ void main() { 'very_good.bash', 'very_good.zsh', 'zsh-config.zsh', - 'bash-config.bash' + 'bash-config.bash', + 'config.json', ]), ); }, @@ -492,6 +495,102 @@ void main() { ); }, ); + + test( + '''doesn't remove command from $CompletionConfiguration uninstalls when not forced to install''', + () { + const systemShell = SystemShell.zsh; + final installation = CompletionInstallation.fromSystemShell( + logger: logger, + isWindowsOverride: false, + environmentOverride: { + 'HOME': tempDir.path, + }, + systemShell: systemShell, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + const command = 'very_good'; + final completionConfigurationFile = + installation.completionConfigurationFile; + + final uninstalls = Uninstalls({ + systemShell: UnmodifiableSetView({command}), + }); + CompletionConfiguration.empty() + .copyWith(uninstalls: uninstalls) + .writeTo(completionConfigurationFile); + final completionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + expect( + completionConfiguration.uninstalls + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration should contain the uninstall for the command before install''', + ); + + installation.install(command); + + final newCompletionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + expect( + newCompletionConfiguration.uninstalls + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration should still contain the uninstall for the command after soft install''', + ); + }); + + test( + '''removes command from $CompletionConfiguration uninstalls when forced to install''', + () { + const systemShell = SystemShell.zsh; + final installation = CompletionInstallation.fromSystemShell( + logger: logger, + isWindowsOverride: false, + environmentOverride: { + 'HOME': tempDir.path, + }, + systemShell: systemShell, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + const command = 'very_good'; + final completionConfigurationFile = + installation.completionConfigurationFile; + + final uninstalls = Uninstalls({ + systemShell: UnmodifiableSetView({command}), + }); + CompletionConfiguration.empty() + .copyWith(uninstalls: uninstalls) + .writeTo(completionConfigurationFile); + final completionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + expect( + completionConfiguration.uninstalls + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration should contain the uninstall for the command before install''', + ); + + installation.install(command, force: true); + + final newCompletionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + expect( + newCompletionConfiguration.uninstalls + .contains(command: command, systemShell: systemShell), + isFalse, + reason: + '''The completion configuration should not contain the uninstall for the command after install''', + ); + }); }); group('uninstall', () { @@ -735,6 +834,36 @@ void main() { ); }); + test('adds command to uninstalls when not the last command', () { + const systemShell = SystemShell.zsh; + final installation = CompletionInstallation.fromSystemShell( + systemShell: systemShell, + logger: logger, + environmentOverride: { + 'HOME': tempDir.path, + }, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + const command = 'very_good'; + installation + ..install(command) + ..install('another_command') + ..uninstall(command); + + final completionConfigurationFile = + installation.completionConfigurationFile; + final completionConfiguration = + CompletionConfiguration.fromFile(completionConfigurationFile); + expect( + completionConfiguration.uninstalls + .contains(command: command, systemShell: systemShell), + isTrue, + reason: 'Command should be added to uninstalls after uninstalling.', + ); + }); + group('throws a CompletionUnistallationException', () { test('when RC file does not exist', () { final installation = CompletionInstallation(