From 3d5d75ab872d67e83545dc454bf169be409797f6 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 30 Mar 2026 19:24:17 -0400 Subject: [PATCH 01/10] Add IGlobalOptionsAccessor for typed access to global options Global options registered via AddGlobalOption are now accessible outside handlers through IGlobalOptionsAccessor, registered in DI automatically. Middleware and DI factories can resolve it to read parsed values. Also adds UseGlobalOptions() for typed class registration and a non-generic AddGlobalOption(name, typeName) overload accepting constraint-style type names. --- src/Repl.Core/CoreReplApp.Interactive.cs | 1 + src/Repl.Core/CoreReplApp.cs | 5 + src/Repl.Core/GlobalOptionDefinition.cs | 3 +- src/Repl.Core/GlobalOptionsSnapshot.cs | 54 ++++++ src/Repl.Core/IGlobalOptionsAccessor.cs | 36 ++++ src/Repl.Core/ParsingOptions.cs | 38 +++- src/Repl.Defaults/GlobalOptionsExtensions.cs | 104 +++++++++++ src/Repl.Defaults/ReplApp.cs | 3 + .../Given_GlobalOptionsAccessor.cs | 151 ++++++++++++++++ src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 169 ++++++++++++++++++ 10 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 src/Repl.Core/GlobalOptionsSnapshot.cs create mode 100644 src/Repl.Core/IGlobalOptionsAccessor.cs create mode 100644 src/Repl.Defaults/GlobalOptionsExtensions.cs create mode 100644 src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs create mode 100644 src/Repl.Tests/Given_GlobalOptionsAccessor.cs 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..f7378de 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,7 @@ private async ValueTask ExecuteCoreAsync( try { var globalOptions = GlobalOptionParser.Parse(args, _options.Output, _options.Parsing); + _globalOptionsSnapshot.Update(globalOptions.CustomGlobalNamedOptions); using var runtimeStateScope = PushRuntimeState(serviceProvider, isInteractiveSession: false); var prefixResolution = ResolveUniquePrefixes(globalOptions.RemainingTokens); var resolvedGlobalOptions = globalOptions with { RemainingTokens = prefixResolution.Tokens }; @@ -1399,6 +1403,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/GlobalOptionsSnapshot.cs b/src/Repl.Core/GlobalOptionsSnapshot.cs new file mode 100644 index 0000000..778d88f --- /dev/null +++ b/src/Repl.Core/GlobalOptionsSnapshot.cs @@ -0,0 +1,54 @@ +namespace Repl; + +internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor +{ + private IReadOnlyDictionary> _currentValues = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + + internal void Update(IReadOnlyDictionary> parsedValues) + { + _currentValues = parsedValues; + } + + 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 _currentValues.ContainsKey(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..e9fc6f6 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -96,11 +96,25 @@ 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 name (for example: "int", "guid", "bool") + /// similar to route constraint names. + /// + /// Canonical name without prefix (for example: "tenant"). + /// Type name: "string", "int", "long", "bool", "guid", "uri", "date", "datetime", "timespan". + /// Optional aliases. Values without prefix are normalized to --alias. + /// Optional default value as string. + public void AddGlobalOption(string name, string typeName, string[]? aliases = null, string? defaultValue = null) => + AddGlobalOptionCore(name, ResolveTypeName(typeName), 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,9 +137,27 @@ 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 ResolveTypeName(string typeName) => + (typeName ?? throw new ArgumentNullException(nameof(typeName))).ToLowerInvariant() switch + { + "string" or "alpha" => 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), + _ => throw new ArgumentException($"Unknown type name '{typeName}'. Use a known name (string, int, long, bool, guid, uri, date, datetime, timespan) or the generic AddGlobalOption overload.", nameof(typeName)), + }; + private static string NormalizeLongToken(string name) => name.StartsWith("--", StringComparison.Ordinal) ? name diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs new file mode 100644 index 0000000..e35c439 --- /dev/null +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -0,0 +1,104 @@ +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); + + app.Options(options => + { + foreach (var property in GetOptionProperties()) + { + var optionAttr = property.GetCustomAttribute(); + var name = optionAttr?.Name ?? ToKebabCase(property.Name); + var aliases = optionAttr?.Aliases; + + options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue: null); + } + }); + + app.ServiceDescriptors.TryAddSingleton(sp => + { + var accessor = sp.GetRequiredService(); + return PopulateInstance(accessor); + }); + + return app; + } + + internal static T PopulateInstance<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(IGlobalOptionsAccessor accessor) + where T : class, new() + { + var instance = new T(); + foreach (var property in GetOptionProperties()) + { + var optionAttr = property.GetCustomAttribute(); + var name = optionAttr?.Name ?? ToKebabCase(property.Name); + + if (!accessor.HasValue(name)) + { + continue; + } + + var rawValues = accessor.GetRawValues(name); + if (rawValues.Count == 0) + { + continue; + } + + var value = ParameterValueConverter.ConvertSingle( + rawValues[0], + property.PropertyType, + System.Globalization.CultureInfo.InvariantCulture); + 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) + { + 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..02e3e66 --- /dev/null +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -0,0 +1,151 @@ +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("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"); + } + + private sealed record TenantConfig(string Name); + + private sealed class TestGlobalOptions + { + public string? Tenant { get; set; } + + public int Port { get; set; } = 8080; + } +} diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs new file mode 100644 index 0000000..272c461 --- /dev/null +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -0,0 +1,169 @@ +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("AddGlobalOption with string type name works.")] + public void When_RegisteredWithStringTypeName_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); + } + + 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; + } +} From 8d9c187214ebbaa44237ded191704d1c81cd08ea Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 30 Mar 2026 19:24:30 -0400 Subject: [PATCH 02/10] Document IGlobalOptionsAccessor and UseGlobalOptions Update commands, configuration reference, execution pipeline, parameter system, and best practices docs with global options accessor usage patterns and examples. --- docs/best-practices.md | 30 +++++++++++++++++++ docs/commands.md | 53 +++++++++++++++++++++++++++++++++ docs/configuration-reference.md | 16 ++++++++++ docs/execution-pipeline.md | 2 ++ docs/parameter-system.md | 2 +- 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 4db1814..0457da5 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. 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..f06df8b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -53,6 +53,59 @@ 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 name string instead of a generic parameter: + +```csharp +app.Options(o => o.Parsing.AddGlobalOption("port", "int")); +``` + +Supported type names: `string`, `int`, `long`, `bool`, `guid`, `uri`, `date`, `datetime`, `timespan`. + ## 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..dff12dc 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -83,6 +83,8 @@ 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. ### 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 From 0339558cd583d18798cc8da7b65ba3e55f1175ab Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Mon, 30 Mar 2026 19:52:09 -0400 Subject: [PATCH 03/10] Support custom route constraints in AddGlobalOption type name The non-generic AddGlobalOption(name, typeName) overload now resolves custom route constraints registered via AddRouteConstraint as string type. Adds tests for all built-in type names, custom constraints, enum conversion, ValueType preservation, and error cases. --- src/Repl.Core/ParsingOptions.cs | 17 +- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 201 +++++++++++++++++- 2 files changed, 212 insertions(+), 6 deletions(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index e9fc6f6..0c842fb 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -112,7 +112,7 @@ public void AddGlobalOption(string name, string[]? aliases = null, T? default /// Optional aliases. Values without prefix are normalized to --alias. /// Optional default value as string. public void AddGlobalOption(string name, string typeName, string[]? aliases = null, string? defaultValue = null) => - AddGlobalOptionCore(name, ResolveTypeName(typeName), aliases, defaultValue); + AddGlobalOptionCore(name, ResolveTypeName(typeName, _customRouteConstraints), aliases, defaultValue); internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue) { @@ -141,8 +141,13 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases ValueType: valueType); } - private static Type ResolveTypeName(string typeName) => - (typeName ?? throw new ArgumentNullException(nameof(typeName))).ToLowerInvariant() switch + private static Type ResolveTypeName( + string typeName, + Dictionary> customConstraints) + { + ArgumentNullException.ThrowIfNull(typeName); + + return typeName.ToLowerInvariant() switch { "string" or "alpha" => typeof(string), "int" => typeof(int), @@ -155,8 +160,12 @@ private static Type ResolveTypeName(string typeName) => "datetimeoffset" or "date-time-offset" => typeof(DateTimeOffset), "time" or "timeonly" or "time-only" => typeof(TimeOnly), "timespan" or "time-span" => typeof(TimeSpan), - _ => throw new ArgumentException($"Unknown type name '{typeName}'. Use a known name (string, int, long, bool, guid, uri, date, datetime, timespan) or the generic AddGlobalOption overload.", nameof(typeName)), + _ when customConstraints.ContainsKey(typeName) => typeof(string), + _ => throw new ArgumentException( + $"Unknown type name '{typeName}'. Use a known name (string, int, long, bool, guid, uri, date, datetime, timespan), a registered custom route constraint, or the generic AddGlobalOption overload.", + nameof(typeName)), }; + } private static string NormalizeLongToken(string name) => name.StartsWith("--", StringComparison.Ordinal) diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 272c461..4449968 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -134,8 +134,8 @@ public void When_UpdatedTwice_Then_SecondValuesReplacePrevious() } [TestMethod] - [Description("AddGlobalOption with string type name works.")] - public void When_RegisteredWithStringTypeName_Then_GetValueConvertsCorrectly() + [Description("AddGlobalOption with string type name 'int' works.")] + public void When_RegisteredWithStringTypeName_Int_Then_GetValueConvertsCorrectly() { var parsing = new ParsingOptions(); parsing.AddGlobalOption("port", "int"); @@ -145,6 +145,203 @@ public void When_RegisteredWithStringTypeName_Then_GetValueConvertsCorrectly() 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); + } + + private enum LogLevel + { + Info, + Warning, + Error, + } + private static GlobalOptionsSnapshot CreateAccessor(string name) => CreateAccessor(name); From d6b711b3f2d1148ed1ec4cc590174bcf22f99b4f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 31 Mar 2026 09:00:06 -0400 Subject: [PATCH 04/10] Fix stale typed global options in interactive mode UseGlobalOptions() was registering the typed class as a singleton, causing stale values when global options changed between interactive REPL invocations. Changed to transient registration so each resolution reflects the latest parsed values. Added integration test verifying typed options update across invocations. --- src/Repl.Defaults/GlobalOptionsExtensions.cs | 2 +- .../Given_GlobalOptionsAccessor.cs | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index e35c439..790809f 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -36,7 +36,7 @@ public static class GlobalOptionsExtensions } }); - app.ServiceDescriptors.TryAddSingleton(sp => + app.ServiceDescriptors.TryAddTransient(sp => { var accessor = sp.GetRequiredService(); return PopulateInstance(accessor); diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 02e3e66..53546ff 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -140,6 +140,32 @@ public void When_UsingCoreReplApp_Then_AccessorIsAvailableInHandler() 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"); + } + private sealed record TenantConfig(string Name); private sealed class TestGlobalOptions From 5a2e67ca00c2406695ac1e435af867ecb6f28d26 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 31 Mar 2026 09:47:52 -0400 Subject: [PATCH 05/10] Add session-sticky global options for interactive mode CLI-level global options now persist as session defaults across all interactive commands. Per-command overrides are temporary and revert to the session baseline on the next command. --- docs/commands.md | 13 ++++ docs/execution-pipeline.md | 4 ++ src/Repl.Core/CoreReplApp.cs | 1 + src/Repl.Core/GlobalOptionsSnapshot.cs | 16 ++++- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 64 +++++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index f06df8b..03cb27d 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -106,6 +106,19 @@ app.Options(o => o.Parsing.AddGlobalOption("port", "int")); Supported type names: `string`, `int`, `long`, `bool`, `guid`, `uri`, `date`, `datetime`, `timespan`. +### 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/execution-pipeline.md b/docs/execution-pipeline.md index dff12dc..486c435 100644 --- a/docs/execution-pipeline.md +++ b/docs/execution-pipeline.md @@ -86,6 +86,10 @@ These tokens are consumed and removed from the argument list before the next sta 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 `ResolveUniquePrefixes()` expands abbreviated command names to their full registered diff --git a/src/Repl.Core/CoreReplApp.cs b/src/Repl.Core/CoreReplApp.cs index f7378de..6634d16 100644 --- a/src/Repl.Core/CoreReplApp.cs +++ b/src/Repl.Core/CoreReplApp.cs @@ -420,6 +420,7 @@ private async ValueTask ExecuteCoreAsync( { 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 }; diff --git a/src/Repl.Core/GlobalOptionsSnapshot.cs b/src/Repl.Core/GlobalOptionsSnapshot.cs index 778d88f..a3c2b19 100644 --- a/src/Repl.Core/GlobalOptionsSnapshot.cs +++ b/src/Repl.Core/GlobalOptionsSnapshot.cs @@ -2,12 +2,26 @@ namespace Repl; internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor { + private IReadOnlyDictionary> _sessionBaseline = + new Dictionary>(StringComparer.OrdinalIgnoreCase); + private IReadOnlyDictionary> _currentValues = new Dictionary>(StringComparer.OrdinalIgnoreCase); + internal void SetSessionBaseline() + { + _sessionBaseline = _currentValues; + } + internal void Update(IReadOnlyDictionary> parsedValues) { - _currentValues = parsedValues; + 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) diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 4449968..945b1de 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -133,6 +133,70 @@ public void When_UpdatedTwice_Then_SecondValuesReplacePrevious() 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("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() From 8a9d6c6b17a54b9ced6fb819cca3013e82a5e21f Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 31 Mar 2026 09:51:27 -0400 Subject: [PATCH 06/10] Propagate property initializer defaults in UseGlobalOptions Create a prototype instance of T to capture property initializer values and pass them as registration defaults to AddGlobalOptionCore. This ensures IGlobalOptionsAccessor returns correct defaults (e.g. 8080 for Port { get; set; } = 8080) even when accessed outside the typed class. --- src/Repl.Defaults/GlobalOptionsExtensions.cs | 4 +++- .../Given_GlobalOptionsAccessor.cs | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 790809f..fae5369 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -26,13 +26,15 @@ public static class GlobalOptionsExtensions app.Options(options => { + 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: null); + options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue); } }); diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 53546ff..92275dd 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -110,6 +110,21 @@ public void When_UsingTypedGlobalOptionsWithoutValues_Then_DefaultsAreKept() 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() From 3b9e0f5d086c85162900a87d10b044998a0dcb6a Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Tue, 31 Mar 2026 19:56:23 -0400 Subject: [PATCH 07/10] Address review findings on global options accessor - HasValue now returns false for baseline-inherited keys not explicitly provided in the current command - Bool-typed global options no longer consume the next positional token - Rename typeName parameter to constraintOrTypeName to reflect that custom route constraint names are also accepted - Add volatile to GlobalOptionsSnapshot fields for hosted session safety - PopulateInstance now uses configured NumericFormatProvider instead of hardcoded InvariantCulture --- docs/commands.md | 4 +-- src/Repl.Core/GlobalOptionParser.cs | 6 +++- src/Repl.Core/GlobalOptionsSnapshot.cs | 9 +++-- src/Repl.Core/ParsingOptions.cs | 27 ++++++++------- src/Repl.Defaults/GlobalOptionsExtensions.cs | 8 +++-- .../Given_GlobalOptionsAccessor.cs | 34 +++++++++++++++++++ src/Repl.Tests/Given_GlobalOptionParser.cs | 32 +++++++++++++++++ src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 25 ++++++++++++++ 8 files changed, 124 insertions(+), 21 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 03cb27d..acd77ad 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -98,13 +98,13 @@ 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 name string instead of a generic parameter: +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")); ``` -Supported type names: `string`, `int`, `long`, `bool`, `guid`, `uri`, `date`, `datetime`, `timespan`. +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 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 index a3c2b19..c41b704 100644 --- a/src/Repl.Core/GlobalOptionsSnapshot.cs +++ b/src/Repl.Core/GlobalOptionsSnapshot.cs @@ -2,12 +2,14 @@ namespace Repl; internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGlobalOptionsAccessor { - private IReadOnlyDictionary> _sessionBaseline = + private volatile IReadOnlyDictionary> _sessionBaseline = new Dictionary>(StringComparer.OrdinalIgnoreCase); - private IReadOnlyDictionary> _currentValues = + private volatile IReadOnlyDictionary> _currentValues = new Dictionary>(StringComparer.OrdinalIgnoreCase); + private volatile HashSet _explicitKeys = new(StringComparer.OrdinalIgnoreCase); + internal void SetSessionBaseline() { _sessionBaseline = _currentValues; @@ -15,6 +17,7 @@ internal void SetSessionBaseline() 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) { @@ -61,7 +64,7 @@ public bool HasValue(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - return _currentValues.ContainsKey(name); + return _explicitKeys.Contains(name); } public IEnumerable GetOptionNames() => _currentValues.Keys; diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 0c842fb..7e60b9c 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -104,15 +104,18 @@ public void AddGlobalOption(string name, string[]? aliases = null, T? default AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString()); /// - /// Registers a custom global option using a type name (for example: "int", "guid", "bool") - /// similar to route constraint names. + /// 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"). - /// Type name: "string", "int", "long", "bool", "guid", "uri", "date", "datetime", "timespan". + /// + /// 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 typeName, string[]? aliases = null, string? defaultValue = null) => - AddGlobalOptionCore(name, ResolveTypeName(typeName, _customRouteConstraints), aliases, defaultValue); + 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) { @@ -141,13 +144,13 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases ValueType: valueType); } - private static Type ResolveTypeName( - string typeName, + private static Type ResolveConstraintOrTypeName( + string constraintOrTypeName, Dictionary> customConstraints) { - ArgumentNullException.ThrowIfNull(typeName); + ArgumentNullException.ThrowIfNull(constraintOrTypeName); - return typeName.ToLowerInvariant() switch + return constraintOrTypeName.ToLowerInvariant() switch { "string" or "alpha" => typeof(string), "int" => typeof(int), @@ -160,10 +163,10 @@ private static Type ResolveTypeName( "datetimeoffset" or "date-time-offset" => typeof(DateTimeOffset), "time" or "timeonly" or "time-only" => typeof(TimeOnly), "timespan" or "time-span" => typeof(TimeSpan), - _ when customConstraints.ContainsKey(typeName) => typeof(string), + _ when customConstraints.ContainsKey(constraintOrTypeName) => typeof(string), _ => throw new ArgumentException( - $"Unknown type name '{typeName}'. Use a known name (string, int, long, bool, guid, uri, date, datetime, timespan), a registered custom route constraint, or the generic AddGlobalOption overload.", - nameof(typeName)), + $"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)), }; } diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index fae5369..65b11ec 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -24,8 +24,10 @@ public static class GlobalOptionsExtensions { ArgumentNullException.ThrowIfNull(app); + ParsingOptions? capturedParsing = null; app.Options(options => { + capturedParsing = options.Parsing; var prototype = new T(); foreach (var property in GetOptionProperties()) { @@ -41,13 +43,13 @@ public static class GlobalOptionsExtensions app.ServiceDescriptors.TryAddTransient(sp => { var accessor = sp.GetRequiredService(); - return PopulateInstance(accessor); + return PopulateInstance(accessor, capturedParsing!.NumericFormatProvider); }); return app; } - internal static T PopulateInstance<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(IGlobalOptionsAccessor accessor) + internal static T PopulateInstance<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(IGlobalOptionsAccessor accessor, IFormatProvider numericFormatProvider) where T : class, new() { var instance = new T(); @@ -70,7 +72,7 @@ public static class GlobalOptionsExtensions var value = ParameterValueConverter.ConvertSingle( rawValues[0], property.PropertyType, - System.Globalization.CultureInfo.InvariantCulture); + numericFormatProvider); property.SetValue(instance, value); } diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 92275dd..657ded6 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -181,6 +181,35 @@ public async Task When_TypedOptionsResolvedMultipleTimes_Then_ReflectsLatestValu 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 @@ -189,4 +218,9 @@ private sealed class TestGlobalOptions public int Port { get; set; } = 8080; } + + private sealed class DecimalGlobalOptions + { + public double Rate { 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 index 945b1de..50f7f16 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -145,6 +145,31 @@ public void When_BaselineSet_Then_UpdateWithEmptyPreservesBaseline() 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("Per-command override takes precedence over session baseline.")] public void When_BaselineSetAndOverridden_Then_OverrideTakesPrecedence() From 0c11df6196930baeaebddacfc386a25f1eb32cb5 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Wed, 1 Apr 2026 18:31:01 -0400 Subject: [PATCH 08/10] Fix ToKebabCase for consecutive uppercase and doc singleton warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToKebabCase now handles acronyms correctly: XMLPort → xml-port instead of x-m-l-port. Only inserts a hyphen at the start of an uppercase run or at the transition from uppercase run to lowercase. Added warning in best-practices.md that singleton DI factories capture global option values once and won't update in interactive mode. --- docs/best-practices.md | 2 +- src/Repl.Defaults/GlobalOptionsExtensions.cs | 10 +++++++++- .../Given_GlobalOptionsAccessor.cs | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/best-practices.md b/docs/best-practices.md index 0457da5..ace6bcd 100644 --- a/docs/best-practices.md +++ b/docs/best-practices.md @@ -105,7 +105,7 @@ services.AddSingleton(sp => }); ``` -Note: DI singleton factories are resolved lazily, so the values are available after global option parsing completes. See [Commands — Accessing global options](commands.md#accessing-global-options-outside-handlers). +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]` diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 65b11ec..bc69ea9 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -97,7 +97,15 @@ private static string ToKebabCase(string pascalCase) var c = pascalCase[i]; if (char.IsUpper(c) && i > 0) { - builder.Append('-'); + // 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)); diff --git a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs index 657ded6..860cfd3 100644 --- a/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs @@ -219,8 +219,28 @@ private sealed class TestGlobalOptions 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; } + } } From 959cfb9e0a1ed4c18fb699ecaa894d748a8cbf37 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Apr 2026 22:08:31 -0400 Subject: [PATCH 09/10] Fix PopulateInstance skipping baseline values and add email type name PopulateInstance used HasValue() which only checks explicitly provided keys, causing session-baseline values to be skipped for typed options classes. Now checks GetRawValues() directly so baseline values are picked up correctly. Added "email" to the built-in type name resolution (resolves to string) to match its presence in ReservedConstraintNames. --- src/Repl.Core/ParsingOptions.cs | 2 +- src/Repl.Defaults/GlobalOptionsExtensions.cs | 5 --- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 44 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 7e60b9c..6e182bb 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -152,7 +152,7 @@ private static Type ResolveConstraintOrTypeName( return constraintOrTypeName.ToLowerInvariant() switch { - "string" or "alpha" => typeof(string), + "string" or "alpha" or "email" => typeof(string), "int" => typeof(int), "long" => typeof(long), "bool" => typeof(bool), diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index bc69ea9..4a0185b 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -58,11 +58,6 @@ public static class GlobalOptionsExtensions var optionAttr = property.GetCustomAttribute(); var name = optionAttr?.Name ?? ToKebabCase(property.Name); - if (!accessor.HasValue(name)) - { - continue; - } - var rawValues = accessor.GetRawValues(name); if (rawValues.Count == 0) { diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 50f7f16..214dd0d 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -424,6 +424,50 @@ public void When_EnumOptionParsed_Then_GetValueReturnsTypedEnum() 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, From 16428bb68c2fba0138614d101b2e8fd46d0244a3 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 2 Apr 2026 22:37:39 -0400 Subject: [PATCH 10/10] Fix session baseline leak across separate Run() cycles SetSessionBaseline now rebuilds from only explicitly parsed keys, preventing stale values from leaking when the same app instance is used for multiple Run() calls. --- src/Repl.Core/GlobalOptionsSnapshot.cs | 14 +++++++++++++- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Repl.Core/GlobalOptionsSnapshot.cs b/src/Repl.Core/GlobalOptionsSnapshot.cs index c41b704..7884809 100644 --- a/src/Repl.Core/GlobalOptionsSnapshot.cs +++ b/src/Repl.Core/GlobalOptionsSnapshot.cs @@ -12,7 +12,19 @@ internal sealed class GlobalOptionsSnapshot(ParsingOptions parsingOptions) : IGl internal void SetSessionBaseline() { - _sessionBaseline = _currentValues; + // 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) diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 214dd0d..2129a3f 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -170,6 +170,23 @@ public void When_BaselineKeyExplicitlyOverridden_Then_HasValueReturnsTrue() 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()