From 9f742f396f9f697658134b314bb190bf71a8f831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20St=C4=99pie=C5=84?= Date: Sat, 22 Aug 2026 19:39:17 +0200 Subject: [PATCH] feat: add console command framework --- .../Console/Application/CommandExecute.cs | 25 +++++++++++ .../Console/Application/CommandExecute.cs.uid | 1 + .../Application/Commands/ClearCommand.cs | 13 ++++++ .../Application/Commands/ClearCommand.cs.uid | 1 + .../Application/Commands/EchoCommand.cs | 13 ++++++ .../Application/Commands/EchoCommand.cs.uid | 1 + .../Application/Commands/HelpCommand.cs | 30 +++++++++++++ .../Application/Commands/HelpCommand.cs.uid | 1 + .../Console/Contracts/CommandArgSpec.cs | 11 +++++ .../Console/Contracts/CommandArgSpec.cs.uid | 1 + .../Console/Contracts/CommandArgType.cs | 10 +++++ .../Console/Contracts/CommandArgType.cs.uid | 1 + .../Console/Contracts/CommandArgValues.cs | 15 +++++++ .../Console/Contracts/CommandArgValues.cs.uid | 1 + .../Console/Contracts/CommandArgsBuilder.cs | 40 +++++++++++++++++ .../Contracts/CommandArgsBuilder.cs.uid | 1 + .../Console/Contracts/CommandResult.cs | 7 +++ .../Console/Contracts/CommandResult.cs.uid | 1 + src/Features/Console/Contracts/ICommand.cs | 11 +++++ .../Console/Contracts/ICommand.cs.uid | 1 + .../Console/Contracts/ICommandRegistry.cs | 10 +++++ .../Console/Contracts/ICommandRegistry.cs.uid | 1 + .../Console/Domain/CommandArgValidator.cs | 43 +++++++++++++++++++ .../Console/Domain/CommandArgValidator.cs.uid | 1 + .../Console/Domain/CommandLineParser.cs | 22 ++++++++++ .../Console/Domain/CommandLineParser.cs.uid | 1 + .../Console/Infrastructure/CommandRegistry.cs | 18 ++++++++ .../Infrastructure/CommandRegistry.cs.uid | 1 + 28 files changed, 282 insertions(+) create mode 100644 src/Features/Console/Application/CommandExecute.cs create mode 100644 src/Features/Console/Application/CommandExecute.cs.uid create mode 100644 src/Features/Console/Application/Commands/ClearCommand.cs create mode 100644 src/Features/Console/Application/Commands/ClearCommand.cs.uid create mode 100644 src/Features/Console/Application/Commands/EchoCommand.cs create mode 100644 src/Features/Console/Application/Commands/EchoCommand.cs.uid create mode 100644 src/Features/Console/Application/Commands/HelpCommand.cs create mode 100644 src/Features/Console/Application/Commands/HelpCommand.cs.uid create mode 100644 src/Features/Console/Contracts/CommandArgSpec.cs create mode 100644 src/Features/Console/Contracts/CommandArgSpec.cs.uid create mode 100644 src/Features/Console/Contracts/CommandArgType.cs create mode 100644 src/Features/Console/Contracts/CommandArgType.cs.uid create mode 100644 src/Features/Console/Contracts/CommandArgValues.cs create mode 100644 src/Features/Console/Contracts/CommandArgValues.cs.uid create mode 100644 src/Features/Console/Contracts/CommandArgsBuilder.cs create mode 100644 src/Features/Console/Contracts/CommandArgsBuilder.cs.uid create mode 100644 src/Features/Console/Contracts/CommandResult.cs create mode 100644 src/Features/Console/Contracts/CommandResult.cs.uid create mode 100644 src/Features/Console/Contracts/ICommand.cs create mode 100644 src/Features/Console/Contracts/ICommand.cs.uid create mode 100644 src/Features/Console/Contracts/ICommandRegistry.cs create mode 100644 src/Features/Console/Contracts/ICommandRegistry.cs.uid create mode 100644 src/Features/Console/Domain/CommandArgValidator.cs create mode 100644 src/Features/Console/Domain/CommandArgValidator.cs.uid create mode 100644 src/Features/Console/Domain/CommandLineParser.cs create mode 100644 src/Features/Console/Domain/CommandLineParser.cs.uid create mode 100644 src/Features/Console/Infrastructure/CommandRegistry.cs create mode 100644 src/Features/Console/Infrastructure/CommandRegistry.cs.uid diff --git a/src/Features/Console/Application/CommandExecute.cs b/src/Features/Console/Application/CommandExecute.cs new file mode 100644 index 0000000..bd48fe4 --- /dev/null +++ b/src/Features/Console/Application/CommandExecute.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; +using Game.Features.Console.Domain; + +namespace Game.Features.Console.Application; + +public sealed class CommandExecute(ICommandRegistry registry) +{ + public CommandResult Execute(string input) + { + if (!CommandLineParser.TryParse(input, out string name, out IReadOnlyList arguments)) + { + return CommandResult.Fail("No command entered."); + } + + if (!registry.TryGet(name, out ICommand command)) + { + return CommandResult.Fail($"Unknown command: {name}"); + } + + CommandResult result = CommandArgValidator.Validate(command.Args, arguments); + + return !result.Success ? result : command.Execute(new CommandArgValues(arguments)); + } +} diff --git a/src/Features/Console/Application/CommandExecute.cs.uid b/src/Features/Console/Application/CommandExecute.cs.uid new file mode 100644 index 0000000..e21bcb8 --- /dev/null +++ b/src/Features/Console/Application/CommandExecute.cs.uid @@ -0,0 +1 @@ +uid://f1ed8fhpbwik diff --git a/src/Features/Console/Application/Commands/ClearCommand.cs b/src/Features/Console/Application/Commands/ClearCommand.cs new file mode 100644 index 0000000..a2d065a --- /dev/null +++ b/src/Features/Console/Application/Commands/ClearCommand.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; + +namespace Game.Features.Console.Application.Commands; + +public sealed class ClearCommand : ICommand +{ + public string Name => "clear"; + public string Description => "Clears the console output."; + public IReadOnlyList Args { get; } = new CommandArgsBuilder().Build(); + + public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(clearOutput: true); +} diff --git a/src/Features/Console/Application/Commands/ClearCommand.cs.uid b/src/Features/Console/Application/Commands/ClearCommand.cs.uid new file mode 100644 index 0000000..db94d20 --- /dev/null +++ b/src/Features/Console/Application/Commands/ClearCommand.cs.uid @@ -0,0 +1 @@ +uid://cmmw8kn2fc3bb diff --git a/src/Features/Console/Application/Commands/EchoCommand.cs b/src/Features/Console/Application/Commands/EchoCommand.cs new file mode 100644 index 0000000..98b814f --- /dev/null +++ b/src/Features/Console/Application/Commands/EchoCommand.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; + +namespace Game.Features.Console.Application.Commands; + +public sealed class EchoCommand : ICommand +{ + public string Name => "echo"; + public string Description => "Prints the given text back to the console."; + public IReadOnlyList Args { get; } = new CommandArgsBuilder().AddStringArg("text").Build(); + + public CommandResult Execute(CommandArgValues args) => CommandResult.Ok(args.GetString(0)); +} diff --git a/src/Features/Console/Application/Commands/EchoCommand.cs.uid b/src/Features/Console/Application/Commands/EchoCommand.cs.uid new file mode 100644 index 0000000..5bee398 --- /dev/null +++ b/src/Features/Console/Application/Commands/EchoCommand.cs.uid @@ -0,0 +1 @@ +uid://cy6dkjf4fxkvj diff --git a/src/Features/Console/Application/Commands/HelpCommand.cs b/src/Features/Console/Application/Commands/HelpCommand.cs new file mode 100644 index 0000000..3bf9a24 --- /dev/null +++ b/src/Features/Console/Application/Commands/HelpCommand.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Game.Features.Console.Contracts; + +namespace Game.Features.Console.Application.Commands; + +public sealed class HelpCommand(ICommandRegistry registry) : ICommand +{ + public string Name => "help"; + public string Description => "Lists all available commands."; + public IReadOnlyList Args { get; } = new CommandArgsBuilder().Build(); + + private static string FormatUsage(ICommand command) + { + string args = string.Join(" ", command.Args.Select(spec => $"<{spec.Name}:{spec}>")); + return $"{command.Name} {args}"; + } + + public CommandResult Execute(CommandArgValues args) + { + IEnumerable lines = registry.All + .OrderBy(command => command.Name, StringComparer.Ordinal) + .Select(command => command.Args.Count == 0 + ? $"{command.Name} - {command.Description}" + : $"{command.Name} - {command.Description}\n usage: {FormatUsage(command)}"); + + return CommandResult.Ok($"List of available commands:\n{string.Join("\n", lines)}"); + } +} diff --git a/src/Features/Console/Application/Commands/HelpCommand.cs.uid b/src/Features/Console/Application/Commands/HelpCommand.cs.uid new file mode 100644 index 0000000..d5f1343 --- /dev/null +++ b/src/Features/Console/Application/Commands/HelpCommand.cs.uid @@ -0,0 +1 @@ +uid://bo7o0t08bb5u5 diff --git a/src/Features/Console/Contracts/CommandArgSpec.cs b/src/Features/Console/Contracts/CommandArgSpec.cs new file mode 100644 index 0000000..9d92b22 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgSpec.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Game.Features.Console.Contracts; + +public sealed record CommandArgSpec(string Name, CommandArgType Type, IReadOnlyList Choices = null) +{ + public override string ToString() => + Type == CommandArgType.Choice && Choices is not null + ? string.Join("|", Choices) + : Type.ToString().ToLowerInvariant(); +} diff --git a/src/Features/Console/Contracts/CommandArgSpec.cs.uid b/src/Features/Console/Contracts/CommandArgSpec.cs.uid new file mode 100644 index 0000000..4f0ab1f --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgSpec.cs.uid @@ -0,0 +1 @@ +uid://c3dojuv6775r1 diff --git a/src/Features/Console/Contracts/CommandArgType.cs b/src/Features/Console/Contracts/CommandArgType.cs new file mode 100644 index 0000000..a8229d9 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgType.cs @@ -0,0 +1,10 @@ +namespace Game.Features.Console.Contracts; + +public enum CommandArgType +{ + String, + Int, + Float, + Bool, + Choice +} diff --git a/src/Features/Console/Contracts/CommandArgType.cs.uid b/src/Features/Console/Contracts/CommandArgType.cs.uid new file mode 100644 index 0000000..41ef40b --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgType.cs.uid @@ -0,0 +1 @@ +uid://dnt7hwlwqtdyq diff --git a/src/Features/Console/Contracts/CommandArgValues.cs b/src/Features/Console/Contracts/CommandArgValues.cs new file mode 100644 index 0000000..bf0c434 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgValues.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Game.Features.Console.Contracts; + +public sealed class CommandArgValues(IReadOnlyList values) +{ + public string GetString(int index) => values[index]; + + public int GetInt(int index) => int.Parse(values[index], CultureInfo.InvariantCulture); + + public float GetFloat(int index) => float.Parse(values[index], CultureInfo.InvariantCulture); + + public bool GetBool(int index) => bool.Parse(values[index]); +} diff --git a/src/Features/Console/Contracts/CommandArgValues.cs.uid b/src/Features/Console/Contracts/CommandArgValues.cs.uid new file mode 100644 index 0000000..8cde729 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgValues.cs.uid @@ -0,0 +1 @@ +uid://carkvp7i86mq4 diff --git a/src/Features/Console/Contracts/CommandArgsBuilder.cs b/src/Features/Console/Contracts/CommandArgsBuilder.cs new file mode 100644 index 0000000..d7d1db9 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgsBuilder.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Game.Features.Console.Contracts; + +public sealed class CommandArgsBuilder +{ + private readonly List _specs = []; + + public CommandArgsBuilder AddStringArg(string name) + { + _specs.Add(new CommandArgSpec(name, CommandArgType.String)); + return this; + } + + public CommandArgsBuilder AddIntArg(string name) + { + _specs.Add(new CommandArgSpec(name, CommandArgType.Int)); + return this; + } + + public CommandArgsBuilder AddFloatArg(string name) + { + _specs.Add(new CommandArgSpec(name, CommandArgType.Float)); + return this; + } + + public CommandArgsBuilder AddBoolArg(string name) + { + _specs.Add(new CommandArgSpec(name, CommandArgType.Bool)); + return this; + } + + public CommandArgsBuilder AddChoiceArg(string name, IReadOnlyList choices) + { + _specs.Add(new CommandArgSpec(name, CommandArgType.Choice, choices)); + return this; + } + + public IReadOnlyList Build() => _specs.ToArray(); +} diff --git a/src/Features/Console/Contracts/CommandArgsBuilder.cs.uid b/src/Features/Console/Contracts/CommandArgsBuilder.cs.uid new file mode 100644 index 0000000..723d633 --- /dev/null +++ b/src/Features/Console/Contracts/CommandArgsBuilder.cs.uid @@ -0,0 +1 @@ +uid://cyviyspt0gye6 diff --git a/src/Features/Console/Contracts/CommandResult.cs b/src/Features/Console/Contracts/CommandResult.cs new file mode 100644 index 0000000..c8ea60c --- /dev/null +++ b/src/Features/Console/Contracts/CommandResult.cs @@ -0,0 +1,7 @@ +namespace Game.Features.Console.Contracts; + +public readonly record struct CommandResult(bool Success, string Message, bool ClearOutput = false) +{ + public static CommandResult Ok(string message = "", bool clearOutput = false) => new(true, message, clearOutput); + public static CommandResult Fail(string message) => new(false, message); +} diff --git a/src/Features/Console/Contracts/CommandResult.cs.uid b/src/Features/Console/Contracts/CommandResult.cs.uid new file mode 100644 index 0000000..51b898d --- /dev/null +++ b/src/Features/Console/Contracts/CommandResult.cs.uid @@ -0,0 +1 @@ +uid://bfvfjkitb1hq7 diff --git a/src/Features/Console/Contracts/ICommand.cs b/src/Features/Console/Contracts/ICommand.cs new file mode 100644 index 0000000..a06dec7 --- /dev/null +++ b/src/Features/Console/Contracts/ICommand.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Game.Features.Console.Contracts; + +public interface ICommand +{ + string Name { get; } + string Description { get; } + IReadOnlyList Args { get; } + CommandResult Execute(CommandArgValues args); +} diff --git a/src/Features/Console/Contracts/ICommand.cs.uid b/src/Features/Console/Contracts/ICommand.cs.uid new file mode 100644 index 0000000..d8e9feb --- /dev/null +++ b/src/Features/Console/Contracts/ICommand.cs.uid @@ -0,0 +1 @@ +uid://d18ye7rqu75ej diff --git a/src/Features/Console/Contracts/ICommandRegistry.cs b/src/Features/Console/Contracts/ICommandRegistry.cs new file mode 100644 index 0000000..41105c9 --- /dev/null +++ b/src/Features/Console/Contracts/ICommandRegistry.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Game.Features.Console.Contracts; + +public interface ICommandRegistry +{ + IReadOnlyCollection All { get; } + void Register(ICommand command); + bool TryGet(string name, out ICommand command); +} diff --git a/src/Features/Console/Contracts/ICommandRegistry.cs.uid b/src/Features/Console/Contracts/ICommandRegistry.cs.uid new file mode 100644 index 0000000..8df6e0c --- /dev/null +++ b/src/Features/Console/Contracts/ICommandRegistry.cs.uid @@ -0,0 +1 @@ +uid://bpw55xoj10toe diff --git a/src/Features/Console/Domain/CommandArgValidator.cs b/src/Features/Console/Domain/CommandArgValidator.cs new file mode 100644 index 0000000..c2e91cc --- /dev/null +++ b/src/Features/Console/Domain/CommandArgValidator.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Game.Features.Console.Contracts; + +namespace Game.Features.Console.Domain; + +public static class CommandArgValidator +{ + private static bool ChoicesContain(IReadOnlyList choices, string value) => + choices.Any(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)); + + private static bool MatchesSpec(string rawValue, CommandArgSpec spec) => spec.Type switch + { + CommandArgType.String => true, + CommandArgType.Int => int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out _), + CommandArgType.Float => float.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _), + CommandArgType.Bool => bool.TryParse(rawValue, out _), + CommandArgType.Choice => spec.Choices is not null && ChoicesContain(spec.Choices, rawValue), + _ => throw new ArgumentOutOfRangeException(nameof(spec), spec.Type, null) + }; + + public static CommandResult Validate(IReadOnlyList specs, IReadOnlyList arguments) + { + if (arguments.Count != specs.Count) + { + return CommandResult.Fail($"Expected {specs.Count} argument(s), got {arguments.Count}."); + } + + for (int i = 0; i < specs.Count; i++) + { + CommandArgSpec spec = specs[i]; + string rawValue = arguments[i]; + + if (MatchesSpec(rawValue, spec)) continue; + + return CommandResult.Fail($"Argument '{spec.Name}' expected {spec}, got '{rawValue}'."); + } + + return CommandResult.Ok(); + } +} diff --git a/src/Features/Console/Domain/CommandArgValidator.cs.uid b/src/Features/Console/Domain/CommandArgValidator.cs.uid new file mode 100644 index 0000000..f475f0b --- /dev/null +++ b/src/Features/Console/Domain/CommandArgValidator.cs.uid @@ -0,0 +1 @@ +uid://dtgsw88f1pejl diff --git a/src/Features/Console/Domain/CommandLineParser.cs b/src/Features/Console/Domain/CommandLineParser.cs new file mode 100644 index 0000000..0f38a08 --- /dev/null +++ b/src/Features/Console/Domain/CommandLineParser.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Game.Features.Console.Domain; + +public static class CommandLineParser +{ + public static bool TryParse(string input, out string name, out IReadOnlyList arguments) + { + string[] tokens = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) + { + name = string.Empty; + arguments = []; + return false; + } + + name = tokens[0]; + arguments = tokens[1..]; + return true; + } +} diff --git a/src/Features/Console/Domain/CommandLineParser.cs.uid b/src/Features/Console/Domain/CommandLineParser.cs.uid new file mode 100644 index 0000000..bf4c01a --- /dev/null +++ b/src/Features/Console/Domain/CommandLineParser.cs.uid @@ -0,0 +1 @@ +uid://brr37gdqsgb0m diff --git a/src/Features/Console/Infrastructure/CommandRegistry.cs b/src/Features/Console/Infrastructure/CommandRegistry.cs new file mode 100644 index 0000000..5fb09da --- /dev/null +++ b/src/Features/Console/Infrastructure/CommandRegistry.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using Game.Features.Console.Contracts; + +namespace Game.Features.Console.Infrastructure; + +public sealed class CommandRegistry : ICommandRegistry +{ + private readonly Dictionary _commands = new(); + + public IReadOnlyCollection All => _commands.Values; + + public void Register(ICommand command) + { + _commands[command.Name] = command; + } + + public bool TryGet(string name, out ICommand command) => _commands.TryGetValue(name, out command); +} diff --git a/src/Features/Console/Infrastructure/CommandRegistry.cs.uid b/src/Features/Console/Infrastructure/CommandRegistry.cs.uid new file mode 100644 index 0000000..1343852 --- /dev/null +++ b/src/Features/Console/Infrastructure/CommandRegistry.cs.uid @@ -0,0 +1 @@ +uid://cfcp8d8sh0gf5