Skip to content
Merged
12 changes: 8 additions & 4 deletions src/Repl.Core/Help/HelpTextBuilder.Rendering.cs
Comment thread
carldebilly marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -619,13 +619,17 @@ 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;

return new[]
{
$"{option.CanonicalToken}{aliases}",
"Custom global option.",
description,
};
});
return [.. BuiltInGlobalOptionRows.Concat(customRows)];
Expand Down
1 change: 1 addition & 0 deletions src/Repl.Core/Parsing/GlobalOptionDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ internal sealed record GlobalOptionDefinition(
string CanonicalToken,
IReadOnlyList<string> Aliases,
string? DefaultValue,
string? Description,
Type ValueType,
Type? OwnerType);
62 changes: 60 additions & 2 deletions src/Repl.Core/ParsingOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,18 @@ internal bool TryGetRouteConstraint(string name, out Func<string, bool> predicat
/// <param name="aliases">Optional aliases. Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Optional default value metadata.</param>
public void AddGlobalOption<T>(string name, string[]? aliases = null, T? defaultValue = default) =>
AddGlobalOptionCore(name, typeof(T), aliases, defaultValue?.ToString());
AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)));

/// <summary>
/// Registers a custom global option consumed before command routing.
/// </summary>
/// <typeparam name="T">Declared value type.</typeparam>
/// <param name="name">Canonical name without prefix (for example: "tenant").</param>
/// <param name="description">Optional description shown in help output.</param>
/// <param name="aliases">Optional aliases. Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Optional default value metadata.</param>
public void AddGlobalOption<T>(string name, string? description, string[]? aliases = null, T? defaultValue = default) =>
Comment thread
carldebilly marked this conversation as resolved.
Outdated
AddGlobalOptionCore(name, typeof(T), aliases, FormatDefaultValue(defaultValue, typeof(T)), description);

/// <summary>
/// Registers a custom global option using a type or constraint name
Expand All @@ -117,7 +128,22 @@ public void AddGlobalOption<T>(string name, string[]? aliases = null, T? default
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, Type? ownerType = null)
/// <summary>
/// Registers a custom global option using a type or constraint name
/// (for example: "int", "guid", "bool", or a registered custom route constraint name).
/// </summary>
/// <param name="name">Canonical name without prefix (for example: "tenant").</param>
/// <param name="constraintOrTypeName">
/// Built-in type name ("string", "int", "long", "bool", "guid", "uri", "date", "datetime", "timespan")
/// or a registered custom route constraint name. Custom constraints resolve to <c>string</c>.
/// </param>
/// <param name="description">Optional description shown in help output.</param>
/// <param name="aliases">Optional aliases. Values without prefix are normalized to <c>--alias</c>.</param>
/// <param name="defaultValue">Optional default value as string.</param>
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)
{
name = string.IsNullOrWhiteSpace(name)
? throw new ArgumentException("Global option name cannot be empty.", nameof(name))
Expand All @@ -141,6 +167,7 @@ internal void AddGlobalOptionCore(string name, Type valueType, string[]? aliases
CanonicalToken: normalizedCanonical,
Aliases: normalizedAliases,
DefaultValue: defaultValue,
Description: description,
ValueType: valueType,
OwnerType: ownerType);
}
Expand All @@ -161,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()
Comment thread
carldebilly marked this conversation as resolved.
: null;
Comment thread
carldebilly marked this conversation as resolved.

internal static bool IsDefaultForType(object value, Type type)
{
var effectiveType = Nullable.GetUnderlyingType(type) ?? type;
Comment thread
carldebilly marked this conversation as resolved.
Outdated
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<string, Func<string, bool>> customConstraints)
Expand Down
5 changes: 3 additions & 2 deletions src/Repl.Defaults/GlobalOptionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ public static class GlobalOptionsExtensions
var optionAttr = property.GetCustomAttribute<ReplOptionAttribute>();
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);
Comment thread
carldebilly marked this conversation as resolved.
Outdated
var description = property.GetCustomAttribute<DescriptionAttribute>()?.Description;

options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, typeof(T));
options.Parsing.AddGlobalOptionCore(name, property.PropertyType, aliases, defaultValue, description, typeof(T));
}
});

Expand Down
51 changes: 51 additions & 0 deletions src/Repl.IntegrationTests/Given_CustomGlobalOptions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Repl.Parameters;

namespace Repl.IntegrationTests;

[TestClass]
Expand Down Expand Up @@ -45,5 +47,54 @@ 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 are rendered in root help without adding default-value display.")]
public void When_RequestingRootHelpForTypedGlobalOptions_Then_DescriptionsAreListed()
{
var sut = ReplApp.Create()
.UseGlobalOptions<DemoGlobals>();
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.");
output.Text.Should().Contain("--verbose, -v");
output.Text.Should().Contain("Enable verbose diagnostics for all commands.");
output.Text.Should().NotContain("[default:");
}

[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<string>(
"tenant",
description: "Tenant id used for all commands.",
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.");
output.Text.Should().NotContain("[default:");
}

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; }
}
}
24 changes: 24 additions & 0 deletions src/Repl.Tests/Given_GlobalOptionsAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ public void When_OptionNotProvidedButRegisteredDefault_Then_GetValueReturnsRegis
sut.GetValue<int>("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<int>("port");
var sut = new GlobalOptionsSnapshot(parsing);
sut.Update(new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase));

sut.GetValue<int>("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<int>("port", "Port used by the server.");
var sut = new GlobalOptionsSnapshot(parsing);
sut.Update(new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase));

sut.GetValue<int>("port", 8080).Should().Be(8080);
}

[TestMethod]
[Description("HasValue returns false before parsing.")]
public void When_NeverUpdated_Then_HasValueReturnsFalse()
Expand Down
Loading