From 7b8ff896586df5e54b3aa0e1179b68961c302caa Mon Sep 17 00:00:00 2001 From: Jacob Chwastek Date: Fri, 1 May 2026 02:55:57 +0200 Subject: [PATCH] Extract reusable WiSave console framework --- .github/workflows/pr-preview-packages.yml | 47 ++++++++ .github/workflows/publish-packages.yml | 70 ++++++++++++ .gitignore | 10 ++ Directory.Build.props | 11 ++ NuGet.config | 8 ++ README.md | 22 ++++ WiSave.Console.slnx | 8 ++ .../Execution/CommandCatalog.cs | 25 ++++ .../Execution/CommandDescriptor.cs | 6 + .../Execution/CommandExecutionContext.cs | 11 ++ .../Execution/CommandInvocation.cs | 14 +++ .../Execution/CommandLineParseResult.cs | 23 ++++ .../Execution/CommandLineParser.cs | 50 ++++++++ .../Execution/CommandParameter.cs | 7 ++ .../Execution/CommandPrompter.cs | 76 ++++++++++++ src/WiSave.Console/Execution/CommandResult.cs | 23 ++++ src/WiSave.Console/Execution/CommandRunner.cs | 108 ++++++++++++++++++ .../Execution/IConsoleCommand.cs | 12 ++ .../Infrastructure/ConsoleShellOptions.cs | 6 + .../ServiceCollectionExtensions.cs | 54 +++++++++ .../Shell/ConsoleApplication.cs | 48 ++++++++ src/WiSave.Console/Shell/ConsoleShell.cs | 93 +++++++++++++++ src/WiSave.Console/Shell/IConsoleOutput.cs | 9 ++ .../Shell/SystemConsoleOutput.cs | 12 ++ src/WiSave.Console/WiSave.Console.csproj | 26 +++++ .../CommandLineParserTests.cs | 55 +++++++++ .../CommandRunnerTests.cs | 76 ++++++++++++ .../ConsoleApplicationTests.cs | 47 ++++++++ .../WiSave.Console.Tests/ConsoleShellTests.cs | 69 +++++++++++ .../WiSave.Console.Tests/TestConsoleOutput.cs | 30 +++++ .../WiSave.Console.Tests.csproj | 28 +++++ 31 files changed, 1084 insertions(+) create mode 100644 .github/workflows/pr-preview-packages.yml create mode 100644 .github/workflows/publish-packages.yml create mode 100644 .gitignore create mode 100644 Directory.Build.props create mode 100644 NuGet.config create mode 100644 README.md create mode 100644 WiSave.Console.slnx create mode 100644 src/WiSave.Console/Execution/CommandCatalog.cs create mode 100644 src/WiSave.Console/Execution/CommandDescriptor.cs create mode 100644 src/WiSave.Console/Execution/CommandExecutionContext.cs create mode 100644 src/WiSave.Console/Execution/CommandInvocation.cs create mode 100644 src/WiSave.Console/Execution/CommandLineParseResult.cs create mode 100644 src/WiSave.Console/Execution/CommandLineParser.cs create mode 100644 src/WiSave.Console/Execution/CommandParameter.cs create mode 100644 src/WiSave.Console/Execution/CommandPrompter.cs create mode 100644 src/WiSave.Console/Execution/CommandResult.cs create mode 100644 src/WiSave.Console/Execution/CommandRunner.cs create mode 100644 src/WiSave.Console/Execution/IConsoleCommand.cs create mode 100644 src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs create mode 100644 src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs create mode 100644 src/WiSave.Console/Shell/ConsoleApplication.cs create mode 100644 src/WiSave.Console/Shell/ConsoleShell.cs create mode 100644 src/WiSave.Console/Shell/IConsoleOutput.cs create mode 100644 src/WiSave.Console/Shell/SystemConsoleOutput.cs create mode 100644 src/WiSave.Console/WiSave.Console.csproj create mode 100644 tests/WiSave.Console.Tests/CommandLineParserTests.cs create mode 100644 tests/WiSave.Console.Tests/CommandRunnerTests.cs create mode 100644 tests/WiSave.Console.Tests/ConsoleApplicationTests.cs create mode 100644 tests/WiSave.Console.Tests/ConsoleShellTests.cs create mode 100644 tests/WiSave.Console.Tests/TestConsoleOutput.cs create mode 100644 tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj diff --git a/.github/workflows/pr-preview-packages.yml b/.github/workflows/pr-preview-packages.yml new file mode 100644 index 0000000..dbac094 --- /dev/null +++ b/.github/workflows/pr-preview-packages.yml @@ -0,0 +1,47 @@ +name: Publish Preview NuGet Packages + +on: + pull_request: + branches: + - master + +env: + DOTNET_VERSION: '10.0.x' + PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json + +jobs: + publish-preview: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore WiSave.Console.slnx + + - name: Build + run: dotnet build WiSave.Console.slnx -c Release --no-restore + + - name: Test + run: dotnet test WiSave.Console.slnx -c Release --no-build + + - name: Pack preview packages + run: > + dotnet pack WiSave.Console.slnx + -c Release + --no-build + -o ./nupkg + -p:MinVerDefaultPreReleaseIdentifiers=preview.${{ github.event.pull_request.number }}.${{ github.run_number }} + + - name: Push preview packages + run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml new file mode 100644 index 0000000..9329cce --- /dev/null +++ b/.github/workflows/publish-packages.yml @@ -0,0 +1,70 @@ +name: Publish Stable NuGet Packages + +on: + push: + branches: + - master + workflow_dispatch: + +env: + DOTNET_VERSION: '10.0.x' + PACKAGE_SOURCE: https://nuget.pkg.github.com/WiSave/index.json + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + packages: write + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Ensure release tag for this commit + id: release_tag + run: | + git fetch --tags + existing_tag="$(git tag --points-at HEAD 'v*' | sort -V | tail -n 1)" + if [ -n "$existing_tag" ]; then + echo "tag=$existing_tag" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest_tag="$(git tag -l 'v*' | sort -V | tail -n 1)" + latest_version="${latest_tag#v}" + if [ -z "$latest_tag" ] || [ "${latest_version%%.*}" -lt 1 ]; then + tag="v1.0.0" + else + major="$(echo "$latest_version" | cut -d. -f1)" + minor="$(echo "$latest_version" | cut -d. -f2)" + patch="$(echo "$latest_version" | cut -d. -f3)" + tag="v${major}.${minor}.$((patch + 1))" + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag "$tag" + git push origin "$tag" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Restore + run: dotnet restore WiSave.Console.slnx + + - name: Build + run: dotnet build WiSave.Console.slnx -c Release --no-restore + + - name: Test + run: dotnet test WiSave.Console.slnx -c Release --no-build + + - name: Pack stable packages + run: dotnet pack WiSave.Console.slnx -c Release --no-build -o ./nupkg + + - name: Push stable packages + run: dotnet nuget push ./nupkg/*.nupkg --source "${{ env.PACKAGE_SOURCE }}" --api-key ${{ secrets.GITHUB_TOKEN }} --skip-duplicate diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11eaaa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +TestResults/ +.vs/ +.idea/ +*.user +*.suo +*.nupkg +*.snupkg +nupkg/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..8a97a6b --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + true + true + v + 1.0 + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..36e9a72 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..eea7514 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# WiSave.Console + +Reusable console command infrastructure for WiSave services. + +The package provides: + +- command contracts and parameter metadata +- command-line parsing +- command catalog and runner +- interactive shell hosting +- console output abstraction for tests +- dependency injection registration helpers + +## Usage + +```csharp +services.AddWiSaveConsole( + options => options.Title = "WiSave Expenses Console", + typeof(Program).Assembly); +``` + +Commands implement `IConsoleCommand` and are discovered from the assemblies passed to `AddWiSaveConsole`. diff --git a/WiSave.Console.slnx b/WiSave.Console.slnx new file mode 100644 index 0000000..ad274b1 --- /dev/null +++ b/WiSave.Console.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/WiSave.Console/Execution/CommandCatalog.cs b/src/WiSave.Console/Execution/CommandCatalog.cs new file mode 100644 index 0000000..4fe848e --- /dev/null +++ b/src/WiSave.Console/Execution/CommandCatalog.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace WiSave.Console.Execution; + +public interface ICommandCatalog +{ + IReadOnlyList List(); + CommandDescriptor? Find(string name); +} + +public sealed class CommandCatalog(IServiceScopeFactory scopeFactory) : ICommandCatalog +{ + public IReadOnlyList List() + { + using var scope = scopeFactory.CreateScope(); + + return scope.ServiceProvider.GetServices() + .Select(command => new CommandDescriptor(command.Name, command.Description, command.ParameterDefinitions)) + .OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public CommandDescriptor? Find(string name) + => List().FirstOrDefault(command => command.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); +} diff --git a/src/WiSave.Console/Execution/CommandDescriptor.cs b/src/WiSave.Console/Execution/CommandDescriptor.cs new file mode 100644 index 0000000..fa3097f --- /dev/null +++ b/src/WiSave.Console/Execution/CommandDescriptor.cs @@ -0,0 +1,6 @@ +namespace WiSave.Console.Execution; + +public sealed record CommandDescriptor( + string Name, + string Description, + IReadOnlyList Parameters); diff --git a/src/WiSave.Console/Execution/CommandExecutionContext.cs b/src/WiSave.Console/Execution/CommandExecutionContext.cs new file mode 100644 index 0000000..7529149 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandExecutionContext.cs @@ -0,0 +1,11 @@ +namespace WiSave.Console.Execution; + +public sealed class CommandExecutionContext(IReadOnlyDictionary arguments, bool allowPrompting = false) +{ + public IReadOnlyDictionary Arguments { get; } = new Dictionary(arguments, StringComparer.OrdinalIgnoreCase); + + public bool AllowPrompting { get; } = allowPrompting; + + public string? GetArgument(string name) + => Arguments.GetValueOrDefault(name); +} diff --git a/src/WiSave.Console/Execution/CommandInvocation.cs b/src/WiSave.Console/Execution/CommandInvocation.cs new file mode 100644 index 0000000..f693939 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandInvocation.cs @@ -0,0 +1,14 @@ +namespace WiSave.Console.Execution; + +public sealed class CommandInvocation +{ + public CommandInvocation(string commandName, IReadOnlyDictionary arguments) + { + CommandName = commandName; + Arguments = new Dictionary(arguments, StringComparer.OrdinalIgnoreCase); + } + + public string CommandName { get; } + + public IReadOnlyDictionary Arguments { get; } +} diff --git a/src/WiSave.Console/Execution/CommandLineParseResult.cs b/src/WiSave.Console/Execution/CommandLineParseResult.cs new file mode 100644 index 0000000..bb06018 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandLineParseResult.cs @@ -0,0 +1,23 @@ +namespace WiSave.Console.Execution; + +public sealed class CommandLineParseResult +{ + private CommandLineParseResult(bool isInteractive, CommandInvocation? invocation, string? errorMessage) + { + IsInteractive = isInteractive; + Invocation = invocation; + ErrorMessage = errorMessage; + } + + public bool IsInteractive { get; } + + public CommandInvocation? Invocation { get; } + + public string? ErrorMessage { get; } + + public static CommandLineParseResult Interactive() => new(true, null, null); + + public static CommandLineParseResult Success(CommandInvocation invocation) => new(false, invocation, null); + + public static CommandLineParseResult Failure(string errorMessage) => new(false, null, errorMessage); +} diff --git a/src/WiSave.Console/Execution/CommandLineParser.cs b/src/WiSave.Console/Execution/CommandLineParser.cs new file mode 100644 index 0000000..fdb71af --- /dev/null +++ b/src/WiSave.Console/Execution/CommandLineParser.cs @@ -0,0 +1,50 @@ +namespace WiSave.Console.Execution; + +public interface ICommandLineParser +{ + CommandLineParseResult Parse(string[] args); +} + +public sealed class CommandLineParser : ICommandLineParser +{ + public CommandLineParseResult Parse(string[] args) + { + if (args.Length == 0) + { + return CommandLineParseResult.Interactive(); + } + + var commandName = args[0].Trim(); + if (string.IsNullOrWhiteSpace(commandName)) + { + return CommandLineParseResult.Failure("Command name cannot be empty."); + } + + var arguments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var index = 1; index < args.Length; index++) + { + var token = args[index]; + if (!token.StartsWith("--", StringComparison.Ordinal)) + { + return CommandLineParseResult.Failure($"Unexpected argument '{token}'. Expected '--name value'."); + } + + var parameterName = token[2..].Trim(); + if (string.IsNullOrWhiteSpace(parameterName)) + { + return CommandLineParseResult.Failure("Parameter name after '--' cannot be empty."); + } + + string? parameterValue = "true"; + if (index + 1 < args.Length && !args[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + parameterValue = args[++index]; + } + + arguments[parameterName] = parameterValue; + } + + return CommandLineParseResult.Success(new CommandInvocation(commandName, arguments)); + } +} diff --git a/src/WiSave.Console/Execution/CommandParameter.cs b/src/WiSave.Console/Execution/CommandParameter.cs new file mode 100644 index 0000000..10c8c17 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandParameter.cs @@ -0,0 +1,7 @@ +namespace WiSave.Console.Execution; + +public sealed record CommandParameter( + string Name, + string Description, + bool Required, + string? DefaultValue = null); diff --git a/src/WiSave.Console/Execution/CommandPrompter.cs b/src/WiSave.Console/Execution/CommandPrompter.cs new file mode 100644 index 0000000..8fd75a2 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandPrompter.cs @@ -0,0 +1,76 @@ +using WiSave.Console.Shell; + +namespace WiSave.Console.Execution; + +public interface ICommandPrompter +{ + Task> PromptForMissingArgumentsAsync( + CommandDescriptor descriptor, + IReadOnlyDictionary existingArguments, + CancellationToken ct); +} + +public sealed class CommandPrompter(IConsoleOutput consoleOutput) : ICommandPrompter +{ + public Task> PromptForMissingArgumentsAsync( + CommandDescriptor descriptor, + IReadOnlyDictionary existingArguments, + CancellationToken ct) + { + var arguments = new Dictionary(existingArguments, StringComparer.OrdinalIgnoreCase); + + foreach (var parameter in descriptor.Parameters) + { + if (arguments.TryGetValue(parameter.Name, out var currentValue) && !string.IsNullOrWhiteSpace(currentValue)) + { + continue; + } + + while (true) + { + ct.ThrowIfCancellationRequested(); + + var label = $"Enter {parameter.Name}"; + if (!string.IsNullOrWhiteSpace(parameter.Description)) + { + label += $" ({parameter.Description})"; + } + + if (!string.IsNullOrWhiteSpace(parameter.DefaultValue)) + { + label += $" [{parameter.DefaultValue}]"; + } + else if (!parameter.Required) + { + label += " [optional]"; + } + + label += ": "; + consoleOutput.Write(label); + + var input = consoleOutput.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(input)) + { + if (!string.IsNullOrWhiteSpace(parameter.DefaultValue)) + { + arguments[parameter.Name] = parameter.DefaultValue; + break; + } + + if (!parameter.Required) + { + break; + } + + consoleOutput.WriteLine($"Parameter '{parameter.Name}' is required."); + continue; + } + + arguments[parameter.Name] = input; + break; + } + } + + return Task.FromResult(arguments); + } +} diff --git a/src/WiSave.Console/Execution/CommandResult.cs b/src/WiSave.Console/Execution/CommandResult.cs new file mode 100644 index 0000000..c80d716 --- /dev/null +++ b/src/WiSave.Console/Execution/CommandResult.cs @@ -0,0 +1,23 @@ +namespace WiSave.Console.Execution; + +public sealed class CommandResult +{ + private CommandResult(bool success, string message, IReadOnlyList? details = null) + { + Success = success; + Message = message; + Details = details ?? []; + } + + public bool Success { get; } + + public string Message { get; } + + public IReadOnlyList Details { get; } + + public static CommandResult SuccessResult(string message, IReadOnlyList? details = null) + => new(true, message, details); + + public static CommandResult FailureResult(string message, IReadOnlyList? details = null) + => new(false, message, details); +} diff --git a/src/WiSave.Console/Execution/CommandRunner.cs b/src/WiSave.Console/Execution/CommandRunner.cs new file mode 100644 index 0000000..465adae --- /dev/null +++ b/src/WiSave.Console/Execution/CommandRunner.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.DependencyInjection; +using WiSave.Console.Shell; + +namespace WiSave.Console.Execution; + +public interface ICommandRunner +{ + Task RunAsync(CommandInvocation invocation, bool allowPrompting, CancellationToken ct); +} + +public sealed class CommandRunner( + IServiceScopeFactory scopeFactory, + ICommandCatalog commandCatalog, + ICommandPrompter commandPrompter, + IConsoleOutput consoleOutput) : ICommandRunner +{ + public async Task RunAsync(CommandInvocation invocation, bool allowPrompting, CancellationToken ct) + { + var descriptor = commandCatalog.Find(invocation.CommandName); + if (descriptor is null) + { + consoleOutput.WriteLine($"Unknown command '{invocation.CommandName}'."); + PrintAvailableCommands(); + return 1; + } + + var arguments = new Dictionary(invocation.Arguments, StringComparer.OrdinalIgnoreCase); + if (arguments.ContainsKey("help")) + { + PrintUsage(descriptor); + return 0; + } + + if (allowPrompting) + { + arguments = await commandPrompter.PromptForMissingArgumentsAsync(descriptor, arguments, ct); + } + + var missingRequiredParameters = descriptor.Parameters + .Where(parameter => + parameter.Required && + (!arguments.TryGetValue(parameter.Name, out var value) || string.IsNullOrWhiteSpace(value))) + .Select(parameter => parameter.Name) + .ToArray(); + + if (missingRequiredParameters.Length > 0) + { + consoleOutput.WriteLine("Missing required parameters:"); + foreach (var parameterName in missingRequiredParameters) + { + consoleOutput.WriteLine($" --{parameterName}"); + } + + consoleOutput.WriteLine(string.Empty); + PrintUsage(descriptor); + return 1; + } + + using var scope = scopeFactory.CreateScope(); + var command = scope.ServiceProvider.GetServices() + .FirstOrDefault(candidate => candidate.Name.Equals(descriptor.Name, StringComparison.OrdinalIgnoreCase)); + + if (command is null) + { + consoleOutput.WriteLine($"Command '{descriptor.Name}' is registered in the catalog but could not be resolved."); + return 1; + } + + try + { + var result = await command.ExecuteAsync(new CommandExecutionContext(arguments, allowPrompting), ct); + PrintResult(result); + return result.Success ? 0 : 1; + } + catch (Exception ex) + { + consoleOutput.WriteLine($"Command '{descriptor.Name}' failed: {ex.Message}"); + return 1; + } + } + + private void PrintAvailableCommands() + { + foreach (var command in commandCatalog.List().OrderBy(command => command.Name, StringComparer.OrdinalIgnoreCase)) + { + consoleOutput.WriteLine($" {command.Name} - {command.Description}"); + } + } + + private void PrintUsage(CommandDescriptor descriptor) + { + var usage = descriptor.Parameters.Count == 0 + ? descriptor.Name + : $"{descriptor.Name} {string.Join(" ", descriptor.Parameters.Select(parameter => $"[--{parameter.Name} ]"))}"; + + consoleOutput.WriteLine($"Usage: {usage}"); + } + + private void PrintResult(CommandResult result) + { + consoleOutput.WriteLine(result.Success ? $"OK: {result.Message}" : $"ERROR: {result.Message}"); + + foreach (var detail in result.Details) + { + consoleOutput.WriteLine($" {detail}"); + } + } +} diff --git a/src/WiSave.Console/Execution/IConsoleCommand.cs b/src/WiSave.Console/Execution/IConsoleCommand.cs new file mode 100644 index 0000000..012ef88 --- /dev/null +++ b/src/WiSave.Console/Execution/IConsoleCommand.cs @@ -0,0 +1,12 @@ +namespace WiSave.Console.Execution; + +public interface IConsoleCommand +{ + string Name { get; } + + string Description { get; } + + IReadOnlyList ParameterDefinitions { get; } + + Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct); +} diff --git a/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs b/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs new file mode 100644 index 0000000..752b4c1 --- /dev/null +++ b/src/WiSave.Console/Infrastructure/ConsoleShellOptions.cs @@ -0,0 +1,6 @@ +namespace WiSave.Console.Infrastructure; + +public sealed class ConsoleShellOptions +{ + public string Title { get; set; } = "WiSave Console"; +} diff --git a/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs b/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..da6b131 --- /dev/null +++ b/src/WiSave.Console/Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using WiSave.Console.Execution; +using WiSave.Console.Shell; + +namespace WiSave.Console.Infrastructure; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddWiSaveConsole( + this IServiceCollection services, + Action? configure = null, + params Assembly[] commandAssemblies) + { + if (configure is null) + { + services.Configure(_ => { }); + } + else + { + services.Configure(configure); + } + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + foreach (var assembly in commandAssemblies) + { + RegisterCommands(services, assembly); + } + + return services; + } + + private static void RegisterCommands(IServiceCollection services, Assembly assembly) + { + var commandTypes = assembly.GetTypes() + .Where(type => + !type.IsAbstract && + !type.IsInterface && + typeof(IConsoleCommand).IsAssignableFrom(type)) + .ToArray(); + + foreach (var commandType in commandTypes) + { + services.AddTransient(typeof(IConsoleCommand), commandType); + } + } +} diff --git a/src/WiSave.Console/Shell/ConsoleApplication.cs b/src/WiSave.Console/Shell/ConsoleApplication.cs new file mode 100644 index 0000000..c0c2c27 --- /dev/null +++ b/src/WiSave.Console/Shell/ConsoleApplication.cs @@ -0,0 +1,48 @@ +using WiSave.Console.Execution; + +namespace WiSave.Console.Shell; + +public interface IConsoleApplication +{ + Task RunAsync(string[] args, CancellationToken ct); +} + +public sealed class ConsoleApplication( + ICommandLineParser commandLineParser, + ICommandRunner commandRunner, + ICommandCatalog commandCatalog, + IConsoleShell consoleShell, + IConsoleOutput consoleOutput) : IConsoleApplication +{ + public async Task RunAsync(string[] args, CancellationToken ct) + { + if (args.Length == 1 && + (args[0].Equals("help", StringComparison.OrdinalIgnoreCase) || + args[0].Equals("--help", StringComparison.OrdinalIgnoreCase))) + { + consoleOutput.WriteLine("Available commands:"); + foreach (var command in commandCatalog.List()) + { + consoleOutput.WriteLine($" {command.Name} - {command.Description}"); + } + + consoleOutput.WriteLine(string.Empty); + consoleOutput.WriteLine("Run without arguments to start the interactive shell."); + return 0; + } + + var parseResult = commandLineParser.Parse(args); + if (parseResult.IsInteractive) + { + return await consoleShell.RunAsync(ct); + } + + if (parseResult.Invocation is null) + { + consoleOutput.WriteLine(parseResult.ErrorMessage ?? "Failed to parse command line arguments."); + return 1; + } + + return await commandRunner.RunAsync(parseResult.Invocation, allowPrompting: false, ct); + } +} diff --git a/src/WiSave.Console/Shell/ConsoleShell.cs b/src/WiSave.Console/Shell/ConsoleShell.cs new file mode 100644 index 0000000..7139f6e --- /dev/null +++ b/src/WiSave.Console/Shell/ConsoleShell.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Options; +using WiSave.Console.Execution; +using WiSave.Console.Infrastructure; + +namespace WiSave.Console.Shell; + +public interface IConsoleShell +{ + Task RunAsync(CancellationToken ct); +} + +public sealed class ConsoleShell( + ICommandCatalog commandCatalog, + ICommandRunner commandRunner, + IConsoleOutput consoleOutput, + IOptions options) : IConsoleShell +{ + public async Task RunAsync(CancellationToken ct) + { + while (true) + { + ct.ThrowIfCancellationRequested(); + + var commands = commandCatalog.List(); + RenderMenu(commands); + + consoleOutput.Write("Choose a command by number or name: "); + var input = consoleOutput.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(input)) + { + consoleOutput.WriteLine("Enter a command, or type 'exit' to close the shell."); + consoleOutput.WriteLine(string.Empty); + continue; + } + + if (input.Equals("exit", StringComparison.OrdinalIgnoreCase) || + input.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + + if (input.Equals("clear", StringComparison.OrdinalIgnoreCase)) + { + consoleOutput.Clear(); + continue; + } + + if (input.Equals("help", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var descriptor = ResolveCommand(commands, input); + if (descriptor is null) + { + consoleOutput.WriteLine($"Unknown selection '{input}'."); + consoleOutput.WriteLine(string.Empty); + continue; + } + + consoleOutput.WriteLine(string.Empty); + await commandRunner.RunAsync(new CommandInvocation(descriptor.Name, new Dictionary()), allowPrompting: true, ct); + consoleOutput.WriteLine(string.Empty); + } + } + + private void RenderMenu(IReadOnlyList commands) + { + consoleOutput.WriteLine(options.Value.Title); + consoleOutput.WriteLine(new string('=', options.Value.Title.Length)); + + for (var index = 0; index < commands.Count; index++) + { + var command = commands[index]; + consoleOutput.WriteLine($"{index + 1}. {command.Name} - {command.Description}"); + } + + consoleOutput.WriteLine(string.Empty); + consoleOutput.WriteLine("Built-ins: help, clear, exit"); + consoleOutput.WriteLine(string.Empty); + } + + private static CommandDescriptor? ResolveCommand(IReadOnlyList commands, string input) + { + if (int.TryParse(input, out var index) && index >= 1 && index <= commands.Count) + { + return commands[index - 1]; + } + + return commands.FirstOrDefault(command => command.Name.Equals(input, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/WiSave.Console/Shell/IConsoleOutput.cs b/src/WiSave.Console/Shell/IConsoleOutput.cs new file mode 100644 index 0000000..6e12ee6 --- /dev/null +++ b/src/WiSave.Console/Shell/IConsoleOutput.cs @@ -0,0 +1,9 @@ +namespace WiSave.Console.Shell; + +public interface IConsoleOutput +{ + void Write(string value); + void WriteLine(string? value); + string? ReadLine(); + void Clear(); +} diff --git a/src/WiSave.Console/Shell/SystemConsoleOutput.cs b/src/WiSave.Console/Shell/SystemConsoleOutput.cs new file mode 100644 index 0000000..a0d9a06 --- /dev/null +++ b/src/WiSave.Console/Shell/SystemConsoleOutput.cs @@ -0,0 +1,12 @@ +namespace WiSave.Console.Shell; + +public sealed class SystemConsoleOutput : IConsoleOutput +{ + public void Write(string value) => global::System.Console.Write(value); + + public void WriteLine(string? value) => global::System.Console.WriteLine(value); + + public string? ReadLine() => global::System.Console.ReadLine(); + + public void Clear() => global::System.Console.Clear(); +} diff --git a/src/WiSave.Console/WiSave.Console.csproj b/src/WiSave.Console/WiSave.Console.csproj new file mode 100644 index 0000000..50a088b --- /dev/null +++ b/src/WiSave.Console/WiSave.Console.csproj @@ -0,0 +1,26 @@ + + + + WiSave.Console + Reusable console command infrastructure for WiSave services. + JacobChwastek + MIT + README.md + https://github.com/WiSave/wisave-console + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/WiSave.Console.Tests/CommandLineParserTests.cs b/tests/WiSave.Console.Tests/CommandLineParserTests.cs new file mode 100644 index 0000000..6b41ec5 --- /dev/null +++ b/tests/WiSave.Console.Tests/CommandLineParserTests.cs @@ -0,0 +1,55 @@ +using WiSave.Console.Execution; + +namespace WiSave.Console.Tests; + +public sealed class CommandLineParserTests +{ + [Fact] + public void Parse_returns_interactive_result_when_no_arguments_are_provided() + { + var sut = new CommandLineParser(); + + var result = sut.Parse([]); + + Assert.True(result.IsInteractive); + Assert.Null(result.Invocation); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public void Parse_returns_invocation_with_named_arguments() + { + var sut = new CommandLineParser(); + + var result = sut.Parse(["db-migrate", "--connection-string", "Host=localhost"]); + + Assert.False(result.IsInteractive); + Assert.NotNull(result.Invocation); + Assert.Equal("db-migrate", result.Invocation.CommandName); + Assert.Equal("Host=localhost", result.Invocation.Arguments["connection-string"]); + } + + [Fact] + public void Parse_treats_argument_without_value_as_boolean_true() + { + var sut = new CommandLineParser(); + + var result = sut.Parse(["db-migrate", "--dry-run"]); + + Assert.False(result.IsInteractive); + Assert.NotNull(result.Invocation); + Assert.Equal("true", result.Invocation.Arguments["dry-run"]); + } + + [Fact] + public void Parse_rejects_unexpected_positional_argument_after_command_name() + { + var sut = new CommandLineParser(); + + var result = sut.Parse(["db-migrate", "unexpected"]); + + Assert.False(result.IsInteractive); + Assert.Null(result.Invocation); + Assert.Equal("Unexpected argument 'unexpected'. Expected '--name value'.", result.ErrorMessage); + } +} diff --git a/tests/WiSave.Console.Tests/CommandRunnerTests.cs b/tests/WiSave.Console.Tests/CommandRunnerTests.cs new file mode 100644 index 0000000..2dabe73 --- /dev/null +++ b/tests/WiSave.Console.Tests/CommandRunnerTests.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.DependencyInjection; +using WiSave.Console.Execution; +using WiSave.Console.Shell; + +namespace WiSave.Console.Tests; + +public sealed class CommandRunnerTests +{ + [Fact] + public async Task RunAsync_reports_missing_required_parameters() + { + var output = new TestConsoleOutput(); + var services = CreateServices(output, new RequiredParameterCommand()); + var sut = services.GetRequiredService(); + + var exitCode = await sut.RunAsync(new CommandInvocation("required", new Dictionary()), false, CancellationToken.None); + + Assert.Equal(1, exitCode); + Assert.Contains("Missing required parameters:", output.Lines); + Assert.Contains(" --name", output.Lines); + } + + [Fact] + public async Task RunAsync_resolves_command_names_case_insensitively() + { + var output = new TestConsoleOutput(); + var command = new RequiredParameterCommand(); + var services = CreateServices(output, command); + var sut = services.GetRequiredService(); + + var exitCode = await sut.RunAsync( + new CommandInvocation("REQUIRED", new Dictionary { ["name"] = "value" }), + false, + CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.True(command.WasExecuted); + Assert.Contains("OK: ran", output.Lines); + } + + private static ServiceProvider CreateServices(IConsoleOutput output, params IConsoleCommand[] commands) + { + var services = new ServiceCollection(); + services.AddSingleton(output); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + foreach (var command in commands) + { + services.AddSingleton(command); + } + + return services.BuildServiceProvider(); + } + + private sealed class RequiredParameterCommand : IConsoleCommand + { + public bool WasExecuted { get; private set; } + + public string Name => "required"; + + public string Description => "Requires a name."; + + public IReadOnlyList ParameterDefinitions { get; } = + [ + new("name", "Name to use.", true) + ]; + + public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct) + { + WasExecuted = true; + return Task.FromResult(CommandResult.SuccessResult("ran")); + } + } +} diff --git a/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs b/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs new file mode 100644 index 0000000..53656eb --- /dev/null +++ b/tests/WiSave.Console.Tests/ConsoleApplicationTests.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using WiSave.Console.Execution; +using WiSave.Console.Infrastructure; +using WiSave.Console.Shell; + +namespace WiSave.Console.Tests; + +public sealed class ConsoleApplicationTests +{ + [Fact] + public async Task RunAsync_prints_available_commands_for_help() + { + var output = new TestConsoleOutput(); + var services = new ServiceCollection(); + services.AddSingleton(output); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Options.Create(new ConsoleShellOptions())); + await using var provider = services.BuildServiceProvider(); + var sut = provider.GetRequiredService(); + + var exitCode = await sut.RunAsync(["help"], CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.Contains("Available commands:", output.Lines); + Assert.Contains(" sample - Sample command.", output.Lines); + Assert.Contains("Run without arguments to start the interactive shell.", output.Lines); + } + + private sealed class SampleCommand : IConsoleCommand + { + public string Name => "sample"; + + public string Description => "Sample command."; + + public IReadOnlyList ParameterDefinitions => []; + + public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct) + => Task.FromResult(CommandResult.SuccessResult("sampled")); + } +} diff --git a/tests/WiSave.Console.Tests/ConsoleShellTests.cs b/tests/WiSave.Console.Tests/ConsoleShellTests.cs new file mode 100644 index 0000000..3f3f200 --- /dev/null +++ b/tests/WiSave.Console.Tests/ConsoleShellTests.cs @@ -0,0 +1,69 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using WiSave.Console.Execution; +using WiSave.Console.Infrastructure; +using WiSave.Console.Shell; + +namespace WiSave.Console.Tests; + +public sealed class ConsoleShellTests +{ + [Fact] + public async Task RunAsync_executes_command_selected_by_number_and_then_exits() + { + var output = new TestConsoleOutput("1", "exit"); + var command = new SampleCommand(); + var services = CreateServices(output, command); + var sut = services.GetRequiredService(); + + var exitCode = await sut.RunAsync(CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.True(command.WasExecuted); + Assert.Contains("Test Console", output.Lines); + } + + [Fact] + public async Task RunAsync_executes_command_selected_by_name_and_then_exits() + { + var output = new TestConsoleOutput("sample", "exit"); + var command = new SampleCommand(); + var services = CreateServices(output, command); + var sut = services.GetRequiredService(); + + var exitCode = await sut.RunAsync(CancellationToken.None); + + Assert.Equal(0, exitCode); + Assert.True(command.WasExecuted); + } + + private static ServiceProvider CreateServices(IConsoleOutput output, IConsoleCommand command) + { + var services = new ServiceCollection(); + services.AddSingleton(output); + services.AddSingleton(command); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(Options.Create(new ConsoleShellOptions { Title = "Test Console" })); + return services.BuildServiceProvider(); + } + + private sealed class SampleCommand : IConsoleCommand + { + public bool WasExecuted { get; private set; } + + public string Name => "sample"; + + public string Description => "Sample command."; + + public IReadOnlyList ParameterDefinitions => []; + + public Task ExecuteAsync(CommandExecutionContext context, CancellationToken ct) + { + WasExecuted = true; + return Task.FromResult(CommandResult.SuccessResult("sampled")); + } + } +} diff --git a/tests/WiSave.Console.Tests/TestConsoleOutput.cs b/tests/WiSave.Console.Tests/TestConsoleOutput.cs new file mode 100644 index 0000000..c5240cb --- /dev/null +++ b/tests/WiSave.Console.Tests/TestConsoleOutput.cs @@ -0,0 +1,30 @@ +using WiSave.Console.Shell; + +namespace WiSave.Console.Tests; + +internal sealed class TestConsoleOutput(params string[] inputs) : IConsoleOutput +{ + private readonly Queue inputQueue = new(inputs); + private readonly List lines = []; + + public IReadOnlyList Lines => lines; + + public void Write(string value) + { + if (value.Length > 0) + { + lines.Add(value); + } + } + + public void WriteLine(string? value) + => lines.Add(value ?? string.Empty); + + public string? ReadLine() + => inputQueue.Count > 0 ? inputQueue.Dequeue() : null; + + public void Clear() + { + lines.Clear(); + } +} diff --git a/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj b/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj new file mode 100644 index 0000000..599a13b --- /dev/null +++ b/tests/WiSave.Console.Tests/WiSave.Console.Tests.csproj @@ -0,0 +1,28 @@ + + + + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + +