Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions source/Cli/CreativeCoders.Cli.Core/CommandResult.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
using JetBrains.Annotations;

namespace CreativeCoders.Cli.Core;

[PublicAPI]
public class CommandResult
{
public CommandResult() { }

public CommandResult(int exitCode)
{
ExitCode = exitCode;
}

public int ExitCode { get; init; }
}
13 changes: 13 additions & 0 deletions source/Cli/CreativeCoders.Cli.Core/IOptionsValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace CreativeCoders.Cli.Core;

/// <summary>
/// Interface for command options validation.
/// </summary>
public interface IOptionsValidation
{
/// <summary>
/// Validates the command options asynchronously.
/// </summary>
/// <returns>A <see cref="OptionsValidationResult"/> indicating the result of the validation.</returns>
Task<OptionsValidationResult> ValidateAsync();
}
19 changes: 19 additions & 0 deletions source/Cli/CreativeCoders.Cli.Core/OptionsValidationResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace CreativeCoders.Cli.Core;

/// <summary>
/// Represents the result of a command options validation.
/// </summary>
/// <param name="isValid">A boolean value indicating whether the options are valid.</param>
/// <param name="messages">An optional collection of validation messages.</param>
public class OptionsValidationResult(bool isValid, IEnumerable<string>? messages = null)
{
/// <summary>
/// Gets a value indicating whether the validation was successful.
/// </summary>
public bool IsValid { get; } = isValid;

/// <summary>
/// Gets the validation messages.
/// </summary>
public IEnumerable<string> Messages { get; } = messages ?? [];
}
2 changes: 2 additions & 0 deletions source/Cli/CreativeCoders.Cli.Hosting/CliExitCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public static class CliExitCodes
public const int CommandCreationFailed = int.MinValue + 1;

public const int CommandResultUnknown = int.MinValue + 2;

