diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index 32989abea..cd6ce999d 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -21,6 +21,7 @@ import 'package:pana/src/license_detection/license_detector.dart' as detector; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/pub_license/spdx_license.gen.dart'; import 'package:very_good_cli/src/pubspec_lock/pubspec_lock.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; /// Overrides the [package_config.findPackageConfig] function for testing. @visibleForTesting @@ -65,6 +66,87 @@ typedef _DependencyLicenseMap = Map?>; /// as values. typedef _BannedDependencyLicenseMap = Map>; +/// Options for configuring the `very_good packages check licenses` command. +class PackagesCheckLicensesOptions { + PackagesCheckLicensesOptions._({ + required this.ignoreRetrievalFailures, + required this.dependencyTypes, + required this.allowedLicenses, + required this.forbiddenLicenses, + required this.skippedPackages, + required this.reporterOutputFormat, + }); + + /// Parses [ArgResults] into a [PackagesCheckLicensesOptions] instance. + /// + /// When [config] is provided, its values are used as defaults for any + /// option that was not explicitly parsed on the command line. + factory PackagesCheckLicensesOptions.parse( + ArgResults argResults, { + VeryGoodConfig config = VeryGoodConfig.empty, + }) { + final licensesConfig = config.packages.check.licenses; + + final ignoreRetrievalFailures = resolveArg( + argResults, + 'ignore-retrieval-failures', + licensesConfig.ignoreRetrievalFailures, + ); + final dependencyTypes = resolveArg>( + argResults, + 'dependency-type', + licensesConfig.dependencyType, + ); + final allowedLicenses = resolveArg>( + argResults, + 'allowed', + licensesConfig.allowed, + ); + final forbiddenLicenses = resolveArg>( + argResults, + 'forbidden', + licensesConfig.forbidden, + ); + final skippedPackages = resolveArg>( + argResults, + 'skip-packages', + licensesConfig.skipPackages, + ); + final reporter = resolveArg( + argResults, + 'reporter', + licensesConfig.reporter, + ); + + return PackagesCheckLicensesOptions._( + ignoreRetrievalFailures: ignoreRetrievalFailures, + dependencyTypes: dependencyTypes.toSet(), + allowedLicenses: allowedLicenses.withoutBlanks, + forbiddenLicenses: forbiddenLicenses.withoutBlanks, + skippedPackages: skippedPackages.toSet(), + reporterOutputFormat: ReporterOutputFormat.fromString(reporter), + ); + } + + /// Whether to disregard licenses that failed to be retrieved. + final bool ignoreRetrievalFailures; + + /// The type of dependencies to check licenses for. + final Set dependencyTypes; + + /// The only licenses allowed to be used. + final List allowedLicenses; + + /// The licenses denied from being used. + final List forbiddenLicenses; + + /// Packages skipped from having their licenses checked. + final Set skippedPackages; + + /// The format used to list all licenses. + final ReporterOutputFormat? reporterOutputFormat; +} + /// {@template packages_check_licenses_command} /// `very_good packages check licenses` command for checking packages licenses. /// {@endtemplate} @@ -132,19 +214,27 @@ class PackagesCheckLicensesCommand extends Command { usageException('Too many arguments'); } - final ignoreFailures = _argResults['ignore-retrieval-failures'] as bool; - final dependencyTypes = _argResults['dependency-type'] as List; - final allowedLicenses = _argResults['allowed'] as List; - final forbiddenLicenses = _argResults['forbidden'] as List; - final skippedPackages = _argResults['skip-packages'] as List; - final reporterOutput = _argResults['reporter'] as String?; + final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.'; + final targetPath = path.normalize(Directory(target).absolute.path); - final reporterOutputFormat = ReporterOutputFormat.fromString( - reporterOutput, + final VeryGoodConfig config; + try { + config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); + } on VeryGoodConfigParseException catch (e) { + _logger.err( + 'Could not read `$veryGoodConfigFileName`.\n' + '${e.message}', + ); + return ExitCode.config.code; + } + + final options = PackagesCheckLicensesOptions.parse( + _argResults, + config: config, ); - allowedLicenses.removeWhere((license) => license.trim().isEmpty); - forbiddenLicenses.removeWhere((license) => license.trim().isEmpty); + final allowedLicenses = options.allowedLicenses; + final forbiddenLicenses = options.forbiddenLicenses; if (allowedLicenses.isNotEmpty && forbiddenLicenses.isNotEmpty) { usageException( @@ -166,8 +256,6 @@ class PackagesCheckLicensesCommand extends Command { ); } - final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.'; - final targetPath = path.normalize(Directory(target).absolute.path); final targetDirectory = Directory(targetPath); if (!targetDirectory.existsSync()) { _logger.err( @@ -195,16 +283,16 @@ class PackagesCheckLicensesCommand extends Command { final filteredDependencies = pubspecLock.packages.where((dependency) { if (!dependency.isPubHosted) return false; - if (skippedPackages.contains(dependency.name)) return false; + if (options.skippedPackages.contains(dependency.name)) return false; final dependencyType = dependency.type; - return (dependencyTypes.contains('direct-main') && + return (options.dependencyTypes.contains('direct-main') && dependencyType == PubspecLockPackageDependencyType.directMain) || - (dependencyTypes.contains('direct-dev') && + (options.dependencyTypes.contains('direct-dev') && dependencyType == PubspecLockPackageDependencyType.directDev) || - (dependencyTypes.contains('transitive') && + (options.dependencyTypes.contains('transitive') && dependencyType == PubspecLockPackageDependencyType.transitive) || - (dependencyTypes.contains('direct-overridden') && + (options.dependencyTypes.contains('direct-overridden') && dependencyType == PubspecLockPackageDependencyType.directOverridden); }); @@ -212,7 +300,7 @@ class PackagesCheckLicensesCommand extends Command { if (filteredDependencies.isEmpty) { progress.cancel(); _logger.info( - '''No hosted dependencies found in $targetPath of type: ${dependencyTypes.stringify()}.''', + '''No hosted dependencies found in $targetPath of type: ${options.dependencyTypes.stringify()}.''', ); return ExitCode.success.code; } @@ -240,7 +328,7 @@ class PackagesCheckLicensesCommand extends Command { if (cachePackageEntry == null) { final errorMessage = '''[$dependencyName] Could not find cached package path. Consider running `dart pub get` or `flutter pub get` to generate a new `package_config.json`.'''; - if (!ignoreFailures) { + if (!options.ignoreRetrievalFailures) { progress.cancel(); _logger.err(errorMessage); return ExitCode.noInput.code; @@ -256,7 +344,7 @@ class PackagesCheckLicensesCommand extends Command { if (!packageDirectory.existsSync()) { final errorMessage = '''[$dependencyName] Could not find package directory at $packagePath.'''; - if (!ignoreFailures) { + if (!options.ignoreRetrievalFailures) { progress.cancel(); _logger.err(errorMessage); return ExitCode.noInput.code; @@ -284,7 +372,7 @@ class PackagesCheckLicensesCommand extends Command { } on Exception catch (e) { final errorMessage = '''[$dependencyName] Failed to detect license from $packagePath: $e'''; - if (!ignoreFailures) { + if (!options.ignoreRetrievalFailures) { progress.cancel(); _logger.err(errorMessage); return ExitCode.software.code; @@ -326,7 +414,7 @@ class PackagesCheckLicensesCommand extends Command { _composeReport( licenses: licenses, bannedDependencies: bannedDependencies, - reporterOutputFormat: reporterOutputFormat, + reporterOutputFormat: options.reporterOutputFormat, ), ); @@ -522,15 +610,20 @@ String _composeBannedReport(_BannedDependencyLicenseMap bannedDependencies) { return '''${bannedDependencies.length} $prefix $suffix: ${bannedDependenciesList.stringify()}.'''; } -extension on List { +extension on Iterable { String stringify() { if (isEmpty) return ''; if (length == 1) return first.toString(); - final last = removeLast(); - return '${join(', ')} and $last'; + return '${take(length - 1).join(', ')} and $last'; } } +extension on List { + /// The licenses that are not blank. + List get withoutBlanks => + where((license) => license.trim().isNotEmpty).toList(); +} + /// Format type for listing all licenses via --reporter option. enum ReporterOutputFormat { /// List all licenses separated by a dash. diff --git a/lib/src/commands/packages/commands/get.dart b/lib/src/commands/packages/commands/get.dart index 4c79f4b79..29abf81f5 100644 --- a/lib/src/commands/packages/commands/get.dart +++ b/lib/src/commands/packages/commands/get.dart @@ -5,6 +5,45 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:universal_io/io.dart'; import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; + +/// Options for configuring the `very_good packages get` command. +class PackagesGetOptions { + PackagesGetOptions._({required this.recursive, required this.ignore}); + + /// Parses [ArgResults] into a [PackagesGetOptions] instance. + /// + /// When [config] is provided, its values are used as defaults for any + /// option that was not explicitly parsed on the command line. + factory PackagesGetOptions.parse( + ArgResults argResults, { + VeryGoodConfig config = VeryGoodConfig.empty, + }) { + final getConfig = config.packages.get; + + final recursive = resolveArg( + argResults, + 'recursive', + getConfig.recursive, + ); + final ignore = resolveArg>( + argResults, + 'ignore', + getConfig.ignore, + ); + + return PackagesGetOptions._( + recursive: recursive, + ignore: ignore.toSet(), + ); + } + + /// Whether to install dependencies recursively for all nested packages. + final bool recursive; + + /// Packages to exclude from installing dependencies. + final Set ignore; +} /// {@template packages_get_command} /// `very_good packages get` command for installing packages. @@ -45,10 +84,22 @@ class PackagesGetCommand extends Command { usageException('Too many arguments'); } - final recursive = _argResults['recursive'] as bool; - final ignore = (_argResults['ignore'] as List).toSet(); final target = _argResults.rest.length == 1 ? _argResults.rest[0] : '.'; final targetPath = path.normalize(Directory(target).absolute.path); + + final VeryGoodConfig config; + try { + config = VeryGoodConfig.loadFromClosestAncestor(Directory(targetPath)); + } on VeryGoodConfigParseException catch (e) { + _logger.err( + 'Could not read `$veryGoodConfigFileName`.\n' + '${e.message}', + ); + return ExitCode.config.code; + } + + final options = PackagesGetOptions.parse(_argResults, config: config); + final isFlutterInstalled = await Flutter.installed(logger: _logger); if (!isFlutterInstalled) { _logger.err( @@ -61,8 +112,8 @@ class PackagesGetCommand extends Command { try { await Flutter.pubGet( cwd: targetPath, - recursive: recursive, - ignore: ignore, + recursive: options.recursive, + ignore: options.ignore, logger: _logger, ); } on PubspecNotFound catch (_) { diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index a71572d12..2a0827005 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -46,39 +46,39 @@ class FlutterTestOptions { }) { final testConfig = config.test; - final concurrency = _resolveArg( + final concurrency = resolveArg( argResults, 'concurrency', testConfig.concurrency, ); - final collectCoverage = _resolveArg( + final collectCoverage = resolveArg( argResults, 'coverage', testConfig.coverage, ); - final minCoverage = _resolveArg( + final minCoverage = resolveArg( argResults, 'min-coverage', testConfig.minCoverage, ); final effectiveMinCoverage = double.tryParse(minCoverage ?? ''); - final showUncovered = _resolveArg( + final showUncovered = resolveArg( argResults, 'show-uncovered', testConfig.showUncovered, ); - final excludeTags = _resolveArg( + final excludeTags = resolveArg( argResults, 'exclude-tags', testConfig.excludeTags, ); - final tags = _resolveArg(argResults, 'tags', testConfig.tags); - final excludeFromCoverage = _resolveArg( + final tags = resolveArg(argResults, 'tags', testConfig.tags); + final excludeFromCoverage = resolveArg( argResults, 'exclude-coverage', testConfig.excludeCoverage, ); - final collectCoverageFrom = _resolveArg( + final collectCoverageFrom = resolveArg( argResults, 'collect-coverage-from', testConfig.collectCoverageFrom, @@ -92,38 +92,38 @@ class FlutterTestOptions { final randomSeed = randomOrderingSeed == 'random' ? Random().nextInt(4294967295).toString() : randomOrderingSeed; - final optimizePerformance = _resolveArg( + final optimizePerformance = resolveArg( argResults, 'optimization', testConfig.optimization, ); - final updateGoldens = _resolveArg( + final updateGoldens = resolveArg( argResults, 'update-goldens', testConfig.updateGoldens, ); - final failFast = _resolveArg( + final failFast = resolveArg( argResults, 'fail-fast', testConfig.failFast, ); final forceAnsi = argResults['force-ansi'] as bool?; - final dartDefine = _resolveArg?>( + final dartDefine = resolveArg?>( argResults, 'dart-define', testConfig.dartDefine, ); - final dartDefineFromFile = _resolveArg?>( + final dartDefineFromFile = resolveArg?>( argResults, 'dart-define-from-file', testConfig.dartDefineFromFile, ); - final platform = _resolveArg( + final platform = resolveArg( argResults, 'platform', testConfig.platform, ); - final reportOn = _resolveArg>( + final reportOn = resolveArg>( argResults, 'report-on', testConfig.reportOn, @@ -133,17 +133,17 @@ class FlutterTestOptions { .where((e) => e.isNotEmpty) .toList(); - final runSkipped = _resolveArg( + final runSkipped = resolveArg( argResults, 'run-skipped', testConfig.runSkipped, ); - final flavor = _resolveArg( + final flavor = resolveArg( argResults, 'flavor', testConfig.flavor, ); - final timeout = _resolveArg( + final timeout = resolveArg( argResults, 'timeout', testConfig.timeout, @@ -152,7 +152,7 @@ class FlutterTestOptions { final effectiveTimeout = timeoutSeconds != null ? Duration(seconds: timeoutSeconds) : null; - final fileReporter = _resolveArg( + final fileReporter = resolveArg( argResults, 'file-reporter', testConfig.fileReporter, @@ -255,27 +255,6 @@ class FlutterTestOptions { final List rest; } -/// Resolves the value for the argument named [name] against a `very_good.yaml` -/// configuration value. -/// -/// Resolution follows a fixed precedence, from highest to lowest: -/// -/// 1. A command line argument that was explicitly parsed. -/// 2. [configValue], the corresponding value from the configuration file. -/// 3. [fallbackValue], used when neither the command line nor the configuration -/// provide a value (typically the argument's command line default). -T _resolveArg( - ArgResults argResults, - String name, - T? configValue, { - T? fallbackValue, -}) { - final value = configValue != null && !argResults.wasParsed(name) - ? configValue - : argResults[name] as T?; - return (value ?? fallbackValue) as T; -} - /// Signature for the [Flutter.installed] method. typedef FlutterInstalledCommand = Future Function({required Logger logger}); diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index a94fedde2..f3aff1c07 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -7,7 +7,7 @@ library; import 'dart:io'; - +import 'package:args/args.dart'; import 'package:checked_yaml/checked_yaml.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -18,6 +18,27 @@ part 'very_good_config.g.dart'; /// The default name of the Very Good CLI configuration file. const veryGoodConfigFileName = 'very_good.yaml'; +/// Resolves the value for the argument named [name] against a `very_good.yaml` +/// configuration value. +/// +/// Resolution follows a fixed precedence, from highest to lowest: +/// +/// 1. A command line argument that was explicitly parsed. +/// 2. [configValue], the corresponding value from the configuration file. +/// 3. [fallbackValue], used when neither the command line nor the configuration +/// provide a value (typically the argument's command line default). +T resolveArg( + ArgResults argResults, + String name, + T? configValue, { + T? fallbackValue, +}) { + final value = configValue != null && !argResults.wasParsed(name) + ? configValue + : argResults[name] as T?; + return (value ?? fallbackValue) as T; +} + /// {@template very_good_config_parse_exception} /// Thrown when a [VeryGoodConfig] fails to parse. /// {@endtemplate} @@ -48,7 +69,10 @@ class VeryGoodConfigParseException implements Exception { ) class VeryGoodConfig extends Equatable { /// {@macro very_good_config} - const VeryGoodConfig({this.test = const VeryGoodTestConfig()}); + const VeryGoodConfig({ + this.test = const VeryGoodTestConfig(), + this.packages = const VeryGoodPackagesConfig(), + }); /// Creates a [VeryGoodConfig] from a decoded YAML/JSON [json] map. factory VeryGoodConfig.fromJson(Map json) { @@ -121,8 +145,11 @@ class VeryGoodConfig extends Equatable { /// Configuration values for the `very_good test` command. final VeryGoodTestConfig test; + /// Configuration values for the `very_good packages` command. + final VeryGoodPackagesConfig packages; + @override - List get props => [test]; + List get props => [test, packages]; } /// {@template very_good_test_config} @@ -256,6 +283,168 @@ class VeryGoodTestConfig extends Equatable { ]; } +/// {@template very_good_packages_config} +/// Configuration values that customize the defaults of the +/// `very_good packages` command. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodPackagesConfig extends Equatable { + /// {@macro very_good_packages_config} + const VeryGoodPackagesConfig({ + this.get = const VeryGoodPackagesGetConfig(), + this.check = const VeryGoodPackagesCheckConfig(), + }); + + /// Creates a [VeryGoodPackagesConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodPackagesConfig.fromJson(Map json) { + return _$VeryGoodPackagesConfigFromJson(json); + } + + /// Configuration values for the `very_good packages get` command. + final VeryGoodPackagesGetConfig get; + + /// Configuration values for the `very_good packages check` command. + final VeryGoodPackagesCheckConfig check; + + @override + List get props => [get, check]; +} + +/// {@template very_good_packages_get_config} +/// Configuration values that customize the defaults of the +/// `very_good packages get` command. +/// +/// Any field that is left as `null` retains its CLI default. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodPackagesGetConfig extends Equatable { + /// {@macro very_good_packages_get_config} + const VeryGoodPackagesGetConfig({this.recursive, this.ignore}); + + /// Creates a [VeryGoodPackagesGetConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodPackagesGetConfig.fromJson(Map json) { + return _$VeryGoodPackagesGetConfigFromJson(json); + } + + /// Whether to install dependencies recursively for all nested packages. + final bool? recursive; + + /// Packages to exclude from installing dependencies. + @JsonKey(fromJson: _stringList) + final List? ignore; + + @override + List get props => [recursive, ignore]; +} + +/// {@template very_good_packages_check_config} +/// Configuration values that customize the defaults of the +/// `very_good packages check` command. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodPackagesCheckConfig extends Equatable { + /// {@macro very_good_packages_check_config} + const VeryGoodPackagesCheckConfig({ + this.licenses = const VeryGoodPackagesCheckLicensesConfig(), + }); + + /// Creates a [VeryGoodPackagesCheckConfig] from a decoded YAML/JSON [json] + /// map. + factory VeryGoodPackagesCheckConfig.fromJson(Map json) { + return _$VeryGoodPackagesCheckConfigFromJson(json); + } + + /// Configuration values for the `very_good packages check licenses` command. + final VeryGoodPackagesCheckLicensesConfig licenses; + + @override + List get props => [licenses]; +} + +/// {@template very_good_packages_check_licenses_config} +/// Configuration values that customize the defaults of the +/// `very_good packages check licenses` command. +/// +/// Any field that is left as `null` retains its CLI default. +/// {@endtemplate} +@JsonSerializable( + anyMap: true, + checked: true, + createToJson: false, + disallowUnrecognizedKeys: true, + fieldRename: FieldRename.snake, +) +class VeryGoodPackagesCheckLicensesConfig extends Equatable { + /// {@macro very_good_packages_check_licenses_config} + const VeryGoodPackagesCheckLicensesConfig({ + this.ignoreRetrievalFailures, + this.dependencyType, + this.allowed, + this.forbidden, + this.skipPackages, + this.reporter, + }); + + /// Creates a [VeryGoodPackagesCheckLicensesConfig] from a decoded YAML/JSON + /// [json] map. + factory VeryGoodPackagesCheckLicensesConfig.fromJson( + Map json, + ) { + return _$VeryGoodPackagesCheckLicensesConfigFromJson(json); + } + + /// Whether to disregard licenses that failed to be retrieved. + final bool? ignoreRetrievalFailures; + + /// The type of dependencies to check licenses for. + @JsonKey(fromJson: _dependencyType) + final List? dependencyType; + + /// Only allow the use of certain licenses. + @JsonKey(fromJson: _stringList) + final List? allowed; + + /// Deny the use of certain licenses. + @JsonKey(fromJson: _stringList) + final List? forbidden; + + /// Packages to skip from having their licenses checked. + @JsonKey(fromJson: _stringList) + final List? skipPackages; + + /// The format used to list all licenses. + @JsonKey(fromJson: _reporter) + final String? reporter; + + @override + List get props => [ + ignoreRetrievalFailures, + dependencyType, + allowed, + forbidden, + skipPackages, + reporter, + ]; +} + // The coercers below intentionally validate more strictly than the CLI flag // parser. A value such as `min_coverage: 150` is rejected here at config load // time even though `--min-coverage 150` is accepted by the flag parser, so @@ -324,6 +513,41 @@ String? _collectCoverageFrom(Object? value) { return value as String; } +/// The dependency types accepted by `very_good packages check licenses`. +const _dependencyTypes = [ + 'direct-main', + 'direct-dev', + 'direct-overridden', + 'transitive', +]; + +/// Validates and returns the `dependency_type` value. +/// +/// Accepts only the values allowed by the CLI option. +List? _dependencyType(Object? value) { + final values = _stringList(value); + if (values == null) return null; + for (final value in values) { + if (!_dependencyTypes.contains(value)) { + throw FormatException( + 'Expected one of ${_dependencyTypes.join(', ')} but got `$value`.', + ); + } + } + return values; +} + +/// Validates and returns the `reporter` value. +/// +/// Accepts only `text` or `csv`. +String? _reporter(Object? value) { + if (value == null) return null; + if (value != 'text' && value != 'csv') { + throw FormatException('Expected `text` or `csv` but got `$value`.'); + } + return value as String; +} + /// Coerces a single string or a list of strings into a `List`. List? _stringList(Object? value) { if (value == null) return null; diff --git a/lib/src/very_good_config/very_good_config.g.dart b/lib/src/very_good_config/very_good_config.g.dart index e9f95ff40..0334b407a 100644 --- a/lib/src/very_good_config/very_good_config.g.dart +++ b/lib/src/very_good_config/very_good_config.g.dart @@ -8,7 +8,7 @@ part of 'very_good_config.dart'; VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => $checkedCreate('VeryGoodConfig', json, ($checkedConvert) { - $checkKeys(json, allowedKeys: const ['test']); + $checkKeys(json, allowedKeys: const ['test', 'packages']); final val = VeryGoodConfig( test: $checkedConvert( 'test', @@ -16,6 +16,12 @@ VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => ? const VeryGoodTestConfig() : VeryGoodTestConfig.fromJson(v as Map), ), + packages: $checkedConvert( + 'packages', + (v) => v == null + ? const VeryGoodPackagesConfig() + : VeryGoodPackagesConfig.fromJson(v as Map), + ), ); return val; }); @@ -92,3 +98,86 @@ VeryGoodTestConfig _$VeryGoodTestConfigFromJson(Map json) => $checkedCreate( 'fileReporter': 'file_reporter', }, ); + +VeryGoodPackagesConfig _$VeryGoodPackagesConfigFromJson(Map json) => + $checkedCreate('VeryGoodPackagesConfig', json, ($checkedConvert) { + $checkKeys(json, allowedKeys: const ['get', 'check']); + final val = VeryGoodPackagesConfig( + get: $checkedConvert( + 'get', + (v) => v == null + ? const VeryGoodPackagesGetConfig() + : VeryGoodPackagesGetConfig.fromJson(v as Map), + ), + check: $checkedConvert( + 'check', + (v) => v == null + ? const VeryGoodPackagesCheckConfig() + : VeryGoodPackagesCheckConfig.fromJson(v as Map), + ), + ); + return val; + }); + +VeryGoodPackagesGetConfig _$VeryGoodPackagesGetConfigFromJson(Map json) => + $checkedCreate('VeryGoodPackagesGetConfig', json, ($checkedConvert) { + $checkKeys(json, allowedKeys: const ['recursive', 'ignore']); + final val = VeryGoodPackagesGetConfig( + recursive: $checkedConvert('recursive', (v) => v as bool?), + ignore: $checkedConvert('ignore', (v) => _stringList(v)), + ); + return val; + }); + +VeryGoodPackagesCheckConfig _$VeryGoodPackagesCheckConfigFromJson(Map json) => + $checkedCreate('VeryGoodPackagesCheckConfig', json, ($checkedConvert) { + $checkKeys(json, allowedKeys: const ['licenses']); + final val = VeryGoodPackagesCheckConfig( + licenses: $checkedConvert( + 'licenses', + (v) => v == null + ? const VeryGoodPackagesCheckLicensesConfig() + : VeryGoodPackagesCheckLicensesConfig.fromJson(v as Map), + ), + ); + return val; + }); + +VeryGoodPackagesCheckLicensesConfig +_$VeryGoodPackagesCheckLicensesConfigFromJson(Map json) => $checkedCreate( + 'VeryGoodPackagesCheckLicensesConfig', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'ignore_retrieval_failures', + 'dependency_type', + 'allowed', + 'forbidden', + 'skip_packages', + 'reporter', + ], + ); + final val = VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: $checkedConvert( + 'ignore_retrieval_failures', + (v) => v as bool?, + ), + dependencyType: $checkedConvert( + 'dependency_type', + (v) => _dependencyType(v), + ), + allowed: $checkedConvert('allowed', (v) => _stringList(v)), + forbidden: $checkedConvert('forbidden', (v) => _stringList(v)), + skipPackages: $checkedConvert('skip_packages', (v) => _stringList(v)), + reporter: $checkedConvert('reporter', (v) => _reporter(v)), + ); + return val; + }, + fieldKeyMap: const { + 'ignoreRetrievalFailures': 'ignore_retrieval_failures', + 'dependencyType': 'dependency_type', + 'skipPackages': 'skip_packages', + }, +); diff --git a/site/docs/commands/check_licenses.md b/site/docs/commands/check_licenses.md index d9f11fb51..6850ddd30 100644 --- a/site/docs/commands/check_licenses.md +++ b/site/docs/commands/check_licenses.md @@ -126,6 +126,31 @@ very_good packages check licenses --reporter=csv # package_c,Apache-2.0 ``` +## Configuring defaults with `very_good.yaml` + +To avoid repeating arguments every time you run `very_good packages check licenses` locally or on CI, you may create a `very_good.yaml` file at the root of your project. The `packages.check.licenses` section accepts the same names as the CLI arguments in snake_case (e.g. `--skip-packages` becomes `skip_packages`). Values from `very_good.yaml` are used as defaults; anything you pass on the command line takes precedence. + +```yaml +# very_good.yaml +packages: + check: + licenses: + dependency_type: + - direct-main + - transitive + allowed: + - MIT + - BSD-3-Clause + skip_packages: + - html + ignore_retrieval_failures: true + reporter: csv +``` + +With the file above, running `very_good packages check licenses` behaves the same as running `very_good packages check licenses --dependency-type=direct-main,transitive --allowed=MIT,BSD-3-Clause --skip-packages=html --ignore-retrieval-failures --reporter=csv`. + +The `very_good.yaml` file is looked up starting from the directory where the command runs and walking up through its ancestors. The closest file wins; configuration from ancestor directories is not merged. + ## Supported licenses 💳 The license detection is processed by [Dart's package analyzer](https://pub.dev/packages/pana), which reports commonly found licenses (SPDX licenses). The list of accepted licenses can be seen in the [SPDX GitHub repository](https://github.com/spdx/license-list-data/tree/main/text) or in the [SPDX License enumeration](https://github.com/VeryGoodOpenSource/very_good_cli/blob/main/lib/src/pub_license/spdx_license.gen.dart). Therefore, when specifying a license within arguments it must strictly match with the SPDX license name. diff --git a/site/docs/commands/get_pkgs.md b/site/docs/commands/get_pkgs.md index 048b4b338..25aa063a0 100644 --- a/site/docs/commands/get_pkgs.md +++ b/site/docs/commands/get_pkgs.md @@ -16,3 +16,20 @@ very_good packages get [arguments] Run "very_good help" to see global options. ``` + +### Configuring defaults with `very_good.yaml` + +To avoid repeating flags every time you run `very_good packages get` locally or on CI, you may create a `very_good.yaml` file at the root of your project. The `packages.get` section accepts the same names as the CLI flags in snake_case. Values from `very_good.yaml` are used as defaults; anything you pass on the command line takes precedence. + +```yaml +# very_good.yaml +packages: + get: + recursive: true + ignore: + - example +``` + +With the file above, running `very_good packages get` behaves the same as running `very_good packages get --recursive --ignore=example`. You can still override any of these values on the command line. + +The `very_good.yaml` file is looked up starting from the directory where the command runs and walking up through its ancestors. The closest file wins; configuration from ancestor directories is not merged. diff --git a/test/src/commands/packages/commands/check/commands/licenses_test.dart b/test/src/commands/packages/commands/check/commands/licenses_test.dart index 38d6e727f..b3045e9f6 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -1754,6 +1754,29 @@ and limitations under the License.'''); }), ); }); + + group('very_good.yaml configuration', () { + test( + 'fails with exit code ${ExitCode.config.code} ' + 'when very_good.yaml is malformed', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync('- not\n- a\n- map'); + + final result = await commandRunner.run([ + ...commandArguments, + tempDirectory.path, + ]); + expect(result, equals(ExitCode.config.code)); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }), + ); + }); }); } diff --git a/test/src/commands/packages/commands/get_test.dart b/test/src/commands/packages/commands/get_test.dart index 61301237e..ac8643243 100644 --- a/test/src/commands/packages/commands/get_test.dart +++ b/test/src/commands/packages/commands/get_test.dart @@ -2,15 +2,20 @@ // and also be longer than 80 chars. // ignore_for_file: no_adjacent_strings_in_list, lines_longer_than_80_chars +import 'package:args/args.dart'; import 'package:mason/mason.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:universal_io/io.dart'; import 'package:very_good_cli/src/cli/cli.dart'; +import 'package:very_good_cli/src/commands/packages/commands/get.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; import '../../../../helpers/helpers.dart'; +class _MockArgResults extends Mock implements ArgResults {} + const _expectedPackagesGetUsage = [ 'Get packages in a Dart or Flutter project.\n' '\n' @@ -598,5 +603,214 @@ sdk: ^3.12.0 }); }), ); + + group('very_good.yaml configuration', () { + test( + 'fails with exit code ${ExitCode.config.code} ' + 'when very_good.yaml is malformed', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File( + path.join(tempDirectory.path, 'pubspec.yaml'), + ).createSync(); + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync('- not\n- a\n- map'); + + final result = await commandRunner.run([ + 'packages', + 'get', + tempDirectory.path, + ]); + expect(result, equals(ExitCode.config.code)); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }), + ); + + test('applies config values when args were not parsed', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['recursive']).thenReturn(false); + when(() => argResults['ignore']).thenReturn([]); + + final options = PackagesGetOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig( + recursive: true, + ignore: ['example'], + ), + ), + ), + ); + + expect(options.recursive, isTrue); + expect(options.ignore, equals({'example'})); + }); + + test('CLI arguments take precedence over config values', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); + when(() => argResults['recursive']).thenReturn(false); + when( + () => argResults['ignore'], + ).thenReturn(['cli']); + + final options = PackagesGetOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig( + recursive: true, + ignore: ['config'], + ), + ), + ), + ); + + expect(options.recursive, isFalse); + expect(options.ignore, equals({'cli'})); + }); + + test('falls back to CLI defaults when neither is set', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['recursive']).thenReturn(false); + when(() => argResults['ignore']).thenReturn([]); + + final options = PackagesGetOptions.parse(argResults); + + expect(options.recursive, isFalse); + expect(options.ignore, isEmpty); + }); + + test( + 'applies recursive value from config', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync(''' +packages: + get: + recursive: true +'''); + + final pubspecA = File( + path.join(tempDirectory.path, 'example_a', 'pubspec.yaml'), + ); + final pubspecB = File( + path.join(tempDirectory.path, 'example_b', 'pubspec.yaml'), + ); + pubspecA + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: example_a +version: 0.1.0 + +environment: + sdk: ^3.12.0 +'''); + pubspecB + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: example_b +version: 0.1.0 + +environment: + sdk: ^3.12.0 +'''); + + final result = await commandRunner.run([ + 'packages', + 'get', + tempDirectory.path, + ]); + expect(result, equals(ExitCode.success.code)); + verify(() { + logger.progress( + any(that: contains('Running "flutter pub get" in')), + ); + }).called(2); + }), + ); + + test( + 'applies ignore value from config', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync(''' +packages: + get: + recursive: true + ignore: + - plugin_b +'''); + + final directoryA = Directory( + path.join(tempDirectory.path, 'plugin_a'), + ); + final directoryB = Directory( + path.join(tempDirectory.path, 'plugin_b'), + ); + File(path.join(directoryA.path, 'pubspec.yaml')) + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: plugin_a +version: 0.1.0 + +environment: + sdk: ^3.12.0 +'''); + File(path.join(directoryB.path, 'pubspec.yaml')) + ..createSync(recursive: true) + ..writeAsStringSync(''' +name: plugin_b +version: 0.1.0 + +environment: + sdk: ^3.12.0 +'''); + + final result = await commandRunner.run([ + 'packages', + 'get', + tempDirectory.path, + ]); + expect(result, equals(ExitCode.success.code)); + verify(() { + logger.progress( + any( + that: contains( + 'Running "flutter pub get" in .${path.context.separator}plugin_a', + ), + ), + ); + }).called(1); + verifyNever(() { + logger.progress( + any( + that: contains( + 'Running "flutter pub get" in .${path.context.separator}plugin_b', + ), + ), + ); + }); + }), + ); + }); }); } diff --git a/test/src/very_good_config/fixtures/all_packages_options.yaml b/test/src/very_good_config/fixtures/all_packages_options.yaml new file mode 100644 index 000000000..f191f4191 --- /dev/null +++ b/test/src/very_good_config/fixtures/all_packages_options.yaml @@ -0,0 +1,18 @@ +packages: + get: + recursive: true + ignore: + - example + - integration_test + check: + licenses: + ignore_retrieval_failures: true + dependency_type: + - direct-main + - direct-dev + allowed: + - MIT + - BSD-3-Clause + skip_packages: + - very_good_analysis + reporter: csv diff --git a/test/src/very_good_config/very_good_config_test.dart b/test/src/very_good_config/very_good_config_test.dart index 2494babbf..a9e53d2be 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -1,6 +1,7 @@ // Ensures we don't have to use const constructors // and instances are created at runtime. // ignore_for_file: prefer_const_constructors +// ignore_for_file: prefer_const_literals_to_create_immutables import 'dart:io'; @@ -161,6 +162,167 @@ test: ); }); + test('parses all supported packages options', () { + final fixture = File( + p.join( + 'test', + 'src', + 'very_good_config', + 'fixtures', + 'all_packages_options.yaml', + ), + ); + final config = VeryGoodConfig.fromString(fixture.readAsStringSync()); + + expect(config.packages.get.recursive, isTrue); + expect( + config.packages.get.ignore, + equals(['example', 'integration_test']), + ); + + final licenses = config.packages.check.licenses; + expect(licenses.ignoreRetrievalFailures, isTrue); + expect( + licenses.dependencyType, + equals(['direct-main', 'direct-dev']), + ); + expect(licenses.allowed, equals(['MIT', 'BSD-3-Clause'])); + expect(licenses.forbidden, isNull); + expect(licenses.skipPackages, equals(['very_good_analysis'])); + expect(licenses.reporter, equals('csv')); + }); + + test('parses single ignore entry as a list', () { + final config = VeryGoodConfig.fromString(''' +packages: + get: + ignore: example +'''); + expect(config.packages.get.ignore, equals(['example'])); + }); + + test('throws when packages section is not a map', () { + expect( + () => VeryGoodConfig.fromString('packages: foo'), + throwsA(isA()), + ); + }); + + test('throws when packages.get section is not a map', () { + expect( + () => VeryGoodConfig.fromString('packages:\n get: foo'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized packages key is present', () { + expect( + () => VeryGoodConfig.fromString('packages:\n unknown: true'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized packages.get key is present', () { + expect( + () => + VeryGoodConfig.fromString('packages:\n get:\n unknown: true'), + throwsA(isA()), + ); + }); + + test('throws when packages.get.recursive has wrong type', () { + expect( + () => VeryGoodConfig.fromString( + 'packages:\n get:\n recursive: maybe', + ), + throwsA(isA()), + ); + }); + + test('throws when packages.get.ignore has wrong type', () { + expect( + () => VeryGoodConfig.fromString('packages:\n get:\n ignore: 42'), + throwsA(isA()), + ); + }); + + test('throws when packages.check section is not a map', () { + expect( + () => VeryGoodConfig.fromString('packages:\n check: foo'), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized packages.check key is present', () { + expect( + () => VeryGoodConfig.fromString( + 'packages:\n check:\n unknown: true', + ), + throwsA(isA()), + ); + }); + + test( + 'throws when an unrecognized packages.check.licenses key is present', + () { + expect( + () => VeryGoodConfig.fromString(''' +packages: + check: + licenses: + unknown: true +'''), + throwsA(isA()), + ); + }, + ); + + test('parses single allowed entry as a list', () { + final config = VeryGoodConfig.fromString(''' +packages: + check: + licenses: + allowed: MIT +'''); + expect(config.packages.check.licenses.allowed, equals(['MIT'])); + }); + + test('throws when dependency_type is not a valid value', () { + expect( + () => VeryGoodConfig.fromString(''' +packages: + check: + licenses: + dependency_type: everything +'''), + throwsA(isA()), + ); + }); + + test('throws when reporter is not a valid value', () { + expect( + () => VeryGoodConfig.fromString(''' +packages: + check: + licenses: + reporter: json +'''), + throwsA(isA()), + ); + }); + + test('throws when ignore_retrieval_failures has wrong type', () { + expect( + () => VeryGoodConfig.fromString(''' +packages: + check: + licenses: + ignore_retrieval_failures: maybe +'''), + throwsA(isA()), + ); + }); + test('throws when an unrecognized root key is present', () { expect( () => VeryGoodConfig.fromString('unknown: true'), @@ -313,6 +475,36 @@ test: equals(VeryGoodConfig(test: VeryGoodTestConfig(coverage: false))), ), ); + expect( + VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + ), + equals( + VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + ), + ), + ); + expect( + VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + ), + isNot( + equals( + VeryGoodConfig( + packages: VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: false), + ), + ), + ), + ), + ); }); }); @@ -329,6 +521,94 @@ test: }); }); + group(VeryGoodPackagesConfig, () { + test('supports value equality', () { + expect( + VeryGoodPackagesConfig(), + equals(VeryGoodPackagesConfig()), + ); + expect( + VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + equals( + VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + ), + ); + expect( + VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: true), + ), + isNot( + equals( + VeryGoodPackagesConfig( + get: VeryGoodPackagesGetConfig(recursive: false), + ), + ), + ), + ); + }); + }); + + group(VeryGoodPackagesGetConfig, () { + test('supports value equality', () { + expect( + VeryGoodPackagesGetConfig(recursive: true, ignore: ['a']), + equals(VeryGoodPackagesGetConfig(recursive: true, ignore: ['a'])), + ); + expect( + VeryGoodPackagesGetConfig(recursive: true), + isNot(equals(VeryGoodPackagesGetConfig(recursive: false))), + ); + }); + }); + + group(VeryGoodPackagesCheckConfig, () { + test('supports value equality', () { + expect( + VeryGoodPackagesCheckConfig(), + equals(VeryGoodPackagesCheckConfig()), + ); + expect( + VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig(reporter: 'csv'), + ), + isNot(equals(VeryGoodPackagesCheckConfig())), + ); + }); + }); + + group(VeryGoodPackagesCheckLicensesConfig, () { + test('supports value equality', () { + expect( + VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['direct-main'], + allowed: ['MIT'], + forbidden: ['BSD'], + skipPackages: ['a'], + reporter: 'csv', + ), + equals( + VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['direct-main'], + allowed: ['MIT'], + forbidden: ['BSD'], + skipPackages: ['a'], + reporter: 'csv', + ), + ), + ); + expect( + VeryGoodPackagesCheckLicensesConfig(reporter: 'csv'), + isNot(equals(VeryGoodPackagesCheckLicensesConfig(reporter: 'text'))), + ); + }); + }); + group(VeryGoodConfigParseException, () { test('provides message via toString', () { const exception = VeryGoodConfigParseException('bad thing');