├── src ├── dotnet-new-template │ ├── content │ │ └── consoleapp │ │ │ ├── appsettings.Development.json │ │ │ ├── appsettings.json │ │ │ ├── Company.ConsoleApplication1.csproj │ │ │ ├── Program.cs │ │ │ └── .template.config │ │ │ └── template.json │ ├── README.md │ └── Pri.ConsoleApplicationBuilder.Templates.csproj ├── CommandLineExtensions │ ├── icon.png │ ├── IBuilderState.cs │ ├── ICommandConfiguration.cs │ ├── ParamSpec.cs │ ├── SubcommandBuilderBase.cs │ ├── ITwoParameterSubcommandBuilder.cs │ ├── IParameterConfiguration.cs │ ├── ICommandHandler.cs │ ├── Pri.CommandLineExtensions.csproj │ ├── ISubcommandBuilder.cs │ ├── CommandLineCommandBuilderExtensions.cs │ ├── ITwoParameterCommandBuilder.cs │ ├── IOneParameterSubcommandBuilder.cs │ ├── SubcommandBuilder.cs │ ├── CommandBuilderBase.cs │ ├── ICommandBuilder.cs │ ├── CommandExtensions.cs │ ├── IOneParameterCommandBuilder.cs │ ├── TwoParameterSubcommandBuilder.cs │ ├── OneParameterSubcommandBuilder.cs │ ├── TwoParameterCommandBuilder.cs │ └── CommandBuilder.cs ├── Tests │ ├── ConsoleApplicationBuilderTests │ │ ├── appsettings.json │ │ ├── appsettings.Development.json │ │ ├── Utility.cs │ │ ├── Constants.cs │ │ ├── Common │ │ │ └── IsolatedExecutionCollectionDefinition.cs │ │ ├── Fixtures │ │ │ └── TestRunnerFixture.cs │ │ ├── GlobalSuppressions.cs │ │ ├── ServiceCollectionTests.cs │ │ ├── ConfigureContainerShould.cs │ │ ├── ApplicationEnvironmentShould.cs │ │ ├── CreatingApplicationInstanceDependentOnServiceShould.cs │ │ ├── ConsoleApplicationBuilderTests.csproj │ │ ├── CreatingMinimalApplicationInstanceWithSettingsShould.cs │ │ └── CreatingMinimalApplicationInstanceShould.cs │ └── CommandLineExtensionsTests │ │ ├── TestDoubles │ │ ├── NullCommand.cs │ │ ├── ProcessFileCommand.cs │ │ ├── FakeCommand.cs │ │ ├── Subcommand.cs │ │ ├── HandlerSpy.cs │ │ ├── MainRootCommand.cs │ │ ├── FileInfoHandlerSpy.cs │ │ ├── ProcessFileCommandHandler.cs │ │ └── FileInfoCountHandlerSpy.cs │ │ ├── Constants.cs │ │ ├── GlobalSuppressions.cs │ │ ├── SclTests.cs │ │ ├── CommandLineBuilderTestingBase.cs │ │ ├── Utility.cs │ │ ├── CommandLineExtensionsWithTwoOptionsAndArgumentParserShould.cs │ │ ├── CommandLineExtensionsTests.csproj │ │ ├── CommandLineExtensionsWithCommandFactoryShould.cs │ │ ├── CommandLineExtensionsWithArgumentParserShould.cs │ │ ├── TwoParameterDefaultValueTests.cs │ │ ├── CommandLineExtensionsWithArgumentAndArgumentParserShould.cs │ │ ├── OneParameterDefaultValueTests.cs │ │ ├── AsyncTests.cs │ │ ├── CommandLineExtensionsGivenCommandWithOneRequiredOptionShould.cs │ │ ├── CommandLineExtensionsWithCommandObjectShould.cs │ │ ├── CommandLineExtensionsGivenCommandWithTwoRequiredOptionsShould.cs │ │ ├── ExitCodeTests.cs │ │ ├── CommandLineExtensionsGivenArgumentFreeRootCommandShould.cs │ │ └── CommandLineExtensionsGivenArgumentFreeCommandShould.cs ├── ConsoleApplicationBuilder │ ├── ConsoleApplicationBuilder │ │ ├── icon.png │ │ ├── ApplicationEnvironment.cs │ │ ├── ConsoleApplication.cs │ │ ├── IConsoleApplicationBuilder.cs │ │ ├── ConsoleApplicationBuilderSettings.cs │ │ ├── Pri.ConsoleApplicationBuilder.csproj │ │ └── DefaultConsoleApplicationBuilder.cs │ └── Directory.Build.props ├── README.md └── ConsoleApplicationBuilder.sln ├── scaffolding ├── scaffold-commandlineextensions.ps1 └── scaffold-console-application.ps1 ├── .github └── ISSUE_TEMPLATE │ └── bug-report.md ├── .azuredevops ├── pr-ConsoleApplicationBuilder-main.yml ├── templates │ └── jobs │ │ ├── github-release-packages.yml │ │ └── build-and-pack-nuget-job.yml ├── cd-template.yml ├── ci-build.yml └── ci-CommandLineExtensions.yml └── adrs └── repo-structure.md /src/dotnet-new-template/content/consoleapp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peteraritchie/ConsoleApplicationBuilder/HEAD/src/CommandLineExtensions/icon.png -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "appSettingsKey": "appSettingsValue", 3 | "developmentAppSettingsKey": "root" 4 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "appSettingsKey": "appSettingsValue", 3 | "developmentAppSettingsKey": "development" 4 | } -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peteraritchie/ConsoleApplicationBuilder/HEAD/src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/icon.png -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/NullCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | public class NullCommand : RootCommand; -------------------------------------------------------------------------------- /src/dotnet-new-template/content/consoleapp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.Hosting.Lifetime": "Information" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/ProcessFileCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | public class ProcessFileCommand() : RootCommand("File processor"); -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/FakeCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | internal class FakeCommand() : RootCommand("A fake command for testing purposes."); -------------------------------------------------------------------------------- /src/dotnet-new-template/README.md: -------------------------------------------------------------------------------- 1 | 3 | -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/Utility.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace ConsoleApplicationBuilderTests; 4 | 5 | public static class Utility 6 | { 7 | public static string ExecutingTestRunnerName { get; } = Assembly.GetEntryAssembly()?.GetName().Name!; 8 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/Subcommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | /// 6 | /// A subcommand for testing 7 | /// 8 | public class Subcommand() : Command("dependencies", "Analyze dependencies."); -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/HandlerSpy.cs: -------------------------------------------------------------------------------- 1 | using Pri.CommandLineExtensions; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | public class HandlerSpy : ICommandHandler 6 | { 7 | internal bool WasExecuted { get; private set; } 8 | public int Execute() 9 | { 10 | WasExecuted = true; 11 | return 0; 12 | } 13 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/IBuilderState.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Pri.CommandLineExtensions; 6 | 7 | public interface IBuilderState 8 | { 9 | IServiceCollection Services { get; } 10 | List ParamSpecs { get; } 11 | string? CommandDescription { get; set; } 12 | Command? Command { get; init; } 13 | Type? CommandType { get; init; } 14 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/MainRootCommand.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.Logging; 4 | 5 | namespace CommandLineExtensionsTests.TestDoubles; 6 | 7 | /// 8 | /// A command for testing. 9 | /// 10 | public class MainRootCommand(ILogger logger) : RootCommand 11 | { 12 | private readonly ILogger logger = logger; 13 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace ConsoleApplicationBuilderTests; 2 | 3 | public static class Constants 4 | { 5 | public const string FileOptionName = "--file"; 6 | public const string CountOptionName = "--count"; 7 | public const string FileArgumentName = "file"; 8 | public const string ReSharperTestRunnerName = "ReSharperTestRunner"; 9 | public const string VisualStudioTestRunnerName = "testhost"; 10 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace CommandLineExtensionsTests; 2 | 3 | public static class Constants 4 | { 5 | public const string FileOptionName = "--file"; 6 | public const string FileOptionAlias = "-f"; 7 | public const string CountOptionName = "--count"; 8 | public const string FileArgumentName = "file"; 9 | public const string ReSharperTestRunnerName = "ReSharperTestRunner"; 10 | public const string VisualStudioTestRunnerName = "testhost"; 11 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/FileInfoHandlerSpy.cs: -------------------------------------------------------------------------------- 1 | using Pri.CommandLineExtensions; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | internal class FileInfoHandlerSpy : ICommandHandler 6 | { 7 | internal bool WasExecuted { get; private set; } 8 | internal FileInfo? GivenFileInfo { get; private set; } 9 | 10 | public int Execute(FileInfo? fileInfo) 11 | { 12 | WasExecuted = true; 13 | GivenFileInfo = fileInfo; 14 | return 0; 15 | } 16 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/Common/IsolatedExecutionCollectionDefinition.cs: -------------------------------------------------------------------------------- 1 | namespace ConsoleApplicationBuilderTests.Common; 2 | 3 | /// 4 | /// A collection definition that when a CollectionAttribute named "Isolated Execution Collection" 5 | /// is added to a test class, it will disable parallelization for that test class. 6 | /// 7 | [CollectionDefinition("Isolated Execution Collection", DisableParallelization = true)] 8 | public class IsolatedExecutionCollectionDefinition 9 | { 10 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/ProcessFileCommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | using Pri.CommandLineExtensions; 4 | 5 | namespace CommandLineExtensionsTests.TestDoubles; 6 | 7 | public class ProcessFileCommandHandler(ILogger logger) : ICommandHandler 8 | { 9 | public int Execute(FileInfo fileInfo) 10 | { 11 | logger.LogInformation("Executed called."); 12 | Console.WriteLine($"Got parameter '{fileInfo.FullName}"); 13 | return 0; 14 | } 15 | } -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/ApplicationEnvironment.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace Pri.ConsoleApplicationBuilder; 5 | 6 | internal class ApplicationEnvironment : IHostEnvironment 7 | { 8 | public string ApplicationName { get; set; } = string.Empty; 9 | public string EnvironmentName { get; set; } = string.Empty; 10 | public string ContentRootPath { get; set; } = string.Empty; 11 | public required IFileProvider ContentRootFileProvider { get; set; } 12 | } -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/ConsoleApplication.cs: -------------------------------------------------------------------------------- 1 | namespace Pri.ConsoleApplicationBuilder; 2 | 3 | public class ConsoleApplication 4 | { 5 | public static IConsoleApplicationBuilder CreateBuilder(string[] args) 6 | { 7 | ArgumentNullException.ThrowIfNull(args); 8 | 9 | return CreateBuilder(new ConsoleApplicationBuilderSettings { Args = args}); 10 | } 11 | 12 | public static IConsoleApplicationBuilder CreateBuilder(ConsoleApplicationBuilderSettings settings) 13 | { 14 | return new DefaultConsoleApplicationBuilder(settings); 15 | } 16 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TestDoubles/FileInfoCountHandlerSpy.cs: -------------------------------------------------------------------------------- 1 | using Pri.CommandLineExtensions; 2 | 3 | namespace CommandLineExtensionsTests.TestDoubles; 4 | 5 | internal class FileInfoCountHandlerSpy : ICommandHandler 6 | { 7 | internal bool WasExecuted { get; private set; } 8 | internal FileInfo? GivenFileInfo { get; private set; } 9 | 10 | public int Execute(FileInfo? fileInfo, int? count) 11 | { 12 | WasExecuted = true; 13 | GivenFileInfo = fileInfo; 14 | GivenCount = count; 15 | return 0; 16 | } 17 | 18 | public int? GivenCount { get; set; } 19 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/ICommandConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace Pri.CommandLineExtensions; 2 | 3 | /// 4 | /// The part of ICommandLineCommandBuilder unique to configuring a command 5 | /// 6 | public interface ICommandConfiguration 7 | { 8 | /// 9 | /// Add a description to the command. 10 | /// 11 | /// The description of the command displayed when showing help. 12 | /// 13 | /// If description already exists. 14 | T WithDescription(string description); 15 | } -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/IConsoleApplicationBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Hosting; 2 | 3 | namespace Pri.ConsoleApplicationBuilder; 4 | 5 | /// 6 | /// Represents a console application builder which helps manage configuration, logging, lifetime, and more. 7 | /// 8 | public interface IConsoleApplicationBuilder : IHostApplicationBuilder 9 | { 10 | 11 | /// 12 | /// Builds the host. This method can only be called once. 13 | /// 14 | /// An initialized . 15 | T Build() where T : class; 16 | 17 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/ParamSpec.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine.Parsing; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | /// 6 | /// A data model for to specify the attribute used to create a parameter (Option<T>/Argument<T>) 7 | /// 8 | public record ParamSpec 9 | { 10 | public required string Name { get; init; } 11 | public required string Description { get; set; } 12 | public bool IsRequired { get; init; } = false; 13 | public bool IsArgument { get; init; } = false; 14 | public List Aliases { get; } = []; 15 | public object? DefaultValue { get; set; } 16 | public object ArgumentParser { get; set; } 17 | } -------------------------------------------------------------------------------- /scaffolding/scaffold-commandlineextensions.ps1: -------------------------------------------------------------------------------- 1 | . $env:USERPROFILE\project.ps1 2 | 3 | $solution = [Solution]::Load('.'); 4 | 5 | $libraryProject = $solution.NewClassLibraryProject("Pri.ConsoleApplicationBuilder.CommandLineExtensions"); 6 | $libraryProject.AddPackageReferencePrerelease("System.CommandLine"); 7 | $libraryProject.AddPackageReference("Microsoft.Extensions.DependencyInjection.Abstractions"); 8 | 9 | $testsProject = $solution.Projects | Where-Object Name -eq "Tests" | Select-Object -first 1; 10 | if($testsProject) { 11 | $testsProject.AddProjectReference($libraryProject); 12 | } else { 13 | echo "Error, Tests project not found!"; 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/SubcommandBuilderBase.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | internal abstract class SubcommandBuilderBase : CommandBuilderBase 6 | { 7 | protected SubcommandBuilderBase(IServiceCollection services) : base(services) 8 | { 9 | } 10 | 11 | /// 12 | /// A copy-constructor to initialize a new SubcommandBuilderBase based on another. 13 | /// 14 | /// 15 | protected SubcommandBuilderBase(SubcommandBuilderBase initiator) : base(initiator) 16 | { 17 | SubcommandAlias = initiator.SubcommandAlias; 18 | } 19 | 20 | protected string? SubcommandAlias { get; set; } 21 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/Fixtures/TestRunnerFixture.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | // [assembly: AssemblyFixture(typeof(Pri.ConsoleApplicationBuilder.Tests.Fixtures.TestRunnerFixture))] 4 | 5 | namespace ConsoleApplicationBuilderTests.Fixtures; 6 | 7 | /// 8 | /// WAITING FOR XUNIT 3.x 9 | /// Something to aid with detecting current test runner in the context of a System.CommandLine command invocation. 10 | /// 11 | public sealed class TestRunnerFixture : IDisposable 12 | { 13 | public string TestRunnerName { get; } = Assembly.GetEntryAssembly()?.GetName().Name!; 14 | public bool IsRunningReSharperTestRunner => TestRunnerName == Constants.ReSharperTestRunnerName; 15 | public void Dispose() 16 | { 17 | } 18 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("Maintainability", "PRm1000:XML Comments Not Present", Justification = "", Scope = "namespaceanddescendants", Target = "~N:CommandLineExtensionsTests")] 9 | [assembly: SuppressMessage("Maintainability", "PRm1010:Method Name Does Not Have Leading Transitive Verb", Justification = "", Scope = "namespaceanddescendants", Target = "~N:CommandLineExtensionsTests")] 10 | -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/GlobalSuppressions.cs: -------------------------------------------------------------------------------- 1 | // This file is used by Code Analysis to maintain SuppressMessage 2 | // attributes that are applied to this project. 3 | // Project-level suppressions either have no target or are given 4 | // a specific target and scoped to a namespace, type, member, etc. 5 | 6 | using System.Diagnostics.CodeAnalysis; 7 | 8 | [assembly: SuppressMessage("Maintainability", "PRm1000:XML Comments Not Present", Justification = "", Scope = "namespaceanddescendants", Target = "~N:ConsoleApplicationBuilderTests")] 9 | [assembly: SuppressMessage("Maintainability", "PRm1010:Method Name Does Not Have Leading Transitive Verb", Justification = "", Scope = "namespaceanddescendants", Target = "~N:ConsoleApplicationBuilderTests")] 10 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/SclTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace CommandLineExtensionsTests; 4 | 5 | public class SclTests 6 | { 7 | [Fact] 8 | public void TestWithOptionAndArgument() 9 | { 10 | string[] args = ["--option", "optionValue", "argumentValue"]; 11 | var rootCommand = new RootCommand("Test command"); 12 | rootCommand.AddOption(new Option("--option", "Option description") { IsRequired = true}); 13 | rootCommand.AddArgument(new Argument("argument", "Argument description")); 14 | bool wasExecuted = false; 15 | rootCommand.SetHandler(_ => wasExecuted = true); 16 | var r = rootCommand.Parse(args); 17 | Assert.Empty(r.Errors); 18 | var r2 = rootCommand.Invoke(args); 19 | Assert.Equal(0, r2); 20 | Assert.True(wasExecuted); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineBuilderTestingBase.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Text; 3 | 4 | namespace CommandLineExtensionsTests; 5 | 6 | public class CommandLineBuilderTestingBase 7 | { 8 | protected StringBuilder OutStringBuilder { get; set; } 9 | protected StringBuilder ErrStringBuilder { get; set; } 10 | protected IConsole Console { get; set; } 11 | 12 | protected CommandLineBuilderTestingBase() 13 | { 14 | (OutStringBuilder, ErrStringBuilder, Console) = BuildConsoleSpy(); 15 | } 16 | 17 | protected static (StringBuilder outStringBuilder, StringBuilder errStringBuilder, IConsole console) BuildConsoleSpy() 18 | { 19 | var outStringBuilder = new StringBuilder(); 20 | var errStringBuilder = new StringBuilder(); 21 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 22 | return (outStringBuilder, errStringBuilder, console); 23 | } 24 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/Utility.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.IO; 3 | using System.Reflection; 4 | using System.Text; 5 | 6 | using NSubstitute; 7 | 8 | namespace CommandLineExtensionsTests; 9 | 10 | public static class Utility 11 | { 12 | public static string ExecutingTestRunnerName { get; } = Assembly.GetEntryAssembly()?.GetName().Name!; 13 | 14 | public static IConsole CreateConsoleSpy(StringBuilder outStringBuilder, StringBuilder errStringBuilder) 15 | { 16 | var console = Substitute.For(); 17 | 18 | var stdout = Substitute.For(); 19 | stdout.Write(Arg.Do(str => outStringBuilder.Append(str))); 20 | console.Out.Returns(stdout); 21 | 22 | var stderr = Substitute.For(); 23 | stderr.Write(Arg.Do(str => errStringBuilder.Append(str))); 24 | console.Error.Returns(stderr); 25 | return console; 26 | } 27 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/ServiceCollectionTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace ConsoleApplicationBuilderTests; 6 | 7 | public class ServiceCollectionTests 8 | { 9 | [Fact, Trait("Category", "Assumption")] 10 | public void CanFindSingleInServiceCollection() 11 | { 12 | IServiceCollection services = new ServiceCollection(); 13 | services.AddSingleton(new RootCommand()); 14 | var service = services.SingleOrDefault(e => e.Lifetime == ServiceLifetime.Singleton && e.ServiceType == typeof(RootCommand)); 15 | Assert.NotNull(service); 16 | #pragma warning disable IDE0028 // Simplify collection initialization 17 | RootCommand rootCommand = service.ImplementationInstance is RootCommand command 18 | ? command 19 | : new RootCommand(); 20 | #pragma warning restore IDE0028 // Simplify collection initialization 21 | Assert.NotNull(rootCommand); 22 | } 23 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/ITwoParameterSubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | public interface ITwoParameterSubcommandBuilder 6 | : IParameterConfiguration, TParam2> 7 | where TSubcommand : Command, new() 8 | { 9 | /// 10 | /// Add a command handler to the subcommand. 11 | /// 12 | /// The action to perform when the subcommand is encountered. 13 | /// 14 | TParentBuilder WithSubcommandHandler(Action action); 15 | 16 | /// 17 | /// Add a command handler of type to the subcommand. 18 | /// 19 | /// 20 | /// A type that implements ICommandHandler>TParam<. 21 | TParentBuilder WithSubcommandHandler() where THandler : class, ICommandHandler; 22 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Source area of bug 11 | 12 | - [ ] Console Application Builder 13 | - [ ] System.CommandLine Extensions 14 | 15 | ## Description of the bug 16 | 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Example code that produces the issue: 21 | 22 | ```csharp 23 | // TODO: 24 | ``` 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Success Criteria** 30 | How can the developers be use they've addressed the issue? Unit test? Assertion (value _x_ is _n_)? 31 | 32 | **Screenshots** 33 | If applicable, add screenshots to help explain your problem. 34 | 35 | **Desktop (please complete the following information):** 36 | 37 | - OS: [e.g. iOS] 38 | - Browser [e.g. chrome, safari] 39 | - Version [e.g. 22] 40 | 41 | **Additional context** 42 | Add any other context about the problem here. 43 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/IParameterConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine.Parsing; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | public interface IParameterConfiguration 6 | { 7 | /// 8 | /// Adds alias of name to the parameter. 9 | /// 10 | /// The subcommand alias name. 11 | /// 12 | T AddAlias(string alias); 13 | 14 | /// 15 | /// Set the System.CommandLine.ParseArgument delegate for the argument of type . 16 | /// 17 | /// The delegate to use when argument values are parsed. 18 | /// 19 | T WithArgumentParser(ParseArgument argumentParser); 20 | 21 | T WithDefault(TParam defaultValue); 22 | 23 | /// 24 | /// Set a parameter's description. 25 | /// 26 | /// The description of the parameter displayed when showing help. 27 | /// 28 | T WithDescription(string parameterDescription); 29 | 30 | } -------------------------------------------------------------------------------- /.azuredevops/pr-ConsoleApplicationBuilder-main.yml: -------------------------------------------------------------------------------- 1 | pr: 2 | - main 3 | 4 | pool: 5 | vmImage: ubuntu-latest 6 | 7 | variables: 8 | dotNetVersion: '8.x' 9 | buildConfiguration: 'Release' 10 | testProjects: | 11 | src/Tests/ConsoleApplicationBuilderTests/ConsoleApplicationBuilderTests.csproj 12 | src/Tests/CommandLineExtensionsTests/CommandLineExtensionsTests.csproj 13 | 14 | stages: 15 | - stage: build_test 16 | displayName: 'Build and Test' 17 | jobs: 18 | - job: Build 19 | displayName: 'Build and Test' 20 | steps: 21 | - task: UseDotNet@2 22 | displayName: 'Use .NET SDK $(dotNetVersion)' 23 | inputs: 24 | packageType: sdk 25 | version: $(dotNetVersion) 26 | 27 | - task: DotNetCoreCLI@2 28 | displayName: 'Dotnet Build/Test - $(buildConfiguration)' 29 | inputs: 30 | command: 'test' 31 | projects: $(testProjects) 32 | publishTestResults: true 33 | arguments: >- 34 | -c $(buildConfiguration) 35 | --nologo 36 | /clp:ErrorsOnly 37 | --collect "Code coverage" 38 | testRunTitle: 'Dotnet Test - $(buildConfiguration)' 39 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # ConsoleApplicationBuilder 2 | 3 | .NET has had a Dependency Injection (DI) feature for a while now. Out-of-the-box geneated ASP.NET applications and console worker project templates create startup code that creates a service collection and service provider (Dependency Injection Container), developers just need to add their services to the service collection and perform any configuration required. 4 | 5 | Except for simple console applications. 6 | 7 | Sometimes you just want to create the simplest of applications to do something very specific. A console application is good for that, but it doesn't have DI out of the box. The Console Worker template uses the .NET Generic Host, which does have DI out of the box. But the Console Worker template implements background worker functionality, which is bit heavy if you're just trying to do something simple, but with DI support. 8 | 9 | This is where ConsoleApplicationBuilder comes into play. 10 | 11 | ```csharp 12 | public class Program 13 | { 14 | public static void Main(string[] args) 15 | { 16 | var program = ConsoleApplication.CreateBuilder(args).Build(); 17 | program.Run(); 18 | } 19 | 20 | public void Run() 21 | { 22 | // ... 23 | } 24 | } 25 | ``` 26 | -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/ConfigureContainerShould.cs: -------------------------------------------------------------------------------- 1 | using Autofac; 2 | using Autofac.Extensions.DependencyInjection; 3 | 4 | using Pri.ConsoleApplicationBuilder; 5 | 6 | namespace ConsoleApplicationBuilderTests; 7 | 8 | public class ConfigureContainerShould 9 | { 10 | public class MyService; 11 | 12 | // ReSharper disable once ClassNeverInstantiated.Local 13 | private class ProgramWithDependency(MyService myService) 14 | { 15 | public MyService MyService { get; } = myService; 16 | } 17 | 18 | [Fact] 19 | public void WorkWithOtherProviderType() 20 | { 21 | var builder = ConsoleApplication.CreateBuilder([]); 22 | builder.ConfigureContainer(new AutofacServiceProviderFactory(), container => container.RegisterType()); 23 | var o = builder.Build(); 24 | Assert.NotNull(o); 25 | Assert.NotNull(o.MyService); 26 | } 27 | 28 | // ReSharper disable once ClassNeverInstantiated.Local 29 | private class Program; 30 | 31 | [Fact] 32 | public void WorkWithOtherProviderTypeWithNoAction() 33 | { 34 | var builder = ConsoleApplication.CreateBuilder([]); 35 | builder.ConfigureContainer(new AutofacServiceProviderFactory()); 36 | var o = builder.Build(); 37 | Assert.NotNull(o); 38 | } 39 | } -------------------------------------------------------------------------------- /adrs/repo-structure.md: -------------------------------------------------------------------------------- 1 | # Repo project structure 2 | 2025-01-12 3 | 4 | `/` 5 | - `src` 6 | - `.azuredevops` 7 | - `docs` 8 | - `scaffolding` 9 | 10 | ## Context 11 | 12 | There are a number of common practices to structuring a repository. Each repository builds off of many practices to establish their unique structure. 13 | 14 | ## Rationale 15 | - src 16 | - It's essential for one or more build pipelines to be triggered independently by changes to the source code. The source code is the most important part of the repository, so it should be at the root of the repository. 17 | - contains all the content that will be used to generate deployables 18 | - .azuredevops 19 | - It's useful for one or more piplines to be triggered idenpendently by changes to Azure DevOps configuration. `.azuredevops` is a recognized location for storing Azure DevOps configuration files. \[[1][pr-templates]\] 20 | - docs 21 | - chosen to be indepedent of other directories that may be used to trigger a pipeline 22 | - scaffolding 23 | - chosen to be indepedent of other directories that may be used to trigger a pipeline 24 | 25 | ## References 26 | - 1: [Improve pull request descriptions using templates][pr-templates] 27 | 28 | [pr-templates]: https://learn.microsoft.com/en-us/azure/devops/repos/git/pull-request-templates?view=azure-devops 29 | -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/ConsoleApplicationBuilderSettings.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace Pri.ConsoleApplicationBuilder; 5 | 6 | public class ConsoleApplicationBuilderSettings 7 | { 8 | // Option collection, and RootCommand? 9 | /// 10 | /// Gets or sets the initial configuration sources to be added to the . These sources can influence 11 | /// the through the use of keys. Disposing the built 12 | /// disposes the . 13 | /// 14 | public ConfigurationManager? Configuration { get; init; } 15 | /// 16 | /// Gets or sets the command-line arguments to add to the . 17 | /// 18 | public string[]? Args { get; init; } 19 | 20 | /// 21 | /// Gets or sets the environment name. 22 | /// 23 | public string? EnvironmentName { get; init; } 24 | 25 | /// 26 | /// Gets or sets the application name. 27 | /// 28 | public string? ApplicationName { get; init; } 29 | 30 | /// 31 | /// Gets or sets the content root path. 32 | /// 33 | public string? ContentRootPath { get; init; } 34 | } -------------------------------------------------------------------------------- /src/dotnet-new-template/content/consoleapp/Company.ConsoleApplication1.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | TargetFrameworkOverride 7 | Company.ConsoleApplication1 8 | $(ProjectLanguageVersion) 9 | enable 10 | enable 11 | 12 | true 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | PreserveNewest 24 | appsettings.json 25 | 26 | 27 | PreserveNewest 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsWithTwoOptionsAndArgumentParserShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Pri.CommandLineExtensions; 4 | using Pri.ConsoleApplicationBuilder; 5 | 6 | namespace CommandLineExtensionsTests; 7 | 8 | public class CommandLineExtensionsWithTwoOptionsAndArgumentParserShould 9 | { 10 | [Fact] 11 | public void ParseAllArguments() 12 | { 13 | string[] args = [ 14 | "--source-folder", @"C:\Users\peter\AppData\Local\Temp", "--file", @"C:\1F482A2D-5EF3-4228-983E-D5A12AD8FF81" 15 | ]; 16 | bool firstArgParserInvoked = false, secondArgParserInvoked = false, handlerInvoked = false; 17 | 18 | var builder = ConsoleApplication.CreateBuilder(args); 19 | builder.Services.AddCommand() 20 | .WithDescription("Update a WxS file with contents from a folder") 21 | .WithRequiredOption("--file", "The input WxS file to update") 22 | .WithArgumentParser((result) => 23 | { 24 | firstArgParserInvoked = true; 25 | return new FileInfo(result.Tokens[0].Value); 26 | }) 27 | .WithRequiredOption("--source-folder", "The directory containing the files to include") 28 | .WithArgumentParser((result) => 29 | { 30 | secondArgParserInvoked = true; 31 | return new DirectoryInfo(result.Tokens[0].Value); 32 | }) 33 | .WithHandler((wxsFile, sourceFolder) => 34 | { 35 | handlerInvoked = true; 36 | }); 37 | 38 | var returnCode = builder.Build().Invoke/*Async*/(args); 39 | Assert.Equal(0, returnCode); 40 | Assert.True(firstArgParserInvoked); 41 | Assert.True(secondArgParserInvoked); 42 | Assert.True(handlerInvoked); 43 | } 44 | } -------------------------------------------------------------------------------- /src/dotnet-new-template/content/consoleapp/Program.cs: -------------------------------------------------------------------------------- 1 | #if (!csharpFeature_ImplicitUsings) 2 | using System; 3 | #endif 4 | using Microsoft.Extensions.Logging; 5 | 6 | using Pri.ConsoleApplicationBuilder; 7 | 8 | #if (csharpFeature_FileScopedNamespaces) 9 | namespace Company.ConsoleApplication1; 10 | 11 | #if (csharp10orLater) 12 | class Program(ILogger logger) 13 | { 14 | #else 15 | class Program 16 | { 17 | public Program(ILogger logger) 18 | { 19 | this.logger = logger; 20 | } 21 | 22 | private readonly ILogger logger; 23 | 24 | #endif 25 | static void Main(string[] args) 26 | { 27 | var builder = ConsoleApplication.CreateBuilder(args); 28 | var program = builder.Build(); 29 | program.Run(); 30 | } 31 | 32 | private void Run() 33 | { 34 | logger.LogInformation("Hello, World!"); 35 | } 36 | } 37 | #else 38 | namespace Company.ConsoleApplication1 39 | { 40 | #if (csharp10orLater) 41 | class Program(ILogger logger) 42 | { 43 | #else 44 | class Program 45 | { 46 | public Program(ILogger logger) 47 | { 48 | this.logger = logger; 49 | } 50 | 51 | private readonly ILogger logger; 52 | 53 | #endif 54 | static void Main(string[] args) 55 | { 56 | var builder = ConsoleApplication.CreateBuilder(args); 57 | var program = builder.Build(); 58 | program.Run(); 59 | } 60 | 61 | private void Run() 62 | { 63 | logger.LogInformation("Hello, World!"); 64 | } 65 | } 66 | } 67 | #endif -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/ApplicationEnvironmentShould.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.FileProviders; 3 | 4 | using Pri.ConsoleApplicationBuilder; 5 | 6 | namespace ConsoleApplicationBuilderTests; 7 | 8 | [Collection("Isolated Execution Collection")] 9 | public class ApplicationEnvironmentShould 10 | { 11 | [Fact ] 12 | public void HaveReflectedApplicationName() 13 | { 14 | string[] args = []; 15 | if (Utility.ExecutingTestRunnerName == Constants.ReSharperTestRunnerName) 16 | { 17 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); 18 | } 19 | var builder = ConsoleApplication.CreateBuilder(args); 20 | Assert.True(builder.Environment.ApplicationName is Constants.VisualStudioTestRunnerName or Constants.ReSharperTestRunnerName); 21 | Assert.Equal("Production", builder.Environment.EnvironmentName); 22 | Assert.IsType(builder.Environment.ContentRootFileProvider); 23 | } 24 | 25 | [Fact] 26 | public void ProperlyInjectConfiguration() 27 | { 28 | string[] args = []; 29 | var builder = ConsoleApplication.CreateBuilder(args); 30 | var o = builder.Build(); 31 | Assert.NotNull(o.Configuration); 32 | } 33 | 34 | [Fact] 35 | public void ProperlyAddCommandLineArgsToConfiguration() 36 | { 37 | string[] args = ["--key=value"]; 38 | var builder = ConsoleApplication.CreateBuilder(args); 39 | var o = builder.Build(); 40 | Assert.Equal("value", o.Configuration["key"]); 41 | } 42 | 43 | // ReSharper disable once ClassNeverInstantiated.Local 44 | private class Program(IConfiguration configuration) 45 | { 46 | public IConfiguration Configuration { get; } = configuration; 47 | } 48 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/Pri.ConsoleApplicationBuilder.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | True 8 | Console Application Builder 9 | Peter Ritchie 10 | Console Application Builder 11 | An application-builder pattern implementation for .NET console applications. ConsoleApplicationBuilder supports dependency injection (including third-party containers), logging, and configuration (json file, environment names, command-line arguments, environment variables). 12 | ©️ 2025 Peter Ritchie 13 | https://github.com/peteraritchie/ConsoleApplicationBuilder 14 | README.md 15 | icon.png 16 | https://github.com/peteraritchie/ConsoleApplicationBuilder 17 | git 18 | dotnet .net console application-builder dependency-injection 19 | True 20 | snupkg 21 | PRI.ConsoleApplicationBuilder 22 | 23 | 24 | 25 | 26 | True 27 | \ 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | True 38 | \ 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/dotnet-new-template/Pri.ConsoleApplicationBuilder.Templates.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PRI.ConsoleApplicationBuilder.Templates 6 | Console application template based on ConsoleApplicationBuilder. 7 | Peter Ritchie 8 | A template for console application that supports standard .NET dependency injection, configuration, and environment names 9 | dotnet-new templates ConsoleApplicationBuilder 10 | https://github.com/peteraritchie/ConsoleApplicationBuilder 11 | 12 | Template 13 | net8.0 14 | true 15 | false 16 | content 17 | $(NoWarn);NU5128 18 | true 19 | README.md 20 | 21 | 22 | 23 | false 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/ICommandHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Pri.CommandLineExtensions; 2 | 3 | public interface ICommandHandler 4 | { 5 | int Execute(); 6 | } 7 | 8 | public interface ICommandHandler 9 | { 10 | int Execute(TParam paramValue); 11 | } 12 | 13 | public interface ICommandHandler 14 | { 15 | int Execute(TParam1 param1Value, TParam2 param2Value); 16 | } 17 | 18 | //public class GenericCommandHandler : ICommandHandler 19 | //{ 20 | // private readonly Func? asyncFunc; 21 | // private readonly Action? syncAction; 22 | 23 | // public GenericCommandHandler(Func asyncFunc) 24 | // { 25 | // this.asyncFunc = asyncFunc; 26 | // } 27 | 28 | // public GenericCommandHandler(Action syncAction) 29 | // { 30 | // this.syncAction = syncAction; 31 | // } 32 | 33 | // public int Invoke(InvocationContext context) 34 | // { 35 | // if (syncAction is not null) 36 | // { 37 | // syncAction(context); 38 | // return context.ExitCode; 39 | // } 40 | // return SyncUsingAsync(context); 41 | // } 42 | // private int SyncUsingAsync(InvocationContext context) => InvokeAsync(context).GetAwaiter().GetResult(); 43 | 44 | // public async Task InvokeAsync(InvocationContext context) 45 | // { 46 | // if (syncAction is not null) 47 | // { 48 | // syncAction(context); 49 | // return context.ExitCode; 50 | // } 51 | 52 | // object returnValue = asyncFunc!(context); 53 | 54 | // int ret; 55 | 56 | // switch (returnValue) 57 | // { 58 | // case Task exitCodeTask: 59 | // ret = await exitCodeTask; 60 | // break; 61 | // case Task task: 62 | // await task; 63 | // ret = context.ExitCode; 64 | // break; 65 | // case int exitCode: 66 | // ret = exitCode; 67 | // break; 68 | // default: 69 | // ret = context.ExitCode; 70 | // break; 71 | // } 72 | 73 | // return ret; 74 | // } 75 | //} 76 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/Pri.CommandLineExtensions.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | True 8 | System.CommandLine Extensions 9 | Peter Ritchie 10 | Console Application Builder 11 | System.CommandLine extensions to support .NET Dendency Injection.. 12 | ©️ 2025 Peter Ritchie 13 | https://github.com/peteraritchie/ConsoleApplicationBuilder 14 | README.md 15 | icon.png 16 | https://github.com/peteraritchie/ConsoleApplicationBuilder 17 | git 18 | dotnet .net console System.CommandLine command-line dependency-injection 19 | True 20 | snupkg 21 | PRI.CommandLineExtensions 22 | 23 | 24 | 25 | 26 | True 27 | \ 28 | 29 | 30 | 31 | 32 | 33 | True 34 | \ 35 | 36 | 37 | 38 | 39 | 1701;1702;NU5104 40 | 0 41 | 42 | 43 | 44 | 1701;1702;NU5104 45 | 0 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/ISubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | public interface ISubcommandBuilder 6 | : ICommandConfiguration> 7 | where TSubcommand : Command, new() 8 | { 9 | /// 10 | /// Adds alias of name to the command. 11 | /// 12 | /// The command alias name. 13 | /// 14 | ISubcommandBuilder AddAlias(string commandAlias); 15 | 16 | /// 17 | /// Adds an argument of type to the subcommand. 18 | /// 19 | /// The type of the argument. 20 | /// The name of the argument when. 21 | /// The description of the argument. 22 | /// 23 | IOneParameterSubcommandBuilder WithArgument(string name, string description); 24 | 25 | /// 26 | /// Adds an option of type to the subcommand. 27 | /// 28 | /// The type of the option. 29 | /// The name of the option when. 30 | /// The description of the option. 31 | /// 32 | IOneParameterSubcommandBuilder WithOption(string name, string description); 33 | 34 | /// 35 | /// Adds a strongly-typed required option of type to the command. 36 | /// 37 | /// The type of the option. 38 | /// The name of the option when provided on the command line. 39 | /// The description of the option. 40 | /// 41 | IOneParameterSubcommandBuilder WithRequiredOption(string name, string description); 42 | 43 | /// 44 | /// Add a command handler to the subcommand. 45 | /// 46 | /// The action to perform when the subcommand is encountered. 47 | /// 48 | TParentBuilder WithSubcommandHandler(Action action); 49 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/CommandLineCommandBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Pri.CommandLineExtensions; 6 | 7 | /// 8 | /// Entry point for adding a command line command to the service collection. 9 | /// 10 | public static class CommandLineCommandBuilderExtensions 11 | { 12 | public static ICommandBuilder AddCommand(this IServiceCollection services) 13 | { 14 | if (services.Any(e => e.ServiceType == typeof(RootCommand))) throw new InvalidOperationException("RootCommand already registered in service collection."); 15 | 16 | CommandBuilder commandBuilder = new(services, new RootCommand()); 17 | return commandBuilder; 18 | } 19 | 20 | public static ICommandBuilder AddCommand(this IServiceCollection services) where TCommand : RootCommand, new() 21 | { 22 | if (services.Any(e => e.ServiceType == typeof(TCommand))) throw new InvalidOperationException($"{typeof(TCommand).Name} already registered in service collection."); 23 | 24 | CommandBuilder commandBuilder = new(services, typeof(TCommand)); 25 | return commandBuilder; 26 | } 27 | 28 | /// 29 | /// Add a command with a factory delegate. 30 | /// 31 | /// 32 | /// This is useful when the Command type doesn't have a default constructor. 33 | /// 34 | /// 35 | /// 36 | /// 37 | public static ICommandBuilder AddCommand(this IServiceCollection services, Func factory) 38 | where TCommand : Command 39 | { 40 | if (services.Any(e => e.ServiceType == typeof(TCommand))) throw new InvalidOperationException($"{typeof(TCommand).Name} already registered in service collection."); 41 | 42 | CommandBuilder commandBuilder = new(services, typeof(TCommand), factory); 43 | return commandBuilder; 44 | } 45 | 46 | public static ICommandBuilder AddCommand(this IServiceCollection services, TCommand command) 47 | where TCommand : Command, new() 48 | { 49 | if (services.Any(e => e.ServiceType == typeof(TCommand))) throw new InvalidOperationException($"{typeof(TCommand).Name} already registered in service collection."); 50 | 51 | CommandBuilder commandBuilder = new(services, command); 52 | return commandBuilder; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/CreatingApplicationInstanceDependentOnServiceShould.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | using Pri.ConsoleApplicationBuilder; 5 | 6 | namespace ConsoleApplicationBuilderTests; 7 | 8 | public class CreatingApplicationInstanceDependentOnServiceShould 9 | { 10 | [Fact] 11 | public void FailIfDependentServiceNotDeclared() 12 | { 13 | string[] args = []; 14 | var builder = ConsoleApplication.CreateBuilder(args); 15 | var ex = Assert.Throws(() => _ = builder.Build()); 16 | Assert.Equal($"Unable to resolve service for type 'System.Net.Http.HttpClient' while attempting to activate '{typeof(Program).FullName}'.", ex.Message); 17 | } 18 | 19 | [Fact] 20 | public void BuildCorrectlyBuildCorrectly() 21 | { 22 | string[] args = []; 23 | var builder = ConsoleApplication.CreateBuilder(args); 24 | builder.Services.AddHttpClient(httpClient => httpClient.BaseAddress = new Uri("https://example.com")); 25 | var o = builder.Build(); 26 | Assert.NotNull(o); 27 | } 28 | 29 | [Fact] 30 | public void ProperlyInjectConfiguration() 31 | { 32 | string[] args = []; 33 | var builder = ConsoleApplication.CreateBuilder(args); 34 | builder.Services.AddHttpClient(httpClient => httpClient.BaseAddress = new Uri("https://example.com")); 35 | var o = builder.Build(); 36 | Assert.NotNull(o.Configuration); 37 | } 38 | 39 | [Fact] 40 | public void ProperlyInjectService() 41 | { 42 | string[] args = []; 43 | var builder = ConsoleApplication.CreateBuilder(args); 44 | builder.Services.AddHttpClient(httpClient => httpClient.BaseAddress = new Uri("https://example.com")); 45 | var o = builder.Build(); 46 | Assert.NotNull(o.HttpClient); 47 | Assert.Equal("https://example.com/", o.HttpClient.BaseAddress!.ToString()); 48 | } 49 | 50 | [Fact] 51 | public void ProperlyAddCommandLineArgsToConfiguration() 52 | { 53 | string[] args = ["--key=value"]; 54 | var builder = ConsoleApplication.CreateBuilder(args); 55 | builder.Services.AddHttpClient(httpClient => httpClient.BaseAddress = new Uri("https://example.com")); 56 | var o = builder.Build(); 57 | Assert.Equal("value", o.Configuration["key"]); 58 | } 59 | 60 | private class Program(IConfiguration configuration, HttpClient httpClient) 61 | { 62 | public IConfiguration Configuration { get; } = configuration; 63 | public HttpClient HttpClient { get; } = httpClient; 64 | } 65 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/ITwoParameterCommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Pri.CommandLineExtensions; 6 | 7 | public interface ITwoParameterCommandBuilder 8 | : IParameterConfiguration, TParam2>, IBuilderState 9 | { 10 | // TODO: WithArgument 11 | // TODO: WithArgumentParser 12 | 13 | /// 14 | /// Adds a handler lambda/anonymous method. 15 | /// 16 | /// 17 | /// This completes the command-line builder. 18 | /// 19 | /// The action to invoke when the command is encountered. 20 | /// 21 | IServiceCollection WithHandler(Action action); 22 | 23 | /// 24 | /// Adds a handler object of type . 25 | /// 26 | /// 27 | /// This completes the command-line builder. 28 | /// 29 | /// The implementation to use. 30 | /// 31 | IServiceCollection WithHandler() where THandler : class, ICommandHandler; 32 | 33 | /// 34 | /// Adds a handler lambda/anonymous method. 35 | /// 36 | /// 37 | /// This completes the command-line builder. 38 | /// 39 | /// The action to invoke when the command is encountered. 40 | /// 41 | IServiceCollection WithHandler(Func func); 42 | 43 | /// 44 | /// Adds a handler lambda/anonymous method. 45 | /// 46 | /// 47 | /// This completes the command-line builder. 48 | /// 49 | /// The action to invoke when the command is encountered. 50 | /// 51 | IServiceCollection WithHandler(Func func); 52 | 53 | // TODO: WithOption 54 | // TODO: WithRequiredOption 55 | 56 | /// 57 | /// Adds a subcommand of type to the command. 58 | /// 59 | /// 60 | /// This completes the command-line builder. 61 | /// 62 | /// The type of subcommand to add to the command. 63 | /// 64 | ISubcommandBuilder> WithSubcommand() where TSubcommand : Command, new(); 65 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/IOneParameterSubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | namespace Pri.CommandLineExtensions; 4 | 5 | public interface IOneParameterSubcommandBuilder 6 | : IParameterConfiguration, TParam> 7 | where TSubcommand : Command, new() 8 | { 9 | /// 10 | /// Adds an argument of type to the subcommand. 11 | /// 12 | /// The type of the argument. 13 | /// The name of the argument when. 14 | /// The description of the argument. 15 | /// 16 | ITwoParameterSubcommandBuilder WithArgument(string name, string description); 17 | 18 | /// 19 | /// Adds an option of type to the subcommand. 20 | /// 21 | /// The type of the option. 22 | /// The name of the option when. 23 | /// The description of the option. 24 | /// 25 | ITwoParameterSubcommandBuilder WithOption(string name, string description); 26 | 27 | /// 28 | /// Adds a strongly-typed required option of type to the command. 29 | /// 30 | /// The type of the option. 31 | /// The name of the option when provided on the command line. 32 | /// The description of the option. 33 | /// 34 | ITwoParameterSubcommandBuilder WithRequiredOption(string name, string description); 35 | 36 | /// 37 | /// Add a command handler to the subcommand. 38 | /// 39 | /// The action to perform when the subcommand is encountered. 40 | /// 41 | TParentBuilder WithSubcommandHandler(Action action); 42 | 43 | /// 44 | /// Add a command handler of type to the subcommand. 45 | /// 46 | /// 47 | /// A type that implements ICommandHandler>TParam<. 48 | TParentBuilder WithSubcommandHandler() where THandler : class, ICommandHandler; 49 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/ConsoleApplicationBuilderTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | 34 | 35 | runtime; build; native; contentfiles; analyzers; buildtransitive 36 | all 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | PreserveNewest 55 | 56 | 57 | PreserveNewest 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /scaffolding/scaffold-console-application.ps1: -------------------------------------------------------------------------------- 1 | . $env:USERPROFILE\project.ps1 2 | 3 | $solutionName = 'ConsoleApplicationBuilder'; 4 | $rootDir = $solutionName; 5 | $srcDir = Join-Path $solutionName 'src'; 6 | 7 | if(Test-Path $rootDir) { 8 | throw "Directory `"$rootDir`" already exists!"; 9 | } 10 | $rootNamespace = "Pri.$solutionName"; 11 | 12 | # supporting files ⬇️ ########################################################## 13 | 14 | dotnet new gitignore -o $rootDir 2>&1 >NUL || $(throw 'error creating .gitignore'); 15 | dotnet new editorconfig -o $srcDir 2>&1 >NUL || $(throw 'error creating .editorconfig'); 16 | $text = (get-content -Raw $srcDir\.editorconfig); 17 | $text = ($text -replace "(\[\*\]`r`nindent_style\s*=\s)space","`$1tab"); 18 | $text = $text.Replace('dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase', 'dotnet_naming_rule.private_fields_should_be_camelcase.style = camelcase'); 19 | $text = $text.Replace('dotnet_naming_rule.private_fields_should_be__camelcase', 'dotnet_naming_rule.private_fields_should_be_camelcase'); 20 | $text = $text.Replace('dotnet_naming_rule.private_fields_should_be__camelcase', 'dotnet_naming_rule.private_fields_should_be_camelcase'); 21 | Set-Content -Path $srcDir\.editorconfig -Value $text; 22 | 23 | # source files ⬇️ ############################################################## 24 | 25 | $solution = [Solution]::Create($srcDir, $solutionName); 26 | 27 | $libraryProject = $solution.NewClassLibraryProject("$($rootNamespace)"); 28 | $libraryProject.AddPackageReference("Microsoft.Extensions.Hosting"); 29 | $libraryProject.AddPackageReference("Microsoft.Extensions.Http"); 30 | 31 | $testProject = $solution.NewTestProject("$($rootNamespace).Tests", $libraryProject); 32 | $testProject.UpdatePackageReference('xunit'); 33 | $testProject.UpdatePackageReference('xunit.runner.visualstudio'); 34 | 35 | ## Create readme ############################################################### 36 | Set-Content -Path $rootDir\README.md -Value "# $solutionName`r`n`r`n## Scaffolding`r`n`r`n``````powershell"; 37 | 38 | foreach($cmd in $solution.ExecutedCommands) 39 | { 40 | Add-Content -Path $rootDir\README.md -Value $cmd; 41 | } 42 | Add-Content -Path $rootDir\README.md -Value ``````; 43 | 44 | ################################################################################ 45 | md "$($rootDir)\scaffolding"; 46 | copy scaffold-console-application.ps1 "$($rootDir)\scaffolding"; 47 | 48 | # git init ##################################################################### 49 | git init $rootDir; 50 | git --work-tree=$rootDir --git-dir=$rootDir/.git add .; 51 | git --work-tree=$rootDir --git-dir=$rootDir/.git commit -m "initial commit"; 52 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsWithCommandFactoryShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Text; 3 | 4 | using CommandLineExtensionsTests.TestDoubles; 5 | 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Logging; 8 | 9 | using Pri.CommandLineExtensions; 10 | using Pri.ConsoleApplicationBuilder; 11 | 12 | namespace CommandLineExtensionsTests; 13 | 14 | public class CommandLineExtensionsWithCommandFactoryShould 15 | { 16 | [Fact] 17 | public void Build() 18 | { 19 | string[] args = []; 20 | bool lambdaInvoked = false; 21 | 22 | var command = BuildCommand(args, sp => 23 | { 24 | lambdaInvoked = true; 25 | return new MainRootCommand(sp.GetRequiredService>()); 26 | }, 27 | () => { }); 28 | 29 | Assert.True(lambdaInvoked); 30 | Assert.NotNull(command); 31 | } 32 | 33 | [Fact] 34 | public void Invoke() 35 | { 36 | string[] args = []; 37 | bool lambdaInvoked = false; 38 | bool handlerInvoked = false; 39 | var command = BuildCommand(args, sp => 40 | { 41 | lambdaInvoked = true; 42 | return new MainRootCommand(sp.GetRequiredService>()); 43 | }, 44 | () => handlerInvoked = true); 45 | 46 | command.Invoke([]); 47 | 48 | Assert.True(lambdaInvoked); 49 | Assert.True(handlerInvoked); 50 | } 51 | 52 | [Fact] 53 | public void OutputHelp() 54 | { 55 | string[] args = []; 56 | bool lambdaInvoked = false; 57 | bool handlerInvoked = false; 58 | 59 | var outStringBuilder = new StringBuilder(); 60 | var errStringBuilder = new StringBuilder(); 61 | var command = BuildCommand(args, sp => 62 | { 63 | lambdaInvoked = true; 64 | return new MainRootCommand(sp.GetRequiredService>()); 65 | }, 66 | () => handlerInvoked = true); 67 | 68 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 69 | 70 | command.Invoke(["--help"], console); 71 | 72 | Assert.Equal($""" 73 | Description: 74 | 75 | Usage: 76 | {Utility.ExecutingTestRunnerName} [options] 77 | 78 | Options: 79 | --version Show version information 80 | -?, -h, --help Show help and usage information 81 | 82 | 83 | 84 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 85 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 86 | Assert.True(lambdaInvoked); 87 | Assert.False(handlerInvoked); 88 | } 89 | 90 | private static MainRootCommand BuildCommand(string[] args, Func factory, Action action) 91 | { 92 | var builder = ConsoleApplication.CreateBuilder(args); 93 | builder.Services.AddCommand(factory) 94 | .WithHandler(action); 95 | return builder.Build(); 96 | } 97 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsWithArgumentParserShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | using System.Text; 4 | 5 | using CommandLineExtensionsTests.TestDoubles; 6 | 7 | using Pri.CommandLineExtensions; 8 | using Pri.ConsoleApplicationBuilder; 9 | 10 | namespace CommandLineExtensionsTests; 11 | 12 | public class CommandLineExtensionsWithOptionAndArgumentParserShould 13 | { 14 | [Fact] 15 | public void Build() 16 | { 17 | string[] args = ["--count","2"]; 18 | bool handlerInvoked = false; 19 | bool parserInvoked = false; 20 | var command = BuildCommand(args, _ => handlerInvoked = true, (result) => 21 | { 22 | parserInvoked = true; 23 | return int.Parse(result.Tokens[0].Value); 24 | }); 25 | Assert.NotNull(command); 26 | Assert.False(handlerInvoked); 27 | Assert.False(parserInvoked); 28 | } 29 | 30 | [Fact] 31 | public void Invoke() 32 | { 33 | string[] args = ["--count","2"]; 34 | int actualCount = -1; 35 | bool handlerInvoked = false; 36 | bool parserInvoked = false; 37 | var command = BuildCommand(args, 38 | count => 39 | { 40 | handlerInvoked = true; 41 | actualCount = count; 42 | }, 43 | result => 44 | { 45 | parserInvoked = true; 46 | return int.Parse(result.Tokens[0].Value); 47 | }); 48 | command.Invoke(args); 49 | Assert.True(handlerInvoked); 50 | Assert.True(parserInvoked); 51 | Assert.Equal(2, actualCount); 52 | } 53 | 54 | [Fact] 55 | public void OutputHelp() 56 | { 57 | string[] args = ["--count", "2"]; 58 | bool handlerInvoked = false; 59 | bool parserInvoked = false; 60 | var command = BuildCommand(args, _ => handlerInvoked = true, (result) => 61 | { 62 | parserInvoked = true; 63 | return int.Parse(result.Tokens[0].Value); 64 | }); 65 | var outStringBuilder = new StringBuilder(); 66 | var errStringBuilder = new StringBuilder(); 67 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 68 | 69 | command.Invoke(["--help"], console); 70 | Assert.Equal($""" 71 | Description: 72 | 73 | Usage: 74 | {Utility.ExecutingTestRunnerName} [options] 75 | 76 | Options: 77 | --count number of times to repeat. 78 | --version Show version information 79 | -?, -h, --help Show help and usage information 80 | 81 | 82 | 83 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 84 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 85 | Assert.False(handlerInvoked); 86 | Assert.False(parserInvoked); 87 | } 88 | 89 | private static NullCommand BuildCommand(string[] args, Action action, ParseArgument argumentParser) 90 | { 91 | var builder = ConsoleApplication.CreateBuilder(args); 92 | builder.Services.AddCommand(new NullCommand()) 93 | .WithOption("--count", "number of times to repeat.") 94 | .WithArgumentParser(argumentParser) 95 | .WithHandler(action); 96 | return builder.Build(); 97 | } 98 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/TwoParameterDefaultValueTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using CommandLineExtensionsTests.TestDoubles; 4 | 5 | using Pri.CommandLineExtensions; 6 | using Pri.ConsoleApplicationBuilder; 7 | 8 | namespace CommandLineExtensionsTests; 9 | 10 | public class TwoParameterDefaultValueTests : CommandLineBuilderTestingBase 11 | { 12 | [Fact] 13 | public void OutputHelpWithDefaultValueCorrectly() 14 | { 15 | string[] args = []; 16 | var builder = ConsoleApplication.CreateBuilder(args); 17 | builder.Services.AddCommand(new NullCommand()) 18 | .WithOption("--count", "number of times to repeat.") 19 | .WithDefault(1) 20 | .WithOption("--delay", "time in ms between repeatst.") 21 | .WithHandler((_,_) => { }); 22 | var command = builder.Build(); 23 | 24 | Assert.Equal(0, command.Invoke(["--help"], Console)); 25 | Assert.Equal($""" 26 | Description: 27 | 28 | Usage: 29 | {Utility.ExecutingTestRunnerName} [options] 30 | 31 | Options: 32 | --count number of times to repeat. [default: 1] 33 | --delay time in ms between repeatst. 34 | --version Show version information 35 | -?, -h, --help Show help and usage information 36 | 37 | 38 | 39 | """.ReplaceLineEndings(), OutStringBuilder.ToString()); 40 | 41 | } 42 | 43 | [Fact] 44 | public void BuildWithDefaultValueCorrectly() 45 | { 46 | string[] args = []; 47 | var builder = ConsoleApplication.CreateBuilder(args); 48 | builder.Services.AddCommand(new NullCommand()) 49 | .WithOption("--count", "number of times to repeat.") 50 | .WithDefault(1) 51 | .WithOption("--delay", "time in ms between repeatst.") 52 | .WithHandler((_,_) => { }); 53 | var command = builder.Build(); 54 | Assert.NotNull(command); 55 | Assert.NotNull(command.Description); 56 | Assert.Empty(command.Description); 57 | Assert.Equal(2, command.Options.Count); 58 | var option = command.Options[0]; 59 | Assert.Equal("number of times to repeat.", option.Description); 60 | Assert.Equal("count", option.Name); 61 | option = command.Options[1]; 62 | Assert.Equal("time in ms between repeatst.", option.Description); 63 | Assert.Equal("delay", option.Name); 64 | } 65 | 66 | [Fact] 67 | public void InvokeWithDefaultValueCorrectly() 68 | { 69 | string[] args = []; 70 | var builder = ConsoleApplication.CreateBuilder(args); 71 | int givenCount = 0; 72 | int givenDelay = 0; 73 | bool wasExecuted = false; 74 | builder.Services.AddCommand(new NullCommand()) 75 | .WithOption("--count", "number of times to repeat.") 76 | .WithDefault(1) 77 | .WithOption("--delay", "time in ms between repeatst.") 78 | .WithDefault(2) 79 | .WithHandler((c,d) => 80 | { 81 | givenCount = c; 82 | givenDelay = d; 83 | wasExecuted = true; 84 | }); 85 | var command = builder.Build(); 86 | Assert.Equal(0, command.Invoke(args)); 87 | Assert.True(wasExecuted); 88 | Assert.Equal(1, givenCount); 89 | Assert.Equal(2, givenDelay); 90 | } 91 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsWithArgumentAndArgumentParserShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | using System.Text; 4 | 5 | using CommandLineExtensionsTests.TestDoubles; 6 | 7 | using Pri.CommandLineExtensions; 8 | using Pri.ConsoleApplicationBuilder; 9 | 10 | namespace CommandLineExtensionsTests; 11 | 12 | public class CommandLineExtensionsWithArgumentAndArgumentParserShould 13 | { 14 | [Fact] 15 | public void Build() 16 | { 17 | string[] args = ["2"]; 18 | bool handlerInvoked = false; 19 | bool parserInvoked = false; 20 | var command = BuildCommand(args, _ => handlerInvoked = true, (result) => 21 | { 22 | parserInvoked = true; 23 | return int.Parse(result.Tokens[0].Value); 24 | }); 25 | Assert.NotNull(command); 26 | Assert.False(handlerInvoked); 27 | Assert.False(parserInvoked); 28 | } 29 | 30 | [Fact] 31 | public void Invoke() 32 | { 33 | string[] args = ["2"]; 34 | int actualCount = -1; 35 | bool handlerInvoked = false; 36 | bool parserInvoked = false; 37 | var command = BuildCommand(args, 38 | count => 39 | { 40 | handlerInvoked = true; 41 | actualCount = count; 42 | }, 43 | result => 44 | { 45 | parserInvoked = true; 46 | return int.Parse(result.Tokens[0].Value); 47 | }); 48 | command.Invoke(args); 49 | Assert.True(handlerInvoked); 50 | Assert.True(parserInvoked); 51 | Assert.Equal(2, actualCount); 52 | } 53 | 54 | [Fact] 55 | public void OutputHelp() 56 | { 57 | string[] args = ["2"]; 58 | bool handlerInvoked = false; 59 | bool parserInvoked = false; 60 | var command = BuildCommand(args, _ => handlerInvoked = true, (result) => 61 | { 62 | parserInvoked = true; 63 | return int.Parse(result.Tokens[0].Value); 64 | }); 65 | var outStringBuilder = new StringBuilder(); 66 | var errStringBuilder = new StringBuilder(); 67 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 68 | 69 | command.Invoke(["--help"], console); 70 | Assert.Equal($""" 71 | Description: 72 | 73 | Usage: 74 | {Utility.ExecutingTestRunnerName} [options] 75 | 76 | Arguments: 77 | number of times to repeat. 78 | 79 | Options: 80 | --version Show version information 81 | -?, -h, --help Show help and usage information 82 | 83 | 84 | 85 | 86 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 87 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 88 | Assert.False(handlerInvoked); 89 | Assert.False(parserInvoked); 90 | } 91 | 92 | private static NullCommand BuildCommand(string[] args, Action action, ParseArgument argumentParser) 93 | { 94 | var builder = ConsoleApplication.CreateBuilder(args); 95 | builder.Services.AddCommand(new NullCommand()) 96 | .WithArgument("count", "number of times to repeat.") 97 | .WithArgumentParser(argumentParser) 98 | .WithHandler(action); 99 | return builder.Build(); 100 | } 101 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/SubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.DependencyInjection.Extensions; 5 | 6 | namespace Pri.CommandLineExtensions; 7 | 8 | internal class SubcommandBuilder 9 | : SubcommandBuilderBase, ISubcommandBuilder 10 | where TSubcommand : Command, new() 11 | where TParentBuilder : IBuilderState 12 | { 13 | internal TParentBuilder ParentBuilder { get; } 14 | private Func? handler; 15 | 16 | public SubcommandBuilder(TParentBuilder parentBuilder) : base(parentBuilder.Services) 17 | { 18 | CommandType = typeof(TSubcommand); 19 | ParentBuilder = parentBuilder; 20 | } 21 | 22 | /// 23 | public ISubcommandBuilder AddAlias(string subcommandAlias) 24 | { 25 | SubcommandAlias = subcommandAlias; 26 | 27 | return this; 28 | } 29 | 30 | /// 31 | public IOneParameterSubcommandBuilder WithArgument(string name, string description) 32 | => new OneParameterSubcommandBuilder(this, 33 | new ParamSpec 34 | { 35 | Name = name, 36 | Description = description, 37 | IsArgument = true 38 | }); 39 | 40 | /// 41 | public ISubcommandBuilder WithDescription(string subcommandDescription) 42 | { 43 | CommandDescription = subcommandDescription; 44 | 45 | return this; 46 | } 47 | 48 | /// 49 | public IOneParameterSubcommandBuilder WithOption(string name, string description) 50 | => new OneParameterSubcommandBuilder(this, 51 | new ParamSpec 52 | { 53 | Name = name, 54 | Description = description 55 | }); 56 | 57 | /// 58 | public IOneParameterSubcommandBuilder WithRequiredOption(string name, string description) 59 | => new OneParameterSubcommandBuilder(this, 60 | new ParamSpec 61 | { 62 | Name = name, 63 | Description = description, 64 | IsRequired = true 65 | }); 66 | 67 | /// 68 | public TParentBuilder WithSubcommandHandler(Action action) 69 | { 70 | handler = action switch 71 | { 72 | null => null, 73 | _ => () => 74 | { 75 | action(); 76 | return Task.FromResult(0); 77 | } 78 | }; 79 | 80 | Services.Replace(ServiceDescriptor.Singleton(CommandType, BuildCommand)); 81 | 82 | return ParentBuilder; 83 | } 84 | 85 | private TSubcommand BuildCommand(IServiceProvider _) 86 | { 87 | if (handler is null) throw new InvalidOperationException("Action must be set before building the subcommand."); 88 | 89 | // can't use DI because we've been called by DI 90 | var subcommand = new TSubcommand(); 91 | 92 | if (CommandDescription is not null) subcommand.Description = CommandDescription; 93 | if (SubcommandAlias is not null) subcommand.AddAlias(SubcommandAlias); 94 | 95 | subcommand.SetHandler(_ => handler()); 96 | 97 | return subcommand; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/OneParameterDefaultValueTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using CommandLineExtensionsTests.TestDoubles; 4 | 5 | using Pri.CommandLineExtensions; 6 | using Pri.ConsoleApplicationBuilder; 7 | 8 | namespace CommandLineExtensionsTests; 9 | 10 | public class OneParameterDefaultValueTests : CommandLineBuilderTestingBase 11 | { 12 | [Fact] 13 | public void OutputHelpWithDefaultValueCorrectly() 14 | { 15 | string[] args = []; 16 | var builder = ConsoleApplication.CreateBuilder(args); 17 | builder.Services.AddCommand(new NullCommand()) 18 | .WithOption("--count", "number of times to repeat.") 19 | .WithDefault(1) 20 | .WithHandler(_ => { }); 21 | var command = builder.Build(); 22 | 23 | Assert.Equal(0, command.Invoke(["--help"], Console)); 24 | Assert.Equal($""" 25 | Description: 26 | 27 | Usage: 28 | {Utility.ExecutingTestRunnerName} [options] 29 | 30 | Options: 31 | --count number of times to repeat. [default: 1] 32 | --version Show version information 33 | -?, -h, --help Show help and usage information 34 | 35 | 36 | 37 | """.ReplaceLineEndings(), OutStringBuilder.ToString()); 38 | 39 | } 40 | 41 | [Fact] 42 | public void BuildWithDefaultValueCorrectly() 43 | { 44 | string[] args = []; 45 | var builder = ConsoleApplication.CreateBuilder(args); 46 | builder.Services.AddCommand(new NullCommand()) 47 | .WithOption("--count", "number of times to repeat.") 48 | .WithDefault(1) 49 | .WithHandler(_ => { }); 50 | var command = builder.Build(); 51 | Assert.NotNull(command); 52 | Assert.NotNull(command.Description); 53 | Assert.Empty(command.Description); 54 | var option = Assert.Single(command.Options); 55 | Assert.Equal("number of times to repeat.", option.Description); 56 | Assert.Equal("count", option.Name); 57 | } 58 | 59 | [Fact] 60 | public void InvokeWithDefaultValueCorrectly() 61 | { 62 | string[] args = []; 63 | var builder = ConsoleApplication.CreateBuilder(args); 64 | int givenCount = 0; 65 | bool wasExecuted = false; 66 | builder.Services.AddCommand(new NullCommand()) 67 | .WithOption("--count", "number of times to repeat.") 68 | .WithDefault(1) 69 | .WithHandler(c => 70 | { 71 | givenCount = c; 72 | wasExecuted = true; 73 | }); 74 | var command = builder.Build(); 75 | Assert.Equal(0, command.Invoke(args)); 76 | Assert.True(wasExecuted); 77 | Assert.Equal(1, givenCount); 78 | } 79 | 80 | [Fact] 81 | public void InvokeWithDefaultValueAndAliasCorrectly() 82 | { 83 | string[] args = []; 84 | var builder = ConsoleApplication.CreateBuilder(args); 85 | int givenCount = 0; 86 | bool wasExecuted = false; 87 | builder.Services.AddCommand(new NullCommand()) 88 | .WithOption("--count", "number of times to repeat.") 89 | .AddAlias("-c") 90 | .WithDefault(1) 91 | .WithHandler(c => 92 | { 93 | givenCount = c; 94 | wasExecuted = true; 95 | }); 96 | var command = builder.Build(); 97 | Assert.Equal(0, command.Invoke(args)); 98 | Assert.True(wasExecuted); 99 | Assert.Equal(1, givenCount); 100 | } 101 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/CommandBuilderBase.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Binding; 3 | using System.CommandLine.Invocation; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace Pri.CommandLineExtensions; 8 | 9 | /// 10 | /// A base class for command line command builder class that contain members common to all command line command builders. 11 | /// 12 | internal abstract class CommandBuilderBase : IBuilderState 13 | { 14 | public IServiceCollection Services { get; } 15 | public List ParamSpecs { get; } = []; 16 | public string? CommandDescription { get; set; } 17 | public Command? Command { get; init; } 18 | public Type? CommandType { get; init; } 19 | protected Func? CommandFactory { get; init; } 20 | protected Type? commandHandlerType; 21 | protected readonly List subcommands = []; 22 | 23 | protected CommandBuilderBase(CommandBuilderBase initiator) 24 | : this(initiator.Services, 25 | initiator.CommandDescription, 26 | initiator.Command, 27 | initiator.CommandType, 28 | initiator.ParamSpecs) 29 | { 30 | } 31 | 32 | [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "YAGNI?")] 33 | protected CommandBuilderBase(ICommandBuilder initiator) 34 | : this((CommandBuilderBase)initiator) 35 | { 36 | } 37 | 38 | private CommandBuilderBase(IServiceCollection services, string? commandDescription, Command? command, 39 | Type? commandType, List paramSpecs) : this(services) 40 | { 41 | CommandDescription = commandDescription; 42 | Command = command; 43 | CommandType = commandType; 44 | this.ParamSpecs = paramSpecs; 45 | } 46 | 47 | /// 48 | /// A base class for command line command builder class that contain members common to all command line command builders. 49 | /// 50 | /// 51 | protected CommandBuilderBase(IServiceCollection services) 52 | { 53 | Services = services; 54 | } 55 | 56 | [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = 57 | "Lifted from System.CommandLine, assuming a high level of quality")] 58 | protected static T? GetValue(IValueDescriptor descriptor, InvocationContext context) 59 | { 60 | if (descriptor is IValueSource valueSource && 61 | valueSource.TryGetValue(descriptor, 62 | context.BindingContext, 63 | out var objectValue) && 64 | objectValue is T value) 65 | { 66 | return value; 67 | } 68 | 69 | return descriptor switch 70 | { 71 | Argument argument => context.ParseResult.GetValueForArgument(argument), 72 | Option option => context.ParseResult.GetValueForOption(option), 73 | _ => throw new ArgumentOutOfRangeException(nameof(descriptor)) 74 | }; 75 | } 76 | 77 | 78 | protected Type GetCommandType() 79 | { 80 | return Command is not null 81 | ? Command.GetType() 82 | : CommandType ?? throw new InvalidOperationException( 83 | "Either command or command type is required to build commandline command."); 84 | } 85 | 86 | protected Command GetCommand(IServiceProvider provider) 87 | { 88 | // Use `Command` if there, otherwise try CommandFactory/CommandType pair, 89 | // or fall back to activator on the Type 90 | return Command ?? ( 91 | CommandFactory is not null 92 | ? CommandFactory(provider) 93 | : CommandType is not null 94 | ? (Command)Activator.CreateInstance(CommandType)! 95 | : throw new InvalidOperationException() 96 | ); 97 | } 98 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/ICommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using Microsoft.Extensions.DependencyInjection; 4 | 5 | namespace Pri.CommandLineExtensions; 6 | 7 | // TODO: refactor this to accept a TNextCommandBuilder and TNextSubcommandBuilder type parameters 8 | /// 9 | /// A builder that starts building command line commands and parameters 10 | /// 11 | public interface ICommandBuilder : ICommandConfiguration, IBuilderState 12 | { 13 | /// 14 | /// Adds an argument of type to the command. 15 | /// 16 | /// The type of the argument. 17 | /// The name of the argument when. 18 | /// The description of the argument. 19 | /// 20 | IOneParameterCommandBuilder WithArgument(string name, string description); 21 | 22 | /// 23 | /// Adds a handler lambda/anonymous method. 24 | /// 25 | /// 26 | /// This completes the command-line builder. 27 | /// 28 | /// The action to invoke when the command is encountered. 29 | /// IServiceCollection 30 | IServiceCollection WithHandler(Action action); 31 | 32 | /// 33 | /// Adds a handler object of type . 34 | /// 35 | /// 36 | /// This completes the command-line builder. 37 | /// 38 | /// The implementation to use. 39 | /// 40 | IServiceCollection WithHandler() where THandler : class, ICommandHandler; 41 | 42 | /// 43 | /// Adds a handler lambda/anonymous method. 44 | /// 45 | /// 46 | /// This completes the command-line builder. 47 | /// 48 | /// The action to invoke when the command is encountered. 49 | /// 50 | IServiceCollection WithHandler(Func func); 51 | 52 | /// 53 | /// Adds a handler lambda/anonymous method, completing the command-line builder. 54 | /// 55 | /// 56 | /// This completes the command-line builder. 57 | /// 58 | /// The action to invoke when the command is encountered. 59 | /// 60 | IServiceCollection WithHandler(Func func); 61 | 62 | /// 63 | /// Adds a strongly-typed optional option to the command. 64 | /// 65 | /// The type of the option. 66 | /// The name of the option. 67 | /// A description of the option. 68 | /// 69 | /// 70 | IOneParameterCommandBuilder WithOption(string name, string description); 71 | 72 | /// 73 | /// Adds a strongly-typed required option of type to the command. 74 | /// 75 | /// The type of the option. 76 | /// The name of the option when provided on the command line. 77 | /// The description of the option. 78 | /// 79 | IOneParameterCommandBuilder WithRequiredOption(string name, string description); 80 | 81 | /// 82 | /// Add a subcommand of type to the command. 83 | /// 84 | /// The type of subcommand to add to the command. 85 | /// 86 | ISubcommandBuilder WithSubcommand() where TSubcommand : Command, new(); 87 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/AsyncTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using CommandLineExtensionsTests.TestDoubles; 4 | 5 | using Pri.CommandLineExtensions; 6 | using Pri.ConsoleApplicationBuilder; 7 | 8 | namespace CommandLineExtensionsTests; 9 | 10 | public class AsyncTests : CommandLineBuilderTestingBase 11 | { 12 | [Fact] 13 | public async Task InvokeAsync() 14 | { 15 | string[] args = []; 16 | bool lambdaWasInvoked = false; 17 | var builder = ConsoleApplication.CreateBuilder(args); 18 | builder.Services.AddCommand() 19 | .WithHandler(async () => 20 | { 21 | await Task.Delay(10); 22 | lambdaWasInvoked = true; 23 | }); 24 | var command = builder.Build(); 25 | int exitCode = await command.InvokeAsync(args); 26 | Assert.True(lambdaWasInvoked); 27 | Assert.Equal(0, exitCode); 28 | } 29 | 30 | [Fact] 31 | public void ThrowWithNullHandler() 32 | { 33 | string[] args = []; 34 | var builder = ConsoleApplication.CreateBuilder(args); 35 | builder.Services.AddCommand() 36 | .WithHandler((Func)null!); 37 | var ex = Assert.Throws(builder.Build); 38 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 39 | } 40 | 41 | [Fact] 42 | public async Task InvokeAsyncWithOneParameter() 43 | { 44 | string[] args = ["--count", "2"]; 45 | bool lambdaWasInvoked = false; 46 | int actualParameter = 0; 47 | var builder = ConsoleApplication.CreateBuilder(args); 48 | builder.Services.AddCommand() 49 | .WithOption("--count", "count") 50 | .WithHandler(async c => 51 | { 52 | await Task.Delay(10); 53 | actualParameter = c; 54 | lambdaWasInvoked = true; 55 | }); 56 | var command = builder.Build(); 57 | int exitCode = await command.InvokeAsync(args); 58 | Assert.True(lambdaWasInvoked); 59 | Assert.Equal(2, actualParameter); 60 | Assert.Equal(0, exitCode); 61 | } 62 | 63 | [Fact] 64 | public void ThrowWithNullHandlerWithOneParameter() 65 | { 66 | string[] args = []; 67 | var builder = ConsoleApplication.CreateBuilder(args); 68 | builder.Services.AddCommand() 69 | .WithOption("--count", "count") 70 | .WithHandler((Func)null!); 71 | var ex = Assert.Throws(builder.Build); 72 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 73 | } 74 | 75 | [Fact] 76 | public async Task InvokeAsyncWithTwoParameters() 77 | { 78 | string[] args = ["--x", "2", "--y", "3"]; 79 | bool lambdaWasInvoked = false; 80 | int actualX = 0; 81 | int actualY = 0; 82 | var builder = ConsoleApplication.CreateBuilder(args); 83 | builder.Services.AddCommand() 84 | .WithOption("--x", "x coordinate") 85 | .WithOption("--y", "y coordinate") 86 | .WithHandler(async (x,y) => 87 | { 88 | await Task.Delay(10); 89 | actualX = x; 90 | actualY = y; 91 | lambdaWasInvoked = true; 92 | }); 93 | var command = builder.Build(); 94 | int exitCode = await command.InvokeAsync(args); 95 | Assert.True(lambdaWasInvoked); 96 | Assert.Equal(2, actualX); 97 | Assert.Equal(3, actualY); 98 | Assert.Equal(0, exitCode); 99 | } 100 | 101 | [Fact] 102 | public void ThrowWithNullHandlerWithTwoParameters() 103 | { 104 | string[] args = []; 105 | var builder = ConsoleApplication.CreateBuilder(args); 106 | builder.Services.AddCommand() 107 | .WithOption("--x", "x coordinate") 108 | .WithOption("--y", "y coordinate") 109 | .WithHandler((Func)null!); 110 | var ex = Assert.Throws(builder.Build); 111 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 112 | } 113 | } -------------------------------------------------------------------------------- /.azuredevops/templates/jobs/github-release-packages.yml: -------------------------------------------------------------------------------- 1 | # Create GitHub release based on NuGet packages and version 2 | parameters: 3 | - name: name 4 | type: string 5 | default: 'github_release' 6 | displayName: 'The name of the job, without spaces or dashes (-).' 7 | - name: displayName 8 | type: string 9 | default: 'Release to GitHub' 10 | displayName: 'The display name of the job, displayed in pipeline runs' 11 | - name: targetRefspec 12 | type: string 13 | default: 'main' 14 | displayName: 'The branch or commit hash to create the release from' 15 | - name: addChangeLog 16 | type: boolean 17 | default: true 18 | displayName: 'whether to add a change log to the release' 19 | - name: artifactName 20 | type: string 21 | default: 'NuGet' 22 | displayName: 'The name of the artifact to download containing one ore more NuGet packages (nupkg)' 23 | - name: condition 24 | type: string 25 | default: '' 26 | displayName: 'The optional condition to evaluate to determine if the job should run' 27 | - name: dependsOn 28 | type: string 29 | displayName: 'The name of the job that produces the NuGet artifacts' 30 | - name: environment 31 | type: string 32 | displayName: 'The name of the environment that the deployment job depends upon for approval/permission.' 33 | - name: githubReleasesServiceConnection 34 | type: string 35 | displayName: 'The name of the service connection/endpoint ' 36 | - name: repositoryName 37 | type: string 38 | displayName: 'The name of the GitHub repository in the form /.' 39 | - name: packageVersion 40 | type: string 41 | displayName: 'The package version number text.' 42 | - name: majorVersion 43 | type: string 44 | displayName: 'The major version number of the release.' 45 | - name: minorVersion 46 | type: string 47 | displayName: 'The minor version number of the release.' 48 | - name: patchVersion 49 | type: string 50 | displayName: 'The patch version number of the release.' 51 | - name: buildVersion 52 | type: string 53 | displayName: 'The build version number of the release.' 54 | 55 | jobs: 56 | - deployment: ${{ parameters.name }} 57 | ${{ if ne(parameters.condition, '') }}: 58 | condition: ${{ parameters.condition }} 59 | dependsOn: ${{ parameters.dependsOn }} 60 | ${{ if ne(parameters.displayName, '') }}: 61 | displayName: ${{ parameters.displayName }} 62 | environment: ${{ parameters.environment }} 63 | variables: 64 | packageVersion: ${{ parameters.packageVersion }} 65 | majorVersion: ${{ parameters.majorVersion }} 66 | minorVersion: ${{ parameters.minorVersion }} 67 | patchVersion: ${{ parameters.patchVersion }} 68 | buildVersion: ${{ parameters.buildVersion }} 69 | artifactName: ${{ parameters.artifactName }} 70 | strategy: 71 | runOnce: 72 | deploy: 73 | steps: 74 | - download: current 75 | artifact: $(artifactName) 76 | - powershell: | 77 | Set-Content -Path $(Pipeline.Workspace)/$(artifactName)/README.txt -Value "Product X, version $($majorVersion).$($minorVersion).$($patchVersion).$($buildVersion)" 78 | displayName: 'Create readme' 79 | - task: GithubRelease@1 80 | displayName: 'Create GitHub Release' 81 | inputs: 82 | action: create 83 | target: ${{ parameters.targetRefspec }} 84 | gitHubConnection: ${{ parameters.githubReleasesServiceConnection }} 85 | addChangeLog: ${{ parameters.addChangeLog }} 86 | repositoryName: ${{ parameters.repositoryName }} 87 | tagSource: userSpecifiedTag 88 | tag: v$(packageVersion) 89 | title: v$(packageVersion) 90 | assets: | 91 | $(Pipeline.Workspace)/$(artifactName)/*.nupkg 92 | $(Pipeline.Workspace)/$(artifactName)/README.txt 93 | -------------------------------------------------------------------------------- /.azuredevops/cd-template.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: verbosity 3 | displayName: 'verbosity of this run' 4 | type: string 5 | default: Minimal 6 | values: 7 | - Detailed 8 | - Quiet 9 | - Diagnostic 10 | - Minimal 11 | - name: versionSuffixType 12 | displayName: Nuget Version Suffix Type 13 | type: string 14 | default: none 15 | values: 16 | - rc. 17 | - beta. 18 | - alpha. 19 | - none 20 | 21 | trigger: 22 | branches: 23 | include: 24 | - main 25 | paths: 26 | include: 27 | - src/dotnet-new-template 28 | - .azuredevops/cd-template.yml 29 | - .azuredevops/templates 30 | 31 | pr: none 32 | 33 | pool: 34 | vmImage: ubuntu-latest 35 | 36 | variables: 37 | majorVersion: 1 38 | minorVersion: 1 39 | dotNetVersion: '8.x' 40 | sourceDir: 'src/dotnet-new-template' 41 | buildConfiguration: 'Release' 42 | nugetServiceConnection: 'Nuget - PRI.ConsoleApplicationBuilder' # 'TestNuGet - PRI-ConsoleApplicationBuilder' 43 | patchVersion: $[counter(format('{0}-{1}', variables['majorVersion'], variables['minorVersion']), 0)] 44 | buildVersion: $[counter(format('{0}-{1}-{2}', variables['majorVersion'], variables['minorVersion'], variables['versionSuffixType']), 0)] 45 | ${{ if not( eq(parameters['versionSuffixType'], 'none') ) }}: 46 | versionSuffix: '-${{ parameters.versionSuffixType }}$(buildVersion)' 47 | ${{ else }}: 48 | versionSuffix: '' 49 | packageVersion: '$(majorVersion).$(minorVersion).$(patchVersion)$(versionSuffix)' 50 | 51 | stages: 52 | - stage: build 53 | displayName: 'Build' 54 | jobs: 55 | - job: Build 56 | displayName: 'Build' 57 | steps: 58 | - task: UseDotNet@2 59 | displayName: 'Use .NET SDK $(dotNetVersion)' 60 | inputs: 61 | packageType: sdk 62 | version: $(dotNetVersion) 63 | 64 | - task: DotNetCoreCLI@2 65 | displayName: 'Restore project dependencies' 66 | inputs: 67 | command: 'restore' 68 | projects: '$(sourceDir)/**/*.csproj' 69 | verbosityRestore: '${{ parameters.verbosity }}' 70 | 71 | - task: DotNetCoreCLI@2 72 | displayName: 'Pack' 73 | inputs: 74 | command: 'pack' 75 | configuration: '$(buildConfiguration)' 76 | verbosityPack: '${{ parameters.verbosity }}' 77 | packagesToPack: '$(sourceDir)/Pri.ConsoleApplicationBuilder.Templates.csproj' 78 | buildProperties: 'Version=$(packageVersion)' 79 | 80 | - task: PublishPipelineArtifact@1 81 | inputs: 82 | artifactName: 'NuGet' 83 | targetPath: '$(Build.ArtifactStagingDirectory)' 84 | publishLocation: 'pipeline' 85 | 86 | # to create a service connection for NuGet 87 | # - see also: https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml 88 | # to create an environment for the service connection 89 | # - see also: https://learn.microsoft.com/en-us/azure/deployment-environments/how-to-create-access-environments 90 | - stage: nuget_publish 91 | displayName: 'NuGet Publish' 92 | dependsOn: build 93 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 94 | jobs: 95 | - deployment: nuget_push 96 | displayName: 'NuGet Push' 97 | environment: 'NuGet' 98 | strategy: 99 | runOnce: 100 | deploy: 101 | steps: 102 | - task: DownloadPipelineArtifact@2 103 | inputs: 104 | artifact: 'NuGet' 105 | targetPath: '$(Build.ArtifactStagingDirectory)' 106 | 107 | - task: NuGetCommand@2 108 | displayName: 'NuGet Push' 109 | inputs: 110 | command: push 111 | nuGetFeedType: external 112 | publishFeedCredentials: $(nugetServiceConnection) -------------------------------------------------------------------------------- /src/CommandLineExtensions/CommandExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Binding; 3 | using System.CommandLine.Parsing; 4 | 5 | namespace Pri.CommandLineExtensions; 6 | 7 | internal static class CommandExtensions 8 | { 9 | internal static IValueDescriptor AddParameter(this Command command, ParamSpec paramSpec, 10 | ParseArgument argumentParser = null) 11 | { 12 | IValueDescriptor descriptor; 13 | if (paramSpec.IsArgument) 14 | { 15 | Argument valueDescriptor = paramSpec.DefaultValue != null 16 | ? CreateArgument(paramSpec.Name, 17 | paramSpec.Description, 18 | argumentParser, 19 | (TParam?)paramSpec.DefaultValue) 20 | : CreateArgument(paramSpec.Name, 21 | paramSpec.Description, 22 | argumentParser); 23 | 24 | descriptor = valueDescriptor; 25 | command.AddArgument((Argument)descriptor); 26 | } 27 | else 28 | { 29 | descriptor = paramSpec.DefaultValue != null 30 | ? CreateOption(paramSpec.Name, 31 | paramSpec.Description, 32 | paramSpec.IsRequired, 33 | paramSpec.Aliases, 34 | argumentParser, 35 | (TParam?)paramSpec.DefaultValue) 36 | : CreateOption(paramSpec.Name, 37 | paramSpec.Description, 38 | paramSpec.IsRequired, 39 | paramSpec.Aliases, 40 | argumentParser); 41 | 42 | command.AddOption((Option)descriptor); 43 | } 44 | 45 | return descriptor; 46 | } 47 | 48 | private static Option CreateOption(string name, 49 | string description, 50 | bool isRequired, 51 | IEnumerable optionAliases, 52 | ParseArgument? parseArgument) 53 | { 54 | Option option = CreateOption(name, description, parseArgument); 55 | 56 | foreach(var alias in optionAliases) 57 | { 58 | option.AddAlias(alias); 59 | } 60 | option.IsRequired = isRequired; 61 | 62 | return option; 63 | } 64 | 65 | private static Option CreateOption(string name, 66 | string description, 67 | bool isRequired, 68 | IEnumerable optionAliases, 69 | ParseArgument? parseArgument, 70 | T? defaultValue) 71 | { 72 | Option option = CreateOption(name, description, isRequired, optionAliases, parseArgument); 73 | 74 | foreach(var alias in optionAliases) 75 | { 76 | option.AddAlias(alias); 77 | } 78 | 79 | option.IsRequired = isRequired; 80 | 81 | if (defaultValue is not null) 82 | { 83 | option.SetDefaultValue(defaultValue); 84 | } 85 | 86 | return option; 87 | } 88 | 89 | private static Option CreateOption(string name, string description, ParseArgument? parseArgument) 90 | { 91 | if(parseArgument is null) 92 | { 93 | return (Option)Activator.CreateInstance(typeof(Option), name, description)!; 94 | } 95 | return (Option)Activator.CreateInstance(typeof(Option), name, parseArgument, false, description)!; 96 | } 97 | 98 | private static Argument CreateArgument(string name, string description, ParseArgument? parseArgument) 99 | { 100 | if (parseArgument is null) return CreateArgument(name, description); 101 | Argument argument = (Argument)Activator.CreateInstance(typeof(Argument), name, parseArgument, false, description)!; 102 | 103 | return argument; 104 | } 105 | 106 | private static Argument CreateArgument(string name, string description, ParseArgument? parseArgument, T? defaultValue) 107 | { 108 | Argument argument = CreateArgument(name, description, parseArgument)!; 109 | if (defaultValue is not null) 110 | { 111 | argument.SetDefaultValue(defaultValue); 112 | } 113 | 114 | return argument; 115 | } 116 | 117 | private static Argument CreateArgument(string name, string description) 118 | { 119 | Argument argument = (Argument)Activator.CreateInstance(typeof(Argument), name, description)!; 120 | return argument; 121 | } 122 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/IOneParameterCommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Pri.CommandLineExtensions; 7 | 8 | /// 9 | /// A builder that manages configuration for and add parameters to chain to another builder 10 | /// 11 | /// 12 | public interface IOneParameterCommandBuilder 13 | : IParameterConfiguration, TParam>, IBuilderState 14 | { 15 | /// 16 | /// Adds an argument of type to the command. 17 | /// 18 | /// The type of the argument. 19 | /// The name of the argument when. 20 | /// The description of the argument. 21 | /// 22 | ITwoParameterCommandBuilder WithArgument(string name, string description); 23 | 24 | /// 25 | /// Adds a handler lambda/anonymous method. 26 | /// 27 | /// 28 | /// This completes the command-line builder. 29 | /// 30 | /// The action to invoke when the command is encountered. 31 | /// 32 | IServiceCollection WithHandler(Action action); 33 | 34 | /// 35 | /// Adds a handler object of type . 36 | /// 37 | /// 38 | /// This completes the command-line builder. 39 | /// 40 | /// The implementation to use. 41 | /// 42 | IServiceCollection WithHandler() where THandler : class, ICommandHandler; 43 | 44 | /// 45 | /// Adds a handler lambda/anonymous method. 46 | /// 47 | /// 48 | /// This completes the command-line builder. 49 | /// 50 | /// The action to invoke when the command is encountered. 51 | /// 52 | IServiceCollection WithHandler(Func func); 53 | 54 | /// 55 | /// Adds a handler lambda/anonymous method. 56 | /// 57 | /// 58 | /// This completes the command-line builder. 59 | /// 60 | /// The action to invoke when the command is encountered. 61 | /// 62 | IServiceCollection WithHandler(Func func); 63 | 64 | /// 65 | /// Adds a strongly-typed optional option to the command. 66 | /// 67 | /// The type of the option. 68 | /// The name of the option. 69 | /// A description of the option. 70 | /// 71 | /// 72 | ITwoParameterCommandBuilder WithOption(string name, string description); 73 | 74 | /// 75 | /// Adds a strongly-typed required option of type to the command. 76 | /// 77 | /// The type of the option. 78 | /// The name of the option when provided on the command line. 79 | /// The description of the option. 80 | /// 81 | ITwoParameterCommandBuilder WithRequiredOption(string name, string description); 82 | 83 | /// 84 | /// Adds a subcommand of type to the command. 85 | /// 86 | /// The type of subcommand to add to the command. 87 | /// 88 | ISubcommandBuilder> WithSubcommand() where TSubcommand : Command, new(); 89 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsGivenCommandWithOneRequiredOptionShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using CommandLineExtensionsTests.TestDoubles; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | using Pri.CommandLineExtensions; 8 | using Pri.ConsoleApplicationBuilder; 9 | 10 | // ReSharper disable StringLiteralTypo 11 | 12 | namespace CommandLineExtensionsTests; 13 | 14 | public class CommandLineExtensionsGivenCommandWithOneRequiredOptionShould 15 | { 16 | [Fact] 17 | public void CorrectlyBuildCommandWithLambdaHandler() 18 | { 19 | string[] args = [Constants.FileOptionName, "appsettings.json"]; 20 | 21 | var builder = ConsoleApplication.CreateBuilder(args); 22 | var command = BuildCommand(builder, _ => { }); 23 | Assert.Equal("command description", command.Description); 24 | Assert.NotNull( 25 | Assert.Single(command.Options) 26 | ); 27 | Assert.Equal(Constants.FileOptionName.Trim('-'), Assert.Single(command.Options).Name); 28 | } 29 | 30 | [Fact] 31 | public void CorrectlyInvokeCommandWithLambdaHandler() 32 | { 33 | string[] args = [Constants.FileOptionName, "appsettings.json"]; 34 | 35 | var builder = ConsoleApplication.CreateBuilder(args); 36 | bool itRan = false; 37 | FileInfo? givenFileInfo = null; 38 | var command = BuildCommand(builder, fileInfo => 39 | { 40 | itRan = true; 41 | givenFileInfo = fileInfo; 42 | }); 43 | Assert.Equal(0, command.Invoke(args)); 44 | Assert.True(itRan); 45 | Assert.NotNull(givenFileInfo); 46 | Assert.Equal(new FileInfo("appsettings.json").FullName, givenFileInfo!.FullName); 47 | } 48 | 49 | [Fact] 50 | public void CorrectlyBuildCommandWithObjectHandler() 51 | { 52 | string[] args = [Constants.FileOptionName, "appsettings.json"]; 53 | FileInfoHandlerSpy fileInfoHandlerSpy = new(); 54 | 55 | var command = BuildCommand(args, fileInfoHandlerSpy); 56 | 57 | Assert.Equal("command description", command.Description); 58 | Assert.NotNull( 59 | Assert.Single(command.Options) 60 | ); 61 | Assert.Equal(Constants.FileOptionName.Trim('-'), Assert.Single(command.Options).Name); 62 | } 63 | 64 | [Fact] 65 | public void CorrectlyInvokeCommandWithObjectHandler() 66 | { 67 | string[] args = [Constants.FileOptionName, "appsettings.json"]; 68 | FileInfoHandlerSpy fileInfoHandlerSpy = new(); 69 | 70 | var command = BuildCommand(args, fileInfoHandlerSpy); 71 | 72 | Assert.Equal(0, command.Invoke(args)); 73 | Assert.True(fileInfoHandlerSpy.WasExecuted); 74 | Assert.NotNull(fileInfoHandlerSpy.GivenFileInfo); 75 | } 76 | 77 | [Fact] 78 | public void CorrectlyFailInvokeWithObjectHandlerGivenMissingOption() 79 | { 80 | string[] args = []; 81 | FileInfoHandlerSpy fileInfoHandlerSpy = new(); 82 | 83 | var command = BuildCommand(args, fileInfoHandlerSpy); 84 | 85 | Assert.Equal(1, command.Invoke(args)); 86 | Assert.False(fileInfoHandlerSpy.WasExecuted); 87 | Assert.Null(fileInfoHandlerSpy.GivenFileInfo); 88 | } 89 | 90 | private static RootCommand BuildCommand(IConsoleApplicationBuilder builder, Action action) 91 | { 92 | builder.Services.AddCommand() 93 | .WithDescription("command description") 94 | .WithRequiredOption(Constants.FileOptionName, "file option description") 95 | .WithHandler(action); 96 | 97 | var command = builder.Build(); 98 | return command; 99 | } 100 | 101 | private static RootCommand BuildCommand(string[] args, FileInfoHandlerSpy fileInfoHandlerSpy) 102 | { 103 | var builder = ConsoleApplication.CreateBuilder(args); 104 | builder.Services.AddSingleton>(_ => fileInfoHandlerSpy); 105 | builder.Services.AddCommand() 106 | .WithDescription("command description") 107 | .WithRequiredOption(Constants.FileOptionName, "file option description") 108 | .WithHandler(); 109 | 110 | var command = builder.Build(); 111 | return command; 112 | } 113 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsWithCommandObjectShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Text; 3 | 4 | using CommandLineExtensionsTests.TestDoubles; 5 | 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | using NSubstitute; 9 | 10 | using Pri.CommandLineExtensions; 11 | using Pri.ConsoleApplicationBuilder; 12 | 13 | namespace CommandLineExtensionsTests; 14 | 15 | public class CommandLineExtensionsWithCommandObjectShould 16 | { 17 | [Fact] 18 | public void Build() 19 | { 20 | string[] args = []; 21 | bool handlerInvoked = false; 22 | var command = BuildCommand(args, () => handlerInvoked = true); 23 | Assert.False(handlerInvoked); 24 | Assert.NotNull(command); 25 | } 26 | 27 | [Fact] 28 | public void Invoke() 29 | { 30 | string[] args = []; 31 | bool handlerInvoked = false; 32 | var command = BuildCommand(args, () => handlerInvoked = true); 33 | command.Invoke(args); 34 | Assert.True(handlerInvoked); 35 | Assert.NotNull(command); 36 | } 37 | 38 | [Fact] 39 | public void OutputHelp() 40 | { 41 | string[] args = []; 42 | bool handlerInvoked = false; 43 | var command = BuildCommand(args, () => handlerInvoked = true); 44 | var outStringBuilder = new StringBuilder(); 45 | var errStringBuilder = new StringBuilder(); 46 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 47 | 48 | command.Invoke(["--help"], console); 49 | Assert.Equal($""" 50 | Description: 51 | 52 | Usage: 53 | {Utility.ExecutingTestRunnerName} [options] 54 | 55 | Options: 56 | --version Show version information 57 | -?, -h, --help Show help and usage information 58 | 59 | 60 | 61 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 62 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 63 | Assert.False(handlerInvoked); 64 | } 65 | 66 | [Fact] 67 | public void ThrowWhenNewRootCommandRegisteredTwice() 68 | { 69 | var builder = ConsoleApplication.CreateBuilder([]); 70 | builder.Services.AddSingleton(); 71 | var ex = Assert.Throws(()=>builder.Services.AddCommand(new AnotherRootCommand())); 72 | Assert.Equal("AnotherRootCommand already registered in service collection.", ex.Message); 73 | } 74 | 75 | [Fact] 76 | public void ThrowWhenImpliedRootCommandRegisteredTwice() 77 | { 78 | var builder = ConsoleApplication.CreateBuilder([]); 79 | builder.Services.AddSingleton(); 80 | var ex = Assert.Throws(builder.Services.AddCommand); 81 | Assert.Equal("RootCommand already registered in service collection.", ex.Message); 82 | } 83 | 84 | [Fact] 85 | public void ThrowWhenTypedRootCommandRegisteredTwice() 86 | { 87 | var builder = ConsoleApplication.CreateBuilder([]); 88 | builder.Services.AddSingleton(); 89 | var ex = Assert.Throws(() => builder.Services.AddCommand()); 90 | Assert.Equal("AnotherRootCommand already registered in service collection.", ex.Message); 91 | } 92 | 93 | [Fact] 94 | public void ThrowWhenFactoryRootCommandRegisteredTwice() 95 | { 96 | var builder = ConsoleApplication.CreateBuilder([]); 97 | builder.Services.AddSingleton(); 98 | var ex = Assert.Throws(() => builder.Services.AddCommand(_=>new AnotherRootCommand())); 99 | Assert.Equal("AnotherRootCommand already registered in service collection.", ex.Message); 100 | } 101 | 102 | private static NullCommand BuildCommand(string[] args, Action action) 103 | { 104 | var builder = ConsoleApplication.CreateBuilder(args); 105 | builder.Services.AddCommand(new NullCommand()) 106 | .WithHandler(action); 107 | return builder.Build(); 108 | } 109 | public class AnotherRootCommand : RootCommand { } 110 | } -------------------------------------------------------------------------------- /.azuredevops/ci-build.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: verbosity 3 | displayName: 'verbosity of this run' 4 | type: string 5 | default: Minimal 6 | values: 7 | - Detailed 8 | - Quiet 9 | - Diagnostic 10 | - Minimal 11 | 12 | trigger: 13 | branches: 14 | include: 15 | - main 16 | paths: 17 | include: 18 | - src/ConsoleApplicationBuilder 19 | - .azuredevops/ci-build.yml 20 | - .azuredevops/templates 21 | 22 | pr: none 23 | 24 | pool: 25 | vmImage: ubuntu-latest 26 | 27 | variables: 28 | majorVersion: 1 29 | minorVersion: 1 30 | projects: 'src/ConsoleApplicationBuilder/**/*.csproj' 31 | dotNetVersion: '8.x' 32 | buildConfiguration: 'Release' 33 | nugetServiceConnection: 'Nuget - PRI.ConsoleApplicationBuilder' # 'TestNuGet - PRI-ConsoleApplicationBuilder' 34 | testProjects: 'src/ConsoleApplicationBuilder/Tests/Pri.ConsoleApplicationBuilder.Tests.csproj' 35 | ${{ if eq(parameters.verbosity, 'Quiet') }}: 36 | dotnetVerbosity: q # Quiet 37 | ${{ elseif eq(parameters.verbosity, 'Detailed') }}: 38 | dotnetVerbosity: d # Detailed 39 | ${{ elseif eq(parameters.verbosity, 'Diagnostic') }}: 40 | dotnetVerbosity: diag # Diagnostic 41 | ${{ else }}: 42 | dotnetVerbosity: m # Minimal 43 | 44 | stages: 45 | - stage: build_test 46 | displayName: 'Build and Test' 47 | jobs: 48 | - job: Build 49 | displayName: 'Build and Test' 50 | steps: 51 | - task: UseDotNet@2 52 | displayName: 'Use .NET SDK $(dotNetVersion)' 53 | inputs: 54 | packageType: sdk 55 | version: $(dotNetVersion) 56 | 57 | - task: DotNetCoreCLI@2 58 | displayName: 'Restore project dependencies' 59 | inputs: 60 | command: 'restore' 61 | projects: $(projects) 62 | verbosityRestore: '${{ parameters.verbosity }}' 63 | 64 | - task: DotNetCoreCLI@2 65 | # run tests and publish results/coverage to Azure DevOps 66 | displayName: 'Dotnet Build/Test - $(buildConfiguration)' 67 | inputs: 68 | command: 'test' 69 | projects: $(testProjects) 70 | arguments: >- 71 | --no-restore 72 | -c $(buildConfiguration) 73 | --nologo 74 | -v $(dotnetVerbosity) 75 | /clp:ErrorsOnly 76 | --collect "Code coverage" 77 | testRunTitle: 'Dotnet Test - $(buildConfiguration)' 78 | 79 | - template: templates/jobs/build-and-pack-nuget-job.yml 80 | parameters: 81 | displayName: 'Pack NuGet' 82 | dependsOn: 'Build' 83 | majorVersion: ${{ variables.majorVersion }} 84 | minorVersion: ${{ variables.minorVersion }} 85 | dotNetVersion: $(dotNetVersion) 86 | projects: $(projects) 87 | packagesToPack: 'src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/Pri.ConsoleApplicationBuilder.csproj' 88 | 89 | # to create a service connection for NuGet 90 | # - see also: https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml 91 | # to create an environment for the service connection 92 | # - see also: https://learn.microsoft.com/en-us/azure/deployment-environments/how-to-create-access-environments 93 | - stage: nuget_publish 94 | displayName: 'NuGet Publish' 95 | dependsOn: build_test 96 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 97 | jobs: 98 | - deployment: nuget_push 99 | displayName: 'NuGet Push' 100 | environment: 'NuGet' 101 | strategy: 102 | runOnce: 103 | deploy: 104 | steps: 105 | - task: DownloadPipelineArtifact@2 106 | inputs: 107 | artifact: 'NuGet' 108 | targetPath: '$(Build.ArtifactStagingDirectory)' 109 | - task: NuGetCommand@2 110 | displayName: 'NuGet Push' 111 | inputs: 112 | command: push 113 | nuGetFeedType: external 114 | publishFeedCredentials: $(nugetServiceConnection) 115 | -------------------------------------------------------------------------------- /.azuredevops/ci-CommandLineExtensions.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: verbosity 3 | displayName: 'verbosity of this run' 4 | type: string 5 | default: Minimal 6 | values: 7 | - Detailed 8 | - Quiet 9 | - Diagnostic 10 | - Minimal 11 | 12 | trigger: 13 | branches: 14 | include: 15 | - main 16 | paths: 17 | include: 18 | - src/CommandLineExtensions 19 | - .azuredevops/ci-CommandLineExtensions.yml 20 | - .azuredevops/templates 21 | 22 | pr: none 23 | 24 | pool: 25 | vmImage: ubuntu-22.04 26 | 27 | variables: 28 | majorVersion: 1 29 | minorVersion: 0 30 | versionSuffixType: 'none' 31 | projects: 'src/CommandLineExtensions/*.csproj' 32 | dotNetVersion: '8.x' 33 | buildConfiguration: 'Release' 34 | nugetServiceConnection: 'Nuget - PRI.ConsoleApplicationBuilder' # 'TestNuGet - PRI-CommandLineExtensions' 35 | testProjects: 'src/Tests/Pri.ConsoleApplicationBuilder.Tests.csproj' 36 | ${{ if eq(parameters.verbosity, 'Quiet') }}: 37 | dotnetVerbosity: q # Quiet 38 | ${{ elseif eq(parameters.verbosity, 'Detailed') }}: 39 | dotnetVerbosity: d # Detailed 40 | ${{ elseif eq(parameters.verbosity, 'Diagnostic') }}: 41 | dotnetVerbosity: diag # Diagnostic 42 | ${{ else }}: 43 | dotnetVerbosity: m # Minimal 44 | 45 | stages: 46 | - stage: build_test 47 | displayName: 'Build and Test' 48 | jobs: 49 | - job: Build 50 | displayName: 'Build and Test' 51 | steps: 52 | - task: UseDotNet@2 53 | displayName: 'Use .NET SDK $(dotNetVersion)' 54 | inputs: 55 | packageType: sdk 56 | version: $(dotNetVersion) 57 | 58 | - task: DotNetCoreCLI@2 59 | displayName: 'Restore project dependencies' 60 | inputs: 61 | command: 'restore' 62 | projects: $(projects) 63 | verbosityRestore: '${{ parameters.verbosity }}' 64 | 65 | - task: DotNetCoreCLI@2 66 | # run tests and publish results/coverage to Azure DevOps 67 | displayName: 'Dotnet Build/Test - $(buildConfiguration)' 68 | inputs: 69 | command: 'test' 70 | projects: $(testProjects) 71 | arguments: >- 72 | --no-restore 73 | -c $(buildConfiguration) 74 | --nologo 75 | -v $(dotnetVerbosity) 76 | /clp:ErrorsOnly 77 | --collect "Code coverage" 78 | testRunTitle: 'Dotnet Test - $(buildConfiguration)' 79 | 80 | - template: templates/jobs/build-and-pack-nuget-job.yml 81 | parameters: 82 | displayName: 'Pack NuGet' 83 | dependsOn: 'Build' 84 | majorVersion: ${{ variables.majorVersion }} 85 | minorVersion: ${{ variables.minorVersion }} 86 | versionSuffixType: ${{ variables.versionSuffixType }} 87 | dotNetVersion: $(dotNetVersion) 88 | projects: $(projects) 89 | packagesToPack: 'src/CommandLineExtensions/Pri.CommandLineExtensions.csproj' 90 | 91 | # to create a service connection for NuGet 92 | # - see also: https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml 93 | # to create an environment for the service connection 94 | # - see also: https://learn.microsoft.com/en-us/azure/deployment-environments/how-to-create-access-environments 95 | - stage: nuget_publish 96 | displayName: 'NuGet Publish' 97 | dependsOn: build_test 98 | condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest')) 99 | jobs: 100 | - deployment: nuget_push 101 | displayName: 'NuGet Push' 102 | environment: 'NuGet' 103 | strategy: 104 | runOnce: 105 | deploy: 106 | steps: 107 | - task: DownloadPipelineArtifact@2 108 | inputs: 109 | artifact: 'NuGet' 110 | targetPath: '$(Build.ArtifactStagingDirectory)' 111 | - task: NuGetAuthenticate@1 112 | displayName: 'Authenticate with NuGet' 113 | - task: NuGetCommand@2 114 | displayName: 'NuGet Push' 115 | inputs: 116 | command: push 117 | nuGetFeedType: external 118 | publishFeedCredentials: $(nugetServiceConnection) 119 | -------------------------------------------------------------------------------- /src/CommandLineExtensions/TwoParameterSubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | #if PARANOID 4 | using System.Diagnostics; 5 | #endif 6 | 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.DependencyInjection.Extensions; 9 | 10 | namespace Pri.CommandLineExtensions; 11 | 12 | internal class TwoParameterSubcommandBuilder 13 | : SubcommandBuilderBase, ITwoParameterSubcommandBuilder 14 | where TSubcommand : Command, new() 15 | where TParentBuilder : IBuilderState 16 | { 17 | internal TParentBuilder ParentBuilder { get; } 18 | private Func? handler; 19 | 20 | private TwoParameterSubcommandBuilder(OneParameterSubcommandBuilder initiator) 21 | : base(initiator) 22 | { 23 | ParentBuilder = initiator.ParentBuilder; 24 | } 25 | 26 | public TwoParameterSubcommandBuilder(OneParameterSubcommandBuilder initiator, 27 | ParamSpec paramSpec) : this(initiator) 28 | => ParamSpecs.Add(paramSpec); 29 | 30 | /// 31 | public ITwoParameterSubcommandBuilder AddAlias(string parameterAlias) 32 | { 33 | ParamSpecs.Last().Aliases.Add(parameterAlias); 34 | 35 | return this; 36 | } 37 | 38 | /// 39 | public ITwoParameterSubcommandBuilder WithArgumentParser(ParseArgument argumentParser) 40 | { 41 | ParamSpecs.Last().ArgumentParser = argumentParser; 42 | 43 | return this; 44 | } 45 | 46 | // TODO: WithArgument 47 | 48 | /// 49 | public ITwoParameterSubcommandBuilder WithDefault(TParam2 defaultValue) 50 | { 51 | ParamSpecs.Last().DefaultValue = defaultValue; 52 | 53 | return this; 54 | } 55 | 56 | /// 57 | public ITwoParameterSubcommandBuilder WithDescription(string parameterDescription) 58 | { 59 | ParamSpecs.Last().Description = parameterDescription; 60 | 61 | return this; 62 | } 63 | 64 | // TODO: WithOption 65 | 66 | // TODO: WithRequiredOption 67 | 68 | /// 69 | public TParentBuilder WithSubcommandHandler(Action action) 70 | { 71 | handler = action switch 72 | { 73 | null => null, 74 | _ => (p1,p2) => 75 | { 76 | action(p1,p2); 77 | return Task.FromResult(0); 78 | } 79 | }; 80 | 81 | Services.Replace(ServiceDescriptor.Singleton(BuildCommand)); 82 | 83 | return ParentBuilder; 84 | } 85 | 86 | /// 87 | public TParentBuilder WithSubcommandHandler() where THandler : class, ICommandHandler 88 | { 89 | commandHandlerType = typeof(THandler); 90 | Services.TryAddSingleton, THandler>(); // TryAdd in case they've already added something prior... 91 | 92 | Services.Replace(ServiceDescriptor.Singleton(BuildCommand)); 93 | 94 | return ParentBuilder; 95 | } 96 | 97 | private TSubcommand BuildCommand(IServiceProvider provider) 98 | { 99 | var subcommand = GetCommand(provider) as TSubcommand; 100 | 101 | if (CommandDescription is not null) 102 | { 103 | subcommand.Description = CommandDescription; 104 | } 105 | 106 | if (SubcommandAlias is not null) 107 | { 108 | subcommand.AddAlias(SubcommandAlias); 109 | } 110 | 111 | Func actualHandler; 112 | if (commandHandlerType is not null) 113 | { 114 | // get a handler object with all the dependencies resolved and injected 115 | var commandHandler = provider.GetRequiredService>(); 116 | actualHandler = (value1, value2) => Task.FromResult(commandHandler.Execute(value1, value2)); 117 | } 118 | else 119 | { 120 | if (handler is null) 121 | { 122 | throw new InvalidOperationException("Cannot build a command without a handler."); 123 | } 124 | 125 | actualHandler = (p1, p2) => handler(p1, p2); 126 | } 127 | 128 | var descriptor1 = subcommand.AddParameter(ParamSpecs[0], ParamSpecs[0].ArgumentParser as ParseArgument); 129 | var descriptor2 = subcommand.AddParameter(ParamSpecs[1], ParamSpecs[1].ArgumentParser as ParseArgument); 130 | subcommand.SetHandler(context => 131 | { 132 | var value1 = GetValue(descriptor1, context); 133 | var value2 = GetValue(descriptor2, context); 134 | // Check for null value? 135 | return actualHandler(value1!, value2!); 136 | }); 137 | 138 | return subcommand; 139 | } 140 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsGivenCommandWithTwoRequiredOptionsShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | 4 | using CommandLineExtensionsTests.TestDoubles; 5 | 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | using Pri.CommandLineExtensions; 9 | using Pri.ConsoleApplicationBuilder; 10 | 11 | // ReSharper disable StringLiteralTypo 12 | 13 | namespace CommandLineExtensionsTests; 14 | 15 | public class CommandLineExtensionsGivenCommandWithTwoRequiredOptionsShould 16 | { 17 | readonly string[] args = [Constants.FileOptionName, "appsettings.json", Constants.CountOptionName, "2"]; 18 | 19 | [Fact] 20 | public void CorrectlyBuildCommandWithLambdaHandler() 21 | { 22 | var command = BuildCommand(args, (_, _) => { }); 23 | 24 | Assert.Equal("command description", command.Description); 25 | Assert.Equal(2, command.Options.Count); 26 | var option1 = command.Options.ElementAtOrDefault(0); 27 | Assert.NotNull(option1); 28 | Assert.Equal(Constants.FileOptionName.Trim('-'), option1.Name); 29 | var option2 = command.Options.ElementAtOrDefault(1); 30 | Assert.NotNull(option2); 31 | Assert.Equal(Constants.CountOptionName.Trim('-'), option2.Name); 32 | } 33 | 34 | [Fact] 35 | public void CorrectlyInvokeCommandWithLambdaHandler() 36 | { 37 | bool itRan = false; 38 | FileInfo? givenFileInfo = null; 39 | int? givenCount = null; 40 | var command = BuildCommand(args, (fileInfo, count) => 41 | { 42 | itRan = true; 43 | givenFileInfo = fileInfo; 44 | givenCount = count; 45 | }); 46 | 47 | Assert.Equal(0, command.Invoke(args)); 48 | Assert.True(itRan); 49 | Assert.NotNull(givenFileInfo); 50 | Assert.Equal(new FileInfo("appsettings.json").FullName, givenFileInfo!.FullName); 51 | Assert.NotNull(givenCount); 52 | Assert.Equal(2, givenCount); 53 | } 54 | 55 | [Fact] 56 | public void CorrectlyBuildCommandWithObjectHandler() 57 | { 58 | FileInfoCountHandlerSpy fileInfoCountHandlerSpy = new(); 59 | 60 | var command = BuildCommand(fileInfoCountHandlerSpy); 61 | 62 | Assert.Equal("command description", command.Description); 63 | Assert.Equal(2, command.Options.Count); 64 | var option1 = command.Options.ElementAtOrDefault(0); 65 | Assert.NotNull(option1); 66 | Assert.Equal(Constants.FileOptionName.Trim('-'), option1.Name); 67 | var option2 = command.Options.ElementAtOrDefault(1); 68 | Assert.NotNull(option2); 69 | Assert.Equal(Constants.CountOptionName.Trim('-'), option2.Name); 70 | } 71 | 72 | [Fact] 73 | public void CorrectlyInvokeCommandWithObjectHandler() 74 | { 75 | FileInfoCountHandlerSpy fileInfoCountHandlerSpy = new(); 76 | 77 | var command = BuildCommand(fileInfoCountHandlerSpy); 78 | 79 | Assert.Equal(0, command.Invoke(args)); 80 | Assert.True(fileInfoCountHandlerSpy.WasExecuted); 81 | Assert.NotNull(fileInfoCountHandlerSpy.GivenFileInfo); 82 | Assert.Equal(2, fileInfoCountHandlerSpy.GivenCount); 83 | } 84 | 85 | [Fact] 86 | public void CorrectlyFailInvokeWithObjectHandlerGivenMissingOption() 87 | { 88 | FileInfoCountHandlerSpy fileInfoCountHandlerSpy = new(); 89 | 90 | var command = BuildCommand(fileInfoCountHandlerSpy); 91 | 92 | Assert.Equal(1, command.Invoke([])); 93 | Assert.False(fileInfoCountHandlerSpy.WasExecuted); 94 | Assert.Null(fileInfoCountHandlerSpy.GivenFileInfo); 95 | Assert.NotEqual(2, fileInfoCountHandlerSpy.GivenCount); 96 | } 97 | 98 | [Fact] 99 | public void CorrectlyBuildParser() 100 | { 101 | var builder = ConsoleApplication.CreateBuilder(args); 102 | 103 | builder.Services.AddCommand() 104 | .WithRequiredOption(Constants.FileOptionName, "file option description"); 105 | 106 | var parser = builder.Build(); 107 | Assert.NotNull(parser); 108 | } 109 | 110 | private RootCommand BuildCommand(FileInfoCountHandlerSpy fileInfoCountHandlerSpy) 111 | { 112 | var builder = ConsoleApplication.CreateBuilder(args); 113 | builder.Services.AddSingleton>(_ => fileInfoCountHandlerSpy); 114 | builder.Services.AddCommand() 115 | .WithDescription("command description") 116 | .WithRequiredOption(Constants.FileOptionName, "file option description") 117 | .WithRequiredOption(Constants.CountOptionName, "count option description") 118 | .WithHandler(); 119 | 120 | return builder.Build(); 121 | } 122 | 123 | private static RootCommand BuildCommand(string[] args, Action action) 124 | { 125 | var builder = ConsoleApplication.CreateBuilder(args); 126 | builder.Services.AddCommand() 127 | .WithDescription("command description") 128 | .WithRequiredOption(Constants.FileOptionName, "file option description") 129 | .WithRequiredOption(Constants.CountOptionName, "count option description") 130 | .WithHandler(action); 131 | return builder.Build(); 132 | } 133 | } -------------------------------------------------------------------------------- /src/dotnet-new-template/content/consoleapp/.template.config/template.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/template", 3 | "author": "Peter Ritchie", 4 | "classifications": [ "Common", "Console" ], 5 | "identity": "PRI.ConsoleApplication", 6 | "name": "Console application using builder", 7 | "shortName": "consoleapp", 8 | "sourceName": "Company.ConsoleApplication1", 9 | "tags": { 10 | "language": "C#", 11 | "type": "project" 12 | }, 13 | "symbols": { 14 | "TargetFrameworkOverride": { 15 | "type": "parameter", 16 | "description": "Overrides the target framework", 17 | "replaces": "TargetFrameworkOverride", 18 | "datatype": "string", 19 | "defaultValue": "", 20 | "displayName": "Target framework override" 21 | }, 22 | "Framework": { 23 | "type": "parameter", 24 | "description": "The target framework for the project.", 25 | "datatype": "choice", 26 | "choices": [ 27 | { 28 | "choice": "net10.0", 29 | "description": "Target net10.0", 30 | "displayName": ".NET 10.0" 31 | } 32 | ], 33 | "replaces": "net10.0", 34 | "defaultValue": "net10.0", 35 | "displayName": "Framework" 36 | }, 37 | "langVersion": { 38 | "type": "parameter", 39 | "datatype": "text", 40 | "description": "Sets the LangVersion property in the created project file", 41 | "defaultValue": "", 42 | "replaces": "$(ProjectLanguageVersion)", 43 | "displayName": "Language version" 44 | }, 45 | "HostIdentifier": { 46 | "type": "bind", 47 | "binding": "host:HostIdentifier" 48 | }, 49 | "skipRestore": { 50 | "type": "parameter", 51 | "datatype": "bool", 52 | "description": "If specified, skips the automatic restore of the project on create.", 53 | "defaultValue": "false", 54 | "displayName": "Skip restore" 55 | }, 56 | "NativeAot" : { 57 | "type": "parameter", 58 | "datatype": "bool", 59 | "defaultValue": "false", 60 | "displayName": "Enable _native AOT publish", 61 | "description": "Whether to enable the project for publishing as native AOT." 62 | }, 63 | "csharp9orOlder": { 64 | "type": "generated", 65 | "generator": "regexMatch", 66 | "datatype": "bool", 67 | "parameters": { 68 | "pattern": "^(ISO-1|ISO-2|[1-7]|[8-9]|[8-9]\\.0|7\\.[0-3])$", 69 | "source": "langVersion" 70 | } 71 | }, 72 | "csharp8orOlder": { 73 | "type": "generated", 74 | "generator": "regexMatch", 75 | "datatype": "bool", 76 | "parameters": { 77 | "pattern": "^(ISO-1|ISO-2|[1-7]|8|8\\.0|7\\.[0-3])$", 78 | "source": "langVersion" 79 | } 80 | }, 81 | "csharp7orOlder": { 82 | "type": "generated", 83 | "generator": "regexMatch", 84 | "datatype": "bool", 85 | "parameters": { 86 | "pattern": "^(ISO-1|ISO-2|[1-7]|7\\.[0-3])$", 87 | "source": "langVersion" 88 | } 89 | }, 90 | "csharp10orLater": { 91 | "type": "computed", 92 | "value": "!csharp9orOlder" 93 | }, 94 | "csharp9orLater": { 95 | "type": "computed", 96 | "value": "!csharp8orOlder" 97 | }, 98 | "csharp8orLater": { 99 | "type": "computed", 100 | "value": "!csharp7orOlder" 101 | }, 102 | "csharpFeature_ImplicitUsings": { 103 | "type": "computed", 104 | "value": "csharp10orLater == \"true\"" 105 | }, 106 | "csharpFeature_Nullable": { 107 | "type": "computed", 108 | "value": "csharp8orLater == \"true\"" 109 | }, 110 | "csharpFeature_FileScopedNamespaces": { 111 | "type": "computed", 112 | "value": "csharp10orLater == \"true\"" 113 | } 114 | }, 115 | "primaryOutputs": [ 116 | { 117 | "path": "Company.ConsoleApplication1.csproj" 118 | }, 119 | { 120 | "condition": "(HostIdentifier != \"dotnetcli\" && HostIdentifier != \"dotnetcli-preview\")", 121 | "path": "Program.cs" 122 | } 123 | ], 124 | "defaultName": "ConsoleApp1", 125 | "postActions": [ 126 | { 127 | "id": "restore", 128 | "condition": "(!skipRestore)", 129 | "description": "Restore NuGet packages required by this project.", 130 | "manualInstructions": [ 131 | { 132 | "text": "Run 'dotnet restore'" 133 | } 134 | ], 135 | "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025", 136 | "continueOnError": true 137 | }, 138 | { 139 | "id": "open-file", 140 | "condition": "(HostIdentifier != \"dotnetcli\" && HostIdentifier != \"dotnetcli-preview\")", 141 | "description": "Opens Program.cs in the editor", 142 | "manualInstructions": [], 143 | "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", 144 | "args": { 145 | "files": "1" 146 | }, 147 | "continueOnError": true 148 | } 149 | ] 150 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/ExitCodeTests.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | 3 | using CommandLineExtensionsTests.TestDoubles; 4 | 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | using NSubstitute; 8 | 9 | using Pri.CommandLineExtensions; 10 | using Pri.ConsoleApplicationBuilder; 11 | 12 | namespace CommandLineExtensionsTests; 13 | 14 | public class ExitCodeTests : CommandLineBuilderTestingBase 15 | { 16 | [Fact] 17 | public async Task ReturnNonZero() 18 | { 19 | string[] args = []; 20 | bool lambdaWasInvoked = false; 21 | var builder = ConsoleApplication.CreateBuilder(args); 22 | builder.Services.AddCommand() 23 | .WithHandler(() => 24 | { 25 | lambdaWasInvoked = true; 26 | return 1; 27 | }); 28 | var command = builder.Build(); 29 | int exitCode = await command.InvokeAsync(args); 30 | Assert.True(lambdaWasInvoked); 31 | Assert.Equal(1, exitCode); 32 | } 33 | 34 | [Fact] 35 | public Task ThrowWithNullFunc() 36 | { 37 | string[] args = []; 38 | var builder = ConsoleApplication.CreateBuilder(args); 39 | builder.Services.AddCommand() 40 | .WithHandler((Func)null!); 41 | var ex = Assert.Throws(builder.Build); 42 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 43 | return Task.CompletedTask; 44 | } 45 | 46 | [Fact] 47 | public async Task ReturnNonZeroWithOneParameter() 48 | { 49 | string[] args = ["--count", "2"]; 50 | bool lambdaWasInvoked = false; 51 | int actualParameter = 0; 52 | var builder = ConsoleApplication.CreateBuilder(args); 53 | builder.Services.AddCommand() 54 | .WithOption("--count", "count") 55 | .WithHandler(c => 56 | { 57 | lambdaWasInvoked = true; 58 | actualParameter = c; 59 | return 1; 60 | }); 61 | var command = builder.Build(); 62 | int exitCode = await command.InvokeAsync(args); 63 | Assert.True(lambdaWasInvoked); 64 | Assert.Equal(2, actualParameter); 65 | Assert.Equal(1, exitCode); 66 | } 67 | 68 | [Fact] 69 | public void ThrowWithNullFuncWithOneParameter() 70 | { 71 | string[] args = []; 72 | var builder = ConsoleApplication.CreateBuilder(args); 73 | builder.Services.AddCommand() 74 | .WithOption("--count", "count") 75 | .WithHandler((Func)null!); 76 | var ex = Assert.Throws(builder.Build); 77 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 78 | } 79 | 80 | [Fact] 81 | public async Task ReturnNonZeroWithTwoParameters() 82 | { 83 | string[] args = ["--x", "2", "--y", "3"]; 84 | bool lambdaWasInvoked = false; 85 | int actualX = 0; 86 | int actualY = 0; 87 | var builder = ConsoleApplication.CreateBuilder(args); 88 | builder.Services.AddCommand() 89 | .WithOption("--x", "x coordinate") 90 | .WithOption("--y", "y coordinate") 91 | .WithHandler((x,y) => 92 | { 93 | lambdaWasInvoked = true; 94 | actualX = x; 95 | actualY = y; 96 | return 1; 97 | }); 98 | var command = builder.Build(); 99 | int exitCode = await command.InvokeAsync(args); 100 | Assert.True(lambdaWasInvoked); 101 | Assert.Equal(2, actualX); 102 | Assert.Equal(3, actualY); 103 | Assert.Equal(1, exitCode); 104 | } 105 | 106 | [Fact] 107 | public void ThrowWithNullFuncWithTwoParameters() 108 | { 109 | string[] args = ["--x", "2", "--y", "3"]; 110 | var builder = ConsoleApplication.CreateBuilder(args); 111 | builder.Services.AddCommand() 112 | .WithOption("--x", "x coordinate") 113 | .WithOption("--y", "y coordinate") 114 | .WithHandler((Func)null!); 115 | var ex = Assert.Throws(builder.Build); 116 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 117 | } 118 | } 119 | 120 | public class InjectionTests : CommandLineBuilderTestingBase 121 | { 122 | [Fact] 123 | public void InjectCorrectly() 124 | { 125 | var injectable = Substitute.For(); 126 | injectable.GetValue().Returns(5); 127 | 128 | string[] args = ["appsettings.json"]; 129 | 130 | var builder = ConsoleApplication.CreateBuilder(args); 131 | builder.Services.AddCommand() 132 | .WithArgument("file", "The filename to process.") 133 | .WithHandler(); 134 | builder.Services.AddSingleton(injectable); 135 | 136 | var exitCode = builder.Build().Invoke(args, Console); 137 | 138 | Assert.Equal(5, exitCode); 139 | injectable.Received(1).GetValue(); 140 | } 141 | 142 | public interface IInjectable 143 | { 144 | int GetValue(); 145 | } 146 | 147 | // ReSharper disable once ClassNeverInstantiated.Global 148 | public class DummyCommandHandler(IInjectable injectable) : ICommandHandler 149 | { 150 | public int Execute(FileInfo paramValue) 151 | { 152 | return injectable.GetValue(); 153 | } 154 | } 155 | } -------------------------------------------------------------------------------- /.azuredevops/templates/jobs/build-and-pack-nuget-job.yml: -------------------------------------------------------------------------------- 1 | # build and pack nuget 2 | parameters: 3 | - name: name 4 | type: string 5 | default: 'pack' 6 | displayName: 'The name of the job, without spaces or dashes (-).' 7 | - name: displayName 8 | type: string 9 | displayName: 'The display name of the job, displayed in pipeline runs.' 10 | - name: majorVersion 11 | type: number 12 | default: 1 13 | displayName: 'The major version number of the package.' 14 | - name: minorVersion 15 | type: number 16 | default: 0 17 | displayName: 'The minor version number of the package.' 18 | - name: versionSuffixType 19 | type: string 20 | default: 'none' 21 | values: 22 | - 'rc' 23 | - 'beta' 24 | - 'alpha' 25 | - 'none' 26 | displayName: 'The version suffix of the package (none, rc, beta, or alpha).' 27 | - name: projects 28 | type: string 29 | displayName: 'A file matching pattern for the projects to restore and build.' 30 | - name: packagesToPack 31 | type: string 32 | displayName: 'A file matching pattern for the packages to pack].' 33 | - name: artifactName 34 | type: string 35 | default: 'NuGet' 36 | displayName: 'The name of the artifact to upload containing one ore more NuGet packages (nupkg)' 37 | - name: condition 38 | type: string 39 | default: '' 40 | displayName: 'The optional condition to evaluate to determine if the job should run' 41 | - name: dependsOn 42 | type: string 43 | displayName: 'the name of the job that produces the NuGet artifacts' 44 | - name: dotNetVersion 45 | type: string 46 | default: '8.x' 47 | displayName: 'The version of the .NET core SDK. e.g. 8.x, 8.0.x, 8.0.12. ' 48 | 49 | jobs: 50 | - job: ${{ parameters.name }} 51 | ${{ if ne(parameters.condition, '') }}: 52 | condition: ${{ parameters.condition }} 53 | dependsOn: ${{ parameters.dependsOn }} 54 | variables: 55 | # Patch version MUST be reset to 0 when minor version is incremented. 56 | # Patch and minor versions MUST be reset to 0 when major version is incremented. 57 | patchVersion: $[counter(format('{0}-{1}', ${{ parameters.majorVersion }}, ${{ parameters.minorVersion }}), 0)] 58 | buildVersion: $[counter(format('{0}-{1}-{2}', ${{ parameters.majorVersion }}, ${{ parameters.minorVersion }}, '${{ parameters.versionSuffixType }}'), 0)] 59 | versionPrefix: '$(majorVersion).$(minorVersion).$(patchVersion)' 60 | ${{ if eq('none', parameters.versionSuffixType) }}: 61 | versionSuffix: '' 62 | ${{ else }}: 63 | versionSuffix: $[replace(format('{0}.{1}', '${{ parameters.versionSuffixType }}', variables['buildVersion'] ), '.0', '')] 64 | 65 | steps: 66 | - powershell: 'echo "executing ${{ parameters.name }} for $(versionPrefix) and $(versionSuffix)"' 67 | condition: eq(variables['Agent.Diagnostic'], 'true') 68 | 69 | - task: UseDotNet@2 70 | displayName: 'Use .NET SDK 8.0' 71 | inputs: 72 | packageType: 'sdk' 73 | version: ${{ parameters.dotNetVersion }} 74 | 75 | - task: DotNetCoreCLI@2 76 | displayName: 'Restore project dependencies' 77 | inputs: 78 | command: 'restore' 79 | projects: ${{ parameters.projects }} 80 | - task: DotNetCoreCLI@2 81 | displayName: 'Build' 82 | inputs: 83 | command: 'build' 84 | projects: ${{ parameters.projects }} 85 | arguments: >- 86 | --no-restore 87 | -c Release 88 | --nologo 89 | /clp:ErrorsOnly 90 | /p:Version=$(versionPrefix).$(buildVersion) 91 | /p:VersionSuffix=$(versionSuffix) 92 | - task: DotNetCoreCLI@2 93 | displayName: 'Pack' 94 | inputs: 95 | command: 'pack' 96 | packagesToPack: ${{ parameters.packagesToPack }} 97 | buildProperties: 'VersionPrefix=$(versionPrefix);VersionSuffix=$(versionSuffix)' 98 | nobuild: true 99 | includeSymbols: true 100 | includesource: true 101 | 102 | - task: PublishPipelineArtifact@1 103 | displayName: 'Publish NuGet package as artifact' 104 | inputs: 105 | artifactName: ${{ parameters.artifactName }} 106 | targetPath: '$(Build.ArtifactStagingDirectory)' 107 | publishLocation: 'pipeline' 108 | 109 | - powershell: | 110 | if('' -eq $(versionPrefix)) { 111 | Write-Host "##vso[task.setvariable variable=packageVersion;isOutput=true]$(versionPrefix)" 112 | } else { 113 | Write-Host "##vso[task.setvariable variable=packageVersion;isOutput=true]$(versionPrefix)-$(versionSuffix)" 114 | } 115 | Write-Host "##vso[task.setvariable variable=assemblyVersion;isOutput=true]$(versionPrefix).$(buildVersion)" 116 | Write-Host "##vso[task.setvariable variable=majorVersion;isOutput=true]${{ parameters.majorVersion }}" 117 | Write-Host "##vso[task.setvariable variable=minorVersion;isOutput=true]${{ parameters.minorVersion }}" 118 | Write-Host "##vso[task.setvariable variable=patchVersion;isOutput=true]$(patchVersion)" 119 | Write-Host "##vso[task.setvariable variable=buildVersion;isOutput=true]$(buildVersion)" 120 | name: versions 121 | displayName: 'Output calculated version values' 122 | -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsGivenArgumentFreeRootCommandShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | using System.Text; 4 | 5 | using CommandLineExtensionsTests.TestDoubles; 6 | 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | using Pri.CommandLineExtensions; 10 | using Pri.ConsoleApplicationBuilder; 11 | 12 | namespace CommandLineExtensionsTests; 13 | 14 | public class CommandLineExtensionsGivenArgumentFreeRootCommandShould 15 | { 16 | [Fact] 17 | public void BuildParserWithLambdaHandlerCorrectly() 18 | { 19 | string[] args = []; 20 | var builder = ConsoleApplication.CreateBuilder(args); 21 | builder.Services.AddCommand() 22 | .WithDescription("command description") 23 | .WithHandler(() => { }); 24 | 25 | var parser = builder.Build(); 26 | Assert.NotNull(parser); 27 | Assert.NotNull(parser.Configuration.RootCommand); 28 | } 29 | 30 | [Fact] 31 | public void HaveUnAliasedCommandCorrectly() 32 | { 33 | var builder = ConsoleApplication.CreateBuilder([]); 34 | builder.Services.AddCommand() 35 | .WithDescription("command description") 36 | .WithHandler(() => { }); 37 | 38 | var command = builder.Build(); 39 | Assert.Single(command.Aliases); 40 | } 41 | 42 | [Fact] 43 | public void HaveExpectedHelpOutput() 44 | { 45 | var outStringBuilder = new StringBuilder(); 46 | var errStringBuilder = new StringBuilder(); 47 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 48 | 49 | var builder = ConsoleApplication.CreateBuilder([]); 50 | builder.Services.AddCommand() 51 | .WithDescription("command description") 52 | .WithHandler(() => { }); 53 | 54 | var command = builder.Build(); 55 | command.Invoke("--help", console); 56 | 57 | Assert.Equal($""" 58 | Description: 59 | command description 60 | 61 | Usage: 62 | {Utility.ExecutingTestRunnerName} [options] 63 | 64 | Options: 65 | --version Show version information 66 | -?, -h, --help Show help and usage information 67 | 68 | 69 | 70 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 71 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 72 | } 73 | 74 | [Fact] 75 | public void GivenACommandAliasHaveExpectedHelpOutput() 76 | { 77 | var outStringBuilder = new StringBuilder(); 78 | var errStringBuilder = new StringBuilder(); 79 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 80 | 81 | var builder = ConsoleApplication.CreateBuilder([]); 82 | builder.Services.AddCommand() 83 | .WithDescription("command description") 84 | .WithHandler(() => { }); 85 | 86 | var command = builder.Build(); 87 | command.Invoke("--help", console); 88 | 89 | Assert.Equal($""" 90 | Description: 91 | command description 92 | 93 | Usage: 94 | {Utility.ExecutingTestRunnerName} [options] 95 | 96 | Options: 97 | --version Show version information 98 | -?, -h, --help Show help and usage information 99 | 100 | 101 | 102 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 103 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 104 | } 105 | 106 | [Fact] 107 | public void BuildCommandWithLambdaHandlerCorrectly() 108 | { 109 | string[] args = []; 110 | 111 | var builder = ConsoleApplication.CreateBuilder(args); 112 | builder.Services.AddCommand() 113 | .WithDescription("command description") 114 | .WithHandler(() => { }); 115 | 116 | var command = builder.Build(); 117 | Assert.Equal("command description", command.Description); 118 | Assert.Empty(command.Options); 119 | } 120 | 121 | [Fact] 122 | public void InvokeCommandWithLambdaHandlerCorrectly() 123 | { 124 | string[] args = []; 125 | 126 | var builder = ConsoleApplication.CreateBuilder(args); 127 | bool itRan = false; 128 | builder.Services.AddCommand() 129 | .WithDescription("command description") 130 | .WithHandler(() => itRan = true); 131 | 132 | var command = builder.Build(); 133 | Assert.Equal(0, command.Invoke(args)); 134 | Assert.True(itRan); 135 | } 136 | 137 | [Fact] 138 | public void BuildCommandWithObjectHandlerCorrectly() 139 | { 140 | string[] args = []; 141 | 142 | var builder = ConsoleApplication.CreateBuilder(args); 143 | builder.Services.AddCommand() 144 | .WithDescription("command description") 145 | .WithHandler(); 146 | 147 | var command = builder.Build(); 148 | Assert.Equal("command description", command.Description); 149 | Assert.Empty(command.Options); 150 | } 151 | 152 | [Fact] 153 | public void InvokeCommandWithObjectHandlerCorrectly() 154 | { 155 | string[] args = []; 156 | HandlerSpy handlerSpy = new(); 157 | 158 | var builder = ConsoleApplication.CreateBuilder(args); 159 | builder.Services.AddSingleton(_ => handlerSpy); 160 | builder.Services.AddCommand() 161 | .WithDescription("command description") 162 | .WithHandler(); 163 | 164 | var command = builder.Build(); 165 | Assert.Equal(0, command.Invoke(args)); 166 | Assert.True(handlerSpy.WasExecuted); 167 | } 168 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/OneParameterSubcommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Binding; 3 | using System.CommandLine.Parsing; 4 | #if PARANOID 5 | using System.Diagnostics; 6 | #endif 7 | 8 | using Microsoft.Extensions.DependencyInjection; 9 | using Microsoft.Extensions.DependencyInjection.Extensions; 10 | 11 | namespace Pri.CommandLineExtensions; 12 | 13 | // Need to support multiple types of parents: IOneParameterCommandLineCommandBuilder, ?ICommandLineOneParameterSubcommandBuilder? 14 | internal class OneParameterSubcommandBuilder : SubcommandBuilderBase, 15 | IOneParameterSubcommandBuilder 16 | where TSubcommand : Command, new() 17 | where TParentBuilder : IBuilderState 18 | { 19 | internal TParentBuilder ParentBuilder { get; } 20 | private Func? handler; 21 | //private ParseArgument? parseArgument; 22 | 23 | private OneParameterSubcommandBuilder(SubcommandBuilder initiator) 24 | : base(initiator) 25 | { 26 | ParentBuilder = initiator.ParentBuilder; 27 | } 28 | 29 | public OneParameterSubcommandBuilder(SubcommandBuilder initiator, ParamSpec paramSpec) : this(initiator) 30 | { 31 | ParamSpecs.Add(paramSpec); 32 | } 33 | 34 | /// 35 | public IOneParameterSubcommandBuilder AddAlias(string parameterAlias) 36 | { 37 | ParamSpecs.Last().Aliases.Add(parameterAlias); 38 | 39 | return this; 40 | } 41 | 42 | /// 43 | public ITwoParameterSubcommandBuilder WithArgument(string name, string description) 44 | => new TwoParameterSubcommandBuilder(this, 45 | new ParamSpec 46 | { 47 | Name = name, 48 | Description = description, 49 | IsArgument = true 50 | }); 51 | 52 | /// 53 | public IOneParameterSubcommandBuilder WithArgumentParser(ParseArgument argumentParser) 54 | { 55 | ParamSpecs.Last().ArgumentParser = argumentParser; 56 | 57 | return this; 58 | } 59 | 60 | /// 61 | public IOneParameterSubcommandBuilder WithDefault(TParam defaultValue) 62 | { 63 | ParamSpecs.Last().DefaultValue = defaultValue; 64 | 65 | return this; 66 | } 67 | 68 | /// 69 | public IOneParameterSubcommandBuilder WithDescription(string parameterDescription) 70 | { 71 | ParamSpecs.Last().Description = parameterDescription; 72 | 73 | return this; 74 | } 75 | 76 | /// 77 | public ITwoParameterSubcommandBuilder WithOption(string name, string description) 78 | => new TwoParameterSubcommandBuilder(this, 79 | new ParamSpec 80 | { 81 | Name = name, 82 | Description = description 83 | }); 84 | 85 | /// 86 | public ITwoParameterSubcommandBuilder WithRequiredOption(string name, string description) 87 | => new TwoParameterSubcommandBuilder(this, 88 | new ParamSpec 89 | { 90 | Name = name, 91 | Description = description, 92 | IsRequired = true 93 | }); 94 | 95 | /// 96 | public TParentBuilder WithSubcommandHandler() where THandler : class, ICommandHandler 97 | { 98 | commandHandlerType = typeof(THandler); 99 | Services.TryAddSingleton, THandler>(); // TryAdd in case they've already added something prior... 100 | Services.Replace(ServiceDescriptor.Singleton(BuildCommand)); 101 | 102 | return ParentBuilder; 103 | } 104 | 105 | /// 106 | public TParentBuilder WithSubcommandHandler(Action action) 107 | { 108 | handler = action switch 109 | { 110 | null => null, 111 | _ => handler = p => 112 | { 113 | action(p); 114 | return Task.FromResult(0); 115 | } 116 | }; 117 | 118 | Services.Replace(ServiceDescriptor.Singleton(BuildCommand)); 119 | 120 | return ParentBuilder; 121 | } 122 | 123 | private TSubcommand BuildCommand(IServiceProvider provider) 124 | { 125 | var subcommand = GetCommand(provider) as TSubcommand; 126 | 127 | if (CommandDescription is not null) 128 | { 129 | subcommand.Description = CommandDescription; 130 | } 131 | 132 | if (SubcommandAlias is not null) 133 | { 134 | subcommand.AddAlias(SubcommandAlias); 135 | } 136 | 137 | Func actualHandler; 138 | if (commandHandlerType is not null) 139 | { 140 | // get a handler object with all the dependencies resolved and injected 141 | var commandHandler = provider.GetRequiredService>(); 142 | actualHandler = value => Task.FromResult(commandHandler.Execute(value)); 143 | } 144 | else 145 | { 146 | if (handler is null) 147 | { 148 | throw new InvalidOperationException("Cannot build a command without a handler."); 149 | } 150 | 151 | actualHandler = p => handler(p); 152 | } 153 | 154 | var paramSpec = ParamSpecs[0]; 155 | 156 | IValueDescriptor descriptor = subcommand.AddParameter(paramSpec, paramSpec.ArgumentParser as ParseArgument); 157 | 158 | subcommand.SetHandler(context => 159 | { 160 | var value = GetValue(descriptor, context); 161 | // check for null value? 162 | return actualHandler(value!); 163 | }); 164 | 165 | return subcommand; 166 | } 167 | } -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/CreatingMinimalApplicationInstanceWithSettingsShould.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | using Pri.ConsoleApplicationBuilder; 4 | 5 | namespace ConsoleApplicationBuilderTests; 6 | 7 | [Collection("Isolated Execution Collection")] 8 | public class CreatingMinimalApplicationInstanceWithSettingsShould 9 | { 10 | [Fact] 11 | public void HaveApplicationNameFromSettings() 12 | { 13 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [], ApplicationName = "HaveApplicationNameFromSettings"}); 14 | Assert.Equal("HaveApplicationNameFromSettings", builder.Environment.ApplicationName); 15 | } 16 | 17 | [Fact] 18 | public void HaveContentRootPath() 19 | { 20 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [], ContentRootPath = "." }); 21 | Assert.Equal(Path.Combine(Directory.GetCurrentDirectory(), "."), builder.Environment.ContentRootPath); 22 | } 23 | 24 | [Fact] 25 | public void HaveApplicationNameFromSettingsConfiguration() 26 | { 27 | ConfigurationManager configurationManager = new(); 28 | configurationManager.AddInMemoryCollection( 29 | new List>([new KeyValuePair("applicationName", "HaveApplicationNameFromSettingsConfiguration")]) 30 | ); 31 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [], Configuration = configurationManager}); 32 | Assert.Equal("HaveApplicationNameFromSettingsConfiguration", builder.Environment.ApplicationName); 33 | } 34 | 35 | [Fact] 36 | public void BuildCorrectly() 37 | { 38 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 39 | var o = builder.Build(); 40 | Assert.NotNull(o); 41 | } 42 | 43 | [Fact] 44 | public void HaveCorrectDefaultEnvironmentName() 45 | { 46 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 47 | _ = builder.Build(); 48 | Assert.Equal("Production", builder.Environment.EnvironmentName); 49 | } 50 | 51 | [Fact] 52 | public void ProperlyInitializeEnvironmentName() 53 | { 54 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [], EnvironmentName = "ProperlyInitializeEnvironmentName" }); 55 | Assert.Equal("ProperlyInitializeEnvironmentName", builder.Environment.EnvironmentName); 56 | } 57 | 58 | [Fact] 59 | public void ProperlyInjectConfiguration() 60 | { 61 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 62 | var o = builder.Build(); 63 | Assert.NotNull(o.Configuration); 64 | } 65 | 66 | [Fact] 67 | public void ProperlyLoadAppSettings() 68 | { 69 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 70 | var o = builder.Build(); 71 | Assert.Equal("appSettingsValue", o.Configuration["appSettingsKey"]); 72 | } 73 | 74 | [Fact] 75 | public void ProperlyAddCommandLineArgsToConfiguration() 76 | { 77 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = ["--key=value"] }); 78 | var o = builder.Build(); 79 | Assert.Equal("value", o.Configuration["key"]); 80 | } 81 | 82 | [Fact] 83 | public void DevelopmentAppSettingsOverridesRootAppSettings() 84 | { 85 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [], EnvironmentName = "Development"}); 86 | var o = builder.Build(); 87 | Assert.Equal("development", o.Configuration["developmentAppSettingsKey"]); 88 | } 89 | 90 | [Fact] 91 | public void EnvironmentVariablesOverrideAppSettings() 92 | { 93 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = ["--appSettingsKey=from-commandline"] }); 94 | var o = builder.Build(); 95 | Assert.Equal("from-commandline", o.Configuration["appSettingsKey"]); 96 | } 97 | 98 | [Fact] 99 | public void CommandLineArgumentsOverrideEnvironmentVariables() 100 | { 101 | Environment.SetEnvironmentVariable("DOTNET_key", "from-environment"); 102 | try 103 | { 104 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = ["--key=from-commandline"] }); 105 | var o = builder.Build(); 106 | Assert.Equal("from-commandline", o.Configuration["key"]); 107 | } 108 | finally 109 | { 110 | Environment.SetEnvironmentVariable("DOTNET_key", null); 111 | } 112 | } 113 | 114 | [Fact] 115 | public void ThrowExceptionIfBuiltTwice() 116 | { 117 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 118 | _ = builder.Build(); 119 | var ex = Assert.Throws(builder.Build); 120 | Assert.Equal("Build can only be called once.", ex.Message); 121 | } 122 | 123 | [Fact] 124 | public void WorkWithReloadConfigOnChangeValueConfiguration() 125 | { 126 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", "true"); 127 | try 128 | { 129 | var builder = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] }); 130 | Assert.NotNull(builder); 131 | } 132 | finally 133 | { 134 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", null); 135 | } 136 | } 137 | 138 | [Fact] 139 | public void ThrowWithBadReloadConfigOnChangeValueConfiguration() 140 | { 141 | try 142 | { 143 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", "gibberish"); 144 | Assert.Throws(() => _ = ConsoleApplication.CreateBuilder(new ConsoleApplicationBuilderSettings { Args = [] })); 145 | } 146 | finally 147 | { 148 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", null); 149 | } 150 | } 151 | 152 | private class Program(IConfiguration configuration) 153 | { 154 | public IConfiguration Configuration { get; } = configuration; 155 | } 156 | } -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pri.ConsoleApplicationBuilder", "ConsoleApplicationBuilder\ConsoleApplicationBuilder\Pri.ConsoleApplicationBuilder.csproj", "{4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleApplicationBuilderTests", "Tests\ConsoleApplicationBuilderTests\ConsoleApplicationBuilderTests.csproj", "{D9239B4F-4550-449C-AEF1-42BD02150414}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Pri.CommandLineExtensions", "CommandLineExtensions\Pri.CommandLineExtensions.csproj", "{0CE8229E-3659-42B2-A337-087FEB014AD0}" 11 | EndProject 12 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}" 13 | ProjectSection(SolutionItems) = preProject 14 | CodeCoverage.runsettings = CodeCoverage.runsettings 15 | EndProjectSection 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" 18 | EndProject 19 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CommandLineExtensionsTests", "Tests\CommandLineExtensionsTests\CommandLineExtensionsTests.csproj", "{CCD13C46-6727-42B1-917A-AAE4FDCF4723}" 20 | EndProject 21 | Global 22 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 23 | Debug|Any CPU = Debug|Any CPU 24 | Debug|x64 = Debug|x64 25 | Debug|x86 = Debug|x86 26 | Release|Any CPU = Release|Any CPU 27 | Release|x64 = Release|x64 28 | Release|x86 = Release|x86 29 | EndGlobalSection 30 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 31 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|x64.ActiveCfg = Debug|Any CPU 34 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|x64.Build.0 = Debug|Any CPU 35 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|x86.ActiveCfg = Debug|Any CPU 36 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Debug|x86.Build.0 = Debug|Any CPU 37 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|x64.ActiveCfg = Release|Any CPU 40 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|x64.Build.0 = Release|Any CPU 41 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|x86.ActiveCfg = Release|Any CPU 42 | {4B0A7ABB-5A2B-493A-AAA2-8DAD57A4D5EC}.Release|x86.Build.0 = Release|Any CPU 43 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 44 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|Any CPU.Build.0 = Debug|Any CPU 45 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|x64.ActiveCfg = Debug|Any CPU 46 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|x64.Build.0 = Debug|Any CPU 47 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|x86.ActiveCfg = Debug|Any CPU 48 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Debug|x86.Build.0 = Debug|Any CPU 49 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|Any CPU.ActiveCfg = Release|Any CPU 50 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|Any CPU.Build.0 = Release|Any CPU 51 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|x64.ActiveCfg = Release|Any CPU 52 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|x64.Build.0 = Release|Any CPU 53 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|x86.ActiveCfg = Release|Any CPU 54 | {D9239B4F-4550-449C-AEF1-42BD02150414}.Release|x86.Build.0 = Release|Any CPU 55 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 56 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|Any CPU.Build.0 = Debug|Any CPU 57 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|x64.ActiveCfg = Debug|Any CPU 58 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|x64.Build.0 = Debug|Any CPU 59 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|x86.ActiveCfg = Debug|Any CPU 60 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Debug|x86.Build.0 = Debug|Any CPU 61 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|Any CPU.ActiveCfg = Release|Any CPU 62 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|Any CPU.Build.0 = Release|Any CPU 63 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|x64.ActiveCfg = Release|Any CPU 64 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|x64.Build.0 = Release|Any CPU 65 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|x86.ActiveCfg = Release|Any CPU 66 | {0CE8229E-3659-42B2-A337-087FEB014AD0}.Release|x86.Build.0 = Release|Any CPU 67 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 68 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|Any CPU.Build.0 = Debug|Any CPU 69 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|x64.ActiveCfg = Debug|Any CPU 70 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|x64.Build.0 = Debug|Any CPU 71 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|x86.ActiveCfg = Debug|Any CPU 72 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Debug|x86.Build.0 = Debug|Any CPU 73 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|Any CPU.ActiveCfg = Release|Any CPU 74 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|Any CPU.Build.0 = Release|Any CPU 75 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|x64.ActiveCfg = Release|Any CPU 76 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|x64.Build.0 = Release|Any CPU 77 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|x86.ActiveCfg = Release|Any CPU 78 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723}.Release|x86.Build.0 = Release|Any CPU 79 | EndGlobalSection 80 | GlobalSection(SolutionProperties) = preSolution 81 | HideSolutionNode = FALSE 82 | EndGlobalSection 83 | GlobalSection(NestedProjects) = preSolution 84 | {D9239B4F-4550-449C-AEF1-42BD02150414} = {0AB3BF05-4346-4AA6-1389-037BE0695223} 85 | {CCD13C46-6727-42B1-917A-AAE4FDCF4723} = {0AB3BF05-4346-4AA6-1389-037BE0695223} 86 | EndGlobalSection 87 | GlobalSection(ExtensibilityGlobals) = postSolution 88 | SolutionGuid = {CB176473-AE0F-4EF1-B514-71DD002905E6} 89 | EndGlobalSection 90 | EndGlobal 91 | -------------------------------------------------------------------------------- /src/Tests/ConsoleApplicationBuilderTests/CreatingMinimalApplicationInstanceShould.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | using Pri.ConsoleApplicationBuilder; 4 | 5 | namespace ConsoleApplicationBuilderTests; 6 | 7 | [Collection("Isolated Execution Collection")] 8 | public class CreatingMinimalApplicationInstanceShould 9 | { 10 | [Fact] 11 | public void BuildCorrectly() 12 | { 13 | var builder = ConsoleApplication.CreateBuilder([]); 14 | Assert.Empty(builder.Properties); 15 | Assert.NotNull(builder.Metrics); 16 | Assert.NotNull(builder.Metrics.Services); 17 | Assert.NotNull(builder.Configuration); 18 | var o = builder.Build(); 19 | Assert.NotNull(o); 20 | } 21 | 22 | [Fact] 23 | public void CreateBuilderThrowsWithInvalidArgs() 24 | { 25 | Assert.Throws(() => ConsoleApplication.CreateBuilder((string[])null!)); 26 | } 27 | 28 | [Fact] 29 | public void ProperlyInitializeEnvironmentName() 30 | { 31 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "ProperlyInitializeEnvironmentName"); 32 | try 33 | { 34 | var builder = ConsoleApplication.CreateBuilder([]); 35 | Assert.Equal("ProperlyInitializeEnvironmentName", builder.Environment.EnvironmentName); 36 | } 37 | finally 38 | { 39 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); 40 | Assert.Null(Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")); 41 | } 42 | } 43 | 44 | [Fact] 45 | public void ProperlyInjectConfiguration() 46 | { 47 | var builder = ConsoleApplication.CreateBuilder([]); 48 | var o = builder.Build(); 49 | Assert.NotNull(o.Configuration); 50 | } 51 | 52 | [Fact] 53 | public void ProperlyLoadAppSettings() 54 | { 55 | var builder = ConsoleApplication.CreateBuilder([]); 56 | var o = builder.Build(); 57 | Assert.Equal("appSettingsValue", o.Configuration["appSettingsKey"]); 58 | } 59 | 60 | [Fact] 61 | public void ProperlyAddCommandLineArgsToConfiguration() 62 | { 63 | string[] args = ["--key=value"]; 64 | var builder = ConsoleApplication.CreateBuilder(args); 65 | var o = builder.Build(); 66 | Assert.Equal("value", o.Configuration["key"]); 67 | } 68 | 69 | [Fact] 70 | public void HaveCorrectOverridenUnRootedContentRootPath() 71 | { 72 | Environment.SetEnvironmentVariable("DOTNET_contentRoot", ".."); 73 | try 74 | { 75 | var builder = ConsoleApplication.CreateBuilder([]); 76 | Assert.EndsWith("..", builder.Environment.ContentRootPath); 77 | } 78 | finally 79 | { 80 | Environment.SetEnvironmentVariable("DOTNET_contentRoot", null); 81 | } 82 | } 83 | 84 | [Fact] 85 | public void HaveCorrectOverridenRootedContentRootPath() 86 | { 87 | Environment.SetEnvironmentVariable("DOTNET_contentRoot", "/"); 88 | try 89 | { 90 | var builder = ConsoleApplication.CreateBuilder([]); 91 | Assert.Equal("/", builder.Environment.ContentRootPath); 92 | } 93 | finally 94 | { 95 | Environment.SetEnvironmentVariable("DOTNET_contentRoot", null); 96 | } 97 | } 98 | 99 | [Fact] 100 | public void DevelopmentAppSettingsWithOtherTestRunnerDoesNotThrow() 101 | { 102 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); 103 | Environment.SetEnvironmentVariable("DOTNET_APPLICATIONNAME", "StrangeTestRunner"); 104 | try 105 | { 106 | var builder = ConsoleApplication.CreateBuilder([]); 107 | var o = builder.Build(); 108 | Assert.Equal("development", o.Configuration["developmentAppSettingsKey"]); 109 | } 110 | finally 111 | { 112 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); 113 | Environment.SetEnvironmentVariable("DOTNET_APPLICATIONNAME", null); 114 | } 115 | } 116 | 117 | [Fact] 118 | public void DevelopmentAppSettingsOverridesRootAppSettings() 119 | { 120 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); 121 | try 122 | { 123 | var builder = ConsoleApplication.CreateBuilder([]); 124 | var o = builder.Build(); 125 | Assert.Equal("development", o.Configuration["developmentAppSettingsKey"]); 126 | } 127 | finally 128 | { 129 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); 130 | Assert.Null(Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")); 131 | } 132 | } 133 | 134 | [Fact] 135 | public void Experiment() 136 | { 137 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); 138 | try 139 | { 140 | var builder = ConsoleApplication.CreateBuilder([]); 141 | var o = builder.Build(); 142 | var configurationManager = Assert.IsType(o.Configuration); 143 | Assert.NotNull(configurationManager.Sources); 144 | } 145 | finally 146 | { 147 | Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", null); 148 | } 149 | } 150 | 151 | [Fact] 152 | public void EnvironmentVariablesOverrideAppSettings() 153 | { 154 | string[] args = ["--appSettingsKey=from-commandline"]; 155 | var builder = ConsoleApplication.CreateBuilder(args); 156 | var o = builder.Build(); 157 | Assert.Equal("from-commandline", o.Configuration["appSettingsKey"]); 158 | } 159 | 160 | [Fact] 161 | public void CommandLineArgumentsOverrideEnvironmentVariables() 162 | { 163 | Environment.SetEnvironmentVariable("DOTNET_key", "from-environment"); 164 | try 165 | { 166 | string[] args = ["--key=from-commandline"]; 167 | var builder = ConsoleApplication.CreateBuilder(args); 168 | var o = builder.Build(); 169 | Assert.Equal("from-commandline", o.Configuration["key"]); 170 | } 171 | finally 172 | { 173 | Environment.SetEnvironmentVariable("DOTNET_key", null); 174 | } 175 | } 176 | 177 | [Fact] 178 | public void ThrowExceptionIfBuiltTwice() 179 | { 180 | var builder = ConsoleApplication.CreateBuilder([]); 181 | _ = builder.Build(); 182 | var ex = Assert.Throws(builder.Build); 183 | Assert.Equal("Build can only be called once.", ex.Message); 184 | } 185 | 186 | [Fact] 187 | public void WorkWithReloadConfigOnChangeValueConfiguration() 188 | { 189 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", "true"); 190 | try 191 | { 192 | var builder = ConsoleApplication.CreateBuilder([]); 193 | Assert.NotNull(builder); 194 | } 195 | finally 196 | { 197 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", null); 198 | } 199 | } 200 | 201 | [Fact] 202 | public void ThrowWithBadReloadConfigOnChangeValueConfiguration() 203 | { 204 | try 205 | { 206 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", "gibberish"); 207 | Assert.Throws(() => _ = ConsoleApplication.CreateBuilder([])); 208 | } 209 | finally 210 | { 211 | Environment.SetEnvironmentVariable("DOTNET_hostBuilder:reloadConfigOnChange", null); 212 | } 213 | } 214 | 215 | private class Program(IConfiguration configuration) 216 | { 217 | public IConfiguration Configuration { get; } = configuration; 218 | } 219 | } -------------------------------------------------------------------------------- /src/Tests/CommandLineExtensionsTests/CommandLineExtensionsGivenArgumentFreeCommandShould.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.Text; 3 | 4 | using CommandLineExtensionsTests.TestDoubles; 5 | 6 | using Pri.CommandLineExtensions; 7 | using Pri.ConsoleApplicationBuilder; 8 | 9 | namespace CommandLineExtensionsTests; 10 | 11 | public class CommandLineExtensionsGivenArgumentFreeCommandShould 12 | { 13 | public class AnotherRootCommand() : RootCommand("Analyze something."); 14 | 15 | [Fact] 16 | public void HaveExpectedHelpOutput() 17 | { 18 | var outStringBuilder = new StringBuilder(); 19 | var errStringBuilder = new StringBuilder(); 20 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 21 | 22 | var builder = ConsoleApplication.CreateBuilder([]); 23 | builder.Services.AddCommand() 24 | .WithDescription("command description") 25 | .WithHandler(() => { }); 26 | 27 | var command = builder.Build(); 28 | command.Invoke("--help", console); 29 | 30 | Assert.Equal($""" 31 | Description: 32 | command description 33 | 34 | Usage: 35 | {Utility.ExecutingTestRunnerName} [options] 36 | 37 | Options: 38 | --version Show version information 39 | -?, -h, --help Show help and usage information 40 | 41 | 42 | 43 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 44 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 45 | } 46 | 47 | [Fact] 48 | public void CorrectlySetSubcommandAlias() 49 | { 50 | var builder = ConsoleApplication.CreateBuilder([]); 51 | builder.Services.AddCommand() 52 | .WithDescription("command description") 53 | .WithSubcommand() 54 | .AddAlias("subcommandAlias") 55 | .WithDescription("Analyze the dependencies.") 56 | .WithSubcommandHandler(() => { }) 57 | .WithHandler(() => { }); 58 | 59 | var command = builder.Build(); 60 | var subcommand = Assert.Single(command.Subcommands); 61 | Assert.Equal(2, subcommand.Aliases.Count); 62 | Assert.Contains("subcommandAlias", subcommand.Aliases); 63 | } 64 | 65 | [Fact] 66 | public void CorrectlyInvokeSubcommand() 67 | { 68 | bool wasSubcommandExecuted = false; 69 | bool wasRootExecuted = false; 70 | 71 | var builder = ConsoleApplication.CreateBuilder([]); 72 | builder.Services.AddCommand() 73 | .WithDescription("command description") 74 | .WithSubcommand() 75 | .WithSubcommandHandler(() => wasSubcommandExecuted = true) 76 | .WithHandler(() => wasRootExecuted = true); 77 | 78 | var command = builder.Build(); 79 | Assert.Equal(0, command.Invoke(["dependencies"])); 80 | Assert.True(wasSubcommandExecuted); 81 | Assert.False(wasRootExecuted); 82 | } 83 | 84 | [Fact] 85 | public void CorrectlyThrowsWithNullSubcommandHandler() 86 | { 87 | var builder = ConsoleApplication.CreateBuilder([]); 88 | builder.Services.AddCommand() 89 | .WithDescription("command description") 90 | .WithSubcommand() 91 | .WithSubcommandHandler(null!) 92 | .WithHandler(() => { }); 93 | 94 | var ex = Assert.Throws(builder.Build); 95 | Assert.Equal("Action must be set before building the subcommand.", ex.Message); 96 | } 97 | 98 | [Fact] 99 | public void CorrectlyThrowsWithNullCommandHandler() 100 | { 101 | var builder = ConsoleApplication.CreateBuilder([]); 102 | builder.Services.AddCommand() 103 | .WithDescription("command description") 104 | .WithSubcommand() 105 | .WithSubcommandHandler(()=>{ }).WithHandler((Action)null!); 106 | 107 | var ex = Assert.Throws(builder.Build); 108 | Assert.Equal("Cannot build a command without a handler.", ex.Message); 109 | } 110 | 111 | [Fact] 112 | public void CorrectlyInvokeSubcommandWithAliasAndDescription() 113 | { 114 | bool wasSubcommandExecuted = false; 115 | bool wasRootExecuted = false; 116 | var builder = ConsoleApplication.CreateBuilder([]); 117 | builder.Services.AddCommand() 118 | .WithDescription("command description") 119 | .WithSubcommand() 120 | .AddAlias("subcommandAlias") 121 | .WithDescription("Analyze the dependencies.") 122 | .WithSubcommandHandler(() => wasSubcommandExecuted = true) 123 | .WithHandler(() => wasRootExecuted = true); 124 | 125 | var command = builder.Build(); 126 | Assert.Equal(0, command.Invoke(["dependencies"])); 127 | Assert.True(wasSubcommandExecuted); 128 | Assert.False(wasRootExecuted); 129 | } 130 | 131 | [Fact] 132 | public void HaveExpectedHelpOutputWithSubcommand() 133 | { 134 | var outStringBuilder = new StringBuilder(); 135 | var errStringBuilder = new StringBuilder(); 136 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 137 | 138 | var builder = ConsoleApplication.CreateBuilder([]); 139 | builder.Services.AddCommand() 140 | .WithDescription("command description") 141 | .WithSubcommand() 142 | .WithDescription("Analyze the dependencies.") 143 | .WithSubcommandHandler(() => { }) 144 | .WithHandler(() => { }); 145 | 146 | var command = builder.Build(); 147 | command.Invoke("--help", console); 148 | 149 | Assert.Equal($""" 150 | Description: 151 | command description 152 | 153 | Usage: 154 | {Utility.ExecutingTestRunnerName} [command] [options] 155 | 156 | Options: 157 | --version Show version information 158 | -?, -h, --help Show help and usage information 159 | 160 | Commands: 161 | dependencies Analyze the dependencies. 162 | 163 | 164 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 165 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 166 | } 167 | 168 | [Fact] 169 | public void HaveExpectedHelpOutputWithSubcommandWithAlias() 170 | { 171 | var outStringBuilder = new StringBuilder(); 172 | var errStringBuilder = new StringBuilder(); 173 | IConsole console = Utility.CreateConsoleSpy(outStringBuilder, errStringBuilder); 174 | 175 | var builder = ConsoleApplication.CreateBuilder([]); 176 | builder.Services.AddCommand() 177 | .WithDescription("command description") 178 | .WithSubcommand() 179 | .AddAlias("subcommandAlias") 180 | .WithDescription("Analyze the dependencies.") 181 | .WithSubcommandHandler(() => { }) 182 | .WithHandler(() => { }); 183 | 184 | var command = builder.Build(); 185 | command.Invoke("--help", console); 186 | 187 | Assert.Equal($""" 188 | Description: 189 | command description 190 | 191 | Usage: 192 | {Utility.ExecutingTestRunnerName} [command] [options] 193 | 194 | Options: 195 | --version Show version information 196 | -?, -h, --help Show help and usage information 197 | 198 | Commands: 199 | dependencies, subcommandAlias Analyze the dependencies. 200 | 201 | 202 | """.ReplaceLineEndings(), outStringBuilder.ToString()); 203 | Assert.Equal(string.Empty, errStringBuilder.ToString()); 204 | } 205 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/TwoParameterCommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | using System.CommandLine.Parsing; 3 | #if PARANOID 4 | using System.Diagnostics; 5 | #endif 6 | 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.DependencyInjection.Extensions; 9 | 10 | namespace Pri.CommandLineExtensions; 11 | 12 | /// 13 | /// terminal builder, if we add more this will have to check `action` in `AddOption` 14 | /// 15 | internal class TwoParameterCommandBuilder 16 | : CommandBuilderBase, ITwoParameterCommandBuilder 17 | { 18 | private Func? handler; 19 | 20 | /// 21 | /// terminal builder, if we add more this will have to check `action` in `AddOption` 22 | /// 23 | private TwoParameterCommandBuilder(CommandBuilderBase commandBuilder) : base(commandBuilder) 24 | { 25 | } 26 | 27 | public TwoParameterCommandBuilder(CommandBuilderBase initiator, ParamSpec paramSpec) : this(initiator) 28 | { 29 | ParamSpecs.Add(paramSpec); 30 | } 31 | 32 | /// 33 | public ITwoParameterCommandBuilder AddAlias(string alias) 34 | { 35 | ParamSpecs.Last().Aliases.Add(alias); 36 | return this; 37 | } 38 | 39 | /// 40 | public ITwoParameterCommandBuilder WithArgumentParser(ParseArgument argumentParser) 41 | { 42 | ParamSpecs.Last().ArgumentParser = argumentParser; 43 | return this; 44 | } 45 | 46 | /// 47 | public ITwoParameterCommandBuilder WithDefault(TParam2 defaultValue) 48 | { 49 | ParamSpecs.Last().DefaultValue = defaultValue; 50 | return this; 51 | } 52 | 53 | /// 54 | public ITwoParameterCommandBuilder WithDescription(string parameterDescription) 55 | { 56 | ParamSpecs.Last().Description = parameterDescription; 57 | 58 | return this; 59 | } 60 | 61 | // TODO: WithOption... when ThreeParameterCommandLineCommandBuilder is done 62 | // TODO: WithRequiredOption... when ThreeParameterCommandLineCommandBuilder is done 63 | // TODO: WithArgument... when ThreeParameterCommandLineCommandBuilder is done 64 | 65 | /// 66 | public ISubcommandBuilder> WithSubcommand() 67 | where TSubcommand : Command, new() 68 | { 69 | #if PARANOID 70 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 71 | #endif 72 | subcommands.Add(typeof(TSubcommand)); 73 | 74 | return new SubcommandBuilder>(this); 75 | } 76 | 77 | /// 78 | public IServiceCollection WithHandler() where THandler : class, ICommandHandler 79 | { 80 | #if PARANOID 81 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 82 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 83 | #endif 84 | 85 | commandHandlerType = typeof(THandler); 86 | 87 | Services.TryAddSingleton, THandler>(); // TryAdd in case they've already added something prior... 88 | 89 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 90 | 91 | return Services; // builder terminal 92 | } 93 | 94 | /// 95 | public IServiceCollection WithHandler(Action action) 96 | { 97 | #if PARANOID 98 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 99 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 100 | #endif 101 | handler = action switch 102 | { 103 | null => null, 104 | _ => (p1, p2) => 105 | { 106 | action(p1, p2); 107 | return Task.FromResult(0); 108 | } 109 | }; 110 | 111 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 112 | 113 | return Services; // builder terminal 114 | } 115 | 116 | /// 117 | public IServiceCollection WithHandler(Func func) 118 | { 119 | #if PARANOID 120 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 121 | Debug.Assert(handler is null); 122 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 123 | if (handler is not null) throw new InvalidOperationException("Cannot add a handler twice."); 124 | #endif 125 | 126 | handler = func switch 127 | { 128 | null => null, 129 | _ => (p1,p2) => Task.FromResult(func(p1,p2)) 130 | }; 131 | 132 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 133 | 134 | return Services; // builder terminal 135 | } 136 | 137 | /// 138 | public IServiceCollection WithHandler(Func func) 139 | { 140 | #if PARANOID 141 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 142 | Debug.Assert(handler is null); 143 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 144 | if (handler is not null) throw new InvalidOperationException("Cannot add a handler twice."); 145 | #endif 146 | 147 | handler = func switch 148 | { 149 | null => null, 150 | _ => async (p1, p2) => await func(p1, p2) 151 | }; 152 | 153 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 154 | 155 | return Services; // builder terminal 156 | } 157 | 158 | private Command BuildCommand(IServiceProvider provider) 159 | { 160 | #if PARANOID 161 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 162 | if (Command is null || CommandType is null) throw new InvalidOperationException("No command to use when building the command."); 163 | #endif 164 | Command command = GetCommand(provider); 165 | 166 | if (CommandDescription is not null) 167 | { 168 | command.Description = CommandDescription; 169 | } 170 | 171 | if (subcommands.Any()) 172 | { 173 | foreach (var subcommandType in subcommands) 174 | { 175 | var subcommand = (Command)provider.GetRequiredService(subcommandType); 176 | command.AddCommand(subcommand); 177 | } 178 | } 179 | Func actualHandler; 180 | if (commandHandlerType is not null) 181 | { 182 | // get a handler object with all the dependencies resolved and injected 183 | var commandHandler = provider.GetRequiredService>(); 184 | actualHandler = (value1, value2) => Task.FromResult(commandHandler.Execute(value1, value2)); 185 | } 186 | else 187 | { 188 | actualHandler = handler ?? 189 | throw new InvalidOperationException("Cannot build a command without a handler."); 190 | } 191 | 192 | var descriptor1 = command.AddParameter(ParamSpecs[0], ParamSpecs[0].ArgumentParser as ParseArgument); 193 | var descriptor2 = command.AddParameter(ParamSpecs[1], ParamSpecs[1].ArgumentParser as ParseArgument); 194 | 195 | command.SetHandler(context => 196 | { 197 | var value1 = GetValue(descriptor1, context); 198 | var value2 = GetValue(descriptor2, context); 199 | // Check for null value? 200 | return actualHandler(value1!, value2!); 201 | }); 202 | 203 | return command; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/ConsoleApplicationBuilder/ConsoleApplicationBuilder/DefaultConsoleApplicationBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | using Microsoft.Extensions.Configuration; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.DependencyInjection.Extensions; 6 | using Microsoft.Extensions.Diagnostics.Metrics; 7 | using Microsoft.Extensions.FileProviders; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace Pri.ConsoleApplicationBuilder; 12 | 13 | /// 14 | /// A builder that follows the application builder pattern to create an application object that includes 15 | /// environment and service configuration. 16 | /// 17 | /// 18 | /// HostApplicationBuilder has ConfigurationManager Configuration, ILoggingBuilder Logging, IMetricsBuilder Metrics, and IServiceCollection Services. 19 | /// WebApplicationBuilder has ConfigurationManager Configuration, ILoggingBuilder Logging, IMetricsBuilder Metrics, and IServiceCollection Services. 20 | /// 21 | internal class DefaultConsoleApplicationBuilder : IConsoleApplicationBuilder 22 | { 23 | private readonly ServiceCollection services = []; 24 | private Func createServiceProvider; 25 | private Action configureContainer = _ => { }; 26 | 27 | public DefaultConsoleApplicationBuilder(ConsoleApplicationBuilderSettings settings) 28 | { 29 | ArgumentNullException.ThrowIfNull(settings); 30 | 31 | Configuration = settings.Configuration ?? new ConfigurationManager(); 32 | var args = settings.Args!; 33 | 34 | Configuration.AddEnvironmentVariables(prefix: "DOTNET_"); 35 | 36 | var env = CreateEnvironment(settings, Configuration); 37 | 38 | #region ApplyDefaultAppConfigurationSlim 39 | bool reloadOnChange = GetReloadConfigOnChangeValue(Configuration); 40 | 41 | var builder = Configuration.AddJsonFile("appsettings.json", optional: true); 42 | if(!string.IsNullOrEmpty(env.EnvironmentName)) 43 | { 44 | builder.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: reloadOnChange); 45 | } 46 | 47 | if (env.IsDevelopment() && env.ApplicationName.Length > 0) 48 | { 49 | try 50 | { 51 | var appAssembly = Assembly.Load(new AssemblyName(env.ApplicationName)); 52 | Configuration.AddUserSecrets(appAssembly, optional: true, reloadOnChange: reloadOnChange); 53 | } 54 | catch (FileNotFoundException) 55 | { 56 | // The assembly cannot be found, so just skip it.; 57 | } 58 | } 59 | 60 | Configuration.AddEnvironmentVariables(); 61 | 62 | if (args.Length > 0 ) 63 | { 64 | Configuration.AddCommandLine(args); 65 | } 66 | #endregion // ApplyDefaultAppConfigurationSlim 67 | 68 | Configuration.SetFileProvider(env.ContentRootFileProvider); 69 | 70 | Services.AddSingleton(Configuration); 71 | Services.AddLogging(); 72 | Logging = new LoggingBuilder(Services); 73 | Environment = env; 74 | createServiceProvider = () => 75 | { 76 | // Call _configureContainer in case anyone adds callbacks via DefaultConsoleApplicationBuilder.ConfigureContainer() during build. 77 | // Otherwise, this no-ops. 78 | configureContainer(Services); 79 | return Services.BuildServiceProvider(); 80 | }; 81 | return; 82 | 83 | static bool GetReloadConfigOnChangeValue(IConfiguration configuration) 84 | { 85 | const string reloadConfigOnChangeKey = "hostBuilder:reloadConfigOnChange"; 86 | return configuration[reloadConfigOnChangeKey] is not { } reloadConfigOnChange || 87 | (bool.TryParse(reloadConfigOnChange, out bool result) 88 | ? result 89 | : throw new InvalidOperationException( 90 | $"Failed to convert configuration value at '{configuration.GetSection(reloadConfigOnChangeKey).Path}' to type '{typeof(bool)}'.")); 91 | } 92 | } 93 | 94 | private static ApplicationEnvironment CreateEnvironment(ConsoleApplicationBuilderSettings settings, 95 | // ReSharper disable once SuggestBaseTypeForParameter 96 | ConfigurationManager configuration) 97 | { 98 | // ConsoleApplicationBuilderSettings override all other config sources. 99 | List>? optionList = null; 100 | if (settings.ApplicationName is not null) 101 | { 102 | optionList ??= []; 103 | optionList.Add(new KeyValuePair(HostDefaults.ApplicationKey, settings.ApplicationName)); 104 | } 105 | if (settings.EnvironmentName is not null) 106 | { 107 | optionList ??= []; 108 | optionList.Add(new KeyValuePair(HostDefaults.EnvironmentKey, settings.EnvironmentName)); 109 | } 110 | if (settings.ContentRootPath is not null) 111 | { 112 | optionList ??= []; 113 | optionList.Add(new KeyValuePair(HostDefaults.ContentRootKey, settings.ContentRootPath)); 114 | } 115 | if (optionList is not null) 116 | { 117 | configuration.AddInMemoryCollection(optionList); 118 | } 119 | 120 | string? envApplicationName = configuration[HostDefaults.ApplicationKey]; 121 | if (string.IsNullOrEmpty(envApplicationName)) 122 | { 123 | envApplicationName = Assembly.GetEntryAssembly()!.GetName().Name; 124 | } 125 | 126 | string contentRootPath = ResolveContentRootPath(configuration[HostDefaults.ContentRootKey]); 127 | return new ApplicationEnvironment 128 | { 129 | EnvironmentName = configuration[HostDefaults.EnvironmentKey] ?? Environments.Production, 130 | ApplicationName = envApplicationName!, 131 | ContentRootPath = contentRootPath, 132 | ContentRootFileProvider = new PhysicalFileProvider(contentRootPath) 133 | }; 134 | } 135 | 136 | private static string ResolveContentRootPath(string? contentRootPath) 137 | { 138 | string basePath = AppContext.BaseDirectory; 139 | 140 | return string.IsNullOrEmpty(contentRootPath) 141 | ? basePath 142 | : Path.IsPathRooted(contentRootPath) 143 | ? contentRootPath 144 | : Path.Combine(Path.GetFullPath(basePath), contentRootPath); 145 | } 146 | 147 | /// 148 | public ConfigurationManager Configuration { get; } 149 | /// 150 | public IServiceCollection Services => services; 151 | /// 152 | public ILoggingBuilder Logging { get; } 153 | /// 154 | public IHostEnvironment Environment { get; } 155 | 156 | /// 157 | public IDictionary Properties => new Dictionary(); 158 | 159 | /// 160 | IConfigurationManager IHostApplicationBuilder.Configuration => Configuration; 161 | 162 | /// 163 | public IMetricsBuilder Metrics => new SimpleMetricsBuilder(Services); 164 | 165 | private sealed class LoggingBuilder(IServiceCollection services) : ILoggingBuilder 166 | { 167 | public IServiceCollection Services => services; 168 | } 169 | 170 | /// 171 | public void ConfigureContainer(IServiceProviderFactory factory, Action? configure = null) where TBuilder : notnull 172 | { 173 | createServiceProvider = () => 174 | { 175 | TBuilder containerBuilder = factory.CreateBuilder(Services); 176 | // Call _configureContainer in case anyone adds more callbacks via HostBuilderAdapter.ConfigureContainer() during build. 177 | // Otherwise, this is equivalent to configure?.Invoke(containerBuilder). 178 | configureContainer(containerBuilder); 179 | return factory.CreateServiceProvider(containerBuilder); 180 | }; 181 | 182 | // Store _configureContainer separately so it can be replaced individually by the HostBuilderAdapter. 183 | configureContainer = containerBuilder => configure?.Invoke((TBuilder)containerBuilder); 184 | } 185 | 186 | private void ConfigureDefaultLogging() 187 | { 188 | Logging.AddConsole(); 189 | } 190 | 191 | private bool isBuilt; 192 | 193 | /// 194 | public T Build() where T : class 195 | { 196 | if(isBuilt) 197 | { 198 | throw new InvalidOperationException("Build can only be called once."); 199 | } 200 | 201 | isBuilt = true; 202 | 203 | ConfigureDefaultLogging(); 204 | 205 | services.TryAdd(ServiceDescriptor.Singleton()); 206 | services.AddSingleton(Configuration); 207 | 208 | IServiceProvider serviceProvider = createServiceProvider(); 209 | 210 | // Mark the service collection as read-only to prevent future modifications 211 | services.MakeReadOnly(); 212 | 213 | return serviceProvider.GetRequiredService(); 214 | } 215 | 216 | private sealed class SimpleMetricsBuilder(IServiceCollection services) : IMetricsBuilder 217 | { 218 | public IServiceCollection Services { get; } = services; 219 | } 220 | } -------------------------------------------------------------------------------- /src/CommandLineExtensions/CommandBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.CommandLine; 2 | #if PARANOID 3 | using System.Diagnostics; 4 | #endif 5 | 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.DependencyInjection.Extensions; 8 | 9 | namespace Pri.CommandLineExtensions; 10 | 11 | /// 12 | /// Builds out a command line command 13 | /// 14 | internal class CommandBuilder : CommandBuilderBase, ICommandBuilder 15 | { 16 | private Func? handler; 17 | 18 | /// 19 | /// Create a builder with an existing command. 20 | /// 21 | /// 22 | /// 23 | internal CommandBuilder(IServiceCollection services, Command command) : base(services) 24 | { 25 | Command = command; 26 | } 27 | 28 | /// 29 | /// Create a builder with a type to instantiate later. 30 | /// 31 | /// 32 | /// 33 | internal CommandBuilder(IServiceCollection services, Type commandType) : base(services) 34 | { 35 | CommandType = commandType; 36 | } 37 | 38 | /// 39 | /// Create a builder with a command factory to invoke later. 40 | /// 41 | /// 42 | /// 43 | internal CommandBuilder(IServiceCollection services, 44 | Type commandType, 45 | Func commandFactory) 46 | : base(services) 47 | { 48 | CommandType = commandType; 49 | CommandFactory = commandFactory; 50 | } 51 | 52 | /// 53 | public IOneParameterCommandBuilder WithArgument(string name, string description) 54 | { 55 | #if PARANOID 56 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 57 | Debug.Assert(handler is null); 58 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add an argument without a command."); 59 | if (handler is not null) throw new InvalidOperationException("Cannot add arguments after adding a handler."); 60 | #endif 61 | 62 | return new OneParameterCommandBuilder(this, new ParamSpec 63 | { 64 | Name = name, 65 | Description = description, 66 | IsArgument = true 67 | }); 68 | } 69 | 70 | /// 71 | public ICommandBuilder WithDescription(string description) 72 | { 73 | if (CommandDescription != null) 74 | { 75 | throw new InvalidOperationException("Command had existing description when WithDescription called"); 76 | } 77 | 78 | #if PARANOID 79 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 80 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a command description without a command."); 81 | #endif 82 | 83 | CommandDescription = description; 84 | 85 | return this; 86 | } 87 | 88 | /// 89 | public IServiceCollection WithHandler(Action action) 90 | { 91 | #if PARANOID 92 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 93 | Debug.Assert(handler is null); 94 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 95 | if (handler is not null) throw new InvalidOperationException("Cannot add a handler twice."); 96 | #endif 97 | 98 | handler = action switch 99 | { 100 | null => null, 101 | _ => () => 102 | { 103 | action(); 104 | return Task.FromResult(0); 105 | } 106 | }; 107 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 108 | 109 | return Services; // builder terminal 110 | } 111 | 112 | /// 113 | public IServiceCollection WithHandler() where THandler : class, ICommandHandler 114 | { 115 | #if PARANOID 116 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 117 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 118 | #endif 119 | 120 | commandHandlerType = typeof(THandler); 121 | Services.TryAddSingleton(); // TryAdd in case they've already added something prior... 122 | 123 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 124 | 125 | return Services; // builder terminal 126 | } 127 | 128 | /// 129 | public IServiceCollection WithHandler(Func func) 130 | { 131 | #if PARANOID 132 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 133 | Debug.Assert(handler is null); 134 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 135 | if (handler is not null) throw new InvalidOperationException("Cannot add a handler twice."); 136 | #endif 137 | 138 | handler = func switch 139 | { 140 | null => null, 141 | _ => () => Task.FromResult(func()) 142 | }; 143 | 144 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 145 | 146 | return Services; // builder terminal 147 | } 148 | 149 | /// 150 | public IServiceCollection WithHandler(Func func) 151 | { 152 | #if PARANOID 153 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 154 | Debug.Assert(handler is null); 155 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a handler without a command."); 156 | if (handler is not null) throw new InvalidOperationException("Cannot add a handler twice."); 157 | #endif 158 | 159 | handler = func switch 160 | { 161 | null => null, 162 | _ => async () => await func() 163 | }; 164 | 165 | Services.Replace(ServiceDescriptor.Singleton(GetCommandType(), BuildCommand)); 166 | 167 | return Services; // builder terminal 168 | } 169 | 170 | /// 171 | public IOneParameterCommandBuilder WithOption(string name, string description) 172 | { 173 | #if PARANOID 174 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 175 | Debug.Assert(handler is null); 176 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add an option without a command."); 177 | if (handler is not null) throw new InvalidOperationException("Cannot add options after adding a handler."); 178 | #endif 179 | 180 | return new OneParameterCommandBuilder(this, new ParamSpec 181 | { 182 | Name = name, Description = description 183 | }); 184 | } 185 | 186 | /// 187 | public IOneParameterCommandBuilder WithRequiredOption(string name, 188 | string description) 189 | { 190 | #if PARANOID 191 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 192 | Debug.Assert(handler is null); 193 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add an option without a command."); 194 | if (handler is not null) throw new InvalidOperationException("Cannot add options after adding a handler."); 195 | #endif 196 | 197 | return new OneParameterCommandBuilder(this, new ParamSpec 198 | { 199 | Name = name, 200 | Description = description, 201 | IsRequired = true 202 | }); 203 | } 204 | 205 | /// 206 | public ISubcommandBuilder WithSubcommand() 207 | where TSubcommand : Command, new() 208 | { 209 | #if PARANOID 210 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 211 | if (Command is null || CommandType is null) throw new InvalidOperationException("Cannot add a subcommand without a command."); 212 | #endif 213 | 214 | subcommands.Add(typeof(TSubcommand)); 215 | 216 | return new SubcommandBuilder(this); 217 | } 218 | 219 | private Command BuildCommand(IServiceProvider provider) 220 | { 221 | #if PARANOID 222 | Debug.Assert(Command != null || CommandType != null || (CommandFactory != null && CommandType != null)); 223 | #endif 224 | Command command = GetCommand(provider); 225 | 226 | if (CommandDescription is not null) 227 | { 228 | command.Description = CommandDescription; 229 | } 230 | 231 | if (subcommands.Any()) 232 | { 233 | foreach (var subcommandType in subcommands) 234 | { 235 | var subcommand = (Command)provider.GetRequiredService(subcommandType); 236 | command.AddCommand(subcommand); 237 | } 238 | } 239 | 240 | Func actualHandler; 241 | if (commandHandlerType is not null) 242 | { 243 | // get a handler object with all the dependencies resolved and injected 244 | var commandHandler = provider.GetRequiredService(); 245 | actualHandler = () => 246 | { 247 | int exitCode = commandHandler.Execute(); 248 | return Task.FromResult(exitCode); 249 | }; 250 | } 251 | else 252 | { 253 | actualHandler = handler ?? throw new InvalidOperationException("Cannot build a command without a handler."); 254 | } 255 | 256 | command.SetHandler(actualHandler); 257 | 258 | return command; 259 | } 260 | } --------------------------------------------------------------------------------