Skip to content

Commit

Permalink
feat: avoid autoinstalling manually uninstalled commands (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
alestiago committed May 23, 2023
1 parent 7675b26 commit 527db4c
Show file tree
Hide file tree
Showing 8 changed files with 436 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class InstallCompletionFilesCommand<T> extends Command<T> {
FutureOr<T>? run() {
final verbose = argResults!['verbose'] as bool;
final level = verbose ? Level.verbose : Level.info;
runner.tryInstallCompletionFiles(level);
runner.tryInstallCompletionFiles(level, force: true);
return null;
}
}
18 changes: 10 additions & 8 deletions lib/src/command_runner/completion_command_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,18 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {
return _completionInstallation = completionInstallation;
}

/// The list of commands that should not trigger the auto installation.
static const _reservedCommands = {
HandleCompletionRequestCommand.commandName,
InstallCompletionFilesCommand.commandName,
UnistallCompletionFilesCommand.commandName,
};

@override
@mustCallSuper
Future<T?> runCommand(ArgResults topLevelResults) async {
final reservedCommands = [
HandleCompletionRequestCommand.commandName,
InstallCompletionFilesCommand.commandName,
];

if (enableAutoInstall &&
!reservedCommands.contains(topLevelResults.command?.name)) {
!_reservedCommands.contains(topLevelResults.command?.name)) {
// When auto installing, use error level to display messages.
tryInstallCompletionFiles(Level.error);
}
Expand All @@ -84,10 +86,10 @@ abstract class CompletionCommandRunner<T> extends CommandRunner<T> {

/// Tries to install completion files for the current shell.
@internal
void tryInstallCompletionFiles(Level level) {
void tryInstallCompletionFiles(Level level, {bool force = false}) {
try {
completionInstallationLogger.level = level;
completionInstallation.install(executableName);
completionInstallation.install(executableName, force: force);
} on CompletionInstallationException catch (e) {
completionInstallationLogger.warn(e.toString());
} on Exception catch (e) {
Expand Down
51 changes: 51 additions & 0 deletions lib/src/installer/completion_configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,54 @@ String _jsonEncodeUninstalls(Uninstalls uninstalls) {
entry.key.toString(): entry.value.toList(),
});
}

/// Provides convinience methods for [Uninstalls].
extension UninstallsExtension on Uninstalls {
/// Returns a new [Uninstalls] with the given [command] added to
/// [systemShell].
Uninstalls include({
required String command,
required SystemShell systemShell,
}) {
final modifiable = _modifiable();

if (modifiable.containsKey(systemShell)) {
modifiable[systemShell]!.add(command);
} else {
modifiable[systemShell] = {command};
}

return UnmodifiableMapView(
modifiable.map((key, value) => MapEntry(key, UnmodifiableSetView(value))),
);
}

/// Returns a new [Uninstalls] with the given [command] removed from
/// [systemShell].
Uninstalls exclude({
required String command,
required SystemShell systemShell,
}) {
final modifiable = _modifiable();

if (modifiable.containsKey(systemShell)) {
modifiable[systemShell]!.remove(command);
}

return UnmodifiableMapView(
modifiable.map((key, value) => MapEntry(key, UnmodifiableSetView(value))),
);
}

/// Whether the [command] is contained in [systemShell].
bool contains({required String command, required SystemShell systemShell}) {
if (containsKey(systemShell)) {
return this[systemShell]!.contains(command);
}
return false;
}

Map<SystemShell, Set<String>> _modifiable() {
return map((key, value) => MapEntry(key, value.toSet()));
}
}
63 changes: 59 additions & 4 deletions lib/src/installer/completion_installation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ class CompletionInstallation {
}
}

/// Define the [File] in which the completion configuration is stored.
@visibleForTesting
File get completionConfigurationFile {
return File(path.join(completionConfigDir.path, 'config.json'));
}

/// Install completion configuration files for a [rootCommand] in the
/// current shell.
///
Expand All @@ -101,7 +107,11 @@ class CompletionInstallation {
/// completion script file.
/// - A line in the shell config file (e.g. `.bash_profile`) that sources
/// the aforementioned config file.
void install(String rootCommand) {
///
/// If [force] is true, it will overwrite the command's completion files even
/// if they already exist. If false, it will check if it has been explicitly
/// uninstalled before installing it.
void install(String rootCommand, {bool force = false}) {
final configuration = this.configuration;

if (configuration == null) {
Expand All @@ -111,6 +121,10 @@ class CompletionInstallation {
);
}

if (!force && !_shouldInstall(rootCommand)) {
return;
}

logger.detail(
'Installing completion for the command $rootCommand '
'on ${configuration.shell.name}',
Expand All @@ -124,6 +138,33 @@ class CompletionInstallation {
if (completionFileCreated) {
_logSourceInstructions(rootCommand);
}

final completionConfiguration =
CompletionConfiguration.fromFile(completionConfigurationFile);
completionConfiguration
.copyWith(
uninstalls: completionConfiguration.uninstalls.exclude(
command: rootCommand,
systemShell: configuration.shell,
),
)
.writeTo(completionConfigurationFile);
}

/// Wether the completion configuration files for a [rootCommand] should be
/// installed or not.
///
/// It will return false if the root command has been explicitly uninstalled.
bool _shouldInstall(String rootCommand) {
final completionConfiguration = CompletionConfiguration.fromFile(
completionConfigurationFile,
);
final systemShell = configuration!.shell;
final isUninstalled = completionConfiguration.uninstalls.contains(
command: rootCommand,
systemShell: systemShell,
);
return !isUninstalled;
}

/// Create a directory in which the completion config files shall be saved.
Expand Down Expand Up @@ -378,9 +419,23 @@ ${configuration!.sourceLineTemplate(scriptPath)}''';
if (!shellCompletionConfigurationFile.existsSync()) {
completionEntry.removeFrom(shellRCFile);
}

if (completionConfigDir.listSync().isEmpty) {
completionConfigDir.deleteSync();
final completionConfigDirContent = completionConfigDir.listSync();
final onlyHasConfigurationFile = completionConfigDirContent.length == 1 &&
path.absolute(completionConfigDirContent.first.path) ==
path.absolute(completionConfigurationFile.path);
if (completionConfigDirContent.isEmpty || onlyHasConfigurationFile) {
completionConfigDir.deleteSync(recursive: true);
} else {
final completionConfiguration =
CompletionConfiguration.fromFile(completionConfigurationFile);
completionConfiguration
.copyWith(
uninstalls: completionConfiguration.uninstalls.include(
command: rootCommand,
systemShell: configuration.shell,
),
)
.writeTo(completionConfigurationFile);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,21 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockLogger extends Mock implements Logger {}
class _MockLogger extends Mock implements Logger {}

class MockCompletionInstallation extends Mock
class _MockCompletionInstallation extends Mock
implements CompletionInstallation {}

class _TestCompletionCommandRunner extends CompletionCommandRunner<int> {
_TestCompletionCommandRunner() : super('test', 'Test command runner');

@override
// ignore: overridden_fields
final Logger completionInstallationLogger = MockLogger();
final Logger completionInstallationLogger = _MockLogger();

@override
final CompletionInstallation completionInstallation =
MockCompletionInstallation();
_MockCompletionInstallation();
}

void main() {
Expand All @@ -45,6 +45,15 @@ void main() {
});

group('install completion files', () {
test('forces install', () async {
await commandRunner.run(['install-completion-files']);

verify(
() => commandRunner.completionInstallation
.install(commandRunner.executableName, force: true),
).called(1);
});

test('when normal', () async {
await commandRunner.run(['install-completion-files']);

Expand Down
38 changes: 28 additions & 10 deletions test/src/command_runner/completion_command_runner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import 'package:test/test.dart';

class MockLogger extends Mock implements Logger {}

class MockCompletionInstallation extends Mock
class _MockCompletionInstallation extends Mock
implements CompletionInstallation {}

class _TestCompletionCommandRunner extends CompletionCommandRunner<int> {
Expand Down Expand Up @@ -116,13 +116,13 @@ void main() {
test('Tries to install completion files on test subcommand', () async {
final commandRunner = _TestCompletionCommandRunner()
..addCommand(_TestUserCommand())
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

await commandRunner.run(['ahoy']);

verify(() => commandRunner.completionInstallation.install('test'))
.called(1);

verify(
() => commandRunner.completionInstallation.install('test'),
).called(1);
verify(
() => commandRunner.completionInstallationLogger.level = Level.error,
).called(1);
Expand All @@ -132,7 +132,7 @@ void main() {
final commandRunner = _TestCompletionCommandRunner()
..enableAutoInstall = false
..addCommand(_TestUserCommand())
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

await commandRunner.run(['ahoy']);

Expand All @@ -142,14 +142,32 @@ void main() {
() => commandRunner.completionInstallationLogger.level = any(),
);
});

test('softly tries to install when enabled', () async {
final commandRunner = _TestCompletionCommandRunner()
..enableAutoInstall = true
..addCommand(_TestUserCommand())
..mockCompletionInstallation = _MockCompletionInstallation()
..environmentOverride = {
'SHELL': '/foo/bar/zsh',
};

await commandRunner.run(['ahoy']);

verify(
() => commandRunner.completionInstallation.install(
commandRunner.executableName,
),
).called(1);
});
});

test(
'When it throws CompletionInstallationException, it logs as a warning',
() async {
final commandRunner = _TestCompletionCommandRunner()
..addCommand(_TestUserCommand())
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

when(
() => commandRunner.completionInstallation.install('test'),
Expand All @@ -168,7 +186,7 @@ void main() {
() async {
final commandRunner = _TestCompletionCommandRunner()
..addCommand(_TestUserCommand())
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

when(
() => commandRunner.completionInstallation.install('test'),
Expand All @@ -185,7 +203,7 @@ void main() {
'logs a warning wen it throws $CompletionUninstallationException',
() async {
final commandRunner = _TestCompletionCommandRunner()
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

when(
() => commandRunner.completionInstallation.uninstall('test'),
Expand All @@ -207,7 +225,7 @@ void main() {
'logs an error when an unknown exception happens during a install',
() async {
final commandRunner = _TestCompletionCommandRunner()
..mockCompletionInstallation = MockCompletionInstallation();
..mockCompletionInstallation = _MockCompletionInstallation();

when(
() => commandRunner.completionInstallation.uninstall('test'),
Expand Down
Loading

0 comments on commit 527db4c

Please sign in to comment.