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..11948a394 --- /dev/null +++ b/src/Fallout.Cli/CommandDispatcher.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +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) + { + this.commands = commands.ToList(); + this.prompts = prompts; + } + + public async Task DispatchAsync(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 await command.ExecuteAsync(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?") + ? await GetRequired("setup").ExecuteAsync(Array.Empty(), rootDirectory: null, buildScript: null) + : 0; + } + + // TODO: docker + + return await GetRequired("run").ExecuteAsync(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..97089fab1 --- /dev/null +++ b/src/Fallout.Cli/Commands/DelegateCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +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; + this.handler = handler; + } + + public string Name { get; } + + // The adapted legacy handlers are still synchronous; each #392 conversion replaces one + // registration of this adapter with a command type that overrides ExecuteAsync for real. + public Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + => Task.FromResult(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..f583aa87d --- /dev/null +++ b/src/Fallout.Cli/Commands/IFalloutCommand.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +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. +/// +internal interface IFalloutCommand +{ + /// + /// 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; } + + /// + /// 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. + Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript); +} diff --git a/src/Fallout.Cli/Program.Run.cs b/src/Fallout.Cli/Commands/RunCommand.cs similarity index 77% rename from src/Fallout.Cli/Program.Run.cs rename to src/Fallout.Cli/Commands/RunCommand.cs index a9aefc998..dd6b66218 100644 --- a/src/Fallout.Cli/Program.Run.cs +++ b/src/Fallout.Cli/Commands/RunCommand.cs @@ -3,31 +3,42 @@ using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading.Tasks; using Fallout.Common; using Fallout.Common.IO; 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 async Task ExecuteAsync(string[] forwardedArgs, AbsolutePath rootDirectory, AbsolutePath buildProjectFile) { var dotnet = ResolveDotnet(rootDirectory); - var buildExitCode = StartDotnet(dotnet, GetBuildArguments(buildProjectFile)); + var buildExitCode = await StartDotnetAsync(dotnet, GetBuildArguments(buildProjectFile)); if (buildExitCode != 0) + { return buildExitCode; + } - return StartDotnet(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); + return await StartDotnetAsync(dotnet, GetRunArguments(buildProjectFile, forwardedArgs)); } private static string ResolveDotnet(AbsolutePath rootDirectory) { var pathDotnet = TryGetDotnetFromPath(); if (pathDotnet != null) + { return pathDotnet; + } var shimDirectoryName = EnvironmentInfo.IsWin ? "dotnet-win" : "dotnet-unix"; var shimExecutableName = EnvironmentInfo.IsWin ? "dotnet.exe" : "dotnet"; @@ -48,7 +59,7 @@ private static string TryGetDotnetFromPath() .FirstOrDefault(File.Exists); } - private static int StartDotnet(string dotnet, IEnumerable arguments) + private static async Task StartDotnetAsync(string dotnet, IEnumerable arguments) { var startInfo = new ProcessStartInfo { @@ -57,17 +68,19 @@ private static int StartDotnet(string dotnet, IEnumerable arguments) }; foreach (var argument in arguments) + { startInfo.ArgumentList.Add(argument); + } startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; 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(); - process.WaitForExit(); + await process.WaitForExitAsync(); return process.ExitCode; } 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.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 819afebce..ae74774ca 100644 --- a/src/Fallout.Cli/Program.Setup.cs +++ b/src/Fallout.Cli/Program.Setup.cs @@ -176,13 +176,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) { @@ -239,7 +239,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 828b31870..2221f1d9a 100644 --- a/src/Fallout.Cli/Program.cs +++ b/src/Fallout.Cli/Program.cs @@ -1,21 +1,22 @@ -using System; +using System; using System.IO; using System.Linq; using System.Text; +using System.Threading.Tasks; +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 = ':'; + internal static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - private static string CurrentBuildScriptName => EnvironmentInfo.IsWin ? "build.ps1" : "build.sh"; - - private static int Main(string[] args) + private static async Task Main(string[] args) { Console.OutputEncoding = Encoding.UTF8; @@ -28,7 +29,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 await services.GetRequiredService().DispatchAsync(args, rootDirectory, buildScript); } catch (Exception exception) { @@ -37,133 +39,70 @@ private static int Main(string[] args) } } - private static void PrintInfo() - { - Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); - } - - private static AbsolutePath TryGetRootDirectory() - { - // TODO: copied in FalloutBuild.GetRootDirectory - var parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); - if (parameterValue != null) - return parameterValue; - - if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName)) - return EnvironmentInfo.WorkingDirectory; - - 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) + private static ServiceProvider BuildServiceProvider() { - AnsiConsole.MarkupLine($":{emoji}: {$"{title}:",-25} [turquoise2 bold]{value}[/]"); - } + var services = new ServiceCollection(); - private static void ShowCompletion(string title) - { - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"[bold green]{title} completed![/] :party_popper:"); - } + services.AddSingleton(); + services.AddSingleton(); + RegisterCommands(services); - private static void ClearPreviousLine() - { - AnsiConsole.Cursor.MoveUp(); - Console.WriteLine(' '.Repeat(Console.WindowWidth)); - AnsiConsole.Cursor.MoveUp(); + return services.BuildServiceProvider(); } - private static bool PromptForConfirmation(string question) + private static void RegisterCommands(IServiceCollection services) { - return AnsiConsole.Confirm(question); + // 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", (_, rootDirectory, _) => PushWithCurrentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithParentRootDirectory", (_, rootDirectory, _) => PushWithParentRootDirectory(rootDirectory))); + services.AddSingleton(new DelegateCommand("PushWithChosenRootDirectory", (_, _, _) => PushWithChosenRootDirectory())); } - private static string PromptForInput(string question, string defaultValue = null) + internal static void PrintInfo() { - return AnsiConsole.Prompt( - new TextPrompt(question) - .DefaultValue(defaultValue)); + Host.Information($"Fallout Global Tool 🌐 {typeof(Program).Assembly.GetInformationalText()}"); } - private static string PromptForSecret(string title, int? minLength = null) + private static AbsolutePath TryGetRootDirectory() { - Assert.False(title.EndsWith(':')); + // TODO: copied in FalloutBuild.GetRootDirectory + AbsolutePath parameterValue = EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName); + if (parameterValue != null) + return parameterValue; - return AnsiConsole.Prompt( - new TextPrompt($"{title}:") - .Secret() - .Validate(x => minLength == null || x.Length >= minLength, - message: $"Secret must be at least {minLength} characters long")); - } + if (EnvironmentInfo.GetNamedArgument(Constants.RootDirectoryParameterName)) + return EnvironmentInfo.WorkingDirectory; - 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; + return Constants.TryGetRootDirectoryFrom(Directory.GetCurrentDirectory()); } - 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..99b383b9b --- /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 +/// . +/// +internal 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..8013f2105 --- /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. +/// +internal 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.Specs/CommandDispatcherSpecs.cs b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs new file mode 100644 index 000000000..7bb97405c --- /dev/null +++ b/tests/Fallout.Cli.Specs/CommandDispatcherSpecs.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Fallout.Cli.Commands; +using Fallout.Cli.Prompts; +using Fallout.Common.IO; +using FluentAssertions; +using Xunit; + +namespace Fallout.Cli.Specs; + +public class CommandDispatcherSpecs +{ + private static readonly AbsolutePath SomeRoot = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-dispatch-root"); + private static readonly AbsolutePath SomeScript = SomeRoot / "build.sh"; + + [Fact] + public async Task 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 = await dispatcher.DispatchAsync([":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 async Task Dispatch_MatchesNameCaseInsensitively(string token) + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { setup }, new FakePrompts()); + + await dispatcher.DispatchAsync([token], SomeRoot, SomeScript); + + setup.WasCalled.Should().BeTrue(); + } + + [Theory] + [InlineData(":add-package")] + [InlineData(":addpackage")] + [InlineData(":ADD-PACKAGE")] + public async Task 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()); + + await dispatcher.DispatchAsync([token], SomeRoot, SomeScript); + + addPackage.WasCalled.Should().BeTrue(); + } + + [Fact] + public async Task Dispatch_ReturnsCommandExitCode() + { + var trigger = new RecordingCommand("trigger", exitCode: 42); + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { trigger }, new FakePrompts()); + + (await dispatcher.DispatchAsync([":trigger"], SomeRoot, SomeScript)).Should().Be(42); + } + + [Fact] + public async Task Dispatch_UnknownCommand_ThrowsWithAvailableCommandListing() + { + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), new RecordingCommand("setup") }, + new FakePrompts()); + + var action = () => dispatcher.DispatchAsync([":bogus"], SomeRoot, SomeScript); + + (await action.Should().ThrowAsync() + .WithMessage("*'bogus' is not supported*")) + .And.Message.Should().ContainAll("- run", "- setup"); + } + + [Fact] + public async Task Dispatch_EmptyCommandToken_Fails() + { + var dispatcher = new CommandDispatcher(new IFalloutCommand[] { new RecordingCommand("run") }, new FakePrompts()); + + var action = () => dispatcher.DispatchAsync([":"], SomeRoot, SomeScript); + + await action.Should().ThrowAsync().WithMessage("*No command specified*"); + } + + [Fact] + public async Task Dispatch_NoCommand_NullRoot_Confirmed_InvokesSetup() + { + var setup = new RecordingCommand("setup"); + var dispatcher = new CommandDispatcher( + new IFalloutCommand[] { new RecordingCommand("run"), setup }, + new FakePrompts(confirm: true)); + + await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); + + setup.WasCalled.Should().BeTrue(); + setup.ReceivedArgs.Should().BeEmpty(); + setup.ReceivedRoot.Should().BeNull(); + } + + [Fact] + public async Task 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 = await dispatcher.DispatchAsync(["whatever"], rootDirectory: null, buildScript: null); + + exitCode.Should().Be(0); + setup.WasCalled.Should().BeFalse(); + } + + [Fact] + public async Task 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()); + + await dispatcher.DispatchAsync(["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; + this.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 Task ExecuteAsync(string[] args, AbsolutePath rootDirectory, AbsolutePath buildScript) + { + WasCalled = true; + ReceivedArgs = args; + ReceivedRoot = rootDirectory; + ReceivedBuildScript = buildScript; + return Task.FromResult(exitCode); + } + } + + private sealed class FakePrompts : IConsolePrompts + { + private readonly bool confirm; + + public FakePrompts(bool confirm = false) => this.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); + } + } + } +}