From 1ce66c9feb83b5c4167b1d069b4d1fa77e0d0df7 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 25 Jun 2026 08:37:20 -0400 Subject: [PATCH 1/6] Fix global option descriptions in help output --- .../Help/HelpTextBuilder.Rendering.cs | 16 ++++-- .../Parsing/GlobalOptionDefinition.cs | 1 + src/Repl.Core/ParsingOptions.cs | 13 +++-- src/Repl.Defaults/GlobalOptionsExtensions.cs | 3 +- .../Given_CustomGlobalOptions.cs | 49 +++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 35ff2b3..c325592 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -619,13 +619,21 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) .Select(option => { - var aliases = option.Aliases.Count == 0 - ? string.Empty - : $", {string.Join(", ", option.Aliases)}"; + var aliases = option.Aliases.Count == 0 + ? string.Empty + : $", {string.Join(", ", option.Aliases)}"; + var description = string.IsNullOrWhiteSpace(option.Description) + ? "Custom global option." + : option.Description; + if (!string.IsNullOrWhiteSpace(option.DefaultValue)) + { + description = $"{description} [default: {option.DefaultValue}]"; + } + return new[] { $"{option.CanonicalToken}{aliases}", - "Custom global option.", + description, }; }); return [.. BuiltInGlobalOptionRows.Concat(customRows)]; diff --git a/src/Repl.Core/Parsing/GlobalOptionDefinition.cs b/src/Repl.Core/Parsing/GlobalOptionDefinition.cs index fd79451..6fd93bc 100644 --- a/src/Repl.Core/Parsing/GlobalOptionDefinition.cs +++ b/src/Repl.Core/Parsing/GlobalOptionDefinition.cs @@ -5,5 +5,6 @@ internal sealed record GlobalOptionDefinition( string CanonicalToken, IReadOnlyList Aliases, string? DefaultValue, + string? Description, Type ValueType, Type? OwnerType); diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index b0b25c5..ac591d3 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -100,8 +100,9 @@ internal bool TryGetRouteConstraint(string name, out Func predicat /// 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) => - AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString()); + /// Optional description shown in help output. + public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default, string? description = null) => + AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString(), description); /// /// Registers a custom global option using a type or constraint name @@ -114,10 +115,11 @@ 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 constraintOrTypeName, string[]? aliases = null, string? defaultValue = null) => - AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue); + /// Optional description shown in help output. + public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases = null, string? defaultValue = null, string? description = null) => + AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue, description); - internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, Type? ownerType = null) + internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, string? description = null, Type? ownerType = null) { name = string.IsNullOrWhiteSpace(name) ? throw new ArgumentException("Global option name cannot be empty.", nameof(name)) @@ -141,6 +143,7 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases CanonicalToken: normalizedCanonical, Aliases: normalizedAliases, DefaultValue: defaultValue, + Description: description, ValueType: valueType, OwnerType: ownerType); } diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index af3c1f6..6a5f73f 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -35,8 +35,9 @@ public static class GlobalOptionsExtensions var name = optionAttr?.Name ?? ToKebabCase(property.Name); var aliases = optionAttr?.Aliases; var defaultValue = property.GetValue(prototype)?.ToString(); + var description = property.GetCustomAttribute()?.Description; - options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, typeof(T)); + options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T)); } }); diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index f77b163..b42048d 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -1,3 +1,5 @@ +using Repl.Parameters; + namespace Repl.IntegrationTests; [TestClass] @@ -45,5 +47,52 @@ public void When_RequestingRootHelp_Then_CustomGlobalOptionIsListedInGlobalOptio output.ExitCode.Should().Be(0); output.Text.Should().Contain("Global Options:"); output.Text.Should().Contain("--tenant, -t"); + output.Text.Should().Contain("Custom global option."); + } + + [TestMethod] + [Description("Regression guard: verifies typed global option descriptions and defaults are rendered in root help.")] + public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAndDefaultsAreListed() + { + var sut = ReplApp.Create() + .UseGlobalOptions(); + sut.Map("status", (DemoGlobals globals) => globals.Tenant); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("--tenant, -t"); + output.Text.Should().Contain("Tenant id used for all commands. [default: default]"); + output.Text.Should().Contain("--verbose, -v"); + output.Text.Should().Contain("Enable verbose diagnostics for all commands."); + } + + [TestMethod] + [Description("Regression guard: verifies explicit global option descriptions are rendered in root help.")] + public void When_RequestingRootHelpForExplicitGlobalOption_Then_DescriptionIsListed() + { + var sut = ReplApp.Create() + .Options(options => options.Parsing.AddGlobalOption( + "tenant", + aliases: ["-t"], + description: "Tenant id used for all commands.")); + sut.Map("ping", () => "ok"); + + var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); + + output.ExitCode.Should().Be(0); + output.Text.Should().Contain("--tenant, -t"); + output.Text.Should().Contain("Tenant id used for all commands."); + } + + private sealed class DemoGlobals + { + [System.ComponentModel.Description("Tenant id used for all commands.")] + [ReplOption(Aliases = ["-t"])] + public string? Tenant { get; set; } = "default"; + + [System.ComponentModel.Description("Enable verbose diagnostics for all commands.")] + [ReplOption(Aliases = ["-v"])] + public bool Verbose { get; set; } } } From e7d16a58cf35b8713c4c8a70b749054193ba6985 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Thu, 25 Jun 2026 09:04:09 -0400 Subject: [PATCH 2/6] Address global option help review feedback --- .../Help/HelpTextBuilder.Rendering.cs | 22 +------ src/Repl.Core/ParsingOptions.cs | 61 ++++++++++++++++++- src/Repl.Defaults/GlobalOptionsExtensions.cs | 2 +- .../Given_CustomGlobalOptions.cs | 6 +- 4 files changed, 64 insertions(+), 27 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index c325592..6f95fe6 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -316,27 +316,7 @@ private static Type UnwrapAsyncReturnType(Type returnType) private static bool IsDefaultForType(object value, Type type) { - if (type == typeof(bool)) - { - return value is false; - } - - if (type == typeof(int)) - { - return value is 0; - } - - if (type == typeof(long)) - { - return value is 0L; - } - - if (type == typeof(double)) - { - return value is 0.0d; - } - - return false; + return ParsingOptions.IsDefaultForType(value, type); } private static string ResolveOptionPlaceholder(Type parameterType) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index ac591d3..a5af447 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -100,9 +100,19 @@ internal bool TryGetRouteConstraint(string name, out Func predicat /// 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) => + AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T))); + + /// + /// Registers a custom global option consumed before command routing. + /// + /// Declared value type. + /// Canonical name without prefix (for example: "tenant"). /// Optional description shown in help output. - public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default, string? description = null) => - AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString(), description); + /// Optional aliases. Values without prefix are normalized to --alias. + /// Optional default value metadata. + public void AddGlobalOption(string name, string? description, string[]? aliases = null, T? defaultValue = default) => + AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description); /// /// Registers a custom global option using a type or constraint name @@ -115,8 +125,22 @@ 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 constraintOrTypeName, string[]? aliases = null, string? defaultValue = null) => + AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue); + + /// + /// 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 description shown in help output. - public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases = null, string? defaultValue = null, string? description = null) => + /// Optional aliases. Values without prefix are normalized to --alias. + /// Optional default value as string. + public void AddGlobalOption(string name, string constraintOrTypeName, string? description, string[]? aliases = null, string? defaultValue = null) => AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue, description); internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, string? description = null, Type? ownerType = null) @@ -164,6 +188,37 @@ private static string BuildDuplicateGlobalOptionMessage(string name, Type? exist return $"A global option named '{name}' is already registered by {existingSource} and cannot also be registered by {newSource}."; } + internal static string? FormatDefaultValue(object? value, Type type) => + value is not null && !IsDefaultForType(value, type) + ? value.ToString() + : null; + + internal static bool IsDefaultForType(object value, Type type) + { + var effectiveType = Nullable.GetUnderlyingType(type) ?? type; + if (effectiveType == typeof(bool)) + { + return value is false; + } + + if (effectiveType == typeof(int)) + { + return value is 0; + } + + if (effectiveType == typeof(long)) + { + return value is 0L; + } + + if (effectiveType == typeof(double)) + { + return value is 0.0d; + } + + return false; + } + private static Type ResolveConstraintOrTypeName( string constraintOrTypeName, Dictionary> customConstraints) diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 6a5f73f..125db8a 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -34,7 +34,7 @@ public static class GlobalOptionsExtensions var optionAttr = property.GetCustomAttribute(); var name = optionAttr?.Name ?? ToKebabCase(property.Name); var aliases = optionAttr?.Aliases; - var defaultValue = property.GetValue(prototype)?.ToString(); + var defaultValue = ParsingOptions.FormatDefaultValue(property.GetValue(prototype), property.PropertyType); var description = property.GetCustomAttribute()?.Description; options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T)); diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index b42048d..0a43035 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -65,6 +65,7 @@ public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAndDef output.Text.Should().Contain("Tenant id used for all commands. [default: default]"); output.Text.Should().Contain("--verbose, -v"); output.Text.Should().Contain("Enable verbose diagnostics for all commands."); + output.Text.Should().NotContain("Enable verbose diagnostics for all commands. [default: False]"); } [TestMethod] @@ -74,15 +75,16 @@ public void When_RequestingRootHelpForExplicitGlobalOption_Then_DescriptionIsLis var sut = ReplApp.Create() .Options(options => options.Parsing.AddGlobalOption( "tenant", + description: "Tenant id used for all commands.", aliases: ["-t"], - description: "Tenant id used for all commands.")); + defaultValue: "default")); sut.Map("ping", () => "ok"); var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); output.ExitCode.Should().Be(0); output.Text.Should().Contain("--tenant, -t"); - output.Text.Should().Contain("Tenant id used for all commands."); + output.Text.Should().Contain("Tenant id used for all commands. [default: default]"); } private sealed class DemoGlobals From f044b584502a5c01fd5509700cb71e36c06976ed Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 23:07:58 -0400 Subject: [PATCH 3/6] fix: keep default values out of global option help --- .../Help/HelpTextBuilder.Rendering.cs | 26 +++++++++++--- src/Repl.Core/ParsingOptions.cs | 34 ++----------------- src/Repl.Defaults/GlobalOptionsExtensions.cs | 2 +- .../Given_CustomGlobalOptions.cs | 14 ++++---- 4 files changed, 31 insertions(+), 45 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 6f95fe6..20771b3 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -316,7 +316,27 @@ private static Type UnwrapAsyncReturnType(Type returnType) private static bool IsDefaultForType(object value, Type type) { - return ParsingOptions.IsDefaultForType(value, type); + if (type == typeof(bool)) + { + return value is false; + } + + if (type == typeof(int)) + { + return value is 0; + } + + if (type == typeof(long)) + { + return value is 0L; + } + + if (type == typeof(double)) + { + return value is 0.0d; + } + + return false; } private static string ResolveOptionPlaceholder(Type parameterType) @@ -605,10 +625,6 @@ private static string[][] BuildGlobalOptionRows(ParsingOptions parsingOptions) var description = string.IsNullOrWhiteSpace(option.Description) ? "Custom global option." : option.Description; - if (!string.IsNullOrWhiteSpace(option.DefaultValue)) - { - description = $"{description} [default: {option.DefaultValue}]"; - } return new[] { diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index a5af447..1737b6a 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -101,7 +101,7 @@ internal bool TryGetRouteConstraint(string name, out Func predicat /// Optional aliases. Values without prefix are normalized to --alias. /// Optional default value metadata. public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default) => - AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T))); + AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString()); /// /// Registers a custom global option consumed before command routing. @@ -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 metadata. public void AddGlobalOption(string name, string? description, string[]? aliases = null, T? defaultValue = default) => - AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description); + AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString(), description); /// /// Registers a custom global option using a type or constraint name @@ -188,36 +188,6 @@ private static string BuildDuplicateGlobalOptionMessage(string name, Type? exist return $"A global option named '{name}' is already registered by {existingSource} and cannot also be registered by {newSource}."; } - internal static string? FormatDefaultValue(object? value, Type type) => - value is not null && !IsDefaultForType(value, type) - ? value.ToString() - : null; - - internal static bool IsDefaultForType(object value, Type type) - { - var effectiveType = Nullable.GetUnderlyingType(type) ?? type; - if (effectiveType == typeof(bool)) - { - return value is false; - } - - if (effectiveType == typeof(int)) - { - return value is 0; - } - - if (effectiveType == typeof(long)) - { - return value is 0L; - } - - if (effectiveType == typeof(double)) - { - return value is 0.0d; - } - - return false; - } private static Type ResolveConstraintOrTypeName( string constraintOrTypeName, diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 125db8a..6a5f73f 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -34,7 +34,7 @@ public static class GlobalOptionsExtensions var optionAttr = property.GetCustomAttribute(); var name = optionAttr?.Name ?? ToKebabCase(property.Name); var aliases = optionAttr?.Aliases; - var defaultValue = ParsingOptions.FormatDefaultValue(property.GetValue(prototype), property.PropertyType); + var defaultValue = property.GetValue(prototype)?.ToString(); var description = property.GetCustomAttribute()?.Description; options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T)); diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index 0a43035..aed9afc 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -51,8 +51,8 @@ public void When_RequestingRootHelp_Then_CustomGlobalOptionIsListedInGlobalOptio } [TestMethod] - [Description("Regression guard: verifies typed global option descriptions and defaults are rendered in root help.")] - public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAndDefaultsAreListed() + [Description("Regression guard: verifies typed global option descriptions are rendered in root help without adding default-value display.")] + public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAreListed() { var sut = ReplApp.Create() .UseGlobalOptions(); @@ -62,10 +62,10 @@ public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAndDef output.ExitCode.Should().Be(0); output.Text.Should().Contain("--tenant, -t"); - output.Text.Should().Contain("Tenant id used for all commands. [default: default]"); + output.Text.Should().Contain("Tenant id used for all commands."); output.Text.Should().Contain("--verbose, -v"); output.Text.Should().Contain("Enable verbose diagnostics for all commands."); - output.Text.Should().NotContain("Enable verbose diagnostics for all commands. [default: False]"); + output.Text.Should().NotContain("[default:"); } [TestMethod] @@ -76,15 +76,15 @@ public void When_RequestingRootHelpForExplicitGlobalOption_Then_DescriptionIsLis .Options(options => options.Parsing.AddGlobalOption( "tenant", description: "Tenant id used for all commands.", - aliases: ["-t"], - defaultValue: "default")); + aliases: ["-t"])); sut.Map("ping", () => "ok"); var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); output.ExitCode.Should().Be(0); output.Text.Should().Contain("--tenant, -t"); - output.Text.Should().Contain("Tenant id used for all commands. [default: default]"); + output.Text.Should().Contain("Tenant id used for all commands."); + output.Text.Should().NotContain("[default:"); } private sealed class DemoGlobals From 5bbfac5cb1b11ef6557f369a4cce05db4bf7e9a7 Mon Sep 17 00:00:00 2001 From: Carl de Billy's personal bot - Autocarl Date: Thu, 25 Jun 2026 23:20:24 -0400 Subject: [PATCH 4/6] fix: preserve caller defaults for value-typed globals --- src/Repl.Core/ParsingOptions.cs | 34 +++++++++++++++++-- src/Repl.Defaults/GlobalOptionsExtensions.cs | 2 +- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 24 +++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index 1737b6a..a5af447 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -101,7 +101,7 @@ internal bool TryGetRouteConstraint(string name, out Func predicat /// Optional aliases. Values without prefix are normalized to --alias. /// Optional default value metadata. public void AddGlobalOption(string name, string[]? aliases = null, T? defaultValue = default) => - AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString()); + AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T))); /// /// Registers a custom global option consumed before command routing. @@ -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 metadata. public void AddGlobalOption(string name, string? description, string[]? aliases = null, T? defaultValue = default) => - AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString(), description); + AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description); /// /// Registers a custom global option using a type or constraint name @@ -188,6 +188,36 @@ private static string BuildDuplicateGlobalOptionMessage(string name, Type? exist return $"A global option named '{name}' is already registered by {existingSource} and cannot also be registered by {newSource}."; } + internal static string? FormatDefaultValue(object? value, Type type) => + value is not null && !IsDefaultForType(value, type) + ? value.ToString() + : null; + + internal static bool IsDefaultForType(object value, Type type) + { + var effectiveType = Nullable.GetUnderlyingType(type) ?? type; + if (effectiveType == typeof(bool)) + { + return value is false; + } + + if (effectiveType == typeof(int)) + { + return value is 0; + } + + if (effectiveType == typeof(long)) + { + return value is 0L; + } + + if (effectiveType == typeof(double)) + { + return value is 0.0d; + } + + return false; + } private static Type ResolveConstraintOrTypeName( string constraintOrTypeName, diff --git a/src/Repl.Defaults/GlobalOptionsExtensions.cs b/src/Repl.Defaults/GlobalOptionsExtensions.cs index 6a5f73f..125db8a 100644 --- a/src/Repl.Defaults/GlobalOptionsExtensions.cs +++ b/src/Repl.Defaults/GlobalOptionsExtensions.cs @@ -34,7 +34,7 @@ public static class GlobalOptionsExtensions var optionAttr = property.GetCustomAttribute(); var name = optionAttr?.Name ?? ToKebabCase(property.Name); var aliases = optionAttr?.Aliases; - var defaultValue = property.GetValue(prototype)?.ToString(); + var defaultValue = ParsingOptions.FormatDefaultValue(property.GetValue(prototype), property.PropertyType); var description = property.GetCustomAttribute()?.Description; options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T)); diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 2129a3f..59b9866 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -67,6 +67,30 @@ public void When_OptionNotProvidedButRegisteredDefault_Then_GetValueReturnsRegis sut.GetValue("port").Should().Be(3000); } + [TestMethod] + [Description("GetValue returns caller default when a value-typed option has no explicit registration default.")] + public void When_ValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueReturnsCallerDefault() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port"); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("port", 8080).Should().Be(8080); + } + + [TestMethod] + [Description("GetValue returns caller default when a described value-typed option has no explicit registration default.")] + public void When_DescribedValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueReturnsCallerDefault() + { + var parsing = new ParsingOptions(); + parsing.AddGlobalOption("port", "Port used by the server."); + var sut = new GlobalOptionsSnapshot(parsing); + sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); + + sut.GetValue("port", 8080).Should().Be(8080); + } + [TestMethod] [Description("HasValue returns false before parsing.")] public void When_NeverUpdated_Then_HasValueReturnsFalse() From 366d7800797bc75ebb01c2e1e60c127ce1ae9e21 Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 26 Jun 2026 00:22:20 -0400 Subject: [PATCH 5/6] fix: disambiguate AddGlobalOption description overloads The description-bearing AddGlobalOption overloads placed `description` as the second parameter, colliding with the existing aliases-only overloads: a positional null (e.g. `AddGlobalOption("tenant", null)`) bound both `string[]? aliases` and `string? description`, producing CS0121. - Move `description` to a trailing required parameter on both the generic and type-name overloads, so existing positional-null calls keep binding the aliases-only overload. The original overloads are untouched (binary-safe). - Update the two call sites that used the description overloads. - Add a regression test asserting the positional-null call binds the aliases-only overload. --- src/Repl.Core/ParsingOptions.cs | 34 +++++++++++++------ .../Given_CustomGlobalOptions.cs | 5 +-- src/Repl.Tests/Given_GlobalOptionsAccessor.cs | 21 +++++++++++- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/Repl.Core/ParsingOptions.cs b/src/Repl.Core/ParsingOptions.cs index a5af447..00f4624 100644 --- a/src/Repl.Core/ParsingOptions.cs +++ b/src/Repl.Core/ParsingOptions.cs @@ -104,14 +104,21 @@ public void AddGlobalOption(string name, string[]? aliases = null, T? default AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T))); /// - /// Registers a custom global option consumed before command routing. + /// Registers a custom global option with an explicit help description. /// + /// + /// is the trailing required parameter so this overload never collides with + /// during overload resolution: a positional + /// null second argument (for example AddGlobalOption<string>("tenant", null)) binds + /// unambiguously to the aliases-only overload. The typed + /// path (UseGlobalOptions<T>()) remains the primary way to attach descriptions. + /// /// Declared value type. /// Canonical name without prefix (for example: "tenant"). - /// Optional description shown in help output. - /// Optional aliases. Values without prefix are normalized to --alias. - /// Optional default value metadata. - public void AddGlobalOption(string name, string? description, string[]? aliases = null, T? defaultValue = default) => + /// Aliases (pass null for none). Values without prefix are normalized to --alias. + /// Default value metadata (pass default for none). + /// Description shown in help output. + public void AddGlobalOption(string name, string[]? aliases, T? defaultValue, string description) => AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description); /// @@ -129,18 +136,23 @@ public void AddGlobalOption(string name, string constraintOrTypeName, string[]? AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue); /// - /// Registers a custom global option using a type or constraint name - /// (for example: "int", "guid", "bool", or a registered custom route constraint name). + /// Registers a custom global option (by type or constraint name) with an explicit help description. /// + /// + /// is the trailing required parameter so this overload never collides with + /// during overload resolution: a positional + /// null third argument (for example AddGlobalOption("port", "int", null)) binds unambiguously + /// to the aliases-only overload. + /// /// 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 description shown in help output. - /// Optional aliases. Values without prefix are normalized to --alias. - /// Optional default value as string. - public void AddGlobalOption(string name, string constraintOrTypeName, string? description, string[]? aliases = null, string? defaultValue = null) => + /// Aliases (pass null for none). Values without prefix are normalized to --alias. + /// Default value as string (pass null for none). + /// Description shown in help output. + public void AddGlobalOption(string name, string constraintOrTypeName, string[]? aliases, string? defaultValue, string description) => AddGlobalOptionCore(name, ResolveConstraintOrTypeName(constraintOrTypeName, _customRouteConstraints), aliases, defaultValue, description); internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases, string? defaultValue, string? description = null, Type? ownerType = null) diff --git a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs index aed9afc..77e39ae 100644 --- a/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs +++ b/src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs @@ -75,8 +75,9 @@ public void When_RequestingRootHelpForExplicitGlobalOption_Then_DescriptionIsLis var sut = ReplApp.Create() .Options(options => options.Parsing.AddGlobalOption( "tenant", - description: "Tenant id used for all commands.", - aliases: ["-t"])); + aliases: ["-t"], + defaultValue: null, + description: "Tenant id used for all commands.")); sut.Map("ping", () => "ok"); var output = ConsoleCaptureHelper.Capture(() => sut.Run(["--help", "--no-logo"])); diff --git a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs index 59b9866..34e066b 100644 --- a/src/Repl.Tests/Given_GlobalOptionsAccessor.cs +++ b/src/Repl.Tests/Given_GlobalOptionsAccessor.cs @@ -84,13 +84,32 @@ public void When_ValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueR public void When_DescribedValueTypeOptionNotProvidedAndNoRegisteredDefault_Then_GetValueReturnsCallerDefault() { var parsing = new ParsingOptions(); - parsing.AddGlobalOption("port", "Port used by the server."); + parsing.AddGlobalOption("port", aliases: null, defaultValue: default, description: "Port used by the server."); var sut = new GlobalOptionsSnapshot(parsing); sut.Update(new Dictionary>(StringComparer.OrdinalIgnoreCase)); sut.GetValue("port", 8080).Should().Be(8080); } + [TestMethod] + [Description("Regression (#34 review): a positional null second argument keeps binding the aliases-only overload. The description overloads take description as the trailing required parameter precisely so this call never becomes ambiguous (CS0121) with the description overload.")] + public void When_AddGlobalOptionCalledWithPositionalNull_Then_BindsAliasesOnlyOverload() + { + var parsing = new ParsingOptions(); + + // The positional null is the regression scenario itself: naming the argument would make + // resolution trivial and defeat the test. These calls must compile and bind unambiguously + // to the aliases-only overloads (description overloads take description as a trailing required arg). +#pragma warning disable MA0003 // Name the parameter — intentionally positional here; see comment above. + parsing.AddGlobalOption("tenant", null); + parsing.AddGlobalOption("port", "int", null); +#pragma warning restore MA0003 + + parsing.GlobalOptions["tenant"].Description.Should().BeNull(); + parsing.GlobalOptions["tenant"].Aliases.Should().BeEmpty(); + parsing.GlobalOptions["port"].Description.Should().BeNull(); + } + [TestMethod] [Description("HasValue returns false before parsing.")] public void When_NeverUpdated_Then_HasValueReturnsFalse() From 9a40cc974affc2cf276f3ece7ee8fe0bd96838fe Mon Sep 17 00:00:00 2001 From: Carl de Billy Date: Fri, 26 Jun 2026 00:22:32 -0400 Subject: [PATCH 6/6] refactor: share a single Nullable-aware IsDefaultForType ParsingOptions and HelpTextBuilder each carried a copy of IsDefaultForType; the help copy did not unwrap Nullable, so the two could diverge for nullable value-typed defaults. Route the help renderer through ParsingOptions.IsDefaultForType and drop the duplicate. --- .../Help/HelpTextBuilder.Rendering.cs | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs index 20771b3..caa47fd 100644 --- a/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs +++ b/src/Repl.Core/Help/HelpTextBuilder.Rendering.cs @@ -208,7 +208,7 @@ or OptionSchemaTokenKind.ValueAlias parameterType = groupInfo.Property.PropertyType; description = groupInfo.Property.GetCustomAttribute()?.Description ?? string.Empty; var propDefault = groupInfo.Property.GetValue(groupInfo.DefaultInstance); - defaultValue = propDefault is not null && !IsDefaultForType(propDefault, parameterType) + defaultValue = propDefault is not null && !ParsingOptions.IsDefaultForType(propDefault, parameterType) ? $" [default: {propDefault}]" : string.Empty; } @@ -314,31 +314,6 @@ private static Type UnwrapAsyncReturnType(Type returnType) : returnType; } - private static bool IsDefaultForType(object value, Type type) - { - if (type == typeof(bool)) - { - return value is false; - } - - if (type == typeof(int)) - { - return value is 0; - } - - if (type == typeof(long)) - { - return value is 0L; - } - - if (type == typeof(double)) - { - return value is 0.0d; - } - - return false; - } - private static string ResolveOptionPlaceholder(Type parameterType) { var effectiveType = Nullable.GetUnderlyingType(parameterType) ?? parameterType;