From 2dd85cb49cca14556adb9565bad02055540a3cfc Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Mon, 27 Jul 2026 13:17:33 +0200 Subject: [PATCH 1/8] feat(create): add create config to very_good.yaml Add a `create` section to `very_good.yaml` so `very_good create` and its subcommands can read defaults for `description`, `org_name`, `publishable`, and `template`. CLI arguments continue to take precedence over config values. Closes #1654 --- .github/workflows/e2e.yaml | 1 + .../very_good_config/fixture/very_good.yaml | 3 + .../malformed_fixture/very_good.yaml | 3 + .../very_good_config_test.dart | 85 ++++++ .../create/commands/create_subcommand.dart | 53 +++- .../commands/create/commands/docs_site.dart | 4 +- .../very_good_config/very_good_config.dart | 53 +++- .../very_good_config/very_good_config.g.dart | 27 +- site/docs/commands/create.md | 17 ++ .../helpers/test_multi_template_commands.dart | 1 + .../create/commands/app_ui_package_test.dart | 1 + .../create/commands/dart_cli_test.dart | 1 + .../create/commands/dart_package_test.dart | 1 + .../create/commands/docs_site_test.dart | 34 +++ .../create/commands/flame_game_test.dart | 1 + .../create/commands/flutter_package_test.dart | 1 + .../create/commands/flutter_plugin_test.dart | 2 + .../create/create_subcommand_test.dart | 267 ++++++++++++++++++ .../fixtures/all_create_options.yaml | 5 + .../very_good_config_test.dart | 73 +++++ 20 files changed, 623 insertions(+), 10 deletions(-) create mode 100644 e2e/test/commands/create/very_good_config/fixture/very_good.yaml create mode 100644 e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml create mode 100644 e2e/test/commands/create/very_good_config/very_good_config_test.dart create mode 100644 test/src/very_good_config/fixtures/all_create_options.yaml diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index f77584dea..318631d40 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -43,6 +43,7 @@ jobs: - test/commands/create/flame_game/flame_game_test.dart - test/commands/create/flutter_package/flutter_pkg_test.dart - test/commands/create/flutter_plugin/flutter_plugin_test.dart + - test/commands/create/very_good_config/very_good_config_test.dart # E2E tests for the `packages check licenses` command - test/commands/packages/check/licenses/licenses_allowed_test.dart diff --git a/e2e/test/commands/create/very_good_config/fixture/very_good.yaml b/e2e/test/commands/create/very_good_config/fixture/very_good.yaml new file mode 100644 index 000000000..5510485a5 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/fixture/very_good.yaml @@ -0,0 +1,3 @@ +create: + description: A project configured via very_good.yaml. + publishable: true diff --git a/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml b/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml new file mode 100644 index 000000000..149ff8996 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/malformed_fixture/very_good.yaml @@ -0,0 +1,3 @@ +- not +- a +- map diff --git a/e2e/test/commands/create/very_good_config/very_good_config_test.dart b/e2e/test/commands/create/very_good_config/very_good_config_test.dart new file mode 100644 index 000000000..050e24c15 --- /dev/null +++ b/e2e/test/commands/create/very_good_config/very_good_config_test.dart @@ -0,0 +1,85 @@ +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 '../../../../helpers/helpers.dart'; + +void main() { + group('very_good.yaml', () { + test( + 'applies create defaults from very_good.yaml when flags are not passed', + timeout: const Timeout(Duration(minutes: 2)), + withRunner((commandRunner, logger, updater, logs, progressLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync( + 'very_good_config_create', + ); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final fixture = Directory( + path.join( + Directory.current.path, + 'test/commands/create/very_good_config/fixture', + ), + ); + + await copyDirectory(fixture, tempDirectory); + + final cwd = Directory.current; + Directory.current = tempDirectory; + addTearDown(() => Directory.current = cwd); + + final result = await commandRunner.run([ + 'create', + 'dart_package', + 'very_good_dart', + ]); + expect(result, equals(ExitCode.success.code)); + + final pubspec = File( + path.join(tempDirectory.path, 'very_good_dart', 'pubspec.yaml'), + ); + expect(pubspec.existsSync(), isTrue); + expect( + pubspec.readAsStringSync(), + contains('A project configured via very_good.yaml.'), + ); + }), + ); + + test( + 'fails with config exit code when very_good.yaml is malformed', + timeout: const Timeout(Duration(minutes: 2)), + withRunner((commandRunner, logger, updater, logs, progressLogs) async { + final tempDirectory = Directory.systemTemp.createTempSync( + 'very_good_config_create_malformed', + ); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final fixture = Directory( + path.join( + Directory.current.path, + 'test/commands/create/very_good_config/malformed_fixture', + ), + ); + + await copyDirectory(fixture, tempDirectory); + + final cwd = Directory.current; + Directory.current = tempDirectory; + addTearDown(() => Directory.current = cwd); + + await expectLater( + commandRunner.run(['create', 'dart_package', 'very_good_dart']), + completion(equals(ExitCode.config.code)), + ); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }), + ); + }); +} diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index 2e00ca665..452a5ceb7 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; import 'package:very_good_cli/src/commands/commands.dart'; import 'package:very_good_cli/src/commands/create/templates/templates.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; // A valid Dart identifier that can be used for a package, i.e. no // capital letters. @@ -105,6 +106,14 @@ abstract class CreateSubCommand extends Command { final Logger logger; final MasonGeneratorFromBundle _generatorFromBundle; + /// The resolved `very_good create` configuration read from the closest + /// `very_good.yaml` file. + /// + /// Values declared here are used as defaults for any option that was not + /// explicitly passed on the command line. It defaults to an empty + /// configuration until [run] loads it. + VeryGoodCreateConfig createConfig = const VeryGoodCreateConfig(); + /// [ArgResults] which can be overridden for testing. @visibleForTesting ArgResults? argResultOverrides; @@ -160,8 +169,20 @@ abstract class CreateSubCommand extends Command { return name; } + /// Resolves the value for the argument named [name] against the matching + /// `very_good.yaml` [configValue]. + /// + /// A command line argument that was explicitly parsed always wins. Otherwise + /// the [configValue] is used when present, falling back to the argument's + /// default value. + T? resolveArg(String name, T? configValue) { + if (argResults.wasParsed(name)) return argResults[name] as T?; + return configValue ?? argResults[name] as T?; + } + /// Gets the description for the project. - String get projectDescription => argResults['description'] as String? ?? ''; + String get projectDescription => + resolveArg('description', createConfig.description) ?? ''; /// Should return the desired template to be created during a command run. /// @@ -181,6 +202,18 @@ abstract class CreateSubCommand extends Command { @override Future run() async { + try { + createConfig = VeryGoodConfig.loadFromClosestAncestor( + Directory.current, + ).create; + } on VeryGoodConfigParseException catch (e) { + logger.err( + 'Could not read `$veryGoodConfigFileName`.\n' + '${e.message}', + ); + return ExitCode.config.code; + } + final template = this.template; final bundle = template.bundle; @@ -258,7 +291,8 @@ abstract class CreateSubCommand extends Command { mixin OrgName on CreateSubCommand { /// Gets the organization name. String get orgName { - final orgName = argResults['org-name'] as String? ?? _defaultOrgName; + final orgName = + resolveArg('org-name', createConfig.orgName) ?? _defaultOrgName; _validateOrgName(orgName); return orgName; } @@ -303,9 +337,15 @@ mixin MultiTemplates on CreateSubCommand { @override Template get template { final templateName = - argResults['template'] as String? ?? defaultTemplateName; - - return templates.firstWhere((template) => template.name == templateName); + resolveArg('template', createConfig.template) ?? + defaultTemplateName; + + return templates.firstWhere( + (template) => template.name == templateName, + orElse: () => usageException( + '"$templateName" is not an allowed value for option "--template".', + ), + ); } } @@ -316,5 +356,6 @@ mixin MultiTemplates on CreateSubCommand { /// to the brick generator. mixin Publishable on CreateSubCommand { /// Gets the publishable flag. - bool get publishable => argResults['publishable'] as bool? ?? false; + bool get publishable => + resolveArg('publishable', createConfig.publishable) ?? false; } diff --git a/lib/src/commands/create/commands/docs_site.dart b/lib/src/commands/create/commands/docs_site.dart index d05060785..39268482c 100644 --- a/lib/src/commands/create/commands/docs_site.dart +++ b/lib/src/commands/create/commands/docs_site.dart @@ -30,7 +30,9 @@ class CreateDocsSite extends CreateSubCommand with Publishable { Map getTemplateVars() { return { ...super.getTemplateVars(), - 'org_name': argResults['org-name'], + 'org_name': + resolveArg('org-name', createConfig.orgName) ?? + _defaultOrgName, }; } diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index a94fedde2..0d1bcec68 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -48,7 +48,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.create = const VeryGoodCreateConfig(), + }); /// Creates a [VeryGoodConfig] from a decoded YAML/JSON [json] map. factory VeryGoodConfig.fromJson(Map json) { @@ -121,8 +124,54 @@ class VeryGoodConfig extends Equatable { /// Configuration values for the `very_good test` command. final VeryGoodTestConfig test; + /// Configuration values for the `very_good create` command. + final VeryGoodCreateConfig create; + + @override + List get props => [test, create]; +} + +/// {@template very_good_create_config} +/// Configuration values that customize the defaults of the +/// `very_good create` command and its subcommands. +/// +/// 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 VeryGoodCreateConfig extends Equatable { + /// {@macro very_good_create_config} + const VeryGoodCreateConfig({ + this.description, + this.orgName, + this.publishable, + this.template, + }); + + /// Creates a [VeryGoodCreateConfig] from a decoded YAML/JSON [json] map. + factory VeryGoodCreateConfig.fromJson(Map json) { + return _$VeryGoodCreateConfigFromJson(json); + } + + /// The description for the generated project. + final String? description; + + /// The organization for the generated project. + final String? orgName; + + /// Whether the generated project is intended to be published. + final bool? publishable; + + /// The template used to generate the project. + final String? template; + @override - List get props => [test]; + List get props => [description, orgName, publishable, template]; } /// {@template very_good_test_config} 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..860fed484 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', 'create']); final val = VeryGoodConfig( test: $checkedConvert( 'test', @@ -16,10 +16,35 @@ VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => ? const VeryGoodTestConfig() : VeryGoodTestConfig.fromJson(v as Map), ), + create: $checkedConvert( + 'create', + (v) => v == null + ? const VeryGoodCreateConfig() + : VeryGoodCreateConfig.fromJson(v as Map), + ), ); return val; }); +VeryGoodCreateConfig _$VeryGoodCreateConfigFromJson(Map json) => $checkedCreate( + 'VeryGoodCreateConfig', + json, + ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const ['description', 'org_name', 'publishable', 'template'], + ); + final val = VeryGoodCreateConfig( + description: $checkedConvert('description', (v) => v as String?), + orgName: $checkedConvert('org_name', (v) => v as String?), + publishable: $checkedConvert('publishable', (v) => v as bool?), + template: $checkedConvert('template', (v) => v as String?), + ); + return val; + }, + fieldKeyMap: const {'orgName': 'org_name'}, +); + VeryGoodTestConfig _$VeryGoodTestConfigFromJson(Map json) => $checkedCreate( 'VeryGoodTestConfig', json, diff --git a/site/docs/commands/create.md b/site/docs/commands/create.md index ade0f0f5d..06b40fef0 100644 --- a/site/docs/commands/create.md +++ b/site/docs/commands/create.md @@ -68,6 +68,23 @@ You cannot combine `.` with `--output-directory`. Very Good CLI will exit with an error if you specify both. ::: +## Configuring defaults with `very_good.yaml` + +To avoid repeating the same flags every time you scaffold a new project, you may create a `very_good.yaml` file at the root of your project. The `create` section accepts the same names as the CLI flags in snake_case (e.g. `--org-name` becomes `org_name`). Values from `very_good.yaml` are used as defaults; anything you pass on the command line takes precedence. + +```yaml +# very_good.yaml +create: + description: A Very Good project. + org_name: com.very.good + publishable: true + template: core +``` + +With the file above, running `very_good create flutter_app my_app` behaves the same as running `very_good create flutter_app my_app --desc 'A Very Good project.' --org-name com.very.good --publishable --template core`. You can still override any of these values on the command line, for example `very_good create flutter_app my_app --org-name com.example` to use a different org name for a single run. + +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. This lets a single `very_good.yaml` at the repository root apply to commands run from any nested package. + ## Available templates Each subcommand maps to a specific project template. For detailed usage options diff --git a/test/helpers/test_multi_template_commands.dart b/test/helpers/test_multi_template_commands.dart index 58d24fa3d..d3100bd31 100644 --- a/test/helpers/test_multi_template_commands.dart +++ b/test/helpers/test_multi_template_commands.dart @@ -31,6 +31,7 @@ Future testMultiTemplateCommand({ final argResults = _MockArgResults(); final command = multiTemplatesCommand..argResultOverrides = argResults; + when(() => argResults.wasParsed(any())).thenReturn(true); when(() => argResults['template'] as String?).thenReturn(templateName); when( () => argResults['output-directory'] as String?, diff --git a/test/src/commands/create/commands/app_ui_package_test.dart b/test/src/commands/create/commands/app_ui_package_test.dart index 9ba3dc779..a6fa7d4bf 100644 --- a/test/src/commands/create/commands/app_ui_package_test.dart +++ b/test/src/commands/create/commands/app_ui_package_test.dart @@ -163,6 +163,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateAppUiPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/dart_cli_test.dart b/test/src/commands/create/commands/dart_cli_test.dart index 97db1dc73..6e0d19a2f 100644 --- a/test/src/commands/create/commands/dart_cli_test.dart +++ b/test/src/commands/create/commands/dart_cli_test.dart @@ -165,6 +165,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDartCLI( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/dart_package_test.dart b/test/src/commands/create/commands/dart_package_test.dart index 68aa00f60..62c210bed 100644 --- a/test/src/commands/create/commands/dart_package_test.dart +++ b/test/src/commands/create/commands/dart_package_test.dart @@ -160,6 +160,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDartPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/docs_site_test.dart b/test/src/commands/create/commands/docs_site_test.dart index 3ddbb6f6a..2e27cd17a 100644 --- a/test/src/commands/create/commands/docs_site_test.dart +++ b/test/src/commands/create/commands/docs_site_test.dart @@ -165,6 +165,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateDocsSite( logger: logger, generatorFromBundle: (_) async => generator, @@ -210,6 +211,39 @@ void main() { () => logger.info('Created a Very Good documentation site! 🦄'), ).called(1); }); + + test('uses default org name when omitted', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final argResults = _MockArgResults(); + final command = CreateDocsSite( + logger: logger, + generatorFromBundle: (_) async => generator, + )..argResultOverrides = argResults; + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['output-directory'] as String?, + ).thenReturn(tempDirectory.path); + when(() => argResults.rest).thenReturn(['my_docs_site']); + when(() => argResults['org-name'] as String?).thenReturn(null); + + final result = await command.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => generator.generate( + any(), + vars: { + 'project_name': 'my_docs_site', + 'description': '', + 'publishable': false, + 'org_name': 'my-org', + }, + logger: logger, + ), + ).called(1); + }); }); }); } diff --git a/test/src/commands/create/commands/flame_game_test.dart b/test/src/commands/create/commands/flame_game_test.dart index 5e451c766..203553908 100644 --- a/test/src/commands/create/commands/flame_game_test.dart +++ b/test/src/commands/create/commands/flame_game_test.dart @@ -174,6 +174,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlameGame( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/flutter_package_test.dart b/test/src/commands/create/commands/flutter_package_test.dart index 7f748b798..b709507ba 100644 --- a/test/src/commands/create/commands/flutter_package_test.dart +++ b/test/src/commands/create/commands/flutter_package_test.dart @@ -163,6 +163,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPackage( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/commands/flutter_plugin_test.dart b/test/src/commands/create/commands/flutter_plugin_test.dart index 150e238a1..d1c15c223 100644 --- a/test/src/commands/create/commands/flutter_plugin_test.dart +++ b/test/src/commands/create/commands/flutter_plugin_test.dart @@ -191,6 +191,7 @@ void main() { addTearDown(() => tempDirectory.deleteSync(recursive: true)); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPlugin( logger: logger, generatorFromBundle: (_) async => generator, @@ -291,6 +292,7 @@ void main() { ).thenAnswer((_) async => successResult); final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); final command = CreateFlutterPlugin( logger: logger, generatorFromBundle: (_) async => generator, diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index dbeb126e0..00ec0c326 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -9,9 +9,12 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:very_good_cli/src/commands/create/commands/create_subcommand.dart'; import 'package:very_good_cli/src/commands/create/templates/template.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; class _MockTemplate extends Mock implements Template {} +class _MockArgResults extends Mock implements ArgResults {} + class _MockLogger extends Mock implements Logger {} class _MockProgress extends Mock implements Progress {} @@ -1025,6 +1028,64 @@ Run "runner help" to see global options.'''; verifyNever(() => template2.onGenerateComplete(logger, any())); }); + test('falls back to the default template when unresolved', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['template'] as String?).thenReturn(null); + + final command = _TestCreateSubCommandMultiTemplate( + templates: templates, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.template.name, equals('template1')); + }); + + test('selects the template from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['template'] as String?).thenReturn('template1'); + + final command = + _TestCreateSubCommandMultiTemplate( + templates: templates, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + template: 'template2', + ); + + expect(command.template.name, equals('template2')); + }); + + test( + 'throws UsageException when the config template is not allowed', + () async { + final cwd = Directory.current; + addTearDown(() => Directory.current = cwd); + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('create:\n template: unknown'); + Directory.current = tempDirectory.path; + + await expectLater( + () => runner.run(['create_subcommand', 'test_project']), + throwsA( + isA().having( + (e) => e.message, + 'message', + '"unknown" is not an allowed value for option "--template".', + ), + ), + ); + }, + ); + group('validates template name', () { test('throws UsageException when --template is invalid', () async { await expectLater( @@ -1052,6 +1113,212 @@ Run "runner help" to see global options.'''; }); }); + group('very_good.yaml configuration', () { + late Template template; + late _MockBundle bundle; + + setUp(() { + bundle = _MockBundle(); + when(() => bundle.name).thenReturn('test'); + when(() => bundle.description).thenReturn('Test bundle'); + when(() => bundle.version).thenReturn(''); + template = _MockTemplate(); + when(() => template.name).thenReturn('test'); + when(() => template.bundle).thenReturn(bundle); + when( + () => template.onGenerateComplete(any(), any()), + ).thenAnswer((_) async {}); + }); + + group('resolveArg', () { + test('applies config value when the arg was not parsed', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['description'] as String?, + ).thenReturn('A Very Good Project created by Very Good CLI.'); + + final command = + _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + description: 'From config', + ); + + expect(command.projectDescription, equals('From config')); + }); + + test('prefers the CLI argument over the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('description')).thenReturn(true); + when( + () => argResults['description'] as String?, + ).thenReturn('From CLI'); + + final command = + _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + description: 'From config', + ); + + expect(command.projectDescription, equals('From CLI')); + }); + + test('falls back to the CLI default when neither is set', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['description'] as String?).thenReturn(null); + + final command = _TestCreateSubCommand( + template: template, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.projectDescription, isEmpty); + }); + + test('resolves the org name from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['org-name'] as String?).thenReturn(null); + + final command = + _TestCreateSubCommandWithOrgName( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig( + orgName: 'com.very.good', + ); + + expect(command.orgName, equals('com.very.good')); + }); + + test('resolves the publishable flag from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['publishable'] as bool?).thenReturn(null); + + final command = + _TestCreateSubCommandWithPublishable( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(publishable: true); + + expect(command.publishable, isTrue); + }); + }); + + group('run', () { + late Directory cwd; + late GeneratorHooks hooks; + late MasonGenerator generator; + late _TestCommandRunner runner; + + setUp(() { + cwd = Directory.current; + hooks = _MockGeneratorHooks(); + generator = _MockMasonGenerator(); + + when(() => generator.hooks).thenReturn(hooks); + when( + () => hooks.preGen( + vars: any(named: 'vars'), + onVarsChanged: any(named: 'onVarsChanged'), + ), + ).thenAnswer((_) async {}); + when( + () => generator.generate( + any(), + vars: any(named: 'vars'), + logger: any(named: 'logger'), + ), + ).thenAnswer((_) async => generatedFiles); + when(() => generator.id).thenReturn('generator_id'); + when(() => generator.description).thenReturn('generator description'); + + final command = _TestCreateSubCommandWithOrgName( + template: template, + logger: logger, + generatorFromBundle: (_) async => generator, + ); + runner = _TestCommandRunner(command: command); + }); + + tearDown(() { + Directory.current = cwd; + }); + + test('applies config values loaded from very_good.yaml', () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('create:\n org_name: com.very.good'); + Directory.current = tempDirectory.path; + + final result = await runner.run(['create_subcommand', 'test_project']); + + expect(result, equals(ExitCode.success.code)); + verify( + () => generator.generate( + any(), + vars: any( + named: 'vars', + that: isA>().having( + (vars) => vars['org_name'], + 'org_name', + 'com.very.good', + ), + ), + logger: logger, + ), + ).called(1); + }); + + test( + 'fails with exit code ${ExitCode.config.code} ' + 'when very_good.yaml is malformed', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + File( + path.join(tempDirectory.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + Directory.current = tempDirectory.path; + + final result = await runner.run([ + 'create_subcommand', + 'test_project', + ]); + + expect(result, equals(ExitCode.config.code)); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); + }); + }); + group('Publishable', () { const expectedUsage = ''' Usage: very_good create create_subcommand [arguments] diff --git a/test/src/very_good_config/fixtures/all_create_options.yaml b/test/src/very_good_config/fixtures/all_create_options.yaml new file mode 100644 index 000000000..90f433f8d --- /dev/null +++ b/test/src/very_good_config/fixtures/all_create_options.yaml @@ -0,0 +1,5 @@ +create: + description: A configured project. + org_name: com.very.good + publishable: true + template: my-template 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..160f64061 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -103,6 +103,52 @@ test: expect(config.test.collectCoverageFrom, equals('imports')); }); + test('parses all supported create options', () { + final fixture = File( + p.join( + 'test', + 'src', + 'very_good_config', + 'fixtures', + 'all_create_options.yaml', + ), + ); + final config = VeryGoodConfig.fromString(fixture.readAsStringSync()); + + expect(config.create.description, equals('A configured project.')); + expect(config.create.orgName, equals('com.very.good')); + expect(config.create.publishable, isTrue); + expect(config.create.template, equals('my-template')); + }); + + test('defaults create to an empty config when omitted', () { + final config = VeryGoodConfig.fromString('test:\n coverage: true'); + expect(config.create, equals(const VeryGoodCreateConfig())); + }); + + test('throws when create section is not a map', () { + expect( + () => VeryGoodConfig.fromString('create: foo'), + throwsA(isA()), + ); + }); + + test('throws when a create bool option has wrong type', () { + expect( + () => VeryGoodConfig.fromString( + 'create:\n publishable: yes-please', + ), + throwsA(isA()), + ); + }); + + test('throws when an unrecognized create key is present', () { + expect( + () => VeryGoodConfig.fromString('create:\n org_nam: foo'), + throwsA(isA()), + ); + }); + test('throws when test section is not a map', () { expect( () => VeryGoodConfig.fromString('test: foo'), @@ -313,6 +359,18 @@ test: equals(VeryGoodConfig(test: VeryGoodTestConfig(coverage: false))), ), ); + expect( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true)), + equals(VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true))), + ); + expect( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: true)), + isNot( + equals( + VeryGoodConfig(create: VeryGoodCreateConfig(publishable: false)), + ), + ), + ); }); }); @@ -329,6 +387,21 @@ test: }); }); + group(VeryGoodCreateConfig, () { + test('supports value equality', () { + expect( + VeryGoodCreateConfig(orgName: 'com.very.good', publishable: true), + equals( + VeryGoodCreateConfig(orgName: 'com.very.good', publishable: true), + ), + ); + expect( + VeryGoodCreateConfig(publishable: true), + isNot(equals(VeryGoodCreateConfig(publishable: false))), + ); + }); + }); + group(VeryGoodConfigParseException, () { test('provides message via toString', () { const exception = VeryGoodConfigParseException('bad thing'); From f7b735f6ebdd46bac156ddff7801c0af15dee5c6 Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Mon, 27 Jul 2026 16:32:37 +0200 Subject: [PATCH 2/8] fix: tests --- .../commands/create/create_subcommand_test.dart | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index 00ec0c326..d24eef234 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -1065,9 +1065,12 @@ Run "runner help" to see global options.'''; 'throws UsageException when the config template is not allowed', () async { final cwd = Directory.current; - addTearDown(() => Directory.current = cwd); final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); + addTearDown(() { + tempDirectory.deleteSync(recursive: true); + Directory.current = cwd; + }); + File( path.join(tempDirectory.path, veryGoodConfigFileName), ).writeAsStringSync('create:\n template: unknown'); @@ -1267,7 +1270,10 @@ Run "runner help" to see global options.'''; test('applies config values loaded from very_good.yaml', () async { final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); File( path.join(tempDirectory.path, veryGoodConfigFileName), ).writeAsStringSync('create:\n org_name: com.very.good'); @@ -1297,7 +1303,10 @@ Run "runner help" to see global options.'''; 'when very_good.yaml is malformed', () async { final tempDirectory = Directory.systemTemp.createTempSync(); - addTearDown(() => tempDirectory.deleteSync(recursive: true)); + addTearDown(() { + Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); + }); File( path.join(tempDirectory.path, veryGoodConfigFileName), ).writeAsStringSync('- not\n- a\n- map'); From 650fd75929d9a0af972fa6bb6e8700433b60abcf Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Mon, 27 Jul 2026 16:59:47 +0200 Subject: [PATCH 3/8] style: format create_subcommand.dart --- .../create/commands/create_subcommand.dart | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index be5ce312d..76fd1fd6c 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -284,13 +284,12 @@ abstract class CreateSubCommand extends Command { mixin OrgName on CreateSubCommand { /// Gets the organization name. String get orgName { - final orgName = - resolveArg( - argResults, - 'org-name', - createConfig.orgName, - fallbackValue: _defaultOrgName, - ); + final orgName = resolveArg( + argResults, + 'org-name', + createConfig.orgName, + fallbackValue: _defaultOrgName, + ); _validateOrgName(orgName); return orgName; } @@ -334,13 +333,12 @@ mixin MultiTemplates on CreateSubCommand { @nonVirtual @override Template get template { - final templateName = - resolveArg( - argResults, - 'template', - createConfig.template, - fallbackValue: defaultTemplateName, - ); + final templateName = resolveArg( + argResults, + 'template', + createConfig.template, + fallbackValue: defaultTemplateName, + ); return templates.firstWhere( (template) => template.name == templateName, @@ -358,11 +356,10 @@ mixin MultiTemplates on CreateSubCommand { /// to the brick generator. mixin Publishable on CreateSubCommand { /// Gets the publishable flag. - bool get publishable => - resolveArg( - argResults, - 'publishable', - createConfig.publishable, - fallbackValue: false, - ); + bool get publishable => resolveArg( + argResults, + 'publishable', + createConfig.publishable, + fallbackValue: false, + ); } From 65bafa99132ee8562d2e53121610b88eb65b0382 Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Mon, 27 Jul 2026 17:36:21 +0200 Subject: [PATCH 4/8] fix: tests --- test/src/commands/create/create_subcommand_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index d24eef234..c0c123604 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -1067,8 +1067,8 @@ Run "runner help" to see global options.'''; final cwd = Directory.current; final tempDirectory = Directory.systemTemp.createTempSync(); addTearDown(() { - tempDirectory.deleteSync(recursive: true); Directory.current = cwd; + tempDirectory.deleteSync(recursive: true); }); File( From 9bfe885f43647fac4176a06c8f1404d7450492e5 Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Tue, 28 Jul 2026 16:11:13 +0200 Subject: [PATCH 5/8] fix: ci issues --- .../commands/create/commands/create_subcommand.dart | 12 ++++-------- lib/src/commands/create/commands/docs_site.dart | 3 +-- test/src/commands/create/create_subcommand_test.dart | 2 +- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index 76fd1fd6c..5ccfcab76 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -170,8 +170,7 @@ abstract class CreateSubCommand extends Command { } /// Gets the description for the project. - String get projectDescription => resolveArg( - argResults, + String get projectDescription => argResults.resolve( 'description', createConfig.description, fallbackValue: '', @@ -284,8 +283,7 @@ abstract class CreateSubCommand extends Command { mixin OrgName on CreateSubCommand { /// Gets the organization name. String get orgName { - final orgName = resolveArg( - argResults, + final orgName = argResults.resolve( 'org-name', createConfig.orgName, fallbackValue: _defaultOrgName, @@ -333,8 +331,7 @@ mixin MultiTemplates on CreateSubCommand { @nonVirtual @override Template get template { - final templateName = resolveArg( - argResults, + final templateName = argResults.resolve( 'template', createConfig.template, fallbackValue: defaultTemplateName, @@ -356,8 +353,7 @@ mixin MultiTemplates on CreateSubCommand { /// to the brick generator. mixin Publishable on CreateSubCommand { /// Gets the publishable flag. - bool get publishable => resolveArg( - argResults, + bool get publishable => argResults.resolve( 'publishable', createConfig.publishable, fallbackValue: false, diff --git a/lib/src/commands/create/commands/docs_site.dart b/lib/src/commands/create/commands/docs_site.dart index eb58233e5..a4653e366 100644 --- a/lib/src/commands/create/commands/docs_site.dart +++ b/lib/src/commands/create/commands/docs_site.dart @@ -31,8 +31,7 @@ class CreateDocsSite extends CreateSubCommand with Publishable { Map getTemplateVars() { return { ...super.getTemplateVars(), - 'org_name': resolveArg( - argResults, + 'org_name': argResults.resolve( 'org-name', createConfig.orgName, fallbackValue: _defaultOrgName, diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index c0c123604..467622e6f 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -1133,7 +1133,7 @@ Run "runner help" to see global options.'''; ).thenAnswer((_) async {}); }); - group('resolveArg', () { + group('resolve', () { test('applies config value when the arg was not parsed', () { final argResults = _MockArgResults(); when(() => argResults.wasParsed(any())).thenReturn(false); From 2e1d2f9c370a3c5fd8327b1057ebe1f7f1bca11b Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Thu, 30 Jul 2026 12:27:39 +0200 Subject: [PATCH 6/8] refactor: move config load to VeryGoodConfig --- .../create/commands/create_subcommand.dart | 20 +-- .../dart/commands/dart_test_command.dart | 12 +- .../commands/check/commands/licenses.dart | 12 +- lib/src/commands/packages/commands/get.dart | 12 +- lib/src/commands/test/test.dart | 12 +- lib/src/mcp/mcp_server.dart | 24 ++- .../very_good_config/very_good_config.dart | 34 +++- .../very_good_config/very_good_config.g.dart | 39 ++--- site/docs/commands/create.md | 1 + .../create/create_subcommand_test.dart | 49 ++++++ .../dart/commands/dart_test_test.dart | 39 +++++ .../check/commands/licenses_test.dart | 151 ++++++++++++++++++ test/src/mcp/mcp_server_test.dart | 24 +++ .../fixtures/all_create_options.yaml | 1 + .../very_good_config_test.dart | 63 +++++++- 15 files changed, 416 insertions(+), 77 deletions(-) diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index fadbcadfe..a1f8f8ea5 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -207,17 +207,9 @@ abstract class CreateSubCommand extends Command { @override Future run() async { - try { - createConfig = VeryGoodConfig.loadFromClosestAncestor( - Directory.current, - ).create; - } on VeryGoodConfigParseException catch (e) { - logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', - ); - return ExitCode.config.code; - } + final config = VeryGoodConfig.load(Directory.current, logger: logger); + if (config == null) return ExitCode.config.code; + createConfig = config.create; final template = this.template; final bundle = template.bundle; @@ -381,5 +373,9 @@ mixin Publishable on CreateSubCommand { /// to the brick generator. mixin Workspace on CreateSubCommand { /// Gets the workspace flag. - bool get workspace => argResults['workspace'] as bool? ?? false; + bool get workspace => argResults.resolve( + 'workspace', + createConfig.workspace, + fallbackValue: false, + ); } diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 4cea82b6c..2f7ce8b51 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -369,16 +369,8 @@ This command should be run from the root of your Dart project.'''); return ExitCode.noInput.code; } - 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 config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final isDartInstalled = await _dartInstalled(logger: _logger); diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index 0c4cdbf1d..de03b25cf 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -211,16 +211,8 @@ class PackagesCheckLicensesCommand extends Command { 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 config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final options = PackagesCheckLicensesOptions.parse( _argResults, diff --git a/lib/src/commands/packages/commands/get.dart b/lib/src/commands/packages/commands/get.dart index f85acf4dc..8ab11d4b7 100644 --- a/lib/src/commands/packages/commands/get.dart +++ b/lib/src/commands/packages/commands/get.dart @@ -85,16 +85,8 @@ class PackagesGetCommand extends Command { 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 config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final options = PackagesGetOptions.parse(_argResults, config: config); diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index cfe0ce8ac..76cbe2018 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -458,16 +458,8 @@ This command should be run from the root of your Flutter project.'''); return ExitCode.noInput.code; } - 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 config = VeryGoodConfig.load(Directory(targetPath), logger: _logger); + if (config == null) return ExitCode.config.code; final isFlutterInstalled = await _flutterInstalled(logger: _logger); diff --git a/lib/src/mcp/mcp_server.dart b/lib/src/mcp/mcp_server.dart index 75f6be931..7d698ac5c 100644 --- a/lib/src/mcp/mcp_server.dart +++ b/lib/src/mcp/mcp_server.dart @@ -328,6 +328,20 @@ Only one value can be selected. ); } + /// The `create` subcommands that define the `--workspace` flag. + /// + /// `docs_site` is intentionally excluded because it does not mix in the + /// `Workspace` mixin and therefore rejects the flag. + static const _workspaceSubcommands = { + 'app_ui_package', + 'flame_game', + 'flutter_app', + 'flutter_package', + 'flutter_plugin', + 'dart_cli', + 'dart_package', + }; + List _parseCreate(Map args) { final subcommand = args['subcommand']! as String; final name = args['name']! as String; @@ -352,10 +366,12 @@ Only one value can be selected. if (args['publishable'] == true) { cliArgs.add('--publishable'); } - if (args['workspace'] == true) { - cliArgs.add('--workspace'); - } else if (args['workspace'] == false) { - cliArgs.add('--no-workspace'); + if (_workspaceSubcommands.contains(subcommand)) { + if (args['workspace'] == true) { + cliArgs.add('--workspace'); + } else if (args['workspace'] == false) { + cliArgs.add('--no-workspace'); + } } if (args['executable-name'] != null) { cliArgs.addAll(['--executable-name', args['executable-name']! as String]); diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index 24400453f..1cd2cc6d1 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -11,6 +11,7 @@ import 'package:args/args.dart'; import 'package:checked_yaml/checked_yaml.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as path; part 'very_good_config.g.dart'; @@ -127,6 +128,26 @@ class VeryGoodConfig extends Equatable { } } + /// Loads the closest [VeryGoodConfig] to [directory]. + /// + /// On a parse failure, logs a formatted error via [logger] and returns + /// `null`, signalling that the caller should exit with a config error. + /// + /// Centralizes the load-and-report contract shared by every command that + /// reads `very_good.yaml`, so the error message and exit behavior stay + /// consistent. + static VeryGoodConfig? load(Directory directory, {required Logger logger}) { + try { + return VeryGoodConfig.loadFromClosestAncestor(directory); + } on VeryGoodConfigParseException catch (e) { + logger.err( + 'Could not read `$veryGoodConfigFileName`.\n' + '${e.message}', + ); + return null; + } + } + /// Loads a [VeryGoodConfig] from the configuration file directly inside /// [directory], or `null` when the file does not exist. /// @@ -180,6 +201,7 @@ class VeryGoodCreateConfig extends Equatable { this.orgName, this.publishable, this.template, + this.workspace, }); /// Creates a [VeryGoodCreateConfig] from a decoded YAML/JSON [json] map. @@ -199,8 +221,18 @@ class VeryGoodCreateConfig extends Equatable { /// The template used to generate the project. final String? template; + /// Whether the generated project should resolve its dependencies from a + /// parent Pub workspace. + final bool? workspace; + @override - List get props => [description, orgName, publishable, template]; + List get props => [ + description, + orgName, + publishable, + template, + workspace, + ]; } /// {@template very_good_test_config} 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 dd601c37f..e463e5231 100644 --- a/lib/src/very_good_config/very_good_config.g.dart +++ b/lib/src/very_good_config/very_good_config.g.dart @@ -41,24 +41,27 @@ VeryGoodConfig _$VeryGoodConfigFromJson(Map json) => $checkedCreate( }, ); -VeryGoodCreateConfig _$VeryGoodCreateConfigFromJson(Map json) => $checkedCreate( - 'VeryGoodCreateConfig', - json, - ($checkedConvert) { - $checkKeys( - json, - allowedKeys: const ['description', 'org_name', 'publishable', 'template'], - ); - final val = VeryGoodCreateConfig( - description: $checkedConvert('description', (v) => v as String?), - orgName: $checkedConvert('org_name', (v) => v as String?), - publishable: $checkedConvert('publishable', (v) => v as bool?), - template: $checkedConvert('template', (v) => v as String?), - ); - return val; - }, - fieldKeyMap: const {'orgName': 'org_name'}, -); +VeryGoodCreateConfig _$VeryGoodCreateConfigFromJson(Map json) => + $checkedCreate('VeryGoodCreateConfig', json, ($checkedConvert) { + $checkKeys( + json, + allowedKeys: const [ + 'description', + 'org_name', + 'publishable', + 'template', + 'workspace', + ], + ); + final val = VeryGoodCreateConfig( + description: $checkedConvert('description', (v) => v as String?), + orgName: $checkedConvert('org_name', (v) => v as String?), + publishable: $checkedConvert('publishable', (v) => v as bool?), + template: $checkedConvert('template', (v) => v as String?), + workspace: $checkedConvert('workspace', (v) => v as bool?), + ); + return val; + }, fieldKeyMap: const {'orgName': 'org_name'}); VeryGoodTestConfig _$VeryGoodTestConfigFromJson(Map json) => $checkedCreate( 'VeryGoodTestConfig', diff --git a/site/docs/commands/create.md b/site/docs/commands/create.md index 06b40fef0..7b9bae7f8 100644 --- a/site/docs/commands/create.md +++ b/site/docs/commands/create.md @@ -79,6 +79,7 @@ create: org_name: com.very.good publishable: true template: core + workspace: true ``` With the file above, running `very_good create flutter_app my_app` behaves the same as running `very_good create flutter_app my_app --desc 'A Very Good project.' --org-name com.very.good --publishable --template core`. You can still override any of these values on the command line, for example `very_good create flutter_app my_app --org-name com.example` to use a different org name for a single run. diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index e6bb6a0da..6ad6d7304 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -1235,6 +1235,55 @@ Run "runner help" to see global options.'''; expect(command.publishable, isTrue); }); + + test('resolves the workspace flag from the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['workspace'] as bool?).thenReturn(null); + + final command = + _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(workspace: true); + + expect(command.workspace, isTrue); + }); + + test('prefers the CLI workspace flag over the config value', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults.wasParsed('workspace')).thenReturn(true); + when(() => argResults['workspace'] as bool?).thenReturn(false); + + final command = + _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + ) + ..argResultOverrides = argResults + ..createConfig = const VeryGoodCreateConfig(workspace: true); + + expect(command.workspace, isFalse); + }); + + test('falls back to false when neither workspace source is set', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['workspace'] as bool?).thenReturn(null); + + final command = _TestCreateSubCommandWithWorkspace( + template: template, + logger: logger, + generatorFromBundle: null, + )..argResultOverrides = argResults; + + expect(command.workspace, isFalse); + }); }); group('run', () { diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index d157d012b..d3a9562df 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -865,6 +865,11 @@ void main() { config: const VeryGoodConfig( dart: VeryGoodDartConfig( test: VeryGoodDartTestConfig( + concurrency: '8', + tags: 'unit', + optimization: false, + platform: 'chrome', + checkIgnore: false, minCoverage: '90', excludeCoverage: '**/*.g.dart', reportOn: ['lib/'], @@ -873,12 +878,46 @@ void main() { ), ), ); + expect(options.concurrency, equals('8')); + expect(options.tags, equals('unit')); + expect(options.optimizePerformance, isFalse); + expect(options.platform, equals('chrome')); + expect(options.checkIgnore, isFalse); expect(options.minCoverage, equals(90)); expect(options.excludeFromCoverage, equals('**/*.g.dart')); expect(options.reportOn, equals(['lib/'])); expect(options.fileReporter, equals('json:reports/tests.json')); }); + test('CLI arguments take precedence over config for all fields', () { + when(() => argResults.wasParsed(any())).thenReturn(true); + when(() => argResults['concurrency']).thenReturn('2'); + when(() => argResults['tags']).thenReturn('cli-tag'); + when(() => argResults['optimization']).thenReturn(true); + when(() => argResults['platform']).thenReturn('vm'); + when(() => argResults['check-ignore']).thenReturn(true); + + final options = DartTestOptions.parse( + argResults, + config: const VeryGoodConfig( + dart: VeryGoodDartConfig( + test: VeryGoodDartTestConfig( + concurrency: '8', + tags: 'unit', + optimization: false, + platform: 'chrome', + checkIgnore: false, + ), + ), + ), + ); + expect(options.concurrency, equals('2')); + expect(options.tags, equals('cli-tag')); + expect(options.optimizePerformance, isTrue); + expect(options.platform, equals('vm')); + expect(options.checkIgnore, isTrue); + }); + test('CLI argument takes precedence over config value', () { when(() => argResults.wasParsed(any())).thenReturn(false); when(() => argResults.wasParsed('min-coverage')).thenReturn(true); 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 b3045e9f6..3aba31eba 100644 --- a/test/src/commands/packages/commands/check/commands/licenses_test.dart +++ b/test/src/commands/packages/commands/check/commands/licenses_test.dart @@ -6,6 +6,7 @@ import 'dart:io'; +import 'package:args/args.dart'; import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,9 +15,12 @@ import 'package:pana/src/license_detection/license_detector.dart' as detector; import 'package:path/path.dart' as path; import 'package:test/test.dart'; import 'package:very_good_cli/src/commands/packages/commands/check/commands/commands.dart'; +import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; import '../../../../../../helpers/helpers.dart'; +class _MockArgResults extends Mock implements ArgResults {} + class _MockProgress extends Mock implements Progress {} class _MockResult extends Mock implements detector.Result {} @@ -1776,6 +1780,153 @@ and limitations under the License.'''); ).called(1); }), ); + + test('applies config values when args were not parsed', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-main']); + when(() => argResults['allowed']).thenReturn([]); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn(null); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['transitive'], + allowed: ['MIT'], + skipPackages: ['example'], + reporter: 'csv', + ), + ), + ), + ), + ); + + expect(options.ignoreRetrievalFailures, isTrue); + expect(options.dependencyTypes, equals({'transitive'})); + expect(options.allowedLicenses, equals(['MIT'])); + expect(options.skippedPackages, equals({'example'})); + expect(options.reporterOutputFormat, equals(ReporterOutputFormat.csv)); + }); + + test('resolves forbidden licenses from config', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(false); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-main']); + when(() => argResults['allowed']).thenReturn([]); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn(null); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + forbidden: ['GPL'], + ), + ), + ), + ), + ); + + expect(options.forbiddenLicenses, equals(['GPL'])); + }); + + test('CLI arguments take precedence over config values', () { + final argResults = _MockArgResults(); + when(() => argResults.wasParsed(any())).thenReturn(true); + when( + () => argResults['ignore-retrieval-failures'], + ).thenReturn(false); + when( + () => argResults['dependency-type'], + ).thenReturn(['direct-dev']); + when(() => argResults['allowed']).thenReturn(['BSD']); + when(() => argResults['forbidden']).thenReturn([]); + when(() => argResults['skip-packages']).thenReturn([]); + when(() => argResults['reporter']).thenReturn('text'); + + final options = PackagesCheckLicensesOptions.parse( + argResults, + config: const VeryGoodConfig( + packages: VeryGoodPackagesConfig( + check: VeryGoodPackagesCheckConfig( + licenses: VeryGoodPackagesCheckLicensesConfig( + ignoreRetrievalFailures: true, + dependencyType: ['transitive'], + allowed: ['MIT'], + reporter: 'csv', + ), + ), + ), + ), + ); + + expect(options.ignoreRetrievalFailures, isFalse); + expect(options.dependencyTypes, equals({'direct-dev'})); + expect(options.allowedLicenses, equals(['BSD'])); + expect(options.reporterOutputFormat, equals(ReporterOutputFormat.text)); + }); + + test( + 'applies skip-packages value from config', + withRunner((commandRunner, logger, pubUpdater, printLogs) async { + File( + path.join(tempDirectory.path, pubspecLockBasename), + ).writeAsStringSync(_validMultiplePubspecLockContent); + File( + path.join(tempDirectory.path, 'very_good.yaml'), + ).writeAsStringSync(''' +packages: + check: + licenses: + skip_packages: + - cli_completion +'''); + + when(() => logger.progress(any())).thenReturn(progress); + + when(() => packageConfig.packages).thenReturn({ + veryGoodTestRunnerConfigPackage, + cliCompletionConfigPackage, + }); + when(() => detectorResult.matches).thenReturn([mitLicenseMatch]); + + final result = await commandRunner.run([ + ...commandArguments, + tempDirectory.path, + ]); + + verify( + () => progress.update( + 'Collecting licenses from 1 out of 1 package', + ), + ).called(1); + verify( + () => progress.complete( + '''Retrieved 1 license from 1 package of type: MIT (1).''', + ), + ).called(1); + expect(result, equals(ExitCode.success.code)); + }), + ); }); }); } diff --git a/test/src/mcp/mcp_server_test.dart b/test/src/mcp/mcp_server_test.dart index e0b872c75..6c36ce9a3 100644 --- a/test/src/mcp/mcp_server_test.dart +++ b/test/src/mcp/mcp_server_test.dart @@ -262,6 +262,30 @@ void main() { ); }); + test('does not forward workspace args to docs_site', () async { + await sendRequest( + CallToolRequest.methodName, + _params( + CallToolRequest( + name: 'create', + arguments: { + 'subcommand': 'docs_site', + 'name': 'my_docs', + 'workspace': true, + }, + ), + ), + ); + + final capturedArgs = + verify(() => mockCommandRunner.run(captureAny())).captured.first + as List; + expect( + capturedArgs, + equals(['create', 'docs_site', 'my_docs']), + ); + }); + test('handles command runner failure', () async { when( () => mockCommandRunner.run(any()), diff --git a/test/src/very_good_config/fixtures/all_create_options.yaml b/test/src/very_good_config/fixtures/all_create_options.yaml index 90f433f8d..d0af1ba75 100644 --- a/test/src/very_good_config/fixtures/all_create_options.yaml +++ b/test/src/very_good_config/fixtures/all_create_options.yaml @@ -3,3 +3,4 @@ create: org_name: com.very.good publishable: true template: my-template + workspace: true 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 cd267877b..387c758cf 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -1,14 +1,17 @@ -// Ensures we don't have to use const constructors -// and instances are created at runtime. +// Ensures instances are created at runtime. // ignore_for_file: prefer_const_constructors // ignore_for_file: prefer_const_literals_to_create_immutables import 'dart:io'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; import 'package:test/test.dart'; import 'package:very_good_cli/src/very_good_config/very_good_config.dart'; +class _MockLogger extends Mock implements Logger {} + void main() { group(VeryGoodConfig, () { group('fromString', () { @@ -117,6 +120,7 @@ test: expect(config.create.orgName, equals('com.very.good')); expect(config.create.publishable, isTrue); expect(config.create.template, equals('my-template')); + expect(config.create.workspace, isTrue); }); test('defaults create to an empty config when omitted', () { @@ -562,6 +566,61 @@ test: }); }); + group('load', () { + late Directory tempDir; + late Logger logger; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('very_good_config_'); + logger = _MockLogger(); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('returns the parsed config when very_good.yaml is valid', () { + File(p.join(tempDir.path, veryGoodConfigFileName)).writeAsStringSync(''' +packages: + get: + recursive: true +'''); + + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, isNotNull); + expect(config!.packages.get.recursive, isTrue); + verifyNever(() => logger.err(any())); + }); + + test('returns an empty config when no very_good.yaml exists', () { + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, equals(VeryGoodConfig.empty)); + verifyNever(() => logger.err(any())); + }); + + test( + 'logs an error and returns null when very_good.yaml is malformed', + () { + File( + p.join(tempDir.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, isNull); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); + }); + test('supports value equality', () { expect(VeryGoodConfig(), equals(VeryGoodConfig())); expect( From 98ab617cc21296c3d92abeac40865a45582270ed Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Thu, 30 Jul 2026 13:34:09 +0200 Subject: [PATCH 7/8] chore: review --- .../create/commands/create_subcommand.dart | 14 ++- lib/src/commands/create/create.dart | 6 ++ .../dart/commands/dart_test_command.dart | 2 +- .../commands/check/commands/licenses.dart | 9 +- lib/src/commands/test/test.dart | 2 +- .../very_good_config/very_good_config.dart | 74 +++++++------- .../create/create_subcommand_test.dart | 3 +- .../very_good_config_test.dart | 97 +++++++++---------- 8 files changed, 102 insertions(+), 105 deletions(-) diff --git a/lib/src/commands/create/commands/create_subcommand.dart b/lib/src/commands/create/commands/create_subcommand.dart index a1f8f8ea5..9e8da3ea7 100644 --- a/lib/src/commands/create/commands/create_subcommand.dart +++ b/lib/src/commands/create/commands/create_subcommand.dart @@ -346,9 +346,17 @@ mixin MultiTemplates on CreateSubCommand { return templates.firstWhere( (template) => template.name == templateName, - orElse: () => usageException( - '"$templateName" is not an allowed value for option "--template".', - ), + orElse: () { + // When the value came from `very_good.yaml` rather than the command + // line, point the user at the config key instead of the CLI option so + // they debug the right place. + final source = argResults.wasParsed('template') + ? 'option "--template"' + : 'the `create.template` key in `$veryGoodConfigFileName`'; + usageException( + '"$templateName" is not an allowed value for $source.', + ); + }, ); } } diff --git a/lib/src/commands/create/create.dart b/lib/src/commands/create/create.dart index c2ed8c6c2..f598a94a4 100644 --- a/lib/src/commands/create/create.dart +++ b/lib/src/commands/create/create.dart @@ -93,4 +93,10 @@ class CreateCommand extends Command { @override String get invocation => 'very_good create [arguments]'; + + /// The names of the subcommands that accept the `--workspace` flag. + Set get workspaceSubcommandNames => subcommands.entries + .where((entry) => entry.value is Workspace) + .map((entry) => entry.key) + .toSet(); } diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 2f7ce8b51..5fe0cc6d0 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -283,7 +283,7 @@ class DartTestCommand extends Command { help: 'Whether to collect coverage from imported files only or all ' 'files.', - allowed: ['imports', 'all'], + allowed: collectCoverageFromAllowedValues, defaultsTo: 'imports', valueHelp: 'imports|all', ) diff --git a/lib/src/commands/packages/commands/check/commands/licenses.dart b/lib/src/commands/packages/commands/check/commands/licenses.dart index de03b25cf..07c361d82 100644 --- a/lib/src/commands/packages/commands/check/commands/licenses.dart +++ b/lib/src/commands/packages/commands/check/commands/licenses.dart @@ -157,12 +157,7 @@ class PackagesCheckLicensesCommand extends Command { ..addMultiOption( 'dependency-type', help: 'The type of dependencies to check licenses for.', - allowed: [ - 'direct-main', - 'direct-dev', - 'direct-overridden', - 'transitive', - ], + allowed: dependencyTypeAllowedValues, allowedHelp: { 'direct-main': 'Check for direct main dependencies.', 'direct-dev': 'Check for direct dev dependencies.', @@ -183,7 +178,7 @@ class PackagesCheckLicensesCommand extends Command { ..addOption( 'reporter', help: 'Lists all licenses.', - allowed: ['text', 'csv'], + allowed: reporterAllowedValues, allowedHelp: { 'text': 'Lists licenses without a specific format.', 'csv': 'Lists licenses in a CSV format.', diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index 76cbe2018..3e9dd091e 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -333,7 +333,7 @@ class TestCommand extends Command { help: 'Whether to collect coverage from imported files only or all ' 'files.', - allowed: ['imports', 'all'], + allowed: collectCoverageFromAllowedValues, defaultsTo: 'imports', valueHelp: 'imports|all', ) diff --git a/lib/src/very_good_config/very_good_config.dart b/lib/src/very_good_config/very_good_config.dart index 1cd2cc6d1..c3329c9e0 100644 --- a/lib/src/very_good_config/very_good_config.dart +++ b/lib/src/very_good_config/very_good_config.dart @@ -107,42 +107,23 @@ class VeryGoodConfig extends Equatable { /// Loads the closest [VeryGoodConfig] by searching [directory] and each of /// its ancestors, from the innermost directory outward. /// - /// [directory] is resolved to an absolute path before the walk, so a relative - /// [directory] is searched relative to the current working directory. - /// - /// This allows a single repository-wide `very_good.yaml` at the project root - /// to apply to commands run from any nested package directory. The first - /// configuration file encountered wins; ancestors are not merged. - /// /// Returns [VeryGoodConfig.empty] when no configuration file is found. - /// Throws a [VeryGoodConfigParseException] when the closest file exists but - /// cannot be parsed. - factory VeryGoodConfig.loadFromClosestAncestor(Directory directory) { - var current = directory.absolute; - while (true) { - final config = _loadFromDirectory(current); - if (config != null) return config; - final parent = current.parent; - if (parent.path == current.path) return VeryGoodConfig.empty; - current = parent; - } - } - - /// Loads the closest [VeryGoodConfig] to [directory]. /// /// On a parse failure, logs a formatted error via [logger] and returns /// `null`, signalling that the caller should exit with a config error. - /// - /// Centralizes the load-and-report contract shared by every command that - /// reads `very_good.yaml`, so the error message and exit behavior stay - /// consistent. static VeryGoodConfig? load(Directory directory, {required Logger logger}) { try { - return VeryGoodConfig.loadFromClosestAncestor(directory); + var current = directory.absolute; + while (true) { + final config = _loadFromDirectory(current); + if (config != null) return config; + final parent = current.parent; + if (parent.path == current.path) return VeryGoodConfig.empty; + current = parent; + } } on VeryGoodConfigParseException catch (e) { logger.err( - 'Could not read `$veryGoodConfigFileName`.\n' - '${e.message}', + 'Could not read `$veryGoodConfigFileName`.\n${e.message}', ); return null; } @@ -717,25 +698,37 @@ String? _minCoverage(Object? value) { return asString; } +/// The values accepted by the `collect-coverage-from` option, shared between +/// the CLI argument parser and the `very_good.yaml` validator so they cannot +/// drift apart. +const collectCoverageFromAllowedValues = ['imports', 'all']; + +/// The dependency types accepted by `very_good packages check licenses`, shared +/// between the CLI argument parser and the `very_good.yaml` validator so they +/// cannot drift apart. +const dependencyTypeAllowedValues = [ + 'direct-main', + 'direct-dev', + 'direct-overridden', + 'transitive', +]; + +/// The values accepted by the license `reporter` option, shared between the +/// CLI argument parser and the `very_good.yaml` validator so they cannot drift +/// apart. +const reporterAllowedValues = ['text', 'csv']; + /// Validates and returns the `collect_coverage_from` value. /// /// Accepts only `imports` or `all`. String? _collectCoverageFrom(Object? value) { if (value == null) return null; - if (value != 'imports' && value != 'all') { + if (!collectCoverageFromAllowedValues.contains(value)) { throw FormatException('Expected `imports` or `all` but got `$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. @@ -743,9 +736,10 @@ List? _dependencyType(Object? value) { final values = _stringList(value); if (values == null) return null; for (final value in values) { - if (!_dependencyTypes.contains(value)) { + if (!dependencyTypeAllowedValues.contains(value)) { throw FormatException( - 'Expected one of ${_dependencyTypes.join(', ')} but got `$value`.', + 'Expected one of ${dependencyTypeAllowedValues.join(', ')} ' + 'but got `$value`.', ); } } @@ -757,7 +751,7 @@ List? _dependencyType(Object? value) { /// Accepts only `text` or `csv`. String? _reporter(Object? value) { if (value == null) return null; - if (value != 'text' && value != 'csv') { + if (!reporterAllowedValues.contains(value)) { throw FormatException('Expected `text` or `csv` but got `$value`.'); } return value as String; diff --git a/test/src/commands/create/create_subcommand_test.dart b/test/src/commands/create/create_subcommand_test.dart index 6ad6d7304..955c07e90 100644 --- a/test/src/commands/create/create_subcommand_test.dart +++ b/test/src/commands/create/create_subcommand_test.dart @@ -1091,7 +1091,8 @@ Run "runner help" to see global options.'''; isA().having( (e) => e.message, 'message', - '"unknown" is not an allowed value for option "--template".', + '"unknown" is not an allowed value for the `create.template` ' + 'key in `$veryGoodConfigFileName`.', ), ), ); 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 387c758cf..b85408ece 100644 --- a/test/src/very_good_config/very_good_config_test.dart +++ b/test/src/very_good_config/very_good_config_test.dart @@ -497,14 +497,16 @@ packages: }); }); - group('loadFromClosestAncestor', () { + group('load', () { late Directory tempDir; late Directory nestedDir; + late Logger logger; setUp(() { tempDir = Directory.systemTemp.createTempSync('very_good_config_'); nestedDir = Directory(p.join(tempDir.path, 'packages', 'foo')) ..createSync(recursive: true); + logger = _MockLogger(); }); tearDown(() { @@ -513,6 +515,20 @@ packages: } }); + test('returns the parsed config when very_good.yaml is valid', () { + File(p.join(tempDir.path, veryGoodConfigFileName)).writeAsStringSync(''' +packages: + get: + recursive: true +'''); + + final config = VeryGoodConfig.load(tempDir, logger: logger); + + expect(config, isNotNull); + expect(config!.packages.get.recursive, isTrue); + verifyNever(() => logger.err(any())); + }); + test('reads config from the starting directory', () { File(p.join(nestedDir.path, veryGoodConfigFileName)).writeAsStringSync( ''' @@ -520,8 +536,10 @@ test: min_coverage: 80 ''', ); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('80')); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config?.test.minCoverage, equals('80')); }); test('reads config from an ancestor directory', () { @@ -529,8 +547,10 @@ test: test: min_coverage: 90 '''); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('90')); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config?.test.minCoverage, equals('90')); }); test('prefers the closest config over an ancestor', () { @@ -544,55 +564,10 @@ test: min_coverage: 80 ''', ); - final config = VeryGoodConfig.loadFromClosestAncestor(nestedDir); - expect(config.test.minCoverage, equals('80')); - }); - test('returns empty config when no file is found in any ancestor', () { - expect( - VeryGoodConfig.loadFromClosestAncestor(nestedDir), - equals(VeryGoodConfig.empty), - ); - }); + final config = VeryGoodConfig.load(nestedDir, logger: logger); - test('rethrows parse exception when the closest file is malformed', () { - File( - p.join(nestedDir.path, veryGoodConfigFileName), - ).writeAsStringSync('- not\n- a\n- map'); - expect( - () => VeryGoodConfig.loadFromClosestAncestor(nestedDir), - throwsA(isA()), - ); - }); - }); - - group('load', () { - late Directory tempDir; - late Logger logger; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('very_good_config_'); - logger = _MockLogger(); - }); - - tearDown(() { - if (tempDir.existsSync()) { - tempDir.deleteSync(recursive: true); - } - }); - - test('returns the parsed config when very_good.yaml is valid', () { - File(p.join(tempDir.path, veryGoodConfigFileName)).writeAsStringSync(''' -packages: - get: - recursive: true -'''); - - final config = VeryGoodConfig.load(tempDir, logger: logger); - - expect(config, isNotNull); - expect(config!.packages.get.recursive, isTrue); - verifyNever(() => logger.err(any())); + expect(config?.test.minCoverage, equals('80')); }); test('returns an empty config when no very_good.yaml exists', () { @@ -619,6 +594,24 @@ packages: ).called(1); }, ); + + test( + 'logs an error and returns null when the closest file is malformed', + () { + File( + p.join(nestedDir.path, veryGoodConfigFileName), + ).writeAsStringSync('- not\n- a\n- map'); + + final config = VeryGoodConfig.load(nestedDir, logger: logger); + + expect(config, isNull); + verify( + () => logger.err( + any(that: contains('Could not read `very_good.yaml`')), + ), + ).called(1); + }, + ); }); test('supports value equality', () { From e1a8c3cb63cfe7f948134c76746cc8281b59ddda Mon Sep 17 00:00:00 2001 From: Marcos Sevilla Date: Thu, 30 Jul 2026 15:12:57 +0200 Subject: [PATCH 8/8] fix(create): remove unused workspaceSubcommandNames getter The getter was never referenced, breaking the 100% coverage gate. --- lib/src/commands/create/create.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/src/commands/create/create.dart b/lib/src/commands/create/create.dart index f598a94a4..c2ed8c6c2 100644 --- a/lib/src/commands/create/create.dart +++ b/lib/src/commands/create/create.dart @@ -93,10 +93,4 @@ class CreateCommand extends Command { @override String get invocation => 'very_good create [arguments]'; - - /// The names of the subcommands that accept the `--workspace` flag. - Set get workspaceSubcommandNames => subcommands.entries - .where((entry) => entry.value is Workspace) - .map((entry) => entry.key) - .toSet(); }