From 1ae8d9e62fc48b186fd566225467141aec408100 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:19:10 +0100 Subject: [PATCH 1/2] Refactor CLI command handling and options validation: - Introduced `CliCommandAbortException` for improved error management. - Added `AssemblyScanResult` for grouping commands and attributes. - Updated help command behavior to support `CommandOrArgument` option. - Simplified command group handling with "demo" namespace constant. - Enhanced `CliCommandStore` to prevent duplicate group nodes. - Modified sample commands for consistency with new group structure. - Added unit test to validate non-duplicated group behavior. --- samples/CliHostSampleApp/DemoCliCommand.cs | 5 +-- .../DemoCliCommandWithOptions.cs | 4 +- samples/CliHostSampleApp/DemoCommandGroup.cs | 10 +++++ samples/CliHostSampleApp/Program.cs | 2 +- .../Commands/AssemblyCommandScanner.cs | 7 --- .../Commands/AssemblyScanResult.cs | 10 +++++ .../Commands/Store/CliCommandStore.cs | 2 +- .../DefaultCliHost.cs | 9 ++++ .../Exceptions/CliCommandAbortException.cs | 9 ++++ .../Hosting/Commands/CliCommandStoreTests.cs | 45 +++++++++++++++++++ 10 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 samples/CliHostSampleApp/DemoCommandGroup.cs create mode 100644 source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyScanResult.cs create mode 100644 source/Cli/CreativeCoders.Cli.Hosting/Exceptions/CliCommandAbortException.cs diff --git a/samples/CliHostSampleApp/DemoCliCommand.cs b/samples/CliHostSampleApp/DemoCliCommand.cs index 07552ba8..2c5b8537 100644 --- a/samples/CliHostSampleApp/DemoCliCommand.cs +++ b/samples/CliHostSampleApp/DemoCliCommand.cs @@ -1,13 +1,10 @@ using CreativeCoders.Cli.Core; using JetBrains.Annotations; -[assembly: CliCommandGroup(["demo"], "Demo commands root group")] -[assembly: CliCommandGroup(["demo", "do"], "Demo do commands")] - namespace CliHostSampleApp; [UsedImplicitly] -[CliCommand(["demo", "do", "list"] +[CliCommand([DemoCommandGroup.Name, "do"] , AlternativeCommands = ["demo1"])] public class DemoCliCommand : ICliCommand { diff --git a/samples/CliHostSampleApp/DemoCliCommandWithOptions.cs b/samples/CliHostSampleApp/DemoCliCommandWithOptions.cs index d4b8f2ee..1850cc49 100644 --- a/samples/CliHostSampleApp/DemoCliCommandWithOptions.cs +++ b/samples/CliHostSampleApp/DemoCliCommandWithOptions.cs @@ -4,7 +4,7 @@ namespace CliHostSampleApp; [UsedImplicitly] -[CliCommand(["demo", "do", "something"], Name = "Demo2 command", +[CliCommand(["demo", "list"], Name = "Demo2 command", Description = "Simple Demo command with options, that prints some text from options.")] public class DemoCliCommandWithOptions : ICliCommand { @@ -15,4 +15,4 @@ public Task ExecuteAsync(DemoOptions options) return Task.FromResult(new CommandResult()); } -} \ No newline at end of file +} diff --git a/samples/CliHostSampleApp/DemoCommandGroup.cs b/samples/CliHostSampleApp/DemoCommandGroup.cs new file mode 100644 index 00000000..27ab67b5 --- /dev/null +++ b/samples/CliHostSampleApp/DemoCommandGroup.cs @@ -0,0 +1,10 @@ +using CreativeCoders.Cli.Core; + +[assembly: CliCommandGroup(["demo"], "Demo commands root group")] + +namespace CliHostSampleApp; + +public static class DemoCommandGroup +{ + public const string Name = "demo"; +} diff --git a/samples/CliHostSampleApp/Program.cs b/samples/CliHostSampleApp/Program.cs index b7347692..7b535186 100644 --- a/samples/CliHostSampleApp/Program.cs +++ b/samples/CliHostSampleApp/Program.cs @@ -13,7 +13,7 @@ static async Task Main(string[] args) private static ICliHost CreateCliHost() { return CliHostBuilder.Create() - .EnableHelp(HelpCommandKind.Command) + .EnableHelp(HelpCommandKind.CommandOrArgument) .Build(); } } diff --git a/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyCommandScanner.cs b/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyCommandScanner.cs index 95a4ff8f..2df6844b 100644 --- a/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyCommandScanner.cs +++ b/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyCommandScanner.cs @@ -29,10 +29,3 @@ public AssemblyScanResult ScanForCommands(Assembly[] assemblies, Func CommandInfos { get; init; } - - public required IEnumerable GroupAttributes { get; init; } -} diff --git a/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyScanResult.cs b/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyScanResult.cs new file mode 100644 index 00000000..8e8c7237 --- /dev/null +++ b/source/Cli/CreativeCoders.Cli.Hosting/Commands/AssemblyScanResult.cs @@ -0,0 +1,10 @@ +using CreativeCoders.Cli.Core; + +namespace CreativeCoders.Cli.Hosting.Commands; + +public class AssemblyScanResult +{ + public required IEnumerable CommandInfos { get; init; } + + public required IEnumerable GroupAttributes { get; init; } +} diff --git a/source/Cli/CreativeCoders.Cli.Hosting/Commands/Store/CliCommandStore.cs b/source/Cli/CreativeCoders.Cli.Hosting/Commands/Store/CliCommandStore.cs index 18ff92d0..361aca36 100644 --- a/source/Cli/CreativeCoders.Cli.Hosting/Commands/Store/CliCommandStore.cs +++ b/source/Cli/CreativeCoders.Cli.Hosting/Commands/Store/CliCommandStore.cs @@ -104,7 +104,7 @@ private void AddCommand(CliCommandInfo command, string[] commands) { var groupNode = GetGroupNode(commands.Take(commands.Length - 1).ToArray()); - if (groupNode.Parent == null) + if (groupNode.Parent == null && !_treeRootNodes.Contains(groupNode)) { _treeRootNodes.Add(groupNode); } diff --git a/source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHost.cs b/source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHost.cs index 189fb331..c1484791 100644 --- a/source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHost.cs +++ b/source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHost.cs @@ -142,6 +142,15 @@ public async Task RunAsync(string[] args) e.ValidationResult.Messages.ForEach(message => _ansiConsole.MarkupLine($"- {message}")); + return new CliResult(e.ExitCode); + } + catch (CliCommandAbortException e) + { + if (e.PrintMessage) + { + _ansiConsole.MarkupLine(e.IsError ? $"[red]{e.Message}[/]" : $"[yellow]{e.Message}[/]"); + } + return new CliResult(e.ExitCode); } } diff --git a/source/Cli/CreativeCoders.Cli.Hosting/Exceptions/CliCommandAbortException.cs b/source/Cli/CreativeCoders.Cli.Hosting/Exceptions/CliCommandAbortException.cs new file mode 100644 index 00000000..78f71a23 --- /dev/null +++ b/source/Cli/CreativeCoders.Cli.Hosting/Exceptions/CliCommandAbortException.cs @@ -0,0 +1,9 @@ +namespace CreativeCoders.Cli.Hosting.Exceptions; + +public class CliCommandAbortException(string message, int exitCode, Exception? exception = null) + : CliExitException(message, exitCode, exception) +{ + public bool IsError { get; set; } = true; + + public bool PrintMessage { get; set; } = true; +} diff --git a/tests/CreativeCoders.Cli.Tests/Hosting/Commands/CliCommandStoreTests.cs b/tests/CreativeCoders.Cli.Tests/Hosting/Commands/CliCommandStoreTests.cs index 8b40acb0..182e1fcf 100644 --- a/tests/CreativeCoders.Cli.Tests/Hosting/Commands/CliCommandStoreTests.cs +++ b/tests/CreativeCoders.Cli.Tests/Hosting/Commands/CliCommandStoreTests.cs @@ -565,6 +565,51 @@ public void AddCommands_WithAlternativeCommands_AddsAliases() .BeSameAs(cmdInfo); } + [Fact] + public void AddCommands_WithGroupAttribute_NoDuplicatedGroups() + { + // Arrange + var commandInfo = new CliCommandInfo + { + CommandAttribute = new CliCommandAttribute(["tools", "admin"]), + CommandType = typeof(DummyCommand) + }; + + var firstGroupAttribute = new CliCommandGroupAttribute(["tools"], "Tools root group"); + + var groupAttributes = new[] + { + firstGroupAttribute + }; + + var store = new CliCommandStore(); + + // Act + store.AddCommands([commandInfo], groupAttributes); + + // Assert + var toolsGroupNode = store.TreeRootNodes + .Should() + .ContainSingle(node => node.Name == "tools") + .Which + .Should() + .BeOfType() + .Which; + + toolsGroupNode.GroupAttribute + .Should() + .BeSameAs(firstGroupAttribute); + + toolsGroupNode.ChildNodes + .OfType() + .Should() + .ContainSingle(node => node.Name == "admin"); + + store.GroupAttributes + .Should() + .BeEquivalentTo(groupAttributes); + } + [Fact] public void AddCommands_WithGroupAttributes_AssignsAttributesToGroupNodes() { From 717feb7a06c7c0a91fbed2dba8d114422e3a3a96 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:56:43 +0100 Subject: [PATCH 2/2] Add test for `DefaultCliHost.RunAsync` to handle `CliCommandAbortException` scenarios: - Introduced `RunAsync_WhenCommandWithAbortException_ExecutesAndReturnsExceptionResult` test with various exception configurations. - Added `DummyCommandWithErrorAbortException`, `DummyCommandWithNoneErrorAbortException`, and `DummyCommandWithoutPrintErrorAbortException` test cases. - Enhanced console output testing with custom `CheckConsoleOutput` utility. --- .../Hosting/DefaultCliHostTests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/CreativeCoders.Cli.Tests/Hosting/DefaultCliHostTests.cs b/tests/CreativeCoders.Cli.Tests/Hosting/DefaultCliHostTests.cs index 4ca2042d..288c9c2c 100644 --- a/tests/CreativeCoders.Cli.Tests/Hosting/DefaultCliHostTests.cs +++ b/tests/CreativeCoders.Cli.Tests/Hosting/DefaultCliHostTests.cs @@ -8,6 +8,7 @@ using FakeItEasy; using JetBrains.Annotations; using Spectre.Console; +using Spectre.Console.Rendering; using Xunit; namespace CreativeCoders.Cli.Tests.Hosting; @@ -123,6 +124,75 @@ public async Task RunAsync_WhenCommandWithoutOptions_ExecutesAndReturnsResult() .Be(5); } + [Theory] + [InlineData(typeof(DummyCommandWithErrorAbortException), DummyCommandWithErrorAbortException.ExitCode, + true, DummyCommandWithErrorAbortException.Message, 0)] + [InlineData(typeof(DummyCommandWithNoneErrorAbortException), + DummyCommandWithNoneErrorAbortException.ExitCode, + true, DummyCommandWithNoneErrorAbortException.Message, 1)] + [InlineData(typeof(DummyCommandWithoutPrintErrorAbortException), + DummyCommandWithoutPrintErrorAbortException.ExitCode, + false, DummyCommandWithoutPrintErrorAbortException.Message, 1)] + public async Task RunAsync_WhenCommandWithAbortException_ExecutesAndReturnsExceptionResult( + Type commandType, int exitCode, bool shouldPrintMessage, string message, int expectedColor) + { + // Arrange + Color[] colors = [Color.Red, Color.Yellow]; + var args = new[] { "run" }; + + var ansiConsole = A.Fake(); + var commandStore = A.Fake(); + var serviceProvider = A.Fake(); + var helpHandler = A.Fake(); + + SetupServiceProvider(serviceProvider, new CliCommandContext()); + + A.CallTo(() => helpHandler.ShouldPrintHelp(args)) + .Returns(false); + + var commandInfo = new CliCommandInfo + { + CommandAttribute = new CliCommandAttribute(["run"]), + CommandType = commandType + }; + + var commandNode = new CliCommandNode(commandInfo, "run", null); + + A.CallTo(() => commandStore.FindCommandNode(args)) + .Returns(new FindCommandNodeResult(commandNode, [])); + + var host = new DefaultCliHost(ansiConsole, commandStore, serviceProvider, helpHandler); + + // Act + var result = await host.RunAsync(args); + + // Assert + result.ExitCode + .Should() + .Be(exitCode); + + if (shouldPrintMessage) + { + A.CallTo(() => ansiConsole.Write(A.That.Matches(r => + CheckConsoleOutput(r, message, colors[expectedColor])))) + .MustHaveHappenedOnceExactly(); + } + else + { + A.CallTo(() => ansiConsole.Write(A.Ignored)) + .MustNotHaveHappened(); + } + } + + private static bool CheckConsoleOutput(IRenderable renderable, string message, Color color) + { + var text = string.Join("", renderable.GetSegments(AnsiConsole.Console).Select(s => s.Text)); + var style = renderable.GetSegments(AnsiConsole.Console).First().Style; + + return text.Trim() == message && + style.Foreground == color; + } + [Fact] public async Task RunMainAsync_WhenCommandWithoutOptions_ExecutesAndReturnsIntResult() { @@ -514,4 +584,46 @@ public Task ExecuteAsync() return Task.FromResult(new CommandResult()); } } + + private sealed class DummyCommandWithErrorAbortException : ICliCommand + { + public const int ExitCode = 12349876; + + public const string Message = "Command aborted with error"; + + public Task ExecuteAsync() + { + throw new CliCommandAbortException(Message, ExitCode); + } + } + + private sealed class DummyCommandWithNoneErrorAbortException : ICliCommand + { + public const int ExitCode = 12349876; + + public const string Message = "Command aborted with warning"; + + public Task ExecuteAsync() + { + throw new CliCommandAbortException(Message, ExitCode) + { + IsError = false + }; + } + } + + private sealed class DummyCommandWithoutPrintErrorAbortException : ICliCommand + { + public const int ExitCode = 12349876; + + public const string Message = "Command aborted with printing"; + + public Task ExecuteAsync() + { + throw new CliCommandAbortException(Message, ExitCode) + { + PrintMessage = false + }; + } + } }