diff --git a/packages/config/lib/config.dart b/packages/config/lib/config.dart index 1c7e7ba..254fca3 100644 --- a/packages/config/lib/config.dart +++ b/packages/config/lib/config.dart @@ -1,3 +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/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/lib/src/config/configuration.dart b/packages/config/lib/src/config/configuration.dart index dc00deb..f93fde6 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'; @@ -46,6 +45,13 @@ 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. + /// + /// Empty when argument parsing fails before any arguments can be classified. + late final List remainingPosArgs; + /// Creates a configuration with the provided option values. /// /// This does not throw upon value parsing or validation errors, @@ -64,7 +70,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. @@ -91,6 +98,7 @@ class Configuration { if (argResults == null && args != null) { argResults = _prepareArgResults(args); if (argResults == null) { + remainingPosArgs = []; return; } } @@ -123,6 +131,7 @@ class Configuration { if (argResults == null && args != null) { argResults = _prepareArgResults(args); if (argResults == null) { + remainingPosArgs = []; throwExceptionOnErrors(); return; } @@ -233,11 +242,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 || @@ -347,7 +356,7 @@ class Configuration { _validateGroups(optionGroups); - final remainingPosArgs = posArgs.restAsList(); + remainingPosArgs = posArgs.restAsList(); if (remainingPosArgs.isNotEmpty && !ignoreUnexpectedPositionalArgs) { _errors.add( "Unexpected positional argument(s): '${remainingPosArgs.join("', '")}'"); @@ -390,10 +399,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 1adcb8d..d812b54 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'; @@ -53,7 +52,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. /// @@ -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 /// @@ -281,7 +280,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. /// @@ -294,6 +293,24 @@ 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 (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( this, "Mandatory options can't have default values"); @@ -302,15 +319,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); @@ -526,8 +543,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; @@ -732,8 +754,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()}'); } } } @@ -763,6 +785,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,10 +936,35 @@ 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(); 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.allConfigKeys.contains(configKey)); + }); + } } 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 diff --git a/packages/config/test/config/configuration_test.dart b/packages/config/test/config/configuration_test.dart index db32801..bcdc9aa 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'; @@ -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', @@ -389,6 +427,214 @@ void main() async { }); }); + 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 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( + 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); + }); + }); + group('Given a configuration flag option', () { const verboseFlag = FlagOption( argName: 'verbose', @@ -1064,6 +1310,313 @@ 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 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',