diff --git a/CommandDotNet.Tests/FeatureTests/Arguments/ArgumentGroupTests.cs b/CommandDotNet.Tests/FeatureTests/Arguments/ArgumentGroupTests.cs new file mode 100644 index 00000000..7ef411f8 --- /dev/null +++ b/CommandDotNet.Tests/FeatureTests/Arguments/ArgumentGroupTests.cs @@ -0,0 +1,363 @@ +using CommandDotNet.TestTools.Scenarios; +using Xunit; +using Xunit.Abstractions; + +namespace CommandDotNet.Tests.FeatureTests.Arguments; + +public class ArgumentGroupTests +{ + public ArgumentGroupTests(ITestOutputHelper output) + { + Ambient.Output = output; + } + + [Fact] + public void BasicHelp_Groups_Options_With_Group_Property() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "GroupedOptions -h"}, + Then = + { + Output = @"Usage: testhost.dll GroupedOptions [options] + +Options: + --ungrouped An ungrouped option + +Database: + + --connection Connection string + --timeout Timeout value + +Logging: + + --logLevel Log level + --logFile Log file path" + } + }); + } + + [Fact] + public void DetailedHelp_Groups_Options_With_Group_Property() + { + new AppRunner(TestAppSettings.DetailedHelp) + .Verify( + new Scenario + { + When = {Args = "GroupedOptions -h"}, + Then = + { + Output = @"Usage: testhost.dll GroupedOptions [options] + +Options: + + --ungrouped + An ungrouped option + +Database: + + --connection + Connection string + + --timeout + Timeout value + +Logging: + + --logLevel + Log level + + --logFile + Log file path" + } + }); + } + + [Fact] + public void Groups_Are_Sorted_Alphabetically() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "AlphabeticalGroups -h"}, + Then = + { + Output = @"Usage: testhost.dll AlphabeticalGroups [options] + +Options: + --ungrouped Ungrouped option + +Alpha: + + --alphaOpt Alpha option + +Beta: + + --betaOpt Beta option + +Zeta: + + --zetaOpt Zeta option" + } + }); + } + + [Fact] + public void ArgumentModel_With_ArgumentGroupAttribute_Groups_All_Properties() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "ModelWithGroup -h"}, + Then = + { + Output = @"Usage: testhost.dll ModelWithGroup [options] + +Options: + --ungrouped Ungrouped option + +Server Settings: + + --Host Server host + --Port Server port" + } + }); + } + + [Fact] + public void PropertyLevel_Group_Overrides_Model_Group() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "ModelWithPropertyOverride -h"}, + Then = + { + Output = @"Usage: testhost.dll ModelWithPropertyOverride [options] + +Options: + --ungrouped Ungrouped option + +Database: + + --Connection Connection string + +Server Settings: + + --Host Server host" + } + }); + } + + [Fact] + public void Nested_Models_Inherit_Parent_Group() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "NestedModelsInheritGroup -h"}, + Then = + { + Output = @"Usage: testhost.dll NestedModelsInheritGroup [options] + +Server Settings: + + --Host Server host + --Port Server port + --Timeout Connection timeout + --Retries Connection retries" + } + }); + } + + [Fact] + public void Nested_Model_Can_Override_Parent_Group() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "NestedModelOverridesGroup -h"}, + Then = + { + Output = @"Usage: testhost.dll NestedModelOverridesGroup [options] + +Advanced: + + --Timeout Advanced timeout + --Retries Advanced retries + +Server Settings: + + --Host Server host + --Port Server port" + } + }); + } + + [Fact] + public void Operands_Are_Not_Grouped() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "MixedOptionsAndOperands -h"}, + Then = + { + Output = @"Usage: testhost.dll MixedOptionsAndOperands [options] + +Arguments: + file Input file + +Options: + --ungrouped Ungrouped option + +Processing: + + --threads Thread count + --memory Memory limit" + } + }); + } + + [Fact] + public void Multiple_Options_In_Same_Group() + { + new AppRunner(TestAppSettings.BasicHelp) + .Verify( + new Scenario + { + When = {Args = "MultipleOptionsPerGroup -h"}, + Then = + { + Output = @"Usage: testhost.dll MultipleOptionsPerGroup [options] + +Security: + + --apiKey API key + --apiSecret API secret + --apiEndpoint API endpoint" + } + }); + } + + private class App + { + public void GroupedOptions( + [Option(Description = "An ungrouped option")] string ungrouped, + [Option(Group = "Database", Description = "Connection string")] string connection, + [Option(Group = "Database", Description = "Timeout value")] int timeout, + [Option(Group = "Logging", Description = "Log level")] string logLevel, + [Option(Group = "Logging", Description = "Log file path")] string logFile) + { } + + public void AlphabeticalGroups( + [Option(Description = "Ungrouped option")] string ungrouped, + [Option(Group = "Zeta", Description = "Zeta option")] string zetaOpt, + [Option(Group = "Alpha", Description = "Alpha option")] string alphaOpt, + [Option(Group = "Beta", Description = "Beta option")] string betaOpt) + { } + + public void ModelWithGroup( + [Option(Description = "Ungrouped option")] string ungrouped, + ServerSettingsModel settings) + { } + + public void ModelWithPropertyOverride( + [Option(Description = "Ungrouped option")] string ungrouped, + ServerSettingsWithOverrideModel settings) + { } + + public void NestedModelsInheritGroup(ServerSettingsWithNestedModel settings) + { } + + public void NestedModelOverridesGroup(ServerSettingsWithNestedOverrideModel settings) + { } + + public void MixedOptionsAndOperands( + [Option(Description = "Ungrouped option")] string ungrouped, + [Option(Group = "Processing", Description = "Thread count")] int threads, + [Option(Group = "Processing", Description = "Memory limit")] int memory, + [Operand(Description = "Input file")] string file) + { } + + public void MultipleOptionsPerGroup( + [Option(Group = "Security", Description = "API key")] string apiKey, + [Option(Group = "Security", Description = "API secret")] string apiSecret, + [Option(Group = "Security", Description = "API endpoint")] string apiEndpoint) + { } + } + + [ArgumentGroup("Server Settings")] + private class ServerSettingsModel : IArgumentModel + { + [Option(Description = "Server host")] + public string? Host { get; set; } + + [Option(Description = "Server port")] + public int Port { get; set; } + } + + [ArgumentGroup("Server Settings")] + private class ServerSettingsWithOverrideModel : IArgumentModel + { + [Option(Description = "Server host")] + public string? Host { get; set; } + + [Option(Group = "Database", Description = "Connection string")] + public string? Connection { get; set; } + } + + [ArgumentGroup("Server Settings")] + private class ServerSettingsWithNestedModel : IArgumentModel + { + [Option(Description = "Server host")] + public string? Host { get; set; } + + [Option(Description = "Server port")] + public int Port { get; set; } + + public ConnectionSettingsModel? Connection { get; set; } + } + + // Nested model without ArgumentGroup - inherits from parent + private class ConnectionSettingsModel : IArgumentModel + { + [Option(Description = "Connection timeout")] + public int Timeout { get; set; } + + [Option(Description = "Connection retries")] + public int Retries { get; set; } + } + + [ArgumentGroup("Server Settings")] + private class ServerSettingsWithNestedOverrideModel : IArgumentModel + { + [Option(Description = "Server host")] + public string? Host { get; set; } + + [Option(Description = "Server port")] + public int Port { get; set; } + + public AdvancedSettingsModel? Advanced { get; set; } + } + + // Nested model with its own ArgumentGroup - overrides parent + [ArgumentGroup("Advanced")] + private class AdvancedSettingsModel : IArgumentModel + { + [Option(Description = "Advanced timeout")] + public int Timeout { get; set; } + + [Option(Description = "Advanced retries")] + public int Retries { get; set; } + } +} + diff --git a/CommandDotNet/AppRunner.cs b/CommandDotNet/AppRunner.cs index 1f92e528..3a219463 100644 --- a/CommandDotNet/AppRunner.cs +++ b/CommandDotNet/AppRunner.cs @@ -7,7 +7,6 @@ using CommandDotNet.Execution; using CommandDotNet.Extensions; using CommandDotNet.Help; -using CommandDotNet.Logging; using CommandDotNet.Parsing; using CommandDotNet.Rendering; using CommandDotNet.Tokens; @@ -48,14 +47,10 @@ public class AppRunner : IIndentableToString public AppSettings AppSettings { get; } public Type RootCommandType { get; } - static AppRunner() => LogProvider.IsDisabled = true; - public AppRunner(Type rootCommandType, AppSettings? settings = null, Resources? resourcesOverride = null) { - LogProvider.IsDisabled = true; - RootCommandType = rootCommandType ?? throw new ArgumentNullException(nameof(rootCommandType)); AppSettings = settings ?? new AppSettings(); diff --git a/CommandDotNet/ArgumentGroupAttribute.cs b/CommandDotNet/ArgumentGroupAttribute.cs new file mode 100644 index 00000000..d0365d33 --- /dev/null +++ b/CommandDotNet/ArgumentGroupAttribute.cs @@ -0,0 +1,30 @@ +using System; +using JetBrains.Annotations; + +namespace CommandDotNet; + +/// +/// Specifies a default group name for all options defined in an . +/// This group will be inherited by all option properties in the model and any nested models, +/// unless overridden by a property-level or a nested model's +/// . +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class)] +public class ArgumentGroupAttribute : Attribute +{ + /// + /// The default group name for all options in this argument model. + /// + public string GroupName { get; } + + /// + /// Specifies a default group name for all options defined in this . + /// + /// The group name to apply to all options in this model + public ArgumentGroupAttribute(string groupName) + { + GroupName = groupName ?? throw new ArgumentNullException(nameof(groupName)); + } +} + diff --git a/CommandDotNet/ClassModeling/Definitions/DefinitionMappingExtensions.cs b/CommandDotNet/ClassModeling/Definitions/DefinitionMappingExtensions.cs index b2ce2234..902d5e9e 100644 --- a/CommandDotNet/ClassModeling/Definitions/DefinitionMappingExtensions.cs +++ b/CommandDotNet/ClassModeling/Definitions/DefinitionMappingExtensions.cs @@ -123,7 +123,8 @@ private static IArgument BuildArgument(IArgumentDef argumentDef, TypeInfo typeIn valueProxy: argumentDef.ValueProxy) { Split = argumentDef.Split, - Default = argumentDefault + Default = argumentDefault, + Group = argumentDef.Group }; SetDescription(option, argumentDef, optionAttr); @@ -292,4 +293,18 @@ private static void SetDescription( : throw new InvalidConfigurationException( $"BooleanMode is set to `{optionAttr.BooleanMode}` for a non boolean type. {argumentDef}"); } + + internal static string? GetGroup(this IArgumentDef argumentDef, string? inheritedGroup = null) + { + // Only options can have groups + if (argumentDef.CommandNodeType != CommandNodeType.Option) + { + return null; + } + + OptionAttribute? optionAttr = argumentDef.GetCustomAttribute(); + + // Property-level group takes precedence over inherited group + return optionAttr?.Group ?? inheritedGroup; + } } \ No newline at end of file diff --git a/CommandDotNet/ClassModeling/Definitions/IArgumentDef.cs b/CommandDotNet/ClassModeling/Definitions/IArgumentDef.cs index 5a618bfc..b4461782 100644 --- a/CommandDotNet/ClassModeling/Definitions/IArgumentDef.cs +++ b/CommandDotNet/ClassModeling/Definitions/IArgumentDef.cs @@ -14,4 +14,5 @@ internal interface IArgumentDef: ISourceDef BooleanMode? BooleanMode { get; } IArgumentArity Arity { get; } char? Split { get; set; } + string? Group { get; set; } } \ No newline at end of file diff --git a/CommandDotNet/ClassModeling/Definitions/MethodDef.cs b/CommandDotNet/ClassModeling/Definitions/MethodDef.cs index fec8660e..f9b44d65 100644 --- a/CommandDotNet/ClassModeling/Definitions/MethodDef.cs +++ b/CommandDotNet/ClassModeling/Definitions/MethodDef.cs @@ -150,7 +150,7 @@ private IEnumerable GetArgsFromParameter(ParameterInfo parameterIn .ToEnumerable(); } - private IEnumerable GetArgsFromProperty(PropertyData propertyData, object modelInstance, ArgumentMode argumentMode) + private IEnumerable GetArgsFromProperty(PropertyData propertyData, object modelInstance, ArgumentMode argumentMode, string? inheritedGroup = null) { var propertyInfo = propertyData.PropertyInfo; return propertyData.IsArgModel @@ -158,17 +158,18 @@ private IEnumerable GetArgsFromProperty(PropertyData propertyData, propertyInfo.PropertyType, argumentMode, propertyInfo.GetValue(modelInstance), - value => propertyInfo.SetValue(modelInstance, value), propertyData) + value => propertyInfo.SetValue(modelInstance, value), propertyData, inheritedGroup) : new PropertyArgumentDef( propertyInfo, GetArgumentType(propertyInfo, argumentMode), _appConfig, - modelInstance) + modelInstance, + inheritedGroup) .ToEnumerable(); } private IEnumerable GetArgumentsFromModel(Type modelType, ArgumentMode argumentMode, - object? existingDefault, Action instanceCreated, PropertyData? parentProperty = null) + object? existingDefault, Action instanceCreated, PropertyData? parentProperty = null, string? inheritedGroup = null) { var instance = existingDefault ?? _appConfig.ResolverService.ResolveArgumentModel(modelType); @@ -181,6 +182,10 @@ private IEnumerable GetArgumentsFromModel(Type modelType, Argument // which would appear as already created. ArgumentModels.Add((IArgumentModel)instance); + // Check if this model defines a group - it overrides inherited group + var groupAttr = modelType.GetCustomAttribute(); + var groupForProperties = groupAttr?.GroupName ?? inheritedGroup; + return modelType .GetDeclaredProperties() .Select((p, i) => new PropertyData( @@ -189,7 +194,7 @@ private IEnumerable GetArgumentsFromModel(Type modelType, Argument GetArgumentType(p, argumentMode))) .OrderBy(pd => pd.LineNumber.GetValueOrDefault(int.MaxValue)) .ThenBy(pd => pd.PropertyIndex) //use reflected order for options since order can be inconsistent - .SelectMany(pd => GetArgsFromProperty(pd, instance, argumentMode)); + .SelectMany(pd => GetArgsFromProperty(pd, instance, argumentMode, groupForProperties)); } private static CommandNodeType GetArgumentType(ICustomAttributeProvider info, ArgumentMode argumentMode) diff --git a/CommandDotNet/ClassModeling/Definitions/ParameterArgumentDef.cs b/CommandDotNet/ClassModeling/Definitions/ParameterArgumentDef.cs index 417d28a9..71ed338c 100644 --- a/CommandDotNet/ClassModeling/Definitions/ParameterArgumentDef.cs +++ b/CommandDotNet/ClassModeling/Definitions/ParameterArgumentDef.cs @@ -30,6 +30,7 @@ internal class ParameterArgumentDef : IArgumentDef public BooleanMode? BooleanMode { get; } public IArgumentArity Arity { get; } public char? Split { get; set; } + public string? Group { get; set; } public ParameterArgumentDef( ParameterInfo parameterInfo, @@ -47,6 +48,7 @@ public ParameterArgumentDef( BooleanMode = this.GetBooleanMode(appConfig.AppSettings.Arguments.BooleanMode); Split = this.GetSplitChar(); + Group = this.GetGroup(); ValueProxy = new ValueProxy( () => parameterValues[parameterInfo.Position], diff --git a/CommandDotNet/ClassModeling/Definitions/PropertyArgumentDef.cs b/CommandDotNet/ClassModeling/Definitions/PropertyArgumentDef.cs index 9766c810..6c048b95 100644 --- a/CommandDotNet/ClassModeling/Definitions/PropertyArgumentDef.cs +++ b/CommandDotNet/ClassModeling/Definitions/PropertyArgumentDef.cs @@ -31,12 +31,14 @@ internal class PropertyArgumentDef : IArgumentDef public BooleanMode? BooleanMode { get; } public IArgumentArity Arity { get; } public char? Split { get; set; } + public string? Group { get; set; } public PropertyArgumentDef( PropertyInfo propertyInfo, CommandNodeType commandNodeType, AppConfig appConfig, - object modelInstance) + object modelInstance, + string? inheritedGroup = null) { if (modelInstance == null) { @@ -51,6 +53,7 @@ public PropertyArgumentDef( BooleanMode = this.GetBooleanMode(appConfig.AppSettings.Arguments.BooleanMode); Split = this.GetSplitChar(); + Group = this.GetGroup(inheritedGroup); ValueProxy = new ValueProxy( () => _propertyInfo.GetValue(modelInstance), diff --git a/CommandDotNet/Help/BasicHelpTextProvider.cs b/CommandDotNet/Help/BasicHelpTextProvider.cs index cf6f47a2..d2b18655 100644 --- a/CommandDotNet/Help/BasicHelpTextProvider.cs +++ b/CommandDotNet/Help/BasicHelpTextProvider.cs @@ -14,6 +14,14 @@ protected override string FormatSectionHeader(string header) protected override string SectionArguments(Command command, ICollection arguments) { + // Check if we're dealing with options (which support grouping) + var options = arguments.OfType