diff --git a/docs/best-practices.md b/docs/best-practices.md index 4db1814..ace6bcd 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -77,6 +77,36 @@ app.Context("project {id:int}", project => }); ``` +## Use global options for cross-cutting configuration + +When configuration applies to all commands (tenant, environment, verbosity), register global options and access them via `IGlobalOptionsAccessor`: + +```csharp +app.Options(o => +{ + o.Parsing.AddGlobalOption("tenant"); + o.Parsing.AddGlobalOption("verbose"); +}); +``` + +For many global options, prefer `UseGlobalOptions()` with a typed class: + +```csharp +app.UseGlobalOptions(); +``` + +Use `IGlobalOptionsAccessor` in DI factories for services that depend on global option values: + +```csharp +services.AddSingleton(sp => +{ + var globals = sp.GetRequiredService(); + return new TenantClient(globals.GetValue("tenant", "default")!); +}); +``` + +Note: DI singleton factories are resolved lazily, so the values are available after global option parsing completes. However, singleton factories capture values once — in interactive mode, global options can change between commands. If your service needs to see updated values per command, inject `IGlobalOptionsAccessor` directly and read values at call time instead of capturing them in a factory. See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers). + ## Group related options with `[ReplOptionsGroup]` When a command has many options, group them into a class instead of listing them all as handler parameters. This keeps handlers clean and makes option sets reusable across commands. diff --git a/docs/commands.md b/docs/commands.md index cb013b3..acd77ad 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,6 +53,72 @@ app.Map( Root help now includes a dedicated `Global Options:` section with built-ins plus custom options registered through `options.Parsing.AddGlobalOption(...)`. +### Accessing global options outside handlers + +Parsed global option values are available via `IGlobalOptionsAccessor`, registered in DI automatically. This enables access from middleware, DI service factories, and handlers: + +```csharp +// Register a global option +app.Options(o => o.Parsing.AddGlobalOption("tenant")); + +// Access in middleware +app.Use(async (ctx, next) => +{ + var globals = ctx.Services.GetRequiredService(); + var tenant = globals.GetValue("tenant"); + await next(); +}); + +// Access in a DI factory (lazy — resolved after parsing) +services.AddSingleton(sp => +{ + var globals = sp.GetRequiredService(); + return new TenantClient(globals.GetValue("tenant", "default")!); +}); + +// Access in a handler +app.Map("show", (IGlobalOptionsAccessor globals) => + globals.GetValue("tenant") ?? "none"); +``` + +For applications with many global options, use `UseGlobalOptions()` to register a typed class: + +```csharp +public class MyGlobalOptions +{ + public string? Tenant { get; set; } + public int Port { get; set; } = 8080; +} + +app.UseGlobalOptions(); + +// Access via DI +app.Map("show", (MyGlobalOptions opts) => $"{opts.Tenant}:{opts.Port}"); +``` + +Property names are converted to kebab-case option names (`Port` → `--port`). Use `[ReplOption]` on properties for custom names or aliases. + +You can also register global options using a type or constraint name instead of a generic parameter: + +```csharp +app.Options(o => o.Parsing.AddGlobalOption("port", "int")); +``` + +Built-in type names: `string`, `alpha`, `int`, `long`, `bool`, `guid`, `uri`/`url`/`urn`, `date`/`dateonly`/`date-only`, `datetime`/`date-time`, `datetimeoffset`/`date-time-offset`, `time`/`timeonly`/`time-only`, `timespan`/`time-span`. Custom route constraint names are also accepted and resolve to `string`. + +### Session-sticky behavior in interactive mode + +Global options passed at CLI launch persist as session defaults throughout the interactive session. Per-command overrides are temporary — they apply to that single command, then the session defaults take effect again: + +``` +$ myapp --env staging # launches interactive with env=staging +> deploy # env=staging (inherited from session) +> deploy --env prod # env=prod (override for this command only) +> status # env=staging (session default restored) +``` + +This eliminates the need to re-specify global options on every interactive command. + ## Parse diagnostics model Command option parsing returns structured diagnostics through the internal `OptionParsingResult` model: diff --git a/docs/configuration-reference.md b/docs/configuration-reference.md index 425b0c4..a8bafdc 100644 --- a/docs/configuration-reference.md +++ b/docs/configuration-reference.md @@ -40,6 +40,22 @@ Accessed via `ReplOptions.Parsing`. - `AddRouteConstraint(name, predicate)` — Register a named route constraint. - `AddGlobalOption(name, aliases, defaultValue)` — Register a global option available to all commands. +- `AddGlobalOption(name, typeName, aliases, defaultValue)` — Register a global option using a type name string (`"int"`, `"bool"`, `"guid"`, etc.). + +### IGlobalOptionsAccessor + +Registered automatically in DI. Provides typed access to parsed global option values from middleware, DI factories, and handlers. + +- `GetValue(name, defaultValue)` — Get typed value, falling back to registration default then caller default. +- `GetRawValues(name)` — Get all raw string values (supports repeated options). +- `HasValue(name)` — Check if the option was explicitly provided. +- `GetOptionNames()` — Enumerate all option names with values. + +Values are updated after each global option parsing pass (per-invocation in interactive mode). + +### UseGlobalOptions<T>() + +Extension method on `ReplApp`. Registers a typed class whose public settable properties become global options. The class is available via DI, populated from parsed values. Property names are converted to kebab-case (`MaxRetries` → `--max-retries`). See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers). ## InteractiveOptions diff --git a/docs/execution-pipeline.md b/docs/execution-pipeline.md index d132647..486c435 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -83,6 +83,12 @@ any routing takes place. Recognized options: - Custom global options registered through configuration These tokens are consumed and removed from the argument list before the next stage. +Parsed custom global option values are stored in `IGlobalOptionsAccessor` (registered in DI), +making them available to middleware, DI service factories, and handlers in subsequent stages. + +In interactive mode, CLI-level global options become session defaults — they persist across +all commands unless explicitly overridden per-command. Overrides are temporary and revert +to the session baseline on the next command. ### 2. Prefix Resolution diff --git a/docs/parameter-system.md b/docs/parameter-system.md index f4cf9f1..8c2771e 100644 --- a/docs/parameter-system.md +++ b/docs/parameter-system.md @@ -14,7 +14,7 @@ This document describes Repl Toolkit's parameter/option model and key design dec - option value syntaxes: `--name value`, `--name=value`, `--name:value` - option parsing is case-sensitive by default, configurable via `ParsingOptions.OptionCaseSensitivity` - response files are supported with `@file.rsp` (non-recursive) -- custom global options can be registered via `ParsingOptions.AddGlobalOption(...)` +- custom global options can be registered via `ParsingOptions.AddGlobalOption(...)` or `AddGlobalOption(name, typeName)`, and accessed outside handlers via `IGlobalOptionsAccessor` (see [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers)) - signed numeric literals (`-1`, `-0.5`, `-1e3`) are treated as positional values, not options ## Public declaration API diff --git a/src/Repl.Core/CoreReplApp.Interactive.cs b/src/Repl.Core/CoreReplApp.Interactive.cs index 8109230..0bb9a70 100644 --- a/src/Repl.Core/CoreReplApp.Interactive.cs +++ b/src/Repl.Core/CoreReplApp.Interactive.cs @@ -150,6 +150,7 @@ or AmbientCommandOutcome.Handled var invocationTokens = scopeTokens.Concat(inputTokens).ToArray(); var globalOptions = GlobalOptionParser.Parse(invocationTokens, _options.Output, _options.Parsing); + _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); if (prefixResolution.IsAmbiguous) { diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index 12bc414..6634d16 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -33,13 +33,16 @@ public sealed partial class CoreReplApp : ICoreReplApp private Delegate? _banner; private readonly AsyncLocal _bannerRendered = new(); private readonly AsyncLocal _allBannersSuppressed = new(); + private readonly GlobalOptionsSnapshot _globalOptionsSnapshot; internal ReplOptions OptionsSnapshot => _options; + internal IGlobalOptionsAccessor GlobalOptionsAccessor => _globalOptionsSnapshot; internal IReplExecutionObserver? ExecutionObserver { get; set; } private CoreReplApp() { _options.Output.SetHostAnsiSupportResolver(() => _options.Capabilities.SupportsAnsi); + _globalOptionsSnapshot = new GlobalOptionsSnapshot(_options.Parsing); _services = CreateDefaultServiceProvider(); _shellCompletionRuntime = new ShellCompletionRuntime( _options, @@ -416,6 +419,8 @@ private async ValueTask ExecuteCoreAsync( try { var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); + _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); + _globalOptionsSnapshot.SetSessionBaseline(); using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; @@ -1399,6 +1404,7 @@ private DefaultServiceProvider CreateDefaultServiceProvider() { [typeof(CoreReplApp)] = this, [typeof(ICoreReplApp)] = this, + [typeof(IGlobalOptionsAccessor)] = _globalOptionsSnapshot, [typeof(IReplSessionState)] = new InMemoryReplSessionState(), [typeof(IReplInteractionChannel)] = new ConsoleInteractionChannel( _options.Interaction, _options.Output, diff --git a/src/Repl.Core/GlobalOptionDefinition.cs b/src/Repl.Core/GlobalOptionDefinition.cs index e4fa338..dbcca96 100644 --- a/src/Repl.Core/GlobalOptionDefinition.cs +++ b/src/Repl.Core/GlobalOptionDefinition.cs @@ -4,4 +4,5 @@ internal sealed record GlobalOptionDefinition( string Name, string CanonicalToken, IReadOnlyList Aliases, - string? DefaultValue); + string? DefaultValue, + Type ValueType); diff --git a/src/Repl.Core/GlobalOptionParser.cs b/src/Repl.Core/GlobalOptionParser.cs index d7a39c5..efa118e 100644 --- a/src/Repl.Core/GlobalOptionParser.cs +++ b/src/Repl.Core/GlobalOptionParser.cs @@ -78,6 +78,7 @@ public static GlobalInvocationOptions Parse( ref index, argument, customTokenMap, + parsingOptions.GlobalOptions, customGlobalValues)) { continue; @@ -164,6 +165,7 @@ private static bool TryParseCustomGlobalOption( ref int index, string argument, IReadOnlyDictionary tokenMap, + IReadOnlyDictionary definitions, Dictionary> customGlobalValues) { if (!TryResolveCustomGlobalName(argument, tokenMap, out var optionName, out var inlineValue)) @@ -171,8 +173,10 @@ private static bool TryParseCustomGlobalOption( return false; } + var isBool = definitions.TryGetValue(optionName, out var def) && def.ValueType == typeof(bool); + var value = inlineValue; - if (value is null + if (value is null && !isBool && index + 1 < args.Count && (!args[index + 1].StartsWith('-') || IsSignedNumericLiteral(args[index + 1]))) { diff --git a/src/Repl.Core/GlobalOptionsSnapshot.cs b/src/Repl.Core/GlobalOptionsSnapshot.cs new file mode 100644 index 0000000..7884809 --- /dev/null +++ b/src/Repl.Core/GlobalOptionsSnapshot.cs @@ -0,0 +1,83 @@ +namespace Repl; + +internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor +{ + private volatile IReadOnlyDictionary> _sessionBaseline = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private volatile IReadOnlyDictionary> _currentValues = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private volatile HashSet _explicitKeys = new(StringComparer.OrdinalIgnoreCase); + + internal void SetSessionBaseline() + { + // Capture only the explicitly parsed values as the new baseline. + // This prevents stale baselines from leaking across separate Run() calls. + var baseline = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var key in _explicitKeys) + { + if (_currentValues.TryGetValue(key, out var values)) + { + baseline[key] = values; + } + } + + _sessionBaseline = baseline; + _currentValues = baseline; + } + + internal void Update(IReadOnlyDictionary> parsedValues) + { + _explicitKeys = new HashSet(parsedValues.Keys, StringComparer.OrdinalIgnoreCase); + var merged = new Dictionary>(_sessionBaseline, StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in parsedValues) + { + merged[key] = value; + } + + _currentValues = merged; + } + + public T? GetValue(string name, T? defaultValue = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + if (_currentValues.TryGetValue(name, out var values) && values.Count > 0) + { + return (T?)ParameterValueConverter.ConvertSingle( + values[0], + typeof(T), + parsingOptions.NumericFormatProvider); + } + + if (parsingOptions.GlobalOptions.TryGetValue(name, out var definition) + && definition.DefaultValue is not null) + { + return (T?)ParameterValueConverter.ConvertSingle( + definition.DefaultValue, + typeof(T), + parsingOptions.NumericFormatProvider); + } + + return defaultValue; + } + + public IReadOnlyList GetRawValues(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return _currentValues.TryGetValue(name, out var values) + ? values + : []; + } + + public bool HasValue(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return _explicitKeys.Contains(name); + } + + public IEnumerable GetOptionNames() => _currentValues.Keys; +} diff --git a/src/Repl.Core/IGlobalOptionsAccessor.cs b/src/Repl.Core/IGlobalOptionsAccessor.cs new file mode 100644 index 0000000..89b5388 --- /dev/null +++ b/src/Repl.Core/IGlobalOptionsAccessor.cs @@ -0,0 +1,36 @@ +namespace Repl; + +/// +/// Provides typed access to parsed global option values. +/// Values are updated after each global option parsing pass. +/// +public interface IGlobalOptionsAccessor +{ + /// + /// Gets the typed value of a global option by its registered name. + /// Returns if the option was not provided. + /// + /// Target value type. + /// The registered option name (without -- prefix). + /// Value to return if the option was not provided. + T? GetValue(string name, T? defaultValue = default); + + /// + /// Gets all raw string values for a global option (supports repeated options). + /// Returns an empty list if the option was not provided. + /// + /// The registered option name (without -- prefix). + IReadOnlyList GetRawValues(string name); + + /// + /// Returns if the named global option was explicitly provided + /// in the current invocation. + /// + /// The registered option name (without -- prefix). + bool HasValue(string name); + + /// + /// Gets all parsed global option names that have values in the current invocation. + /// + IEnumerable GetOptionNames(); +} diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index e3bdb10..6e182bb 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -96,11 +96,28 @@ internal bool TryGetRouteConstraint(string name, out Func predicat /// /// Registers a custom global option consumed before command routing. /// - /// Declared value type (metadata only for now). + /// Declared value type. /// Canonical name without prefix (for example: "tenant"). /// Optional aliases. Values without prefix are normalized to --alias. /// Optional default value metadata. - public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default) + public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default) => + AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString()); + + /// + /// Registers a custom global option using a type or constraint name + /// (for example: "int", "guid", "bool", or a registered custom route constraint name). + /// + /// Canonical name without prefix (for example: "tenant"). + /// + /// Built-in type name ("string", "int", "long", "bool", "guid", "uri", "date", "datetime", "timespan") + /// or a registered custom route constraint name. Custom constraints resolve to string. + /// + /// Optional aliases. Values without prefix are normalized to --alias. + /// Optional default value as string. + public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases = null, string? defaultValue = null) => + AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue); + + internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue) { name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) @@ -123,7 +140,34 @@ public void AddGlobalOption(string name, string[]? aliases = null, T? default Name: name, CanonicalToken: normalizedCanonical, Aliases: normalizedAliases, - DefaultValue: defaultValue?.ToString()); + DefaultValue: defaultValue, + ValueType: valueType); + } + + private static Type ResolveConstraintOrTypeName( + string constraintOrTypeName, + Dictionary> customConstraints) + { + ArgumentNullException.ThrowIfNull(constraintOrTypeName); + + return constraintOrTypeName.ToLowerInvariant() switch + { + "string" or "alpha" or "email" => typeof(string), + "int" => typeof(int), + "long" => typeof(long), + "bool" => typeof(bool), + "guid" => typeof(Guid), + "uri" or "url" or "urn" => typeof(Uri), + "date" or "dateonly" or "date-only" => typeof(DateOnly), + "datetime" or "date-time" => typeof(DateTime), + "datetimeoffset" or "date-time-offset" => typeof(DateTimeOffset), + "time" or "timeonly" or "time-only" => typeof(TimeOnly), + "timespan" or "time-span" => typeof(TimeSpan), + _ when customConstraints.ContainsKey(constraintOrTypeName) => typeof(string), + _ => throw new ArgumentException( + $"Unknown type or constraint name '{constraintOrTypeName}'. Use a known name (string, int, long, bool, guid, uri, date, datetime, timespan), a registered custom route constraint, or the generic AddGlobalOption overload.", + nameof(constraintOrTypeName)), + }; } private static string NormalizeLongToken(string name) => diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs new file mode 100644 index 0000000..4a0185b --- /dev/null +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Repl.Parameters; + +namespace Repl; + +/// +/// Extension methods for registering typed global options on . +/// +public static class GlobalOptionsExtensions +{ + /// + /// Registers a typed global options class. Public settable properties are registered as + /// global options and the class itself is available via DI, populated from parsed values. + /// + /// Options class with a parameterless constructor. + /// The REPL application. + /// The application for fluent chaining. + public static ReplApp UseGlobalOptions<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(this ReplApp app) + where T : class, new() + { + ArgumentNullException.ThrowIfNull(app); + + ParsingOptions? capturedParsing = null; + app.Options(options => + { + capturedParsing = options.Parsing; + var prototype = new T(); + foreach (var property in GetOptionProperties()) + { + var optionAttr = property.GetCustomAttribute(); + var name = optionAttr?.Name ?? ToKebabCase(property.Name); + var aliases = optionAttr?.Aliases; + var defaultValue = property.GetValue(prototype)?.ToString(); + + options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue); + } + }); + + app.ServiceDescriptors.TryAddTransient(sp => + { + var accessor = sp.GetRequiredService(); + return PopulateInstance(accessor, capturedParsing!.NumericFormatProvider); + }); + + return app; + } + + internal static T PopulateInstance<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(IGlobalOptionsAccessor accessor, IFormatProvider numericFormatProvider) + where T : class, new() + { + var instance = new T(); + foreach (var property in GetOptionProperties()) + { + var optionAttr = property.GetCustomAttribute(); + var name = optionAttr?.Name ?? ToKebabCase(property.Name); + + var rawValues = accessor.GetRawValues(name); + if (rawValues.Count == 0) + { + continue; + } + + var value = ParameterValueConverter.ConvertSingle( + rawValues[0], + property.PropertyType, + numericFormatProvider); + property.SetValue(instance, value); + } + + return instance; + } + + private static PropertyInfo[] GetOptionProperties<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>() => + typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.CanWrite) + .ToArray(); + + private static string ToKebabCase(string pascalCase) + { + if (string.IsNullOrEmpty(pascalCase)) + { + return pascalCase; + } + + var builder = new System.Text.StringBuilder(pascalCase.Length + 4); + for (var i = 0; i < pascalCase.Length; i++) + { + var c = pascalCase[i]; + if (char.IsUpper(c) && i > 0) + { + // Only insert hyphen at the start of an uppercase run or + // at the transition from an uppercase run to a lowercase char. + // "XMLPort" → "xml-port", "MaxRetries" → "max-retries" + var prevIsUpper = char.IsUpper(pascalCase[i - 1]); + var nextIsLower = i + 1 < pascalCase.Length && char.IsLower(pascalCase[i + 1]); + if (!prevIsUpper || nextIsLower) + { + builder.Append('-'); + } + } + + builder.Append(char.ToLowerInvariant(c)); + } + + return builder.ToString(); + } +} diff --git a/src/Repl.Defaults/ReplApp.cs b/src/Repl.Defaults/ReplApp.cs index 897c3d0..cc5fa86 100644 --- a/src/Repl.Defaults/ReplApp.cs +++ b/src/Repl.Defaults/ReplApp.cs @@ -20,6 +20,8 @@ public sealed class ReplApp : IReplApp // as handler parameters resolved at runtime. private ServiceProvider? _sharedProvider; + internal IServiceCollection ServiceDescriptors => _services; + private ReplApp(IServiceCollection services) { _services = services; @@ -684,6 +686,7 @@ private static void EnsureDefaultServices(IServiceCollection services, CoreReplA services.TryAddSingleton(); services.TryAddSingleton( _ => new ConsoleTerminalInfo(core.OptionsSnapshot.Output)); + services.TryAddSingleton(_ => core.GlobalOptionsAccessor); } private sealed class ScopedReplApp(ICoreReplApp map, ReplApp root) : IReplApp diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs new file mode 100644 index 0000000..860cfd3 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Repl.IntegrationTests; + +[TestClass] +[DoNotParallelize] +public sealed class Given_GlobalOptionsAccessor +{ + [TestMethod] + [Description("Global option is accessible in handler via IGlobalOptionsAccessor parameter.")] + public void When_GlobalOptionProvided_Then_HandlerCanReadItViaAccessor() + { + var sut = ReplApp.Create(); + sut.Options(o => o.Parsing.AddGlobalOption("tenant")); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("tenant") ?? "none"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("acme"); + } + + [TestMethod] + [Description("Global option is accessible in middleware via DI.")] + public void When_GlobalOptionProvided_Then_MiddlewareCanReadIt() + { + string? captured = null; + var sut = ReplApp.Create(); + sut.Options(o => o.Parsing.AddGlobalOption("tenant")); + sut.Use(async (ctx, next) => + { + var globals = ctx.Services.GetRequiredService(); + captured = globals.GetValue("tenant"); + await next().ConfigureAwait(false); + }); + sut.Map("ping", () => "pong"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["ping", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(0); + captured.Should().Be("acme"); + } + + [TestMethod] + [Description("Global option is accessible in DI factory via lazy resolution.")] + public void When_GlobalOptionProvided_Then_DiFactoryCanReadIt() + { + var sut = ReplApp.Create(services => + { + services.AddSingleton(sp => + { + var globals = sp.GetRequiredService(); + return new TenantConfig(globals.GetValue("tenant") ?? "default"); + }); + }); + sut.Options(o => o.Parsing.AddGlobalOption("tenant")); + sut.Map("show", (TenantConfig cfg) => cfg.Name); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("acme"); + } + + [TestMethod] + [Description("Global option defaults are returned when option not provided.")] + public void When_GlobalOptionNotProvided_Then_DefaultIsReturned() + { + var sut = ReplApp.Create(); + sut.Options(o => o.Parsing.AddGlobalOption("port", defaultValue: 3000)); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("port")); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("3000"); + } + + [TestMethod] + [Description("UseGlobalOptions registers typed class accessible via DI.")] + public void When_UsingTypedGlobalOptions_Then_ClassIsPopulatedFromParsedValues() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (TestGlobalOptions opts) => $"{opts.Tenant}:{opts.Port}"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "acme", "--port", "9090", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("acme:9090"); + } + + [TestMethod] + [Description("UseGlobalOptions properties without values keep defaults.")] + public void When_UsingTypedGlobalOptionsWithoutValues_Then_DefaultsAreKept() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (TestGlobalOptions opts) => $"{opts.Tenant ?? "none"}:{opts.Port}"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("none:8080"); + } + + [TestMethod] + [Description("UseGlobalOptions property defaults are visible via IGlobalOptionsAccessor.")] + public void When_UsingTypedGlobalOptionsWithoutValues_Then_AccessorReturnsPropertyDefaults() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("port")); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("8080"); + } + + [TestMethod] + [Description("Global option registered with string type name works end to end.")] + public void When_GlobalOptionRegisteredWithStringTypeName_Then_TypedAccessWorks() + { + var sut = ReplApp.Create(); + sut.Options(o => o.Parsing.AddGlobalOption("port", "int")); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("port")); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--port", "4000", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("4000"); + } + + [TestMethod] + [Description("CoreReplApp (no MS DI) also provides IGlobalOptionsAccessor.")] + public void When_UsingCoreReplApp_Then_AccessorIsAvailableInHandler() + { + var sut = CoreReplApp.Create(); + sut.Options(o => o.Parsing.AddGlobalOption("tenant")); + sut.Map("show", (IGlobalOptionsAccessor globals) => globals.GetValue("tenant") ?? "none"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "acme", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("acme"); + } + + [TestMethod] + [Description("UseGlobalOptions returns fresh values on each resolution (not stale singleton).")] + public async Task When_TypedOptionsResolvedMultipleTimes_Then_ReflectsLatestValues() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + var results = new List(); + sut.Map("show", (TestGlobalOptions opts) => + { + results.Add($"{opts.Tenant}:{opts.Port}"); + return "ok"; + }); + + // First invocation + ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "first", "--port", "1111", "--no-logo"])); + + // Second invocation with different values + ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--tenant", "second", "--port", "2222", "--no-logo"])); + + results.Should().HaveCount(2); + results[0].Should().Be("first:1111"); + results[1].Should().Be("second:2222"); + } + + [TestMethod] + [Description("UseGlobalOptions uses configured NumericFormatProvider, not invariant culture.")] + public void When_NumericCultureIsCurrent_Then_TypedOptionsUsesConfiguredCulture() + { + var previousCulture = System.Globalization.CultureInfo.CurrentCulture; + try + { + // Set a culture that uses comma as decimal separator + System.Globalization.CultureInfo.CurrentCulture = + new System.Globalization.CultureInfo("fr-FR"); + + var sut = ReplApp.Create(); + sut.Options(o => o.Parsing.NumericCulture = NumericParsingCulture.Current); + sut.UseGlobalOptions(); + sut.Map("show", (DecimalGlobalOptions opts) => opts.Rate.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // fr-FR uses comma, but we pass "1,5" which should parse with current culture + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--rate", "1,5", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("1.5"); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = previousCulture; + } + } + + private sealed record TenantConfig(string Name); + + private sealed class TestGlobalOptions + { + public string? Tenant { get; set; } + + public int Port { get; set; } = 8080; + } + + [TestMethod] + [Description("UseGlobalOptions converts consecutive uppercase property names to kebab-case correctly.")] + public void When_PropertyHasConsecutiveUppercase_Then_KebabCaseIsCorrect() + { + var sut = ReplApp.Create(); + sut.UseGlobalOptions(); + sut.Map("show", (AcronymGlobalOptions opts) => $"{opts.XMLPort}"); + + var output = ConsoleCaptureHelper.Capture( + () => sut.Run(["show", "--xml-port", "9090", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("9090"); + } + + private sealed class DecimalGlobalOptions + { + public double Rate { get; set; } + } + + private sealed class AcronymGlobalOptions + { + public int XMLPort { get; set; } + } +} diff --git a/src/Repl.Tests/Given_GlobalOptionParser.cs b/src/Repl.Tests/Given_GlobalOptionParser.cs index 7146b0e..b2d3e94 100644 --- a/src/Repl.Tests/Given_GlobalOptionParser.cs +++ b/src/Repl.Tests/Given_GlobalOptionParser.cs @@ -73,4 +73,36 @@ public void When_CustomGlobalOptionValueIsSignedNumber_Then_ValueIsConsumed() parsed.CustomGlobalNamedOptions.Should().ContainKey("tenant"); parsed.CustomGlobalNamedOptions["tenant"].Should().ContainSingle().Which.Should().Be("-1"); } + + [TestMethod] + [Description("Bool-typed global option does not consume next positional token.")] + public void When_BoolGlobalOptionFollowedByCommand_Then_CommandNotConsumed() + { + var parsingOptions = new ParsingOptions(); + parsingOptions.AddGlobalOption("verbose"); + + var parsed = GlobalOptionParser.Parse( + ["--verbose", "deploy"], + new OutputOptions(), + parsingOptions); + + parsed.RemainingTokens.Should().Equal("deploy"); + parsed.CustomGlobalNamedOptions["verbose"].Should().ContainSingle().Which.Should().Be("true"); + } + + [TestMethod] + [Description("Bool-typed global option with inline value still works.")] + public void When_BoolGlobalOptionWithInlineValue_Then_ValueIsUsed() + { + var parsingOptions = new ParsingOptions(); + parsingOptions.AddGlobalOption("verbose"); + + var parsed = GlobalOptionParser.Parse( + ["--verbose=false", "deploy"], + new OutputOptions(), + parsingOptions); + + parsed.RemainingTokens.Should().Equal("deploy"); + parsed.CustomGlobalNamedOptions["verbose"].Should().ContainSingle().Which.Should().Be("false"); + } } diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs new file mode 100644 index 0000000..2129a3f --- /dev/null +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -0,0 +1,516 @@ +using AwesomeAssertions; + +namespace Repl.Tests; + +[TestClass] +public sealed class Given_GlobalOptionsAccessor +{ + [TestMethod] + [Description("GetValue returns parsed string value after Update.")] + public void When_StringOptionParsed_Then_GetValueReturnsIt() + { + var sut = CreateAccessor("tenant"); + sut.Update(Values(("tenant", "acme"))); + + sut.GetValue("tenant").Should().Be("acme"); + } + + [TestMethod] + [Description("GetValue returns typed int value after Update.")] + public void When_IntOptionParsed_Then_GetValueReturnsTypedValue() + { + var sut = CreateAccessor("port"); + sut.Update(Values(("port", "8080"))); + + sut.GetValue("port").Should().Be(8080); + } + + [TestMethod] + [Description("GetValue returns typed bool value after Update.")] + public void When_BoolOptionParsed_Then_GetValueReturnsTypedValue() + { + var sut = CreateAccessor("verbose"); + sut.Update(Values(("verbose", "true"))); + + sut.GetValue("verbose").Should().BeTrue(); + } + + [TestMethod] + [Description("GetValue returns default when option not provided.")] + public void When_OptionNotProvided_Then_GetValueReturnsDefault() + { + var sut = CreateAccessor("tenant"); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("tenant").Should().BeNull(); + } + + [TestMethod] + [Description("GetValue returns caller default when option not provided.")] + public void When_OptionNotProvided_Then_GetValueReturnsCallerDefault() + { + var sut = CreateAccessor("tenant"); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("tenant", "fallback").Should().Be("fallback"); + } + + [TestMethod] + [Description("GetValue returns registration default when option not provided.")] + public void When_OptionNotProvidedButRegisteredDefault_Then_GetValueReturnsRegisteredDefault() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port", defaultValue: 3000); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("port").Should().Be(3000); + } + + [TestMethod] + [Description("HasValue returns false before parsing.")] + public void When_NeverUpdated_Then_HasValueReturnsFalse() + { + var sut = CreateAccessor("tenant"); + + sut.HasValue("tenant").Should().BeFalse(); + } + + [TestMethod] + [Description("HasValue returns true after parsing.")] + public void When_OptionParsed_Then_HasValueReturnsTrue() + { + var sut = CreateAccessor("tenant"); + sut.Update(Values(("tenant", "acme"))); + + sut.HasValue("tenant").Should().BeTrue(); + } + + [TestMethod] + [Description("GetRawValues returns all values for repeated option.")] + public void When_OptionRepeated_Then_GetRawValuesReturnsAll() + { + var sut = CreateAccessor("tag"); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase) + { + ["tag"] = (IReadOnlyList)["alpha", "beta"], + }); + + sut.GetRawValues("tag").Should().Equal("alpha", "beta"); + } + + [TestMethod] + [Description("GetRawValues returns empty for unset option.")] + public void When_OptionNotSet_Then_GetRawValuesReturnsEmpty() + { + var sut = CreateAccessor("tag"); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetRawValues("tag").Should().BeEmpty(); + } + + [TestMethod] + [Description("GetOptionNames returns all parsed option names.")] + public void When_MultipleOptionsParsed_Then_GetOptionNamesReturnsThem() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("tenant"); + parsing.AddGlobalOption("port"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("tenant", "acme"), ("port", "8080"))); + + sut.GetOptionNames().Should().Contain("tenant").And.Contain("port"); + } + + [TestMethod] + [Description("Update replaces previous values (per-invocation semantics).")] + public void When_UpdatedTwice_Then_SecondValuesReplacePrevious() + { + var sut = CreateAccessor("tenant"); + sut.Update(Values(("tenant", "first"))); + sut.Update(Values(("tenant", "second"))); + + sut.GetValue("tenant").Should().Be("second"); + } + + [TestMethod] + [Description("Session baseline values persist when interactive command provides no override.")] + public void When_BaselineSet_Then_UpdateWithEmptyPreservesBaseline() + { + var sut = CreateAccessor("env"); + sut.Update(Values(("env", "staging"))); + sut.SetSessionBaseline(); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("env").Should().Be("staging"); + } + + [TestMethod] + [Description("HasValue returns false for baseline-only keys not explicitly provided.")] + public void When_BaselineKeyNotExplicitlyProvided_Then_HasValueReturnsFalse() + { + var sut = CreateAccessor("env"); + sut.Update(Values(("env", "staging"))); + sut.SetSessionBaseline(); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.HasValue("env").Should().BeFalse(); + sut.GetValue("env").Should().Be("staging"); // still accessible via GetValue + } + + [TestMethod] + [Description("HasValue returns true when option is explicitly provided even with baseline.")] + public void When_BaselineKeyExplicitlyOverridden_Then_HasValueReturnsTrue() + { + var sut = CreateAccessor("env"); + sut.Update(Values(("env", "staging"))); + sut.SetSessionBaseline(); + sut.Update(Values(("env", "prod"))); + + sut.HasValue("env").Should().BeTrue(); + } + + [TestMethod] + [Description("Session baseline does not leak across separate Run() cycles.")] + public void When_SecondRunOmitsOption_Then_BaselineFromFirstRunDoesNotLeak() + { + var sut = CreateAccessor("tenant"); + + // First Run() cycle + sut.Update(Values(("tenant", "acme"))); + sut.SetSessionBaseline(); + + // Second Run() cycle — no --tenant provided + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + sut.SetSessionBaseline(); + + sut.GetValue("tenant").Should().BeNull(); + } + + [TestMethod] + [Description("Per-command override takes precedence over session baseline.")] + public void When_BaselineSetAndOverridden_Then_OverrideTakesPrecedence() + { + var sut = CreateAccessor("env"); + sut.Update(Values(("env", "staging"))); + sut.SetSessionBaseline(); + sut.Update(Values(("env", "prod"))); + + sut.GetValue("env").Should().Be("prod"); + } + + [TestMethod] + [Description("Session baseline is restored after override is gone.")] + public void When_OverrideRemovedOnNextUpdate_Then_BaselineRestored() + { + var sut = CreateAccessor("env"); + sut.Update(Values(("env", "staging"))); + sut.SetSessionBaseline(); + + // Command with override + sut.Update(Values(("env", "prod"))); + sut.GetValue("env").Should().Be("prod"); + + // Next command without override — baseline restored + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + sut.GetValue("env").Should().Be("staging"); + } + + [TestMethod] + [Description("Override does not mutate the session baseline.")] + public void When_OverrideApplied_Then_BaselineRemainsUnchanged() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("env"); + parsing.AddGlobalOption("port"); + var sut = new GlobalOptionsSnapshot(parsing); + + sut.Update(Values(("env", "staging"), ("port", "3000"))); + sut.SetSessionBaseline(); + + // Override only env + sut.Update(Values(("env", "prod"))); + sut.GetValue("env").Should().Be("prod"); + sut.GetValue("port").Should().Be(3000); // baseline preserved + + // Next command — both baseline values restored + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + sut.GetValue("env").Should().Be("staging"); + sut.GetValue("port").Should().Be(3000); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'int' works.")] + public void When_RegisteredWithStringTypeName_Int_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port", "int"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("port", "9090"))); + + sut.GetValue("port").Should().Be(9090); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'bool' works.")] + public void When_RegisteredWithStringTypeName_Bool_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("verbose", "bool"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("verbose", "true"))); + + sut.GetValue("verbose").Should().BeTrue(); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'long' works.")] + public void When_RegisteredWithStringTypeName_Long_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("size", "long"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("size", "9999999999"))); + + sut.GetValue("size").Should().Be(9_999_999_999L); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'guid' works.")] + public void When_RegisteredWithStringTypeName_Guid_Then_GetValueConvertsCorrectly() + { + var id = Guid.NewGuid(); + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("id", "guid"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("id", id.ToString()))); + + sut.GetValue("id").Should().Be(id); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'uri' works.")] + public void When_RegisteredWithStringTypeName_Uri_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("endpoint", "uri"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("endpoint", "https://example.com"))); + + sut.GetValue("endpoint").Should().Be(new Uri("https://example.com")); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'date' works.")] + public void When_RegisteredWithStringTypeName_Date_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("since", "date"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("since", "2026-03-30"))); + + sut.GetValue("since").Should().Be(new DateOnly(2026, 3, 30)); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'timespan' works.")] + public void When_RegisteredWithStringTypeName_TimeSpan_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("timeout", "timespan"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("timeout", "00:05:00"))); + + sut.GetValue("timeout").Should().Be(TimeSpan.FromMinutes(5)); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'datetime' works.")] + public void When_RegisteredWithStringTypeName_DateTime_Then_GetValueConvertsCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("at", "datetime"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("at", "2026-03-30T14:00:00"))); + + sut.GetValue("at").Day.Should().Be(30); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'string' works.")] + public void When_RegisteredWithStringTypeName_String_Then_GetValueReturnsIt() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("name", "string"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("name", "hello"))); + + sut.GetValue("name").Should().Be("hello"); + } + + [TestMethod] + [Description("AddGlobalOption with unknown type name throws ArgumentException.")] + public void When_RegisteredWithUnknownTypeName_Then_Throws() + { + var parsing = new ParsingOptions(); + + var act = () => parsing.AddGlobalOption("x", "foobar"); + + act.Should().Throw() + .Which.Message.Should().Contain("foobar"); + } + + [TestMethod] + [Description("AddGlobalOption with case-variant type name works.")] + public void When_RegisteredWithUpperCaseTypeName_Then_ResolvesCorrectly() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port", "INT"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("port", "443"))); + + sut.GetValue("port").Should().Be(443); + } + + [TestMethod] + [Description("AddGlobalOption preserves ValueType from generic overload.")] + public void When_RegisteredWithGenericOverload_Then_ValueTypeIsPreserved() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("id"); + + parsing.GlobalOptions["id"].ValueType.Should().Be(typeof(Guid)); + } + + [TestMethod] + [Description("AddGlobalOption preserves ValueType from string type name overload.")] + public void When_RegisteredWithStringTypeNameOverload_Then_ValueTypeIsPreserved() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port", "int"); + + parsing.GlobalOptions["port"].ValueType.Should().Be(typeof(int)); + } + + [TestMethod] + [Description("AddGlobalOption with custom route constraint type name resolves as string.")] + public void When_RegisteredWithCustomConstraintTypeName_Then_ResolvesAsString() + { + var parsing = new ParsingOptions(); + parsing.AddRouteConstraint("hex", value => value.All(c => "0123456789abcdefABCDEF".Contains(c, StringComparison.Ordinal))); + parsing.AddGlobalOption("color", "hex"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("color", "ff00aa"))); + + sut.GetValue("color").Should().Be("ff00aa"); + parsing.GlobalOptions["color"].ValueType.Should().Be(typeof(string)); + } + + [TestMethod] + [Description("AddGlobalOption with custom constraint that doesn't exist still throws.")] + public void When_RegisteredWithUnregisteredConstraintName_Then_Throws() + { + var parsing = new ParsingOptions(); + + var act = () => parsing.AddGlobalOption("x", "nonexistent"); + + act.Should().Throw() + .Which.Message.Should().Contain("nonexistent"); + } + + [TestMethod] + [Description("Duplicate global option name throws.")] + public void When_RegisteringDuplicateName_Then_Throws() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("tenant"); + + var act = () => parsing.AddGlobalOption("tenant", "int"); + + act.Should().Throw() + .Which.Message.Should().Contain("tenant"); + } + + [TestMethod] + [Description("GetValue with enum type converts string to enum.")] + public void When_EnumOptionParsed_Then_GetValueReturnsTypedEnum() + { + var sut = CreateAccessor("level"); + sut.Update(Values(("level", "Warning"))); + + sut.GetValue("level").Should().Be(LogLevel.Warning); + } + + [TestMethod] + [Description("PopulateInstance picks up session-baseline values not explicitly provided.")] + public void When_BaselineSetAndNoExplicitValue_Then_PopulateInstanceUsesBaseline() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("tenant"); + parsing.AddGlobalOption("port"); + var snapshot = new GlobalOptionsSnapshot(parsing); + + // Simulate CLI launch with --tenant acme --port 3000 + snapshot.Update(Values(("tenant", "acme"), ("port", "3000"))); + snapshot.SetSessionBaseline(); + + // Simulate interactive command with no global options + snapshot.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + // PopulateInstance should still see baseline values + var result = GlobalOptionsExtensions.PopulateInstance( + snapshot, System.Globalization.CultureInfo.InvariantCulture); + + result.Tenant.Should().Be("acme"); + result.Port.Should().Be(3000); + } + + [TestMethod] + [Description("AddGlobalOption with string type name 'email' resolves as string.")] + public void When_RegisteredWithStringTypeName_Email_Then_ResolvesAsString() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("contact", "email"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(Values(("contact", "test@example.com"))); + + sut.GetValue("contact").Should().Be("test@example.com"); + parsing.GlobalOptions["contact"].ValueType.Should().Be(typeof(string)); + } + + private sealed class TestTypedOptions + { + public string? Tenant { get; set; } + + public int Port { get; set; } + } + + private enum LogLevel + { + Info, + Warning, + Error, + } + + private static GlobalOptionsSnapshot CreateAccessor(string name) + => CreateAccessor(name); + + private static GlobalOptionsSnapshot CreateAccessor(string name) + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption(name); + return new GlobalOptionsSnapshot(parsing); + } + + private static Dictionary> Values( + params (string Key, string Value)[] entries) + { + var dict = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in entries) + { + dict[key] = [value]; + } + + return dict; + } +}