public const int CommandOptionsInvalid = int.MinValue + 3;
}
54 changes: 50 additions & 4 deletions source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using CreativeCoders.Cli.Hosting.Exceptions;
using CreativeCoders.Cli.Hosting.Help;
using CreativeCoders.Core;
using CreativeCoders.Core.Collections;
using CreativeCoders.Core.Reflection;
using CreativeCoders.SysConsole.Cli.Parsing;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -27,6 +28,9 @@ public class DefaultCliHost(

private readonly ICliCommandHelpHandler _commandHelpHandler = Ensure.NotNull(commandHelpHandler);

private readonly CliHostSettings _settings =
serviceProvider.GetService<CliHostSettings>() ?? new CliHostSettings();

private (object Command, string[] Args, CliCommandInfo CommandInfo) CreateCliCommand(string[] args)
{
var findCommandNodeResult = _commandStore.FindCommandNode(args);
Expand All @@ -43,7 +47,7 @@ public class DefaultCliHost(
var command =
commandInfo.CommandType.CreateInstance<object>(_serviceProvider);

return command == null
return command is null
? throw new CliCommandConstructionFailedException("Command creation failed", args)
: (command, findCommandNodeResult.RemainingArgs, commandInfo);
}
Expand All @@ -53,10 +57,10 @@ public class DefaultCliHost(
}
}

private static async Task<CliResult> ExecuteAsync(CliCommandInfo commandInfo, object command,
private async Task<CliResult> ExecuteAsync(CliCommandInfo commandInfo, object command,
string[] optionsArgs)
{
if (commandInfo.OptionsType == null)
if (commandInfo.OptionsType is null)
{
var commandResult = await ((ICliCommand)command).ExecuteAsync().ConfigureAwait(false);

Expand All @@ -66,6 +70,7 @@ private static async Task<CliResult> ExecuteAsync(CliCommandInfo commandInfo, ob
var optionsParser = new OptionParser(commandInfo.OptionsType);

var options = optionsParser.Parse(optionsArgs);
await ValidateCommandOptionsAsync(options).ConfigureAwait(false);

var commandWithOptionsResult = command.GetType().InvokeMember(nameof(ICliCommand<>.ExecuteAsync),
BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.Public, null, command,
Expand All @@ -79,6 +84,26 @@ private static async Task<CliResult> ExecuteAsync(CliCommandInfo commandInfo, ob
return new CliResult(CliExitCodes.CommandResultUnknown);
}

private async Task ValidateCommandOptionsAsync(object options)
{
if (!_settings.UseValidation)
{
return;
}

if (options is not IOptionsValidation validator)
{
return;
}

var validationResult = await validator.ValidateAsync().ConfigureAwait(false);

if (!validationResult.IsValid)
{
throw new CliCommandOptionsInvalidException(validationResult);
}
}

public async Task<CliResult> RunAsync(string[] args)
{
try
Expand Down Expand Up @@ -109,10 +134,26 @@ public async Task<CliResult> RunAsync(string[] args)
{
PrintNearestMatch(args);

return new CliResult(e.ExitCode);
}
catch (CliCommandOptionsInvalidException e)
{
_ansiConsole.MarkupLine("[red]Error validating command options[/]");

e.ValidationResult.Messages.ForEach(message => _ansiConsole.MarkupLine($"- {message}"));

return new CliResult(e.ExitCode);
}
}

/// <inheritdoc />
public async Task<int> RunMainAsync(string[] args)
{
var result = await RunAsync(args).ConfigureAwait(false);

return result.ExitCode;
}

private void PrintNearestMatch(string[] args)
{
_ansiConsole.Markup($"[red]No command found for given arguments: {string.Join(" ", args)}[/]");
Expand All @@ -121,7 +162,7 @@ private void PrintNearestMatch(string[] args)

var findCommandGroupNodeResult = _commandStore.FindCommandGroupNode(args);

if (findCommandGroupNodeResult?.Node == null)
if (findCommandGroupNodeResult?.Node is null)
{
_ansiConsole.WriteLine("No matches found");

Expand All @@ -131,3 +172,8 @@ private void PrintNearestMatch(string[] args)
_commandHelpHandler.PrintHelpFor(findCommandGroupNodeResult.Node.ChildNodes);
}
}

public class CliHostSettings
{
public bool UseValidation { get; init; }
}
Comment thread
darthsharp marked this conversation as resolved.
19 changes: 19 additions & 0 deletions source/Cli/CreativeCoders.Cli.Hosting/DefaultCliHostBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class DefaultCliHostBuilder : ICliHostBuilder

private bool _contextConfigured;

private bool _useValidation;

public ICliHostBuilder UseContext<TContext>(Action<IServiceProvider, TContext>? configure = null)
where TContext : class, ICliCommandContext
{
Expand Down Expand Up @@ -70,6 +72,13 @@ public ICliHostBuilder EnableHelp(HelpCommandKind commandKind)
return this;
}

public ICliHostBuilder UseValidation(bool useValidation = true)
{
_useValidation = useValidation;

return this;
}

public ICliHostBuilder SkipScanEntryAssembly(bool skipScanEntryAssembly = true)
{
_skipScanEntryAssembly = skipScanEntryAssembly;
Expand All @@ -94,6 +103,16 @@ private IServiceProvider BuildServiceProvider()
services.TryAddSingleton<ICliCommandHelpHandler, DisabledCommandHelpHandler>();
}

if (_useValidation)
{
var settings = new CliHostSettings
{
UseValidation = true
};

services.TryAddSingleton(settings);
}

_configureServicesActions.ForEach(x => x(services));

services.AddCliHosting();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using CreativeCoders.Cli.Core;
using CreativeCoders.Core;

namespace CreativeCoders.Cli.Hosting.Exceptions;

public class CliCommandOptionsInvalidException(OptionsValidationResult validationResult)
: CliExitException("Command options are invalid", CliExitCodes.CommandOptionsInvalid)
{
public OptionsValidationResult ValidationResult { get; } = Ensure.NotNull(validationResult);
}
7 changes: 7 additions & 0 deletions source/Cli/CreativeCoders.Cli.Hosting/ICliHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,11 @@ namespace CreativeCoders.Cli.Hosting;
public interface ICliHost
{
Task<CliResult> RunAsync(string[] args);

/// <summary>
/// Runs the CLI host and returns the exit code as an integer.
/// </summary>
/// <param name="args">The command line arguments.</param>
/// <returns>The exit code of the executed command.</returns>
Task<int> RunMainAsync(string[] args);
}
7 changes: 7 additions & 0 deletions source/Cli/CreativeCoders.Cli.Hosting/ICliHostBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ ICliHostBuilder UseContext<TContext>(Action<IServiceProvider, TContext>? configu

ICliHostBuilder EnableHelp(HelpCommandKind commandKind);

/// <summary>
/// Enables or disables command options validation.
/// </summary>
/// <param name="useValidation">A boolean value indicating whether validation should be enabled. Default is true.</param>
/// <returns>The same <see cref="ICliHostBuilder"/> instance.</returns>
ICliHostBuilder UseValidation(bool useValidation = true);

ICliHostBuilder SkipScanEntryAssembly(bool skipScanEntryAssembly = true);

ICliHost Build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,70 @@ public void UseContext_AddsContextToServices()
.Be(42);
}

[Fact]
public void UseValidation_AddsSettingsToServices()
{
// Arrange
var builder = CreateBuilder();

builder.SkipScanEntryAssembly();

builder.UseValidation();

var commandScanner = A.Fake<IAssemblyCommandScanner>();
var commandStore = A.Fake<ICliCommandStore>();
var validator = A.Fake<ICliCommandStructureValidator>();
var cliHost = A.Fake<ICliHost>();

A.CallTo(() => commandScanner.ScanForCommands(A<Assembly[]>.Ignored))
.Returns(CreateScanResult());

SubstituteServices(builder, commandScanner, commandStore, validator, cliHost);

var services = GetBuiltServiceProvider(builder);

// Act
builder.Build();

// Assert
var settings = services.GetRequiredService<CliHostSettings>();

settings.UseValidation
.Should().Be(true);
}

[Fact]
public void DontUseValidation_SettingsNotAddedToServicesOrSettingsUseValidationIsFalse()
{
// Arrange
var builder = CreateBuilder();

builder.SkipScanEntryAssembly();

builder.UseValidation(false);

var commandScanner = A.Fake<IAssemblyCommandScanner>();
var commandStore = A.Fake<ICliCommandStore>();
var validator = A.Fake<ICliCommandStructureValidator>();
var cliHost = A.Fake<ICliHost>();

A.CallTo(() => commandScanner.ScanForCommands(A<Assembly[]>.Ignored))
.Returns(CreateScanResult());

SubstituteServices(builder, commandScanner, commandStore, validator, cliHost);

var services = GetBuiltServiceProvider(builder);

// Act
builder.Build();

// Assert
var settings = services.GetService<CliHostSettings>();

settings?.UseValidation
.Should().NotBe(true);
Comment on lines +330 to +331

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion "Should().NotBe(true)" is an indirect way of checking that validation is disabled. This could pass if settings is null or if UseValidation is false. Consider being more explicit: check if settings is null OR if it exists, verify that UseValidation is explicitly false. This would make the test intent clearer and distinguish between "settings not added" versus "settings added with validation disabled".

Suggested change
settings?.UseValidation
.Should().NotBe(true);
if (settings == null)
{
settings.Should().BeNull();
}
else
{
settings.UseValidation.Should().BeFalse();
}

Copilot uses AI. Check for mistakes.
}

private static DefaultCliHostBuilder CreateBuilder()
{
return new DefaultCliHostBuilder();
Expand Down
Loading
Loading