From 41936b3390281ff1cb2b410166efd3e45d856401 Mon Sep 17 00:00:00 2001 From: Christer Date: Sat, 19 Apr 2025 14:50:57 +0200 Subject: [PATCH 1/9] feat: Migrated the Config library to cli_tools --- analysis_options.yaml | 6 +- lib/config.dart | 1 + lib/src/config/config.dart | 9 + lib/src/config/config_source.dart | 22 + lib/src/config/config_source_provider.dart | 42 + lib/src/config/configuration.dart | 1020 +++++++++++++ lib/src/config/configuration_parser.dart | 68 + lib/src/config/file_system_options.dart | 139 ++ lib/src/config/json_yaml_document.dart | 28 + lib/src/config/multi_config_source.dart | 95 ++ lib/src/config/option_groups.dart | 42 + lib/src/config/option_resolution.dart | 55 + lib/src/config/options.dart | 385 +++++ lib/src/config/source_type.dart | 10 + pubspec.lock | 44 +- pubspec.yaml | 7 +- test/config/config_source_test.dart | 259 ++++ test/config/configuration_test.dart | 1512 ++++++++++++++++++++ test/config/configuration_type_test.dart | 834 +++++++++++ test/config/date_parsing_test.dart | 117 ++ test/config/duration_parsing_test.dart | 244 ++++ test/config/file_options_test.dart | 457 ++++++ 22 files changed, 5388 insertions(+), 8 deletions(-) create mode 100644 lib/config.dart create mode 100644 lib/src/config/config.dart create mode 100644 lib/src/config/config_source.dart create mode 100644 lib/src/config/config_source_provider.dart create mode 100644 lib/src/config/configuration.dart create mode 100644 lib/src/config/configuration_parser.dart create mode 100644 lib/src/config/file_system_options.dart create mode 100644 lib/src/config/json_yaml_document.dart create mode 100644 lib/src/config/multi_config_source.dart create mode 100644 lib/src/config/option_groups.dart create mode 100644 lib/src/config/option_resolution.dart create mode 100644 lib/src/config/options.dart create mode 100644 lib/src/config/source_type.dart create mode 100644 test/config/config_source_test.dart create mode 100644 test/config/configuration_test.dart create mode 100644 test/config/configuration_type_test.dart create mode 100644 test/config/date_parsing_test.dart create mode 100644 test/config/duration_parsing_test.dart create mode 100644 test/config/file_options_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 9d4376c..65336e6 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,5 @@ -include: package:serverpod_lints/cli.yaml \ No newline at end of file +include: package:serverpod_lints/cli.yaml + +analyzer: + errors: + unnecessary_final: false diff --git a/lib/config.dart b/lib/config.dart new file mode 100644 index 0000000..f66c639 --- /dev/null +++ b/lib/config.dart @@ -0,0 +1 @@ +export 'src/config/config.dart'; diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart new file mode 100644 index 0000000..217bcf6 --- /dev/null +++ b/lib/src/config/config.dart @@ -0,0 +1,9 @@ +export 'config_source_provider.dart'; +export 'config_source.dart'; +export 'configuration_parser.dart'; +export 'configuration.dart'; +export 'file_system_options.dart'; +export 'multi_config_source.dart'; +export 'options.dart'; +export 'option_groups.dart'; +export 'source_type.dart'; diff --git a/lib/src/config/config_source.dart b/lib/src/config/config_source.dart new file mode 100644 index 0000000..77f41fb --- /dev/null +++ b/lib/src/config/config_source.dart @@ -0,0 +1,22 @@ +/// A source of configuration values. +/// +/// {@template config_source.valueOrNull} +/// Returns the value for the given key, or `null` if the key is not found +/// or has no value. +/// {@endtemplate} +abstract interface class ConfigurationSource { + /// {@macro config_source.valueOrNull} + Object? valueOrNull(final String key); +} + +/// Simple [ConfigurationSource] adapter for a [Map]. +class MapConfigSource implements ConfigurationSource { + final Map entries; + + MapConfigSource(this.entries); + + @override + String? valueOrNull(final String key) { + return entries[key]; + } +} diff --git a/lib/src/config/config_source_provider.dart b/lib/src/config/config_source_provider.dart new file mode 100644 index 0000000..e042216 --- /dev/null +++ b/lib/src/config/config_source_provider.dart @@ -0,0 +1,42 @@ +import 'config_source.dart'; +import 'configuration.dart'; +import 'configuration_parser.dart'; + +/// Provider of a [ConfigurationSource] that is dynamically +/// based on the current configuration. +/// +/// It is lazily invoked and should cache the [ConfigurationSource]. +abstract class ConfigSourceProvider { + /// Get the [ConfigurationSource] given the current configuration. + /// This is lazily invoked and should cache the [ConfigurationSource] + /// for subsequent invocations. + ConfigurationSource getConfigSource(final Configuration cfg); +} + +/// A [ConfigSourceProvider] that uses the value of an option +/// as data source. +class OptionContentConfigProvider> + extends ConfigSourceProvider { + final O contentOption; + final ConfigEncoding format; + ConfigurationSource? _configProvider; + + OptionContentConfigProvider({ + required this.contentOption, + required this.format, + }); + + @override + ConfigurationSource getConfigSource(final Configuration cfg) { + var provider = _configProvider; + if (provider == null) { + final optionValue = cfg.optionalValue(contentOption) ?? ''; + provider = ConfigurationParser.fromString( + optionValue, + format: format, + ); + _configProvider = provider; + } + return provider; + } +} diff --git a/lib/src/config/configuration.dart b/lib/src/config/configuration.dart new file mode 100644 index 0000000..9d231ce --- /dev/null +++ b/lib/src/config/configuration.dart @@ -0,0 +1,1020 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; + +import 'option_resolution.dart'; +import 'source_type.dart'; + +/// Common interface to enable same treatment for [ConfigOptionBase] and option enums. +abstract class OptionDefinition { + ConfigOptionBase get option; +} + +/// An option group allows grouping options together under a common name, +/// and optionally provide option value validation on the group as a whole. +/// +/// [name] might be used as group header in usage information +/// so it is recommended to format it appropriately, e.g. `File mode`. +/// +/// An [OptionGroup] is uniquely identified by its [name]. +class OptionGroup { + final String name; + + const OptionGroup(this.name); + + /// Validates the values of the options in this group, + /// returning a descriptive error message if the values are invalid. + /// + /// Subclasses may override this method to perform specific validations. + String? validate( + final Map optionResolutions, + ) { + return null; + } + + @override + bool operator ==(final Object other) { + if (identical(this, other)) return true; + return other is OptionGroup && other.name == name; + } + + @override + int get hashCode => name.hashCode; +} + +/// A [ValueParser] converts a source string value to the specific option value type. +/// +/// {@template value_parser} +/// Must throw a [FormatException] with an appropriate message +/// if the value cannot be parsed. +/// {@endtemplate} +abstract class ValueParser { + const ValueParser(); + + /// Converts a source string value to the specific option value type. + /// {@macro value_parser} + V parse(final String value); + + /// Returns a usage documentation friendly string representation of the value. + /// The default implementation simply invokes [toString]. + String format(final V value) { + return value.toString(); + } +} + +/// Defines a configuration option that can be set from configuration sources. +/// +/// When an option can be set in multiple ways, the precedence is as follows: +/// +/// 1. Named command line argument +/// 2. Positional command line argument +/// 3. Environment variable +/// 4. By lookup key in configuration sources (such as files) +/// 5. A custom callback function +/// 6. Default value +/// +/// ### Typed values, parsing, and validation +/// +/// Option values are typed, and parsed using the [ValueParser]. +/// Subclasses of [ConfigOptionBase] may also override [validateValue] +/// to perform additional validation such as range checking. +/// +/// The subclasses implement specific option value types, +/// e.g. [StringOption], [FlagOption] (boolean), [IntOption], etc. +/// +/// ### Positional arguments +/// +/// If multiple positional arguments are defined, +/// follow these restrictions to prevent ambiguity: +/// - all but the last one must be mandatory +/// - all but the last one must have no non-argument configuration sources +/// +/// If an argument is defined as both named and positional, +/// and the named argument is provided, the positional index +/// is still consumed so that subsequent positional arguments +/// will get the correct value. +/// +/// Note that this prevents an option from being provided both +/// named and positional on the same command line. +/// +/// ### Mandatory and Default +/// +/// If [mandatory] is true, the option must be provided in the +/// configuration sources, i.e. be explicitly set. +/// This cannot be used together with [defaultsTo] or [fromDefault]. +/// +/// If no value is provided from the configuration sources, +/// the [fromDefault] callback is used if available, +/// otherwise the [defaultsTo] value is used. +/// [fromDefault] must return the same value if called multiple times. +/// +/// If an option is either mandatory or has a default value, +/// it is guaranteed to have a value and can be retrieved using +/// the non-nullable [value] method. +/// Otherwise it may be retrieved using the nullable [valueOrNull] method. +class ConfigOptionBase implements OptionDefinition { + final ValueParser valueParser; + + final String? argName; + final List? argAliases; + final String? argAbbrev; + final int? argPos; + final String? envName; + final String? configKey; + final V? Function(Configuration cfg)? fromCustom; + final V Function()? fromDefault; + final V? defaultsTo; + + final String? helpText; + final String? valueHelp; + final Map? allowedHelp; + final OptionGroup? group; + + final List? allowedValues; + final void Function(V value)? customValidator; + final bool mandatory; + final bool hide; + + const ConfigOptionBase({ + required this.valueParser, + this.argName, + this.argAliases, + this.argAbbrev, + this.argPos, + this.envName, + this.configKey, + this.fromCustom, + this.fromDefault, + this.defaultsTo, + this.helpText, + this.valueHelp, + this.allowedHelp, + this.group, + this.allowedValues, + this.customValidator, + this.mandatory = false, + this.hide = false, + }); + + V? defaultValue() { + final df = fromDefault; + return (df != null ? df() : defaultsTo); + } + + String? defaultValueString() { + return defaultValue()?.toString(); + } + + String? valueHelpString() { + return valueHelp; + } + + /// Adds this configuration option to the provided argument parser. + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add option without arg name to arg parser."); + } + argParser.addOption( + argName, + abbr: argAbbrev, + help: helpText, + valueHelp: valueHelpString(), + allowed: allowedValues?.map(valueParser.format), + allowedHelp: allowedHelp, + defaultsTo: defaultValueString(), + mandatory: mandatory, + hide: hide, + aliases: argAliases ?? const [], + ); + } + + /// Validates the configuration option definition. + /// + /// This method is called by [prepareOptionsForParsing] to validate + /// the configuration option definition. + /// Throws an error if the definition is invalid. + /// + /// Subclasses may override this method to perform specific validations. + /// If they do, they must also call the super implementation. + @mustCallSuper + void validateDefinition() { + if (argName == null && argAbbrev != null) { + throw InvalidOptionConfigurationError(this, + "An argument option can't have an abbreviation but not a full name"); + } + + if ((fromDefault != null || defaultsTo != null) && mandatory) { + throw InvalidOptionConfigurationError( + this, "Mandatory options can't have default values"); + } + } + + /// Validates the parsed value, + /// throwing a [FormatException] if the value is invalid, + /// or a [UsageException] 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()}', ''); + } + + customValidator?.call(value); + } + + /// Returns self. + @override + ConfigOptionBase get option => this; + + @override + String toString() => argName ?? envName ?? ''; + + String qualifiedString() { + if (argName != null) { + return V is bool ? 'flag `$argName`' : 'option `$argName`'; + } + if (envName != null) { + return 'environment variable `$envName`'; + } + if (argPos != null) { + return 'positional argument $argPos'; + } + if (configKey != null) { + return 'configuration key `$configKey`'; + } + return _unnamedOptionString; + } + + static const _unnamedOptionString = ''; + + ///////////////////// + // Value resolution + + /// Returns the resolved value of this configuration option from the provided context. + /// For options with positional arguments this must be invoked in ascending position order. + /// Returns the result with the resolved value or error. + OptionResolution _resolveValue( + final Configuration cfg, { + final ArgResults? args, + final Iterator? posArgs, + final Map? env, + final ConfigurationBroker? configBroker, + }) { + OptionResolution res; + try { + res = _doResolve( + cfg, + args: args, + posArgs: posArgs, + env: env, + configBroker: configBroker, + ); + } on Exception catch (e) { + return OptionResolution.error( + 'Failed to resolve ${option.qualifiedString()}: $e', + ); + } + + if (res.error != null) { + return res; + } + + final stringValue = res.stringValue; + if (stringValue != null) { + // value provided by string-based config source, parse to the designated type + try { + res = res.copyWithValue( + option.option.valueParser.parse(stringValue), + ); + } on FormatException catch (e) { + return res.copyWithError( + _makeFormatErrorMessage(e), + ); + } + } + + final error = _validateOptionValue(res.value); + if (error != null) return res.copyWithError(error); + + return res; + } + + OptionResolution _doResolve( + final Configuration cfg, { + final ArgResults? args, + final Iterator? posArgs, + final Map? env, + final ConfigurationBroker? configBroker, + }) { + OptionResolution? result; + + result = _resolveNamedArg(args); + if (result != null) return result; + + result = _resolvePosArg(posArgs); + if (result != null) return result; + + result = _resolveEnvVar(env); + if (result != null) return result; + + result = _resolveConfigValue(cfg, configBroker); + if (result != null) return result; + + result = _resolveCustomValue(cfg); + if (result != null) return result; + + result = _resolveDefaultValue(); + if (result != null) return result; + + return OptionResolution.noValue(); + } + + OptionResolution? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + return OptionResolution( + stringValue: args.option(argOptName), + source: ValueSourceType.arg, + ); + } + + OptionResolution? _resolvePosArg(final Iterator? posArgs) { + final argOptPos = argPos; + if (argOptPos == null || posArgs == null) return null; + if (!posArgs.moveNext()) return null; + return OptionResolution( + stringValue: posArgs.current, + source: ValueSourceType.arg, + ); + } + + OptionResolution? _resolveEnvVar(final Map? env) { + final envVarName = envName; + if (envVarName == null || env == null || !env.containsKey(envVarName)) { + return null; + } + return OptionResolution( + stringValue: env[envVarName], + source: ValueSourceType.envVar, + ); + } + + OptionResolution? _resolveConfigValue( + final Configuration cfg, + final ConfigurationBroker? configBroker, + ) { + final key = configKey; + if (configBroker == null || key == null) return null; + final value = configBroker.valueOrNull(key, cfg); + if (value == null) return null; + if (value is String) { + return OptionResolution( + stringValue: value, + source: ValueSourceType.config, + ); + } + if (value is V) { + return OptionResolution( + value: value as V, + source: ValueSourceType.config, + ); + } + return OptionResolution.error( + '${option.qualifiedString()} value $value ' + 'is of type ${value.runtimeType}, not $V.', + ); + } + + OptionResolution? _resolveCustomValue(final Configuration cfg) { + final value = fromCustom?.call(cfg); + if (value == null) return null; + return OptionResolution( + value: value, + source: ValueSourceType.custom, + ); + } + + OptionResolution? _resolveDefaultValue() { + final value = fromDefault?.call() ?? defaultsTo; + if (value == null) return null; + return OptionResolution( + value: value, + source: ValueSourceType.defaultValue, + ); + } + + /// Returns an error message if the value is invalid, or null if valid. + String? _validateOptionValue(final V? value) { + if (value == null && mandatory) { + return '${qualifiedString()} is mandatory'; + } + + if (value != null) { + try { + validateValue(value); + } on FormatException catch (e) { + return _makeFormatErrorMessage(e); + } on UsageException catch (e) { + return _makeErrorMessage(e.message); + } + } + return null; + } + + String _makeFormatErrorMessage(final FormatException e) { + const prefix = 'FormatException: '; + var message = e.toString(); + if (message.startsWith(prefix)) { + message = message.substring(prefix.length); + } + return _makeErrorMessage(message); + } + + String _makeErrorMessage(final String message) { + final help = valueHelp != null ? ' <$valueHelp>' : ''; + return 'Invalid value for ${qualifiedString()}$help: $message'; + } +} + +/// Parses a boolean value from a string. +class BoolParser extends ValueParser { + const BoolParser(); + + @override + bool parse(final String value) { + return bool.tryParse(value, caseSensitive: false) ?? false; + } +} + +/// Boolean value configuration option. +class FlagOption extends ConfigOptionBase { + final bool negatable; + final bool hideNegatedUsage; + + const FlagOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.negatable = true, + this.hideNegatedUsage = false, + }) : super( + valueParser: const BoolParser(), + ); + + @override + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add flag without arg name to arg parser."); + } + argParser.addFlag( + argName, + abbr: argAbbrev, + help: helpText, + defaultsTo: defaultValue(), + negatable: negatable, + hideNegatedUsage: hideNegatedUsage, + hide: hide, + aliases: argAliases ?? const [], + ); + } + + @override + OptionResolution? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + return OptionResolution( + value: args.flag(argOptName), + source: ValueSourceType.arg, + ); + } +} + +/// Parses a list of values from a comma-separated string. +/// +/// The [elementParser] is used to parse the individual elements. +/// +/// The [separator] is the pattern that separates the elements, +/// if the input is a single string. It is comma by default. +/// If it is null, the input is treated as a single element. +/// +/// The [joiner] is the string that joins the elements in the +/// formatted display string, also comma by default. +class MultiParser extends ValueParser> { + final ValueParser elementParser; + final Pattern? separator; + final String joiner; + + const MultiParser({ + required this.elementParser, + this.separator = ',', + this.joiner = ',', + }); + + @override + List parse(final String value) { + final sep = separator; + if (sep == null) return [elementParser.parse(value)]; + return value.split(sep).map(elementParser.parse).toList(); + } + + @override + String format(final List value) { + return value.map(elementParser.format).join(joiner); + } +} + +/// Multi-value configuration option. +class MultiOption extends ConfigOptionBase> { + final List? allowedElementValues; + + const MultiOption({ + required final MultiParser multiParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + final List? allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : allowedElementValues = allowedValues, + super( + valueParser: multiParser, + ); + + @override + void _addToArgParser(final ArgParser argParser) { + final argName = this.argName; + if (argName == null) { + throw StateError("Can't add option without arg name to arg parser."); + } + + final multiParser = valueParser as MultiParser; + argParser.addMultiOption( + argName, + abbr: argAbbrev, + help: helpText, + valueHelp: valueHelpString(), + allowed: allowedElementValues?.map(multiParser.elementParser.format), + allowedHelp: allowedHelp, + defaultsTo: defaultValue()?.map(multiParser.elementParser.format), + hide: hide, + splitCommas: multiParser.separator == ',', + aliases: argAliases ?? const [], + ); + } + + @override + OptionResolution>? _resolveNamedArg(final ArgResults? args) { + final argOptName = argName; + if (argOptName == null || args == null || !args.wasParsed(argOptName)) { + return null; + } + final multiParser = valueParser as MultiParser; + return OptionResolution( + value: args + .multiOption(argOptName) + .map(multiParser.elementParser.parse) + .toList(), + source: ValueSourceType.arg, + ); + } + + @override + @mustCallSuper + void validateValue(final List value) { + super.validateValue(value); + + final allowed = allowedElementValues; + if (allowed != null) { + for (final v in value) { + if (allowed.contains(v) == false) { + throw UsageException( + '`$v` is not an allowed value for ${qualifiedString()}', ''); + } + } + } + } +} + +/// Extension to add a [qualifiedString] shorthand method to [OptionDefinition]. +/// Since enum classes that implement [OptionDefinition] don't inherit +/// its method implementations, this extension provides this method +/// implementation instead. +extension QualifiedString on OptionDefinition { + String qualifiedString() { + final str = option.qualifiedString(); + if (str == ConfigOptionBase._unnamedOptionString && this is Enum) { + return (this as Enum).name; + } + return str; + } +} + +/// Validates and prepares a set of options for the provided argument parser. +void prepareOptionsForParsing( + final Iterable options, + final ArgParser argParser, +) { + final argNameOpts = {}; + final argPosOpts = {}; + final envNameOpts = {}; + + for (final opt in options) { + opt.option.validateDefinition(); + + final argName = opt.option.argName; + if (argName != null) { + if (argNameOpts.containsKey(opt.option.argName)) { + throw InvalidOptionConfigurationError( + opt, 'Duplicate argument name: ${opt.option.argName} for $opt'); + } + argNameOpts[argName] = opt; + } + + final argPos = opt.option.argPos; + if (argPos != null) { + if (argPosOpts.containsKey(opt.option.argPos)) { + throw InvalidOptionConfigurationError( + opt, 'Duplicate argument position: ${opt.option.argPos} for $opt'); + } + argPosOpts[argPos] = opt; + } + + final envName = opt.option.envName; + if (envName != null) { + if (envNameOpts.containsKey(opt.option.envName)) { + throw InvalidOptionConfigurationError(opt, + 'Duplicate environment variable name: ${opt.option.envName} for $opt'); + } + envNameOpts[envName] = opt; + } + } + + if (argPosOpts.isNotEmpty) { + final orderedPosOpts = argPosOpts.values.sorted( + (final a, final b) => a.option.argPos!.compareTo(b.option.argPos!)); + + if (orderedPosOpts.first.option.argPos != 0) { + throw InvalidOptionConfigurationError( + orderedPosOpts.first, + 'First positional argument must have index 0.', + ); + } + + if (orderedPosOpts.last.option.argPos != orderedPosOpts.length - 1) { + throw InvalidOptionConfigurationError( + orderedPosOpts.last, + 'The positional arguments must have consecutive indices without gaps.', + ); + } + } + + for (final opt in argNameOpts.values) { + opt.option._addToArgParser(argParser); + } +} + +extension PrepareOptions on Iterable { + /// Validates and prepares these options for the provided argument parser. + void prepareForParsing(final ArgParser argParser) => + prepareOptionsForParsing(this, argParser); +} + +/// Resolves configuration values dynamically +/// and possibly from multiple sources. +abstract interface class ConfigurationBroker { + /// Returns the value for the given key, or `null` if the key is not found + /// or has no value. + /// + /// Resolution may depend on the value of other options, accessed via [cfg]. + Object? valueOrNull(final String key, final Configuration cfg); +} + +/// A configuration object that holds the values for a set of configuration options. +class Configuration { + final List _options; + final Map _config; + final List _errors; + + /// Creates a configuration with the provided option values. + /// + /// This does not throw upon value parsing or validation errors, + /// instead the caller is responsible for checking if [errors] is non-empty. + Configuration.fromValues({ + required final Map values, + }) : this.resolve( + options: values.keys, + presetValues: values, + ); + + /// Creates a configuration with option values resolved from the provided context. + /// + /// [argResults] is used if provided. Otherwise [args] is used if provided. + /// + /// If [presetValues] is provided, the values present will override the other sources, + /// including if they are null. + /// + /// This does not throw upon value parsing or validation errors, + /// instead the caller is responsible for checking if [errors] is non-empty. + Configuration.resolve({ + required final Iterable options, + ArgResults? argResults, + final Iterable? args, + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + }) : _options = List.from(options), + _config = {}, + _errors = [] { + if (argResults == null && args != null) { + final parser = ArgParser(); + options.prepareForParsing(parser); + + try { + argResults = parser.parse(args); + } on FormatException catch (e) { + _errors.add(e.message); + for (var o in _options) { + _config[o] = const OptionResolution.error('Previous ArgParser error'); + } + return; + } + } + + _resolveWithArgResults( + args: argResults, + env: env, + configBroker: configBroker, + presetValues: presetValues, + ); + } + + /// Gets the option definitions for this configuration. + Iterable get options => _config.keys; + + /// Gets the errors that occurred during configuration resolution. + Iterable get errors => _errors; + + /// 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. + O? findOption({ + final String? enumName, + final String? argName, + final int? argPos, + final String? envName, + final String? configKey, + }) { + return _options.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); + }); + } + + /// Returns the value of a configuration option + /// identified by name, position, or key. + /// + /// Returns `null` if the option is not found or is not set. + V? findValueOf({ + final String? enumName, + final String? argName, + final int? argPos, + final String? envName, + final String? configKey, + }) { + final option = findOption( + enumName: enumName, + argName: argName, + argPos: argPos, + envName: envName, + configKey: configKey, + ); + if (option == null) return null; + return optionalValue(option as OptionDefinition); + } + + /// Returns the options that have a value matching + /// the source type test. + Iterable optionsWhereSource( + final bool Function(ValueSourceType source) test, + ) { + return _config.entries + .where((final e) => test(e.value.source)) + .map((final e) => e.key); + } + + /// 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 [ArgumentError] if the option is unknown. + V value(final OptionDefinition option) { + if (!(option.option.mandatory || + option.option.fromDefault != null || + option.option.defaultsTo != null)) { + throw StateError( + "Can't invoke non-nullable value() for ${option.qualifiedString()} " + 'which is neither mandatory nor has a default value.'); + } + final val = optionalValue(option); + if (val != null) return val; + + throw InvalidParseStateError( + 'No value available for ${option.qualifiedString()} due to previous errors'); + } + + /// Returns the value of an optional configuration option. + /// Returns `null` if the option is not set. + /// + /// Throws [ArgumentError] if the option is unknown. + V? optionalValue(final OptionDefinition option) { + final resolution = _getOptionResolution(option); + + return resolution.value as V?; + } + + /// Returns the source type of the given option's value. + ValueSourceType valueSourceType(final O option) { + final resolution = _getOptionResolution(option); + + return resolution.source; + } + + OptionResolution _getOptionResolution(final OptionDefinition option) { + if (!_options.contains(option)) { + throw ArgumentError( + '${option.qualifiedString()} is not part of this configuration'); + } + + final resolution = _config[option]; + + if (resolution == null) { + throw InvalidOptionConfigurationError(option, + 'Out-of-order dependency on not-yet-resolved ${option.qualifiedString()}'); + } + + if (resolution.error != null) { + throw InvalidParseStateError( + 'No value available for ${option.qualifiedString()} due to previous errors'); + } + + return resolution; + } + + void _resolveWithArgResults({ + final ArgResults? args, + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + }) { + final posArgs = (args?.rest ?? []).iterator; + final orderedOpts = _options.sorted((final a, final b) => + (a.option.argPos ?? -1).compareTo(b.option.argPos ?? -1)); + + final optionGroups = >{}; + + for (final opt in orderedOpts) { + OptionResolution resolution; + try { + if (presetValues != null && presetValues.containsKey(opt)) { + resolution = _resolvePresetValue(opt, presetValues[opt]); + } else { + resolution = opt.option._resolveValue( + this, + args: args, + posArgs: posArgs, + env: env, + configBroker: configBroker, + ); + } + + final group = opt.option.group; + if (group != null) { + optionGroups.update( + group, + (final value) => {...value, opt: resolution}, + ifAbsent: () => {opt: resolution}, + ); + } + + final error = resolution.error; + if (error != null) { + _errors.add(error); + } + } on InvalidParseStateError catch (e) { + // Represents an option resolution that depends on another option + // whose resolution failed, so this resolution fails in turn. + // Not adding to _errors to avoid double reporting. + resolution = OptionResolution.error(e.message); + } + + _config[opt] = resolution; + } + + _validateGroups(optionGroups); + + final remainingPosArgs = posArgs.restAsList(); + if (remainingPosArgs.isNotEmpty) { + _errors.add( + "Unexpected positional argument(s): '${remainingPosArgs.join("', '")}'"); + } + } + + OptionResolution _resolvePresetValue( + final O option, + final Object? value, + ) { + final resolution = value == null + ? const OptionResolution.noValue() + : OptionResolution(value: value, source: ValueSourceType.preset); + + final error = option.option._validateOptionValue(value); + if (error != null) return resolution.copyWithError(error); + return resolution; + } + + void _validateGroups( + final Map> optionGroups, + ) { + optionGroups.forEach((final group, final optionResolutions) { + final error = group.validate(optionResolutions); + if (error != null) { + _errors.add(error); + } + }); + } +} + +extension _RestAsList on Iterator { + /// Returns the remaining elements of this iterator as a list. + /// Consumes the iterator. + List restAsList() { + final list = []; + while (moveNext()) { + list.add(current); + } + return list; + } +} + +/// Indicates that the option definition is invalid. +class InvalidOptionConfigurationError extends Error { + final OptionDefinition option; + final String? message; + + InvalidOptionConfigurationError(this.option, [this.message]); + + @override + String toString() { + return message != null + ? 'Invalid configuration for ${option.qualifiedString()}: $message' + : 'Invalid configuration 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); +} diff --git a/lib/src/config/configuration_parser.dart b/lib/src/config/configuration_parser.dart new file mode 100644 index 0000000..e6e478d --- /dev/null +++ b/lib/src/config/configuration_parser.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'config_source.dart'; +import 'json_yaml_document.dart'; + +/// Encoding format of a configuration data source. +enum ConfigEncoding { + json, + yaml, +} + +/// Parsers for various configuration data sources. +/// +/// Produces a [ConfigurationSource] from a data source and a format. +/// +/// Supports the formats in [ConfigEncoding]. +abstract final class ConfigurationParser { + /// Parses a configuration from a string. + static ConfigurationSource fromString( + final String source, { + required final ConfigEncoding format, + }) { + switch (format) { + case ConfigEncoding.json: + return _JyConfigSource(JsonYamlDocument.fromJson(source)); + case ConfigEncoding.yaml: + return _JyConfigSource(JsonYamlDocument.fromYaml(source)); + } + } + + /// Parses a configuration from a file. + static ConfigurationSource fromFile( + final String filePath, + ) { + if (filePath.endsWith('.json')) { + return fromString( + _loadFile(filePath), + format: ConfigEncoding.json, + ); + } else if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + return fromString( + _loadFile(filePath), + format: ConfigEncoding.yaml, + ); + } + throw UnsupportedError('Unsupported file extension: $filePath'); + } + + static String _loadFile(final String filePath) { + final file = File(filePath); + if (!file.existsSync()) { + throw ArgumentError('File not found: $filePath'); + } + return file.readAsStringSync(); + } +} + +/// [ConfigurationSource] adapter for a [JsonYamlDocument]. +class _JyConfigSource implements ConfigurationSource { + final JsonYamlDocument _jyDocument; + + _JyConfigSource(this._jyDocument); + + @override + Object? valueOrNull(final String pointerKey) { + return _jyDocument.valueAtPointer(pointerKey); + } +} diff --git a/lib/src/config/file_system_options.dart b/lib/src/config/file_system_options.dart new file mode 100644 index 0000000..1fe0668 --- /dev/null +++ b/lib/src/config/file_system_options.dart @@ -0,0 +1,139 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; + +import 'configuration.dart'; + +enum PathExistMode { + mayExist, + mustExist, + mustNotExist, +} + +class DirParser extends ValueParser { + const DirParser(); + + @override + Directory parse(final String value) { + return Directory(value); + } +} + +/// Directory path configuration option. +/// +/// If the input is not valid according to the [mode], +/// the validation throws a [UsageException]. +class DirOption extends ConfigOptionBase { + final PathExistMode mode; + + const DirOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.mode = PathExistMode.mayExist, + }) : super(valueParser: const DirParser()); + + @override + void validateValue(final Directory value) { + super.validateValue(value); + + final type = FileSystemEntity.typeSync(value.path); + switch (mode) { + case PathExistMode.mayExist: + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.directory) { + throw UsageException('Path "${value.path}" is not a directory', ''); + } + break; + case PathExistMode.mustExist: + if (type == FileSystemEntityType.notFound) { + throw UsageException('Directory "${value.path}" does not exist', ''); + } + if (type != FileSystemEntityType.directory) { + throw UsageException('Path "${value.path}" is not a directory', ''); + } + break; + case PathExistMode.mustNotExist: + if (type != FileSystemEntityType.notFound) { + throw UsageException('Path "${value.path}" already exists', ''); + } + break; + } + } +} + +class FileParser extends ValueParser { + const FileParser(); + + @override + File parse(final String value) { + return File(value); + } +} + +/// File path configuration option. +/// +/// If the input is not valid according to the [mode], +/// the validation throws a [UsageException]. +class FileOption extends ConfigOptionBase { + final PathExistMode mode; + + const FileOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.group, + super.customValidator, + super.mandatory, + super.hide, + this.mode = PathExistMode.mayExist, + }) : super(valueParser: const FileParser()); + + @override + void validateValue(final File value) { + super.validateValue(value); + + final type = FileSystemEntity.typeSync(value.path); + switch (mode) { + case PathExistMode.mayExist: + if (type != FileSystemEntityType.notFound && + type != FileSystemEntityType.file) { + throw UsageException('Path "${value.path}" is not a file', ''); + } + break; + case PathExistMode.mustExist: + if (type == FileSystemEntityType.notFound) { + throw UsageException('File "${value.path}" does not exist', ''); + } + if (type != FileSystemEntityType.file) { + throw UsageException('Path "${value.path}" is not a file', ''); + } + break; + case PathExistMode.mustNotExist: + if (type != FileSystemEntityType.notFound) { + throw UsageException('Path "${value.path}" already exists', ''); + } + break; + } + } +} diff --git a/lib/src/config/json_yaml_document.dart b/lib/src/config/json_yaml_document.dart new file mode 100644 index 0000000..da7555d --- /dev/null +++ b/lib/src/config/json_yaml_document.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:rfc_6901/rfc_6901.dart'; +import 'package:yaml_codec/yaml_codec.dart'; + +/// A parsed JSON or YAML document. +/// +/// {@template json_yaml_document.valueAtPointer} +/// Supports accessing values identified by JSON pointers (RFC 6901). +/// See: https://datatracker.ietf.org/doc/html/rfc6901 +/// +/// Example: '/foo/0/bar' +/// {@endtemplate} +class JsonYamlDocument { + final Object? _document; + + JsonYamlDocument.fromJson(final String jsonSource) + : _document = jsonSource.isEmpty ? null : jsonDecode(jsonSource); + + JsonYamlDocument.fromYaml(final String yamlSource) + : _document = yamlDecode(yamlSource); + + /// {@macro json_yaml_document.valueAtPointer} + Object? valueAtPointer(final String pointerKey) { + final pointer = JsonPointer(pointerKey); + return pointer.read(_document, orElse: () => null); + } +} diff --git a/lib/src/config/multi_config_source.dart b/lib/src/config/multi_config_source.dart new file mode 100644 index 0000000..92bb3b4 --- /dev/null +++ b/lib/src/config/multi_config_source.dart @@ -0,0 +1,95 @@ +import 'config_source_provider.dart'; +import 'configuration.dart'; + +/// A [ConfigurationBroker] that combines configuration sources +/// from multiple providers, called configuration *domains*. +/// +/// Each configuration value is identified by a *qualified key*. +/// The first domain that matches the qualified key is used to retrieve the value. +/// This means that the order of the domains is significant if the +/// matching patterns overlap. +/// +/// Domains are matched using [Pattern], e.g. string prefixes or regular expressions. +/// +/// ### Regex domains +/// {@template multi_domain_config_broker.regex} +/// When using regular expressions to identify the domain, the value key is derived from +/// the qualified key depending on the capturing groups in the regex. +/// +/// - If the regex has no capturing groups: +/// - If the regex matches a shorter string than the qualified key, the value key is the remainder after the match.\ +/// This makes prefix matching simple. +/// - If the regex matches the entire qualified key, the value key is the entire qualified key.\ +/// This can be used for specific syntaxes like URLs. +/// +/// - If the regex has one or more capturing groups:\ +/// The value key is the string captured by the first group. +/// {@endtemplate} +class MultiDomainConfigBroker + implements ConfigurationBroker { + final Map> _configSourceProviders; + + /// Creates a [MultiDomainConfigBroker] with the given + /// configuration source providers, each identified by matching the + /// qualified key against a [Pattern]. + MultiDomainConfigBroker._(this._configSourceProviders); + + /// Creates a [MultiDomainConfigBroker] with the given + /// configuration source providers, each identified by matching the + /// qualified key against a [RegExp]. + /// + /// {@macro multi_domain_config_broker.regex} + MultiDomainConfigBroker.regex( + final Map> regexDomains, + ) : this._({ + for (final entry in regexDomains.entries) + RegExp(entry.key): entry.value, + }); + + /// Creates a [MultiDomainConfigBroker] from a map with domain prefixes as keys. + /// Each configuration value will be identified by a qualified key, + /// which consists of a domain name and a value key separated by a colon. + /// + /// E.g. `myapp:my_setting_name` + /// + /// The domain prefixes must not contain colons, and it is recommended + /// to only use lowercase letters, numbers, and underscores. + MultiDomainConfigBroker.prefix( + final Map> prefixDomains, + ) : this._({ + for (final entry in prefixDomains.entries) + '${entry.key}:': entry.value, + }); + + @override + Object? valueOrNull( + final String qualifiedKey, + final Configuration cfg, + ) { + final matchingProvider = _configSourceProviders.entries + .map( + (final entry) => (entry, entry.key.matchAsPrefix(qualifiedKey)), + ) + .firstWhere( + (final matches) => matches.$2 != null, + orElse: () => throw StateError( + 'No matching configuration domain for key: $qualifiedKey'), + ); + + final configSource = matchingProvider.$1.value.getConfigSource(cfg); + final match = matchingProvider.$2!; + + final String valueKey; + if (match.groupCount > 0) { + valueKey = match.group(1)!; + } else { + if (match.end < qualifiedKey.length) { + valueKey = qualifiedKey.substring(match.end); + } else { + valueKey = qualifiedKey; + } + } + + return configSource.valueOrNull(valueKey); + } +} diff --git a/lib/src/config/option_groups.dart b/lib/src/config/option_groups.dart new file mode 100644 index 0000000..b9b91eb --- /dev/null +++ b/lib/src/config/option_groups.dart @@ -0,0 +1,42 @@ +import 'configuration.dart'; +import 'option_resolution.dart'; + +/// An option group for mutually exclusive options. +/// +/// No more than one of the options in the group can be specified. +/// +/// Optionally the group can allow defaults, i.e. default values +/// are disregarded when counting the number of specified options. +/// +/// Optionally the group can be made mandatory, in which case +/// one of its options must be specified. +class MutuallyExclusive extends OptionGroup { + final bool mandatory; + final bool allowDefaults; + + const MutuallyExclusive( + super.name, { + this.mandatory = false, + this.allowDefaults = false, + }); + + @override + String? validate( + final Map optionResolutions, + ) { + final providedCount = optionResolutions.values + .where((final r) => allowDefaults ? r.isSpecified : r.hasValue) + .length; + + if (providedCount > 1) { + final opts = optionResolutions.keys.map((final o) => o.option); + return 'These options are mutually exclusive: ${opts.join(', ')}'; + } + + if (mandatory && providedCount == 0) { + return 'Option group $name requires one of the options to be provided'; + } + + return null; + } +} diff --git a/lib/src/config/option_resolution.dart b/lib/src/config/option_resolution.dart new file mode 100644 index 0000000..bba7af4 --- /dev/null +++ b/lib/src/config/option_resolution.dart @@ -0,0 +1,55 @@ +import 'source_type.dart'; + +final class OptionResolution { + final String? stringValue; + final V? value; + final String? error; + final ValueSourceType source; + + const OptionResolution._({ + required this.source, + this.stringValue, + this.value, + this.error, + }); + + const OptionResolution({ + required this.source, + this.stringValue, + this.value, + }) : error = null; + + const OptionResolution.noValue() + : source = ValueSourceType.noValue, + stringValue = null, + value = null, + error = null; + + const OptionResolution.error(this.error) + : source = ValueSourceType.noValue, + stringValue = null, + value = null; + + OptionResolution copyWithValue(final V newValue) => OptionResolution._( + source: source, + stringValue: stringValue, + value: newValue, + error: error, + ); + + OptionResolution copyWithError(final String error) => OptionResolution._( + source: source, + stringValue: stringValue, + value: value, + error: error, + ); + + /// Whether the option has a proper value (without errors). + bool get hasValue => source != ValueSourceType.noValue && error == null; + + /// Whether the option has a value that was specified explicitly (not default). + bool get isSpecified => hasValue && source != ValueSourceType.defaultValue; + + /// Whether the option has the default value. + bool get isDefault => hasValue && source == ValueSourceType.defaultValue; +} diff --git a/lib/src/config/options.dart b/lib/src/config/options.dart new file mode 100644 index 0000000..7745bea --- /dev/null +++ b/lib/src/config/options.dart @@ -0,0 +1,385 @@ +import 'package:meta/meta.dart'; + +import 'configuration.dart'; + +/// ValueParser that returns the input string unchanged. +class StringParser extends ValueParser { + const StringParser(); + + @override + String parse(final String value) { + return value; + } +} + +/// String value configuration option. +class StringOption extends ConfigOptionBase { + const StringOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + valueParser: const StringParser(), + ); +} + +/// Convenience class for multi-value configuration option for strings. +class MultiStringOption extends MultiOption { + /// Creates a MultiStringOption which splits input strings on commas. + const MultiStringOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + multiParser: const MultiParser(elementParser: StringParser()), + ); + + /// Creates a MultiStringOption which treats input strings as single elements. + const MultiStringOption.noSplit({ + super.argName, + super.argAliases, + super.argAbbrev, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super( + multiParser: const MultiParser( + elementParser: StringParser(), + separator: null, + ), + ); +} + +/// Parses a string value into an enum value. +/// Currently requires an exact, case-sensitive match. +class EnumParser extends ValueParser { + final List enumValues; + + const EnumParser(this.enumValues); + + @override + E parse(final String value) { + return enumValues.firstWhere( + (final e) => e.name == value, + orElse: () => throw FormatException( + '"$value" is not in ${valueHelpString()}', + ), + ); + } + + String valueHelpString() { + return enumValues.map((final e) => e.name).join('|'); + } +} + +/// Enum value configuration option. +/// +/// If the input is not one of the enum names, +/// the validation throws a [FormatException]. +class EnumOption extends ConfigOptionBase { + const EnumOption({ + required final EnumParser enumParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + }) : super(valueParser: enumParser); + + @override + String? valueHelpString() { + return valueHelp ?? (valueParser as EnumParser).valueHelpString(); + } +} + +/// Base class for configuration options that +/// support minimum and maximum range checking. +/// +/// If the input is outside the specified limits +/// the validation throws a [FormatException]. +class ComparableValueOption extends ConfigOptionBase { + final V? min; + final V? max; + + const ComparableValueOption({ + required super.valueParser, + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp, + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + this.min, + this.max, + }); + + @override + @mustCallSuper + void validateValue(final V value) { + super.validateValue(value); + + final mininum = min; + if (mininum != null && value.compareTo(mininum) < 0) { + throw FormatException( + '${valueParser.format(value)} is below the minimum ' + '(${valueParser.format(mininum)})', + ); + } + final maximum = max; + if (maximum != null && value.compareTo(maximum) > 0) { + throw FormatException( + '${valueParser.format(value)} is above the maximum ' + '(${valueParser.format(maximum)})', + ); + } + } +} + +class IntParser extends ValueParser { + const IntParser(); + + @override + int parse(final String value) { + return int.parse(value); + } +} + +/// Integer value configuration option. +/// +/// Supports minimum and maximum range checking. +class IntOption extends ComparableValueOption { + const IntOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'integer', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const IntParser()); +} + +/// Parses a date string into a [DateTime] object. +/// Throws [FormatException] if parsing failed. +/// +/// This implementation is more forgiving than [DateTime.parse]. +/// In addition to the standard T and space separators between +/// date and time it also allows [-_/:t]. +class DateTimeParser extends ValueParser { + const DateTimeParser(); + + @override + DateTime parse(final String value) { + final val = DateTime.tryParse(value); + if (val != null) return val; + if (value.length >= 11 && '-_/:t'.contains(value[10])) { + final val = + DateTime.tryParse('${value.substring(0, 10)}T${value.substring(11)}'); + if (val != null) return val; + } + throw FormatException('Invalid date-time "$value"'); + } +} + +/// Date-time value configuration option. +/// +/// Supports minimum and maximum range checking. +class DateTimeOption extends ComparableValueOption { + const DateTimeOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'YYYY-MM-DDtHH:MM:SSz', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const DateTimeParser()); +} + +/// Parses a duration string into a [Duration] object. +/// +/// The input string must be a number followed by an optional unit +/// which is one of: seconds (s), minutes (m), hours (h), days (d), +/// milliseconds (ms), or microseconds (us). +/// If no unit is specified, seconds are assumed. +/// Examples: +/// - `10`, equivalent to `10s` +/// - `10m` +/// - `10h` +/// - `10d` +/// - `10ms` +/// - `10us` +/// +/// Throws [FormatException] if parsing failed. +class DurationParser extends ValueParser { + const DurationParser(); + + @override + Duration parse(final String value) { + // integer followed by an optional unit (s, m, h, d, ms, us) + const pattern = r'^(-?\d+)([smhd]|ms|us)?$'; + final regex = RegExp(pattern); + final match = regex.firstMatch(value); + + if (match == null || match.groupCount != 2) { + throw FormatException('Invalid duration value "$value"'); + } + final valueStr = match.group(1); + final unit = match.group(2) ?? 's'; + final val = int.parse(valueStr ?? ''); + switch (unit) { + case 's': + return Duration(seconds: val); + case 'm': + return Duration(minutes: val); + case 'h': + return Duration(hours: val); + case 'd': + return Duration(days: val); + case 'ms': + return Duration(milliseconds: val); + case 'us': + return Duration(microseconds: val); + default: + throw FormatException('Invalid duration unit "$unit".'); + } + } + + @override + String format(final Duration value) { + if (value == Duration.zero) return '0s'; + + final sign = value.isNegative ? '-' : ''; + final d = _unitStr(value.inDays, 24, 'd'); + final h = _unitStr(value.inHours, 24, 'h'); + final m = _unitStr(value.inMinutes, 60, 'm'); + final s = _unitStr(value.inSeconds, 60, 's'); + final ms = _unitStr(value.inMilliseconds, 1000, 'ms'); + final us = _unitStr(value.inMicroseconds, 1000, 'us'); + + return '$sign$d$h$m$s$ms$us'; + } + + static String _unitStr(final int value, final int mod, final String unit) { + final absValue = value.abs(); + return absValue % mod > 0 ? '${absValue.remainder(mod)}$unit' : ''; + } +} + +/// Duration value configuration option. +/// +/// Supports minimum and maximum range checking. +class DurationOption extends ComparableValueOption { + const DurationOption({ + super.argName, + super.argAliases, + super.argAbbrev, + super.argPos, + super.envName, + super.configKey, + super.fromCustom, + super.fromDefault, + super.defaultsTo, + super.helpText, + super.valueHelp = 'integer[s|m|h|d]', + super.allowedHelp, + super.group, + super.allowedValues, + super.customValidator, + super.mandatory, + super.hide, + super.min, + super.max, + }) : super(valueParser: const DurationParser()); + + @override + String? defaultValueString() { + final defValue = defaultValue(); + if (defValue == null) return null; + return valueParser.format(defValue); + } +} diff --git a/lib/src/config/source_type.dart b/lib/src/config/source_type.dart new file mode 100644 index 0000000..b6058bc --- /dev/null +++ b/lib/src/config/source_type.dart @@ -0,0 +1,10 @@ +/// The type of source that an option's value was resolved from. +enum ValueSourceType { + noValue, + preset, + arg, + envVar, + config, + custom, + defaultValue, +} diff --git a/pubspec.lock b/pubspec.lock index bfea99a..55a172d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -26,10 +26,10 @@ packages: dependency: "direct main" description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: @@ -63,7 +63,7 @@ packages: source: hosted version: "0.1.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -215,7 +215,7 @@ packages: source: hosted version: "0.12.16+1" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c @@ -294,6 +294,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + rfc_6901: + dependency: "direct main" + description: + name: rfc_6901 + sha256: df1bbfa3d023009598f19636d6114c6ac1e0b7bb7bf6a260f0e6e6ce91416820 + url: "https://pub.dev" + source: hosted + version: "0.2.0" serverpod_lints: dependency: "direct dev" description: @@ -422,6 +430,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" + test_descriptor: + dependency: "direct dev" + description: + name: test_descriptor + sha256: "9ce468c97ae396e8440d26bb43763f84e2a2a5331813ee5a397cb4da481aaf9a" + url: "https://pub.dev" + source: hosted + version: "2.0.2" type_plus: dependency: transitive description: @@ -490,9 +506,25 @@ packages: dependency: transitive description: name: yaml - sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" + yaml_codec: + dependency: "direct main" + description: + name: yaml_codec + sha256: a75848e1ccd9526959d1b3dd41b3b14a652f93aeadfb9261a7d6446072ef214c + url: "https://pub.dev" + source: hosted + version: "1.0.0" + yaml_writer: + dependency: transitive + description: + name: yaml_writer + sha256: "69651cd7238411179ac32079937d4aa9a2970150d6b2ae2c6fe6de09402a5dc5" + url: "https://pub.dev" + source: hosted + version: "2.1.0" sdks: dart: ">=3.5.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 36ee76e..35784ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,14 +15,19 @@ environment: sdk: ^3.1.0 dependencies: - args: ^2.4.0 + args: ^2.7.0 ci: ^0.1.0 + collection: ^1.19.1 http: '>=0.13.0 <2.0.0' + meta: ^1.16.0 path: ^1.8.2 pub_api_client: '>=2.4.0 <4.0.0' pub_semver: ^2.1.4 + rfc_6901: ^0.2.0 super_string: ^1.0.3 + yaml_codec: ^1.0.0 dev_dependencies: test: ^1.24.0 serverpod_lints: '>=1.2.6' + test_descriptor: ^2.0.2 diff --git a/test/config/config_source_test.dart b/test/config/config_source_test.dart new file mode 100644 index 0000000..134fc83 --- /dev/null +++ b/test/config/config_source_test.dart @@ -0,0 +1,259 @@ +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +void main() { + group( + 'Given a MultiDomainConfigBroker with two domains and correctly configured options', + () { + const yamlContentOpt = StringOption( + argName: 'yaml-content', + envName: 'YAML_CONTENT', + ); + const jsonContentOpt = StringOption( + argName: 'json-content', + envName: 'JSON_CONTENT', + ); + const yamlProjectIdOpt = StringOption( + configKey: 'yamlOption:/project/projectId', + ); + const jsonProjectIdOpt = StringOption( + configKey: 'jsonOption:/project/projectId', + ); + final options = [ + yamlContentOpt, + jsonContentOpt, + yamlProjectIdOpt, + jsonProjectIdOpt, + ]; + + late ConfigurationBroker configSource; + + setUp(() { + configSource = MultiDomainConfigBroker.prefix({ + 'yamlOption': OptionContentConfigProvider( + contentOption: yamlContentOpt, + format: ConfigEncoding.yaml, + ), + 'jsonOption': OptionContentConfigProvider( + contentOption: jsonContentOpt, + format: ConfigEncoding.json, + ), + }); + }); + + test( + 'when the YAML content option has data ' + 'then the correct value is retrieved', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--yaml-content', + ''' +project: + projectId: '123' +''', + ], + configBroker: configSource, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(yamlProjectIdOpt), equals('123')); + expect(config.optionalValue(jsonProjectIdOpt), isNull); + }); + + test( + 'when the JSON content option has data ' + 'then the correct value is retrieved', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--json-content', + ''' +{ + "project": { + "projectId": "123" + } +} +''', + ], + configBroker: configSource, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(yamlProjectIdOpt), isNull); + expect(config.optionalValue(jsonProjectIdOpt), equals('123')); + }); + + test( + 'when the YAML content option has data of the wrong type ' + 'then an appropriate error is registered', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--yaml-content', + ''' +project: + projectId: 123 +''', + ], + configBroker: configSource, + ); + + expect( + config.errors, + contains( + equals( + 'configuration key `yamlOption:/project/projectId` value 123 is of type int, not String.', + ), + )); + expect( + () => config.optionalValue(yamlProjectIdOpt), + throwsA(isA()), + ); + expect(config.optionalValue(jsonProjectIdOpt), isNull); + }); + + test( + 'when the JSON content option has data of the wrong type ' + 'then an appropriate error is registered', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--json-content', + ''' +{ + "project": { + "projectId": 123 + } +} +''', + ], + configBroker: configSource, + ); + + expect( + config.errors, + contains( + equals( + 'configuration key `jsonOption:/project/projectId` value 123 is of type int, not String.', + ), + )); + expect( + () => config.optionalValue(jsonProjectIdOpt), + throwsA(isA()), + ); + expect(config.optionalValue(yamlProjectIdOpt), isNull); + }); + + test( + 'when the YAML content option has malformed data ' + 'then an appropriate error is registered', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--yaml-content', + ''' +project: +projectId:123 +''', + ], + configBroker: configSource, + ); + + expect( + config.errors, + contains(contains( + 'Failed to resolve configuration key `yamlOption:/project/projectId`: Error on line', + ))); + expect( + () => config.optionalValue(yamlProjectIdOpt), + throwsA(isA()), + ); + expect(config.optionalValue(jsonProjectIdOpt), isNull); + }); + + test( + 'when the JSON content option has malformed data ' + 'then an appropriate error is registered', () async { + final config = Configuration.resolve( + options: options, + args: [ + '--json-content', + ''' +{ + "project": { + "projectId": + } +} +''', + ], + configBroker: configSource, + ); + + expect( + config.errors, + contains(contains( + 'Failed to resolve configuration key `jsonOption:/project/projectId`: FormatException: Unexpected character', + ))); + expect( + () => config.optionalValue(jsonProjectIdOpt), + throwsA(isA()), + ); + expect(config.optionalValue(yamlProjectIdOpt), isNull); + }); + }); + + group( + 'Given a MultiDomainConfigBroker with a domain and misconfigured options', + () { + const yamlContentOpt = StringOption( + argName: 'yaml-content', + envName: 'YAML_CONTENT', + ); + const yamlProjectIdOpt = StringOption( + configKey: 'yamlOption:/project/projectId', + ); + const missingDomainOpt = StringOption( + configKey: '/project/projectId', + ); + const unknownDomainOpt = StringOption( + configKey: 'unknown:/project/projectId', + ); + final options = [ + yamlContentOpt, + yamlProjectIdOpt, + missingDomainOpt, + unknownDomainOpt, + ]; + + late ConfigurationBroker configSource; + + setUp(() { + configSource = MultiDomainConfigBroker.prefix({ + 'yamlOption': OptionContentConfigProvider( + contentOption: yamlContentOpt, + format: ConfigEncoding.yaml, + ), + }); + }); + + test( + 'when creating the configuration ' + 'then the expected errors are registered', () async { + expect( + () => Configuration.resolve( + options: options, + configBroker: configSource, + ), + throwsA(isA().having( + (final e) => e.message, + 'message', + equals( + 'No matching configuration domain for key: /project/projectId', + ), + )), + ); + }); + }); +} diff --git a/test/config/configuration_test.dart b/test/config/configuration_test.dart new file mode 100644 index 0000000..efe8bef --- /dev/null +++ b/test/config/configuration_test.dart @@ -0,0 +1,1512 @@ +import 'package:args/args.dart'; +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +void main() async { + group('Given invalid configuration abbrevation without full name', () { + const projectIdOpt = StringOption( + argAbbrev: 'p', + ); + final parser = ArgParser(); + + test('when preparing for parsing then throws exception', () async { + expect( + () => [projectIdOpt].prepareForParsing(parser), + throwsA(allOf( + isA(), + (final e) => e.toString().contains( + "An argument option can't have an abbreviation but not a full name", + ), + )), + ); + }); + }); + + group('Given invalid configuration mandatory with default value', () { + const projectIdOpt = StringOption( + mandatory: true, + defaultsTo: 'default', + ); + final parser = ArgParser(); + + test('when preparing for parsing then throws exception', () async { + expect( + () => [projectIdOpt].prepareForParsing(parser), + throwsA(allOf( + isA(), + (final e) => e + .toString() + .contains("Mandatory options can't have default values"), + )), + ); + }); + }); + + group( + 'Given invalid configuration mandatory with default value from function', + () { + const projectIdOpt = StringOption( + mandatory: true, + fromDefault: _defaultValueFunction, + ); + + final parser = ArgParser(); + + test('when preparing for parsing then throws exception', () async { + expect( + () => [projectIdOpt].prepareForParsing(parser), + throwsA(allOf( + isA(), + (final e) => e + .toString() + .contains("Mandatory options can't have default values"), + )), + ); + }); + }); + + group('Given a configuration option definition', () { + const projectIdOpt = StringOption( + argName: 'project', + ); + + group('added to the arg parser', () { + final parser = ArgParser(); + [projectIdOpt].prepareForParsing(parser); + + test('then it is listed as an option there', () async { + expect(parser.options, contains('project')); + }); + + test('when present on the command line, then it is successfully parsed', + () async { + final results = parser.parse(['--project', '123']); + expect(results.option('project'), '123'); + }); + + test('when present on the command line, then it is marked as parsed', + () async { + final results = parser.parse(['--project', '123']); + expect(results.wasParsed('project'), isTrue); + }); + + test( + 'when not present on the command line, then it is marked as not parsed', + () async { + final results = parser.parse(['123']); + expect(results.wasParsed('project'), isFalse); + }); + + test('when misspelled on the command line, then it fails to parse', + () async { + expect(() => parser.parse(['--projectid', '123']), + throwsA(isA())); + }); + + test('when present twice on the command line, the value is the last one', + () async { + final results = parser.parse(['--project', '123', '--project', '456']); + expect(results.option('project'), '456'); + }); + }); + }); + + group('Given a configuration option defined for all sources', () { + const projectIdOpt = StringOption( + argName: 'project', + envName: 'PROJECT_ID', + configKey: 'config:/projectId', + fromCustom: _customValueFunction, + fromDefault: _defaultValueFunction, + defaultsTo: 'constDefaultValue', + ); + + test('then command line argument has first precedence', () async { + final args = ['--project', '123']; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('123')); + }); + + test('then env variable has second precedence', () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('456')); + }); + + test('then configKey has third precedence', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('configSourceValue')); + }); + + test('then fromCustom function has fourth precedence', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: _TestConfigBroker({}), + ); + expect(config.value(projectIdOpt), equals('customValueFunction')); + }); + + test('when provided twice via args then the last value is used', () async { + // Note: This is the behavior of ArgParser. + // It may be considered to make this a usage error instead. + final args = ['--project', '123', '--project', '456']; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + ); + expect(config.value(projectIdOpt), equals('456')); + }); + }); + + group('Given a configuration option with a defaultsTo value', () { + const projectIdOpt = StringOption( + argName: 'project', + envName: 'PROJECT_ID', + configKey: 'config:/projectId', + fromCustom: _customNullFunction, + defaultsTo: 'constDefaultValue', + ); + + test('then command line argument has first precedence', () async { + final args = ['--project', '123']; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('123')); + }); + + test('then env variable has second precedence', () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('456')); + }); + + test('then configKey has third precedence', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: + _TestConfigBroker({'config:/projectId': 'configSourceValue'}), + ); + expect(config.value(projectIdOpt), equals('configSourceValue')); + }); + + test('then defaultsTo value has last precedence', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + configBroker: _TestConfigBroker({}), + ); + expect(config.value(projectIdOpt), equals('constDefaultValue')); + }); + }); + + group('Given a configuration flag option', () { + const verboseFlag = FlagOption( + argName: 'verbose', + envName: 'VERBOSE', + defaultsTo: false, + ); + + test('then command line argument has first precedence', () async { + final args = ['--verbose']; + final envVars = {'VERBOSE': 'false'}; + final config = Configuration.resolve( + options: [verboseFlag], + args: args, + env: envVars, + ); + expect(config.value(verboseFlag), isTrue); + }); + + test('then env variable has second precedence', () async { + final args = []; + final envVars = {'VERBOSE': 'true'}; + final config = Configuration.resolve( + options: [verboseFlag], + args: args, + env: envVars, + ); + expect(config.value(verboseFlag), isTrue); + }); + }); + + group('Given a configuration flag option', () { + const verboseFlag = FlagOption( + argName: 'verbose', + envName: 'VERBOSE', + defaultsTo: true, + ); + + test('then defaultsTo value has last precedence', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [verboseFlag], + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(verboseFlag), isTrue); + }); + }); + + group('Given an optional configuration option', () { + const projectIdOpt = StringOption( + argName: 'project', + envName: 'PROJECT_ID', + ); + + test('when provided as argument then value() still throws StateError', + () async { + final args = ['--project', '123']; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(() => config.value(projectIdOpt), throwsA(isA())); + }); + + test('when provided as env variable then value() still throws StateError', + () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(() => config.value(projectIdOpt), throwsA(isA())); + }); + + test('when not provided then calling value() throws StateError', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(() => config.value(projectIdOpt), throwsA(isA())); + }); + + test('when provided as argument then parsing succeeds', () async { + final args = ['--project', '123']; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.optionalValue(projectIdOpt), equals('123')); + }); + + test('when provided as env variable then parsing succeeds', () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.optionalValue(projectIdOpt), equals('456')); + }); + + test('when not provided then parsing succeeds and results in null', + () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.optionalValue(projectIdOpt), isNull); + }); + }); + + group('Given a mandatory configuration option', () { + const projectIdOpt = StringOption( + argName: 'project', + envName: 'PROJECT_ID', + mandatory: true, + ); + + test('when provided as argument then parsing succeeds', () async { + final args = ['--project', '123']; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(projectIdOpt), equals('123')); + }); + + test('when provided as env variable then parsing succeeds', () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(projectIdOpt), equals('456')); + }); + + test('when not provided then parsing has error', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.errors, hasLength(1)); + expect(config.errors.first, 'option `project` is mandatory'); + expect( + () => config.value(projectIdOpt), + throwsA(isA().having( + (final e) => e.message, + 'message', + contains( + 'No value available for option `project` due to previous errors'), + )), + ); + }); + }); + + group('Given a mandatory env-only configuration option', () { + const projectIdOpt = StringOption( + envName: 'PROJECT_ID', + mandatory: true, + ); + + test('when provided as argument then parsing fails', () async { + final parser = ArgParser(); + [projectIdOpt].prepareForParsing(parser); + expect(() => parser.parse(['--project', '123']), + throwsA(isA())); + }); + + test('when provided as env variable then parsing succeeds', () async { + final args = []; + final envVars = {'PROJECT_ID': '456'}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(projectIdOpt), equals('456')); + }); + + test('when not provided then parsing has error', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: [projectIdOpt], + args: args, + env: envVars, + ); + expect(config.errors, hasLength(1)); + expect(config.errors.first, + 'environment variable `PROJECT_ID` is mandatory'); + expect( + () => config.value(projectIdOpt), + throwsA(isA().having( + (final e) => e.message, + 'message', + contains( + 'No value available for environment variable `PROJECT_ID` due to previous errors'), + )), + ); + }); + }); + + group('Given invalid combinations of options', () { + const argNameOpt = StringOption( + argName: 'arg-name', + ); + const envNameOpt = StringOption( + envName: 'env-name', + ); + const duplicateOpt = StringOption( + argName: 'arg-name', + envName: 'env-name', + argPos: 0, + ); + const argPosOpt = StringOption( + argPos: 0, + ); + const argPos2Opt = StringOption( + argPos: 2, + ); + + test( + 'when duplicate arg names specified then InvalidOptionConfigurationException is thrown', + () async { + final parser = ArgParser(); + expect(() => [argNameOpt, duplicateOpt].prepareForParsing(parser), + throwsA(isA())); + }); + + test( + 'when duplicate env names specified then InvalidOptionConfigurationException is thrown', + () async { + final parser = ArgParser(); + expect(() => [envNameOpt, duplicateOpt].prepareForParsing(parser), + throwsA(isA())); + }); + + test( + 'when duplicate arg positions specified then InvalidOptionConfigurationException is thrown', + () async { + final parser = ArgParser(); + expect(() => [argPosOpt, duplicateOpt].prepareForParsing(parser), + throwsA(isA())); + }); + + test( + 'when non-consecutive arg positions specified then InvalidOptionConfigurationException is thrown', + () async { + final parser = ArgParser(); + expect(() => [argPosOpt, argPos2Opt].prepareForParsing(parser), + throwsA(isA())); + }); + + test( + 'when first arg position does not start at 0 then InvalidOptionConfigurationException is thrown', + () async { + final parser = ArgParser(); + expect(() => [argPos2Opt].prepareForParsing(parser), + throwsA(isA())); + }); + }); + + group('Given an optional positional argument option', () { + const positionalOpt = StringOption( + argPos: 0, + ); + const projectIdOpt = StringOption( + argName: 'project', + ); + final options = [positionalOpt, projectIdOpt]; + + test('when provided as lone positional argument then parsing succeeds', + () async { + final args = ['pos-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + }); + + test('when provided before named argument then parsing succeeds', () async { + final args = ['pos-arg', '--project', '123']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + }); + + test('when provided after named argument then parsing succeeds', () async { + final args = ['--project', '123', 'pos-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(positionalOpt), equals('pos-arg')); + }); + + test( + 'when not provided then parsing succeeds and value() throws StateError', + () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(() => config.value(positionalOpt), throwsA(isA())); + }); + + test('when not provided then parsing succeeds and its value is null', + () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(positionalOpt), isNull); + }); + }); + + group('Given a mandatory positional argument option', () { + const positionalOpt = StringOption( + argPos: 0, + mandatory: true, + ); + const projectIdOpt = StringOption( + argName: 'project', + ); + final options = [positionalOpt, projectIdOpt]; + + test('when provided as lone positional argument then parsing succeeds', + () async { + final args = ['pos-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(positionalOpt), equals('pos-arg')); + }); + + test('when provided before named argument then parsing succeeds', () async { + final args = ['pos-arg', '--project', '123']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(positionalOpt), equals('pos-arg')); + }); + + test('when provided after named argument then parsing succeeds', () async { + final args = ['--project', '123', 'pos-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.value(positionalOpt), equals('pos-arg')); + }); + + test('when not provided then parsing has error', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, hasLength(1)); + expect(config.errors.first, 'positional argument 0 is mandatory'); + expect( + () => config.value(positionalOpt), + throwsA(isA().having( + (final e) => e.message, + 'message', + contains( + 'No value available for positional argument 0 due to previous errors'), + )), + ); + }); + }); + + group('Given two argument options that can be both positional and named', () { + const firstOpt = StringOption( + argName: 'first', + argPos: 0, + ); + const secondOpt = StringOption( + argName: 'second', + argPos: 1, + ); + final options = [firstOpt, secondOpt]; + + test('when provided as lone positional argument then parsing succeeds', + () async { + final args = ['1st-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test('when provided as lone named argument then parsing succeeds', + () async { + final args = ['--first', '1st-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when second pos arg is provided as lone named argument then parsing succeeds', + () async { + final args = ['--second', '2st-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), equals('2st-arg')); + }); + + test('when provided as two positional args then parsing succeeds', + () async { + final args = ['1st-arg', '2nd-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when provided as 1 positional & 1 named argument then parsing succeeds', + () async { + final args = ['1st-arg', '--second', '2nd-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when provided as 1 named & 1 positional argument then parsing succeeds', + () async { + final args = ['--first', '1st-arg', '2nd-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when provided as 1 named & 1 positional argument in reverse order then parsing succeeds', + () async { + final args = ['2nd-arg', '--first', '1st-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test('when provided as 2 named arguments then parsing succeeds', () async { + final args = ['--first', '1st-arg', '--second', '2nd-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when provided as 2 named arguments in reverse order then parsing succeeds', + () async { + final args = ['--second', '2nd-arg', '--first', '1st-arg']; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test('when not provided then parsing succeeds and both are null', () async { + final args = []; + final envVars = {}; + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), isNull); + }); + + test('when superfluous positional argument provided then parsing has error', + () async { + final args = ['1st-arg', '2nd-arg', '3rd-arg']; + final envVars = {}; + + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.first, "Unexpected positional argument(s): '3rd-arg'"); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when superfluous positional argument provided after named args then parsing has error', + () async { + final args = ['--first', '1st-arg', '--second', '2nd-arg', '3rd-arg']; + final envVars = {}; + + final config = Configuration.resolve( + options: options, + args: args, + env: envVars, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.first, "Unexpected positional argument(s): '3rd-arg'"); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + }); + + group('Given two options that have aliases', () { + const firstOpt = StringOption( + argName: 'first', + argAliases: ['alias-first-a', 'alias-first-b'], + ); + const secondOpt = StringOption( + argName: 'second', + argAliases: ['alias-second-a', 'alias-second-b'], + ); + final options = [firstOpt, secondOpt]; + + test( + 'when the first option is provided using primary name then parsing succeeds', + () async { + final args = ['--first', '1st-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the first option is provided using first alias then parsing succeeds', + () async { + final args = ['--alias-first-a', '1st-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the first option is provided using second alias then parsing succeeds', + () async { + final args = ['--alias-first-b', '1st-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the first option is provided twice using aliases then the last value is used', + () async { + final args = ['--alias-first-a', '1st-arg', '--alias-first-b', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('2nd-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when both options are provided using their aliases then parsing succeeds', + () async { + final args = [ + '--alias-first-a', + '1st-arg', + '--alias-second-b', + '2nd-arg' + ]; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + }); + + group('Given two options that are mutually exclusive', () { + const firstOpt = StringOption( + argName: 'first', + envName: 'FIRST', + group: MutuallyExclusive('mutex-group'), + ); + const secondOpt = StringOption( + argName: 'second', + envName: 'SECOND', + group: MutuallyExclusive('mutex-group'), + ); + final options = [firstOpt, secondOpt]; + + test( + 'when the first of the mut-ex options is provided as argument then parsing succeeds', + () async { + final args = ['--first', '1st-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the second of the mut-ex options is provided as argument then parsing succeeds', + () async { + final args = ['--second', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when the first of the mut-ex options is provided as env var then parsing succeeds', + () async { + final config = Configuration.resolve( + options: options, + env: {'FIRST': '1st-arg'}, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the second of the mut-ex options is provided as env var then parsing succeeds', + () async { + final config = Configuration.resolve( + options: options, + env: {'SECOND': '2nd-arg'}, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when both mut-ex options are provided as arguments then parsing has error', + () async { + final args = ['--first', '1st-arg', '--second', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'These options are mutually exclusive: first, second', + ); + }); + + test( + 'when both mut-ex options are provided as arg and env var then parsing has error', + () async { + final config = Configuration.resolve( + options: options, + args: ['--first', '1st-arg'], + env: {'SECOND': '2nd-arg'}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'These options are mutually exclusive: first, second', + ); + }); + + test( + 'when neither of the mut-ex options are provided then parsing succeeds', + () async { + final config = Configuration.resolve( + options: options, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), isNull); + }); + }); + + group('Given two options that are mandatory and mutually exclusive', () { + const group = MutuallyExclusive('mutex-group', mandatory: true); + const firstOpt = StringOption( + argName: 'first', + envName: 'FIRST', + group: group, + ); + const secondOpt = StringOption( + argName: 'second', + envName: 'SECOND', + group: group, + ); + final options = [firstOpt, secondOpt]; + + test( + 'when the first of the mut-ex options is provided as argument then parsing succeeds', + () async { + final args = ['--first', '1st-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the second of the mut-ex options is provided as argument then parsing succeeds', + () async { + final args = ['--second', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when the first of the mut-ex options is provided as env var then parsing succeeds', + () async { + final config = Configuration.resolve( + options: options, + env: {'FIRST': '1st-arg'}, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + }); + + test( + 'when the second of the mut-ex options is provided as env var then parsing succeeds', + () async { + final config = Configuration.resolve( + options: options, + env: {'SECOND': '2nd-arg'}, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), isNull); + expect(config.optionalValue(secondOpt), equals('2nd-arg')); + }); + + test( + 'when both mut-ex options are provided as arguments then parsing has error', + () async { + final args = ['--first', '1st-arg', '--second', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'These options are mutually exclusive: first, second', + ); + }); + + test( + 'when both mut-ex options are provided as arg and env var then parsing has error', + () async { + final config = Configuration.resolve( + options: options, + args: ['--first', '1st-arg'], + env: {'SECOND': '2nd-arg'}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'These options are mutually exclusive: first, second', + ); + }); + + test( + 'when neither of the mut-ex options are provided then parsing has error', + () async { + final config = Configuration.resolve( + options: options, + args: [], + env: {}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'Option group mutex-group requires one of the options to be provided', + ); + }); + }); + + group('Given four mutually exclusive options in two option groups', () { + const firstOpt = StringOption( + argName: 'first', + group: MutuallyExclusive('mutex-group-a'), + ); + const secondOpt = StringOption( + argName: 'second', + group: MutuallyExclusive('mutex-group-a'), + ); + const thirdOpt = StringOption( + argName: 'third', + group: MutuallyExclusive('mutex-group-b'), + ); + const fourthOpt = StringOption( + argName: 'fourth', + group: MutuallyExclusive('mutex-group-b'), + ); + final options = [firstOpt, secondOpt, thirdOpt, fourthOpt]; + + test('when one option from each group is provided then parsing succeeds', + () async { + final args = ['--first', '1st-arg', '--third', '3rd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(firstOpt), equals('1st-arg')); + expect(config.optionalValue(secondOpt), isNull); + expect(config.optionalValue(thirdOpt), equals('3rd-arg')); + expect(config.optionalValue(fourthOpt), isNull); + }); + + test( + 'when two options from the same group are provided then parsing has error', + () async { + final args = ['--first', '1st-arg', '--second', '2nd-arg']; + final config = Configuration.resolve( + options: options, + args: args, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + 'These options are mutually exclusive: first, second', + ); + }); + }); + + group('Given a configuration source option that depends on another option', + () { + const projectIdOpt = StringOption( + configKey: 'config:/project/projectId', + ); + const configFileOpt = StringOption( + argName: 'file', + envName: 'FILE', + defaultsTo: 'config.yaml', + ); + final configSource = _dependentConfigBroker( + {'config:/project/projectId': '123'}, + configFileOpt, + ); + + test('when dependee is specified after depender then parsing succeeds', + () async { + final options = [configFileOpt, projectIdOpt]; + + final config = Configuration.resolve( + options: options, + args: ['--file', 'config.yaml'], + env: {}, + configBroker: configSource, + ); + expect(config.errors, isEmpty); + expect(config.optionalValue(projectIdOpt), equals('123')); + }); + + test('when dependee is specified before depender then parsing fails', + () async { + final options = [projectIdOpt, configFileOpt]; + + expect( + () => Configuration.resolve( + options: options, + args: ['--file', 'config.yaml'], + env: {}, + configBroker: configSource, + ), + throwsA(isA().having( + (final e) => e.message, + 'message', + 'Out-of-order dependency on not-yet-resolved option `file`')), + ); + }); + }); + + group('Given two typed argument options', () { + const strOpt = StringOption( + argName: 'string', + ); + const intOpt = IntOption( + argName: 'int', + ); + + test( + 'when constructing Configuration ' + 'with direct option values of correct type ' + 'then it succeeds', () async { + final config = Configuration.fromValues( + values: { + strOpt: '1', + intOpt: 2, + }, + ); + + expect(config.errors, isEmpty); + expect(config.optionalValue(strOpt), equals('1')); + expect(config.optionalValue(intOpt), equals(2)); + }); + + test( + 'when constructing Configuration ' + 'with direct option values of incorrect type ' + 'then construction throws TypeError', () async { + expect( + () => Configuration.fromValues( + values: { + strOpt: 1, + intOpt: '2', + }, + ), + throwsA(isA().having( + (final e) => e.toString(), + 'toString()', + contains("type 'int' is not a subtype of type 'String?' of 'value'"), + )), + ); + }); + + test( + 'when accessing an unknown option ' + 'then an ArgumentError is thrown', () async { + final config = Configuration.fromValues( + values: { + strOpt: '1', + intOpt: 2, + }, + ); + + const unknownOption = IntOption(argName: 'otherInt'); + expect( + () => config.optionalValue(unknownOption), + throwsA(isA().having( + (final e) => e.message, + 'message', + 'option `otherInt` is not part of this configuration', + )), + ); + }); + }); + + group('Given a Configuration with an options enum', () { + final Configuration<_TestOption> config = Configuration.fromValues( + values: <_TestOption, Object>{ + _TestOption.stringOpt: '1', + _TestOption.intOpt: 2, + }, + ); + + test( + 'when getting the string option value via the enum name ' + 'then it succeeds', () async { + final value = config.findValueOf(enumName: _TestOption.stringOpt.name); + expect(value, equals('1')); + }); + + test( + 'when getting the string option value via the arg name ' + 'then it succeeds', () async { + final value = config.findValueOf(argName: 'string'); + expect(value, equals('1')); + }); + + test( + 'when getting the string option value via the arg pos ' + 'then it succeeds', () async { + final value = config.findValueOf(argPos: 0); + expect(value, equals('1')); + }); + + test( + 'when getting the string option value via the env name ' + 'then it succeeds', () async { + final value = config.findValueOf(envName: 'STRING'); + expect(value, equals('1')); + }); + + test( + 'when getting the string option value via the config key ' + 'then it succeeds', () async { + final value = config.findValueOf(configKey: 'config:/string'); + expect(value, equals('1')); + }); + + test( + 'when getting the int option value via the enum name ' + 'then it succeeds', () async { + final value = config.findValueOf(enumName: _TestOption.intOpt.name); + expect(value, equals(2)); + }); + + test( + 'when getting the int option value via the arg name ' + 'then it succeeds', () async { + final value = config.findValueOf(argName: 'int'); + expect(value, equals(2)); + }); + + test( + 'when getting the int option value via the arg pos ' + 'then it succeeds', () async { + final value = config.findValueOf(argPos: 1); + expect(value, equals(2)); + }); + + test( + 'when getting the int option value via the env name ' + 'then it succeeds', () async { + final value = config.findValueOf(envName: 'INT'); + expect(value, equals(2)); + }); + + test( + 'when getting the int option value via the config key ' + 'then it succeeds', () async { + final value = config.findValueOf(configKey: 'config:/int'); + expect(value, equals(2)); + }); + + test( + 'when getting an unknown option value via the enum name ' + 'then it returns null', () async { + final value = config.findValueOf(enumName: 'unknown'); + expect(value, isNull); + }); + + test( + 'when getting an unknown option value via the arg name ' + 'then it succeeds', () async { + final value = config.findValueOf(argName: 'unknown'); + expect(value, isNull); + }); + + test( + 'when getting an unknown option value via the arg pos ' + 'then it succeeds', () async { + final value = config.findValueOf(argPos: 2); + expect(value, isNull); + }); + + test( + 'when getting an unknown option value via the env name ' + 'then it succeeds', () async { + final value = config.findValueOf(envName: 'UNKNOWN'); + expect(value, isNull); + }); + + test( + 'when getting an unknown option value via the config key ' + 'then it succeeds', () async { + final value = config.findValueOf(configKey: 'config:/unknown'); + expect(value, isNull); + }); + }); +} + +enum _TestOption implements OptionDefinition { + stringOpt( + StringOption( + argName: 'string', + argPos: 0, + envName: 'STRING', + configKey: 'config:/string', + ), + ), + intOpt( + IntOption( + argName: 'int', + argPos: 1, + envName: 'INT', + configKey: 'config:/int', + ), + ); + + const _TestOption(this.option); + + @override + final ConfigOptionBase option; +} + +class _TestConfigBroker implements ConfigurationBroker { + final Map entries; + final StringOption? requiredOption; + + _TestConfigBroker( + this.entries, { + this.requiredOption, + }); + + @override + String? valueOrNull(final String key, final Configuration cfg) { + if (requiredOption != null) { + if (cfg.optionalValue(requiredOption!) == null) { + return null; + } + } + return entries[key]; + } +} + +/// Makes a [ConfigurationBroker] that returns the values from the given map. +/// The returned value is null if the required option does not have a value. +ConfigurationBroker _dependentConfigBroker( + final Map entries, + final StringOption requiredOption, +) { + return _TestConfigBroker(entries, requiredOption: requiredOption); +} + +/// Default value function for testing. +/// Needs to be a top-level function (or static method) in order to use it with a const constructor. +String _defaultValueFunction() { + return 'defaultValueFunction'; +} + +/// Custom value function for testing. +/// Needs to be a top-level function (or static method) in order to use it with a const constructor. +String? _customValueFunction(final Configuration cfg) { + return 'customValueFunction'; +} + +/// Custom value function for testing. +/// Needs to be a top-level function (or static method) in order to use it with a const constructor. +String? _customNullFunction(final Configuration cfg) { + return null; +} diff --git a/test/config/configuration_type_test.dart b/test/config/configuration_type_test.dart new file mode 100644 index 0000000..ef68068 --- /dev/null +++ b/test/config/configuration_type_test.dart @@ -0,0 +1,834 @@ +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +enum AnimalEnum { + cat, + dog, + mouse, +} + +void main() async { + group('Given an EnumOption', () { + const typedOpt = EnumOption( + argName: 'animal', + enumParser: EnumParser(AnimalEnum.values), + mandatory: true, + ); + + test('when passed a valid value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--animal', 'cat'], + env: {}, + ); + expect(config.value(typedOpt), equals(AnimalEnum.cat)); + }); + + test('when passed an invalid value then it reports an error', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--animal', 'unicorn'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `animal`: "unicorn" is not in cat|dog|mouse'), + ); + }); + }); + + group('Given an IntOption', () { + const typedOpt = IntOption( + argName: 'number', + mandatory: true, + ); + + test('when passed a valid positive value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '123'], + env: {}, + ); + expect(config.value(typedOpt), equals(123)); + }); + + test('when passed a valid negative value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '-123'], + env: {}, + ); + expect(config.value(typedOpt), equals(-123)); + }); + + test('when passed a non-integer value then it reports an error', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '0.45'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + contains('Invalid value for option `number` '), + ); + }); + test('when passed a non-number value then it reports an error', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', 'unicorn'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + contains('Invalid value for option `number` '), + ); + }); + }); + + group('Given a ranged IntOption', () { + const typedOpt = IntOption( + argName: 'number', + mandatory: true, + min: 100, + max: 200, + ); + + test('when passed a valid value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '123'], + env: {}, + ); + expect(config.value(typedOpt), equals(123)); + }); + + test( + 'when passed an integer value less than the range then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '99'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `number` : 99 is below the minimum (100)'), + ); + }); + test( + 'when passed an integer value greater than the range then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '201'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `number` : 201 is above the maximum (200)'), + ); + }); + }); + + group('Given an IntOption with an allow-list', () { + const typedOpt = IntOption( + argName: 'number', + envName: 'NUMBER', + mandatory: true, + allowedValues: [100, 200], + ); + + test('when passed a valid value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '100'], + ); + expect(config.value(typedOpt), equals(100)); + }); + + test('when passed an invalid integer value as arg then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--number', '99'], + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals('"99" is not an allowed value for option "--number".'), + ); + }); + + test( + 'when passed an invalid integer value as env var then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'NUMBER': '99'}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals('Invalid value for option `number` : ' + '`99` is not an allowed value for option `number`'), + ); + }); + }); + + group('Given a ranged DurationOption', () { + const typedOpt = DurationOption( + argName: 'duration', + mandatory: true, + min: Duration.zero, + max: Duration(days: 2), + ); + + test('when passed a valid days value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '1d'], + env: {}, + ); + expect(config.value(typedOpt), equals(const Duration(days: 1))); + }); + + test('when passed a valid hours value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '2h'], + env: {}, + ); + expect(config.value(typedOpt), equals(const Duration(hours: 2))); + }); + + test('when passed a valid minutes value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '3m'], + env: {}, + ); + expect(config.value(typedOpt), equals(const Duration(minutes: 3))); + }); + + test('when passed a valid seconds value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '24s'], + env: {}, + ); + expect(config.value(typedOpt), equals(const Duration(seconds: 24))); + }); + + test('when passed a valid value with no unit then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '2'], + env: {}, + ); + expect(config.value(typedOpt), equals(const Duration(seconds: 2))); + }); + + test('when passed a value less than the range then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '-2s'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `duration` : -2s is below the minimum (0s)'), + ); + }); + + test('when passed a value greater than the range then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--duration', '20d'], + env: {}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `duration` : 20d is above the maximum (2d)'), + ); + }); + }); + + group('Given a MultiOption of strings', () { + const typedOpt = MultiOption( + multiParser: MultiParser(elementParser: StringParser()), + argName: 'many', + envName: 'SERVERPOD_MANY', + configKey: 'many', + ); + + test('when passed no values then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: [], + ); + expect(config.optionalValue(typedOpt), isNull); + }); + + test('when passed a single arg value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123'], + ); + expect(config.optionalValue(typedOpt), equals(['123'])); + }); + + test('when passed several arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123', '--many', '456'], + ); + expect(config.optionalValue(typedOpt), equals(['123', '456'])); + }); + + test( + 'when passed several comma-separated arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123,456'], + ); + expect(config.optionalValue(typedOpt), equals(['123', '456'])); + }); + + test('when passed empty env value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': ''}, + ); + expect(config.optionalValue(typedOpt), equals([''])); + }); + + test( + 'when passed several comma-separated env values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': '123,456'}, + ); + expect(config.optionalValue(typedOpt), equals(['123', '456'])); + }); + + test( + 'when passed null value from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': null}), + ); + expect(config.optionalValue(typedOpt), isNull); + }); + + test( + 'when passed empty array of strings from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': []}), + ); + expect(config.optionalValue(typedOpt), equals([])); + }); + + test( + 'when passed empty array of ints from config source then it reports a type error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': []}), + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'option `many` value [] is of type List, not List.'), + ); + }); + + test( + 'when passed string array value from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({ + 'many': ['123', '456'] + }), + ); + expect(config.optionalValue(typedOpt), equals(['123', '456'])); + }); + + test( + 'when passed plain string value from config source then it is parsed into a single-element array', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': 'plain-string'}), + ); + expect(config.optionalValue(typedOpt), equals(['plain-string'])); + }); + + test( + 'when passed int array value from config source then it reports a type error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({ + 'many': [123] + }), + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'option `many` value [123] is of type List, not List.'), + ); + }); + + test( + 'when passed several comma-separated values in a plain string from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': '123,456'}), + ); + expect(config.optionalValue(typedOpt), equals(['123', '456'])); + }); + }); + + group('Given a MultiOption of integers without default value', () { + const typedOpt = MultiOption( + multiParser: MultiParser(elementParser: IntParser()), + argName: 'many', + envName: 'SERVERPOD_MANY', + configKey: 'many', + ); + + test('when passed no values then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: [], + ); + expect(config.optionalValue(typedOpt), isNull); + }); + + test('when passed a single arg value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123'], + ); + expect(config.optionalValue(typedOpt), equals([123])); + }); + + test('when passed several arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123', '--many', '456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed several comma-separated arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123,456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test('when passed empty env value then it reports a parse error', () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': ''}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + startsWith( + 'Invalid value for option `many`: Invalid number (at character 1)'), + ); + }); + + test( + 'when passed several comma-separated env values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': '123,456'}, + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed null value from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': null}), + ); + expect(config.optionalValue(typedOpt), isNull); + }); + + test( + 'when passed empty array of strings from config source then it reports a type error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': []}), + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'option `many` value [] is of type List, not List.'), + ); + }); + + test( + 'when passed empty array of ints from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': []}), + ); + expect(config.optionalValue(typedOpt), equals([])); + }); + + test( + 'when passed int array value from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({ + 'many': [123, 456] + }), + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed plain string value from config source then it reports a parse error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': 'plain-string'}), + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + startsWith( + 'Invalid value for option `many`: Invalid radix-10 number (at character 1)'), + ); + }); + + test( + 'when passed string array value from config source then it reports a type error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({ + 'many': ['123'] + }), + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'option `many` value [123] is of type List, not List.'), + ); + }); + + test( + 'when passed several comma-separated values in a plain string from config source then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + configBroker: _TestConfigBroker({'many': '123,456'}), + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + }); + + group('Given a MultiOption of integers with default value', () { + const typedOpt = MultiOption( + multiParser: MultiParser(elementParser: IntParser()), + argName: 'many', + envName: 'SERVERPOD_MANY', + configKey: 'many', + defaultsTo: [12, 45], + ); + + test('when passed no values then it produces the default value', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: [], + ); + expect(config.optionalValue(typedOpt), equals([12, 45])); + }); + + test('when passed a single arg value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123'], + ); + expect(config.optionalValue(typedOpt), equals([123])); + }); + + test('when passed several arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123', '--many', '456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed several comma-separated arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123,456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test('when passed empty env value then it reports a parse error', () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': ''}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + startsWith( + 'Invalid value for option `many`: Invalid number (at character 1)'), + ); + }); + + test( + 'when passed several comma-separated env values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': '123,456'}, + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + }); + + group('Given a mandatory MultiOption of integers with alias', () { + const typedOpt = MultiOption( + multiParser: MultiParser(elementParser: IntParser()), + argName: 'many', + argAliases: ['alias-many'], + envName: 'SERVERPOD_MANY', + configKey: 'many', + mandatory: true, + ); + + test('when passed no values then it reports a parse error', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: [], + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals('option `many` is mandatory'), + ); + }); + + test('when passed a single arg value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123'], + ); + expect(config.optionalValue(typedOpt), equals([123])); + }); + + test('when passed several arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123', '--many', '456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed several arg values using both regular name and alias ' + 'then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123', '--alias-many', '456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test( + 'when passed several comma-separated arg values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', '123,456'], + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + + test('when passed empty env value then it reports a parse error', () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': ''}, + ); + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + startsWith( + 'Invalid value for option `many`: Invalid number (at character 1)'), + ); + }); + + test( + 'when passed several comma-separated env values then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'SERVERPOD_MANY': '123,456'}, + ); + expect(config.optionalValue(typedOpt), equals([123, 456])); + }); + }); + + group('Given a MultiStringOption with an allow-list', () { + const typedOpt = MultiStringOption( + argName: 'many', + envName: 'MANY', + configKey: 'many', + allowedValues: ['foo', 'bar', ''], + ); + + test('when passed no value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: [], + ); + expect(config.optionalValue(typedOpt), isNull); + }); + + test('when passed a valid value then it is parsed correctly', () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', 'foo'], + ); + expect(config.optionalValue(typedOpt), equals(['foo'])); + }); + + test('when passed a valid empty value then it is parsed correctly', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', ''], + ); + expect(config.optionalValue(typedOpt), equals([''])); + }); + + test('when passed an invalid value as arg then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', 'wrong'], + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals('"wrong" is not an allowed value for option "--many".'), + ); + expect(() => config.optionalValue(typedOpt), throwsA(isA())); + }); + + test('when passed an invalid value as env var then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + env: {'MANY': 'wrong'}, + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `many`: `wrong` is not an allowed value for option `many`'), + ); + expect(() => config.optionalValue(typedOpt), throwsA(isA())); + }); + + test('when passed a valid and an invalid value then it reports an error', + () async { + final config = Configuration.resolve( + options: [typedOpt], + args: ['--many', 'foo', '--many', 'wrong'], + ); + + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals('"wrong" is not an allowed value for option "--many".'), + ); + expect(() => config.optionalValue(typedOpt), throwsA(isA())); + }); + }); +} + +class _TestConfigBroker implements ConfigurationBroker { + final Map entries; + + _TestConfigBroker(this.entries); + + @override + Object? valueOrNull(final String key, final Configuration cfg) { + return entries[key]; + } +} diff --git a/test/config/date_parsing_test.dart b/test/config/date_parsing_test.dart new file mode 100644 index 0000000..2f5d63c --- /dev/null +++ b/test/config/date_parsing_test.dart @@ -0,0 +1,117 @@ +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +void main() { + group('Given a DateTimeParser', () { + const dateTimeParser = DateTimeParser(); + + test( + 'When calling parseDate() with empty string then it throws FormatException.', + () { + expect( + () => dateTimeParser.parse(''), + throwsA(isA()), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01'), + equals(DateTime(2020, 1, 1)), + ); + }); + + test( + 'When calling parseDate() with 20200101 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('20200101'), + equals(DateTime(2020, 1, 1)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01 12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01 12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01T12:20:40Z then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01T12:20:40Z'), + equals(DateTime.utc(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01T12:20:40.001z then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01T12:20:40.001z'), + equals(DateTime.utc(2020, 1, 1, 12, 20, 40, 1)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01t12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01t12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01-12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01-12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01:12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01:12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01_12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01_12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01/12:20:40 then it successfully returns a DateTime.', + () { + expect( + dateTimeParser.parse('2020-01-01/12:20:40'), + equals(DateTime(2020, 1, 1, 12, 20, 40)), + ); + }); + + test( + 'When calling parseDate() with 2020-01-01x12:20:40 then it throws FormatException.', + () { + expect( + () => dateTimeParser.parse('2020-01-01x12:20:40'), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/config/duration_parsing_test.dart b/test/config/duration_parsing_test.dart new file mode 100644 index 0000000..b1417f1 --- /dev/null +++ b/test/config/duration_parsing_test.dart @@ -0,0 +1,244 @@ +import 'package:test/test.dart'; + +import 'package:cli_tools/config.dart'; + +void main() { + group('Given a DurationParser', () { + const durationParser = DurationParser(); + + test('when calling parse with empty string then it throws FormatException.', + () { + expect( + () => durationParser.parse(''), + throwsA(isA()), + ); + }); + + test('when calling parse with "-" then it throws FormatException.', () { + expect( + () => durationParser.parse('-'), + throwsA(isA()), + ); + }); + + test( + 'when calling parse with just an s unit then it throws FormatException.', + () { + expect( + () => durationParser.parse('s'), + throwsA(isA()), + ); + }); + + test( + 'when calling parse with 10 (implicit s unit) then it successfully returns a 10s Duration.', + () { + expect( + durationParser.parse('10'), + equals(const Duration(seconds: 10)), + ); + }); + + test( + 'when calling parse with 10s then it successfully returns a 10s Duration.', + () { + expect( + durationParser.parse('10s'), + equals(const Duration(seconds: 10)), + ); + }); + + test( + 'when calling parse with 10m then it successfully returns a 10m Duration.', + () { + expect( + durationParser.parse('10m'), + equals(const Duration(minutes: 10)), + ); + }); + + test( + 'when calling parse with 10h then it successfully returns a 10h Duration.', + () { + expect( + durationParser.parse('10h'), + equals(const Duration(hours: 10)), + ); + }); + + test( + 'when calling parse with 10d then it successfully returns a 10d Duration.', + () { + expect( + durationParser.parse('10d'), + equals(const Duration(days: 10)), + ); + }); + + test( + 'when calling parse with 10ms then it successfully returns a 10ms Duration.', + () { + expect( + durationParser.parse('10ms'), + equals(const Duration(milliseconds: 10)), + ); + }); + + test( + 'when calling parse with 10us then it successfully returns a 10us Duration.', + () { + expect( + durationParser.parse('10us'), + equals(const Duration(microseconds: 10)), + ); + }); + + test('when calling parse with 0 then it successfully returns a 0 Duration.', + () { + expect( + durationParser.parse('0'), + equals(Duration.zero), + ); + }); + + test( + 'when calling parse with 0s then it successfully returns a 0 Duration.', + () { + expect( + durationParser.parse('0s'), + equals(Duration.zero), + ); + }); + + test( + 'when calling parse with -10 then it successfully returns a -10s Duration.', + () { + expect( + durationParser.parse('-10'), + equals(const Duration(seconds: -10)), + ); + }); + + test( + 'when calling parse with -10s then it successfully returns a -10s Duration.', + () { + expect( + durationParser.parse('-10s'), + equals(const Duration(seconds: -10)), + ); + }); + + test( + 'when calling parse with 70s then it successfully returns a 1m10s Duration.', + () { + expect( + durationParser.parse('70s'), + equals(const Duration(minutes: 1, seconds: 10)), + ); + }); + + test( + 'when calling parse with 70m then it successfully returns a 1h10m Duration.', + () { + expect( + durationParser.parse('70m'), + equals(const Duration(hours: 1, minutes: 10)), + ); + }); + + test( + 'when calling parse with 70h then it successfully returns a 2d22h Duration.', + () { + expect( + durationParser.parse('70h'), + equals(const Duration(days: 2, hours: 22)), + ); + }); + + test( + 'when calling parse with a date string 2020-01-01T12:20:40 then it throws FormatException.', + () { + expect( + () => durationParser.parse('2020-01-01T12:20:40'), + throwsA(isA()), + ); + }); + + test('when calling format with 0 Duration then it returns the string "0s".', + () { + expect( + durationParser.format(Duration.zero), + equals('0s'), + ); + }); + + test( + 'when calling format with -10s Duration then it returns the string "-10s".', + () { + expect( + durationParser.format(const Duration(seconds: -10)), + equals('-10s'), + ); + }); + + test( + 'when calling format with 10m Duration then it returns the string "10m".', + () { + expect(durationParser.format(const Duration(minutes: 10)), equals('10m')); + }); + + test( + 'when calling format with 10h Duration then it returns the string "10h".', + () { + expect(durationParser.format(const Duration(hours: 10)), equals('10h')); + }); + + test( + 'when calling format with 10d Duration then it returns the string "10d".', + () { + expect(durationParser.format(const Duration(days: 10)), equals('10d')); + }); + + test( + 'when calling format with 10ms Duration then it returns the string "10ms".', + () { + expect(durationParser.format(const Duration(milliseconds: 10)), + equals('10ms')); + }); + + test( + 'when calling format with 10us Duration then it returns the string "10us".', + () { + expect(durationParser.format(const Duration(microseconds: 10)), + equals('10us')); + }); + + test('when calling format with 0 Duration then it returns the string "0s".', + () { + expect(durationParser.format(Duration.zero), equals('0s')); + }); + + test( + 'when calling format with 1m10s Duration then it returns the string "1m10s".', + () { + expect( + durationParser.format(const Duration(minutes: 1, seconds: 10)), + equals('1m10s'), + ); + }); + + test( + 'when calling format with 1h10m Duration then it returns the string "1h10m".', + () { + expect(durationParser.format(const Duration(hours: 1, minutes: 10)), + equals('1h10m')); + }); + + test( + 'when calling format with 2d22h Duration then it returns the string "2d22h".', + () { + expect(durationParser.format(const Duration(days: 2, hours: 22)), + equals('2d22h')); + }); + }); +} diff --git a/test/config/file_options_test.dart b/test/config/file_options_test.dart new file mode 100644 index 0000000..0ca26c0 --- /dev/null +++ b/test/config/file_options_test.dart @@ -0,0 +1,457 @@ +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +import 'package:cli_tools/config.dart'; + +void main() async { + group('Given a DirOption', () { + group('with mode mayExist', () { + const dirOpt = DirOption( + argName: 'folder', + mode: PathExistMode.mayExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', 'does-not-exist'], + ); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(dirOpt).path, + equals('does-not-exist'), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', dirPath], + ); + }); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(dirOpt).path, + equals(dirPath), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', filePath], + ); + }); + + test('then it reports a "not a directory" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `folder`: Path "$filePath" is not a directory', + ), + ); + }); + }); + }); + + group('with mode mustExist', () { + const dirOpt = DirOption( + argName: 'folder', + mode: PathExistMode.mustExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', 'does-not-exist'], + ); + + test('then it reports a "does not exist" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `folder`: Directory "does-not-exist" does not exist', + ), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', dirPath], + ); + }); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(dirOpt).path, + equals(dirPath), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', filePath], + ); + }); + + test('then it reports a "not a directory" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `folder`: Path "$filePath" is not a directory', + ), + ); + }); + }); + }); + + group('with mode mustNotExist', () { + const dirOpt = DirOption( + argName: 'folder', + mode: PathExistMode.mustNotExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', 'does-not-exist'], + ); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(dirOpt).path, + equals('does-not-exist'), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', dirPath], + ); + }); + + test('then it reports an "already exists" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `folder`: Path "$dirPath" already exists', + ), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [dirOpt], + args: ['--folder', filePath], + ); + }); + + test('then it reports an "already exists" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `folder`: Path "$filePath" already exists', + ), + ); + }); + }); + }); + }); + + group('Given a FileOption', () { + group('with mode mayExist', () { + const fileOpt = FileOption( + argName: 'file', + mode: PathExistMode.mayExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [fileOpt], + args: ['--file', 'does-not-exist'], + ); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(fileOpt).path, + equals('does-not-exist'), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', dirPath], + ); + }); + + test('then it reports a "not a directory" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `file`: Path "$dirPath" is not a file', + ), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', filePath], + ); + }); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(fileOpt).path, + equals(filePath), + ); + }); + }); + }); + + group('with mode mustExist', () { + const fileOpt = FileOption( + argName: 'file', + mode: PathExistMode.mustExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [fileOpt], + args: ['--file', 'does-not-exist'], + ); + + test('then it reports a "does not exist" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `file`: File "does-not-exist" does not exist', + ), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', dirPath], + ); + }); + + test('then it reports a "not a directory" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `file`: Path "$dirPath" is not a file', + ), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', filePath], + ); + }); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(fileOpt).path, + equals(filePath), + ); + }); + }); + }); + + group('with mode mustNotExist', () { + const fileOpt = FileOption( + argName: 'file', + mode: PathExistMode.mustNotExist, + mandatory: true, + ); + + group('when passing arg that is non-existent', () { + final config = Configuration.resolve( + options: [fileOpt], + args: ['--file', 'does-not-exist'], + ); + + test('then it is parsed successfully', () async { + expect(config.errors, isEmpty); + expect( + config.value(fileOpt).path, + equals('does-not-exist'), + ); + }); + }); + + group('when passing arg that is an existing directory', () { + const existingDirName = 'existing-dir'; + late final String dirPath; + late final Configuration config; + + setUp(() async { + await d.dir(existingDirName).create(); + dirPath = p.join(d.sandbox, existingDirName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', dirPath], + ); + }); + + test('then it reports an "already exists" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `file`: Path "$dirPath" already exists', + ), + ); + }); + }); + + group('when passing arg that is an existing file', () { + const existingFileName = 'existing-file'; + late final String filePath; + late final Configuration config; + + setUp(() async { + await d.file(existingFileName).create(); + filePath = p.join(d.sandbox, existingFileName); + + config = Configuration.resolve( + options: [fileOpt], + args: ['--file', filePath], + ); + }); + + test('then it reports an "already exists" error', () async { + expect(config.errors, hasLength(1)); + expect( + config.errors.single, + equals( + 'Invalid value for option `file`: Path "$filePath" already exists', + ), + ); + }); + }); + }); + }); +} From 79de14789196fbb4cb5a3d27f10418b484ed86ae Mon Sep 17 00:00:00 2001 From: Christer Date: Sat, 19 Apr 2025 15:20:31 +0200 Subject: [PATCH 2/9] feat: ConfigParser replacement for ArgParser --- lib/src/config/config.dart | 1 + lib/src/config/config_parser.dart | 405 ++++++++++++++++++++++++++++++ lib/src/config/configuration.dart | 21 +- 3 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 lib/src/config/config_parser.dart diff --git a/lib/src/config/config.dart b/lib/src/config/config.dart index 217bcf6..69f935f 100644 --- a/lib/src/config/config.dart +++ b/lib/src/config/config.dart @@ -1,3 +1,4 @@ +export 'config_parser.dart'; export 'config_source_provider.dart'; export 'config_source.dart'; export 'configuration_parser.dart'; diff --git a/lib/src/config/config_parser.dart b/lib/src/config/config_parser.dart new file mode 100644 index 0000000..49920a6 --- /dev/null +++ b/lib/src/config/config_parser.dart @@ -0,0 +1,405 @@ +import 'package:args/args.dart'; +import 'package:args/command_runner.dart' show UsageException; + +import 'configuration.dart'; +import 'options.dart'; +import 'source_type.dart'; + +/// A backwards compatible parser for command args and configuration sources. +/// +/// This class is designed as a drop-in replacement for [ArgParser] +/// of the `args` package. It is almost entirely compatible except for: +/// - addCommand() is not supported +/// +/// The [parse] method returns a [ConfigResults] object which is a backwards +/// compatible with [ArgResults] of the args package. +/// +/// The purpose of this class is to enable an easy transition to the +/// configuration package. This can be dropped in place of existing `args` +/// code and gradually extended to use multiple configuration sources +/// such as environment variables and configuration files. +/// +/// It is recommended to migrate to the [Configuration] class to enable the +/// full range of typed configuration values. +class ConfigParser implements ArgParser { + final ArgParser _parser; + final List _optionDefinitions = []; + final Map _flagCallbacks = {}; + final Map _optionCallbacks = {}; + final Map)> _multiOptionCallbacks = {}; + + ConfigParser({ + final bool allowTrailingOptions = true, + final int? usageLineLength, + }) : _parser = ArgParser( + allowTrailingOptions: allowTrailingOptions, + usageLineLength: usageLineLength, + ); + + ArgParser get parser => _parser; + + List get optionDefinitions => _optionDefinitions; + + @override + void addFlag( + final String name, { + final String? abbr, + final String? help, + final bool? defaultsTo = false, + final bool negatable = true, + @Deprecated('Use parse results instead') + final void Function(bool)? callback, + final bool hide = false, + final bool hideNegatedUsage = false, + final List aliases = const [], + final String? envName, + final String? configKey, + final bool? Function(Configuration cfg)? fromCustom, + final bool Function()? fromDefault, + final String? valueHelp, + final bool mandatory = false, + final OptionGroup? group, + final void Function(bool value)? customValidator, + }) { + _addOption( + FlagOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo, + helpText: help, + valueHelp: valueHelp, + group: group, + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + negatable: negatable, + hideNegatedUsage: hideNegatedUsage, + ), + ); + if (callback != null) { + _flagCallbacks[name] = callback; + } + } + + @override + void addOption( + final String name, { + final String? abbr, + final String? help, + final String? valueHelp, + final Iterable? allowed, + final Map? allowedHelp, + final String? defaultsTo, + @Deprecated('Use parse results instead') + final void Function(String?)? callback, + final bool mandatory = false, + final bool hide = false, + final List aliases = const [], + final int? argPos, + final String? envName, + final String? configKey, + final String? Function(Configuration cfg)? fromCustom, + final String Function()? fromDefault, + final OptionGroup? group, + final void Function(String value)? customValidator, + }) { + _addOption( + StringOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + argPos: argPos, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo, + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + if (callback != null) { + _optionCallbacks[name] = callback; + } + } + + @override + void addMultiOption( + final String name, { + final String? abbr, + final String? help, + final String? valueHelp, + final Iterable? allowed, + final Map? allowedHelp, + final Iterable? defaultsTo, + @Deprecated('Use parse results instead') + final void Function(List)? callback, + final bool splitCommas = true, + final bool hide = false, + final List aliases = const [], + final String? envName, + final String? configKey, + final List? Function(Configuration cfg)? fromCustom, + final List Function()? fromDefault, + final OptionGroup? group, + final void Function(List value)? customValidator, + final bool mandatory = false, + }) { + if (splitCommas) { + _addOption( + MultiStringOption( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo?.toList(), + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + } else { + _addOption( + MultiStringOption.noSplit( + argName: name, + argAliases: aliases, + argAbbrev: abbr, + envName: envName, + configKey: configKey, + fromCustom: fromCustom, + fromDefault: fromDefault, + defaultsTo: defaultsTo?.toList(), + helpText: help, + valueHelp: valueHelp, + allowedHelp: allowedHelp, + group: group, + allowedValues: allowed?.toList(), + customValidator: customValidator, + mandatory: mandatory, + hide: hide, + ), + ); + } + if (callback != null) { + _multiOptionCallbacks[name] = callback; + } + } + + void _addOption(final OptionDefinition opt) { + _optionDefinitions.add(opt); + // added continuously to the parser so separators are placed correctly: + addOptionsToParser([opt], _parser); + } + + @override + void addSeparator(final String text) => parser.addSeparator(text); + + @override + ConfigResults parse( + final Iterable args, { + final Map? env, + final ConfigurationBroker? configBroker, + final Map? presetValues, + }) { + validateOptions(_optionDefinitions); + + final argResults = parser.parse(args); + + final configuration = Configuration.resolve( + options: _optionDefinitions, + argResults: argResults, + env: env, + presetValues: presetValues, + ignoreUnexpectedPositionalArgs: true, + ); + + if (configuration.errors.isNotEmpty) { + throw UsageException( + configuration.errors.join('\n'), + usage, + ); + } + + _invokeCallbacks(configuration); + + return ConfigResults( + configuration, + argResults.arguments, + argResults.rest, + ); + } + + void _invokeCallbacks(final Configuration cfg) { + for (final entry in _flagCallbacks.entries) { + entry.value(cfg.findValueOf(argName: entry.key) ?? false); + } + for (final entry in _optionCallbacks.entries) { + entry.value(cfg.findValueOf(argName: entry.key)); + } + for (final entry in _multiOptionCallbacks.entries) { + entry.value(cfg.findValueOf>(argName: entry.key) ?? []); + } + } + + @override + String get usage => parser.usage; + + @override + int? get usageLineLength => parser.usageLineLength; + + @override + dynamic defaultFor(final String option) => parser.defaultFor(option); + + @override + @Deprecated('Use defaultFor instead.') + dynamic getDefault(final String option) => parser.getDefault(option); + + @override + Option? findByAbbreviation(final String abbr) => + parser.findByAbbreviation(abbr); + + @override + Option? findByNameOrAlias(final String name) => + parser.findByNameOrAlias(name); + + @override + bool get allowTrailingOptions => parser.allowTrailingOptions; + + @override + bool get allowsAnything => parser.allowsAnything; + + @override + ArgParser addCommand(final String name, [final ArgParser? parser]) { + throw UnsupportedError('addCommand is not supported'); + } + + @override + Map get commands => parser.commands; + + @override + @Deprecated('Use optionDefinitions instead.') + Map get options => parser.options; +} + +/// A wrapper around a [Configuration] object that implements the [ArgResults] +/// interface. This is returned by the [ConfigParser.parse] method. +/// +/// This is backwards compatible with [ArgResults] except for the name and +/// command properties, which are always null since commands are not supported. +class ConfigResults implements ArgResults { + final Configuration _configuration; + + ConfigResults( + this._configuration, + this.arguments, + this.rest, + ); + + /// The original arguments that were parsed. + @override + final List arguments; + + /// The remaining command-line arguments that were not parsed. + @override + final List rest; + + /// The name of the command for which these options are parsed. + /// Currently always null. + @override + String? get name => null; + + /// The name of the command for which these options are parsed. + /// Currently always null. + @override + ArgResults? get command => null; + + @override + dynamic operator [](final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + return _configuration.optionalValue(option); + } + + @override + String? option(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! String?) { + throw ArgumentError('Arg option $name is not a string option.'); + } + return value; + } + + @override + bool flag(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg flag named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! bool) { + throw ArgumentError('Arg flag $name is not a boolean flag.'); + } + return value; + } + + @override + List multiOption(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('No arg option named "--$name".'); + } + final value = _configuration.optionalValue(option); + if (value is! List) { + throw ArgumentError('Arg option $name is not a multi-string option.'); + } + return value; + } + + /// The arg names of the available options, + /// i.e. the ones that have a value. + /// Options that do not have arg names are omitted. + @override + Iterable get options { + return _configuration + .optionsWhereSource((final source) => source != ValueSourceType.noValue) + .map((final o) => o.option.argName) + .nonNulls; + } + + /// Returns `true` if the option with [name] was parsed from an actual + /// argument. + @override + bool wasParsed(final String name) { + final option = _configuration.findOption(argName: name); + if (option == null) { + throw ArgumentError('Could not find an arg option named "--$name".'); + } + final sourceType = _configuration.valueSourceType(option); + return sourceType == ValueSourceType.arg; + } +} diff --git a/lib/src/config/configuration.dart b/lib/src/config/configuration.dart index 9d231ce..4b6cb38 100644 --- a/lib/src/config/configuration.dart +++ b/lib/src/config/configuration.dart @@ -644,6 +644,13 @@ extension QualifiedString on OptionDefinition { void prepareOptionsForParsing( final Iterable options, final ArgParser argParser, +) { + final argNameOpts = validateOptions(options); + addOptionsToParser(argNameOpts, argParser); +} + +Iterable validateOptions( + final Iterable options, ) { final argNameOpts = {}; final argPosOpts = {}; @@ -699,7 +706,14 @@ void prepareOptionsForParsing( } } - for (final opt in argNameOpts.values) { + return argNameOpts.values; +} + +void addOptionsToParser( + final Iterable argNameOpts, + final ArgParser argParser, +) { + for (final opt in argNameOpts) { opt.option._addToArgParser(argParser); } } @@ -753,6 +767,7 @@ class Configuration { final Map? env, final ConfigurationBroker? configBroker, final Map? presetValues, + final bool ignoreUnexpectedPositionalArgs = false, }) : _options = List.from(options), _config = {}, _errors = [] { @@ -776,6 +791,7 @@ class Configuration { env: env, configBroker: configBroker, presetValues: presetValues, + ignoreUnexpectedPositionalArgs: ignoreUnexpectedPositionalArgs, ); } @@ -906,6 +922,7 @@ class Configuration { final Map? env, final ConfigurationBroker? configBroker, final Map? presetValues, + final bool ignoreUnexpectedPositionalArgs = false, }) { final posArgs = (args?.rest ?? []).iterator; final orderedOpts = _options.sorted((final a, final b) => @@ -954,7 +971,7 @@ class Configuration { _validateGroups(optionGroups); final remainingPosArgs = posArgs.restAsList(); - if (remainingPosArgs.isNotEmpty) { + if (remainingPosArgs.isNotEmpty && !ignoreUnexpectedPositionalArgs) { _errors.add( "Unexpected positional argument(s): '${remainingPosArgs.join("', '")}'"); } From dce3788f6b541c945d7aaf43e85b98d6081db60c Mon Sep 17 00:00:00 2001 From: Christer Date: Sat, 19 Apr 2025 15:20:41 +0200 Subject: [PATCH 3/9] chore: Baseline import of Dart args package tests --- .../allow_anything_test.dart | 67 ++ test/config/args_compatibility/args_test.dart | 349 ++++++++ .../command_parse_test.dart | 204 +++++ .../command_runner_test.dart | 759 ++++++++++++++++ .../args_compatibility/command_test.dart | 158 ++++ .../parse_performance_test.dart | 74 ++ .../config/args_compatibility/parse_test.dart | 820 ++++++++++++++++++ .../config/args_compatibility/test_utils.dart | 368 ++++++++ .../trailing_options_test.dart | 100 +++ .../config/args_compatibility/usage_test.dart | 547 ++++++++++++ .../config/args_compatibility/utils_test.dart | 216 +++++ 11 files changed, 3662 insertions(+) create mode 100644 test/config/args_compatibility/allow_anything_test.dart create mode 100644 test/config/args_compatibility/args_test.dart create mode 100644 test/config/args_compatibility/command_parse_test.dart create mode 100644 test/config/args_compatibility/command_runner_test.dart create mode 100644 test/config/args_compatibility/command_test.dart create mode 100644 test/config/args_compatibility/parse_performance_test.dart create mode 100644 test/config/args_compatibility/parse_test.dart create mode 100644 test/config/args_compatibility/test_utils.dart create mode 100644 test/config/args_compatibility/trailing_options_test.dart create mode 100644 test/config/args_compatibility/usage_test.dart create mode 100644 test/config/args_compatibility/utils_test.dart diff --git a/test/config/args_compatibility/allow_anything_test.dart b/test/config/args_compatibility/allow_anything_test.dart new file mode 100644 index 0000000..52e22b7 --- /dev/null +++ b/test/config/args_compatibility/allow_anything_test.dart @@ -0,0 +1,67 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('new ArgParser.allowAnything()', () { + late ArgParser parser; + setUp(() { + parser = ArgParser.allowAnything(); + }); + + test('exposes empty values', () { + expect(parser.options, isEmpty); + expect(parser.commands, isEmpty); + expect(parser.allowTrailingOptions, isFalse); + expect(parser.allowsAnything, isTrue); + expect(parser.usage, isEmpty); + expect(parser.findByAbbreviation('a'), isNull); + }); + + test('mutation methods throw errors', () { + expect(() => parser.addCommand('command'), throwsUnsupportedError); + expect(() => parser.addFlag('flag'), throwsUnsupportedError); + expect(() => parser.addOption('option'), throwsUnsupportedError); + expect(() => parser.addSeparator('==='), throwsUnsupportedError); + }); + + test('getDefault() throws an error', () { + expect(() => parser.defaultFor('option'), throwsArgumentError); + }); + + test('parses all values as rest arguments', () { + var results = parser.parse(['--foo', '-abc', '--', 'bar']); + expect(results.options, isEmpty); + expect(results.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command, isNull); + expect(results.name, isNull); + }); + + test('works as a subcommand', () { + var commandParser = ArgParser()..addCommand('command', parser); + var results = + commandParser.parse(['command', '--foo', '-abc', '--', 'bar']); + expect(results.command!.options, isEmpty); + expect(results.command!.rest, equals(['--foo', '-abc', '--', 'bar'])); + expect( + results.command!.arguments, equals(['--foo', '-abc', '--', 'bar'])); + expect(results.command!.name, equals('command')); + }); + + test('works as a subcommand in a CommandRunner', () async { + var commandRunner = + CommandRunner('command', 'Description of command'); + var command = AllowAnythingCommand(); + commandRunner.addCommand(command); + + await commandRunner.run([command.name, '--foo', '--bar', '-b', 'qux']); + }); + }); +} diff --git a/test/config/args_compatibility/args_test.dart b/test/config/args_compatibility/args_test.dart new file mode 100644 index 0000000..04bbc47 --- /dev/null +++ b/test/config/args_compatibility/args_test.dart @@ -0,0 +1,349 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:args/args.dart'; +import 'package:test/test.dart'; + +import 'test_utils.dart'; + +void main() { + group('ArgParser.addFlag()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ArgParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ArgParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addFlag('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ArgParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addFlag('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if a flag name is invalid', () { + var parser = ArgParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addFlag(name), reason: reason); + } + }); + + test('accepts valid flag names', () { + var parser = ArgParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addFlag(name), returnsNormally, reason: reason); + } + }); + }); + + group('ArgParser.addOption()', () { + test('throws ArgumentError if the flag already exists', () { + var parser = ArgParser(); + parser.addFlag('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the option already exists', () { + var parser = ArgParser(); + parser.addOption('foo'); + throwsIllegalArg(() => parser.addOption('foo')); + }); + + test('throws ArgumentError if the abbreviation exists', () { + var parser = ArgParser(); + parser.addFlag('foo', abbr: 'f'); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'f')); + }); + + test( + 'throws ArgumentError if the abbreviation is longer ' + 'than one character', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: 'flu')); + }); + + test('throws ArgumentError if the abbreviation is empty', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '')); + }); + + test('throws ArgumentError if the abbreviation is an invalid value', () { + var parser = ArgParser(); + for (var name in _invalidOptions) { + throwsIllegalArg(() => parser.addOption('flummox', abbr: name)); + } + }); + + test('throws ArgumentError if the abbreviation is a dash', () { + var parser = ArgParser(); + throwsIllegalArg(() => parser.addOption('flummox', abbr: '-')); + }); + + test('allows explict null value for "abbr"', () { + var parser = ArgParser(); + expect(() => parser.addOption('flummox', abbr: null), returnsNormally); + }); + + test('throws ArgumentError if an option name is invalid', () { + var parser = ArgParser(); + + for (var name in _invalidOptions) { + var reason = '${Error.safeToString(name)} is not valid'; + throwsIllegalArg(() => parser.addOption(name), reason: reason); + } + }); + + test('accepts valid option names', () { + var parser = ArgParser(); + + for (var name in _validOptions) { + var reason = '${Error.safeToString(name)} is valid'; + expect(() => parser.addOption(name), returnsNormally, reason: reason); + } + }); + }); + + group('ArgParser.getDefault()', () { + test('returns the default value for an option', () { + var parser = ArgParser(); + parser.addOption('mode', defaultsTo: 'debug'); + expect(parser.defaultFor('mode'), 'debug'); + }); + + test('throws if the option is unknown', () { + var parser = ArgParser(); + parser.addOption('mode', defaultsTo: 'debug'); + throwsIllegalArg(() => parser.defaultFor('undefined')); + }); + }); + + group('ArgParser.commands', () { + test('returns an empty map if there are no commands', () { + var parser = ArgParser(); + expect(parser.commands, isEmpty); + }); + + test('returns the commands that were added', () { + var parser = ArgParser(); + parser.addCommand('hide'); + parser.addCommand('seek'); + expect(parser.commands, hasLength(2)); + expect(parser.commands['hide'], isNotNull); + expect(parser.commands['seek'], isNotNull); + }); + + test('iterates over the commands in the order they were added', () { + var parser = ArgParser(); + parser.addCommand('a'); + parser.addCommand('d'); + parser.addCommand('b'); + parser.addCommand('c'); + expect(parser.commands.keys, equals(['a', 'd', 'b', 'c'])); + }); + }); + + group('ArgParser.options', () { + test('returns an empty map if there are no options', () { + var parser = ArgParser(); + expect(parser.options, isEmpty); + }); + + test('returns the options that were added', () { + var parser = ArgParser(); + parser.addFlag('hide'); + parser.addOption('seek'); + expect(parser.options, hasLength(2)); + expect(parser.options['hide'], isNotNull); + expect(parser.options['seek'], isNotNull); + }); + + test('iterates over the options in the order they were added', () { + var parser = ArgParser(); + parser.addFlag('a'); + parser.addOption('d'); + parser.addFlag('b'); + parser.addOption('c'); + expect(parser.options.keys, equals(['a', 'd', 'b', 'c'])); + }); + }); + + group('ArgParser.findByNameOrAlias', () { + test('returns null if there is no match', () { + var parser = ArgParser(); + expect(parser.findByNameOrAlias('a'), isNull); + }); + + test('can find options by alias', () { + var parser = ArgParser()..addOption('a', aliases: ['b']); + expect(parser.findByNameOrAlias('b'), + isA