diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index 732951d..7552ddc 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -304,6 +304,85 @@ ${configuration!.sourceLineTemplate(scriptPath)}'''; logger.info('Added config to $configFilePath'); } + + /// Uninstalls the completion for the command [rootCommand] on the current + /// shell. + /// + /// Before uninstalling, it checks if the completion is installed: + /// - The shell has an existing RCFile with a completion + /// [ScriptConfigurationEntry]. + /// - The shell has an existing completion configuration file with a + /// [ScriptConfigurationEntry] for the [rootCommand]. + /// + /// If any of the above is not true, it throws a + /// [CompletionUnistallationException]. + /// + /// Upon a successful uninstallation the executable [ScriptConfigurationEntry] + /// is removed from the shell configuration file. If after this removal the + /// latter is empty, it is deleted together with the the executable completion + /// script and the completion [ScriptConfigurationEntry] from the shell RC + /// file. In the case that there are no other completion scripts installed on + /// other shells the completion config directory is deleted, leaving the + /// user's system as it was before the installation. + void uninstall(String rootCommand) { + final configuration = this.configuration!; + logger.detail( + '''Uninstalling completion for the command $rootCommand on ${configuration.shell.name}''', + ); + + final shellRCFile = File(_shellRCFilePath); + if (!shellRCFile.existsSync()) { + throw CompletionUnistallationException( + executableName: rootCommand, + message: 'No shell RC file found at ${shellRCFile.path}', + ); + } + + const completionEntry = ScriptConfigurationEntry('Completion'); + if (!completionEntry.existsIn(shellRCFile)) { + throw CompletionUnistallationException( + executableName: rootCommand, + message: 'Completion is not installed at ${shellRCFile.path}', + ); + } + + final shellCompletionConfigurationFile = File( + path.join( + completionConfigDir.path, + configuration.completionConfigForShellFileName, + ), + ); + final executableEntry = ScriptConfigurationEntry(rootCommand); + if (!executableEntry.existsIn(shellCompletionConfigurationFile)) { + throw CompletionUnistallationException( + executableName: rootCommand, + message: + '''No shell script file found at ${shellCompletionConfigurationFile.path}''', + ); + } + + final executableShellCompletionScriptFile = File( + path.join( + completionConfigDir.path, + '$rootCommand.${configuration.shell.name}', + ), + ); + if (executableShellCompletionScriptFile.existsSync()) { + executableShellCompletionScriptFile.deleteSync(); + } + + executableEntry.removeFrom( + shellCompletionConfigurationFile, + shouldDelete: true, + ); + if (!shellCompletionConfigurationFile.existsSync()) { + completionEntry.removeFrom(shellRCFile); + } + + if (completionConfigDir.listSync().isEmpty) { + completionConfigDir.deleteSync(); + } + } } /// Resolve the home from a path string diff --git a/lib/src/installer/exceptions.dart b/lib/src/installer/exceptions.dart index 06106b1..6c481b6 100644 --- a/lib/src/installer/exceptions.dart +++ b/lib/src/installer/exceptions.dart @@ -18,3 +18,24 @@ class CompletionInstallationException implements Exception { String toString() => 'Could not install completion scripts for $rootCommand: ' '$message'; } + +/// {@template completion_unistallation_exception} +/// Describes an exception during the uninstallation of completion scripts. +/// {@endtemplate} +class CompletionUnistallationException implements Exception { + /// {@macro completion_unistallation_exception} + CompletionUnistallationException({ + required this.message, + required this.executableName, + }); + + /// The error message for this exception + final String message; + + /// The command for which the installation failed. + final String executableName; + + @override + String toString() => + '''Could not uninstall completion scripts for $executableName: $message'''; +} diff --git a/lib/src/installer/script_configuration_entry.dart b/lib/src/installer/script_configuration_entry.dart index ff4b60d..3eb38f7 100644 --- a/lib/src/installer/script_configuration_entry.dart +++ b/lib/src/installer/script_configuration_entry.dart @@ -38,16 +38,46 @@ class ScriptConfigurationEntry { file.createSync(recursive: true); } - final entry = StringBuffer() + final stringBuffer = StringBuffer() ..writeln() - ..writeln(_startComment) - ..writeln(content) + ..writeln(_startComment); + if (content != null) stringBuffer.writeln(content); + stringBuffer ..writeln(_endComment) ..writeln(); file.writeAsStringSync( - entry.toString(), + stringBuffer.toString(), mode: FileMode.append, ); } + + /// Removes the entry with [name] from the [file]. + /// + /// If the [file] does not exist, this will do nothing. + /// + /// If a file has multiple entries with the same [name], all of them will be + /// removed. + /// + /// If [shouldDelete] is true, the [file] will be deleted if it is empty after + /// removing the entry. Otherwise, the [file] will be left empty. + void removeFrom(File file, {bool shouldDelete = false}) { + if (!file.existsSync()) return; + + final content = file.readAsStringSync(); + final stringPattern = '\n$_startComment.*$_endComment\n\n' + .replaceAll('[', r'\[') + .replaceAll(']', r'\]'); + final pattern = RegExp( + stringPattern, + multiLine: true, + dotAll: true, + ); + final newContent = content.replaceAllMapped(pattern, (_) => ''); + file.writeAsStringSync(newContent); + + if (shouldDelete && newContent.trim().isEmpty) { + file.deleteSync(); + } + } } diff --git a/test/src/installer/completion_installation_test.dart b/test/src/installer/completion_installation_test.dart index 421dc63..a7ead70 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -493,5 +493,339 @@ void main() { }, ); }); + + group('uninstall', () { + test( + '''deletes entire completion configuration when there is a single command''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final configuration = zshConfiguration; + final rcFile = File(path.join(tempDirectory.path, '.zshrc')) + ..createSync(); + + const rootCommand = 'very_good'; + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: configuration, + ) + ..install(rootCommand) + ..uninstall(rootCommand); + + expect( + rcFile.existsSync(), + isTrue, + reason: 'RC file should not be deleted.', + ); + expect( + const ScriptConfigurationEntry('Completion').existsIn(rcFile), + isFalse, + reason: 'Completion config entry should be removed from RC file.', + ); + expect(installation.completionConfigDir.existsSync(), isFalse); + }); + + test( + '''only deletes shell configuration when there is a single command in multiple shells''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final zshConfig = zshConfiguration; + final zshRCFile = File(path.join(tempDirectory.path, '.zshrc')) + ..createSync(); + + final bashConfig = bashConfiguration; + final bashRCFile = File(path.join(tempDirectory.path, '.bash_profile')) + ..createSync(); + + const rootCommand = 'very_good'; + + final bashInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: bashConfig, + )..install(rootCommand); + + final zshInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: zshConfig, + ) + ..install(rootCommand) + ..uninstall(rootCommand); + + // Zsh should be uninstalled + expect( + zshRCFile.existsSync(), + isTrue, + reason: 'Zsh RC file should still exist.', + ); + expect( + const ScriptConfigurationEntry('Completion').existsIn(zshRCFile), + isFalse, + reason: 'Zsh should not have completion entry.', + ); + + final zshCompletionConfigurationFile = File( + path.join( + zshInstallation.completionConfigDir.path, + zshConfig.completionConfigForShellFileName, + ), + ); + expect( + zshCompletionConfigurationFile.existsSync(), + isFalse, + reason: 'Zsh completion configuration should be deleted.', + ); + + final zshCommandCompletionConfigurationFile = File( + path.join( + zshInstallation.completionConfigDir.path, + '$rootCommand.zsh', + ), + ); + expect( + zshCommandCompletionConfigurationFile.existsSync(), + isFalse, + reason: 'Zsh command completion configuration should be deleted.', + ); + + // Bash should still be installed + expect( + bashRCFile.existsSync(), + isTrue, + reason: 'Bash RC file should still exist.', + ); + expect( + const ScriptConfigurationEntry('Completion').existsIn(bashRCFile), + isTrue, + reason: 'Bash should have completion entry.', + ); + + final bashCompletionConfigurationFile = File( + path.join( + bashInstallation.completionConfigDir.path, + bashConfig.completionConfigForShellFileName, + ), + ); + expect( + bashCompletionConfigurationFile.existsSync(), + isTrue, + reason: 'Bash completion configuration should still exist.', + ); + + final bashCommandCompletionConfigurationFile = File( + path.join( + bashInstallation.completionConfigDir.path, + '$rootCommand.bash', + ), + ); + expect( + bashCommandCompletionConfigurationFile.existsSync(), + isTrue, + reason: 'Bash command completion configuration should still exist.', + ); + + expect( + bashInstallation.completionConfigDir.existsSync(), + isTrue, + reason: 'Completion configuration directory should still exist.', + ); + }); + + test( + '''only deletes command completion configuration when there are multiple installed commands''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final configuration = zshConfiguration; + const commandName = 'very_good'; + const anotherCommandName = 'not_good'; + + final rcFile = File(path.join(tempDirectory.path, '.zshrc')) + ..createSync(); + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: configuration, + ) + ..install(commandName) + ..install(anotherCommandName); + + final shellCompletionConfigurationFile = File( + path.join( + installation.completionConfigDir.path, + configuration.completionConfigForShellFileName, + ), + ); + + installation.uninstall(commandName); + + expect( + rcFile.existsSync(), + isTrue, + reason: 'RC file should not be deleted.', + ); + expect( + const ScriptConfigurationEntry('Completion').existsIn(rcFile), + isTrue, + reason: 'Completion config entry should not be removed from RC file.', + ); + + expect( + shellCompletionConfigurationFile.existsSync(), + isTrue, + reason: 'Shell completion configuration should still exist.', + ); + + expect( + const ScriptConfigurationEntry(commandName) + .existsIn(shellCompletionConfigurationFile), + isFalse, + reason: + '''Command completion for $commandName configuration should be removed.''', + ); + final commandCompletionConfigurationFile = File( + path.join( + installation.completionConfigDir.path, + '$commandName.zsh', + ), + ); + expect( + commandCompletionConfigurationFile.existsSync(), + false, + reason: + '''Command completion configuration for $commandName should be deleted.''', + ); + + expect( + const ScriptConfigurationEntry(anotherCommandName) + .existsIn(shellCompletionConfigurationFile), + isTrue, + reason: + '''Command completion configuration for $anotherCommandName should still exist.''', + ); + final anotherCommandCompletionConfigurationFile = File( + path.join( + installation.completionConfigDir.path, + '$anotherCommandName.zsh', + ), + ); + expect( + anotherCommandCompletionConfigurationFile.existsSync(), + isTrue, + reason: + '''Command completion configuration for $anotherCommandName should still exist.''', + ); + }); + + group('throws a CompletionUnistallationException', () { + test('when RC file does not exist', () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDir.path, + }, + configuration: zshConfiguration, + ); + final rcFile = File(path.join(tempDir.path, '.zshrc')); + + expect( + () => installation.uninstall('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('No shell RC file found at ${rcFile.path}'), + ), + ), + ); + }); + + test('when RC file does not have a completion entry', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: zshConfiguration, + ); + + final rcFile = File(path.join(tempDirectory.path, '.zshrc')) + ..createSync(); + + expect( + () => installation.uninstall('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals('Completion is not installed at ${rcFile.path}'), + ), + ), + ); + }); + + test('when RC file has a completion entry but no script file', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final configuration = zshConfiguration; + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: { + 'HOME': tempDirectory.path, + }, + configuration: configuration, + ); + + final rcFile = File(path.join(tempDirectory.path, '.zshrc')) + ..createSync(); + const ScriptConfigurationEntry('Completion').appendTo(rcFile); + + final shellCompletionConfigurationFile = File( + path.join( + installation.completionConfigDir.path, + configuration.completionConfigForShellFileName, + ), + ); + + expect( + () => installation.uninstall('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + equals( + '''No shell script file found at ${shellCompletionConfigurationFile.path}''', + ), + ), + ), + ); + }); + }); + }); }); } diff --git a/test/src/installer/exceptions_test.dart b/test/src/installer/exceptions_test.dart new file mode 100644 index 0000000..221364e --- /dev/null +++ b/test/src/installer/exceptions_test.dart @@ -0,0 +1,60 @@ +import 'package:cli_completion/installer.dart'; +import 'package:test/test.dart'; + +void main() { + group('$CompletionUnistallationException', () { + test('can be instantiated', () { + expect( + () => CompletionUnistallationException( + message: 'message', + executableName: 'executableName', + ), + returnsNormally, + ); + }); + + test('has a message', () { + expect( + CompletionUnistallationException( + message: 'message', + executableName: 'executableName', + ).message, + equals('message'), + ); + }); + + test('has an executableName', () { + expect( + CompletionUnistallationException( + message: 'message', + executableName: 'executableName', + ).executableName, + equals('executableName'), + ); + }); + + group('toString', () { + test('returns a string', () { + expect( + CompletionUnistallationException( + message: 'message', + executableName: 'executableName', + ).toString(), + isA(), + ); + }); + + test('returns a correctly formatted string', () { + expect( + CompletionUnistallationException( + message: 'message', + executableName: 'executableName', + ).toString(), + equals( + '''Could not uninstall completion scripts for executableName: message''', + ), + ); + }); + }); + }); +} diff --git a/test/src/installer/script_configuration_entry_test.dart b/test/src/installer/script_configuration_entry_test.dart index 7b0a70c..6a29b32 100644 --- a/test/src/installer/script_configuration_entry_test.dart +++ b/test/src/installer/script_configuration_entry_test.dart @@ -16,7 +16,7 @@ void main() { expect(ScriptConfigurationEntry('name').name, 'name'); }); - group('appendsTo', () { + group('appendTo', () { test('returns normally when file exist', () { final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() => tempDirectory.deleteSync(recursive: true)); @@ -56,10 +56,8 @@ void main() { const initialContent = 'hello world\n'; file.writeAsStringSync(initialContent); - final entry = ScriptConfigurationEntry('name'); const entryContent = 'hello world'; - - entry.appendTo(file, content: entryContent); + ScriptConfigurationEntry('name').appendTo(file, content: entryContent); final fileContent = file.readAsStringSync(); const expectedContent = ''' @@ -68,6 +66,27 @@ $initialContent $entryContent ## [/name] +'''; + expect(fileContent, equals(expectedContent)); + }); + + test('correctly appends content when null', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + const initialContent = 'hello world\n'; + file.writeAsStringSync(initialContent); + + ScriptConfigurationEntry('name').appendTo(file); + + final fileContent = file.readAsStringSync(); + const expectedContent = ''' +$initialContent +## [name] +## [/name] + '''; expect(fileContent, equals(expectedContent)); }); @@ -124,5 +143,120 @@ $entryContent expect(entry.existsIn(file), isTrue); }); }); + + group('removeFrom', () { + test('returns normally when file does not exist', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath); + + final entry = ScriptConfigurationEntry('name'); + + expect(() => entry.removeFrom(file), returnsNormally); + }); + + test('deletes file when file is empty and should delete', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + ScriptConfigurationEntry('name').removeFrom(file, shouldDelete: true); + + expect(file.existsSync(), isFalse); + }); + + test('does not change the file when another entry exists in file', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + ScriptConfigurationEntry('name').appendTo(file); + final content = file.readAsStringSync(); + + ScriptConfigurationEntry('anotherName').removeFrom(file); + + final currentContent = file.readAsStringSync(); + expect(content, equals(currentContent)); + }); + + test( + '''removes file when there is only a single matching entry and should delete''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + final entry = ScriptConfigurationEntry('name')..appendTo(file); + expect(entry.existsIn(file), isTrue); + + ScriptConfigurationEntry('name').removeFrom(file, shouldDelete: true); + expect(file.existsSync(), isFalse); + }); + + test( + '''preseves file when there is a single matching entry and should not delete''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + final entry = ScriptConfigurationEntry('name')..appendTo(file); + expect(entry.existsIn(file), isTrue); + + ScriptConfigurationEntry('name').removeFrom(file); + expect(file.existsSync(), isTrue); + final currentContent = file.readAsStringSync(); + expect(currentContent, isEmpty); + }); + + test( + '''removes file when there are only multiple matching entries and should delete''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + final entry = ScriptConfigurationEntry('name') + ..appendTo(file) + ..appendTo(file) + ..appendTo(file); + expect(entry.existsIn(file), isTrue); + + ScriptConfigurationEntry('name').removeFrom(file, shouldDelete: true); + expect(file.existsSync(), isFalse); + }); + + test('only removes matching entries from file', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final filePath = path.join(tempDirectory.path, 'file'); + final file = File(filePath)..createSync(); + + final entry = ScriptConfigurationEntry('name')..appendTo(file); + expect(entry.existsIn(file), isTrue); + final newContent = file.readAsStringSync(); + + final anotherEntry = ScriptConfigurationEntry('anotherName') + ..appendTo(file); + expect(anotherEntry.existsIn(file), isTrue); + + ScriptConfigurationEntry('anotherName').removeFrom(file); + final actualContent = file.readAsStringSync(); + expect(actualContent, equals(newContent)); + }); + }); }); }