diff --git a/lib/src/installer/completion_configuration.dart b/lib/src/installer/completion_configuration.dart index 33e116a..d4c6747 100644 --- a/lib/src/installer/completion_configuration.dart +++ b/lib/src/installer/completion_configuration.dart @@ -10,7 +10,7 @@ import 'package:meta/meta.dart'; /// /// The map and its content are unmodifiable. This is to ensure that /// [CompletionConfiguration]s is fully immutable. -typedef Uninstalls +typedef ShellCommandsMap = UnmodifiableMapView>; /// {@template completion_configuration} @@ -22,11 +22,14 @@ class CompletionConfiguration { /// {@macro completion_configuration} const CompletionConfiguration._({ required this.uninstalls, + required this.installs, }); /// Creates an empty [CompletionConfiguration]. @visibleForTesting - CompletionConfiguration.empty() : uninstalls = UnmodifiableMapView({}); + CompletionConfiguration.empty() + : uninstalls = ShellCommandsMap({}), + installs = ShellCommandsMap({}); /// Creates a [CompletionConfiguration] from the given [file] content. /// @@ -56,17 +59,34 @@ class CompletionConfiguration { } return CompletionConfiguration._( - uninstalls: _jsonDecodeUninstalls(decodedJson), + uninstalls: _jsonDecodeShellCommandsMap( + decodedJson, + jsonKey: CompletionConfiguration.uninstallsJsonKey, + ), + installs: _jsonDecodeShellCommandsMap( + decodedJson, + jsonKey: CompletionConfiguration.installsJsonKey, + ), ); } /// The JSON key for the [uninstalls] field. - static const String _uninstallsJsonKey = 'uninstalls'; + @visibleForTesting + static const String uninstallsJsonKey = 'uninstalls'; + + /// The JSON key for the [installs] field. + @visibleForTesting + static const String installsJsonKey = 'installs'; /// Stores those commands that have been manually uninstalled by the user. /// /// Uninstalls are specific to a given [SystemShell]. - final Uninstalls uninstalls; + final ShellCommandsMap uninstalls; + + /// Stores those commands that have completion installed. + /// + /// Installed commands are specific to a given [SystemShell]. + final ShellCommandsMap installs; /// Stores the [CompletionConfiguration] in the given [file]. void writeTo(File file) { @@ -76,73 +96,80 @@ class CompletionConfiguration { file.writeAsStringSync(_toJson()); } + /// Returns a JSON representation of this [CompletionConfiguration]. + String _toJson() { + return jsonEncode({ + uninstallsJsonKey: _jsonEncodeShellCommandsMap(uninstalls), + installsJsonKey: _jsonEncodeShellCommandsMap(installs), + }); + } + /// Returns a copy of this [CompletionConfiguration] with the given fields /// replaced. CompletionConfiguration copyWith({ - Uninstalls? uninstalls, + ShellCommandsMap? uninstalls, + ShellCommandsMap? installs, }) { return CompletionConfiguration._( uninstalls: uninstalls ?? this.uninstalls, + installs: installs ?? this.installs, ); } - - /// Returns a JSON representation of this [CompletionConfiguration]. - String _toJson() { - return jsonEncode({ - _uninstallsJsonKey: _jsonEncodeUninstalls(uninstalls), - }); - } } -/// Decodes [Uninstalls] from the given [json]. +/// Decodes [ShellCommandsMap] from the given [json]. /// /// If the [json] is not partially or fully valid, it handles issues gracefully /// without throwing an [Exception]. -Uninstalls _jsonDecodeUninstalls(Map json) { - if (!json.containsKey(CompletionConfiguration._uninstallsJsonKey)) { - return UnmodifiableMapView({}); +ShellCommandsMap _jsonDecodeShellCommandsMap( + Map json, { + required String jsonKey, +}) { + if (!json.containsKey(jsonKey)) { + return ShellCommandsMap({}); } - final jsonUninstalls = json[CompletionConfiguration._uninstallsJsonKey]; - if (jsonUninstalls is! String) { - return UnmodifiableMapView({}); + final jsonShellCommandsMap = json[jsonKey]; + if (jsonShellCommandsMap is! String) { + return ShellCommandsMap({}); } - late final Map decodedUninstalls; + late final Map decodedShellCommandsMap; try { - decodedUninstalls = jsonDecode(jsonUninstalls) as Map; + decodedShellCommandsMap = + jsonDecode(jsonShellCommandsMap) as Map; } on FormatException { - return UnmodifiableMapView({}); + return ShellCommandsMap({}); } - final newUninstalls = >{}; - for (final entry in decodedUninstalls.entries) { + final newShellCommandsMap = >{}; + for (final entry in decodedShellCommandsMap.entries) { final systemShell = SystemShell.tryParse(entry.key); if (systemShell == null) continue; - final uninstallSet = {}; + final commandsSet = {}; if (entry.value is List) { for (final uninstall in entry.value as List) { if (uninstall is String) { - uninstallSet.add(uninstall); + commandsSet.add(uninstall); } } } - newUninstalls[systemShell] = UnmodifiableSetView(uninstallSet); + newShellCommandsMap[systemShell] = UnmodifiableSetView(commandsSet); } - return UnmodifiableMapView(newUninstalls); + return UnmodifiableMapView(newShellCommandsMap); } -/// Returns a JSON representation of the given [Uninstalls]. -String _jsonEncodeUninstalls(Uninstalls uninstalls) { +/// Returns a JSON representation of the given [ShellCommandsMap]. +String _jsonEncodeShellCommandsMap(ShellCommandsMap shellCommandsMap) { return jsonEncode({ - for (final entry in uninstalls.entries) + for (final entry in shellCommandsMap.entries) 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 +/// Provides convinience methods for [ShellCommandsMap]. +extension ShellCommandsMapExtension on ShellCommandsMap { + /// Returns a new [ShellCommandsMap] with the given [command] added to /// [systemShell]. - Uninstalls include({ + ShellCommandsMap include({ required String command, required SystemShell systemShell, }) { @@ -159,9 +186,9 @@ extension UninstallsExtension on Uninstalls { ); } - /// Returns a new [Uninstalls] with the given [command] removed from + /// Returns a new [ShellCommandsMap] with the given [command] removed from /// [systemShell]. - Uninstalls exclude({ + ShellCommandsMap exclude({ required String command, required SystemShell systemShell, }) { diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index d01c075..9cb0d4d 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -109,8 +109,8 @@ class CompletionInstallation { /// the aforementioned config file. /// /// 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. + /// if they already exist. If false, it will check if is already installed, or + /// if it has been explicitly uninstalled before installing it. void install(String rootCommand, {bool force = false}) { final configuration = this.configuration; @@ -147,6 +147,10 @@ class CompletionInstallation { command: rootCommand, systemShell: configuration.shell, ), + installs: completionConfiguration.installs.include( + command: rootCommand, + systemShell: configuration.shell, + ), ) .writeTo(completionConfigurationFile); } @@ -154,17 +158,22 @@ class CompletionInstallation { /// 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. + /// It will return false if the root command is already installed or it + /// has been explicitly uninstalled. bool _shouldInstall(String rootCommand) { final completionConfiguration = CompletionConfiguration.fromFile( completionConfigurationFile, ); final systemShell = configuration!.shell; + final isInstalled = completionConfiguration.installs.contains( + command: rootCommand, + systemShell: systemShell, + ); final isUninstalled = completionConfiguration.uninstalls.contains( command: rootCommand, systemShell: systemShell, ); - return !isUninstalled; + return !isInstalled && !isUninstalled; } /// Create a directory in which the completion config files shall be saved. @@ -434,6 +443,10 @@ ${configuration!.sourceLineTemplate(scriptPath)}'''; command: rootCommand, systemShell: configuration.shell, ), + installs: completionConfiguration.installs.exclude( + command: rootCommand, + systemShell: configuration.shell, + ), ) .writeTo(completionConfigurationFile); } diff --git a/test/src/installer/completion_configuration_test.dart b/test/src/installer/completion_configuration_test.dart index 3e48821..d9e9371 100644 --- a/test/src/installer/completion_configuration_test.dart +++ b/test/src/installer/completion_configuration_test.dart @@ -9,13 +9,16 @@ import 'package:test/test.dart'; void main() { group('$CompletionConfiguration', () { - final testUninstalls = UnmodifiableMapView({ + final testInstalls = ShellCommandsMap({ + SystemShell.bash: UnmodifiableSetView({'very_good'}), + }); + final testUninstalls = ShellCommandsMap({ SystemShell.bash: UnmodifiableSetView({'very_bad'}), }); group('fromFile', () { test( - 'returns empty cache when file does not exist', + 'returns empty $CompletionConfiguration when file does not exist', () { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); @@ -27,25 +30,26 @@ void main() { reason: 'File should not exist', ); - final cache = CompletionConfiguration.fromFile(file); + final completionConfiguration = + CompletionConfiguration.fromFile(file); expect( - cache.uninstalls, + completionConfiguration.uninstalls, isEmpty, reason: 'Uninstalls should be initially empty', ); }, ); - test('returns empty cache when file is empty', () { + test('returns empty $CompletionConfiguration when file is empty', () { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); final file = File(path.join(tempDirectory.path, 'config.json')) ..writeAsStringSync(''); - final cache = CompletionConfiguration.fromFile(file); + final completionConfiguration = CompletionConfiguration.fromFile(file); expect( - cache.uninstalls, + completionConfiguration.uninstalls, isEmpty, reason: 'Uninstalls should be initially empty', ); @@ -57,31 +61,40 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final file = File(path.join(tempDirectory.path, 'config.json')); - final cache = CompletionConfiguration.empty().copyWith( + final completionConfiguration = + CompletionConfiguration.empty().copyWith( + installs: testInstalls, uninstalls: testUninstalls, )..writeTo(file); - final newCache = CompletionConfiguration.fromFile(file); + final newConfiguration = CompletionConfiguration.fromFile(file); + expect( + newConfiguration.installs, + equals(completionConfiguration.installs), + reason: 'Installs should match those defined in the file', + ); expect( - newCache.uninstalls, - cache.uninstalls, + newConfiguration.uninstalls, + equals(completionConfiguration.uninstalls), reason: 'Uninstalls should match those defined in the file', ); }); test( - '''returns a $CompletionConfiguration with empty uninstalls if the file's JSON "uninstalls" key has a string value''', + '''returns a $CompletionConfiguration with empty uninstalls if the file's JSON uninstalls key has a string value''', () { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); - const json = '{"uninstalls": "very_bad"}'; + const json = + '{"${CompletionConfiguration.uninstallsJsonKey}": "very_bad"}'; final file = File(path.join(tempDirectory.path, 'config.json')) ..writeAsStringSync(json); - final cache = CompletionConfiguration.fromFile(file); + final completionConfiguration = + CompletionConfiguration.fromFile(file); expect( - cache.uninstalls, + completionConfiguration.uninstalls, isEmpty, reason: '''Uninstalls should be empty when the value is of an invalid type''', @@ -90,24 +103,68 @@ void main() { ); test( - '''returns a $CompletionConfiguration with empty uninstalls if file's JSON "uninstalls" key has a numeric value''', + '''returns a $CompletionConfiguration with empty installs if the file's JSON installs key has a string value''', () { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); - const json = '{"uninstalls": 1}'; + const json = + '{"${CompletionConfiguration.installsJsonKey}": "very_bad"}'; final file = File(path.join(tempDirectory.path, 'config.json')) ..writeAsStringSync(json); - final cache = CompletionConfiguration.fromFile(file); + final completionConfiguration = + CompletionConfiguration.fromFile(file); expect( - cache.uninstalls, + completionConfiguration.installs, + isEmpty, + reason: + '''Installs should be empty when the value is of an invalid type''', + ); + }, + ); + + test( + '''returns a $CompletionConfiguration with empty uninstalls if file's JSON uninstalls key has a numeric value''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{"${CompletionConfiguration.uninstallsJsonKey}": 1}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final completionConfiguration = + CompletionConfiguration.fromFile(file); + expect( + completionConfiguration.uninstalls, isEmpty, reason: '''Uninstalls should be empty when the value is of an invalid type''', ); }, ); + + test( + '''returns a $CompletionConfiguration with empty installs if file's JSON installs key has a numeric value''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{"${CompletionConfiguration.installsJsonKey}": 1}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final completionConfiguration = + CompletionConfiguration.fromFile(file); + expect( + completionConfiguration.installs, + isEmpty, + reason: + '''Installs should be empty when the value is of an invalid type''', + ); + }, + ); }); group('writeTo', () { @@ -127,7 +184,7 @@ void main() { expect( file.existsSync(), isTrue, - reason: 'File should exist after cache creation', + reason: 'File should exist after completionConfiguration creation', ); }); @@ -155,14 +212,22 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final file = File(path.join(tempDirectory.path, 'config.json')); - final cache = CompletionConfiguration.empty().copyWith( + final completionConfiguration = + CompletionConfiguration.empty().copyWith( + installs: testInstalls, uninstalls: testUninstalls, )..writeTo(file); - final newCache = CompletionConfiguration.fromFile(file); + final newcompletionConfiguration = + CompletionConfiguration.fromFile(file); expect( - newCache.uninstalls, - cache.uninstalls, + newcompletionConfiguration.installs, + completionConfiguration.installs, + reason: 'Installs should match those defined in the file', + ); + expect( + newcompletionConfiguration.uninstalls, + completionConfiguration.uninstalls, reason: 'Uninstalls should match those defined in the file', ); }); @@ -170,82 +235,103 @@ void main() { group('copyWith', () { test('members remain unchanged when nothing is specified', () { - final cache = CompletionConfiguration.empty(); - final newCache = cache.copyWith(); + final completionConfiguration = CompletionConfiguration.empty(); + final newcompletionConfiguration = completionConfiguration.copyWith(); expect( - newCache.uninstalls, - cache.uninstalls, + newcompletionConfiguration.uninstalls, + completionConfiguration.uninstalls, reason: 'Uninstalls should remain unchanged', ); }); test('modifies uninstalls when specified', () { - final cache = CompletionConfiguration.empty(); + final completionConfiguration = CompletionConfiguration.empty(); final uninstalls = testUninstalls; - final newCache = cache.copyWith(uninstalls: uninstalls); + final newcompletionConfiguration = + completionConfiguration.copyWith(uninstalls: uninstalls); expect( - newCache.uninstalls, + newcompletionConfiguration.uninstalls, equals(uninstalls), reason: 'Uninstalls should be modified', ); }); + + test('modifies installs when specified', () { + final completionConfiguration = CompletionConfiguration.empty(); + final installs = testUninstalls; + final newcompletionConfiguration = + completionConfiguration.copyWith(installs: installs); + + expect( + newcompletionConfiguration.installs, + equals(installs), + reason: 'Installs should be modified', + ); + }); }); }); - group('UninstallsExtension', () { + group('ShellCommandsMapExtension', () { group('include', () { - test('adds command to $Uninstalls when not already in', () { + test('adds command to $ShellCommandsMap when not already in', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({}); + final shellCommandsMap = ShellCommandsMap({}); - final newUninstalls = - uninstalls.include(command: testCommand, systemShell: testShell); + final newShellCommadsMap = shellCommandsMap.include( + command: testCommand, + systemShell: testShell, + ); expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), + newShellCommadsMap.contains( + command: testCommand, + systemShell: testShell, + ), isTrue, ); }); - test('does nothing when $Uninstalls already has command', () { + test('remains the same when $ShellCommandsMap already has command', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); - final newUninstalls = - uninstalls.include(command: testCommand, systemShell: testShell); - - expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), - isTrue, + final newShellCommadsMap = shellCommandsMap.include( + command: testCommand, + systemShell: testShell, ); + + expect(newShellCommadsMap, equals(shellCommandsMap)); }); - test('adds command $Uninstalls when on a different shell', () { + test('adds command $ShellCommandsMap when on a different shell', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); const anotherShell = SystemShell.zsh; - final newUninstalls = uninstalls.include( + final newShellCommadsMap = shellCommandsMap.include( command: testCommand, systemShell: anotherShell, ); expect(testShell, isNot(equals(anotherShell))); expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), + newShellCommadsMap.contains( + command: testCommand, + systemShell: testShell, + ), isTrue, ); expect( - newUninstalls.contains( + newShellCommadsMap.contains( command: testCommand, systemShell: anotherShell, ), @@ -255,75 +341,84 @@ void main() { }); group('exclude', () { - test('removes command when in $Uninstalls', () { + test('removes command when in $ShellCommandsMap', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); - final newUninstalls = - uninstalls.exclude(command: testCommand, systemShell: testShell); + final newShellCommandsMap = shellCommandsMap.exclude( + command: testCommand, + systemShell: testShell, + ); expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), + newShellCommandsMap.contains( + command: testCommand, + systemShell: testShell, + ), isFalse, ); }); - test('does nothing when command not in $Uninstalls', () { + test('remains the same when command not in $ShellCommandsMap', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({}); - - final newUninstalls = - uninstalls.exclude(command: testCommand, systemShell: testShell); + final shellCommandsMap = ShellCommandsMap({}); - expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), - isFalse, + final newShellCommandsMap = shellCommandsMap.exclude( + command: testCommand, + systemShell: testShell, ); + + expect(newShellCommandsMap, equals(shellCommandsMap)); }); - test('does nothing when command in $Uninstalls is on a different shell', + test( + '''remains the same when command in $ShellCommandsMap is on a different shell''', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); const anotherShell = SystemShell.zsh; - final newUninstalls = - uninstalls.exclude(command: testCommand, systemShell: anotherShell); - - expect( - newUninstalls.contains(command: testCommand, systemShell: testShell), - isTrue, + final newShellCommadsMap = shellCommandsMap.exclude( + command: testCommand, + systemShell: anotherShell, ); + + expect(newShellCommadsMap, equals(shellCommandsMap)); }); }); group('contains', () { - test('returns true when command is in $Uninstalls for the given shell', + test( + '''returns true when command is in $ShellCommandsMap for the given shell''', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); expect( - uninstalls.contains(command: testCommand, systemShell: testShell), + shellCommandsMap.contains( + command: testCommand, + systemShell: testShell, + ), isTrue, ); }); - test('returns false when command is in $Uninstalls for another shell', + test( + '''returns false when command is in $ShellCommandsMap for another shell''', () { const testCommand = 'test_command'; const testShell = SystemShell.bash; - final uninstalls = Uninstalls({ + final shellCommandsMap = ShellCommandsMap({ testShell: UnmodifiableSetView({testCommand}), }); @@ -331,7 +426,10 @@ void main() { expect(testShell, isNot(equals(anotherShell))); expect( - uninstalls.contains(command: testCommand, systemShell: anotherShell), + shellCommandsMap.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 2e6addb..0cfff6d 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -296,7 +296,7 @@ void main() { }); test( - 'installing completion for a command when it is already installed', + 'installs completion for a an already installed command when forced', () { final installation = CompletionInstallation( logger: logger, @@ -325,7 +325,7 @@ void main() { reset(logger); // install again - installation.install('very_good'); + installation.install('very_good', force: true); verify( () => logger.warn( @@ -371,6 +371,45 @@ void main() { }, ); + test( + '''avoid installing completion for an already installed command''', + () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDir.path, + }, + configuration: zshConfiguration, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + installation.install('very_good'); + + verify(() => logger.level = Level.info).called(1); + + verify( + () => logger.info( + '\n' + 'Completion files installed. To enable completion, run the ' + 'following command in your shell:\n' + 'source ${path.join(tempDir.path, '.zshrc')}\n', + ), + ).called(1); + + reset(logger); + + // Install again + installation.install('very_good'); + + verifyNever(() => logger.detail(any())); + verifyNever(() => logger.warn(any())); + verifyNever(() => logger.level = Level.debug); + verifyNever(() => logger.info(any())); + }, + ); + test( 'installing completion for two different commands', () { @@ -515,7 +554,7 @@ void main() { final completionConfigurationFile = installation.completionConfigurationFile; - final uninstalls = Uninstalls({ + final uninstalls = ShellCommandsMap({ systemShell: UnmodifiableSetView({command}), }); CompletionConfiguration.empty() @@ -563,7 +602,7 @@ void main() { final completionConfigurationFile = installation.completionConfigurationFile; - final uninstalls = Uninstalls({ + final uninstalls = ShellCommandsMap({ systemShell: UnmodifiableSetView({command}), }); CompletionConfiguration.empty() @@ -591,6 +630,81 @@ void main() { '''The completion configuration should not contain the uninstall for the command after install''', ); }); + + test( + '''adds command to $CompletionConfiguration installs when installed''', + () { + 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'; + + installation.install(command); + + final newCompletionConfiguration = CompletionConfiguration.fromFile( + installation.completionConfigurationFile, + ); + expect( + newCompletionConfiguration.installs + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration installs should contain the command after install''', + ); + }); + + test( + '''command still in $CompletionConfiguration installs when already installed''', + () { + 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'; + installation.install(command); + + var completionConfiguration = CompletionConfiguration.fromFile( + installation.completionConfigurationFile, + ); + expect( + completionConfiguration.installs + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration installs should contain the command after install''', + ); + + // Install again. + installation.install(command); + + completionConfiguration = CompletionConfiguration.fromFile( + installation.completionConfigurationFile, + ); + expect( + completionConfiguration.installs + .contains(command: command, systemShell: systemShell), + isTrue, + reason: + '''The completion configuration installs should still contain the command after install''', + ); + }); }); group('uninstall', () { @@ -864,6 +978,36 @@ void main() { ); }); + test('removes command from installs 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.installs + .contains(command: command, systemShell: systemShell), + isFalse, + reason: 'Command should be removed from installs after uninstalling.', + ); + }); + group('throws a CompletionUnistallationException', () { test('when RC file does not exist', () { final installation = CompletionInstallation(