diff --git a/lib/src/installer/completion_configuration.dart b/lib/src/installer/completion_configuration.dart new file mode 100644 index 0000000..7a10957 --- /dev/null +++ b/lib/src/installer/completion_configuration.dart @@ -0,0 +1,139 @@ +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cli_completion/installer.dart'; +import 'package:cli_completion/parser.dart'; +import 'package:meta/meta.dart'; + +/// A map of [SystemShell]s to a list of uninstalled commands. +/// +/// The map and its content are unmodifiable. This is to ensure that +/// [CompletionConfiguration]s is fully immutable. +typedef Uninstalls + = UnmodifiableMapView>; + +/// {@template completion_configuration} +/// A configuration that stores information on how to handle command +/// completions. +/// {@endtemplate} +@immutable +class CompletionConfiguration { + /// {@macro completion_configuration} + const CompletionConfiguration._({ + required this.uninstalls, + }); + + /// Creates an empty [CompletionConfiguration]. + @visibleForTesting + CompletionConfiguration.empty() : uninstalls = UnmodifiableMapView({}); + + /// Creates a [CompletionConfiguration] from the given [file] content. + /// + /// If the file does not exist or is empty, a [CompletionConfiguration.empty] + /// is created. + /// + /// If the file is not empty, a [CompletionConfiguration] is created from the + /// file's content. This content is assumed to be a JSON string. The parsing + /// is handled gracefully, so if the JSON is partially or fully invalid, it + /// handles issues without throwing an [Exception]. + factory CompletionConfiguration.fromFile(File file) { + if (!file.existsSync()) { + return CompletionConfiguration.empty(); + } + + final json = file.readAsStringSync(); + return CompletionConfiguration._fromJson(json); + } + + /// Creates a [CompletionConfiguration] from the given JSON string. + factory CompletionConfiguration._fromJson(String json) { + late final Map decodedJson; + try { + decodedJson = jsonDecode(json) as Map; + } on FormatException { + decodedJson = {}; + } + + return CompletionConfiguration._( + uninstalls: _jsonDecodeUninstalls(decodedJson), + ); + } + + /// The JSON key for the [uninstalls] field. + static const String _uninstallsJsonKey = 'uninstalls'; + + /// Stores those commands that have been manually uninstalled by the user. + /// + /// Uninstalls are specific to a given [SystemShell]. + final Uninstalls uninstalls; + + /// Stores the [CompletionConfiguration] in the given [file]. + void writeTo(File file) { + if (!file.existsSync()) { + file.createSync(recursive: true); + } + file.writeAsStringSync(_toJson()); + } + + /// Returns a copy of this [CompletionConfiguration] with the given fields + /// replaced. + CompletionConfiguration copyWith({ + Uninstalls? uninstalls, + }) { + return CompletionConfiguration._( + uninstalls: uninstalls ?? this.uninstalls, + ); + } + + /// Returns a JSON representation of this [CompletionConfiguration]. + String _toJson() { + return jsonEncode({ + _uninstallsJsonKey: _jsonEncodeUninstalls(uninstalls), + }); + } +} + +/// Decodes [Uninstalls] 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({}); + } + final jsonUninstalls = json[CompletionConfiguration._uninstallsJsonKey]; + if (jsonUninstalls is! String) { + return UnmodifiableMapView({}); + } + late final Map decodedUninstalls; + try { + decodedUninstalls = jsonDecode(jsonUninstalls) as Map; + } on FormatException { + return UnmodifiableMapView({}); + } + + final newUninstalls = >{}; + for (final entry in decodedUninstalls.entries) { + final systemShell = SystemShell.tryParse(entry.key); + if (systemShell == null) continue; + final uninstallSet = {}; + if (entry.value is List) { + for (final uninstall in entry.value as List) { + if (uninstall is String) { + uninstallSet.add(uninstall); + } + } + } + newUninstalls[systemShell] = UnmodifiableSetView(uninstallSet); + } + return UnmodifiableMapView(newUninstalls); +} + +/// Returns a JSON representation of the given [Uninstalls]. +String _jsonEncodeUninstalls(Uninstalls uninstalls) { + return jsonEncode({ + for (final entry in uninstalls.entries) + entry.key.toString(): entry.value.toList(), + }); +} diff --git a/lib/src/installer/installer.dart b/lib/src/installer/installer.dart index 03776bd..f341398 100644 --- a/lib/src/installer/installer.dart +++ b/lib/src/installer/installer.dart @@ -1,3 +1,4 @@ +export 'completion_configuration.dart'; export 'completion_installation.dart'; export 'exceptions.dart'; export 'script_configuration_entry.dart'; diff --git a/lib/src/system_shell.dart b/lib/src/system_shell.dart index 1ce1568..ec744c7 100644 --- a/lib/src/system_shell.dart +++ b/lib/src/system_shell.dart @@ -54,7 +54,16 @@ enum SystemShell { // On windows basename can be bash.exe return SystemShell.bash; } + return null; + } + /// Tries to parse a [SystemShell] from a [String]. + /// + /// Returns `null` if the [value] does not match any of the shells. + static SystemShell? tryParse(String value) { + for (final shell in SystemShell.values) { + if (value == shell.name || value == shell.toString()) return shell; + } return null; } } diff --git a/test/src/installer/completion_configuration_test.dart b/test/src/installer/completion_configuration_test.dart new file mode 100644 index 0000000..ae158e8 --- /dev/null +++ b/test/src/installer/completion_configuration_test.dart @@ -0,0 +1,196 @@ +// ignore_for_file: prefer_const_constructors + +import 'dart:collection'; +import 'dart:io'; + +import 'package:cli_completion/installer.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + group('$CompletionConfiguration', () { + final testUninstalls = UnmodifiableMapView({ + SystemShell.bash: UnmodifiableSetView({'very_bad'}), + }); + + group('fromFile', () { + test( + 'returns empty cache when file does not exist', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')); + expect( + file.existsSync(), + isFalse, + reason: 'File should not exist', + ); + + final cache = CompletionConfiguration.fromFile(file); + expect( + cache.uninstalls, + isEmpty, + reason: 'Uninstalls should be initially empty', + ); + }, + ); + + test('returns empty cache 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); + expect( + cache.uninstalls, + isEmpty, + reason: 'Uninstalls should be initially empty', + ); + }); + + test("returns a $CompletionConfiguration with the file's defined members", + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')); + final cache = CompletionConfiguration.empty().copyWith( + uninstalls: testUninstalls, + )..writeTo(file); + + final newCache = CompletionConfiguration.fromFile(file); + expect( + newCache.uninstalls, + cache.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''', + () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + const json = '{"uninstalls": "very_bad"}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final cache = CompletionConfiguration.fromFile(file); + expect( + cache.uninstalls, + isEmpty, + reason: + '''Uninstalls 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 = '{"uninstalls": 1}'; + final file = File(path.join(tempDirectory.path, 'config.json')) + ..writeAsStringSync(json); + + final cache = CompletionConfiguration.fromFile(file); + expect( + cache.uninstalls, + isEmpty, + reason: + '''Uninstalls should be empty when the value is of an invalid type''', + ); + }, + ); + }); + + group('writeTo', () { + test('creates a file when it does not exist', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')); + expect( + file.existsSync(), + isFalse, + reason: 'File should not exist', + ); + + CompletionConfiguration.empty().writeTo(file); + + expect( + file.existsSync(), + isTrue, + reason: 'File should exist after cache creation', + ); + }); + + test('returns normally when file already exists', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')) + ..createSync(); + expect( + file.existsSync(), + isTrue, + reason: 'File should exist', + ); + + expect( + () => CompletionConfiguration.empty().writeTo(file), + returnsNormally, + reason: 'Should not throw when file exists', + ); + }); + + test('content can be read succesfully after written', () { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final file = File(path.join(tempDirectory.path, 'config.json')); + final cache = CompletionConfiguration.empty().copyWith( + uninstalls: testUninstalls, + )..writeTo(file); + + final newCache = CompletionConfiguration.fromFile(file); + expect( + newCache.uninstalls, + cache.uninstalls, + reason: 'Uninstalls should match those defined in the file', + ); + }); + }); + + group('copyWith', () { + test('members remain unchanged when nothing is specified', () { + final cache = CompletionConfiguration.empty(); + final newCache = cache.copyWith(); + + expect( + newCache.uninstalls, + cache.uninstalls, + reason: 'Uninstalls should remain unchanged', + ); + }); + + test('modifies uninstalls when specified', () { + final cache = CompletionConfiguration.empty(); + final uninstalls = testUninstalls; + final newCache = cache.copyWith(uninstalls: uninstalls); + + expect( + newCache.uninstalls, + equals(uninstalls), + reason: 'Uninstalls should be modified', + ); + }); + }); + }); +} diff --git a/test/src/system_shell_test.dart b/test/src/system_shell_test.dart index fa819af..5c6a97f 100644 --- a/test/src/system_shell_test.dart +++ b/test/src/system_shell_test.dart @@ -103,5 +103,43 @@ void main() { }); }); }); + + group('tryParse', () { + group('returns bash', () { + test('when given "bash"', () { + expect( + SystemShell.tryParse('bash'), + equals(SystemShell.bash), + ); + }); + + test('when given "SystemShell.bash"', () { + expect( + SystemShell.tryParse('SystemShell.bash'), + equals(SystemShell.bash), + ); + }); + }); + + group('returns zsh', () { + test('when given "zsh"', () { + expect( + SystemShell.tryParse('zsh'), + equals(SystemShell.zsh), + ); + }); + + test('when given "SystemShell.zsh"', () { + expect( + SystemShell.tryParse('SystemShell.zsh'), + equals(SystemShell.zsh), + ); + }); + }); + + test('returns null for unknown shell', () { + expect(SystemShell.tryParse('unknown'), equals(null)); + }); + }); }); }