├── 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