From d0452cae3b5ec6d228d28439cc155901a1d7fb9d Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Wed, 17 Jun 2026 21:42:56 +1200 Subject: [PATCH 1/3] 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/3] 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()}"); } From 44b711c77c50a70aa19ea20b0b02587a34ad18db Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Sat, 27 Jun 2026 11:57:59 +1200 Subject: [PATCH 3/3] Make Cli interfaces internal for now we currently dont plan to expose any of those, so we can make these internal. If we need to expose those inside of fallout, we can do so with `` or at some point make a conscious decision to publish those interfaces for wider, public use Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Fallout.Cli/Commands/IFalloutCommand.cs | 8 ++++---- src/Fallout.Cli/Prompts/IConsolePrompts.cs | 2 +- src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Fallout.Cli/Commands/IFalloutCommand.cs b/src/Fallout.Cli/Commands/IFalloutCommand.cs index 04f027d7d..d591ae554 100644 --- a/src/Fallout.Cli/Commands/IFalloutCommand.cs +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -12,12 +12,12 @@ namespace Fallout.Cli.Commands; /// 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 +internal interface IFalloutCommand { /// - /// The canonical command name as typed after the : prefix, in dash form - /// (e.g. "run", "add-package", "cake-convert"). Matched case-insensitively. - /// + /// The command name as typed after the : prefix (prefer dash form, e.g. "add-package").\ + /// Matched case-insensitively and with dashes ignored (so :addpackage also matches).\ + /// Legacy commands may still use PascalCase names (e.g. "GetNextDirectory"). string Name { get; } /// diff --git a/src/Fallout.Cli/Prompts/IConsolePrompts.cs b/src/Fallout.Cli/Prompts/IConsolePrompts.cs index 465e1fd81..99b383b9b 100644 --- a/src/Fallout.Cli/Prompts/IConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/IConsolePrompts.cs @@ -8,7 +8,7 @@ namespace Fallout.Cli.Prompts; /// static Spectre helpers on Program. The default implementation is /// . /// -public interface IConsolePrompts +internal interface IConsolePrompts { /// Renders a labelled, read-only input line (used to echo resolved setup choices). void ShowInput(string emoji, string title, string value); diff --git a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs index 5f688a751..8013f2105 100644 --- a/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs +++ b/src/Fallout.Cli/Prompts/SpectreConsolePrompts.cs @@ -10,7 +10,7 @@ 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 +internal sealed class SpectreConsolePrompts : IConsolePrompts { public void ShowInput(string emoji, string title, string value) {