-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'main' into feat/uninstall-command
- Loading branch information
Showing
6 changed files
with
384 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SystemShell, UnmodifiableSetView<String>>; | ||
|
||
/// {@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<String, dynamic> decodedJson; | ||
try { | ||
decodedJson = jsonDecode(json) as Map<String, dynamic>; | ||
} 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<String, dynamic> json) { | ||
if (!json.containsKey(CompletionConfiguration._uninstallsJsonKey)) { | ||
return UnmodifiableMapView({}); | ||
} | ||
final jsonUninstalls = json[CompletionConfiguration._uninstallsJsonKey]; | ||
if (jsonUninstalls is! String) { | ||
return UnmodifiableMapView({}); | ||
} | ||
late final Map<String, dynamic> decodedUninstalls; | ||
try { | ||
decodedUninstalls = jsonDecode(jsonUninstalls) as Map<String, dynamic>; | ||
} on FormatException { | ||
return UnmodifiableMapView({}); | ||
} | ||
|
||
final newUninstalls = <SystemShell, UnmodifiableSetView<String>>{}; | ||
for (final entry in decodedUninstalls.entries) { | ||
final systemShell = SystemShell.tryParse(entry.key); | ||
if (systemShell == null) continue; | ||
final uninstallSet = <String>{}; | ||
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(), | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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', | ||
); | ||
}); | ||
}); | ||
}); | ||
} |
Oops, something went wrong.