Skip to content

Commit a9e21de

Browse files
authored
feat: included CompletionConfiguration class (#71)
1 parent d73f33a commit a9e21de

File tree

5 files changed

+383
-0
lines changed

5 files changed

+383
-0
lines changed
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import 'dart:collection';
2+
import 'dart:convert';
3+
import 'dart:io';
4+
5+
import 'package:cli_completion/installer.dart';
6+
import 'package:cli_completion/parser.dart';
7+
import 'package:meta/meta.dart';
8+
9+
/// A map of [SystemShell]s to a list of uninstalled commands.
10+
///
11+
/// The map and its content are unmodifiable. This is to ensure that
12+
/// [CompletionConfiguration]s is fully immutable.
13+
typedef Uninstalls
14+
= UnmodifiableMapView<SystemShell, UnmodifiableSetView<String>>;
15+
16+
/// {@template completion_configuration}
17+
/// A configuration that stores information on how to handle command
18+
/// completions.
19+
/// {@endtemplate}
20+
@immutable
21+
class CompletionConfiguration {
22+
/// {@macro completion_configuration}
23+
const CompletionConfiguration._({
24+
required this.uninstalls,
25+
});
26+
27+
/// Creates an empty [CompletionConfiguration].
28+
@visibleForTesting
29+
CompletionConfiguration.empty() : uninstalls = UnmodifiableMapView({});
30+
31+
/// Creates a [CompletionConfiguration] from the given [file] content.
32+
///
33+
/// If the file does not exist or is empty, a [CompletionConfiguration.empty]
34+
/// is created.
35+
///
36+
/// If the file is not empty, a [CompletionConfiguration] is created from the
37+
/// file's content. This content is assumed to be a JSON string. The parsing
38+
/// is handled gracefully, so if the JSON is partially or fully invalid, it
39+
/// handles issues without throwing an [Exception].
40+
factory CompletionConfiguration.fromFile(File file) {
41+
if (!file.existsSync()) {
42+
return CompletionConfiguration.empty();
43+
}
44+
45+
final json = file.readAsStringSync();
46+
return CompletionConfiguration._fromJson(json);
47+
}
48+
49+
/// Creates a [CompletionConfiguration] from the given JSON string.
50+
factory CompletionConfiguration._fromJson(String json) {
51+
late final Map<String, dynamic> decodedJson;
52+
try {
53+
decodedJson = jsonDecode(json) as Map<String, dynamic>;
54+
} on FormatException {
55+
decodedJson = {};
56+
}
57+
58+
return CompletionConfiguration._(
59+
uninstalls: _jsonDecodeUninstalls(decodedJson),
60+
);
61+
}
62+
63+
/// The JSON key for the [uninstalls] field.
64+
static const String _uninstallsJsonKey = 'uninstalls';
65+
66+
/// Stores those commands that have been manually uninstalled by the user.
67+
///
68+
/// Uninstalls are specific to a given [SystemShell].
69+
final Uninstalls uninstalls;
70+
71+
/// Stores the [CompletionConfiguration] in the given [file].
72+
void writeTo(File file) {
73+
if (!file.existsSync()) {
74+
file.createSync(recursive: true);
75+
}
76+
file.writeAsStringSync(_toJson());
77+
}
78+
79+
/// Returns a copy of this [CompletionConfiguration] with the given fields
80+
/// replaced.
81+
CompletionConfiguration copyWith({
82+
Uninstalls? uninstalls,
83+
}) {
84+
return CompletionConfiguration._(
85+
uninstalls: uninstalls ?? this.uninstalls,
86+
);
87+
}
88+
89+
/// Returns a JSON representation of this [CompletionConfiguration].
90+
String _toJson() {
91+
return jsonEncode({
92+
_uninstallsJsonKey: _jsonEncodeUninstalls(uninstalls),
93+
});
94+
}
95+
}
96+
97+
/// Decodes [Uninstalls] from the given [json].
98+
///
99+
/// If the [json] is not partially or fully valid, it handles issues gracefully
100+
/// without throwing an [Exception].
101+
Uninstalls _jsonDecodeUninstalls(Map<String, dynamic> json) {
102+
if (!json.containsKey(CompletionConfiguration._uninstallsJsonKey)) {
103+
return UnmodifiableMapView({});
104+
}
105+
final jsonUninstalls = json[CompletionConfiguration._uninstallsJsonKey];
106+
if (jsonUninstalls is! String) {
107+
return UnmodifiableMapView({});
108+
}
109+
late final Map<String, dynamic> decodedUninstalls;
110+
try {
111+
decodedUninstalls = jsonDecode(jsonUninstalls) as Map<String, dynamic>;
112+
} on FormatException {
113+
return UnmodifiableMapView({});
114+
}
115+
116+
final newUninstalls = <SystemShell, UnmodifiableSetView<String>>{};
117+
for (final entry in decodedUninstalls.entries) {
118+
final systemShell = SystemShell.tryParse(entry.key);
119+
if (systemShell == null) continue;
120+
final uninstallSet = <String>{};
121+
if (entry.value is List) {
122+
for (final uninstall in entry.value as List) {
123+
if (uninstall is String) {
124+
uninstallSet.add(uninstall);
125+
}
126+
}
127+
}
128+
newUninstalls[systemShell] = UnmodifiableSetView(uninstallSet);
129+
}
130+
return UnmodifiableMapView(newUninstalls);
131+
}
132+
133+
/// Returns a JSON representation of the given [Uninstalls].
134+
String _jsonEncodeUninstalls(Uninstalls uninstalls) {
135+
return jsonEncode({
136+
for (final entry in uninstalls.entries)
137+
entry.key.toString(): entry.value.toList(),
138+
});
139+
}

lib/src/installer/installer.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export 'completion_configuration.dart';
12
export 'completion_installation.dart';
23
export 'exceptions.dart';
34
export 'script_configuration_entry.dart';

lib/src/system_shell.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,16 @@ enum SystemShell {
5454
// On windows basename can be bash.exe
5555
return SystemShell.bash;
5656
}
57+
return null;
58+
}
5759

