From d0452cae3b5ec6d228d28439cc155901a1d7fb9d Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 17 Jun 2026 21:42:56 +1200 Subject: [PATCH 1/2] Introduce IFalloutCommand dispatch with DI for Fallout.Cli Replace the reflection-over-Program command dispatch (the partial god-class described in #392) with a typed command abstraction resolved through Microsoft.Extensions.DependencyInjection. This is the foundation PR: it lands the abstraction, the dispatcher, the prompt service, and the first real command conversion, with the remaining handlers converted one per follow-up PR. - Add public IFalloutCommand (Name + Execute) and a CommandDispatcher that resolves by name, dash- and case-insensitively, preserving every spelling the old reflection accepted (:add-package == :addpackage, :PopDirectory, ...). - Move the Spectre prompt/render helpers off Program into an injectable IConsolePrompts / SpectreConsolePrompts (namespace Fallout.Cli.Prompts to avoid colliding with System.Console). Program keeps thin static delegators so the not-yet-extracted handlers compile; the last conversion deletes them. - Convert Run into a real RunCommand type; delete Program.Run.cs. - Adapt the 13 still-legacy handlers via a transitional DelegateCommand so the registry and dispatch are uniform from day one. Each future PR deletes one registration line plus its Program.X.cs partial. - Delete the reflection dispatch and its "add assertions about return type and parameters" TODO; typed commands make signature-mismatch dispatch impossible. - Add CommandDispatcherTests (first-ever dispatch coverage): name matching, dash/case insensitivity, exit-code passthrough, unknown-command listing, empty token, and all default-routing branches. - Add .vscode launch/build tasks for debugging the global tool from source. Co-Authored-By: Claude Opus 4.8 (1M context) --- .vscode/launch.json | 47 ++++ .vscode/tasks.json | 17 ++ Directory.Packages.props | 1 + src/Fallout.Cli/CommandDispatcher.cs | 69 ++++++ src/Fallout.Cli/Commands/DelegateCommand.cs | 27 +++ src/Fallout.Cli/Commands/IFalloutCommand.cs | 30 +++ .../RunCommand.cs} | 14 +- src/Fallout.Cli/Fallout.Cli.csproj | 1 + src/Fallout.Cli/Program.cs | 172 +++++--------- src/Fallout.Cli/Prompts/IConsolePrompts.cs | 39 ++++ .../Prompts/SpectreConsolePrompts.cs | 95 ++++++++ .../CommandDispatcherTests.cs | 217 ++++++++++++++++++ 12 files changed, 608 insertions(+), 121 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 src/Fallout.Cli/CommandDispatcher.cs create mode 100644 src/Fallout.Cli/Commands/DelegateCommand.cs create mode 100644 src/Fallout.Cli/Commands/IFalloutCommand.cs rename src/Fallout.Cli/{Program.Run.cs => Commands/RunCommand.cs} (87%) create mode 100644 src/Fallout.Cli/Prompts/IConsolePrompts.cs create mode 100644 src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs create mode 100644 tests/Fallout.Cli.Tests/CommandDispatcherTests.cs diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..577166d43 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,47 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Prompts you for the command/args each launch, e.g. ":get-configuration" or ":bogus". + "name": "fallout (ask for args)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": "${input:falloutArgs}", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + }, + { + // Dispatch error path — prints the command manifest, no side effects. + "name": "fallout :bogus", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":bogus"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + }, + { + // Real command via the DelegateCommand path — read-only. + "name": "fallout :get-configuration", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-cli", + "program": "${workspaceFolder}/src/Fallout.Cli/bin/Debug/net10.0/Fallout.Cli.dll", + "args": [":get-configuration"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ], + "inputs": [ + { + "id": "falloutArgs", + "type": "promptString", + "description": "Arguments to pass to fallout (space-separated), e.g. ':get-configuration'", + "default": ":bogus" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..e19bb9547 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-cli", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Fallout.Cli/Fallout.Cli.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 644d92315..5a1a8e0e3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/src/Fallout.Cli/CommandDispatcher.cs b/src/Fallout.Cli/CommandDispatcher.cs new file mode 100644 index 000000000..86ce2c430 --- /dev/null +++ b/src/Fallout.Cli/CommandDispatcher.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; + +namespace Fallout.Cli; + +/// +/// Routes a command-line invocation to the matching . Commands are +/// resolved by from the registered set — dash- and +/// case-insensitively, preserving every spelling the historical reflection dispatch accepted +/// (e.g. :add-package and :addpackage, :PopDirectory and :popdirectory). +/// +internal sealed class CommandDispatcher +{ + private const char CommandPrefix = ':'; + + private readonly IReadOnlyList _commands; + private readonly IConsolePrompts _prompts; + + public CommandDispatcher(IEnumerable commands, IConsolePrompts prompts) + { + _commands = commands.ToList(); + _prompts = prompts; + } + + public int Dispatch(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; + if (hasCommand) + { + var token = args.First().Trim(CommandPrefix); + if (string.IsNullOrWhiteSpace(token)) + Assert.Fail($"No command specified. Usage is: fallout {CommandPrefix} [args]"); + + var command = Resolve(token); + return command.Execute(args.Skip(count: 1).ToArray(), rootDirectory, buildScript); + } + + if (rootDirectory == null) + { + return _prompts.PromptForConfirmation( + $"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") + ? GetRequired("setup").Execute(Array.Empty(), rootDirectory: null, buildScript: null) + : 0; + } + + // TODO: docker + + return GetRequired("run").Execute(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); + } + + private IFalloutCommand Resolve(string token) + { + return _commands.SingleOrDefault(x => Normalize(x.Name).EqualsOrdinalIgnoreCase(Normalize(token))) + .NotNull(new[] { $"Command '{token}' is not supported, available commands are:" } + .Concat(_commands.Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); + } + + private IFalloutCommand GetRequired(string name) + => _commands.Single(x => x.Name.EqualsOrdinalIgnoreCase(name)); + + private static string Normalize(string value) => value.Replace("-", string.Empty); +} diff --git a/src/Fallout.Cli/Commands/DelegateCommand.cs b/src/Fallout.Cli/Commands/DelegateCommand.cs new file mode 100644 index 000000000..5a60e8559 --- /dev/null +++ b/src/Fallout.Cli/Commands/DelegateCommand.cs @@ -0,0 +1,27 @@ +using System; +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// Transitional adapter that exposes a not-yet-extracted Program.X handler as an +/// , so the dispatcher can route every command uniformly through the +/// registry while the per-command conversion (issue #392) lands one PR at a time. Each conversion +/// replaces one registration of this adapter with a real command type; the adapter is deleted once +/// the last legacy handler is gone. +/// +internal sealed class DelegateCommand : IFalloutCommand +{ + private readonly Func _handler; + + public DelegateCommand(string name, Func handler) + { + Name = name; + _handler = handler; + } + + public string Name { get; } + + public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => _handler(args, rootDirectory, buildScript); +} diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs new file mode 100644 index 000000000..04f027d7d --- /dev/null +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -0,0 +1,30 @@ +using Fallout.Common.IO; + +namespace Fallout.Cli.Commands; + +/// +/// A single global-tool command (e.g. fallout :run, fallout :setup). +/// One command = one type, resolved by from the dependency-injection +/// container — replacing the historical reflection-over-Program dispatch. +/// +/// +/// This surface is intentionally minimal. It is not a stable public plugin contract yet; +/// when a public command SDK lands (milestone #7) the API will be annotated and versioned +/// explicitly. Until then, treat additions here as internal-by-convention. +/// +public interface IFalloutCommand +{ + /// + /// The canonical command name as typed after the : prefix, in dash form + /// (e.g. "run", "add-package", "cake-convert"). Matched case-insensitively. + /// + string Name { get; } + + /// + /// Executes the command and returns the process exit code. + /// + /// The arguments following the command token. + /// The resolved repository root, or null when none was found. + /// The resolved build script / project file, or null when none applies. + int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); +} diff --git a/src/Fallout.Cli/Program.Run.cs b/src/Fallout.Cli/Commands/RunCommand.cs similarity index 87% rename from src/Fallout.Cli/Program.Run.cs rename to src/Fallout.Cli/Commands/RunCommand.cs index a9aefc998..28982c927 100644 --- a/src/Fallout.Cli/Program.Run.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -8,11 +8,17 @@ using Fallout.Common.Utilities; using static Fallout.Common.Constants; -namespace Fallout.Cli; +namespace Fallout.Cli.Commands; -partial class Program +/// +/// fallout :run (and the default, command-less invocation): builds the build project and +/// runs it, forwarding any remaining arguments to the build. +/// +public sealed class RunCommand : IFalloutCommand { - private static int Run(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) + public string Name => "run"; + + public int Execute(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) { var dotnet = ResolveDotnet(rootDirectory); @@ -63,7 +69,7 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) startInfo.Environment["DOTNET_NOLOGO"] = "1"; startInfo.Environment["DOTNET_ROLL_FORWARD"] = "Major"; startInfo.Environment["FALLOUT_TELEMETRY_OPTOUT"] = "1"; - startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(Program).Assembly.GetVersionText(); + startInfo.Environment[GlobalToolVersionEnvironmentKey] = typeof(RunCommand).Assembly.GetVersionText(); startInfo.Environment[GlobalToolStartTimeEnvironmentKey] = DateTime.Now.ToString("O"); var process = Process.Start(startInfo).NotNull(); diff --git a/src/Fallout.Cli/Fallout.Cli.csproj b/src/Fallout.Cli/Fallout.Cli.csproj index 996a07f13..60071de75 100644 --- a/src/Fallout.Cli/Fallout.Cli.csproj +++ b/src/Fallout.Cli/Fallout.Cli.csproj @@ -17,6 +17,7 @@ + diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index 1702ab81a..b9ab0351b 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -1,18 +1,18 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; -using Spectre.Console; +using Microsoft.Extensions.DependencyInjection; namespace Fallout.Cli; public partial class Program { - private const char CommandPrefix = ':'; - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; private static int Main(string[] args) @@ -28,7 +28,8 @@ private static int Main(string[] args) .FirstOrDefault(x => Constants.TryGetRootDirectoryFrom(x.Parent) == rootDirectory) : null; - return Handle(args, rootDirectory, buildScript); + using var services = BuildServiceProvider(); + return services.GetRequiredService().Dispatch(args, rootDirectory, buildScript); } catch (Exception exception) { @@ -37,6 +38,40 @@ private static int Main(string[] args) } } + private static ServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + + services.AddSingleton(); + services.AddSingleton(); + RegisterCommands(services); + + return services.BuildServiceProvider(); + } + + private static void RegisterCommands(IServiceCollection services) + { + // Real command types — issue #392 converts one legacy handler per PR. + services.AddSingleton(); + + // Legacy handlers still living on Program, adapted until they are extracted into command + // types. Each conversion deletes one line here plus its Program.X.cs partial. + services.AddSingleton(new DelegateCommand("setup", Setup)); + services.AddSingleton(new DelegateCommand("update", Update)); + services.AddSingleton(new DelegateCommand("add-package", AddPackage)); + services.AddSingleton(new DelegateCommand("cake-convert", CakeConvert)); + services.AddSingleton(new DelegateCommand("cake-clean", CakeClean)); + services.AddSingleton(new DelegateCommand("complete", Complete)); + services.AddSingleton(new DelegateCommand("get-configuration", GetConfiguration)); + services.AddSingleton(new DelegateCommand("secrets", Secrets)); + services.AddSingleton(new DelegateCommand("trigger", Trigger)); + services.AddSingleton(new DelegateCommand("GetNextDirectory", GetNextDirectory)); + services.AddSingleton(new DelegateCommand("PopDirectory", PopDirectory)); + services.AddSingleton(new DelegateCommand("PushWithCurrentRootDirectory", PushWithCurrentRootDirectory)); + services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", PushWithParentRootDirectory)); + services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", PushWithChosenRootDirectory)); + } + private static void PrintInfo() { Host.Information($"NUKE Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); @@ -45,7 +80,7 @@ private static void PrintInfo() private static AbsolutePath TryGetRootDirectory() { // TODO: copied in FalloutBuild.GetRootDirectory - var parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); + AbsolutePath parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); if (parameterValue != null) return parameterValue; @@ -55,115 +90,18 @@ private static AbsolutePath TryGetRootDirectory() return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); } - private static int Handle(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) - { - var hasCommand = args.FirstOrDefault()?.StartsWithOrdinalIgnoreCase(CommandPrefix.ToString()) ?? false; - if (hasCommand) - { - var command = args.First().Trim(CommandPrefix).Replace("-", string.Empty); - if (string.IsNullOrWhiteSpace(command)) - Assert.Fail($"No command specified. Usage is: nuke {CommandPrefix} [args]"); - - var availableCommands = typeof(Program).GetMethods(ReflectionUtility.Static).Where(x => x.ReturnType == typeof(int)).ToList(); - var commandHandler = availableCommands.SingleOrDefault(x => x.Name.EqualsOrdinalIgnoreCase(command)) - .NotNull(new[] { $"Command '{command}' is not supported, available commands are:" } - .Concat(availableCommands.Where(x => x.IsPublic).Select(x => $" - {x.Name}").OrderBy(x => x)).JoinNewLine()); - // TODO: add assertions about return type and parameters - - var commandArguments = new object[] { args.Skip(count: 1).ToArray(), rootDirectory, buildScript }; - return (int)commandHandler.Invoke(obj: null, commandArguments).NotNull($"Command '{command}' did not return exit code"); - } - - if (rootDirectory == null) - { - return PromptForConfirmation($"Could not find {Constants.FalloutDirectoryName} directory/file. Do you want to setup a build?") - ? Setup(new string[0], rootDirectory: null, buildScript: null) - : 0; - } - - // TODO: docker - - return Run(args, rootDirectory, BuildProjectResolver.Resolve(rootDirectory)); - } - - private static void ShowInput(string emoji, string title, string value) - { - AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); - } - - private static void ShowCompletion(string title) - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); - } - - private static void ClearPreviousLine() - { - AnsiConsole.Cursor.MoveUp(); - Console.WriteLine(' '.Repeat(Console.WindowWidth)); - AnsiConsole.Cursor.MoveUp(); - } - - private static bool PromptForConfirmation(string question) - { - return AnsiConsole.Confirm(question); - } - - private static string PromptForInput(string question, string defaultValue = null) - { - return AnsiConsole.Prompt( - new TextPrompt(question) - .DefaultValue(defaultValue)); - } - - private static string PromptForSecret(string title, int? minLength = null) - { - Assert.False(title.EndsWith(':')); - - return AnsiConsole.Prompt( - new TextPrompt($"{title}:") - .Secret() - .Validate(x => minLength == null || x.Length >= minLength, - message: $"Secret must be at least {minLength} characters long")); - } - - private static T PromptForChoice(string question, params (T Value, string Description)[] choices) - { - var choice = AnsiConsole.Prompt( - new SelectionPrompt() - .Title(question) - .HighlightStyle(new Style(Color.Turquoise2)) - .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) - .AddChoices(choices.Select(x => x.Value))); - return choice; - } - - private static void ConfirmExecution(string title, Action action) - { - Assert.False(title.EndsWith('?')); - - var confirmation = PromptForConfirmation($"{title}?"); - ClearPreviousLine(); - - if (confirmation) - { - AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); - try - { - action.Invoke(); - } - catch (Exception) - { - confirmation = false; - title = $"{title} (failed)"; - } - finally - { - ClearPreviousLine(); - } - } - - var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); - AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); - } + // ── Transitional Spectre prompt delegators ────────────────────────────────────────────────── + // The implementations now live in SpectreConsolePrompts; these forwards keep the not-yet-extracted + // Program.X.cs command handlers compiling. As each handler becomes a command type taking + // IConsolePrompts via the constructor, its use of these disappears; the last conversion deletes them. + private static readonly IConsolePrompts s_prompts = new SpectreConsolePrompts(); + + private static void ShowInput(string emoji, string title, string value) => s_prompts.ShowInput(emoji, title, value); + private static void ShowCompletion(string title) => s_prompts.ShowCompletion(title); + private static void ClearPreviousLine() => s_prompts.ClearPreviousLine(); + private static bool PromptForConfirmation(string question) => s_prompts.PromptForConfirmation(question); + private static string PromptForInput(string question, string defaultValue = null) => s_prompts.PromptForInput(question, defaultValue); + private static string PromptForSecret(string title, int? minLength = null) => s_prompts.PromptForSecret(title, minLength); + private static T PromptForChoice(string question, params (T Value, string Description)[] choices) => s_prompts.PromptForChoice(question, choices); + private static void ConfirmExecution(string title, Action action) => s_prompts.ConfirmExecution(title, action); } diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs new file mode 100644 index 000000000..465e1fd81 --- /dev/null +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -0,0 +1,39 @@ +using System; + +namespace Fallout.Cli.Prompts; + +/// +/// Interactive console prompts and status rendering used by commands. Injected into commands +/// so their interaction logic can be unit-tested with a fake, instead of reaching the historical +/// static Spectre helpers on Program. The default implementation is +/// . +/// +public interface IConsolePrompts +{ + /// Renders a labelled, read-only input line (used to echo resolved setup choices). + void ShowInput(string emoji, string title, string value); + + /// Renders a " completed!" banner. + void ShowCompletion(string title); + + /// Clears the previously written console line. + void ClearPreviousLine(); + + /// Asks a yes/no question and returns the answer. + bool PromptForConfirmation(string question); + + /// Asks for free-text input, optionally with a default value. + string PromptForInput(string question, string defaultValue = null); + + /// Asks for a masked secret value, optionally enforcing a minimum length. + string PromptForSecret(string title, int? minLength = null); + + /// Asks the user to pick one of the supplied . + T PromptForChoice(string question, params (T Value, string Description)[] choices); + + /// + /// Confirms, then runs with progress/completion rendering, + /// reporting success or failure without throwing. + /// + void ConfirmExecution(string title, Action action); +} diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs new file mode 100644 index 000000000..5f688a751 --- /dev/null +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -0,0 +1,95 @@ +using System; +using System.Linq; +using Fallout.Common; +using Fallout.Common.Utilities; +using Spectre.Console; + +namespace Fallout.Cli.Prompts; + +/// +/// backed by . This is the single home for +/// the interactive prompt logic that previously lived as static helpers on Program. +/// +public sealed class SpectreConsolePrompts : IConsolePrompts +{ + public void ShowInput(string emoji, string title, string value) + { + AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); + } + + public void ShowCompletion(string title) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); + } + + public void ClearPreviousLine() + { + AnsiConsole.Cursor.MoveUp(); + System.Console.WriteLine(' '.Repeat(System.Console.WindowWidth)); + AnsiConsole.Cursor.MoveUp(); + } + + public bool PromptForConfirmation(string question) + { + return AnsiConsole.Confirm(question); + } + + public string PromptForInput(string question, string defaultValue = null) + { + return AnsiConsole.Prompt( + new TextPrompt(question) + .DefaultValue(defaultValue)); + } + + public string PromptForSecret(string title, int? minLength = null) + { + Assert.False(title.EndsWith(':')); + + return AnsiConsole.Prompt( + new TextPrompt($"{title}:") + .Secret() + .Validate(x => minLength == null || x.Length >= minLength, + message: $"Secret must be at least {minLength} characters long")); + } + + public T PromptForChoice(string question, params (T Value, string Description)[] choices) + { + var choice = AnsiConsole.Prompt( + new SelectionPrompt() + .Title(question) + .HighlightStyle(new Style(Color.Turquoise2)) + .UseConverter(x => choices.Single(y => Equals(x, y.Value)).Description) + .AddChoices(choices.Select(x => x.Value))); + return choice; + } + + public void ConfirmExecution(string title, Action action) + { + Assert.False(title.EndsWith('?')); + + var confirmation = PromptForConfirmation($"{title}?"); + ClearPreviousLine(); + + if (confirmation) + { + AnsiConsole.MarkupLine($":hourglass_not_done: {title} ..."); + try + { + action.Invoke(); + } + catch (Exception) + { + confirmation = false; + title = $"{title} (failed)"; + } + finally + { + ClearPreviousLine(); + } + } + + var (emoji, color) = confirmation ? ("check_mark", "green") : ("multiply", "red"); + AnsiConsole.MarkupLine($"[{color}]:{emoji}:[/] {title}"); + } +} diff --git a/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs b/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs new file mode 100644 index 000000000..7fadf6841 --- /dev/null +++ b/tests/Fallout.Cli.Tests/CommandDispatcherTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Cli.Tests; + +public class CommandDispatcherTests +{ + private static readonly AbsolutePath SomeRoot = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-dispatch-root"); + private static readonly AbsolutePath SomeScript = SomeRoot / "build.sh"; + + [Fact] + public void Dispatch_ColonCommand_InvokesMatchingCommandWithForwardedArgs() + { + var run = new RecordingCommand("run"); + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { run, setup }, new FakePrompts()); + + var exitCode = dispatcher.Dispatch([":setup", "alpha", "beta"], SomeRoot, SomeScript); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().Equal("alpha", "beta"); + setup.ReceivedRoot.Should().Be(SomeRoot); + setup.ReceivedBuildScript.Should().Be(SomeScript); + run.WasCalled.Should().BeFalse(); + } + + [Theory] + [InlineData(":setup")] + [InlineData(":SETUP")] + [InlineData(":Setup")] + public void Dispatch_MatchesNameCaseInsensitively(string token) + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { setup }, new FakePrompts()); + + dispatcher.Dispatch([token], SomeRoot, SomeScript); + + setup.WasCalled.Should().BeTrue(); + } + + [Theory] + [InlineData(":add-package")] + [InlineData(":addpackage")] + [InlineData(":ADD-PACKAGE")] + public void Dispatch_MatchesNameIgnoringDashes(string token) + { + // Preserves every spelling the historical reflection dispatch accepted. + var addPackage = new RecordingCommand("add-package"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { addPackage }, new FakePrompts()); + + dispatcher.Dispatch([token], SomeRoot, SomeScript); + + addPackage.WasCalled.Should().BeTrue(); + } + + [Fact] + public void Dispatch_ReturnsCommandExitCode() + { + var trigger = new RecordingCommand("trigger", exitCode: 42); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { trigger }, new FakePrompts()); + + dispatcher.Dispatch([":trigger"], SomeRoot, SomeScript).Should().Be(42); + } + + [Fact] + public void Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing() + { + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), new RecordingCommand("setup") }, + new FakePrompts()); + + var action = () => dispatcher.Dispatch([":bogus"], SomeRoot, SomeScript); + + action.Should().Throw() + .WithMessage("*'bogus' is not supported*") + .And.Message.Should().ContainAll("- run", "- setup"); + } + + [Fact] + public void Dispatch_EmptyCommandToken_Fails() + { + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts()); + + var action = () => dispatcher.Dispatch([":"], SomeRoot, SomeScript); + + action.Should().Throw().WithMessage("*No command specified*"); + } + + [Fact] + public void Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: true)); + + dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().BeEmpty(); + setup.ReceivedRoot.Should().BeNull(); + } + + [Fact] + public void Dispatch_NoCommand_NullRoot_Declined_ReturnsZeroWithoutSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: false)); + + var exitCode = dispatcher.Dispatch(["whatever"], rootDirectory: null, buildScript: null); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeFalse(); + } + + [Fact] + public void Dispatch_NoCommand_WithRoot_InvokesRunWithResolvedBuildProject() + { + using var root = TempRoot.Create(); + var buildProject = root.WriteBuildProjectAtConvention(); + var run = new RecordingCommand("run"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { run, new RecordingCommand("setup") }, + new FakePrompts()); + + dispatcher.Dispatch(["compile", "--verbose"], root.Path, buildScript: null); + + run.WasCalled.Should().BeTrue(); + run.ReceivedArgs.Should().Equal("compile", "--verbose"); + run.ReceivedBuildScript.Should().Be(buildProject); + } + + private sealed class RecordingCommand : IFalloutCommand + { + private readonly int _exitCode; + + public RecordingCommand(string name, int exitCode = 0) + { + Name = name; + _exitCode = exitCode; + } + + public string Name { get; } + public bool WasCalled { get; private set; } + public string[] ReceivedArgs { get; private set; } + public AbsolutePath ReceivedRoot { get; private set; } + public AbsolutePath ReceivedBuildScript { get; private set; } + + public int Execute(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + WasCalled = true; + ReceivedArgs = args; + ReceivedRoot = rootDirectory; + ReceivedBuildScript = buildScript; + return _exitCode; + } + } + + private sealed class FakePrompts : IConsolePrompts + { + private readonly bool _confirm; + + public FakePrompts(bool confirm = false) => _confirm = confirm; + + public bool PromptForConfirmation(string question) => _confirm; + + public void ShowInput(string emoji, string title, string value) { } + public void ShowCompletion(string title) { } + public void ClearPreviousLine() { } + public string PromptForInput(string question, string defaultValue = null) => defaultValue; + public string PromptForSecret(string title, int? minLength = null) => string.Empty; + public T PromptForChoice(string question, params (T Value, string Description)[] choices) => default; + public void ConfirmExecution(string title, Action action) => action(); + } + + private sealed class TempRoot : IDisposable + { + public AbsolutePath Path { get; } + + private TempRoot(AbsolutePath path) + { + Path = path; + (path / ".fallout").CreateDirectory(); + } + + public static TempRoot Create() + { + var dir = (AbsolutePath)System.IO.Path.Combine(System.IO.Path.GetTempPath(), "fallout-dispatch-" + Guid.NewGuid().ToString("N")); + dir.CreateDirectory(); + return new TempRoot(dir); + } + + public AbsolutePath WriteBuildProjectAtConvention() + { + var buildDir = Path / "build"; + buildDir.CreateDirectory(); + var projectFile = buildDir / "_build.csproj"; + File.WriteAllText(projectFile, string.Empty); + return projectFile; + } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, recursive: true); + } + } +} From 87bad6efce114e7d00be8ab0e548a3cfdedf9cb4 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 17 Jun 2026 21:52:37 +1200 Subject: [PATCH 2/2] Make shared CLI helpers internal for incremental command extraction Bump the cross-command helpers (GetConfiguration(buildScript, evaluate), AddOrReplacePackage, WriteBuildScripts, WriteConfigurationFile, GetTemplate, PrintInfo, CurrentBuildScriptName, BUILD_PROJECT_FILE) from private to internal so the per-command IFalloutCommand types extracted in the #392 follow-up PRs can call them during the transition. These move into dedicated services in the final collapse PR; this is the minimal enabler that lets each command be converted in an independent, conflict-free PR. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Cli/Program.AddPackage.cs | 2 +- src/Fallout.Cli/Program.GetConfiguration.cs | 6 ++++-- src/Fallout.Cli/Program.Setup.cs | 6 +++--- src/Fallout.Cli/Program.cs | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Fallout.Cli/Program.AddPackage.cs b/src/Fallout.Cli/Program.AddPackage.cs index ea6d28cf5..29efb84be 100644 --- a/src/Fallout.Cli/Program.AddPackage.cs +++ b/src/Fallout.Cli/Program.AddPackage.cs @@ -45,7 +45,7 @@ public static int AddPackage(string[] args, AbsolutePath rootDirectory, Absolute return 0; } - private static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) + internal static void AddOrReplacePackage(string packageId, string packageVersion, string packageType, string buildProjectFile) { var buildProject = ProjectModelTasks.ParseProject(buildProjectFile).NotNull(); diff --git a/src/Fallout.Cli/Program.GetConfiguration.cs b/src/Fallout.Cli/Program.GetConfiguration.cs index 6600b3273..9a9875d95 100644 --- a/src/Fallout.Cli/Program.GetConfiguration.cs +++ b/src/Fallout.Cli/Program.GetConfiguration.cs @@ -11,7 +11,9 @@ namespace Fallout.Cli; partial class Program { - private const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); + // internal (not private): shared with AddPackage/Update/Cake; will move into a configuration + // service in the final #392 collapse PR. + internal const string BUILD_PROJECT_FILE = nameof(BUILD_PROJECT_FILE); private const string TEMP_DIRECTORY = nameof(TEMP_DIRECTORY); private const string DOTNET_GLOBAL_FILE = nameof(DOTNET_GLOBAL_FILE); private const string DOTNET_INSTALL_URL = nameof(DOTNET_INSTALL_URL); @@ -27,7 +29,7 @@ public static int GetConfiguration(string[] args, AbsolutePath rootDirectory, Ab return 0; } - private static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) + internal static Dictionary GetConfiguration(AbsolutePath buildScript, bool evaluate) { string ReplaceScriptDirectory(string value) => evaluate diff --git a/src/Fallout.Cli/Program.Setup.cs b/src/Fallout.Cli/Program.Setup.cs index a094e2e2f..fd0ce6709 100644 --- a/src/Fallout.Cli/Program.Setup.cs +++ b/src/Fallout.Cli/Program.Setup.cs @@ -178,13 +178,13 @@ internal static void UpdateSolutionFileContent( "EndProject"); } - private static string[] GetTemplate(string templateName) + internal static string[] GetTemplate(string templateName) { return ResourceUtility.GetResourceAllLines($"templates.{templateName}"); } - private static void WriteBuildScripts( + internal static void WriteBuildScripts( AbsolutePath scriptDirectory, AbsolutePath rootDirectory, AbsolutePath buildDirectory, @@ -243,7 +243,7 @@ void MakeExecutable(AbsolutePath scriptFile) } } - private static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) + internal static void WriteConfigurationFile(AbsolutePath rootDirectory, AbsolutePath solutionFile) { var parametersFile = GetDefaultParametersFile(rootDirectory); var dictionary = new Dictionary { ["$schema"] = BuildSchemaFileName }; diff --git a/src/Fallout.Cli/Program.cs b/src/Fallout.Cli/Program.cs index b9ab0351b..0ce3d7e55 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -13,7 +13,7 @@ namespace Fallout.Cli; public partial class Program { - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; + internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; private static int Main(string[] args) { @@ -72,7 +72,7 @@ private static void RegisterCommands(IServiceCollection services) services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", PushWithChosenRootDirectory)); } - private static void PrintInfo() + internal static void PrintInfo() { Host.Information($"NUKE Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); }