diff --git a/README.md b/README.md index e393952..b20aa18 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,14 @@ Additionally, when `enableAutoInstall` is disabled a `completion-script` command $ example_cli completion-script >> ~/.zshrc ``` +When `enableAutoInstall` is enabled, users that prefer to opt out of the automatic installation can do so by running the hidden `disable-completion-auto-install` command. This choice is persisted, so completion files will no longer be installed automatically on command runs: + +```bash +$ example_cli disable-completion-auto-install +``` + +Users can still install completion files manually with `install-completion-files`, which also re-enables the automatic installation. + ## Documentation 📝 For an overview of how this package works, check out the [documentation][docs_link]. diff --git a/lib/src/command_runner/commands/commands.dart b/lib/src/command_runner/commands/commands.dart index abf3335..0979760 100644 --- a/lib/src/command_runner/commands/commands.dart +++ b/lib/src/command_runner/commands/commands.dart @@ -1,3 +1,4 @@ +export 'disable_completion_installation_command.dart'; export 'handle_completion_command.dart'; export 'install_completion_files_command.dart'; export 'print_completion_script_command.dart'; diff --git a/lib/src/command_runner/commands/disable_completion_installation_command.dart b/lib/src/command_runner/commands/disable_completion_installation_command.dart new file mode 100644 index 0000000..9cbaf41 --- /dev/null +++ b/lib/src/command_runner/commands/disable_completion_installation_command.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:cli_completion/cli_completion.dart'; + +/// {@template disable_completion_installation_command} +/// A hidden [Command] added by [CompletionCommandRunner] that allows the user +/// to disable the automatic installation of completion files. +/// +/// By default, [CompletionCommandRunner] tries to install completion files +/// upon any command run. Running this command persists the user's choice to +/// opt out of that behavior. +/// +/// Users can still manually install completion files via the +/// `install-completion-files` command, which also re-enables the automatic +/// installation. +/// {@endtemplate} +class DisableCompletionInstallationCommand extends Command { + /// {@macro disable_completion_installation_command} + DisableCompletionInstallationCommand(); + + @override + String get description { + return 'Disables the automatic installation of completion files.'; + } + + /// The string that the user can call to disable the automatic installation + /// of completion files. + static const commandName = 'disable-completion-auto-install'; + + @override + String get name => commandName; + + @override + bool get hidden => true; + + @override + CompletionCommandRunner get runner { + return super.runner! as CompletionCommandRunner; + } + + @override + FutureOr? run() { + runner.disableAutoInstall(); + return null; + } +} diff --git a/lib/src/command_runner/completion_command_runner.dart b/lib/src/command_runner/completion_command_runner.dart index 4128001..6155fb1 100644 --- a/lib/src/command_runner/completion_command_runner.dart +++ b/lib/src/command_runner/completion_command_runner.dart @@ -21,7 +21,11 @@ import 'package:meta/meta.dart'; /// Adds [InstallCompletionFilesCommand] to enable the user to /// manually install completion files. /// -/// When [enableAutoInstall] is disabled, it also adds +/// When [enableAutoInstall] is enabled, it also adds +/// [DisableCompletionInstallationCommand] so the user can opt out of the +/// automatic installation of completion files. +/// +/// When [enableAutoInstall] is disabled, it instead adds /// [PrintCompletionScriptCommand] so the user can print the completion script /// and install it manually. abstract class CompletionCommandRunner extends CommandRunner { @@ -36,10 +40,14 @@ abstract class CompletionCommandRunner extends CommandRunner { addCommand(InstallCompletionFilesCommand()); addCommand(UnistallCompletionFilesCommand()); - // The print completion script command is only useful when the completion - // files are not installed automatically. Otherwise, users should rely on - // the auto installation (or the `install-completion-files` command). - if (!enableAutoInstall) { + if (enableAutoInstall) { + // Allow the user to opt out of the automatic installation of completion + // files that is performed on any command run. + addCommand(DisableCompletionInstallationCommand()); + } else { + // The print completion script command is only useful when the completion + // files are not installed automatically. Otherwise, users should rely on + // the auto installation (or the `install-completion-files` command). addCommand(PrintCompletionScriptCommand()); } } @@ -86,6 +94,7 @@ abstract class CompletionCommandRunner extends CommandRunner { HandleCompletionRequestCommand.commandName, InstallCompletionFilesCommand.commandName, UnistallCompletionFilesCommand.commandName, + DisableCompletionInstallationCommand.commandName, }; @override @@ -94,6 +103,11 @@ abstract class CompletionCommandRunner extends CommandRunner { if (enableAutoInstall && !_reservedCommands.contains(topLevelResults.command?.name)) { // When auto installing, use error level to display messages. + // + // The installation is skipped if the user has opted out of the automatic + // installation of completion files. This is handled by + // [CompletionInstallation.install] which respects the persisted user + // preference when not forced. tryInstallCompletionFiles(Level.error); } @@ -113,6 +127,21 @@ abstract class CompletionCommandRunner extends CommandRunner { } } + /// Disables the automatic installation of completion files. + /// + /// This persists the user's choice so that completion files are no longer + /// automatically installed upon command runs. Users can still manually + /// install completion files via the [InstallCompletionFilesCommand], which + /// also re-enables the automatic installation. + @internal + void disableAutoInstall() { + try { + completionInstallation.setAutoInstallEnabled(enabled: false); + } on Exception catch (e) { + completionInstallationLogger.err(e.toString()); + } + } + /// Prints the completion script for the current shell to stdout. /// /// This is used by [PrintCompletionScriptCommand] to allow users to install diff --git a/lib/src/installer/completion_configuration.dart b/lib/src/installer/completion_configuration.dart index a524450..eaf2a59 100644 --- a/lib/src/installer/completion_configuration.dart +++ b/lib/src/installer/completion_configuration.dart @@ -23,13 +23,15 @@ class CompletionConfiguration { const CompletionConfiguration._({ required this.uninstalls, required this.installs, + required this.enabled, }); /// Creates an empty [CompletionConfiguration]. @visibleForTesting CompletionConfiguration.empty() : uninstalls = ShellCommandsMap({}), - installs = ShellCommandsMap({}); + installs = ShellCommandsMap({}), + enabled = true; /// Creates a [CompletionConfiguration] from the given [file] content. /// @@ -67,6 +69,7 @@ class CompletionConfiguration { decodedJson, jsonKey: CompletionConfiguration.installsJsonKey, ), + enabled: _jsonDecodeEnabled(decodedJson), ); } @@ -78,6 +81,10 @@ class CompletionConfiguration { @visibleForTesting static const String installsJsonKey = 'installs'; + /// The JSON key for the [enabled] field. + @visibleForTesting + static const String enabledJsonKey = 'enabled'; + /// Stores those commands that have been manually uninstalled by the user. /// /// Uninstalls are specific to a given [SystemShell]. @@ -88,6 +95,15 @@ class CompletionConfiguration { /// Installed commands are specific to a given [SystemShell]. final ShellCommandsMap installs; + /// Whether the automatic installation of completion files is enabled. + /// + /// When set to false, the [CompletionCommandRunner] will not attempt to + /// automatically install completion files upon command runs. Users can + /// still manually install completion files. + /// + /// Defaults to true. + final bool enabled; + /// Stores the [CompletionConfiguration] in the given [file]. void writeTo(File file) { if (!file.existsSync()) { @@ -101,6 +117,7 @@ class CompletionConfiguration { return jsonEncode({ uninstallsJsonKey: _jsonEncodeShellCommandsMap(uninstalls), installsJsonKey: _jsonEncodeShellCommandsMap(installs), + enabledJsonKey: enabled, }); } @@ -109,14 +126,28 @@ class CompletionConfiguration { CompletionConfiguration copyWith({ ShellCommandsMap? uninstalls, ShellCommandsMap? installs, + bool? enabled, }) { return CompletionConfiguration._( uninstalls: uninstalls ?? this.uninstalls, installs: installs ?? this.installs, + enabled: enabled ?? this.enabled, ); } } +/// Decodes the [CompletionConfiguration.enabled] field from the given [json]. +/// +/// If the value is missing or not a boolean, it defaults to true so that +/// auto installation remains enabled unless the user explicitly disables it. +bool _jsonDecodeEnabled(Map json) { + final value = json[CompletionConfiguration.enabledJsonKey]; + if (value is bool) { + return value; + } + return true; +} + /// Decodes [ShellCommandsMap] from the given [json]. /// /// If the [json] is not partially or fully valid, it handles issues gracefully diff --git a/lib/src/installer/completion_installation.dart b/lib/src/installer/completion_installation.dart index 6b2fa40..10beb5f 100644 --- a/lib/src/installer/completion_installation.dart +++ b/lib/src/installer/completion_installation.dart @@ -96,6 +96,31 @@ class CompletionInstallation { return File(path.join(completionConfigDir.path, 'config.json')); } + /// Whether the automatic installation of completion files is enabled. + /// + /// This reads the user's persisted preference from the + /// [completionConfigurationFile]. It defaults to true when no preference has + /// been persisted. + bool get isAutoInstallEnabled { + return CompletionConfiguration.fromFile( + completionConfigurationFile, + ).enabled; + } + + /// Persists whether the automatic installation of completion files is + /// [enabled]. + /// + /// This is used to let the user opt out of the automatic installation of + /// completion files performed on command runs. + void setAutoInstallEnabled({required bool enabled}) { + final completionConfiguration = CompletionConfiguration.fromFile( + completionConfigurationFile, + ); + completionConfiguration + .copyWith(enabled: enabled) + .writeTo(completionConfigurationFile); + } + /// Install completion configuration files for a [rootCommand] in the /// current shell. /// @@ -145,6 +170,10 @@ class CompletionInstallation { command: rootCommand, systemShell: configuration.shell, ), + // Installing completion files (either automatically or manually) + // implies that the user wants completion, so we re-enable the + // automatic installation in case it was previously disabled. + enabled: true, ) .writeTo(completionConfigurationFile); } @@ -185,12 +214,16 @@ class CompletionInstallation { /// Wether the completion configuration files for a [rootCommand] should be /// installed or not. /// - /// It will return false if the root command is already installed or it - /// has been explicitly uninstalled. + /// It will return false if the user has disabled the automatic installation, + /// if the root command is already installed or if it has been explicitly + /// uninstalled. bool _shouldInstall(String rootCommand) { final completionConfiguration = CompletionConfiguration.fromFile( completionConfigurationFile, ); + if (!completionConfiguration.enabled) { + return false; + } final systemShell = configuration!.shell; final isInstalled = completionConfiguration.installs.contains( command: rootCommand, diff --git a/test/src/command_runner/commands/disable_completion_installation_command_test.dart b/test/src/command_runner/commands/disable_completion_installation_command_test.dart new file mode 100644 index 0000000..e7bc372 --- /dev/null +++ b/test/src/command_runner/commands/disable_completion_installation_command_test.dart @@ -0,0 +1,72 @@ +import 'package:cli_completion/cli_completion.dart'; +import 'package:cli_completion/installer.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +class _MockCompletionInstallation extends Mock + implements CompletionInstallation {} + +class _TestCompletionCommandRunner extends CompletionCommandRunner { + _TestCompletionCommandRunner() : super('test', 'Test command runner'); + + @override + // Override acceptable for test files + // ignore: overridden_fields + final Logger completionInstallationLogger = _MockLogger(); + + @override + final CompletionInstallation completionInstallation = + _MockCompletionInstallation(); +} + +void main() { + group('DisableCompletionInstallationCommand', () { + late _TestCompletionCommandRunner commandRunner; + + setUp(() { + commandRunner = _TestCompletionCommandRunner(); + }); + + test('can be instantiated', () { + expect(DisableCompletionInstallationCommand(), isNotNull); + }); + + test('is hidden', () { + expect(DisableCompletionInstallationCommand().hidden, isTrue); + }); + + test('description', () { + expect( + DisableCompletionInstallationCommand().description, + 'Disables the automatic installation of completion files.', + ); + }); + + test('disables the automatic installation', () async { + await commandRunner.run(['disable-completion-auto-install']); + + verify( + () => commandRunner.completionInstallation.setAutoInstallEnabled( + enabled: false, + ), + ).called(1); + }); + + test('logs an error when an unknown exception happens', () async { + when( + () => commandRunner.completionInstallation.setAutoInstallEnabled( + enabled: any(named: 'enabled'), + ), + ).thenThrow(Exception('oops')); + + await commandRunner.run(['disable-completion-auto-install']); + + verify( + () => commandRunner.completionInstallationLogger.err(any()), + ).called(1); + }); + }); +} diff --git a/test/src/command_runner/completion_command_runner_test.dart b/test/src/command_runner/completion_command_runner_test.dart index 7968dd0..cb2afcf 100644 --- a/test/src/command_runner/completion_command_runner_test.dart +++ b/test/src/command_runner/completion_command_runner_test.dart @@ -80,27 +80,16 @@ void main() { final commandRunner = _TestCompletionCommandRunner() ..addCommand(_TestUserCommand()); - expect( - commandRunner.usage, - contains('ahoy'), - ); - expect( - commandRunner.usage, - isNot(contains('install-completion-files')), - ); - expect( - commandRunner.usage, - isNot(contains('completion')), - ); + expect(commandRunner.usage, contains('ahoy')); + expect(commandRunner.usage, isNot(contains('install-completion-files'))); + expect(commandRunner.usage, isNot(contains('completion'))); }); group('completionInstallation', () { // test if it gets one with the current system shell test('creates one with the given system shell', () { final commandRunner = _TestCompletionCommandRunner() - ..environmentOverride = { - 'SHELL': '/foo/bar/zsh', - }; + ..environmentOverride = {'SHELL': '/foo/bar/zsh'}; expect( commandRunner.completionInstallation, isA().having( @@ -116,37 +105,59 @@ void main() { final commandRunner = _TestCompletionCommandRunner(); expect( commandRunner.commands.keys, - containsAll([ - 'completion', - 'install-completion-files', - ]), + containsAll(['completion', 'install-completion-files']), ); }); + group('disable completion installation command', () { + test('is added when auto install is enabled', () { + final commandRunner = _TestCompletionCommandRunner(); + + expect( + commandRunner.commands.keys, + contains('disable-completion-auto-install'), + ); + }); + + test('is not added when auto install is disabled', () { + final commandRunner = _TestNoAutoInstallCompletionCommandRunner(); + + expect( + commandRunner.commands.keys, + isNot(contains('disable-completion-auto-install')), + ); + }); + + test('does not trigger auto install when run', () async { + final commandRunner = _TestCompletionCommandRunner() + ..mockCompletionInstallation = _MockCompletionInstallation(); + + await commandRunner.run(['disable-completion-auto-install']); + + verifyNever(() => commandRunner.completionInstallation.install(any())); + verify( + () => commandRunner.completionInstallation.setAutoInstallEnabled( + enabled: false, + ), + ).called(1); + }); + }); + group('print completion script command', () { - test( - 'is not added when auto install is enabled', - () { - final commandRunner = _TestCompletionCommandRunner(); + test('is not added when auto install is enabled', () { + final commandRunner = _TestCompletionCommandRunner(); - expect( - commandRunner.commands.keys, - isNot(contains('completion-script')), - ); - }, - ); + expect( + commandRunner.commands.keys, + isNot(contains('completion-script')), + ); + }); - test( - 'is added when auto install is disabled', - () { - final commandRunner = _TestNoAutoInstallCompletionCommandRunner(); + test('is added when auto install is disabled', () { + final commandRunner = _TestNoAutoInstallCompletionCommandRunner(); - expect( - commandRunner.commands.keys, - contains('completion-script'), - ); - }, - ); + expect(commandRunner.commands.keys, contains('completion-script')); + }); }); group('auto install', () { @@ -185,9 +196,7 @@ void main() { ..enableAutoInstall = true ..addCommand(_TestUserCommand()) ..mockCompletionInstallation = _MockCompletionInstallation() - ..environmentOverride = { - 'SHELL': '/foo/bar/zsh', - }; + ..environmentOverride = {'SHELL': '/foo/bar/zsh'}; await commandRunner.run(['ahoy']); @@ -292,9 +301,7 @@ void main() { }); final commandRunner = _TestCompletionCommandRunner() - ..environmentOverride = { - 'SHELL': '/foo/bar/zsh', - }; + ..environmentOverride = {'SHELL': '/foo/bar/zsh'}; final output = StringBuffer(); when(() { @@ -322,9 +329,7 @@ suggestion4:description4 }); final commandRunner = _TestCompletionCommandRunner() - ..environmentOverride = { - 'SHELL': '/foo/bar/bash', - }; + ..environmentOverride = {'SHELL': '/foo/bar/bash'}; final output = StringBuffer(); when(() { diff --git a/test/src/installer/completion_configuration_test.dart b/test/src/installer/completion_configuration_test.dart index 147f2dc..485b287 100644 --- a/test/src/installer/completion_configuration_test.dart +++ b/test/src/installer/completion_configuration_test.dart @@ -24,11 +24,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final file = File(path.join(tempDirectory.path, 'config.json')); - expect( - file.existsSync(), - isFalse, - reason: 'File should not exist', - ); + expect(file.existsSync(), isFalse, reason: 'File should not exist'); final completionConfiguration = CompletionConfiguration.fromFile( file, @@ -100,7 +96,8 @@ void main() { expect( completionConfiguration.uninstalls, isEmpty, - reason: '''Uninstalls should be empty when the value is of an invalid type''', + reason: + '''Uninstalls should be empty when the value is of an invalid type''', ); }, ); @@ -122,7 +119,8 @@ void main() { expect( completionConfiguration.installs, isEmpty, - reason: '''Installs should be empty when the value is of an invalid type''', + reason: + '''Installs should be empty when the value is of an invalid type''', ); }, ); @@ -143,7 +141,8 @@ void main() { expect( completionConfiguration.uninstalls, isEmpty, - reason: '''Uninstalls should be empty when the value is of an invalid type''', + reason: + '''Uninstalls should be empty when the value is of an invalid type''', ); }, ); @@ -164,7 +163,71 @@ void main() { expect( completionConfiguration.installs, isEmpty, - reason: '''Installs should be empty when the value is of an invalid type''', + reason: + '''Installs should be empty when the value is of an invalid type''', + ); + }, + ); + + test( + 'returns a $CompletionConfiguration with enabled true by default', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final completionConfiguration = CompletionConfiguration.fromFile( + file, + ); + expect( + completionConfiguration.enabled, + isTrue, + reason: 'Enabled should default to true when not defined', + ); + }, + ); + + test( + '''returns a $CompletionConfiguration with the file's defined enabled value''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{"${CompletionConfiguration.enabledJsonKey}": false}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final completionConfiguration = CompletionConfiguration.fromFile( + file, + ); + expect( + completionConfiguration.enabled, + isFalse, + reason: 'Enabled should match the value defined in the file', + ); + }, + ); + + test( + '''returns a $CompletionConfiguration with enabled true if the file's JSON enabled key has an invalid type''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{"${CompletionConfiguration.enabledJsonKey}": "nope"}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final completionConfiguration = CompletionConfiguration.fromFile( + file, + ); + expect( + completionConfiguration.enabled, + isTrue, + reason: 'Enabled should default to true for an invalid type', ); }, ); @@ -176,11 +239,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final file = File(path.join(tempDirectory.path, 'config.json')); - expect( - file.existsSync(), - isFalse, - reason: 'File should not exist', - ); + expect(file.existsSync(), isFalse, reason: 'File should not exist'); CompletionConfiguration.empty().writeTo(file); @@ -197,11 +256,7 @@ void main() { final file = File(path.join(tempDirectory.path, 'config.json')) ..createSync(); - expect( - file.existsSync(), - isTrue, - reason: 'File should exist', - ); + expect(file.existsSync(), isTrue, reason: 'File should exist'); expect( () => CompletionConfiguration.empty().writeTo(file), @@ -276,6 +331,56 @@ void main() { reason: 'Installs should be modified', ); }); + + test('enabled defaults to true when empty', () { + expect( + CompletionConfiguration.empty().enabled, + isTrue, + reason: 'Enabled should default to true', + ); + }); + + test('enabled remains unchanged when nothing is specified', () { + final completionConfiguration = CompletionConfiguration.empty() + .copyWith(enabled: false); + final newcompletionConfiguration = completionConfiguration.copyWith(); + + expect( + newcompletionConfiguration.enabled, + completionConfiguration.enabled, + reason: 'Enabled should remain unchanged', + ); + }); + + test('modifies enabled when specified', () { + final completionConfiguration = CompletionConfiguration.empty(); + final newcompletionConfiguration = completionConfiguration.copyWith( + enabled: false, + ); + + expect( + newcompletionConfiguration.enabled, + isFalse, + reason: 'Enabled should be modified', + ); + }); + + test('enabled can be read successfully after written', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')); + CompletionConfiguration.empty().copyWith(enabled: false).writeTo(file); + + final newcompletionConfiguration = CompletionConfiguration.fromFile( + file, + ); + expect( + newcompletionConfiguration.enabled, + isFalse, + reason: 'Enabled should match the value written to the file', + ); + }); }); }); diff --git a/test/src/installer/completion_installation_test.dart b/test/src/installer/completion_installation_test.dart index 666f2be..33d84ba 100644 --- a/test/src/installer/completion_installation_test.dart +++ b/test/src/installer/completion_installation_test.dart @@ -63,9 +63,7 @@ void main() { configuration: zshConfiguration, logger: logger, isWindows: true, - environment: { - 'LOCALAPPDATA': tempDir.path, - }, + environment: {'LOCALAPPDATA': tempDir.path}, ); expect( @@ -80,10 +78,7 @@ void main() { configuration: zshConfiguration, logger: logger, isWindows: false, - environment: { - 'XDG_CONFIG_HOME': tempDir.path, - 'HOME': 'ooohnoooo', - }, + environment: {'XDG_CONFIG_HOME': tempDir.path, 'HOME': 'ooohnoooo'}, ); expect( @@ -97,9 +92,7 @@ void main() { configuration: zshConfiguration, logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, ); expect( @@ -116,9 +109,7 @@ void main() { configuration: zshConfiguration, logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, ); expect( @@ -130,9 +121,7 @@ void main() { configuration: bashConfiguration, logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, ); expect( @@ -166,9 +155,7 @@ void main() { configuration: zshConfiguration, logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, ); expect(installation.completionConfigDir.existsSync(), false); @@ -178,25 +165,15 @@ void main() { expect(installation.completionConfigDir.existsSync(), true); verifyNever( - () => logger.warn( - any( - that: endsWith( - 'directory was already found.', - ), - ), - ), + () => + logger.warn(any(that: endsWith('directory was already found.'))), ); installation.createCompletionConfigDir(); verify( - () => logger.warn( - any( - that: endsWith( - 'directory was already found.', - ), - ), - ), + () => + logger.warn(any(that: endsWith('directory was already found.'))), ).called(1); }); @@ -204,9 +181,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); @@ -256,9 +231,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); @@ -285,11 +258,7 @@ void main() { verify( () => logger.info( - any( - that: startsWith( - 'No file found at ${configFile.path}', - ), - ), + any(that: startsWith('No file found at ${configFile.path}')), ), ).called(1); @@ -310,9 +279,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); @@ -351,9 +318,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); @@ -385,19 +350,13 @@ void main() { ).called(1); verify( () => logger.warn( - 'A script file for very_good was already found on ${path.join( - installation.completionConfigDir.path, - 'very_good.zsh', - )}.', + 'A script file for very_good was already found on ${path.join(installation.completionConfigDir.path, 'very_good.zsh')}.', ), ).called(1); verify( () => logger.warn( 'A config entry for very_good was already found on ' - '${path.join( - installation.completionConfigDir.path, - 'zsh-config.zsh', - )}.', + '${path.join(installation.completionConfigDir.path, 'zsh-config.zsh')}.', ), ).called(1); @@ -427,9 +386,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); @@ -460,30 +417,110 @@ void main() { }, ); - test( - 'installing completion for two different commands', - () { - final zshInstallation = CompletionInstallation( - logger: logger, - isWindows: false, - environment: { - 'HOME': tempDir.path, - }, - configuration: zshConfiguration, - ); + test('auto install is enabled by default', () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: zshConfiguration, + ); + + expect(installation.isAutoInstallEnabled, isTrue); + }); + + test('setAutoInstallEnabled persists the given value', () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: zshConfiguration, + ); + + installation.setAutoInstallEnabled(enabled: false); + + expect(installation.isAutoInstallEnabled, isFalse); + + final completionConfiguration = CompletionConfiguration.fromFile( + installation.completionConfigurationFile, + ); + expect(completionConfiguration.enabled, isFalse); + }); + + test('avoids installing completion when auto install is disabled', () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: zshConfiguration, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + installation.setAutoInstallEnabled(enabled: false); + reset(logger); + when(() => logger.level).thenReturn(Level.quiet); + + installation.install('very_good'); + + verifyNever(() => logger.detail(any())); + verifyNever(() => logger.warn(any())); + verifyNever(() => logger.info(any())); - final rcFile = File(path.join(tempDir.path, '.zshrc'))..createSync(); + final completionConfiguration = CompletionConfiguration.fromFile( + installation.completionConfigurationFile, + ); + expect( + completionConfiguration.installs.contains( + command: 'very_good', + systemShell: SystemShell.zsh, + ), + isFalse, + reason: 'Command should not be installed when disabled', + ); + }); + + test('re-enables auto install when forced install is performed', () { + final installation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: zshConfiguration, + ); + + File(path.join(tempDir.path, '.zshrc')).createSync(); + + installation.setAutoInstallEnabled(enabled: false); + expect(installation.isAutoInstallEnabled, isFalse); + + installation.install('very_good', force: true); + + expect( + installation.isAutoInstallEnabled, + isTrue, + reason: 'Forcing an install should re-enable auto install', + ); + }); + + test('installing completion for two different commands', () { + final zshInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: zshConfiguration, + ); - final configDir = zshInstallation.completionConfigDir; + final rcFile = File(path.join(tempDir.path, '.zshrc'))..createSync(); + + final configDir = zshInstallation.completionConfigDir; - zshInstallation - ..install('very_good') - ..install('not_good'); + zshInstallation + ..install('very_good') + ..install('not_good'); - // rc fle includes one reference to the global config + // rc fle includes one reference to the global config - // Different format needed for matching cli output - expect(rcFile.readAsStringSync(), ''' + // Different format needed for matching cli output + expect(rcFile.readAsStringSync(), ''' \n## [Completion] ## Completion scripts setup. Remove the following line to uninstall [[ -f ${configDir.path}/zsh-config.zsh ]] && . ${configDir.path}/zsh-config.zsh || true @@ -491,13 +528,11 @@ void main() { '''); - // global config includes one reference for each command - final globalConfig = File( - path.join(configDir.path, 'zsh-config.zsh'), - ); + // global config includes one reference for each command + final globalConfig = File(path.join(configDir.path, 'zsh-config.zsh')); - // Different format needed for matching cli output - expect(globalConfig.readAsStringSync(), ''' + // Different format needed for matching cli output + expect(globalConfig.readAsStringSync(), ''' \n## [very_good] ## Completion config for "very_good" [[ -f ${configDir.path}/very_good.zsh ]] && . ${configDir.path}/very_good.zsh || true @@ -510,34 +545,32 @@ void main() { '''); - expect( - configDir.listSync().map((e) => path.basename(e.path)), - unorderedEquals([ - 'not_good.zsh', - 'very_good.zsh', - 'zsh-config.zsh', - 'config.json', - ]), - ); + expect( + configDir.listSync().map((e) => path.basename(e.path)), + unorderedEquals([ + 'not_good.zsh', + 'very_good.zsh', + 'zsh-config.zsh', + 'config.json', + ]), + ); - final bashInstallation = CompletionInstallation( - logger: logger, - isWindows: false, - environment: { - 'HOME': tempDir.path, - }, - configuration: bashConfiguration, - ); + final bashInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: bashConfiguration, + ); - final bashProfile = File(path.join(tempDir.path, '.bash_profile')) - ..createSync(); + final bashProfile = File(path.join(tempDir.path, '.bash_profile')) + ..createSync(); - bashInstallation - ..install('very_good') - ..install('not_good'); + bashInstallation + ..install('very_good') + ..install('not_good'); - // Different format needed for matching cli output - expect(bashProfile.readAsStringSync(), ''' + // Different format needed for matching cli output + expect(bashProfile.readAsStringSync(), ''' \n## [Completion] ## Completion scripts setup. Remove the following line to uninstall [ -f ${configDir.path}/bash-config.bash ] && . ${configDir.path}/bash-config.bash || true @@ -545,45 +578,39 @@ void main() { '''); - expect( - configDir.listSync().map((e) => path.basename(e.path)), - unorderedEquals([ - 'not_good.bash', - 'not_good.zsh', - 'very_good.bash', - 'very_good.zsh', - 'zsh-config.zsh', - 'bash-config.bash', - 'config.json', - ]), - ); - }, - ); + expect( + configDir.listSync().map((e) => path.basename(e.path)), + unorderedEquals([ + 'not_good.bash', + 'not_good.zsh', + 'very_good.bash', + 'very_good.zsh', + 'zsh-config.zsh', + 'bash-config.bash', + 'config.json', + ]), + ); + }); - test( - 'installing completion when the current shell is not supported', - () { - final installation = CompletionInstallation.fromSystemShell( - logger: logger, - isWindowsOverride: false, - environmentOverride: { - 'HOME': tempDir.path, - }, - systemShell: null, - ); + test('installing completion when the current shell is not supported', () { + final installation = CompletionInstallation.fromSystemShell( + logger: logger, + isWindowsOverride: false, + environmentOverride: {'HOME': tempDir.path}, + systemShell: null, + ); - expect( - () => installation.install('very_good'), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Unknown shell.', - ), + expect( + () => installation.install('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Unknown shell.', ), - ); - }, - ); + ), + ); + }); test( '''doesn't remove command from $CompletionConfiguration uninstalls when not forced to install''', @@ -592,9 +619,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( logger: logger, isWindowsOverride: false, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, systemShell: systemShell, ); @@ -619,7 +644,8 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration should contain the uninstall for the command before install''', + reason: + '''The completion configuration should contain the uninstall for the command before install''', ); installation.install(command); @@ -633,7 +659,8 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration should still contain the uninstall for the command after soft install''', + reason: + '''The completion configuration should still contain the uninstall for the command after soft install''', ); }, ); @@ -645,9 +672,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( logger: logger, isWindowsOverride: false, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, systemShell: systemShell, ); @@ -672,7 +697,8 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration should contain the uninstall for the command before install''', + reason: + '''The completion configuration should contain the uninstall for the command before install''', ); installation.install(command, force: true); @@ -686,7 +712,8 @@ void main() { systemShell: systemShell, ), isFalse, - reason: '''The completion configuration should not contain the uninstall for the command after install''', + reason: + '''The completion configuration should not contain the uninstall for the command after install''', ); }, ); @@ -698,9 +725,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( logger: logger, isWindowsOverride: false, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, systemShell: systemShell, ); @@ -719,7 +744,8 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration installs should contain the command after install''', + reason: + '''The completion configuration installs should contain the command after install''', ); }, ); @@ -731,9 +757,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( logger: logger, isWindowsOverride: false, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, systemShell: systemShell, ); @@ -751,7 +775,8 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration installs should contain the command after install''', + reason: + '''The completion configuration installs should contain the command after install''', ); // Install again. @@ -766,96 +791,82 @@ void main() { systemShell: systemShell, ), isTrue, - reason: '''The completion configuration installs should still contain the command after install''', + reason: + '''The completion configuration installs should still contain the command after install''', ); }, ); - test( - 'installing completion for .bashrc', - () { - final bashInstallation = CompletionInstallation( - logger: logger, - isWindows: false, - environment: { - 'HOME': tempDir.path, - }, - configuration: bashConfiguration, - ); + test('installing completion for .bashrc', () { + final bashInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: bashConfiguration, + ); - final configDir = bashInstallation.completionConfigDir; + final configDir = bashInstallation.completionConfigDir; - final bashProfile = File(path.join(tempDir.path, '.bash_profile')) - ..createSync(); + final bashProfile = File(path.join(tempDir.path, '.bash_profile')) + ..createSync(); - bashInstallation.install('very_good'); + bashInstallation.install('very_good'); - // Different format needed for matching cli output - expect(bashProfile.readAsStringSync(), ''' + // Different format needed for matching cli output + expect(bashProfile.readAsStringSync(), ''' \n## [Completion] ## Completion scripts setup. Remove the following line to uninstall [ -f ${configDir.path}/bash-config.bash ] && . ${configDir.path}/bash-config.bash || true ## [/Completion] '''); - }, - ); + }); - test( - 'installing completion for .bash_profile', - () { - final bashInstallation = CompletionInstallation( - logger: logger, - isWindows: false, - environment: { - 'HOME': tempDir.path, - }, - configuration: bashConfiguration, - ); + test('installing completion for .bash_profile', () { + final bashInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: bashConfiguration, + ); - final configDir = bashInstallation.completionConfigDir; + final configDir = bashInstallation.completionConfigDir; - final bashRc = File(path.join(tempDir.path, '.bashrc'))..createSync(); + final bashRc = File(path.join(tempDir.path, '.bashrc'))..createSync(); - bashInstallation.install('very_good'); + bashInstallation.install('very_good'); - // Different format needed for matching cli output - expect(bashRc.readAsStringSync(), ''' + // Different format needed for matching cli output + expect(bashRc.readAsStringSync(), ''' \n## [Completion] ## Completion scripts setup. Remove the following line to uninstall [ -f ${configDir.path}/bash-config.bash ] && . ${configDir.path}/bash-config.bash || true ## [/Completion] '''); - }, - ); + }); - test( - 'missing .bashrc and .bash_profile', - () { - final bashInstallation = CompletionInstallation( - logger: logger, - isWindows: false, - environment: { - 'HOME': tempDir.path, - }, - configuration: bashConfiguration, - ); + test('missing .bashrc and .bash_profile', () { + final bashInstallation = CompletionInstallation( + logger: logger, + isWindows: false, + environment: {'HOME': tempDir.path}, + configuration: bashConfiguration, + ); - expect( - () => bashInstallation.install('very_good'), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'No configuration files where found at ' - '\n ${path.join(tempDir.path, '.bashrc')}' - '\n ${path.join(tempDir.path, '.bash_profile')}', - ), + expect( + () => bashInstallation.install('very_good'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'No configuration files where found at ' + '\n ${path.join(tempDir.path, '.bashrc')}' + '\n ${path.join(tempDir.path, '.bash_profile')}', ), - ); - }, - ); + ), + ); + }); }); group('uninstall', () { @@ -874,9 +885,7 @@ void main() { CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: configuration, ) ..install(rootCommand) @@ -916,9 +925,7 @@ void main() { final bashInstallation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: bashConfig, )..install(rootCommand); @@ -926,9 +933,7 @@ void main() { CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: zshConfig, ) ..install(rootCommand) @@ -1030,9 +1035,7 @@ void main() { CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: configuration, ) ..install(commandName) @@ -1114,9 +1117,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( systemShell: systemShell, logger: logger, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, ); File(path.join(tempDir.path, '.zshrc')).createSync(); @@ -1147,9 +1148,7 @@ void main() { final installation = CompletionInstallation.fromSystemShell( systemShell: systemShell, logger: logger, - environmentOverride: { - 'HOME': tempDir.path, - }, + environmentOverride: {'HOME': tempDir.path}, ); File(path.join(tempDir.path, '.zshrc')).createSync(); @@ -1180,9 +1179,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDir.path, - }, + environment: {'HOME': tempDir.path}, configuration: zshConfiguration, ); final rcFile = File(path.join(tempDir.path, '.zshrc')); @@ -1206,9 +1203,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: zshConfiguration, ); @@ -1235,9 +1230,7 @@ void main() { final installation = CompletionInstallation( logger: logger, isWindows: false, - environment: { - 'HOME': tempDirectory.path, - }, + environment: {'HOME': tempDirectory.path}, configuration: configuration, );