60+
/// Tries to parse a [SystemShell] from a [String].
61+
///
62+
/// Returns `null` if the [value] does not match any of the shells.
63+
static SystemShell? tryParse(String value) {
64+
for (final shell in SystemShell.values) {
65+
if (value == shell.name || value == shell.toString()) return shell;
66+
}
5867
return null;
5968
}
6069
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// ignore_for_file: prefer_const_constructors
2+
3+
import 'dart:collection';
4+
import 'dart:io';
5+
6+
import 'package:cli_completion/installer.dart';
7+
import 'package:path/path.dart' as path;
8+
import 'package:test/test.dart';
9+
10+
void main() {
11+
group('$CompletionConfiguration', () {
12+
final testUninstalls = UnmodifiableMapView({
13+
SystemShell.bash: UnmodifiableSetView({'very_bad'}),
14+
});
15+
16+
group('fromFile', () {
17+
test(
18+
'returns empty cache when file does not exist',
19+
() {
20+
final tempDirectory = Directory.systemTemp.createTempSync();
21+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
22+
23+
final file = File(path.join(tempDirectory.path, 'config.json'));
24+
expect(
25+
file.existsSync(),
26+
isFalse,
27+
reason: 'File should not exist',
28+
);
29+
30+
final cache = CompletionConfiguration.fromFile(file);
31+
expect(
32+
cache.uninstalls,
33+
isEmpty,
34+
reason: 'Uninstalls should be initially empty',
35+
);
36+
},
37+
);
38+
39+
test('returns empty cache when file is empty', () {
40+
final tempDirectory = Directory.systemTemp.createTempSync();
41+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
42+
43+
final file = File(path.join(tempDirectory.path, 'config.json'))
44+
..writeAsStringSync('');
45+
46+
final cache = CompletionConfiguration.fromFile(file);
47+
expect(
48+
cache.uninstalls,
49+
isEmpty,
50+
reason: 'Uninstalls should be initially empty',
51+
);
52+
});
53+
54+
test("returns a $CompletionConfiguration with the file's defined members",
55+
() {
56+
final tempDirectory = Directory.systemTemp.createTempSync();
57+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
58+
59+
final file = File(path.join(tempDirectory.path, 'config.json'));
60+
final cache = CompletionConfiguration.empty().copyWith(
61+
uninstalls: testUninstalls,
62+
)..writeTo(file);
63+
64+
final newCache = CompletionConfiguration.fromFile(file);
65+
expect(
66+
newCache.uninstalls,
67+
cache.uninstalls,
68+
reason: 'Uninstalls should match those defined in the file',
69+
);
70+
});
71+
72+
test(
73+
'''returns a $CompletionConfiguration with empty uninstalls if the file's JSON "uninstalls" key has a string value''',
74+
() {
75+
final tempDirectory = Directory.systemTemp.createTempSync();
76+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
77+
78+
const json = '{"uninstalls": "very_bad"}';
79+
final file = File(path.join(tempDirectory.path, 'config.json'))
80+
..writeAsStringSync(json);
81+
82+
final cache = CompletionConfiguration.fromFile(file);
83+
expect(
84+
cache.uninstalls,
85+
isEmpty,
86+
reason:
87+
'''Uninstalls should be empty when the value is of an invalid type''',
88+
);
89+
},
90+
);
91+
92+
test(
93+
'''returns a $CompletionConfiguration with empty uninstalls if file's JSON "uninstalls" key has a numeric value''',
94+
() {
95+
final tempDirectory = Directory.systemTemp.createTempSync();
96+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
97+
98+
const json = '{"uninstalls": 1}';
99+
final file = File(path.join(tempDirectory.path, 'config.json'))
100+
..writeAsStringSync(json);
101+
102+
final cache = CompletionConfiguration.fromFile(file);
103+
expect(
104+
cache.uninstalls,
105+
isEmpty,
106+
reason:
107+
'''Uninstalls should be empty when the value is of an invalid type''',
108+
);
109+
},
110+
);
111+
});
112+
113+
group('writeTo', () {
114+
test('creates a file when it does not exist', () {
115+
final tempDirectory = Directory.systemTemp.createTempSync();
116+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
117+
118+
final file = File(path.join(tempDirectory.path, 'config.json'));
119+
expect(
120+
file.existsSync(),
121+
isFalse,
122+
reason: 'File should not exist',
123+
);
124+
125+
CompletionConfiguration.empty().writeTo(file);
126+
127+
expect(
128+
file.existsSync(),
129+
isTrue,
130+
reason: 'File should exist after cache creation',
131+
);
132+
});
133+
134+
test('returns normally when file already exists', () {
135+
final tempDirectory = Directory.systemTemp.createTempSync();
136+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
137+
138+
final file = File(path.join(tempDirectory.path, 'config.json'))
139+
..createSync();
140+
expect(
141+
file.existsSync(),
142+
isTrue,
143+
reason: 'File should exist',
144+
);
145+
146+
expect(
147+
() => CompletionConfiguration.empty().writeTo(file),
148+
returnsNormally,
149+
reason: 'Should not throw when file exists',
150+
);
151+
});
152+
153+
test('content can be read succesfully after written', () {
154+
final tempDirectory = Directory.systemTemp.createTempSync();
155+
addTearDown(() => tempDirectory.deleteSync(recursive: true));
156+
157+
final file = File(path.join(tempDirectory.path, 'config.json'));
158+
final cache = CompletionConfiguration.empty().copyWith(
159+
uninstalls: testUninstalls,
160+
)..writeTo(file);
161+
162+
final newCache = CompletionConfiguration.fromFile(file);
163+
expect(
164+
newCache.uninstalls,
165+
cache.uninstalls,
166+
reason: 'Uninstalls should match those defined in the file',
167+
);
168+
});
169+
});
170+
171+
group('copyWith', () {
172+
test('members remain unchanged when nothing is specified', () {
173+
final cache = CompletionConfiguration.empty();
174+
final newCache = cache.copyWith();
175+
176+
expect(
177+
newCache.uninstalls,
178+
cache.uninstalls,
179+
reason: 'Uninstalls should remain unchanged',
180+
);
181+
});
182+
183+
test('modifies uninstalls when specified', () {
184+
final cache = CompletionConfiguration.empty();
185+
final uninstalls = testUninstalls;
186+
final newCache = cache.copyWith(uninstalls: uninstalls);
187+
188+
expect(
189+
newCache.uninstalls,
190+
equals(uninstalls),
191+
reason: 'Uninstalls should be modified',
192+
);
193+
});
194+
});
195+
});
196+
}

0 commit comments

Comments
 (0)