Skip to content

Commit a8f3d88

Browse files
committed
fix: treat non-null nullable defaults as real defaults
IsDefaultForType unwrapped Nullable<T> before comparing against the CLR default, so a nullable options-group property initialized to 0 or false (e.g. `int? Limit { get; set; } = 0`) had its default hidden from command help, and AddGlobalOption<T?> discarded explicit CLR-default metadata that GlobalOptionsSnapshot.GetValue would otherwise apply. - Return false from IsDefaultForType for Nullable<T>: its default is null, so any non-null value is a deliberate default. - Add regression tests for group-property help rendering and for runtime resolution of an explicit nullable global default. Claude-Session: https://claude.ai/code/session_01AopmWBr1VknLrUqerBEWsB
1 parent 9a40cc9 commit a8f3d88

3 files changed

Lines changed: 57 additions & 5 deletions

File tree

src/Repl.Core/ParsingOptions.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,23 +207,31 @@ private static string BuildDuplicateGlobalOptionMessage(string name, Type? exist
207207

208208
internal static bool IsDefaultForType(object value, Type type)
209209
{
210-
var effectiveType = Nullable.GetUnderlyingType(type) ?? type;
211-
if (effectiveType == typeof(bool))
210+
// The default of a Nullable<T> is null, never a value: a nullable property or
211+
// parameter initialized to 0 or false is a deliberate default that must be kept
212+
// (rendered in help, applied at resolution), unlike the implicit default of the
213+
// underlying non-nullable type.
214+
if (Nullable.GetUnderlyingType(type) is not null)
215+
{
216+
return false;
217+
}
218+
219+
if (type == typeof(bool))
212220
{
213221
return value is false;
214222
}
215223

216-
if (effectiveType == typeof(int))
224+
if (type == typeof(int))
217225
{
218226
return value is 0;
219227
}
220228

221-
if (effectiveType == typeof(long))
229+
if (type == typeof(long))
222230
{
223231
return value is 0L;
224232
}
225233

226-
if (effectiveType == typeof(double))
234+
if (type == typeof(double))
227235
{
228236
return value is 0.0d;
229237
}

src/Repl.IntegrationTests/Given_GlobalOptionsAccessor.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ public void When_GlobalOptionNotProvided_Then_DefaultIsReturned()
8181
output.Text.Should().Contain("3000");
8282
}
8383

84+
[TestMethod]
85+
[Description("Regression guard: an explicit registration default equal to the CLR default of the underlying type (0), declared through a nullable type parameter, is preserved as metadata and applied when the option is omitted instead of the call-site fallback.")]
86+
public void When_NullableGlobalOptionDeclaresUnderlyingClrDefault_Then_RegisteredDefaultWins()
87+
{
88+
var sut = ReplApp.Create();
89+
sut.Options(o => o.Parsing.AddGlobalOption<int?>("port", defaultValue: 0));
90+
sut.Map("show", (IGlobalOptionsAccessor globals) => $"port:{globals.GetValue<int>("port", 8080)}");
91+
92+
var output = ConsoleCaptureHelper.Capture(
93+
() => sut.Run(["show", "--no-logo"]));
94+
95+
output.ExitCode.Should().Be(0);
96+
output.Text.Should().Contain("port:0");
97+
}
98+
8499
[TestMethod]
85100
[Description("UseGlobalOptions<T> registers typed class accessible via DI.")]
86101
public void When_UsingTypedGlobalOptions_Then_ClassIsPopulatedFromParsedValues()

src/Repl.IntegrationTests/Given_OptionsGroupBinding.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ public class PositionalSearchOptions
3232
public string Query { get; set; } = "";
3333
}
3434

35+
[ReplOptionsGroup]
36+
public class NullableDefaultsOptions
37+
{
38+
[ReplOption]
39+
public int? Limit { get; set; } = 0;
40+
41+
[ReplOption]
42+
public bool? Force { get; set; } = false;
43+
44+
[ReplOption]
45+
public int Offset { get; set; }
46+
}
47+
3548
[TestMethod]
3649
[Description("Regression guard: verifies named options bind to options group properties.")]
3750
public void When_UsingNamedOptionOnGroup_Then_PropertyBindsSuccessfully()
@@ -125,6 +138,22 @@ public void When_UsingTwoGroups_Then_BothBindIndependently()
125138
output.Text.Should().Contain("json:20:5");
126139
}
127140

141+
[TestMethod]
142+
[Description("Regression guard: a nullable group property initialized to the CLR default of its underlying type (int? = 0, bool? = false) is a deliberate default the binder preserves, so command help must advertise it — while implicit defaults of non-nullable properties stay hidden.")]
143+
public void When_NullableGroupPropertyInitializedToUnderlyingClrDefault_Then_HelpShowsDefault()
144+
{
145+
var sut = ReplApp.Create();
146+
sut.Map("list", (NullableDefaultsOptions options) => "ok");
147+
148+
var output = ConsoleCaptureHelper.Capture(() => sut.Run(["list", "--help", "--no-logo"]));
149+
150+
output.ExitCode.Should().Be(0);
151+
var lines = output.Text.Split('\n');
152+
lines.Single(line => line.Contains("--limit")).Should().Contain("[default: 0]");
153+
lines.Single(line => line.Contains("--force")).Should().Contain("[default: False]");
154+
lines.Single(line => line.Contains("--offset")).Should().NotContain("[default:");
155+
}
156+
128157
[TestMethod]
129158
[Description("Regression guard: verifies token collision between group and regular parameter fails at registration.")]
130159
public void When_GroupPropertyCollidesWithParam_Then_MapFails()

0 commit comments

Comments
 (0)