diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ae29463 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,64 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2025-01-06 + +### Added + +#### Generic Type-Safe API +- **Generic Methods**: Added `AddOption()` and `AddArgument()` for type-safe command configuration +- **Typed Getters**: Added `GetOption()`, `GetArgument()`, and `GetOptionValues()` to `ParsedArguments` +- **Automatic Type Conversion**: Support for `string`, `int`, `double`, `bool`, and other primitive types +- **Type Display**: Help system now shows types (``, ``, etc.) for options and arguments + +#### Automatic Help System +- **Auto-Generated Help**: All commands now automatically support `--help` and `-h` without manual implementation +- **Metadata-Driven**: Help is generated from `Arguments` and `Options` definitions +- **Hierarchical Help**: Parent commands show child commands automatically +- **Opt-Out**: Added `DisableHelp` property and `WithoutHelp()` method for custom help handling + +#### New Classes and APIs +- **`ArgumentDefinition`**: Define positional arguments with type, description, and default values +- **`CommandBuilder`**: Fluent API for configuring commands with chainable methods +- **Enhanced `CommandDefinition`**: Added `Arguments` list and `DisableHelp` property + +### Changed + +#### Breaking Changes (Backward Compatible) +- `AddCommand()` now returns `CommandBuilder` instead of `CliHostBuilder` for fluent configuration +- Commands no longer need to manually handle `--help` unless `DisableHelp` is true + +#### Improvements +- **`ParsedArguments`**: Added robust type conversion with nullable type support +- **Help Display**: Improved formatting with proper alignment and type information +- **Recursive Command Display**: Better hierarchy visualization in global help + +### Enhanced +- **Boolean Options**: Automatically set `HasValue = false` for `bool` type options (flag behavior) +- **Default Values**: Display default values in help output +- **Required Indicators**: Show `(required)` for mandatory arguments and options + +### Documentation +- Added comprehensive documentation for automatic help system +- Added guide for typed arguments and options with generics +- Updated README with new API examples + +## [1.0.0] - 2025-01-XX + +### Added +- Initial release +- POSIX, GNU, and Windows-style argument parsing +- Command registry and routing +- Middleware pipeline support +- IHostBuilder integration +- Dependency injection support +- Validation middleware +- Hierarchical command support +- Command aliases + +[1.1.0]: https://github.com/monbsoft/CliCoreKit/compare/v1.0.0...v1.1.0 +[1.0.0]: https://github.com/monbsoft/CliCoreKit/releases/tag/v1.0.0 diff --git a/docs/AutomaticHelp.md b/docs/AutomaticHelp.md new file mode 100644 index 0000000..a235282 --- /dev/null +++ b/docs/AutomaticHelp.md @@ -0,0 +1,292 @@ +# Automatic Help System + +CliCoreKit includes a powerful automatic help generation system that eliminates the need to manually handle `--help` in your commands. + +## Overview + +By default, **all commands** automatically support `--help` and `-h` options. The help is generated from the command's metadata (arguments, options, description). + +### Key Features + +✅ **Automatic**: No code needed to handle `--help` +✅ **Type-aware**: Shows types in help (``, ``, etc.) +✅ **Default values**: Displays default values for options +✅ **Required indicators**: Shows which arguments/options are required +✅ **Hierarchical**: Works for parent and child commands +✅ **Opt-out**: Can disable auto-help if needed + +## Basic Usage + +### Before (Manual Help) + +```csharp +public class GreetCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken ct) + { + // ❌ Manual help handling - NO LONGER NEEDED! + if (context.Arguments.HasOption("help")) + { + Console.WriteLine("Usage: greet [options]"); + Console.WriteLine("Options:"); + Console.WriteLine(" --name Your name"); + return Task.FromResult(0); + } + + var name = context.Arguments.GetOption("name", "World"); + Console.WriteLine($"Hello, {name}!"); + return Task.FromResult(0); + } +} +``` + +### After (Automatic Help) + +```csharp +// Configuration +cli.AddCommand("greet", "Greets a person") + .AddOption("name", 'n', "Your name", defaultValue: "World"); + +// Command - Clean and focused! +public class GreetCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken ct) + { + // ✅ No help handling needed - just business logic! + var name = context.Arguments.GetOption("name", "World"); + Console.WriteLine($"Hello, {name}!"); + return Task.FromResult(0); + } +} +``` + +## Automatic Help Output + +When a user runs `greet --help`: + +``` +Usage: greet [options] + +Greets a person + +Options: + -n, --name Your name (default: World) + -h, --help Show this help message +``` + +## With Arguments + +```csharp +cli.AddCommand("deploy", "Deploy application") + .AddArgument("environment", "Target environment", required: true) + .AddArgument("version", "Version to deploy", required: false) + .AddOption("force", 'f', "Force deployment"); +``` + +Help output for `deploy --help`: + +``` +Usage: deploy [version] [options] + +Deploy application + +Arguments: + environment Target environment [string] (required) + version Version to deploy [string] + +Options: + -f, --force Force deployment + -h, --help Show this help message +``` + +## With Typed Options + +```csharp +cli.AddCommand("serve", "Start web server") + .AddOption("port", 'p', "Port number", defaultValue: 8080) + .AddOption("host", 'h', "Host address", defaultValue: "localhost") + .AddOption("watch", 'w', "Watch for changes"); +``` + +Help output for `serve --help`: + +``` +Usage: serve [options] + +Start web server + +Options: + -p, --port Port number (default: 8080) + -h, --host Host address (default: localhost) + -w, --watch Watch for changes + -h, --help Show this help message +``` + +## Hierarchical Commands + +Automatic help works for parent commands too: + +```csharp +cli.AddCommand("git", "Git operations") + .AddCommand("commit", "Commit changes") + .AddCommand("push", "Push to remote"); +``` + +Help output for `git --help`: + +``` +Usage: git [options] + +Git operations + +Commands: + commit Commit changes + push Push to remote + +Options: + -h, --help Show this help message + +Run 'git --help' for more information on a command. +``` + +## Disabling Auto-Help + +If you need custom help handling, disable auto-help: + +```csharp +cli.AddCommand("custom", "Command with fancy help") +.WithoutHelp(); // ← Disable automatic help + +public class CustomCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken ct) + { + // Now you handle --help yourself + if (context.Arguments.HasOption("help")) + { + Console.WriteLine("╔══════════════════════╗"); + Console.WriteLine("║ FANCY CUSTOM HELP ║"); + Console.WriteLine("╚══════════════════════╝"); + // Your custom help... + return Task.FromResult(0); + } + + // Command logic... + return Task.FromResult(0); + } +} +``` + +## Global Help + +Global help (no command specified) is also automatic: + +```bash +$ myapp --help +Usage: [command] [options] + +Available commands: + + greet Greets a person + add Adds two numbers + list List operations + files Lists files + +Options: + -h, --help Show this help message + +Run '[command] --help' for more information on a command. +``` + +## Best Practices + +### ✅ DO + +- **Define metadata**: Add descriptions to commands, options, and arguments +- **Use typed options**: Types are shown in help automatically +- **Set default values**: Users see defaults in help +- **Mark required**: Users know what's mandatory + +```csharp +cli.AddCommand("process", "Process data files") + .AddArgument("input", "Input file path", required: true) + .AddOption("output", 'o', "Output directory", defaultValue: "./output") + .AddOption("threads", 't', "Number of threads", defaultValue: 4) + .AddOption("verbose", 'v', "Verbose logging"); +``` + +### ❌ DON'T + +- **Don't handle --help manually** (unless you use `WithoutHelp()`) +- **Don't forget descriptions** (help becomes less useful) +- **Don't use generic descriptions** like "An option" + +## Migration Guide + +### Step 1: Remove Manual Help + +**Before:** +```csharp +public Task ExecuteAsync(CommandContext context, CancellationToken ct) +{ + if (context.Arguments.HasOption("help")) // ← Remove this + { + ShowHelp(); + return Task.FromResult(0); + } + // ... +} +``` + +**After:** +```csharp +public Task ExecuteAsync(CommandContext context, CancellationToken ct) +{ + // Just business logic - help is automatic! + // ... +} +``` + +### Step 2: Add Metadata + +```csharp +cli.AddCommand("mycommand", "Clear description") + .AddArgument("arg1", "What is this argument?", required: true) + .AddOption("opt1", 'o', "What does this option do?", defaultValue: 10); +``` + +### Step 3: Test + +```bash +$ myapp mycommand --help +``` + +Verify the help looks good and contains all the information users need. + +## Advanced: Conditional Help + +If you need to show help conditionally but still use auto-help: + +```csharp +public Task ExecuteAsync(CommandContext context, CancellationToken ct) +{ + if (context.Arguments.Positional.Count == 0) + { + Console.WriteLine("No input provided. Use --help for usage information."); + return Task.FromResult(1); + } + + // Normal execution... +} +``` + +## Summary + +- ✅ **All commands** have automatic help by default +- ✅ Help is generated from **Arguments** and **Options** metadata +- ✅ Shows **types**, **defaults**, **required** indicators +- ✅ Works for **hierarchical** commands +- ✅ Can opt-out with **`WithoutHelp()`** +- ✅ **Cleaner code** - no manual help handling + +The automatic help system makes your CLI more consistent, maintainable, and user-friendly! 🚀 diff --git a/docs/TypedArguments.md b/docs/TypedArguments.md new file mode 100644 index 0000000..16c3a8b --- /dev/null +++ b/docs/TypedArguments.md @@ -0,0 +1,238 @@ +# CliCoreKit - Typed Command-Line Arguments + +## Type-Safe API with Generics + +CliCoreKit now supports **strongly-typed arguments and options** using C# generics, making your CLI applications more robust and easier to maintain. + +### Defining Commands with Typed Options + +```csharp +builder.ConfigureCli(cli => +{ + cli.AddCommand("greet", "Greets a person") + .AddArgument("name", "The name to greet", defaultValue: "World") + .AddOption("greeting", 'g', "Custom greeting", defaultValue: "Hello") + .AddOption("formal", 'f', "Use formal greeting") + .AddOption("repeat", 'r', "Repeat count", defaultValue: 1); +}); +``` + +### Accessing Typed Values in Commands + +```csharp +public class GreetCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + // Type-safe access with IntelliSense support + var name = context.Arguments.GetArgument(0, "World"); + var greeting = context.Arguments.GetOption("greeting", "Hello"); + var formal = context.Arguments.GetOption("formal"); + var repeat = context.Arguments.GetOption("repeat", 1); + + var message = formal ? $"Good day, {name}!" : $"{greeting}, {name}!"; + + for (int i = 0; i < repeat; i++) + { + Console.WriteLine(message); + } + + return Task.FromResult(0); + } +} +``` + +## Supported Types + +CliCoreKit supports automatic conversion for: + +- **Primitive types**: `string`, `int`, `long`, `double`, `float`, `bool` +- **Nullable types**: `int?`, `double?`, etc. +- **Enums**: Automatic parsing with case-insensitive matching +- **Custom types**: Implementing `IConvertible` + +### Boolean Options + +Boolean options automatically set `HasValue = false`, making them work as flags: + +```csharp +cli.AddCommand("build") + .AddOption("watch", 'w', "Watch for changes") // Flag option + .AddOption("output", 'o', "Output directory"); // Value option +``` + +Usage: +```bash +app build -w -o ./dist +app build --watch --output ./dist +``` + +## Type-Safe API Methods + +### Getting Options + +```csharp +// Get with default value +var port = context.Arguments.GetOption("port", 8080); +var name = context.Arguments.GetOption("name", "default"); +var verbose = context.Arguments.GetOption("verbose"); // defaults to false + +// Try get with error handling +if (context.Arguments.TryGetValue("port", out var port)) +{ + Console.WriteLine($"Using port: {port}"); +} +else +{ + Console.Error.WriteLine("Invalid port number"); +} +``` + +### Getting Arguments (Positional) + +```csharp +// Get typed positional argument +var count = context.Arguments.GetArgument(0); +var name = context.Arguments.GetArgument(1, "default"); + +// With null safety +var value = context.Arguments.GetArgument(0); +if (value.HasValue) +{ + Console.WriteLine($"Value: {value.Value}"); +} +``` + +### Multiple Values + +```csharp +// Define option that accepts multiple values +cli.AddCommand("process") + .AddOption("file", 'f', "Files to process"); + +// Get all values +var files = context.Arguments.GetOptionValues("file"); +foreach (var file in files) +{ + Console.WriteLine($"Processing: {file}"); +} +``` + +Usage: +```bash +app process -f file1.txt -f file2.txt -f file3.txt +``` + +## Complete Examples + +### Temperature Converter + +```csharp +cli.AddCommand("convert", "Convert temperature") + .AddArgument("value", "Temperature value", required: true) + .AddOption("from", 'f', "Source unit (C/F/K)", required: true) + .AddOption("to", 't', "Target unit (C/F/K)", required: true); + +public class ConvertCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + var value = context.Arguments.GetArgument(0); + var from = context.Arguments.GetOption("from")!.ToUpper(); + var to = context.Arguments.GetOption("to")!.ToUpper(); + + // Conversion logic... + Console.WriteLine($"{value}°{from} = {result:F2}°{to}"); + return Task.FromResult(0); + } +} +``` + +Usage: +```bash +app convert 100 -f C -t F +# Output: 100°C = 212.00°F + +app convert 32 --from F --to C +# Output: 32°F = 0.00°C +``` + +### Calculator with Multiple Operations + +```csharp +cli.AddCommand("calc", "Perform calculations") + .AddArgument("a", "First number", required: true) + .AddArgument("b", "Second number", required: true) + .AddOption("operation", 'o', "Operation (+,-,*,/)", defaultValue: "+"); + +public class CalcCommand : ICommand +{ + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken) + { + var a = context.Arguments.GetArgument(0); + var b = context.Arguments.GetArgument(1); + var op = context.Arguments.GetOption("operation", "+"); + + var result = op switch + { + "+" => a + b, + "-" => a - b, + "*" => a * b, + "/" => b != 0 ? a / b : double.NaN, + _ => throw new ArgumentException("Invalid operation") + }; + + Console.WriteLine($"{a} {op} {b} = {result}"); + return Task.FromResult(0); + } +} +``` + +## Automatic Help Generation + +The help system automatically shows type information: + +```bash +$ app convert --help +Usage: convert [options] + +Convert temperature + +Arguments: + value Temperature value [double] (required) + +Options: + -f, --from Source unit (C/F/K) (required) + -t, --to Target unit (C/F/K) (required) + -h, --help Show this help message +``` + +## Migration from Non-Generic API + +**Before:** +```csharp +var name = context.Arguments.GetOptionValue("name") ?? "default"; +if (int.TryParse(context.Arguments.GetOptionValue("port"), out var port)) +{ + // use port +} +``` + +**After:** +```csharp +var name = context.Arguments.GetOption("name", "default"); +var port = context.Arguments.GetOption("port", 8080); +``` + +## Benefits + +✅ **Type Safety**: Catch type errors at compile time +✅ **IntelliSense**: Full IDE support with auto-completion +✅ **Less Boilerplate**: No manual parsing or conversion +✅ **Better Documentation**: Types shown in help +✅ **Default Values**: Type-safe defaults +✅ **Validation**: Automatic type validation + +## See Also + +- [Full Sample Application](../samples/CliCoreKit.Sample/Program.cs) diff --git a/samples/CliCoreKit.Sample/CliCoreKit.Sample.csproj b/samples/CliCoreKit.Sample/CliCoreKit.Sample.csproj index 56ab3a3..0db1967 100644 --- a/samples/CliCoreKit.Sample/CliCoreKit.Sample.csproj +++ b/samples/CliCoreKit.Sample/CliCoreKit.Sample.csproj @@ -9,6 +9,7 @@ net10.0 enable enable + false true diff --git a/samples/CliCoreKit.Sample/Program.cs b/samples/CliCoreKit.Sample/Program.cs index f543151..66e149d 100644 --- a/samples/CliCoreKit.Sample/Program.cs +++ b/samples/CliCoreKit.Sample/Program.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting; using Monbsoft.CliCoreKit.Core; using Monbsoft.CliCoreKit.Hosting; @@ -6,24 +6,41 @@ builder.ConfigureCli(cli => { + // Simple command with typed arguments and options cli.AddCommand("greet", "Greets a person") - .AddCommand("add", "Adds two numbers") - .AddCommand("list", "List operations") - .AddSubCommand("file", "list", "Lists files") - .UseValidation(); + .AddArgument("name", "The name to greet", defaultValue: "World") + .AddOption("greeting", 'g', "Custom greeting message", defaultValue: "Hello") + .AddOption("formal", 'f', "Use a formal greeting") + .AddOption("repeat", 'r', "Number of times to repeat", defaultValue: 1); + + // Command with required arguments + cli.AddCommand("add", "Adds two numbers") + .AddArgument("number1", "The first number", required: true) + .AddArgument("number2", "The second number", required: true) + .AddOption("verbose", 'v', "Show detailed output"); + + // Hierarchical commands + cli.AddCommand("list", "List operations") + .AddCommand("files", "Lists files in a directory") + .AddOption("path", 'p', "Directory path", defaultValue: ".") + .AddOption("pattern", 't', "File pattern", defaultValue: "*.*") + .AddOption("invert", 'i', "Invert sort order") + .AddOption("recursive", 'r', "Search recursively"); + + cli.UseValidation(); }); var host = builder.Build(); var exitCode = await host.RunCliAsync(args); return exitCode; -// Commands +// Commands implementation public class ListCommand : ICommand { public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) { - Console.WriteLine("Please specify a subcommand. Use 'list --help' for available subcommands."); + Console.WriteLine("Please specify a command. Use 'list --help' for available commands."); return Task.FromResult(1); } } @@ -32,29 +49,18 @@ public class GreetCommand : ICommand { public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) { - if (context.Arguments.HasOption("help") || context.Arguments.HasOption("h")) - { - Console.WriteLine("Usage: greet [options] [name]"); - Console.WriteLine(); - Console.WriteLine("Greets a person."); - Console.WriteLine(); - Console.WriteLine("Arguments:"); - Console.WriteLine(" name The name to greet (default: World)"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --name The name to greet"); - Console.WriteLine(" --formal Use a formal greeting"); - Console.WriteLine(" -h, --help Show this help message"); - return Task.FromResult(0); - } + var name = context.Arguments.GetArgument(0, "World"); + var greeting = context.Arguments.GetOption("greeting", "Hello"); + var formal = context.Arguments.GetOption("formal"); + var repeat = context.Arguments.GetOption("repeat", 1); - var name = context.Arguments.GetOptionValue("name") - ?? context.Arguments.GetPositional(0) - ?? "World"; + var message = formal ? $"Good day, {name}!" : $"{greeting}, {name}!"; - var greeting = context.Arguments.HasOption("formal") ? "Good day" : "Hello"; + for (int i = 0; i < repeat; i++) + { + Console.WriteLine(message); + } - Console.WriteLine($"{greeting}, {name}!"); return Task.FromResult(0); } } @@ -63,36 +69,30 @@ public class AddCommand : ICommand { public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) { - if (context.Arguments.HasOption("help") || context.Arguments.HasOption("h")) - { - Console.WriteLine("Usage: add "); - Console.WriteLine(); - Console.WriteLine("Adds two numbers together."); - Console.WriteLine(); - Console.WriteLine("Arguments:"); - Console.WriteLine(" number1 The first number"); - Console.WriteLine(" number2 The second number"); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" -h, --help Show this help message"); - return Task.FromResult(0); - } + var a = context.Arguments.GetArgument(0); + var b = context.Arguments.GetArgument(1); + var verbose = context.Arguments.GetOption("verbose"); - if (context.Arguments.Positional.Count < 2) + if (a == 0 && b == 0 && context.Arguments.Positional.Count < 2) { - Console.Error.WriteLine("Usage: add "); + Console.Error.WriteLine("Error: Two numbers are required."); + Console.Error.WriteLine("Use 'add --help' for usage information."); return Task.FromResult(1); } - if (int.TryParse(context.Arguments.GetPositional(0), out var a) && - int.TryParse(context.Arguments.GetPositional(1), out var b)) + var result = a + b; + + if (verbose) { - Console.WriteLine($"{a} + {b} = {a + b}"); - return Task.FromResult(0); + Console.WriteLine($"Adding {a} and {b}..."); + Console.WriteLine($"Result: {result}"); + } + else + { + Console.WriteLine(result); } - Console.Error.WriteLine("Invalid numbers provided"); - return Task.FromResult(1); + return Task.FromResult(0); } } @@ -100,30 +100,25 @@ public class ListFilesCommand : ICommand { public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) { - if (context.Arguments.HasOption("help") || context.Arguments.HasOption("h")) - { - Console.WriteLine("Usage: list file [options]"); - Console.WriteLine(); - Console.WriteLine("Lists files in a directory."); - Console.WriteLine(); - Console.WriteLine("Options:"); - Console.WriteLine(" --path The directory path (default: current directory)"); - Console.WriteLine(" --pattern File pattern to match (default: *.*)"); - Console.WriteLine(" -h, --help Show this help message"); - return Task.FromResult(0); - } - - var path = context.Arguments.GetOptionValue("path") ?? "."; - var pattern = context.Arguments.GetOptionValue("pattern") ?? "*.*"; + var path = context.Arguments.GetOption("path", ".")!; + var pattern = context.Arguments.GetOption("pattern", "*.*")!; + var invert = context.Arguments.GetOption("invert"); + var recursive = context.Arguments.GetOption("recursive"); try { - var files = Directory.GetFiles(path, pattern); - - Console.WriteLine($"Files in '{path}' matching '{pattern}':"); - foreach (var file in files) + var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + var files = Directory.GetFiles(path, pattern, searchOption); + + var sortedFiles = invert + ? files.OrderByDescending(f => Path.GetFileName(f)) + : files.OrderBy(f => Path.GetFileName(f)); + + Console.WriteLine($"Files in '{path}' matching '{pattern}'{(invert ? " (inverted)" : "")}{(recursive ? " (recursive)" : "")}:"); + foreach (var file in sortedFiles) { - Console.WriteLine($" - {Path.GetFileName(file)}"); + var relativePath = Path.GetRelativePath(path, file); + Console.WriteLine($" - {relativePath}"); } Console.WriteLine($"\nTotal: {files.Length} file(s)"); @@ -136,3 +131,4 @@ public Task ExecuteAsync(CommandContext context, CancellationToken cancella } } } + diff --git a/src/CliCoreKit.Core/ArgumentDefinition.cs b/src/CliCoreKit.Core/ArgumentDefinition.cs new file mode 100644 index 0000000..4127a26 --- /dev/null +++ b/src/CliCoreKit.Core/ArgumentDefinition.cs @@ -0,0 +1,37 @@ +namespace Monbsoft.CliCoreKit.Core; + +/// +/// Defines metadata for a command argument (positional parameter). +/// +public sealed class ArgumentDefinition +{ + /// + /// Gets or sets the argument name. + /// + public required string Name { get; init; } + + /// + /// Gets or sets the argument description. + /// + public string? Description { get; init; } + + /// + /// Gets or sets the value type (e.g., string, int, bool). + /// + public Type ValueType { get; init; } = typeof(string); + + /// + /// Gets or sets whether this argument is required. + /// + public bool IsRequired { get; init; } + + /// + /// Gets or sets the default value if not provided. + /// + public object? DefaultValue { get; init; } + + /// + /// Gets or sets the position of this argument (0-based). + /// + public int Position { get; init; } +} diff --git a/src/CliCoreKit.Core/CliApplication.cs b/src/CliCoreKit.Core/CliApplication.cs index 82cdbbe..0a5a436 100644 --- a/src/CliCoreKit.Core/CliApplication.cs +++ b/src/CliCoreKit.Core/CliApplication.cs @@ -48,13 +48,11 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok var parsedArgs = _router.ParseArguments(route.RemainingArgs); - // Check if help is requested AND the command has no subcommands - // If it has subcommands, show the system-generated help - // Otherwise, let the command handle --help itself for custom help - var hasSubCommands = _registry.Commands - .Any(c => string.Equals(c.Parent, route.CommandDefinition.Name, StringComparison.OrdinalIgnoreCase)); + // Check if help is requested + var helpRequested = parsedArgs.HasOption("help") || parsedArgs.HasOption("h"); - if ((parsedArgs.HasOption("help") || parsedArgs.HasOption("h")) && hasSubCommands) + // If help is requested and help is not disabled + if (helpRequested && !route.CommandDefinition.DisableHelp) { ShowCommandHelp(route.CommandDefinition, route.CommandPath); return 0; @@ -93,26 +91,7 @@ private void ShowHelp() Console.WriteLine("Available commands:"); Console.WriteLine(); - var rootCommands = _registry.Commands - .Where(c => string.IsNullOrEmpty(c.Parent)) - .OrderBy(c => c.Name); - - foreach (var cmd in rootCommands) - { - var description = !string.IsNullOrEmpty(cmd.Description) ? cmd.Description : "No description"; - Console.WriteLine($" {cmd.Name,-20} {description}"); - - // Show subcommands - var subCommands = _registry.Commands - .Where(c => string.Equals(c.Parent, cmd.Name, StringComparison.OrdinalIgnoreCase)) - .OrderBy(c => c.Name); - - foreach (var sub in subCommands) - { - var subDescription = !string.IsNullOrEmpty(sub.Description) ? sub.Description : "No description"; - Console.WriteLine($" {sub.Name,-18} {subDescription}"); - } - } + ShowCommandsRecursive(null, 0); Console.WriteLine(); Console.WriteLine("Options:"); @@ -121,20 +100,39 @@ private void ShowHelp() Console.WriteLine("Run '[command] --help' for more information on a command."); } + private void ShowCommandsRecursive(string? parent, int indentLevel) + { + var commands = _registry.Commands + .Where(c => string.Equals(c.Parent, parent, StringComparison.OrdinalIgnoreCase)) + .OrderBy(c => c.Name); + + foreach (var cmd in commands) + { + var indent = new string(' ', indentLevel * 2); + var description = !string.IsNullOrEmpty(cmd.Description) ? cmd.Description : "No description"; + var nameWidth = 20 - (indentLevel * 2); + var paddedName = cmd.Name.PadRight(nameWidth); + Console.WriteLine($" {indent}{paddedName} {description}"); + + // Show child commands + ShowCommandsRecursive(cmd.Name, indentLevel + 1); + } + } + private void ShowCommandHelp(CommandDefinition command, string[] commandPath) { var fullCommandName = string.Join(" ", commandPath); - // Check if this command has subcommands - var subCommands = _registry.Commands + // Check if this command has child commands + var childCommands = _registry.Commands .Where(c => string.Equals(c.Parent, command.Name, StringComparison.OrdinalIgnoreCase)) .OrderBy(c => c.Name) .ToList(); - if (subCommands.Any()) + if (childCommands.Any()) { - // Show help for a command with subcommands - Console.WriteLine($"Usage: {fullCommandName} [subcommand] [options]"); + // Show help for a command with child commands + Console.WriteLine($"Usage: {fullCommandName} [options]"); Console.WriteLine(); if (!string.IsNullOrEmpty(command.Description)) @@ -143,25 +141,38 @@ private void ShowCommandHelp(CommandDefinition command, string[] commandPath) Console.WriteLine(); } - Console.WriteLine("Available subcommands:"); + Console.WriteLine("Commands:"); Console.WriteLine(); - foreach (var sub in subCommands) + foreach (var child in childCommands) { - var subDescription = !string.IsNullOrEmpty(sub.Description) ? sub.Description : "No description"; - Console.WriteLine($" {sub.Name,-20} {subDescription}"); + var childDescription = !string.IsNullOrEmpty(child.Description) ? child.Description : "No description"; + Console.WriteLine($" {child.Name,-20} {childDescription}"); } Console.WriteLine(); Console.WriteLine("Options:"); Console.WriteLine(" -h, --help Show this help message"); Console.WriteLine(); - Console.WriteLine($"Run '{fullCommandName} [subcommand] --help' for more information on a subcommand."); + Console.WriteLine($"Run '{fullCommandName} --help' for more information on a command."); } else { - // Show basic help for a leaf command (without subcommands) - Console.WriteLine($"Usage: {fullCommandName} [options]"); + // Show basic help for a leaf command (without child commands) + var usageParts = new List { fullCommandName }; + + // Add arguments to usage + if (command.Arguments.Any()) + { + foreach (var arg in command.Arguments.OrderBy(a => a.Position)) + { + var argDisplay = arg.IsRequired ? $"<{arg.Name}>" : $"[{arg.Name}]"; + usageParts.Add(argDisplay); + } + } + + usageParts.Add("[options]"); + Console.WriteLine($"Usage: {string.Join(" ", usageParts)}"); Console.WriteLine(); if (!string.IsNullOrEmpty(command.Description)) @@ -170,6 +181,22 @@ private void ShowCommandHelp(CommandDefinition command, string[] commandPath) Console.WriteLine(); } + // Show arguments + if (command.Arguments.Any()) + { + Console.WriteLine("Arguments:"); + foreach (var arg in command.Arguments.OrderBy(a => a.Position)) + { + var argName = arg.Name; + var argDesc = !string.IsNullOrEmpty(arg.Description) ? arg.Description : "No description"; + var required = arg.IsRequired ? " (required)" : ""; + var typeInfo = arg.ValueType != typeof(string) ? $" [{GetTypeName(arg.ValueType)}]" : ""; + var defaultInfo = arg.DefaultValue != null ? $" (default: {arg.DefaultValue})" : ""; + Console.WriteLine($" {argName,-25} {argDesc}{typeInfo}{required}{defaultInfo}"); + } + Console.WriteLine(); + } + if (command.Options.Any()) { Console.WriteLine("Options:"); @@ -179,14 +206,21 @@ private void ShowCommandHelp(CommandDefinition command, string[] commandPath) ? $"-{option.ShortName}, --{option.Name}" : $"--{option.Name}"; + if (option.HasValue && option.ValueType != typeof(bool)) + { + var typeName = GetTypeName(option.ValueType); + optionDisplay += $" <{typeName.ToLower()}>"; + } + var description = !string.IsNullOrEmpty(option.Description) ? option.Description : "No description"; var required = option.IsRequired ? " (required)" : ""; - Console.WriteLine($" {optionDisplay,-25} {description}{required}"); + var defaultInfo = option.DefaultValue != null ? $" (default: {option.DefaultValue})" : ""; + Console.WriteLine($" {optionDisplay,-30} {description}{required}{defaultInfo}"); } - Console.WriteLine($" {"-h, --help",-25} Show this help message"); + Console.WriteLine($" {"-h, --help",-30} Show this help message"); } else { @@ -196,6 +230,24 @@ private void ShowCommandHelp(CommandDefinition command, string[] commandPath) } } + private static string GetTypeName(Type? type) + { + if (type == null) return "string"; + + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + return underlyingType.Name.ToLower() switch + { + "int32" => "int", + "int64" => "long", + "single" => "float", + "double" => "double", + "boolean" => "bool", + "string" => "string", + _ => underlyingType.Name + }; + } + private ICommand CreateCommand(Type commandType) { if (_commandFactory != null) diff --git a/src/CliCoreKit.Core/CliCoreKit.Core.csproj b/src/CliCoreKit.Core/CliCoreKit.Core.csproj index b49cc9c..af6cc6a 100644 --- a/src/CliCoreKit.Core/CliCoreKit.Core.csproj +++ b/src/CliCoreKit.Core/CliCoreKit.Core.csproj @@ -8,7 +8,7 @@ Monbsoft.CliCoreKit.Core - 1.0.0 + 1.1.0 Monbsoft Monbsoft CliCoreKit diff --git a/src/CliCoreKit.Core/CommandDefinition.cs b/src/CliCoreKit.Core/CommandDefinition.cs index cb6de10..426eec9 100644 --- a/src/CliCoreKit.Core/CommandDefinition.cs +++ b/src/CliCoreKit.Core/CommandDefinition.cs @@ -30,6 +30,11 @@ public sealed class CommandDefinition /// public required Type CommandType { get; init; } + /// + /// Gets or sets argument definitions for this command (positional parameters). + /// + public List Arguments { get; init; } = new(); + /// /// Gets or sets option definitions for this command. /// @@ -40,6 +45,13 @@ public sealed class CommandDefinition /// public bool IsHidden { get; init; } + /// + /// Gets or sets whether to disable help generation for this command. + /// When false (default), the system automatically handles --help/-h. + /// When true, the command must handle help itself. + /// + public bool DisableHelp { get; set; } + /// /// Checks if this command matches a given name. /// @@ -70,6 +82,11 @@ public sealed class OptionDefinition /// public string? Description { get; init; } + /// + /// Gets or sets whether this option requires a value. + /// + public bool HasValue { get; init; } = true; + /// /// Gets or sets whether this option is required. /// diff --git a/src/CliCoreKit.Core/ParsedArguments.cs b/src/CliCoreKit.Core/ParsedArguments.cs index 8826a47..ec627d3 100644 --- a/src/CliCoreKit.Core/ParsedArguments.cs +++ b/src/CliCoreKit.Core/ParsedArguments.cs @@ -90,7 +90,7 @@ public bool TryGetValue(string name, out T? value) try { - value = (T)Convert.ChangeType(stringValue, typeof(T)); + value = ConvertValue(stringValue); return true; } catch @@ -98,4 +98,140 @@ public bool TryGetValue(string name, out T? value) return false; } } + + /// + /// Gets an option value as a specific type with a default value. + /// + public T GetOption(string name, T defaultValue = default!) + { + // Special handling for bool - if option exists without value, it's true + if (typeof(T) == typeof(bool)) + { + if (HasOption(name)) + { + var stringValue = GetOptionValue(name); + if (string.IsNullOrEmpty(stringValue)) + { + return (T)(object)true; // Flag option present = true + } + try + { + return (T)(object)ConvertValue(stringValue); + } + catch + { + return defaultValue; + } + } + return defaultValue; + } + + if (TryGetValue(name, out var value)) + { + return value!; + } + + return defaultValue; + } + + /// + /// Gets a positional argument as a specific type. + /// + public T? GetArgument(int index, T? defaultValue = default) + { + var stringValue = GetPositional(index); + + if (stringValue == null) + { + return defaultValue; + } + + try + { + return ConvertValue(stringValue); + } + catch + { + return defaultValue; + } + } + + /// + /// Gets all values of an option as a specific type. + /// + public IReadOnlyList GetOptionValues(string name) + { + if (!_options.TryGetValue(name, out var values)) + { + return Array.Empty(); + } + + var result = new List(); + foreach (var value in values) + { + try + { + result.Add(ConvertValue(value)); + } + catch + { + // Skip invalid values + } + } + + return result.AsReadOnly(); + } + + private static T ConvertValue(string value) + { + var targetType = typeof(T); + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(targetType); + if (underlyingType != null) + { + targetType = underlyingType; + } + + // Special handling for bool + if (targetType == typeof(bool)) + { + if (bool.TryParse(value, out var boolResult)) + { + return (T)(object)boolResult; + } + // Treat presence as true, or values like "1", "yes", "on" as true + if (string.IsNullOrEmpty(value) || + value.Equals("1", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase) || + value.Equals("on", StringComparison.OrdinalIgnoreCase)) + { + return (T)(object)true; + } + return (T)(object)false; + } + + // Handle enums + if (targetType.IsEnum) + { + return (T)Enum.Parse(targetType, value, ignoreCase: true); + } + + // Special handling for floating-point types with invariant culture + if (targetType == typeof(double)) + { + return (T)(object)double.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + if (targetType == typeof(float)) + { + return (T)(object)float.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + if (targetType == typeof(decimal)) + { + return (T)(object)decimal.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + // Use Convert for other primitive types + return (T)Convert.ChangeType(value, targetType, System.Globalization.CultureInfo.InvariantCulture); + } } diff --git a/src/CliCoreKit.Hosting/CliCoreKit.Hosting.csproj b/src/CliCoreKit.Hosting/CliCoreKit.Hosting.csproj index 37f3faa..a7b9d12 100644 --- a/src/CliCoreKit.Hosting/CliCoreKit.Hosting.csproj +++ b/src/CliCoreKit.Hosting/CliCoreKit.Hosting.csproj @@ -1,14 +1,5 @@  - - - - - - - - - net10.0 enable @@ -17,7 +8,7 @@ Monbsoft.CliCoreKit.Hosting - 1.0.0 + 1.1.0 Monbsoft Monbsoft CliCoreKit @@ -45,4 +36,13 @@ + + + + + + + + + diff --git a/src/CliCoreKit.Hosting/CliHostBuilder.cs b/src/CliCoreKit.Hosting/CliHostBuilder.cs index e355464..5d6c268 100644 --- a/src/CliCoreKit.Hosting/CliHostBuilder.cs +++ b/src/CliCoreKit.Hosting/CliHostBuilder.cs @@ -22,9 +22,9 @@ public CliHostBuilder(IServiceCollection services) } /// - /// Registers a command. + /// Registers a command with fluent configuration. /// - public CliHostBuilder AddCommand(string name, string? description = null, string[]? aliases = null) + public CommandBuilder AddCommand(string name, string? description = null, string[]? aliases = null) where TCommand : class, ICommand { _services.AddTransient(); @@ -38,13 +38,13 @@ public CliHostBuilder AddCommand(string name, string? description = nu }; _registry.Register(definition); - return this; + return new CommandBuilder(_services, _registry, definition); } /// /// Registers a subcommand. /// - public CliHostBuilder AddSubCommand(string name, string parent, string? description = null) + public CommandBuilder AddSubCommand(string name, string parent, string? description = null) where TCommand : class, ICommand { _services.AddTransient(); @@ -58,7 +58,7 @@ public CliHostBuilder AddSubCommand(string name, string parent, string }; _registry.Register(definition); - return this; + return new CommandBuilder(_services, _registry, definition); } /// diff --git a/src/CliCoreKit.Hosting/CommandBuilder.cs b/src/CliCoreKit.Hosting/CommandBuilder.cs new file mode 100644 index 0000000..bb12a34 --- /dev/null +++ b/src/CliCoreKit.Hosting/CommandBuilder.cs @@ -0,0 +1,122 @@ +using Microsoft.Extensions.DependencyInjection; +using Monbsoft.CliCoreKit.Core; + +namespace Monbsoft.CliCoreKit.Hosting; + +/// +/// Fluent builder for configuring command definitions. +/// +public sealed class CommandBuilder +{ + private readonly IServiceCollection _services; + private readonly CommandRegistry _registry; + private readonly CommandDefinition _definition; + + internal CommandBuilder(IServiceCollection services, CommandRegistry registry, CommandDefinition definition) + { + _services = services; + _registry = registry; + _definition = definition; + } + + /// + /// Adds an argument (positional parameter) to the command. + /// + /// The argument type (default: string) + /// The argument name + /// The argument description + /// Whether the argument is required + /// The default value if not provided + public CommandBuilder AddArgument(string name, string? description = null, bool required = false, T? defaultValue = default) + { + var arg = new ArgumentDefinition + { + Name = name, + Description = description, + IsRequired = required, + ValueType = typeof(T), + DefaultValue = defaultValue, + Position = _definition.Arguments.Count + }; + + _definition.Arguments.Add(arg); + return this; + } + + /// + /// Adds a string argument (positional parameter) to the command. + /// + public CommandBuilder AddArgument(string name, string? description = null, bool required = false, string? defaultValue = null) + { + return AddArgument(name, description, required, defaultValue); + } + + /// + /// Adds an option to the command with type safety. + /// + /// The option value type (default: string) + /// The long option name (without --) + /// The short option name (single character, without -) + /// The option description + /// Whether the option is required + /// The default value if not provided + public CommandBuilder AddOption(string name, char? shortName = null, string? description = null, + bool required = false, T? defaultValue = default) + { + var hasValue = typeof(T) != typeof(bool); + + var option = new OptionDefinition + { + Name = name, + ShortName = shortName, + Description = description, + IsRequired = required, + HasValue = hasValue, + ValueType = typeof(T), + DefaultValue = defaultValue + }; + + _definition.Options.Add(option); + return this; + } + + /// + /// Adds a string option to the command. + /// + public CommandBuilder AddOption(string name, char? shortName = null, string? description = null, + bool required = false, string? defaultValue = null) + { + return AddOption(name, shortName, description, required, defaultValue); + } + + /// + /// Adds a child command. + /// + public CommandBuilder AddCommand(string name, string? description = null) + where TCommand : class, ICommand + { + _services.AddTransient(); + + var childDefinition = new CommandDefinition + { + Name = name, + Parent = _definition.Name, + Description = description, + CommandType = typeof(TCommand) + }; + + _registry.Register(childDefinition); + return new CommandBuilder(_services, _registry, childDefinition); + } + + /// + /// Disables help generation for this command. + /// The command will need to handle --help itself. + /// + public CommandBuilder WithoutHelp() + { + _definition.DisableHelp = true; + return this; + } +} + diff --git a/tests/CliCoreKit.Core.Tests/AutoHelpTests.cs b/tests/CliCoreKit.Core.Tests/AutoHelpTests.cs new file mode 100644 index 0000000..e5eac0d --- /dev/null +++ b/tests/CliCoreKit.Core.Tests/AutoHelpTests.cs @@ -0,0 +1,265 @@ +using AwesomeAssertions; +using Monbsoft.CliCoreKit.Core; +using Monbsoft.CliCoreKit.Core.Middleware; + +namespace CliCoreKit.Core.Tests; + +public class AutoHelpTests +{ + [Fact] + public async Task RunAsync_WithHelpOption_ShowsHelpAndReturnsZero() + { + // Arrange + var registry = new CommandRegistry(); + var definition = new CommandDefinition + { + Name = "test", + Description = "Test command", + CommandType = typeof(TestCommand) + }; + definition.Options.Add(new OptionDefinition + { + Name = "name", + ShortName = 'n', + Description = "Your name", + ValueType = typeof(string) + }); + registry.Register(definition); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "test", "--help" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain("Usage:"); + help.Should().Contain("test"); + help.Should().Contain("Test command"); + help.Should().Contain("-n, --name"); + } + } + + [Fact] + public async Task RunAsync_WithShortHelpOption_ShowsHelp() + { + // Arrange + var registry = new CommandRegistry(); + registry.Register(new CommandDefinition + { + Name = "test", + CommandType = typeof(TestCommand) + }); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "test", "-h" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain("Usage:"); + } + } + + [Fact] + public async Task RunAsync_WithDisableHelp_DoesNotShowAutomaticHelp() + { + // Arrange + var registry = new CommandRegistry(); + var definition = new CommandDefinition + { + Name = "custom", + CommandType = typeof(CustomHelpCommand), + DisableHelp = true + }; + registry.Register(definition); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "custom", "--help" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain("CUSTOM HELP"); + help.Should().NotContain("Usage:"); // Should not show automatic help + } + } + + [Fact] + public async Task RunAsync_WithArguments_ShowsArgumentsInHelp() + { + // Arrange + var registry = new CommandRegistry(); + var definition = new CommandDefinition + { + Name = "deploy", + CommandType = typeof(TestCommand) + }; + definition.Arguments.Add(new ArgumentDefinition + { + Name = "environment", + Description = "Target environment", + IsRequired = true, + ValueType = typeof(string), + Position = 0 + }); + registry.Register(definition); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "deploy", "--help" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain("Arguments:"); + help.Should().Contain("environment"); + help.Should().Contain("Target environment"); + help.Should().Contain("(required)"); + } + } + + [Fact] + public async Task RunAsync_WithTypedOptions_ShowsTypesInHelp() + { + // Arrange + var registry = new CommandRegistry(); + var definition = new CommandDefinition + { + Name = "serve", + CommandType = typeof(TestCommand) + }; + definition.Options.Add(new OptionDefinition + { + Name = "port", + ShortName = 'p', + Description = "Port number", + ValueType = typeof(int), + DefaultValue = 8080 + }); + registry.Register(definition); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "serve", "--help" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain(""); + help.Should().Contain("(default: 8080)"); + } + } + + [Fact] + public async Task RunAsync_WithChildCommands_ShowsChildCommandsInHelp() + { + // Arrange + var registry = new CommandRegistry(); + registry.Register(new CommandDefinition + { + Name = "git", + Description = "Git operations", + CommandType = typeof(TestCommand) + }); + registry.Register(new CommandDefinition + { + Name = "commit", + Parent = "git", + Description = "Commit changes", + CommandType = typeof(TestCommand) + }); + registry.Register(new CommandDefinition + { + Name = "push", + Parent = "git", + Description = "Push commits", + CommandType = typeof(TestCommand) + }); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "git", "--help" }); + + // Assert + exitCode.Should().Be(0); + var help = output.ToString(); + help.Should().Contain("Commands:"); + help.Should().Contain("commit"); + help.Should().Contain("push"); + } + } + + [Fact] + public async Task RunAsync_WithoutHelpOption_ExecutesCommand() + { + // Arrange + var registry = new CommandRegistry(); + registry.Register(new CommandDefinition + { + Name = "test", + CommandType = typeof(TestCommand) + }); + + var app = new CliApplication(registry); + using (var output = new StringWriter()) + { + Console.SetOut(output); + + // Act + var exitCode = await app.RunAsync(new[] { "test" }); + + // Assert + exitCode.Should().Be(0); + var result = output.ToString(); + result.Should().Contain("Command executed"); + } + } + + private class TestCommand : ICommand + { + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) + { + Console.WriteLine("Command executed"); + return Task.FromResult(0); + } + } + + private class CustomHelpCommand : ICommand + { + public Task ExecuteAsync(CommandContext context, CancellationToken cancellationToken = default) + { + if (context.Arguments.HasOption("help")) + { + Console.WriteLine("CUSTOM HELP"); + } + return Task.FromResult(0); + } + } +} diff --git a/tests/CliCoreKit.Core.Tests/ParsedArgumentsGenericTests.cs b/tests/CliCoreKit.Core.Tests/ParsedArgumentsGenericTests.cs new file mode 100644 index 0000000..05eca1d --- /dev/null +++ b/tests/CliCoreKit.Core.Tests/ParsedArgumentsGenericTests.cs @@ -0,0 +1,180 @@ +using AwesomeAssertions; +using Monbsoft.CliCoreKit.Core; + +namespace CliCoreKit.Core.Tests; + +public class ParsedArgumentsGenericTests +{ + [Fact] + public void GetOption_WithInt_ReturnsTypedValue() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("port", "8080"); + + // Act + var port = args.GetOption("port"); + + // Assert + port.Should().Be(8080); + } + + [Fact] + public void GetOption_WithBool_ReturnsTrue() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("verbose"); + + // Act + var verbose = args.GetOption("verbose"); + + // Assert + verbose.Should().BeTrue(); + } + + [Fact] + public void GetOption_WithBoolAndValue_ParsesCorrectly() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("enabled", "true"); + + // Act + var enabled = args.GetOption("enabled"); + + // Assert + enabled.Should().BeTrue(); + } + + [Fact] + public void GetOption_WithDouble_ReturnsTypedValue() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("temperature", "23.5"); + + // Act + var temp = args.GetOption("temperature"); + + // Assert + temp.Should().Be(23.5); + } + + [Fact] + public void GetOption_WithDefaultValue_ReturnsDefault() + { + // Arrange + var args = new ParsedArguments(); + + // Act + var port = args.GetOption("port", 3000); + + // Assert + port.Should().Be(3000); + } + + [Fact] + public void GetArgument_WithInt_ReturnsTypedValue() + { + // Arrange + var args = new ParsedArguments(); + args.AddPositional("42"); + args.AddPositional("100"); + + // Act + var first = args.GetArgument(0); + var second = args.GetArgument(1); + + // Assert + first.Should().Be(42); + second.Should().Be(100); + } + + [Fact] + public void GetArgument_WithString_ReturnsValue() + { + // Arrange + var args = new ParsedArguments(); + args.AddPositional("hello"); + + // Act + var value = args.GetArgument(0); + + // Assert + value.Should().Be("hello"); + } + + [Fact] + public void GetArgument_WithMissingIndex_ReturnsDefault() + { + // Arrange + var args = new ParsedArguments(); + + // Act + var value = args.GetArgument(5, 999); + + // Assert + value.Should().Be(999); + } + + [Fact] + public void GetOptionValues_WithMultipleInts_ReturnsTypedList() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("numbers", "1"); + args.AddOption("numbers", "2"); + args.AddOption("numbers", "3"); + + // Act + var numbers = args.GetOptionValues("numbers"); + + // Assert + numbers.Should().HaveCount(3); + numbers.Should().Contain(new[] { 1, 2, 3 }); + } + + [Fact] + public void TryGetValue_WithValidInt_ReturnsTrue() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("count", "42"); + + // Act + var success = args.TryGetValue("count", out var value); + + // Assert + success.Should().BeTrue(); + value.Should().Be(42); + } + + [Fact] + public void TryGetValue_WithInvalidInt_ReturnsFalse() + { + // Arrange + var args = new ParsedArguments(); + args.AddOption("count", "not-a-number"); + + // Act + var success = args.TryGetValue("count", out var value); + + // Assert + success.Should().BeFalse(); + value.Should().Be(0); + } + + [Fact] + public void TryGetValue_WithMissingOption_ReturnsFalse() + { + // Arrange + var args = new ParsedArguments(); + + // Act + var success = args.TryGetValue("missing", out var value); + + // Assert + success.Should().BeFalse(); + } +}