From 3c6cf664cb566fe3cca9708e1ab0d37e4ab1bbce Mon Sep 17 00:00:00 2001 From: Christer Date: Fri, 20 Mar 2026 10:52:52 +0100 Subject: [PATCH 1/8] fix(config): Updated doc comments --- packages/config/lib/src/config/options.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index 1adcb8d..bc6bc91 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -53,7 +53,7 @@ class OptionGroup { /// Validates the configuration option definitions as a group. /// - /// This method is called by [prepareOptionsForParsing] to validate + /// This method is called by [validateOptions] to validate /// the configuration option definitions as a group. /// Throws an error if any definition is invalid as part of this group. /// @@ -281,7 +281,7 @@ abstract class ConfigOptionBase implements OptionDefinition { /// Validates the configuration option definition. /// - /// This method is called by [prepareOptionsForParsing] to validate + /// This method is called by [validateOptions] to validate /// the configuration option definition. /// Throws an error if the definition is invalid. /// @@ -763,6 +763,12 @@ void prepareOptionsForParsing( addOptionsToParser(argNameOpts, argParser, addGroupSeparators: true); } +/// Validates a set of options. +/// +/// Throws an [OptionDefinitionError] if the options are invalid. +/// +/// Returns an iterable of the options that are specifyable with an argument +/// name on the command line. Iterable validateOptions( final Iterable options, ) { @@ -908,6 +914,9 @@ extension PrepareOptions on Iterable { void prepareForParsing(final ArgParser argParser) => prepareOptionsForParsing(this, argParser); + /// Validates these options. + void validate() => validateOptions(this); + /// Returns the usage help text for these options. String get usage { final parser = ArgParser(); From f058fecfc5f2c877b11d9a49dff9bf00a5a6911d Mon Sep 17 00:00:00 2001 From: Christer Date: Sat, 21 Mar 2026 13:41:40 +0100 Subject: [PATCH 2/8] feat(config): A findOption method on option list extension Mirrors the Configuration.findOption method. --- packages/config/lib/src/config/options.dart | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index bc6bc91..a766178 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -923,4 +923,26 @@ extension PrepareOptions on Iterable { prepareForParsing(parser); return parser.usage; } + + /// Returns the option definition for the given enum name, + /// or any provided argument name, position, + /// environment variable name, or configuration key. + /// The first one that matches is returned, or null if none match. + /// + /// The recommended practice is to define options as enums and identify them by the enum name. + OptionDefinition? findOption({ + final String? enumName, + final String? argName, + final int? argPos, + final String? envName, + final String? configKey, + }) { + return firstWhereOrNull((final o) { + return (enumName != null && o is Enum && (o as Enum).name == enumName) || + (argName != null && o.option.argName == argName) || + (argPos != null && o.option.argPos == argPos) || + (envName != null && o.option.envName == envName) || + (configKey != null && o.option.configKey == configKey); + }); + } } From 97f84b604d82373d8e03003d36c8a090a4871099 Mon Sep 17 00:00:00 2001 From: Christer Date: Sun, 29 Mar 2026 17:21:55 +0200 Subject: [PATCH 3/8] fix(config): Added validation for allowed option name chars --- packages/config/lib/src/config/options.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index a766178..cad47ee 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -294,6 +294,16 @@ abstract class ConfigOptionBase implements OptionDefinition { "An argument option can't have an abbreviation but not a full name"); } + if (argName?.contains('=') == true || argAbbrev?.contains('=') == true) { + throw OptionDefinitionError( + this, "An argument option name / abbreviation can't contain '='"); + } + if (argName?.startsWith('-') == true || + argAbbrev?.startsWith('-') == true) { + throw OptionDefinitionError( + this, "An argument option name / abbreviation can't start with '-'"); + } + if ((fromDefault != null || defaultsTo != null) && mandatory) { throw OptionDefinitionError( this, "Mandatory options can't have default values"); From b6514f6e5361090e1589cfefc47fc692323aa295 Mon Sep 17 00:00:00 2001 From: Christer Date: Wed, 14 Oct 2026 09:08:40 +0200 Subject: [PATCH 4/8] feat(config): Store unparsed args list in Configuration --- .../config/lib/src/config/configuration.dart | 10 +- .../test/config/configuration_test.dart | 280 ++++++++++++++++++ 2 files changed, 288 insertions(+), 2 deletions(-) diff --git a/packages/config/lib/src/config/configuration.dart b/packages/config/lib/src/config/configuration.dart index dc00deb..42757fd 100644 --- a/packages/config/lib/src/config/configuration.dart +++ b/packages/config/lib/src/config/configuration.dart @@ -46,6 +46,11 @@ class Configuration { final Map _config; final List _errors; + /// The remaining positional arguments that were not parsed as options. + /// If non-empty and [ignoreUnexpectedPositionalArgs] is false, + /// such arguments will also be registered as errors. + late final List remainingPosArgs; + /// Creates a configuration with the provided option values. /// /// This does not throw upon value parsing or validation errors, @@ -64,7 +69,8 @@ class Configuration { required final Configuration configuration, }) : _options = List.from(configuration._options), _config = Map.from(configuration._config), - _errors = List.from(configuration._errors); + _errors = List.from(configuration._errors), + remainingPosArgs = List.from(configuration.remainingPosArgs); /// {@template Configuration.resolveNoExcept} /// Creates a configuration with option values resolved from the provided context. @@ -347,7 +353,7 @@ class Configuration { _validateGroups(optionGroups); - final remainingPosArgs = posArgs.restAsList(); + remainingPosArgs = posArgs.restAsList(); if (remainingPosArgs.isNotEmpty && !ignoreUnexpectedPositionalArgs) { _errors.add( "Unexpected positional argument(s): '${remainingPosArgs.join("', '")}'"); diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index db32801..6fa8a48 100644 --- a/packages/config/test/config/configuration_test.dart +++ b/packages/config/test/config/configuration_test.dart @@ -1064,6 +1064,286 @@ void main() async { }); }); + group('Given a configuration with one positional option', () { + const positionalOpt = StringOption( + argPos: 0, + ); + const projectOpt = StringOption( + argName: 'project', + ); + final options = [positionalOpt, projectOpt]; + + group('when every positional argument is consumed', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['pos-arg', '--project', '123'], + ); + }); + + test('then remaining positional arguments are empty', () { + expect(config.remainingPosArgs, isEmpty); + }); + + test('then the positional option has its argument', () { + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + }); + }); + + group('when no arguments are provided', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: [], + ); + }); + + test('then remaining positional arguments are empty', () { + expect(config.remainingPosArgs, isEmpty); + }); + }); + + group('when an extra positional argument is provided', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['pos-arg', 'extra'], + ); + }); + + test( + 'then remaining positional arguments contain the extra argument', + () { + expect(config.remainingPosArgs, equals(['extra'])); + }, + ); + + test('then the extra argument is reported as unexpected', () { + expect( + config.errors, + equals(["Unexpected positional argument(s): 'extra'"]), + ); + }); + + test('then the positional option still has its argument', () { + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + }); + }); + + group('when several extra positional arguments are provided', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['pos-arg', 'extra-a', 'extra-b'], + ); + }); + + test('then remaining positional arguments keep their order', () { + expect(config.remainingPosArgs, equals(['extra-a', 'extra-b'])); + }); + + test('then every extra argument is reported as unexpected', () { + expect( + config.errors, + equals(["Unexpected positional argument(s): 'extra-a', 'extra-b'"]), + ); + }); + }); + + group('when extra positional arguments are ignored', () { + late Configuration config; + + setUp(() { + config = Configuration.resolve( + options: options, + args: ['pos-arg', '--project', '123', 'extra-a', 'extra-b'], + ignoreUnexpectedPositionalArgs: true, + ); + }); + + test('then remaining positional arguments keep their order', () { + expect(config.remainingPosArgs, equals(['extra-a', 'extra-b'])); + }); + + test('then there are no resolution errors', () { + expect(config.errors, isEmpty); + }); + + test('then named and positional options keep their values', () { + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + expect(config.optionalValue(projectOpt), equals('123')); + }); + }); + + group( + 'when the positional option is preset and a positional argument remains', + () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['from-args', 'extra'], + presetValues: {positionalOpt: 'preset'}, + ignoreUnexpectedPositionalArgs: true, + ); + }); + + test( + 'then remaining positional arguments include the unconsumed arguments', + () { + expect(config.remainingPosArgs, equals(['from-args', 'extra'])); + }, + ); + + test('then the positional option keeps the preset value', () { + expect(config.optionalValue(positionalOpt), equals('preset')); + }); + }, + ); + }); + + group('Given a configuration with only a named option', () { + const projectOpt = StringOption( + argName: 'project', + ); + + group('when positional arguments are provided', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: [projectOpt], + args: ['--project', '123', 'left', 'over'], + ); + }); + + test('then remaining positional arguments contain those arguments', () { + expect(config.remainingPosArgs, equals(['left', 'over'])); + }); + + test('then those arguments are reported as unexpected', () { + expect( + config.errors, + equals(["Unexpected positional argument(s): 'left', 'over'"]), + ); + }); + + test('then the named option keeps its value', () { + expect(config.optionalValue(projectOpt), equals('123')); + }); + }); + }); + + group('Given two positional options supplied by name', () { + const firstOpt = StringOption( + argName: 'first', + argPos: 0, + ); + const secondOpt = StringOption( + argName: 'second', + argPos: 1, + ); + final options = [firstOpt, secondOpt]; + + group('when one positional argument follows the named arguments', () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['--first', '1st-arg', '--second', '2nd-arg', 'extra'], + ignoreUnexpectedPositionalArgs: true, + ); + }); + + test('then remaining positional arguments contain that argument', () { + expect(config.remainingPosArgs, equals(['extra'])); + }); + + test('then both options keep their named values', () { + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + }); + + group( + 'when the first option is named and further positional arguments follow', + () { + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: options, + args: ['--first', '1st-arg', '2nd-arg', 'extra'], + ignoreUnexpectedPositionalArgs: true, + ); + }); + + test('then the next positional argument fills the second option', () { + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test('then the last positional argument remains', () { + expect(config.remainingPosArgs, equals(['extra'])); + }); + }, + ); + }); + + group( + 'Given a configuration copied from one with remaining positional arguments', + () { + late Configuration config; + + setUp(() { + const positionalOpt = StringOption(argPos: 0); + config = Configuration.resolveNoExcept( + options: [positionalOpt], + args: ['pos-arg', 'extra-a', 'extra-b'], + ignoreUnexpectedPositionalArgs: true, + ); + }); + + test('then the copy has the same remaining positional arguments', () { + final copy = Configuration.from(configuration: config); + + expect(copy.remainingPosArgs, equals(['extra-a', 'extra-b'])); + }); + + test('then changing the copy does not change the original', () { + final copy = Configuration.from(configuration: config); + + copy.remainingPosArgs.add('mutated'); + + expect(config.remainingPosArgs, equals(['extra-a', 'extra-b'])); + expect( + copy.remainingPosArgs, + equals(['extra-a', 'extra-b', 'mutated']), + ); + }); + }, + ); + + group('Given a configuration built from preset values', () { + test('then remaining positional arguments are empty', () { + const projectOpt = StringOption(argName: 'project'); + final config = Configuration.fromValues( + values: {projectOpt: '123'}, + ); + + expect(config.remainingPosArgs, isEmpty); + }); + }); + group('Given two options that have aliases', () { const firstOpt = StringOption( argName: 'first', From bff621d69c13c941c0e5f98f2b0b84436bab2a3f Mon Sep 17 00:00:00 2001 From: Christer Date: Wed, 7 Oct 2026 21:10:00 +0200 Subject: [PATCH 5/8] refactor(config): Moved config_parser.dart to subdir --- packages/config/lib/config.dart | 1 + .../{config => args_compatibility}/config_parser.dart | 10 +++++----- packages/config/lib/src/config/config.dart | 1 - .../args_compatibility/allow_anything_test.dart | 0 .../{config => }/args_compatibility/args_test.dart | 0 .../args_compatibility/command_parse_test.dart | 0 .../args_compatibility/command_runner_test.dart | 0 .../{config => }/args_compatibility/command_test.dart | 0 .../args_compatibility/parse_performance_test.dart | 0 .../{config => }/args_compatibility/parse_test.dart | 0 .../{config => }/args_compatibility/test_utils.dart | 0 .../args_compatibility/trailing_options_test.dart | 0 .../{config => }/args_compatibility/usage_test.dart | 0 .../{config => }/args_compatibility/utils_test.dart | 0 14 files changed, 6 insertions(+), 6 deletions(-) rename packages/config/lib/src/{config => args_compatibility}/config_parser.dart (98%) rename packages/config/test/{config => }/args_compatibility/allow_anything_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/args_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/command_parse_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/command_runner_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/command_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/parse_performance_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/parse_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/test_utils.dart (100%) rename packages/config/test/{config => }/args_compatibility/trailing_options_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/usage_test.dart (100%) rename packages/config/test/{config => }/args_compatibility/utils_test.dart (100%) diff --git a/packages/config/lib/config.dart b/packages/config/lib/config.dart index 1c7e7ba..50dbdb7 100644 --- a/packages/config/lib/config.dart +++ b/packages/config/lib/config.dart @@ -1,3 +1,4 @@ export 'package:args/command_runner.dart' show UsageException; +export 'src/args_compatibility/config_parser.dart'; export 'src/config/config.dart'; diff --git a/packages/config/lib/src/config/config_parser.dart b/packages/config/lib/src/args_compatibility/config_parser.dart similarity index 98% rename from packages/config/lib/src/config/config_parser.dart rename to packages/config/lib/src/args_compatibility/config_parser.dart index 84323b6..68f564b 100644 --- a/packages/config/lib/src/config/config_parser.dart +++ b/packages/config/lib/src/args_compatibility/config_parser.dart @@ -2,11 +2,11 @@ import 'dart:io' show Platform; import 'package:args/args.dart'; -import 'configuration.dart'; -import 'configuration_broker.dart'; -import 'option_types.dart'; -import 'options.dart'; -import 'source_type.dart'; +import '../config/configuration.dart'; +import '../config/configuration_broker.dart'; +import '../config/option_types.dart'; +import '../config/options.dart'; +import '../config/source_type.dart'; /// A backwards compatible parser for command args and configuration sources. /// diff --git a/packages/config/lib/src/config/config.dart b/packages/config/lib/src/config/config.dart index 64a7a7b..6f9839e 100644 --- a/packages/config/lib/src/config/config.dart +++ b/packages/config/lib/src/config/config.dart @@ -1,4 +1,3 @@ -export 'config_parser.dart'; export 'config_source.dart'; export 'config_source_provider.dart'; export 'configuration.dart'; diff --git a/packages/config/test/config/args_compatibility/allow_anything_test.dart b/packages/config/test/args_compatibility/allow_anything_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/allow_anything_test.dart rename to packages/config/test/args_compatibility/allow_anything_test.dart diff --git a/packages/config/test/config/args_compatibility/args_test.dart b/packages/config/test/args_compatibility/args_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/args_test.dart rename to packages/config/test/args_compatibility/args_test.dart diff --git a/packages/config/test/config/args_compatibility/command_parse_test.dart b/packages/config/test/args_compatibility/command_parse_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/command_parse_test.dart rename to packages/config/test/args_compatibility/command_parse_test.dart diff --git a/packages/config/test/config/args_compatibility/command_runner_test.dart b/packages/config/test/args_compatibility/command_runner_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/command_runner_test.dart rename to packages/config/test/args_compatibility/command_runner_test.dart diff --git a/packages/config/test/config/args_compatibility/command_test.dart b/packages/config/test/args_compatibility/command_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/command_test.dart rename to packages/config/test/args_compatibility/command_test.dart diff --git a/packages/config/test/config/args_compatibility/parse_performance_test.dart b/packages/config/test/args_compatibility/parse_performance_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/parse_performance_test.dart rename to packages/config/test/args_compatibility/parse_performance_test.dart diff --git a/packages/config/test/config/args_compatibility/parse_test.dart b/packages/config/test/args_compatibility/parse_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/parse_test.dart rename to packages/config/test/args_compatibility/parse_test.dart diff --git a/packages/config/test/config/args_compatibility/test_utils.dart b/packages/config/test/args_compatibility/test_utils.dart similarity index 100% rename from packages/config/test/config/args_compatibility/test_utils.dart rename to packages/config/test/args_compatibility/test_utils.dart diff --git a/packages/config/test/config/args_compatibility/trailing_options_test.dart b/packages/config/test/args_compatibility/trailing_options_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/trailing_options_test.dart rename to packages/config/test/args_compatibility/trailing_options_test.dart diff --git a/packages/config/test/config/args_compatibility/usage_test.dart b/packages/config/test/args_compatibility/usage_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/usage_test.dart rename to packages/config/test/args_compatibility/usage_test.dart diff --git a/packages/config/test/config/args_compatibility/utils_test.dart b/packages/config/test/args_compatibility/utils_test.dart similarity index 100% rename from packages/config/test/config/args_compatibility/utils_test.dart rename to packages/config/test/args_compatibility/utils_test.dart From cd4d4eb6b2d2f84e83bfd5fb1c15b6b6cafa90d9 Mon Sep 17 00:00:00 2001 From: Christer Date: Fri, 16 Oct 2026 14:01:32 +0200 Subject: [PATCH 6/8] refactor(config): Reorganized internal exception handling Differentiate between UsageException with its augmented usage content and the internally used InvalidValueException. --- packages/config/lib/config.dart | 2 -- .../config/lib/src/config/configuration.dart | 12 ++------- .../config/lib/src/config/exceptions.dart | 24 ++++++++++++++++++ .../lib/src/config/file_system_options.dart | 25 +++++++++++-------- packages/config/lib/src/config/options.dart | 20 +++++++++------ .../test/config/configuration_test.dart | 2 +- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/packages/config/lib/config.dart b/packages/config/lib/config.dart index 50dbdb7..254fca3 100644 --- a/packages/config/lib/config.dart +++ b/packages/config/lib/config.dart @@ -1,4 +1,2 @@ -export 'package:args/command_runner.dart' show UsageException; - export 'src/args_compatibility/config_parser.dart'; export 'src/config/config.dart'; diff --git a/packages/config/lib/src/config/configuration.dart b/packages/config/lib/src/config/configuration.dart index 42757fd..50c7e66 100644 --- a/packages/config/lib/src/config/configuration.dart +++ b/packages/config/lib/src/config/configuration.dart @@ -1,5 +1,4 @@ import 'package:args/args.dart'; -import 'package:args/command_runner.dart'; import 'package:collection/collection.dart'; import 'configuration_broker.dart'; @@ -239,11 +238,11 @@ class Configuration { /// Returns the value of a configuration option /// that is guaranteed to be non-null. /// - /// Throws [UsageException] if the option is mandatory and no value is provided. - /// /// If called for an option that is neither mandatory nor has defaults, /// [StateError] is thrown. See also [optionalValue]. /// + /// Throws [InvalidParseStateError] if the option has no value due to previous errors. + /// /// Throws [ArgumentError] if the option is unknown. V value(final OptionDefinition option) { if (!(option.option.mandatory || @@ -396,10 +395,3 @@ extension _RestAsList on Iterator { return list; } } - -/// Specialized [StateError] that indicates that the configuration -/// has not been successfully parsed and this prevents accessing -/// some or all of the configuration values. -class InvalidParseStateError extends StateError { - InvalidParseStateError(super.message); -} diff --git a/packages/config/lib/src/config/exceptions.dart b/packages/config/lib/src/config/exceptions.dart index 9a12055..d016e71 100644 --- a/packages/config/lib/src/config/exceptions.dart +++ b/packages/config/lib/src/config/exceptions.dart @@ -1,5 +1,7 @@ import 'options.dart'; +export 'package:args/command_runner.dart' show UsageException; + /// Indicates that the option definition is invalid. class OptionDefinitionError extends Error { final OptionDefinition option; @@ -14,3 +16,25 @@ class OptionDefinitionError extends Error { : 'Invalid definition for ${option.qualifiedString()}'; } } + +/// Specialized [StateError] that indicates that the configuration +/// has not been successfully parsed and this prevents accessing +/// some or all of the configuration values. +class InvalidParseStateError extends StateError { + InvalidParseStateError(super.message); +} + +/// Indicates that the value of an option is invalid. +/// Used by option implementations: Thrown by [ConfigOptionBase.validateValue] +/// when a value is invalid. +/// Not normally thrown to package users, it gets converted to [UsageException]. +class InvalidValueException implements Exception { + final String message; + + InvalidValueException(this.message); + + @override + String toString() { + return '$runtimeType: $message'; + } +} diff --git a/packages/config/lib/src/config/file_system_options.dart b/packages/config/lib/src/config/file_system_options.dart index 58585b8..f5d583c 100644 --- a/packages/config/lib/src/config/file_system_options.dart +++ b/packages/config/lib/src/config/file_system_options.dart @@ -1,8 +1,8 @@ import 'dart:io'; -import 'package:args/command_runner.dart'; import 'package:path/path.dart' as p; +import 'exceptions.dart'; import 'options.dart'; enum PathExistMode { @@ -29,7 +29,7 @@ class DirParser extends ValueParser { /// Directory path configuration option. /// /// If the input is not valid according to the [mode], -/// the validation throws a [UsageException]. +/// the validation throws a [InvalidValueException]. class DirOption extends ConfigOptionBase { final PathExistMode mode; @@ -62,20 +62,23 @@ class DirOption extends ConfigOptionBase { case PathExistMode.mayExist: if (type != FileSystemEntityType.notFound && type != FileSystemEntityType.directory) { - throw UsageException('Path "${value.path}" is not a directory', ''); + throw InvalidValueException( + 'Path "${value.path}" is not a directory'); } break; case PathExistMode.mustExist: if (type == FileSystemEntityType.notFound) { - throw UsageException('Directory "${value.path}" does not exist', ''); + throw InvalidValueException( + 'Directory "${value.path}" does not exist'); } if (type != FileSystemEntityType.directory) { - throw UsageException('Path "${value.path}" is not a directory', ''); + throw InvalidValueException( + 'Path "${value.path}" is not a directory'); } break; case PathExistMode.mustNotExist: if (type != FileSystemEntityType.notFound) { - throw UsageException('Path "${value.path}" already exists', ''); + throw InvalidValueException('Path "${value.path}" already exists'); } break; } @@ -99,7 +102,7 @@ class FileParser extends ValueParser { /// File path configuration option. /// /// If the input is not valid according to the [mode], -/// the validation throws a [UsageException]. +/// the validation throws a [InvalidValueException]. class FileOption extends ConfigOptionBase { final PathExistMode mode; @@ -132,20 +135,20 @@ class FileOption extends ConfigOptionBase { case PathExistMode.mayExist: if (type != FileSystemEntityType.notFound && type != FileSystemEntityType.file) { - throw UsageException('Path "${value.path}" is not a file', ''); + throw InvalidValueException('Path "${value.path}" is not a file'); } break; case PathExistMode.mustExist: if (type == FileSystemEntityType.notFound) { - throw UsageException('File "${value.path}" does not exist', ''); + throw InvalidValueException('File "${value.path}" does not exist'); } if (type != FileSystemEntityType.file) { - throw UsageException('Path "${value.path}" is not a file', ''); + throw InvalidValueException('Path "${value.path}" is not a file'); } break; case PathExistMode.mustNotExist: if (type != FileSystemEntityType.notFound) { - throw UsageException('Path "${value.path}" already exists', ''); + throw InvalidValueException('Path "${value.path}" already exists'); } break; } diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index cad47ee..19758c6 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -1,5 +1,4 @@ import 'package:args/args.dart'; -import 'package:args/command_runner.dart'; import 'package:collection/collection.dart'; import 'package:meta/meta.dart'; @@ -126,7 +125,7 @@ abstract class ValueParser { /// A [customValidator] may be provided for an individual option. /// If a value was provided the customValidator is invoked, /// and shall throw a [FormatException] if its format is invalid, -/// or a [UsageException] if the it is invalid for other reasons. +/// or a [InvalidValueException] if the it is invalid for other reasons. /// /// ### Positional arguments /// @@ -312,15 +311,15 @@ abstract class ConfigOptionBase implements OptionDefinition { /// Validates the parsed value, /// throwing a [FormatException] if the value is invalid, - /// or a [UsageException] if the value is invalid for other reasons. + /// or a [InvalidValueException] if the value is invalid for other reasons. /// /// Subclasses may override this method to perform specific validations. /// If they do, they must also call the super implementation. @mustCallSuper void validateValue(final V value) { if (allowedValues?.contains(value) == false) { - throw UsageException( - '`$value` is not an allowed value for ${qualifiedString()}', ''); + throw InvalidValueException( + '`$value` is not an allowed value for ${qualifiedString()}'); } customValidator?.call(value); @@ -536,8 +535,13 @@ abstract class ConfigOptionBase implements OptionDefinition { validateValue(value); } on FormatException catch (e) { return _makeFormatErrorMessage(e); - } on UsageException catch (e) { + } on InvalidValueException catch (e) { return _makeErrorMessage(e.message); + } on UsageException catch (e) { + // Retained for backwards compatibility for users who may have custom + // option implementations that throw UsageException. + // TODO: Remove in a future version. + return _makeErrorMessage(e.toString()); } } return null; @@ -742,8 +746,8 @@ class MultiOption extends ConfigOptionBase> { if (allowed != null) { for (final v in value) { if (allowed.contains(v) == false) { - throw UsageException( - '`$v` is not an allowed value for ${qualifiedString()}', ''); + throw InvalidValueException( + '`$v` is not an allowed value for ${qualifiedString()}'); } } } diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index 6fa8a48..482807b 100644 --- a/packages/config/test/config/configuration_test.dart +++ b/packages/config/test/config/configuration_test.dart @@ -1,4 +1,4 @@ -import 'package:args/args.dart'; +import 'package:args/args.dart' show ArgParser, ArgParserException; import 'package:config/config.dart'; import 'package:test/test.dart'; From 1334a0e169503494fe671241e44732a34288411a Mon Sep 17 00:00:00 2001 From: Christer Date: Fri, 16 Oct 2026 14:08:49 +0200 Subject: [PATCH 7/8] fix(config): Handle parse failure, invalid aliases, and config key lookup Keep remaining positional arguments initialized when argument parsing fails, reject aliases ArgParser would reject, and find options by every configuration key. Co-authored-by: Cursor --- .../config/lib/src/config/configuration.dart | 4 + packages/config/lib/src/config/options.dart | 10 ++- .../test/config/configuration_test.dart | 83 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/config/lib/src/config/configuration.dart b/packages/config/lib/src/config/configuration.dart index 50c7e66..f93fde6 100644 --- a/packages/config/lib/src/config/configuration.dart +++ b/packages/config/lib/src/config/configuration.dart @@ -48,6 +48,8 @@ class Configuration { /// The remaining positional arguments that were not parsed as options. /// If non-empty and [ignoreUnexpectedPositionalArgs] is false, /// such arguments will also be registered as errors. + /// + /// Empty when argument parsing fails before any arguments can be classified. late final List remainingPosArgs; /// Creates a configuration with the provided option values. @@ -96,6 +98,7 @@ class Configuration { if (argResults == null && args != null) { argResults = _prepareArgResults(args); if (argResults == null) { + remainingPosArgs = []; return; } } @@ -128,6 +131,7 @@ class Configuration { if (argResults == null && args != null) { argResults = _prepareArgResults(args); if (argResults == null) { + remainingPosArgs = []; throwExceptionOnErrors(); return; } diff --git a/packages/config/lib/src/config/options.dart b/packages/config/lib/src/config/options.dart index 19758c6..d812b54 100644 --- a/packages/config/lib/src/config/options.dart +++ b/packages/config/lib/src/config/options.dart @@ -302,6 +302,14 @@ abstract class ConfigOptionBase implements OptionDefinition { throw OptionDefinitionError( this, "An argument option name / abbreviation can't start with '-'"); } + if (argAliases?.any((final alias) => alias.contains('=')) == true) { + throw OptionDefinitionError( + this, "An argument option alias can't contain '='"); + } + if (argAliases?.any((final alias) => alias.startsWith('-')) == true) { + throw OptionDefinitionError( + this, "An argument option alias can't start with '-'"); + } if ((fromDefault != null || defaultsTo != null) && mandatory) { throw OptionDefinitionError( @@ -956,7 +964,7 @@ extension PrepareOptions on Iterable { (argName != null && o.option.argName == argName) || (argPos != null && o.option.argPos == argPos) || (envName != null && o.option.envName == envName) || - (configKey != null && o.option.configKey == configKey); + (configKey != null && o.option.allConfigKeys.contains(configKey)); }); } } diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index 482807b..55d978f 100644 --- a/packages/config/test/config/configuration_test.dart +++ b/packages/config/test/config/configuration_test.dart @@ -65,6 +65,44 @@ void main() async { }); }); + group('Given an option with an invalid argument alias', () { + final parser = ArgParser(); + + test("when the alias contains '=' then definition validation throws", + () async { + const option = StringOption( + argName: 'name', + argAliases: ['bad=name'], + ); + + expect( + () => [option].prepareForParsing(parser), + throwsA(isA().having( + (final e) => e.message, + 'message', + "An argument option alias can't contain '='", + )), + ); + }); + + test("when the alias starts with '-' then definition validation throws", + () async { + const option = StringOption( + argName: 'name', + argAliases: ['-bad'], + ); + + expect( + () => [option].prepareForParsing(parser), + throwsA(isA().having( + (final e) => e.message, + 'message', + "An argument option alias can't start with '-'", + )), + ); + }); + }); + group('Given a configuration option definition', () { const projectIdOpt = StringOption( argName: 'project', @@ -348,6 +386,15 @@ void main() async { equals('fallbackValue'), ); }); + + test( + 'when finding the option on the option list via a configKeys entry ' + 'then it succeeds', () { + expect( + [projectIdOpt].findOption(configKey: 'fallback:/projectId'), + same(projectIdOpt), + ); + }); }); group('Given a configuration option with both configKey and configKeys', () { @@ -387,6 +434,15 @@ void main() async { ); expect(config.optionalValue(projectIdOpt), equals('fallbackValue')); }); + + test( + 'when finding the option on the option list via a configKeys entry ' + 'then it succeeds', () { + expect( + [projectIdOpt].findOption(configKey: 'fallback:/projectId'), + same(projectIdOpt), + ); + }); }); group('Given a configuration flag option', () { @@ -1344,6 +1400,33 @@ void main() async { }); }); + group('Given argument parsing fails', () { + const projectOpt = StringOption(argName: 'project'); + + late Configuration config; + + setUp(() { + config = Configuration.resolveNoExcept( + options: [projectOpt], + args: ['--not-an-option'], + ); + }); + + test('then remaining positional arguments are empty', () { + expect(config.remainingPosArgs, isEmpty); + }); + + test('then the parse error is recorded', () { + expect(config.errors, isNotEmpty); + }); + + test('then a copy has empty remaining positional arguments', () { + final copy = Configuration.from(configuration: config); + + expect(copy.remainingPosArgs, isEmpty); + }); + }); + group('Given two options that have aliases', () { const firstOpt = StringOption( argName: 'first', From b3207d3a78ddf09b2bd6880724eb0256c07705db Mon Sep 17 00:00:00 2001 From: Christer Date: Mon, 19 Oct 2026 15:41:03 +0200 Subject: [PATCH 8/8] test(config): Cover findOption across a list of several options Replace the duplicate single-option lookup with cases that select by name, position, environment, enum, and every config key. Co-authored-by: Cursor --- .../test/config/configuration_test.dart | 216 ++++++++++++++++-- 1 file changed, 203 insertions(+), 13 deletions(-) diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index 55d978f..bcdc9aa 100644 --- a/packages/config/test/config/configuration_test.dart +++ b/packages/config/test/config/configuration_test.dart @@ -386,15 +386,6 @@ void main() async { equals('fallbackValue'), ); }); - - test( - 'when finding the option on the option list via a configKeys entry ' - 'then it succeeds', () { - expect( - [projectIdOpt].findOption(configKey: 'fallback:/projectId'), - same(projectIdOpt), - ); - }); }); group('Given a configuration option with both configKey and configKeys', () { @@ -434,14 +425,213 @@ void main() async { ); expect(config.optionalValue(projectIdOpt), equals('fallbackValue')); }); + }); + + group('Given an option list with several options', () { + const firstOpt = StringOption( + argName: 'first', + argPos: 0, + envName: 'FIRST', + configKey: 'config:/first', + configKeys: ['config:/first-fallback'], + ); + const keysOnlyOpt = StringOption( + configKeys: ['config:/keys-only', 'config:/keys-only-fallback'], + ); + const lastOpt = StringOption( + argName: 'last', + argPos: 2, + envName: 'LAST', + configKey: 'config:/last', + ); + final options = [ + firstOpt, + _TestOption.intOpt, + keysOnlyOpt, + lastOpt, + ]; + final config = Configuration.fromValues( + values: { + firstOpt: 'first', + _TestOption.intOpt: 2, + keysOnlyOpt: null, + lastOpt: 'last', + }, + ); + + test( + 'when finding by the first option argument name ' + 'then that option is returned', () { + expect(options.findOption(argName: 'first'), same(firstOpt)); + expect(config.findOption(argName: 'first'), same(firstOpt)); + }); + + test( + 'when finding by the last option argument name ' + 'then that option is returned', () { + expect(options.findOption(argName: 'last'), same(lastOpt)); + expect(config.findOption(argName: 'last'), same(lastOpt)); + }); test( - 'when finding the option on the option list via a configKeys entry ' - 'then it succeeds', () { + 'when finding by an unknown argument name ' + 'then no option is returned', () { + expect(options.findOption(argName: 'missing'), isNull); + expect(config.findOption(argName: 'missing'), isNull); + }); + + test( + 'when finding by the middle option enum name ' + 'then that option is returned', () { expect( - [projectIdOpt].findOption(configKey: 'fallback:/projectId'), - same(projectIdOpt), + options.findOption(enumName: _TestOption.intOpt.name), + same(_TestOption.intOpt), ); + expect( + config.findOption(enumName: _TestOption.intOpt.name), + same(_TestOption.intOpt), + ); + }); + + test( + 'when finding by an unknown enum name ' + 'then no option is returned', () { + expect(options.findOption(enumName: 'missing'), isNull); + expect(config.findOption(enumName: 'missing'), isNull); + }); + + test( + 'when finding by the first option position ' + 'then that option is returned', () { + expect(options.findOption(argPos: 0), same(firstOpt)); + expect(config.findOption(argPos: 0), same(firstOpt)); + }); + + test( + 'when finding by the last option position ' + 'then that option is returned', () { + expect(options.findOption(argPos: 2), same(lastOpt)); + expect(config.findOption(argPos: 2), same(lastOpt)); + }); + + test( + 'when finding by an unknown position ' + 'then no option is returned', () { + expect(options.findOption(argPos: 3), isNull); + expect(config.findOption(argPos: 3), isNull); + }); + + test( + 'when finding by the first option environment name ' + 'then that option is returned', () { + expect(options.findOption(envName: 'FIRST'), same(firstOpt)); + expect(config.findOption(envName: 'FIRST'), same(firstOpt)); + }); + + test( + 'when finding by the last option environment name ' + 'then that option is returned', () { + expect(options.findOption(envName: 'LAST'), same(lastOpt)); + expect(config.findOption(envName: 'LAST'), same(lastOpt)); + }); + + test( + 'when finding by an unknown environment name ' + 'then no option is returned', () { + expect(options.findOption(envName: 'MISSING'), isNull); + expect(config.findOption(envName: 'MISSING'), isNull); + }); + + test( + 'when finding by the first option config key ' + 'then that option is returned', () { + expect(options.findOption(configKey: 'config:/first'), same(firstOpt)); + expect(config.findOption(configKey: 'config:/first'), same(firstOpt)); + }); + + test( + 'when finding by a later config key of the first option ' + 'then that option is returned', () { + expect( + options.findOption(configKey: 'config:/first-fallback'), + same(firstOpt), + ); + expect( + config.findOption(configKey: 'config:/first-fallback'), + same(firstOpt), + ); + }); + + test( + 'when finding by the middle option config key ' + 'then that option is returned', () { + expect( + options.findOption(configKey: 'config:/int'), + same(_TestOption.intOpt), + ); + expect( + config.findOption(configKey: 'config:/int'), + same(_TestOption.intOpt), + ); + }); + + test( + 'when finding by the first key of an option that has only configKeys ' + 'then that option is returned', () { + expect( + options.findOption(configKey: 'config:/keys-only'), + same(keysOnlyOpt), + ); + expect( + config.findOption(configKey: 'config:/keys-only'), + same(keysOnlyOpt), + ); + }); + + test( + 'when finding by a later key of an option that has only configKeys ' + 'then that option is returned', () { + expect( + options.findOption(configKey: 'config:/keys-only-fallback'), + same(keysOnlyOpt), + ); + expect( + config.findOption(configKey: 'config:/keys-only-fallback'), + same(keysOnlyOpt), + ); + }); + + test( + 'when finding by the last option config key ' + 'then that option is returned', () { + expect(options.findOption(configKey: 'config:/last'), same(lastOpt)); + expect(config.findOption(configKey: 'config:/last'), same(lastOpt)); + }); + + test( + 'when finding by an unknown config key ' + 'then no option is returned', () { + expect(options.findOption(configKey: 'config:/missing'), isNull); + expect(config.findOption(configKey: 'config:/missing'), isNull); + }); + + test( + 'when two options share a config key ' + 'then the earlier option is returned', () { + const earlier = StringOption(configKeys: ['config:/shared']); + const later = StringOption(configKey: 'config:/shared'); + final shared = [earlier, later]; + final sharedConfig = Configuration.fromValues( + values: {earlier: 'earlier', later: 'later'}, + ); + + expect(shared.findOption(configKey: 'config:/shared'), same(earlier)); + expect( + sharedConfig.findOption(configKey: 'config:/shared'), same(earlier)); + }); + + test('when the option list is empty then no option is returned', () { + expect([].findOption(argName: 'first'), isNull); }); });