Skip to content

Commit

Permalink
Merge branch 'main' into feat/uninstall-command
Browse files Browse the repository at this point in the history
  • Loading branch information
alestiago authored May 17, 2023
2 parents f1d4fce + 7b1726b commit a609970
Show file tree
Hide file tree
Showing 6 changed files with 384 additions and 1 deletion.
139 changes: 139 additions & 0 deletions lib/src/installer/completion_configuration.dart
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(),
});
}
2 changes: 1 addition & 1 deletion lib/src/installer/completion_installation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ ${configuration!.sourceLineTemplate(scriptPath)}''';
/// Before uninstalling, it checks if the completion is installed:
/// - The shell has an existing RCFile with a completion
/// [ScriptConfigurationEntry].
/// - The shell has an exisiting completion configuration file with a
/// - The shell has an existing completion configuration file with a
/// [ScriptConfigurationEntry] for the [rootCommand].
///
/// If any of the above is not true, it throws a
Expand Down
1 change: 1 addition & 0 deletions lib/src/installer/installer.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'completion_configuration.dart';
export 'completion_installation.dart';
export 'exceptions.dart';
export 'script_configuration_entry.dart';
Expand Down
9 changes: 9 additions & 0 deletions lib/src/system_shell.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
196 changes: 196 additions & 0 deletions test/src/installer/completion_configuration_test.dart
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',
);
});
});
});
}
Loading

0 comments on commit a609970

Please sign in to comment.