From 3fc3f89c947f7ffb17d653c21c554c7c44e6c22d Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 14:24:50 -0700 Subject: [PATCH 01/10] Added central package and build management to this solution. Cleaned up individual projects. Turned on warnings as errors across all projects. Fixed nullable setting in UnitTests project and fixed up all warnings/errors. --- ...e.Console.Extensions.MoreContainers.csproj | 16 ++++++------- .../CommandAppBuilderTests.cs | 2 +- .../Controls/RecordTests.cs | 6 ++--- .../Controls/StringExtensionsTests.cs | 2 +- ...pectre.Console.Extensions.UnitTests.csproj | 11 ++++----- .../AutofacTypeRegistrarTests.Exceptions.cs | 14 +++++------ .../Injection/AutofacTypeResolverTests.cs | 2 +- ...yInjectionTypeRegistrarTests.Exceptions.cs | 14 +++++------ .../DependencyInjectionTypeRegistrarTests.cs | 2 +- .../DependencyInjectionTypeResolverTests.cs | 2 +- .../LamarTypeRegistrarTests.Exceptions.cs | 14 +++++------ .../Injection/LamarTypeResolverTests.cs | 2 +- .../LifetimeExtensionsTests.Exceptions.cs | 8 +++---- ...ightInjectTypeRegistrarTests.Exceptions.cs | 14 +++++------ .../Injection/LightInjectTypeResolverTests.cs | 2 +- .../NinjectTypeRegistrarTests.Exceptions.cs | 14 +++++------ .../Injection/NinjectTypeResolverTests.cs | 2 +- .../Mocks/CustomTypeConverter.cs | 6 ++--- .../Mocks/MockCommandWithSettings.cs | 3 +-- .../Services/ConsoleVerbosityWriterTests.cs | 2 +- .../CommandAppBuilderTestContextTests.cs | 4 ++-- .../Testing/CommandAppTestContextTests.cs | 4 ++-- .../CommandConfigurationTestContext.cs | 2 +- .../Testing/FakeConfiguratorTests.cs | 2 +- .../Testing/TestCommandInterceptorTests.cs | 4 ++-- .../Testing/TestConsoleInputTests.cs | 2 +- .../D20Tek.Spectre.Console.Extensions.csproj | 20 ++++------------ Directory.Build.props | 10 ++++++++ Directory.Packages.props | 24 +++++++++++++++++++ samples/Autofac.Cli/Autofac.Cli.csproj | 2 -- samples/Basic.Cli/Basic.Cli.csproj | 2 -- .../DependencyInjection.Cli.csproj | 4 +--- .../InteractivePrompt.Cli.csproj | 2 -- samples/Lamar.Cli/Lamar.Cli.csproj | 2 -- .../LightInject.Cli/LightInject.Cli.csproj | 2 -- samples/Ninject.Cli/Ninject.Cli.csproj | 2 -- samples/NoDI.Cli/NoDI.Cli.csproj | 2 -- 37 files changed, 115 insertions(+), 113 deletions(-) create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj index af16fdc..d3c9bfb 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj @@ -2,8 +2,6 @@ net9.0;net10.0 - enable - enable True Spectre.Console Container Extensions 1.2.1 @@ -33,13 +31,13 @@ The current release contain implementations of ITypeRegistrar and ITypeResolver - - - - - - - + + + + + + + diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs index 18ada28..9d1a0f1 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs @@ -113,7 +113,7 @@ public void WithStartupAndRegistrar() // assert Assert.IsNotNull(result); - var resolver = result.Registrar.Build(); + var resolver = result.Registrar!.Build(); Assert.IsNotNull(resolver); Assert.IsNotNull(resolver.Resolve(typeof(IMockService))); } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/RecordTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/RecordTests.cs index 689ecc8..89595f8 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/RecordTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/RecordTests.cs @@ -15,7 +15,7 @@ public class RecordTests public void ReadLineRequest_WithChanges_ReturnUpdate() { // arrange - var request = new ReadLineRequest(null, Style.Plain, false, null, [], []); + var request = new ReadLineRequest(null!, Style.Plain, false, null, [], []); // act var result = request with @@ -36,8 +36,8 @@ public void ReadLineRequest_WithChanges_ReturnUpdate() public void InputState_WithChanges_ReturnUpdate() { // arrange - var request = new ReadLineRequest(null, Style.Plain, false, null, [], []); - var state = new InputState(null, null, -1, true, null, -1, null, false, false); + var request = new ReadLineRequest(null!, Style.Plain, false, null, [], []); + var state = new InputState(null!, null!, -1, true, null!, -1, null!, false, false); // act var result = state with diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/StringExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/StringExtensionsTests.cs index 4f729a2..3fd475a 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/StringExtensionsTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Controls/StringExtensionsTests.cs @@ -65,7 +65,7 @@ public void Repeat_WithEmptyTextAndCount_ReturnsEmptyString() public void Repeat_WithNullText_ThrowsException() { // arrange - string text = null; + string text = null!; // act - assert Assert.Throws([ExcludeFromCodeCoverage]() => text.Repeat(5)); diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj index 282ab55..3562813 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj @@ -2,17 +2,16 @@ net10.0 - disable false - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeRegistrarTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeRegistrarTests.Exceptions.cs index cff360e..0596150 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeRegistrarTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeRegistrarTests.Exceptions.cs @@ -23,7 +23,7 @@ public void Create_WithNullStandardKernel() // arrange // act - Assert.ThrowsExactly(() => new AutofacTypeRegistrar(null)); + Assert.ThrowsExactly(() => new AutofacTypeRegistrar(null!)); } [TestMethod] @@ -34,7 +34,7 @@ public void Register_WithNullServiceType() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(null, typeof(TestService))); + Assert.ThrowsExactly(() => registrar.Register(null!, typeof(TestService))); } [TestMethod] @@ -45,7 +45,7 @@ public void Register_WithNullImplementationType() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null!)); } [TestMethod] @@ -56,7 +56,7 @@ public void RegisterInstance_WithNullType() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(null, new TestService())); + Assert.ThrowsExactly(() => registrar.RegisterInstance(null!, new TestService())); } [TestMethod] @@ -67,7 +67,7 @@ public void RegisterInstance_WithNullImplementation() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null!)); } [TestMethod] @@ -78,7 +78,7 @@ public void RegisterLazy_WithNullType() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(null, null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(null!, null!)); } [TestMethod] @@ -89,6 +89,6 @@ public void RegisterLazy_WithNullFactory() var registrar = new AutofacTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeResolverTests.cs index 4071b4d..7aeb95d 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeResolverTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/AutofacTypeResolverTests.cs @@ -90,6 +90,6 @@ public void Constructor_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => new AutofacTypeResolver(null)); + Assert.ThrowsExactly(() => new AutofacTypeResolver(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.Exceptions.cs index 39e08f8..ccbccc6 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.Exceptions.cs @@ -23,7 +23,7 @@ public void Create_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => new DependencyInjectionTypeRegistrar(null)); + Assert.ThrowsExactly(() => new DependencyInjectionTypeRegistrar(null!)); } [TestMethod] @@ -34,7 +34,7 @@ public void Register_WithNullServiceType() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(null, typeof(TestService))); + Assert.ThrowsExactly(() => registrar.Register(null!, typeof(TestService))); } [TestMethod] @@ -45,7 +45,7 @@ public void Register_WithNullImplementationType() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null!)); } [TestMethod] @@ -56,7 +56,7 @@ public void RegisterInstance_WithNullType() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(null, new TestService())); + Assert.ThrowsExactly(() => registrar.RegisterInstance(null!, new TestService())); } [TestMethod] @@ -67,7 +67,7 @@ public void RegisterInstance_WithNullImplementation() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null!)); } [TestMethod] @@ -78,7 +78,7 @@ public void RegisterLazy_WithNullType() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(null, null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(null!, null!)); } [TestMethod] @@ -89,6 +89,6 @@ public void RegisterLazy_WithNullFactory() var registrar = new DependencyInjectionTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.cs index 4145842..f1ed234 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeRegistrarTests.cs @@ -133,6 +133,6 @@ public void RegisterLazy_WithNullFactory() // act Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => - registrar.RegisterLazy(typeof(ITestService), null)); + registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeResolverTests.cs index 703fee6..2e209c2 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeResolverTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/DependencyInjectionTypeResolverTests.cs @@ -76,6 +76,6 @@ public void Constructor_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => new DependencyInjectionTypeResolver(null)); + Assert.ThrowsExactly(() => new DependencyInjectionTypeResolver(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeRegistrarTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeRegistrarTests.Exceptions.cs index 68324b2..e2f7b35 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeRegistrarTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeRegistrarTests.Exceptions.cs @@ -23,7 +23,7 @@ public void Create_WithNullStandardKernel() // arrange // act - Assert.ThrowsExactly(() => new LamarTypeRegistrar(null)); + Assert.ThrowsExactly(() => new LamarTypeRegistrar(null!)); } [TestMethod] @@ -34,7 +34,7 @@ public void Register_WithNullServiceType() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(null, typeof(TestService))); + Assert.ThrowsExactly(() => registrar.Register(null!, typeof(TestService))); } [TestMethod] @@ -45,7 +45,7 @@ public void Register_WithNullImplementationType() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null!)); } [TestMethod] @@ -56,7 +56,7 @@ public void RegisterInstance_WithNullType() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(null, new TestService())); + Assert.ThrowsExactly(() => registrar.RegisterInstance(null!, new TestService())); } [TestMethod] @@ -67,7 +67,7 @@ public void RegisterInstance_WithNullImplementation() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null!)); } [TestMethod] @@ -78,7 +78,7 @@ public void RegisterLazy_WithNullType() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(null, null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(null!, null!)); } [TestMethod] @@ -89,6 +89,6 @@ public void RegisterLazy_WithNullFactory() var registrar = new LamarTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeResolverTests.cs index 9b489a6..1c62bba 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeResolverTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LamarTypeResolverTests.cs @@ -76,6 +76,6 @@ public void Constructor_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => _ = new LamarTypeResolver(null)); + Assert.ThrowsExactly(() => _ = new LamarTypeResolver(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LifetimeExtensionsTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LifetimeExtensionsTests.Exceptions.cs index b76cc4c..a6f49a1 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LifetimeExtensionsTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LifetimeExtensionsTests.Exceptions.cs @@ -19,7 +19,7 @@ public void RegisterSingleton_WithNullInstance_ThrowsException() // arrange var container = new ServiceCollection(); var registrar = new DependencyInjectionTypeRegistrar(container); - TestService instance = null; + TestService instance = null!; // act - assert Assert.Throws([ExcludeFromCodeCoverage]() => @@ -35,7 +35,7 @@ public void RegisterSingleton_WithNullFactoryMethod_ThrowsException() // act - assert Assert.Throws([ExcludeFromCodeCoverage] () => - registrar.WithLifetimes().RegisterSingleton(null)); + registrar.WithLifetimes().RegisterSingleton(null!)); } [TestMethod] @@ -47,7 +47,7 @@ public void RegisterScoped_WithNullFactoryMethod_ThrowsException() // act - assert Assert.Throws([ExcludeFromCodeCoverage] () => - registrar.WithLifetimes().RegisterScoped(null)); + registrar.WithLifetimes().RegisterScoped(null!)); } [TestMethod] @@ -59,6 +59,6 @@ public void RegisterTransient_WithNullFactoryMethod_ThrowsException() // act - assert Assert.Throws([ExcludeFromCodeCoverage] () => - registrar.WithLifetimes().RegisterTransient(null)); + registrar.WithLifetimes().RegisterTransient(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeRegistrarTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeRegistrarTests.Exceptions.cs index 6fd439d..d58d096 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeRegistrarTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeRegistrarTests.Exceptions.cs @@ -23,7 +23,7 @@ public void Create_WithNullContainer() // arrange // act - Assert.ThrowsExactly(() => new LightInjectTypeRegistrar(null)); + Assert.ThrowsExactly(() => new LightInjectTypeRegistrar(null!)); } [TestMethod] @@ -34,7 +34,7 @@ public void Register_WithNullServiceType() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(null, typeof(TestService))); + Assert.ThrowsExactly(() => registrar.Register(null!, typeof(TestService))); } [TestMethod] @@ -45,7 +45,7 @@ public void Register_WithNullImplementationType() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null!)); } [TestMethod] @@ -56,7 +56,7 @@ public void RegisterInstance_WithNullType() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(null, new TestService())); + Assert.ThrowsExactly(() => registrar.RegisterInstance(null!, new TestService())); } [TestMethod] @@ -67,7 +67,7 @@ public void RegisterInstance_WithNullImplementation() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null!)); } [TestMethod] @@ -78,7 +78,7 @@ public void RegisterLazy_WithNullType() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(null, null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(null!, null!)); } [TestMethod] @@ -89,6 +89,6 @@ public void RegisterLazy_WithNullFactory() var registrar = new LightInjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeResolverTests.cs index 7a90ec2..d30735d 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeResolverTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/LightInjectTypeResolverTests.cs @@ -90,6 +90,6 @@ public void Constructor_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => new LightInjectTypeResolver(null)); + Assert.ThrowsExactly(() => new LightInjectTypeResolver(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeRegistrarTests.Exceptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeRegistrarTests.Exceptions.cs index 7fb8dd6..252db16 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeRegistrarTests.Exceptions.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeRegistrarTests.Exceptions.cs @@ -23,7 +23,7 @@ public void Create_WithNullStandardKernel() // arrange // act - Assert.ThrowsExactly(() => new NinjectTypeRegistrar(null)); + Assert.ThrowsExactly(() => new NinjectTypeRegistrar(null!)); } [TestMethod] @@ -34,7 +34,7 @@ public void Register_WithNullServiceType() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(null, typeof(TestService))); + Assert.ThrowsExactly(() => registrar.Register(null!, typeof(TestService))); } [TestMethod] @@ -45,7 +45,7 @@ public void Register_WithNullImplementationType() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.Register(typeof(ITestService), null!)); } [TestMethod] @@ -56,7 +56,7 @@ public void RegisterInstance_WithNullType() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(null, new TestService())); + Assert.ThrowsExactly(() => registrar.RegisterInstance(null!, new TestService())); } [TestMethod] @@ -67,7 +67,7 @@ public void RegisterInstance_WithNullImplementation() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterInstance(typeof(ITestService), null!)); } [TestMethod] @@ -78,7 +78,7 @@ public void RegisterLazy_WithNullType() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(null, null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(null!, null!)); } [TestMethod] @@ -89,6 +89,6 @@ public void RegisterLazy_WithNullFactory() var registrar = new NinjectTypeRegistrar(services); // act - Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null)); + Assert.ThrowsExactly(() => registrar.RegisterLazy(typeof(ITestService), null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeResolverTests.cs index b5beae4..cd9b880 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeResolverTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Injection/NinjectTypeResolverTests.cs @@ -90,6 +90,6 @@ public void Constructor_WithNullServiceCollection() // arrange // act - Assert.ThrowsExactly(() => new NinjectTypeResolver(null)); + Assert.ThrowsExactly(() => new NinjectTypeResolver(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/CustomTypeConverter.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/CustomTypeConverter.cs index b4b558c..e000084 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/CustomTypeConverter.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/CustomTypeConverter.cs @@ -13,11 +13,11 @@ internal class CustomType [ExcludeFromCodeCoverage] internal sealed class CustomTypeConverter : TypeConverter { - public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) => + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) => destinationType == typeof(string); - public override object ConvertTo( - ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) + public override object? ConvertTo( + ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) { if (value is CustomType custom) { diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/MockCommandWithSettings.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/MockCommandWithSettings.cs index 3150665..f8957a0 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/MockCommandWithSettings.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Mocks/MockCommandWithSettings.cs @@ -5,7 +5,6 @@ using Spectre.Console.Cli; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; -using System.Threading; namespace D20Tek.Spectre.Console.Extensions.UnitTests.Mocks; @@ -15,7 +14,7 @@ internal class MockCommandWithSettings(IAnsiConsole console) : Command")] - [Description("The verbosity level for this operation (low, med, high).")] + [System.ComponentModel.Description("The verbosity level for this operation (low, med, high).")] [DefaultValue("default")] public string Value { get; set; } = string.Empty; } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Services/ConsoleVerbosityWriterTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Services/ConsoleVerbosityWriterTests.cs index ab2ba6d..3c715d3 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Services/ConsoleVerbosityWriterTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Services/ConsoleVerbosityWriterTests.cs @@ -109,6 +109,6 @@ public void Create_WithNullConsole() // arrange // act - Assert.ThrowsExactly(() => new ConsoleVerbosityWriter(null)); + Assert.ThrowsExactly(() => new ConsoleVerbosityWriter(null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppBuilderTestContextTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppBuilderTestContextTests.cs index 9653c9e..c775b83 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppBuilderTestContextTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppBuilderTestContextTests.cs @@ -29,7 +29,7 @@ public void Run() Assert.IsNotNull(result); Assert.AreEqual(0, result.ExitCode); Assert.Contains("Success", result.Output); - Assert.AreEqual("mock", result.Context.Name); + Assert.AreEqual("mock", result.Context!.Name); Assert.IsInstanceOfType(result.Settings, typeof(EmptyCommandSettings)); } @@ -91,7 +91,7 @@ public async Task RunAsync() Assert.IsNotNull(result); Assert.AreEqual(0, result.ExitCode); Assert.Contains("Success", result.Output); - Assert.AreEqual("mock", result.Context.Name); + Assert.AreEqual("mock", result.Context!.Name); Assert.IsInstanceOfType(result.Settings, typeof(EmptyCommandSettings)); } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppTestContextTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppTestContextTests.cs index d8f2d39..7107c42 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppTestContextTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandAppTestContextTests.cs @@ -32,7 +32,7 @@ public void Run() Assert.IsNotNull(result); Assert.AreEqual(0, result.ExitCode); Assert.Contains("Success", result.Output); - Assert.AreEqual("test", result.Context.Name); + Assert.AreEqual("test", result.Context!.Name); Assert.IsInstanceOfType(result.Settings, typeof(EmptyCommandSettings)); } @@ -137,7 +137,7 @@ public async Task RunAsync() Assert.IsNotNull(result); Assert.AreEqual(0, result.ExitCode); Assert.Contains("Success", result.Output); - Assert.AreEqual("test", result.Context.Name); + Assert.AreEqual("test", result.Context!.Name); Assert.IsInstanceOfType(result.Settings, typeof(EmptyCommandSettings)); } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandConfigurationTestContext.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandConfigurationTestContext.cs index f15f9cf..878f183 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandConfigurationTestContext.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/CommandConfigurationTestContext.cs @@ -226,5 +226,5 @@ public void Configurator_WithCommandAppSettings() } [ExcludeFromCodeCoverage] - private int HandlerMethod(Exception ex, ITypeResolver resolver) => 0; + private int HandlerMethod(Exception ex, ITypeResolver? resolver) => 0; } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/FakeConfiguratorTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/FakeConfiguratorTests.cs index 64599b7..adb83c0 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/FakeConfiguratorTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/FakeConfiguratorTests.cs @@ -103,7 +103,7 @@ private FakeConfigurator CreateConfigurator() [ExcludeFromCodeCoverage] internal class TestHelpProvider : IHelpProvider { - public IEnumerable Write(ICommandModel model, ICommandInfo command) => + public IEnumerable Write(ICommandModel model, ICommandInfo? command) => Enumerable.Empty(); } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestCommandInterceptorTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestCommandInterceptorTests.cs index f04945d..45f96d9 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestCommandInterceptorTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestCommandInterceptorTests.cs @@ -59,7 +59,7 @@ public void Intercept_WithNullContext() var i = new TestCommandInterceptor(); // act - Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => i.Intercept(null, _settings)); + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => i.Intercept(null!, _settings)); } [TestMethod] @@ -69,6 +69,6 @@ public void Intercept_WithNullSettings() var i = new TestCommandInterceptor(); // act - Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => i.Intercept(_context, null)); + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => i.Intercept(_context, null!)); } } diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleInputTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleInputTests.cs index f2b1981..f550b12 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleInputTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleInputTests.cs @@ -48,7 +48,7 @@ public void PushText_WithNull() var input = new TestConsoleInput(); // act - Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => input.PushTextWithEnter(null)); + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => input.PushTextWithEnter(null!)); } diff --git a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj index 6681c32..2fa644e 100644 --- a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj +++ b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj @@ -2,8 +2,6 @@ net9.0;net10.0 - enable - enable True d20Tek Copyright (c) d20Tek. @@ -26,16 +24,6 @@ The new Extensions.Testing namespace support test infrastructure classes to easi README.md - - 5 - True - - - - 5 - True - - True @@ -44,10 +32,10 @@ The new Extensions.Testing namespace support test infrastructure classes to easi - - - - + + + + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..10b58ab --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + enable + enable + true + 5 + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..28453d8 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,24 @@ + + + + true + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Autofac.Cli/Autofac.Cli.csproj b/samples/Autofac.Cli/Autofac.Cli.csproj index 7e1b1e5..7e6cff2 100644 --- a/samples/Autofac.Cli/Autofac.Cli.csproj +++ b/samples/Autofac.Cli/Autofac.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/Basic.Cli/Basic.Cli.csproj b/samples/Basic.Cli/Basic.Cli.csproj index dc153e3..6821ea3 100644 --- a/samples/Basic.Cli/Basic.Cli.csproj +++ b/samples/Basic.Cli/Basic.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/DependencyInjection.Cli/DependencyInjection.Cli.csproj b/samples/DependencyInjection.Cli/DependencyInjection.Cli.csproj index 3141bf7..2ee4f88 100644 --- a/samples/DependencyInjection.Cli/DependencyInjection.Cli.csproj +++ b/samples/DependencyInjection.Cli/DependencyInjection.Cli.csproj @@ -3,12 +3,10 @@ Exe net10.0 - enable - enable - + diff --git a/samples/InteractivePrompt.Cli/InteractivePrompt.Cli.csproj b/samples/InteractivePrompt.Cli/InteractivePrompt.Cli.csproj index b8fb523..4de2a89 100644 --- a/samples/InteractivePrompt.Cli/InteractivePrompt.Cli.csproj +++ b/samples/InteractivePrompt.Cli/InteractivePrompt.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/Lamar.Cli/Lamar.Cli.csproj b/samples/Lamar.Cli/Lamar.Cli.csproj index bcab98e..945f285 100644 --- a/samples/Lamar.Cli/Lamar.Cli.csproj +++ b/samples/Lamar.Cli/Lamar.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/LightInject.Cli/LightInject.Cli.csproj b/samples/LightInject.Cli/LightInject.Cli.csproj index 7e1b1e5..7e6cff2 100644 --- a/samples/LightInject.Cli/LightInject.Cli.csproj +++ b/samples/LightInject.Cli/LightInject.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/Ninject.Cli/Ninject.Cli.csproj b/samples/Ninject.Cli/Ninject.Cli.csproj index bcab98e..945f285 100644 --- a/samples/Ninject.Cli/Ninject.Cli.csproj +++ b/samples/Ninject.Cli/Ninject.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable diff --git a/samples/NoDI.Cli/NoDI.Cli.csproj b/samples/NoDI.Cli/NoDI.Cli.csproj index 0485411..2db77ac 100644 --- a/samples/NoDI.Cli/NoDI.Cli.csproj +++ b/samples/NoDI.Cli/NoDI.Cli.csproj @@ -3,8 +3,6 @@ Exe net10.0 - enable - enable From fa283ea1f70bc4cf668a8bdbeb525c12aa719c4d Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 15:30:16 -0700 Subject: [PATCH 02/10] Updated dependencies to their latest versions. --- .plans/future-features.md | 83 +++++++++++++++++++++++++++++++++++++++ Directory.Packages.props | 35 ++++++++--------- 2 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 .plans/future-features.md diff --git a/.plans/future-features.md b/.plans/future-features.md new file mode 100644 index 0000000..c4a0ee7 --- /dev/null +++ b/.plans/future-features.md @@ -0,0 +1,83 @@ +# Future Features + +This document captures candidate features for future releases of the D20Tek.Spectre.Console.Extensions packages. Each item notes what it adds, why it is valuable, and whether Spectre.Console already covers the capability. Items are grouped by priority tier based on impact relative to effort. + +## Guiding Principle + +Prioritize features that reinforce the library's existing strengths and fill genuine gaps that Spectre.Console does not already cover. Avoid thin wrappers over existing Spectre.Console.Cli APIs, since they add surface area without meaningful value. + +The library's strongest, genuinely differentiating areas are: +- Testing infrastructure (no equivalent ships in Spectre.Console). +- Culture-aware, validated prompt controls (for example, CurrencyPrompt). +- Verbosity services. +- Integration points that Spectre.Console.Cli does not provide (verbosity-aware logging and configuration). + +## Tier 1 - High Impact, Fills Genuine Gaps + +### 1. Verbosity-Aware Logging Integration (Microsoft.Extensions.Logging) +- What it adds: Convenience and cohesion around Microsoft.Extensions.Logging, not basic injection support. Specifically: + - A verbosity bridge that maps the existing VerbosityLevel enum to LogLevel, so the same -v|--verbosity switch that controls prompts and output also sets the minimum log level. + - An IAnsiConsole-backed logger provider so log output renders through Spectre (consistent styling and markup, and respects TestConsole in tests) instead of the stock AddConsole() provider writing directly to System.Console. + - A one-liner builder hook, for example CommandAppBuilder.WithLogging(...), that wires AddLogging plus the verbosity bridge plus the console provider, so users do not have to reach through WithLifetimes().Services. +- Why it matters: The README already lists logging integration as a future goal. Without the verbosity bridge, verbosity and logging are configured independently. Without the IAnsiConsole-backed provider, log output bypasses TestConsole and breaks the testing story. +- Spectre.Console coverage: None. Spectre.Console.Cli does not ship ILogger wiring. +- Important clarification: Basic logger injection already works today with no new code. The DependencyInjectionTypeRegistrar exposes the underlying IServiceCollection via its Services property, and the resolver forwards to IServiceProvider.GetService. A consumer can already call registrar.WithLifetimes().Services.AddLogging(...) in ConfigureServices, and any command can then inject ILogger through its constructor. This feature is therefore about verbosity integration, Spectre-rendered output, and a fluent builder hook, not about enabling injection. + +### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) +- What it adds: Wiring for Microsoft.Extensions.Configuration (JSON, environment variables, user secrets) into the CommandAppBuilder, for example a WithConfiguration(...) method, plus binding to strongly typed settings objects. +- Why it matters: Configuration is table-stakes for production CLI tools and pairs naturally with the existing dependency injection container. +- Spectre.Console coverage: None. Spectre.Console.Cli does not ship IConfiguration wiring, so this is a real gap. + +### 3. Additional Prompt Controls +Round out the "Controls" story with a themed family of culture-aware, validated prompts that follow the existing CurrencyPrompt pattern (IPrompt plus IHasCulture, with a validator and presenter split). + +- DatePrompt / DateRangePrompt: Culture-aware date entry with format hints and range validation. + - Spectre.Console coverage: None dedicated. Ask() exists, but there is no culture-aware, format-hinted, range-validating date control. Recommended first control because of its everyday utility and close similarity to CurrencyPrompt. +- PathPrompt: Filesystem path input with existence validation and path auto-completion. + - Spectre.Console coverage: None. Leverages the existing HistoryTextPrompt autocomplete infrastructure. Genuinely new. +- PatternPrompt (formerly proposed as MaskedPrompt): Patterned input such as phone numbers or identifiers, enforcing a format like (###) ###-####. + - Spectre.Console coverage: Partial and easily confused. Spectre.Console provides secret masking (hiding input) but not pattern or format masking (enforcing a layout). Rename away from "Masked" to avoid ambiguity with the existing secret feature. Hold this item unless rebranded. + +## Tier 2 - Minor Value-Add + +### 4. CompositeCommandInterceptor +- What it adds: A helper that composes multiple ICommandInterceptor instances into a chain (for example, timing plus logging plus telemetry). +- Why it matters: Spectre.Console.Cli's SetInterceptor registers a single interceptor. Composing several currently requires custom code. +- Spectre.Console coverage: The interceptor mechanism (ICommandInterceptor, SetInterceptor) already exists. Only the multi-interceptor composition is additive, and the value is modest. + +### 5. Async Cancellation Ergonomics +- What it adds: Out-of-the-box Ctrl+C wiring (Console.CancelKeyPress linked to a CancellationToken) provided through the CommandAppBuilder so long-running commands cancel cleanly. +- Why it matters: InteractiveCommandBase already accepts a CancellationToken. Providing the cancellation plumbing by default removes boilerplate. +- Spectre.Console coverage: Cancellation tokens are supported, but the default Ctrl+C linkage is left to the consumer. + +## Tier 3 - Polish for a 1.0 Feel + +### 6. Fluent Assertions for Testing +- What it adds: A fluent assertion helper set over CommandAppResult, for example result.ShouldSucceed().AndOutputContains(...). +- Why it matters: Complements the differentiating testing infrastructure and improves the test authoring experience. +- Spectre.Console coverage: None. + +### 7. Command Registration Analyzer or Source Generator (stretch) +- What it adds: Auto-discovery of ICommandConfiguration and commands via attributes to reduce startup wiring. +- Why it matters: Cuts boilerplate for larger command sets. +- Spectre.Console coverage: None. This is a larger investment and is intentionally a stretch goal. + +### 8. Documentation and Changelog Parity +- What it adds: An api-reference documentation set under docs/ and a CHANGELOG.md following the Keep a Changelog format. +- Why it matters: Contributor guidelines require both api-reference docs and changelog entries whenever the public API changes. A public launch should include this structure. The repository currently has ReleaseNotes.md but no docs/ folder or CHANGELOG.md. + +## Explicitly Deprioritized + +These items were considered and removed because they would be thin wrappers over existing Spectre.Console.Cli APIs and risk appearing as padding: + +- Global exception handling: Already provided by SetExceptionHandler, PropagateExceptions, and AnsiConsole.WriteException. A builder passthrough or a default styled renderer would be a small convenience helper at most, not a feature. +- Interceptor pipeline as a headline feature: The ICommandInterceptor mechanism already exists. Only CompositeCommandInterceptor (see Tier 2) is additive, and its value is minor. + +## Recommended Splash Focus + +For the initial public release, prioritize the items that fill genuine gaps and extend existing strengths: +1. Verbosity-aware logging integration (verbosity bridge, Spectre-rendered output, and a builder hook; note that basic logger injection already works today). +2. Configuration and options binding. +3. One or two new prompt controls, starting with DatePrompt, then PathPrompt. + +This produces a coherent launch narrative: a complete toolkit for building, configuring, testing, and polishing Spectre.Console CLI apps. diff --git a/Directory.Packages.props b/Directory.Packages.props index 28453d8..8f263fe 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,24 +1,21 @@ - - true + true - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + \ No newline at end of file From c3cfe4f52a152e02a8bea62e5b486ac6bed8a378 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 16:34:48 -0700 Subject: [PATCH 03/10] Implemented WithLogging builder extension method to configure logging on Spectre cli app. Implemented an IAnsiConsole logger, so log statements can be written out and support verbosity levels for log messages. Added Logging.Cli sample to show how logging is configured and used in a command. --- .plans/future-features.md | 13 +- ReleaseNotes.md => CHANGELOG.md | 19 +- ...LoggingCommandAppBuilderExtensionsTests.cs | 105 +++++++ .../SpectreConsoleLoggerOptionsTests.cs | 45 +++ .../SpectreConsoleLoggerProviderTests.cs | 110 +++++++ .../Logging/SpectreConsoleLoggerTests.cs | 285 ++++++++++++++++++ .../Logging/SpectreLoggingExtensionsTests.cs | 79 +++++ .../Logging/VerbosityLevelExtensionsTests.cs | 76 +++++ .../Testing/TestConsoleTests.cs | 30 ++ D20Tek.Spectre.Console.Extensions.sln | 13 +- .../D20Tek.Spectre.Console.Extensions.csproj | 3 +- .../LoggingCommandAppBuilderExtensions.cs | 61 ++++ .../Logging/SpectreConsoleLogger.cs | 117 +++++++ .../Logging/SpectreConsoleLoggerOptions.cs | 34 +++ .../Logging/SpectreConsoleLoggerProvider.cs | 49 +++ .../Logging/SpectreLoggingExtensions.cs | 56 ++++ .../Logging/VerbosityLevelExtensions.cs | 55 ++++ Directory.Packages.props | 1 + README.md | 41 ++- samples/Logging.Cli/LogSampleCommand.cs | 45 +++ samples/Logging.Cli/Logging.Cli.csproj | 17 ++ samples/Logging.Cli/Program.cs | 24 ++ .../Properties/launchSettings.json | 8 + samples/Logging.Cli/Startup.cs | 30 ++ 24 files changed, 1296 insertions(+), 20 deletions(-) rename ReleaseNotes.md => CHANGELOG.md (88%) create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/LoggingCommandAppBuilderExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerOptionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerProviderTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreLoggingExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Logging/VerbosityLevelExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLogger.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerOptions.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerProvider.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/SpectreLoggingExtensions.cs create mode 100644 D20Tek.Spectre.Console.Extensions/Logging/VerbosityLevelExtensions.cs create mode 100644 samples/Logging.Cli/LogSampleCommand.cs create mode 100644 samples/Logging.Cli/Logging.Cli.csproj create mode 100644 samples/Logging.Cli/Program.cs create mode 100644 samples/Logging.Cli/Properties/launchSettings.json create mode 100644 samples/Logging.Cli/Startup.cs diff --git a/.plans/future-features.md b/.plans/future-features.md index c4a0ee7..474ba1b 100644 --- a/.plans/future-features.md +++ b/.plans/future-features.md @@ -14,7 +14,7 @@ The library's strongest, genuinely differentiating areas are: ## Tier 1 - High Impact, Fills Genuine Gaps -### 1. Verbosity-Aware Logging Integration (Microsoft.Extensions.Logging) +### 1. Verbosity-Aware Logging Integration (Microsoft.Extensions.Logging) [DONE] - What it adds: Convenience and cohesion around Microsoft.Extensions.Logging, not basic injection support. Specifically: - A verbosity bridge that maps the existing VerbosityLevel enum to LogLevel, so the same -v|--verbosity switch that controls prompts and output also sets the minimum log level. - An IAnsiConsole-backed logger provider so log output renders through Spectre (consistent styling and markup, and respects TestConsole in tests) instead of the stock AddConsole() provider writing directly to System.Console. @@ -63,15 +63,8 @@ Round out the "Controls" story with a themed family of culture-aware, validated - Spectre.Console coverage: None. This is a larger investment and is intentionally a stretch goal. ### 8. Documentation and Changelog Parity -- What it adds: An api-reference documentation set under docs/ and a CHANGELOG.md following the Keep a Changelog format. -- Why it matters: Contributor guidelines require both api-reference docs and changelog entries whenever the public API changes. A public launch should include this structure. The repository currently has ReleaseNotes.md but no docs/ folder or CHANGELOG.md. - -## Explicitly Deprioritized - -These items were considered and removed because they would be thin wrappers over existing Spectre.Console.Cli APIs and risk appearing as padding: - -- Global exception handling: Already provided by SetExceptionHandler, PropagateExceptions, and AnsiConsole.WriteException. A builder passthrough or a default styled renderer would be a small convenience helper at most, not a feature. -- Interceptor pipeline as a headline feature: The ICommandInterceptor mechanism already exists. Only CompositeCommandInterceptor (see Tier 2) is additive, and its value is minor. +- What it adds: An api-reference documentation set under docs/ to complement the existing CHANGELOG.md. +- Why it matters: Contributor guidelines require both api-reference docs and changelog entries whenever the public API changes. A public launch should include this structure. The repository now has a CHANGELOG.md following the Keep a Changelog format, but still lacks a docs/ folder. ## Recommended Splash Focus diff --git a/ReleaseNotes.md b/CHANGELOG.md similarity index 88% rename from ReleaseNotes.md rename to CHANGELOG.md index 3a0e9be..5a69d20 100644 --- a/ReleaseNotes.md +++ b/CHANGELOG.md @@ -1,4 +1,21 @@ -# Release Notes +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- Verbosity-aware logging that renders through Spectre.Console. New public API includes `LoggingCommandAppBuilderExtensions.WithLogging`, `SpectreLoggingExtensions.AddSpectreConsole`, `SpectreConsoleLoggerProvider`, `SpectreConsoleLogger`, `SpectreConsoleLoggerOptions`, and the `VerbosityLevel`/`LogLevel` mapping extensions. +- New `Logging.Cli` sample that demonstrates enabling verbosity-aware logging with `WithLogging` and injecting `ILogger` into a command. + +### Changed +- Upgraded Spectre dependencies to latest version 0.57.2. +- Updated other dependencies to latest versions. +- `SpectreLoggingExtensions.AddSpectreConsole` now sets the logging builder's minimum level from the mapped verbosity, so Debug and Trace entries are emitted when a more detailed verbosity is requested. + +### Changed ## Release v1.56.1 * Upgraded Spectre dependencies to latest version 0.56. diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/LoggingCommandAppBuilderExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/LoggingCommandAppBuilderExtensionsTests.cs new file mode 100644 index 0000000..cc95a48 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/LoggingCommandAppBuilderExtensionsTests.cs @@ -0,0 +1,105 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Settings; +using D20Tek.Spectre.Console.Extensions.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class LoggingCommandAppBuilderExtensionsTests +{ + [TestMethod] + public void WithLogging_WithNullBuilder_ThrowsException() + { + // Arrange + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => + LoggingCommandAppBuilderExtensions.WithLogging(null!)); + } + + [TestMethod] + public void WithLogging_WithoutRegistrar_ThrowsException() + { + // Arrange + var builder = new CommandAppBuilder(); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => builder.WithLogging()); + } + + [TestMethod] + public void WithLogging_WithDIContainer_ReturnsBuilder() + { + // Arrange + var builder = new CommandAppBuilder().WithDIContainer(); + + // Act + var result = builder.WithLogging(); + + // Assert + Assert.AreSame(builder, result); + } + + [TestMethod] + public void WithLogging_WithDIContainer_RegistersLoggingServices() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + + // Act + builder.WithLogging(VerbosityLevel.Detailed); + + // Assert + var provider = services.BuildServiceProvider(); + var logger = provider.GetService>(); + Assert.IsNotNull(logger); + } + + [TestMethod] + public void WithLogging_WithCustomConsole_LogsThroughConsole() + { + // Arrange + var console = new TestConsole(); + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + + // Act + builder.WithLogging(VerbosityLevel.Normal, console); + + // Assert + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + logger.LogWarning("warned"); + Assert.Contains("warned", console.Output); + } + + [TestMethod] + public void WithLogging_WithConfigureOptions_AppliesOptions() + { + // Arrange + var console = new TestConsole(); + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + + // Act + builder.WithLogging( + VerbosityLevel.Normal, + console, + options => options.IncludeCategory = true); + + // Assert + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + logger.LogInformation("categorized"); + var output = console.Output.Replace("\n", string.Empty).Replace("\r", string.Empty); + Assert.Contains(nameof(LoggingCommandAppBuilderExtensionsTests), output); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerOptionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerOptionsTests.cs new file mode 100644 index 0000000..a7f6857 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerOptionsTests.cs @@ -0,0 +1,45 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Logging; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class SpectreConsoleLoggerOptionsTests +{ + [TestMethod] + public void Constructor_WithDefaults_SetsExpectedValues() + { + // Arrange + + // Act + var options = new SpectreConsoleLoggerOptions(); + + // Assert + Assert.IsTrue(options.IncludeLevelLabel); + Assert.IsFalse(options.IncludeCategory); + Assert.IsFalse(options.IncludeTimestamp); + Assert.AreEqual("HH:mm:ss", options.TimestampFormat); + } + + [TestMethod] + public void Properties_WhenSet_RetainValues() + { + // Arrange + var options = new SpectreConsoleLoggerOptions + { + // Act + IncludeLevelLabel = false, + IncludeCategory = true, + IncludeTimestamp = true, + TimestampFormat = "yyyy-MM-dd" + }; + + // Assert + Assert.IsFalse(options.IncludeLevelLabel); + Assert.IsTrue(options.IncludeCategory); + Assert.IsTrue(options.IncludeTimestamp); + Assert.AreEqual("yyyy-MM-dd", options.TimestampFormat); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerProviderTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerProviderTests.cs new file mode 100644 index 0000000..50730a7 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerProviderTests.cs @@ -0,0 +1,110 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Logging; +using D20Tek.Spectre.Console.Extensions.Testing; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class SpectreConsoleLoggerProviderTests +{ + [TestMethod] + public void Constructor_WithNullConsole_ThrowsException() + { + // Arrange + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => new SpectreConsoleLoggerProvider(null!)); + } + + [TestMethod] + public void Constructor_WithDefaults_SetsInformationMinimumLevel() + { + // Arrange + var console = new TestConsole(); + + // Act + using var provider = new SpectreConsoleLoggerProvider(console); + + // Assert + Assert.AreEqual(LogLevel.Information, provider.MinimumLevel); + } + + [TestMethod] + public void Constructor_WithNullOptions_UsesDefaultOptions() + { + // Arrange + var console = new TestConsole(); + + // Act + using var provider = new SpectreConsoleLoggerProvider(console, null); + + // Assert + Assert.IsNotNull(provider.CreateLogger("cat")); + } + + [TestMethod] + public void CreateLogger_WithCategory_ReturnsLogger() + { + // Arrange + var console = new TestConsole(); + using var provider = new SpectreConsoleLoggerProvider(console); + + // Act + var logger = provider.CreateLogger("MyCategory"); + + // Assert + Assert.IsNotNull(logger); + } + + [TestMethod] + public void CreateLogger_WithSameCategoryTwice_ReturnsSameInstance() + { + // Arrange + var console = new TestConsole(); + using var provider = new SpectreConsoleLoggerProvider(console); + + // Act + var first = provider.CreateLogger("Shared"); + var second = provider.CreateLogger("Shared"); + + // Assert + Assert.AreSame(first, second); + } + + [TestMethod] + public void MinimumLevel_WhenChanged_AffectsCreatedLoggers() + { + // Arrange + var console = new TestConsole(); + using var provider = new SpectreConsoleLoggerProvider(console) + { + MinimumLevel = LogLevel.Warning, + }; + var logger = provider.CreateLogger("cat"); + + // Act + provider.MinimumLevel = LogLevel.Trace; + + // Assert + Assert.IsTrue(logger.IsEnabled(LogLevel.Debug)); + } + + [TestMethod] + public void Dispose_AfterCreatingLoggers_DoesNotThrow() + { + // Arrange + var console = new TestConsole(); + var provider = new SpectreConsoleLoggerProvider(console); + provider.CreateLogger("cat"); + + // Act + provider.Dispose(); + + // Assert + Assert.IsNotNull(provider); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerTests.cs new file mode 100644 index 0000000..cde0f48 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreConsoleLoggerTests.cs @@ -0,0 +1,285 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Logging; +using D20Tek.Spectre.Console.Extensions.Testing; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class SpectreConsoleLoggerTests +{ + private static SpectreConsoleLogger CreateLogger( + TestConsole console, + LogLevel minLevel = LogLevel.Information, + SpectreConsoleLoggerOptions? options = null) => + new(console, "TestCategory", options ?? new SpectreConsoleLoggerOptions(), () => minLevel); + + [TestMethod] + public void Constructor_WithNullConsole_ThrowsException() + { + // Arrange + var options = new SpectreConsoleLoggerOptions(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new SpectreConsoleLogger(null!, "cat", options, [ExcludeFromCodeCoverage]() => LogLevel.Information)); + } + + [TestMethod] + public void Constructor_WithNullCategory_ThrowsException() + { + // Arrange + var console = new TestConsole(); + var options = new SpectreConsoleLoggerOptions(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new SpectreConsoleLogger(console, null!, options, [ExcludeFromCodeCoverage]() => LogLevel.Information)); + } + + [TestMethod] + public void Constructor_WithNullOptions_ThrowsException() + { + // Arrange + var console = new TestConsole(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new SpectreConsoleLogger(console, "cat", null!, [ExcludeFromCodeCoverage]() => LogLevel.Information)); + } + + [TestMethod] + public void Constructor_WithNullAccessor_ThrowsException() + { + // Arrange + var console = new TestConsole(); + var options = new SpectreConsoleLoggerOptions(); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => new SpectreConsoleLogger(console, "cat", options, null!)); + } + + [TestMethod] + public void BeginScope_Always_ReturnsNull() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console); + + // Act + var scope = logger.BeginScope("state"); + + // Assert + Assert.IsNull(scope); + } + + [TestMethod] + public void IsEnabled_WithLevelAtOrAboveMinimum_ReturnsTrue() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act + var result = logger.IsEnabled(LogLevel.Warning); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void IsEnabled_WithLevelBelowMinimum_ReturnsFalse() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act + var result = logger.IsEnabled(LogLevel.Debug); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void IsEnabled_WithNoneLevel_ReturnsFalse() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Trace); + + // Act + var result = logger.IsEnabled(LogLevel.None); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Log_WithEnabledLevel_WritesMessage() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act + logger.LogInformation("hello world"); + + // Assert + Assert.Contains("hello world", console.Output); + Assert.Contains("info", console.Output); + } + + [TestMethod] + public void Log_WithDisabledLevel_WritesNothing() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Warning); + + // Act + logger.LogDebug("should not appear"); + + // Assert + Assert.AreEqual(string.Empty, console.Output); + } + + [TestMethod] + public void Log_WithCategoryEnabled_IncludesCategory() + { + // Arrange + var console = new TestConsole(); + var options = new SpectreConsoleLoggerOptions { IncludeCategory = true }; + var logger = CreateLogger(console, LogLevel.Information, options); + + // Act + logger.LogInformation("message"); + + // Assert + Assert.Contains("TestCategory", console.Output); + } + + [TestMethod] + public void Log_WithLevelLabelDisabled_OmitsLabel() + { + // Arrange + var console = new TestConsole(); + var options = new SpectreConsoleLoggerOptions { IncludeLevelLabel = false }; + var logger = CreateLogger(console, LogLevel.Information, options); + + // Act + logger.LogInformation("plain message"); + + // Assert + Assert.Contains("plain message", console.Output); + Assert.DoesNotContain("info", console.Output); + } + + [TestMethod] + public void Log_WithTimestampEnabled_IncludesTimestamp() + { + // Arrange + var console = new TestConsole(); + var options = new SpectreConsoleLoggerOptions { IncludeTimestamp = true }; + var logger = CreateLogger(console, LogLevel.Information, options); + + // Act + logger.LogInformation("timed message"); + + // Assert + Assert.Contains("timed message", console.Output); + Assert.Contains(":", console.Output); + } + + [TestMethod] + public void Log_WithException_RendersException() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + var exception = new InvalidOperationException("boom"); + + // Act + logger.LogError(exception, "operation failed"); + + // Assert + Assert.Contains("operation failed", console.Output); + Assert.Contains("boom", console.Output); + } + + [TestMethod] + public void Log_WithEmptyMessageAndNoException_WritesNothing() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act + logger.LogInformation(string.Empty); + + // Assert + Assert.AreEqual(string.Empty, console.Output); + } + + [TestMethod] + public void Log_WithNullFormatter_ThrowsException() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => + logger.Log(LogLevel.Information, new EventId(0), "state", null, null!)); + } + + [TestMethod] + public void Log_WithMarkupInMessage_EscapesContent() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Information); + + // Act + logger.LogInformation("value is [red]not markup[/]"); + + // Assert + Assert.Contains("[red]not markup[/]", console.Output); + } + + [TestMethod] + public void Log_WithCriticalLevel_WritesMessage() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Trace); + + // Act + logger.LogCritical("critical failure"); + + // Assert + Assert.Contains("critical failure", console.Output); + Assert.Contains("crit", console.Output); + } + + [TestMethod] + public void Log_WithTraceAndDebugLevels_WritesMessages() + { + // Arrange + var console = new TestConsole(); + var logger = CreateLogger(console, LogLevel.Trace); + + // Act + logger.LogTrace("trace message"); + logger.LogDebug("debug message"); + + // Assert + Assert.Contains("trce", console.Output); + Assert.Contains("dbug", console.Output); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreLoggingExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreLoggingExtensionsTests.cs new file mode 100644 index 0000000..778d54a --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/SpectreLoggingExtensionsTests.cs @@ -0,0 +1,79 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Logging; +using D20Tek.Spectre.Console.Extensions.Settings; +using D20Tek.Spectre.Console.Extensions.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class SpectreLoggingExtensionsTests +{ + [TestMethod] + public void AddSpectreConsole_WithNullBuilder_ThrowsException() + { + // Arrange + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => SpectreLoggingExtensions.AddSpectreConsole(null!)); + } + + [TestMethod] + public void AddSpectreConsole_WithDefaults_RegistersProvider() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddLogging(b => b.AddSpectreConsole()); + + // Assert + var provider = services.BuildServiceProvider(); + var loggerProvider = provider.GetServices() + .OfType() + .SingleOrDefault(); + Assert.IsNotNull(loggerProvider); + } + + [TestMethod] + public void AddSpectreConsole_WithVerbosity_SetsMappedMinimumLevel() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddLogging(b => b.AddSpectreConsole(VerbosityLevel.Detailed)); + + // Assert + var provider = services.BuildServiceProvider(); + var loggerProvider = provider.GetServices() + .OfType() + .Single(); + Assert.AreEqual(LogLevel.Debug, loggerProvider.MinimumLevel); + } + + [TestMethod] + public void AddSpectreConsole_WithConfigureAndRegisteredConsole_LogsThroughConsole() + { + // Arrange + var console = new TestConsole(); + var services = new ServiceCollection(); + services.AddSingleton(console); + + // Act + services.AddLogging(b => b.AddSpectreConsole( + VerbosityLevel.Normal, + options => options.IncludeCategory = true)); + + // Assert + var provider = services.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + logger.LogInformation("through console"); + Assert.Contains("through console", console.Output); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/VerbosityLevelExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/VerbosityLevelExtensionsTests.cs new file mode 100644 index 0000000..8389fe3 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Logging/VerbosityLevelExtensionsTests.cs @@ -0,0 +1,76 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Logging; +using D20Tek.Spectre.Console.Extensions.Settings; +using Microsoft.Extensions.Logging; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Logging; + +[TestClass] +public class VerbosityLevelExtensionsTests +{ + [TestMethod] + [DataRow(VerbosityLevel.Quiet, LogLevel.Error)] + [DataRow(VerbosityLevel.Minimal, LogLevel.Warning)] + [DataRow(VerbosityLevel.Normal, LogLevel.Information)] + [DataRow(VerbosityLevel.Detailed, LogLevel.Debug)] + [DataRow(VerbosityLevel.Diagnostic, LogLevel.Trace)] + public void ToLogLevel_WithKnownVerbosity_ReturnsMappedLevel( + VerbosityLevel verbosity, LogLevel expected) + { + // Arrange + + // Act + var result = verbosity.ToLogLevel(); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void ToLogLevel_WithUndefinedVerbosity_ReturnsInformation() + { + // Arrange + var verbosity = (VerbosityLevel)999; + + // Act + var result = verbosity.ToLogLevel(); + + // Assert + Assert.AreEqual(LogLevel.Information, result); + } + + [TestMethod] + [DataRow(LogLevel.Trace, VerbosityLevel.Diagnostic)] + [DataRow(LogLevel.Debug, VerbosityLevel.Detailed)] + [DataRow(LogLevel.Information, VerbosityLevel.Normal)] + [DataRow(LogLevel.Warning, VerbosityLevel.Minimal)] + [DataRow(LogLevel.Error, VerbosityLevel.Quiet)] + [DataRow(LogLevel.Critical, VerbosityLevel.Quiet)] + [DataRow(LogLevel.None, VerbosityLevel.Quiet)] + public void ToVerbosityLevel_WithKnownLogLevel_ReturnsMappedVerbosity( + LogLevel logLevel, VerbosityLevel expected) + { + // Arrange + + // Act + var result = logLevel.ToVerbosityLevel(); + + // Assert + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void ToVerbosityLevel_WithUndefinedLogLevel_ReturnsNormal() + { + // Arrange + var logLevel = (LogLevel)999; + + // Act + var result = logLevel.ToVerbosityLevel(); + + // Assert + Assert.AreEqual(VerbosityLevel.Normal, result); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleTests.cs index 27853fc..b0a918c 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Testing/TestConsoleTests.cs @@ -105,4 +105,34 @@ public void SetCursor() // assert Assert.AreEqual(testCursor, c.Cursor); } + + [TestMethod] + public void WriteAnsi() + { + // arranage + using var c = new TestConsole(); + + // act + c.WriteAnsi(writer => writer.Write("ansi output")); + + // assert + Assert.Contains("ansi output", c.Output); + } + + [TestMethod] + public void WriteAnsi_WithProvidedWriter_WritesToOutput() + { + // arranage + using var c = new TestConsole(); + + // act + c.WriteAnsi(writer => + { + Assert.IsNotNull(writer); + writer.Write("first ").Write(42); + }); + + // assert + Assert.AreEqual("first 42", c.Output); + } } diff --git a/D20Tek.Spectre.Console.Extensions.sln b/D20Tek.Spectre.Console.Extensions.sln index 76f53c8..514979d 100644 --- a/D20Tek.Spectre.Console.Extensions.sln +++ b/D20Tek.Spectre.Console.Extensions.sln @@ -1,7 +1,7 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.1.32407.343 +# Visual Studio Version 18 +VisualStudioVersion = 18.9.12120.119 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".items", ".items", "{1E2AFA48-704D-4E7B-8A90-BB8D79F69E80}" ProjectSection(SolutionItems) = preProject @@ -43,6 +43,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "D20Tek.Spectre.Console.Exte EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InteractivePrompt.Cli", "samples\InteractivePrompt.Cli\InteractivePrompt.Cli.csproj", "{3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging.Cli", "samples\Logging.Cli\Logging.Cli.csproj", "{9903BE21-C7A3-4D12-8919-6DCD04644544}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -93,6 +95,10 @@ Global {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Debug|Any CPU.Build.0 = Debug|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Release|Any CPU.ActiveCfg = Release|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Release|Any CPU.Build.0 = Release|Any CPU + {9903BE21-C7A3-4D12-8919-6DCD04644544}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9903BE21-C7A3-4D12-8919-6DCD04644544}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9903BE21-C7A3-4D12-8919-6DCD04644544}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9903BE21-C7A3-4D12-8919-6DCD04644544}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -107,6 +113,7 @@ Global {379F0F43-AFAB-4DDB-9A7C-5385441BD3B3} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {55CFC515-C6AB-4BCF-8558-F226FF5CC8C2} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} + {9903BE21-C7A3-4D12-8919-6DCD04644544} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7D1641E6-17C4-4467-946F-C057C45F4A62} diff --git a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj index 2fa644e..c9d19b8 100644 --- a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj +++ b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj @@ -15,7 +15,7 @@ The new Extensions.Testing namespace support test infrastructure classes to easi git Spectre; Spectre.Console; CLI; dependency injection; testing; unit test; test infrastructure; Latest Release: Upgrade to .NET 9. And Split off additional dependency injection containers into its own package to minimize dependencies in this core package. - For full release notes, please read: https://github.com/d20Tek/Spectre.Console.Extensions/blob/main/ReleaseNotes.md + For full release notes, please read: https://github.com/d20Tek/Spectre.Console.Extensions/blob/main/CHANGELOG.md MIT latest True @@ -33,6 +33,7 @@ The new Extensions.Testing namespace support test infrastructure classes to easi + diff --git a/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs b/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs new file mode 100644 index 0000000..a7b8bff --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs @@ -0,0 +1,61 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Injection; +using D20Tek.Spectre.Console.Extensions.Logging; +using D20Tek.Spectre.Console.Extensions.Settings; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console; + +namespace D20Tek.Spectre.Console.Extensions; + +/// +/// Extension methods that add verbosity-aware logging to a CommandAppBuilder. +/// +public static class LoggingCommandAppBuilderExtensions +{ + /// + /// Adds logging that renders through an IAnsiConsole to the builder's DI container, with the + /// minimum log level derived from the supplied verbosity level. Requires that a DI container + /// supporting lifetimes has already been configured, for example by calling WithDIContainer. + /// + /// CommandAppBuilder to extend. + /// + /// [Optional] The verbosity level that sets the minimum LogLevel to emit. Defaults to Normal. + /// + /// + /// [Optional] Console used to render log entries. Defaults to AnsiConsole.Console when null. + /// + /// + /// [Optional] Delegate to configure how log entries are rendered. + /// + /// Returns the CommandAppBuilder. + /// When builder is null. + /// + /// When no registrar has been configured or the registrar does not support lifetimes. + /// + public static CommandAppBuilder WithLogging( + this CommandAppBuilder builder, + VerbosityLevel minimumVerbosity = VerbosityLevel.Normal, + IAnsiConsole? console = null, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.Registrar is null) + { + throw new InvalidOperationException( + "WithLogging requires a DI container. Call WithDIContainer before WithLogging."); + } + + var services = builder.Registrar.WithLifetimes().Services; + if (console is not null) + { + services.AddSingleton(console); + } + + services.AddLogging(logging => logging.AddSpectreConsole(minimumVerbosity, configure)); + + return builder; + } +} diff --git a/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLogger.cs b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLogger.cs new file mode 100644 index 0000000..2161bd3 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLogger.cs @@ -0,0 +1,117 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace D20Tek.Spectre.Console.Extensions.Logging; + +/// +/// An ILogger implementation that renders log entries through an IAnsiConsole, so that +/// log output is styled consistently with the rest of a Spectre.Console application and +/// can be captured by a test console. +/// +public sealed class SpectreConsoleLogger : ILogger +{ + private readonly IAnsiConsole _console; + private readonly string _categoryName; + private readonly SpectreConsoleLoggerOptions _options; + private readonly Func _minLevelAccessor; + + /// + /// Constructor that takes the console, category, options, and a minimum level accessor. + /// + /// Console used to render log entries. + /// Category (typically the source type name) for this logger. + /// Options controlling how entries are rendered. + /// + /// Accessor that returns the current minimum LogLevel to emit. A delegate is used so the + /// level can be changed after the logger is created. + /// + /// When any argument is null. + public SpectreConsoleLogger( + IAnsiConsole console, + string categoryName, + SpectreConsoleLoggerOptions options, + Func minLevelAccessor) + { + ArgumentNullException.ThrowIfNull(console); + ArgumentNullException.ThrowIfNull(categoryName); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(minLevelAccessor); + + _console = console; + _categoryName = categoryName; + _options = options; + _minLevelAccessor = minLevelAccessor; + } + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && logLevel >= _minLevelAccessor(); + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + + if (!IsEnabled(logLevel)) return; + + var message = formatter(state, exception); + if (string.IsNullOrEmpty(message) && exception is null) return; + + var line = BuildLine(logLevel, message); + _console.MarkupLine(line); + + if (exception is not null) + { + _console.WriteException(exception, ExceptionFormats.ShortenEverything); + } + } + + private string BuildLine(LogLevel logLevel, string message) + { + var builder = new System.Text.StringBuilder(); + + if (_options.IncludeTimestamp) + { + builder.Append('[').Append("grey").Append(']') + .Append(Markup.Escape(DateTime.Now.ToString(_options.TimestampFormat))) + .Append("[/] "); + } + + if (_options.IncludeLevelLabel) + { + var (label, color) = GetLevelDisplay(logLevel); + builder.Append('[').Append(color).Append(']') + .Append(label) + .Append("[/] "); + } + + if (_options.IncludeCategory) + { + builder.Append("[grey]").Append(Markup.Escape(_categoryName)).Append("[/] "); + } + + builder.Append(Markup.Escape(message)); + return builder.ToString(); + } + + private static (string Label, string Color) GetLevelDisplay(LogLevel logLevel) => + logLevel switch + { + LogLevel.Debug => ("dbug", "grey"), + LogLevel.Information => ("info", "green"), + LogLevel.Warning => ("warn", "yellow"), + LogLevel.Error => ("fail", "red"), + LogLevel.Critical => ("crit", "white on red"), + _ => ("trce", "grey"), + }; +} diff --git a/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerOptions.cs b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerOptions.cs new file mode 100644 index 0000000..62197ea --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerOptions.cs @@ -0,0 +1,34 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +namespace D20Tek.Spectre.Console.Extensions.Logging; + +/// +/// Options that control how the Spectre console logger renders log entries. +/// +public sealed class SpectreConsoleLoggerOptions +{ + /// + /// Gets or sets a value indicating whether the log level label (for example, "info") + /// is included at the start of each rendered line. Defaults to true. + /// + public bool IncludeLevelLabel { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the log category (typically the source + /// type name) is included in each rendered line. Defaults to false. + /// + public bool IncludeCategory { get; set; } + + /// + /// Gets or sets a value indicating whether a timestamp is included at the start of + /// each rendered line. Defaults to false. + /// + public bool IncludeTimestamp { get; set; } + + /// + /// Gets or sets the format string used to render the timestamp when + /// is enabled. Defaults to "HH:mm:ss". + /// + public string TimestampFormat { get; set; } = "HH:mm:ss"; +} diff --git a/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerProvider.cs b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerProvider.cs new file mode 100644 index 0000000..3fcee9d --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/SpectreConsoleLoggerProvider.cs @@ -0,0 +1,49 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Logging; +using Spectre.Console; +using System.Collections.Concurrent; + +namespace D20Tek.Spectre.Console.Extensions.Logging; + +/// +/// An ILoggerProvider that creates SpectreConsoleLogger instances, rendering log output +/// through an IAnsiConsole with a configurable minimum log level. +/// +public sealed class SpectreConsoleLoggerProvider : ILoggerProvider +{ + private readonly IAnsiConsole _console; + private readonly SpectreConsoleLoggerOptions _options; + private readonly ConcurrentDictionary _loggers = new(); + + /// + /// Gets or sets the minimum LogLevel that created loggers will emit. Changing this value + /// affects loggers that have already been created. + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Information; + + /// + /// Constructor that takes the console and rendering options. + /// + /// Console used to render log entries. + /// + /// [Optional] Options controlling how entries are rendered. A default instance is used when null. + /// + /// When console is null. + public SpectreConsoleLoggerProvider(IAnsiConsole console, SpectreConsoleLoggerOptions? options = null) + { + ArgumentNullException.ThrowIfNull(console); + _console = console; + _options = options ?? new SpectreConsoleLoggerOptions(); + } + + /// + public ILogger CreateLogger(string categoryName) => + _loggers.GetOrAdd( + categoryName, + name => new SpectreConsoleLogger(_console, name, _options, () => MinimumLevel)); + + /// + public void Dispose() => _loggers.Clear(); +} diff --git a/D20Tek.Spectre.Console.Extensions/Logging/SpectreLoggingExtensions.cs b/D20Tek.Spectre.Console.Extensions/Logging/SpectreLoggingExtensions.cs new file mode 100644 index 0000000..d570cb8 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/SpectreLoggingExtensions.cs @@ -0,0 +1,56 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Settings; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Spectre.Console; + +namespace D20Tek.Spectre.Console.Extensions.Logging; + +/// +/// Extension methods that add the Spectre console logger provider to an ILoggingBuilder. +/// +public static class SpectreLoggingExtensions +{ + /// + /// Adds a logger provider that renders output through an IAnsiConsole. The logging builder's + /// minimum level is also set from the mapped verbosity so that levels below Information (Debug + /// and Trace) are emitted when a more detailed verbosity is requested. + /// + /// The logging builder to extend. + /// + /// [Optional] The verbosity level that sets the minimum LogLevel to emit. Defaults to Normal. + /// + /// + /// [Optional] Delegate to configure how log entries are rendered. + /// + /// The logging builder, for chaining. + /// When builder is null. + public static ILoggingBuilder AddSpectreConsole( + this ILoggingBuilder builder, + VerbosityLevel minimumVerbosity = VerbosityLevel.Normal, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new SpectreConsoleLoggerOptions(); + configure?.Invoke(options); + + var minimumLevel = minimumVerbosity.ToLogLevel(); + builder.SetMinimumLevel(minimumLevel); + + builder.Services.TryAddSingleton(_ => AnsiConsole.Console); + builder.Services.AddSingleton(sp => + { + var console = sp.GetRequiredService(); + return new SpectreConsoleLoggerProvider(console, options) + { + MinimumLevel = minimumLevel, + }; + }); + + return builder; + } +} diff --git a/D20Tek.Spectre.Console.Extensions/Logging/VerbosityLevelExtensions.cs b/D20Tek.Spectre.Console.Extensions/Logging/VerbosityLevelExtensions.cs new file mode 100644 index 0000000..da10db6 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions/Logging/VerbosityLevelExtensions.cs @@ -0,0 +1,55 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Settings; +using Microsoft.Extensions.Logging; + +namespace D20Tek.Spectre.Console.Extensions.Logging; + +/// +/// Extension methods that map between the library's VerbosityLevel and the +/// Microsoft.Extensions.Logging LogLevel, so a single verbosity switch can drive +/// both console output and log filtering. +/// +public static class VerbosityLevelExtensions +{ + /// + /// Converts a VerbosityLevel into the minimum LogLevel that should be emitted. + /// + /// The verbosity level to convert. + /// + /// The minimum LogLevel to emit. Quiet maps to Error, Minimal to Warning, + /// Normal to Information, Detailed to Debug, and Diagnostic to Trace. + /// + public static LogLevel ToLogLevel(this VerbosityLevel verbosity) => + verbosity switch + { + VerbosityLevel.Quiet => LogLevel.Error, + VerbosityLevel.Minimal => LogLevel.Warning, + VerbosityLevel.Normal => LogLevel.Information, + VerbosityLevel.Detailed => LogLevel.Debug, + VerbosityLevel.Diagnostic => LogLevel.Trace, + _ => LogLevel.Information, + }; + + /// + /// Converts a LogLevel into the nearest VerbosityLevel. + /// + /// The log level to convert. + /// + /// The nearest VerbosityLevel. Trace maps to Diagnostic, Debug to Detailed, + /// Information to Normal, Warning to Minimal, and Error, Critical, or None to Quiet. + /// + public static VerbosityLevel ToVerbosityLevel(this LogLevel logLevel) => + logLevel switch + { + LogLevel.Trace => VerbosityLevel.Diagnostic, + LogLevel.Debug => VerbosityLevel.Detailed, + LogLevel.Information => VerbosityLevel.Normal, + LogLevel.Warning => VerbosityLevel.Minimal, + LogLevel.Error => VerbosityLevel.Quiet, + LogLevel.Critical => VerbosityLevel.Quiet, + LogLevel.None => VerbosityLevel.Quiet, + _ => VerbosityLevel.Normal, + }; +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 8f263fe..fd0a0c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/README.md b/README.md index 1800293..79f3355 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,16 @@ Additional Spectre Controls: Note: Only Microsoft.Extensions.DependencyInjection is implemented in the core extensions package (D20Tek.Spectre.Console.Extensions). The other DI containers have been repackaged into D20Tek.Spectre.Console.Extensions.MoreContainers, so that we could minimize the dependencies of the core package, and only add those dependencies for users that want to use one of those other frameworks. And, our TypeRegistrars continue to work for those different frameworks. -For future releases, I will continue to investigate integration with other DI frameworks and logging integrations. - ## Installation This libraries are NuGet packages so they are easy to add to your project. To install these packages into your solution, you can use the NuGet Package Manager. In PM, please use the following command: ``` -PM > Install-Package D20Tek.Spectre.Console.Extensions -Version 1.56.1 -PM > Install-Package D20Tek.Spectre.Console.Extensions.MoreContainers -Version 1.56.1 +PM > Install-Package D20Tek.Spectre.Console.Extensions -Version 1.57.1 +PM > Install-Package D20Tek.Spectre.Console.Extensions.MoreContainers -Version 1.57.1 ``` To install in the Visual Studio UI, go to the Tools menu > "Manage NuGet Packages". Then search for D20Tek.Spectre.Console.Extensions and install it from there. -Read more about the current release in our [Release Notes](ReleaseNotes.md). +Read more about the current release in our [Changelog](CHANGELOG.md). ## Usage Once you've installed the NuGet package, you can start using it in your Spectre.Console projects. @@ -147,6 +145,38 @@ namespace D20Tek.CountryService.Cli Note: these code snippets assume using the Microsoft.Extensions.DependencyInjection framework. But similar sample code also exists for the other DI frameworks. +### Verbosity-Aware Logging +Because the CommandAppBuilder bridges to a Microsoft.Extensions.DependencyInjection service collection, any command can already inject an `ILogger` once logging is registered. To render log output through Spectre.Console with a minimum log level derived from a verbosity level, call `WithLogging` after configuring a DI container: +```csharp +using D20Tek.Spectre.Console.Extensions; +using D20Tek.Spectre.Console.Extensions.Settings; + +return await new CommandAppBuilder() + .WithDIContainer() + .WithLogging(VerbosityLevel.Detailed) + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +The verbosity level maps to a minimum `LogLevel` (Quiet -> Error, Minimal -> Warning, Normal -> Information, Detailed -> Debug, Diagnostic -> Trace). You can optionally supply a custom `IAnsiConsole` and configure how entries are rendered: +```csharp +builder.WithLogging( + VerbosityLevel.Normal, + console: AnsiConsole.Console, + configure: options => + { + options.IncludeCategory = true; + options.IncludeTimestamp = true; + }); +``` + +You can also register the provider directly against an `ILoggingBuilder` using `AddSpectreConsole`: +```csharp +services.AddLogging(logging => logging.AddSpectreConsole(VerbosityLevel.Normal)); +``` + ### Samples: For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, please review the following samples: @@ -159,6 +189,7 @@ For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, plea * [SimpleInjector.Cli](samples/SimpleInjector.Cli) - Use the SimpleInjector DI framework to build type registrar and resolver. * [NoDI.Cli](samples/NoDI.Cli) - Use the CommandAppBuilder to configure a console app that does not use a DI framework. * [InteractivePrompt.Cli](samples/InteractivePrompt.Cli) - Create an interactive prompt that can run other registered commands while remaining in the prompt. +* [Logging.Cli](samples/Logging.Cli) - Use WithLogging to enable verbosity-aware, Spectre-rendered logging and inject an ILogger<T> into a command. ### Testing Infrastructure This library also provides testing classes that help in building your CommandApp unit tests. Using the CommandAppTestContext allows you to easily configure and run commands in isolation. diff --git a/samples/Logging.Cli/LogSampleCommand.cs b/samples/Logging.Cli/LogSampleCommand.cs new file mode 100644 index 0000000..52b0508 --- /dev/null +++ b/samples/Logging.Cli/LogSampleCommand.cs @@ -0,0 +1,45 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Logging; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace Logging.Cli; + +internal sealed class LogSampleCommand(ILogger logger) : Command +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "[NAME]")] + [Description("The name to greet in the log output.")] + [DefaultValue("world")] + public string Name { get; set; } = "world"; + } + + protected override int Execute( + [NotNull] CommandContext context, + [NotNull] Settings settings, + CancellationToken cancellation) + { + _logger.LogTrace("Starting {Command} for {Name}.", context.Name, settings.Name); + _logger.LogDebug("Resolved settings with Name = {Name}.", settings.Name); + _logger.LogInformation("Hello, {Name}! The logging sample is running.", settings.Name); + _logger.LogWarning("This is a warning message that shows verbosity filtering."); + + try + { + throw new InvalidOperationException("Simulated failure to demonstrate exception logging."); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "An error occurred while processing the greeting."); + } + + _logger.LogInformation("Logging sample completed."); + return 0; + } +} diff --git a/samples/Logging.Cli/Logging.Cli.csproj b/samples/Logging.Cli/Logging.Cli.csproj new file mode 100644 index 0000000..d3a3adb --- /dev/null +++ b/samples/Logging.Cli/Logging.Cli.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + + + + + + + + + + + + diff --git a/samples/Logging.Cli/Program.cs b/samples/Logging.Cli/Program.cs new file mode 100644 index 0000000..c790243 --- /dev/null +++ b/samples/Logging.Cli/Program.cs @@ -0,0 +1,24 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions; +using D20Tek.Spectre.Console.Extensions.Settings; +using Logging.Cli; + +// WithLogging registers a Spectre.Console-rendered ILogger and derives the minimum +// LogLevel from the supplied verbosity level. Detailed maps to LogLevel.Debug, so the +// trace message below is filtered out while debug and higher are shown. The optional +// configure delegate controls how each log entry is rendered. +return await new CommandAppBuilder() + .WithDIContainer() + .WithLogging( + VerbosityLevel.Detailed, + configure: options => + { + options.IncludeCategory = true; + options.IncludeTimestamp = true; + }) + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); diff --git a/samples/Logging.Cli/Properties/launchSettings.json b/samples/Logging.Cli/Properties/launchSettings.json new file mode 100644 index 0000000..5d9f5ff --- /dev/null +++ b/samples/Logging.Cli/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "Logging.Cli": { + "commandName": "Project", + "commandLineArgs": "greet Bob" + } + } +} diff --git a/samples/Logging.Cli/Startup.cs b/samples/Logging.Cli/Startup.cs new file mode 100644 index 0000000..b1df42a --- /dev/null +++ b/samples/Logging.Cli/Startup.cs @@ -0,0 +1,30 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions; +using Spectre.Console.Cli; + +namespace Logging.Cli; + +internal sealed class Startup : StartupBase +{ + public override void ConfigureServices(ITypeRegistrar registrar) + { + // No additional services are required for this sample. Logging is wired up + // through the CommandAppBuilder using WithLogging in Program.cs, which makes + // ILogger available for injection into any command. + } + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.CaseSensitivity(CaseSensitivity.None); + config.SetApplicationName("Logging.Cli"); + config.ValidateExamples(); + + config.AddCommand("greet") + .WithDescription("Writes a few log messages at different levels through Spectre.Console.") + .WithExample(["greet", "Linus"]); + + return config; + } +} From f90db921157c847b709bb8fe958d354e4056b6ce Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 16:55:55 -0700 Subject: [PATCH 04/10] Make the GetServices accessible in CommandAppBuilder to make it easier to access in other extensions to this builder. Updated the logging builder extension to use that. Added unit tests for new accessors. --- .plans/future-features.md | 9 ++++-- CHANGELOG.md | 4 +-- .../CommandAppBuilderTests.cs | 28 +++++++++++++++-- .../CommandAppBuilder.cs | 31 +++++++++++++++++-- .../LoggingCommandAppBuilderExtensions.cs | 9 +----- 5 files changed, 64 insertions(+), 17 deletions(-) diff --git a/.plans/future-features.md b/.plans/future-features.md index 474ba1b..c1c1b67 100644 --- a/.plans/future-features.md +++ b/.plans/future-features.md @@ -24,9 +24,14 @@ The library's strongest, genuinely differentiating areas are: - Important clarification: Basic logger injection already works today with no new code. The DependencyInjectionTypeRegistrar exposes the underlying IServiceCollection via its Services property, and the resolver forwards to IServiceProvider.GetService. A consumer can already call registrar.WithLifetimes().Services.AddLogging(...) in ConfigureServices, and any command can then inject ILogger through its constructor. This feature is therefore about verbosity integration, Spectre-rendered output, and a fluent builder hook, not about enabling injection. ### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) -- What it adds: Wiring for Microsoft.Extensions.Configuration (JSON, environment variables, user secrets) into the CommandAppBuilder, for example a WithConfiguration(...) method, plus binding to strongly typed settings objects. -- Why it matters: Configuration is table-stakes for production CLI tools and pairs naturally with the existing dependency injection container. +- What it adds: A new separate package (D20Tek.Spectre.Console.Extensions.Configuration) that wires Microsoft.Extensions.Configuration and Options into the CommandAppBuilder. Two builder hooks: + - WithConfiguration(...): builds an IConfiguration (appsettings.json plus environment variables by default, with an optional configure delegate) and registers it in the container. + - WithOptions<T>(sectionName): binds a configuration section to a strongly typed options class, resolvable as IOptions<T>. +- Why it matters: Configuration is table-stakes for production CLI tools and pairs naturally with the existing dependency injection container. Any command can then inject IConfiguration or IOptions<T> through its constructor, exactly like ILogger<T> today. - Spectre.Console coverage: None. Spectre.Console.Cli does not ship IConfiguration wiring, so this is a real gap. +- Packaging decision: Separate package. The code surface is small (roughly two extension methods), but it pulls in 4-5 additional Microsoft.Extensions.Configuration/Options dependencies. Keeping it out of the core package preserves the core's minimal-dependency goal, consistent with the MoreContainers split. +- Design decision: Keep CommandSettings (CLI args) and IOptions<T> (config) separate; commands decide precedence explicitly. Config-backed defaults for command options can be added later if needed. +- Implementation note: The builder hooks need access to the container. DONE - CommandAppBuilder now exposes a public ITypeRegistrar? Registrar getter and a public GetServiceCollection() helper that returns the registrar's IServiceCollection (throwing if no DI container is configured). Add-on extension packages (logging, configuration, and future ones) should call GetServiceCollection() rather than reaching through WithLifetimes().Services. The existing WithLogging hook was refactored to use this accessor. ### 3. Additional Prompt Controls Round out the "Controls" story with a themed family of culture-aware, validated prompts that follow the existing CurrencyPrompt pattern (IPrompt plus IHasCulture, with a validator and presenter split). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a69d20..0f6d709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,16 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Public `CommandAppBuilder.Registrar` getter and `CommandAppBuilder.GetServiceCollection()` helper so add-on extension packages can access the builder's DI container. - Verbosity-aware logging that renders through Spectre.Console. New public API includes `LoggingCommandAppBuilderExtensions.WithLogging`, `SpectreLoggingExtensions.AddSpectreConsole`, `SpectreConsoleLoggerProvider`, `SpectreConsoleLogger`, `SpectreConsoleLoggerOptions`, and the `VerbosityLevel`/`LogLevel` mapping extensions. - New `Logging.Cli` sample that demonstrates enabling verbosity-aware logging with `WithLogging` and injecting `ILogger` into a command. ### Changed - Upgraded Spectre dependencies to latest version 0.57.2. - Updated other dependencies to latest versions. +- `LoggingCommandAppBuilderExtensions.WithLogging` now uses the new `GetServiceCollection()` accessor instead of reaching through the internal registrar. - `SpectreLoggingExtensions.AddSpectreConsole` now sets the logging builder's minimum level from the mapped verbosity, so Debug and Trace entries are emitted when a more detailed verbosity is requested. -### Changed - ## Release v1.56.1 * Upgraded Spectre dependencies to latest version 0.56. * Updated other dependencies to latest versions. diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs index 9d1a0f1..ed67dad 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/CommandAppBuilderTests.cs @@ -2,9 +2,7 @@ // Copyright (c) d20Tek. All rights reserved. //--------------------------------------------------------------------------------------------------------------------- using D20Tek.Spectre.Console.Extensions.UnitTests.Mocks; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; namespace D20Tek.Spectre.Console.Extensions.UnitTests; @@ -197,4 +195,28 @@ public void Run_LamarRegistrar() // assert Assert.AreEqual(0, result); } + + [TestMethod] + public void GetServiceCollection_WithDIContainer_ReturnsServices() + { + // arrange + var builder = new CommandAppBuilder().WithDIContainer(); + + // act + var services = builder.GetServiceCollection(); + + // assert + Assert.IsNotNull(services); + } + + [TestMethod] + public void GetServiceCollection_WithoutRegistrar_ThrowsException() + { + // arrange + var builder = new CommandAppBuilder(); + + // act - assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => builder.GetServiceCollection()); + } } diff --git a/D20Tek.Spectre.Console.Extensions/CommandAppBuilder.cs b/D20Tek.Spectre.Console.Extensions/CommandAppBuilder.cs index bf4409e..3db16f2 100644 --- a/D20Tek.Spectre.Console.Extensions/CommandAppBuilder.cs +++ b/D20Tek.Spectre.Console.Extensions/CommandAppBuilder.cs @@ -1,6 +1,8 @@ //--------------------------------------------------------------------------------------------------------------------- // Copyright (c) d20Tek. All rights reserved. //--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Injection; +using Microsoft.Extensions.DependencyInjection; using Spectre.Console.Cli; namespace D20Tek.Spectre.Console.Extensions; @@ -11,15 +13,40 @@ namespace D20Tek.Spectre.Console.Extensions; public class CommandAppBuilder { internal CommandApp? App { get; set; } = null; - + internal Action? SetDefaultCommand { get; set; } - internal ITypeRegistrar? Registrar { get; set; } + /// + /// Gets the type registrar configured for this builder, or null if none has been set. + /// Exposed so that add-on extension packages can reach the underlying DI container. + /// + public ITypeRegistrar? Registrar { get; internal set; } internal StartupBase? Startup { get; set; } internal Action? SetCustomConfig { get; set; } + /// + /// Gets the underlying service collection from the configured registrar. Intended for + /// add-on extension packages that need to register additional services (for example + /// logging or configuration) into the builder's DI container. + /// + /// The registrar's service collection. + /// + /// When no registrar has been configured or the registrar does not support lifetimes. + /// Call WithDIContainer before using service-based extensions. + /// + public IServiceCollection GetServiceCollection() + { + if (Registrar is null) + { + throw new InvalidOperationException( + "A DI container is required. Call WithDIContainer before using this extension."); + } + + return Registrar.WithLifetimes().Services; + } + /// /// Sets up the Startup class to use in this builder. /// diff --git a/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs b/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs index a7b8bff..d219d87 100644 --- a/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs +++ b/D20Tek.Spectre.Console.Extensions/Logging/LoggingCommandAppBuilderExtensions.cs @@ -1,7 +1,6 @@ //--------------------------------------------------------------------------------------------------------------------- // Copyright (c) d20Tek. All rights reserved. //--------------------------------------------------------------------------------------------------------------------- -using D20Tek.Spectre.Console.Extensions.Injection; using D20Tek.Spectre.Console.Extensions.Logging; using D20Tek.Spectre.Console.Extensions.Settings; using Microsoft.Extensions.DependencyInjection; @@ -42,13 +41,7 @@ public static CommandAppBuilder WithLogging( { ArgumentNullException.ThrowIfNull(builder); - if (builder.Registrar is null) - { - throw new InvalidOperationException( - "WithLogging requires a DI container. Call WithDIContainer before WithLogging."); - } - - var services = builder.Registrar.WithLifetimes().Services; + var services = builder.GetServiceCollection(); if (console is not null) { services.AddSingleton(console); From f8c7ffa27b3b29e2bc7a1451a8b0ebdeebd44b6b Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 17:31:16 -0700 Subject: [PATCH 05/10] Implemented WithConfiguration registration on the CommandAppBuilder. Implemented WithOptions registration on the CommandAppBuilder. Added unit tests. Implemented the Configuration.Cli sample app that shows how to register and use both IConfiguration and IOption config data. --- .plans/future-features.md | 3 +- CHANGELOG.md | 2 + ...onfigurationCommandAppBuilderExtensions.cs | 84 +++++++ ...re.Console.Extensions.Configuration.csproj | 44 ++++ ...urationCommandAppBuilderExtensionsTests.cs | 206 ++++++++++++++++++ .../Configuration/Fakes/SampleOptions.cs | 15 ++ ...pectre.Console.Extensions.UnitTests.csproj | 1 + D20Tek.Spectre.Console.Extensions.sln | 18 +- Directory.Packages.props | 6 + README.md | 33 +++ .../Configuration.Cli.csproj | 19 ++ samples/Configuration.Cli/GreetCommand.cs | 48 ++++ samples/Configuration.Cli/GreetingOptions.cs | 20 ++ samples/Configuration.Cli/InfoCommand.cs | 36 +++ samples/Configuration.Cli/Program.cs | 20 ++ .../Properties/launchSettings.json | 16 ++ samples/Configuration.Cli/Startup.cs | 35 +++ samples/Configuration.Cli/appsettings.json | 13 ++ 18 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 D20Tek.Spectre.Console.Extensions.Configuration/ConfigurationCommandAppBuilderExtensions.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/ConfigurationCommandAppBuilderExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/Fakes/SampleOptions.cs create mode 100644 samples/Configuration.Cli/Configuration.Cli.csproj create mode 100644 samples/Configuration.Cli/GreetCommand.cs create mode 100644 samples/Configuration.Cli/GreetingOptions.cs create mode 100644 samples/Configuration.Cli/InfoCommand.cs create mode 100644 samples/Configuration.Cli/Program.cs create mode 100644 samples/Configuration.Cli/Properties/launchSettings.json create mode 100644 samples/Configuration.Cli/Startup.cs create mode 100644 samples/Configuration.Cli/appsettings.json diff --git a/.plans/future-features.md b/.plans/future-features.md index c1c1b67..f012b13 100644 --- a/.plans/future-features.md +++ b/.plans/future-features.md @@ -23,7 +23,7 @@ The library's strongest, genuinely differentiating areas are: - Spectre.Console coverage: None. Spectre.Console.Cli does not ship ILogger wiring. - Important clarification: Basic logger injection already works today with no new code. The DependencyInjectionTypeRegistrar exposes the underlying IServiceCollection via its Services property, and the resolver forwards to IServiceProvider.GetService. A consumer can already call registrar.WithLifetimes().Services.AddLogging(...) in ConfigureServices, and any command can then inject ILogger through its constructor. This feature is therefore about verbosity integration, Spectre-rendered output, and a fluent builder hook, not about enabling injection. -### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) +### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) - DONE - What it adds: A new separate package (D20Tek.Spectre.Console.Extensions.Configuration) that wires Microsoft.Extensions.Configuration and Options into the CommandAppBuilder. Two builder hooks: - WithConfiguration(...): builds an IConfiguration (appsettings.json plus environment variables by default, with an optional configure delegate) and registers it in the container. - WithOptions<T>(sectionName): binds a configuration section to a strongly typed options class, resolvable as IOptions<T>. @@ -32,6 +32,7 @@ The library's strongest, genuinely differentiating areas are: - Packaging decision: Separate package. The code surface is small (roughly two extension methods), but it pulls in 4-5 additional Microsoft.Extensions.Configuration/Options dependencies. Keeping it out of the core package preserves the core's minimal-dependency goal, consistent with the MoreContainers split. - Design decision: Keep CommandSettings (CLI args) and IOptions<T> (config) separate; commands decide precedence explicitly. Config-backed defaults for command options can be added later if needed. - Implementation note: The builder hooks need access to the container. DONE - CommandAppBuilder now exposes a public ITypeRegistrar? Registrar getter and a public GetServiceCollection() helper that returns the registrar's IServiceCollection (throwing if no DI container is configured). Add-on extension packages (logging, configuration, and future ones) should call GetServiceCollection() rather than reaching through WithLifetimes().Services. The existing WithLogging hook was refactored to use this accessor. +- Status: DONE - Package implemented with WithConfiguration and WithOptions<T> (data-annotation validated), covered by unit tests, and demonstrated by the Configuration.Cli sample. ### 3. Additional Prompt Controls Round out the "Controls" story with a themed family of culture-aware, validated prompts that follow the existing CurrencyPrompt pattern (IPrompt plus IHasCulture, with a validator and presenter split). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6d709..e6fe8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Public `CommandAppBuilder.Registrar` getter and `CommandAppBuilder.GetServiceCollection()` helper so add-on extension packages can access the builder's DI container. - Verbosity-aware logging that renders through Spectre.Console. New public API includes `LoggingCommandAppBuilderExtensions.WithLogging`, `SpectreLoggingExtensions.AddSpectreConsole`, `SpectreConsoleLoggerProvider`, `SpectreConsoleLogger`, `SpectreConsoleLoggerOptions`, and the `VerbosityLevel`/`LogLevel` mapping extensions. - New `Logging.Cli` sample that demonstrates enabling verbosity-aware logging with `WithLogging` and injecting `ILogger` into a command. +- New `D20Tek.Spectre.Console.Extensions.Configuration` package that adds Microsoft.Extensions.Configuration and Options binding to the builder. New public API includes `ConfigurationCommandAppBuilderExtensions.WithConfiguration` and `ConfigurationCommandAppBuilderExtensions.WithOptions`. +- New `Configuration.Cli` sample that demonstrates binding configuration with `WithConfiguration` and injecting `IOptions` bound via `WithOptions` into a command. ### Changed - Upgraded Spectre dependencies to latest version 0.57.2. diff --git a/D20Tek.Spectre.Console.Extensions.Configuration/ConfigurationCommandAppBuilderExtensions.cs b/D20Tek.Spectre.Console.Extensions.Configuration/ConfigurationCommandAppBuilderExtensions.cs new file mode 100644 index 0000000..e906699 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Configuration/ConfigurationCommandAppBuilderExtensions.cs @@ -0,0 +1,84 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace D20Tek.Spectre.Console.Extensions.Configuration; + +/// +/// Extension methods that add Microsoft.Extensions.Configuration and Options binding to a +/// CommandAppBuilder. These hooks require that a DI container has already been configured, +/// for example by calling WithDIContainer. +/// +public static class ConfigurationCommandAppBuilderExtensions +{ + /// + /// Builds an IConfiguration and registers it in the builder's DI container. By default the + /// configuration reads from an optional appsettings.json file and environment variables. The + /// optional configure delegate can add or replace configuration sources. + /// + /// CommandAppBuilder to extend. + /// + /// [Optional] Delegate to customize the configuration sources. When null, the default sources + /// (appsettings.json and environment variables) are used. + /// + /// Returns the CommandAppBuilder. + /// When builder is null. + /// + /// When no DI container has been configured. Call WithDIContainer before WithConfiguration. + /// + public static CommandAppBuilder WithConfiguration( + this CommandAppBuilder builder, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var services = builder.GetServiceCollection(); + var configBuilder = new ConfigurationBuilder().SetBasePath(AppContext.BaseDirectory); + if (configure is null) + { + configBuilder.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables(); + } + else + { + configure(configBuilder); + } + + IConfiguration configuration = configBuilder.Build(); + services.AddSingleton(configuration); + + return builder; + } + + /// + /// Binds a configuration section to a strongly typed options class, registered so that it can + /// be injected as IOptions<TOptions>. Data annotations on the options class are validated. + /// Call WithConfiguration first so that an IConfiguration is available in the container. + /// + /// The options class to bind and register. + /// CommandAppBuilder to extend. + /// The configuration section name to bind from. + /// Returns the CommandAppBuilder. + /// When builder is null. + /// When sectionName is null or whitespace. + /// + /// When no DI container has been configured. Call WithDIContainer before WithOptions. + /// + public static CommandAppBuilder WithOptions( + this CommandAppBuilder builder, + string sectionName) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(sectionName); + + builder.GetServiceCollection() + .AddOptions() + .BindConfiguration(sectionName) + .ValidateDataAnnotations(); + + return builder; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj new file mode 100644 index 0000000..556ff8c --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj @@ -0,0 +1,44 @@ + + + + net9.0;net10.0 + True + Spectre.Console Configuration Extensions + 1.2.1 + d20Tek + d20Tek + Extensions for common code and patterns when using Spectre.Console CLI app framework. + +The current release wires Microsoft.Extensions.Configuration and Options binding into the CommandAppBuilder, so CLI commands can inject IConfiguration and IOptions<T> alongside the existing dependency injection container. It provides fluent WithConfiguration and WithOptions builder hooks. This capability lives in a separate package to keep the core package's dependencies minimal. + Copyright (c) d20Tek. + https://github.com/d20Tek/Spectre.Console.Extensions + README.md + https://github.com/d20Tek/Spectre.Console.Extensions + git + Spectre; Spectre.Console; CLI; configuration; options; IConfiguration; IOptions; settings + Added Microsoft.Extensions.Configuration and Options binding support for the CommandAppBuilder in a separate package to keep the core package's dependencies minimal. + MIT + True + + + + + True + \ + + + + + + + + + + + + + + + + + diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/ConfigurationCommandAppBuilderExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/ConfigurationCommandAppBuilderExtensionsTests.cs new file mode 100644 index 0000000..aa4e072 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/ConfigurationCommandAppBuilderExtensionsTests.cs @@ -0,0 +1,206 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Configuration; +using D20Tek.Spectre.Console.Extensions.UnitTests.Configuration.Fakes; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Configuration; + +[TestClass] +public class ConfigurationCommandAppBuilderExtensions_WithConfigurationTests +{ + [TestMethod] + public void WithConfiguration_WithNullBuilder_ThrowsException() + { + // Arrange + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + ConfigurationCommandAppBuilderExtensions.WithConfiguration(null!)); + } + + [TestMethod] + public void WithConfiguration_WithoutRegistrar_ThrowsException() + { + // Arrange + var builder = new CommandAppBuilder(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => builder.WithConfiguration()); + } + + [TestMethod] + public void WithConfiguration_WithDIContainer_ReturnsBuilder() + { + // Arrange + var builder = new CommandAppBuilder().WithDIContainer(); + + // Act + var result = builder.WithConfiguration(); + + // Assert + Assert.AreSame(builder, result); + } + + [TestMethod] + public void WithConfiguration_WithDefaultSources_RegistersConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + + // Act + builder.WithConfiguration(); + + // Assert + var provider = services.BuildServiceProvider(); + var configuration = provider.GetService(); + Assert.IsNotNull(configuration); + } + + [TestMethod] + public void WithConfiguration_WithCustomSource_BindsProvidedValues() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + var values = new Dictionary + { + ["Sample:Name"] = "custom-value", + }; + + // Act + builder.WithConfiguration(config => config.AddInMemoryCollection(values)); + + // Assert + var provider = services.BuildServiceProvider(); + var configuration = provider.GetRequiredService(); + Assert.AreEqual("custom-value", configuration["Sample:Name"]); + } + + [TestMethod] + public void WithConfiguration_WithCustomSource_DoesNotUseDefaultSources() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + + // Act + builder.WithConfiguration(config => config.AddInMemoryCollection([])); + + // Assert + var provider = services.BuildServiceProvider(); + var configuration = provider.GetRequiredService(); + Assert.IsEmpty(configuration.GetChildren()); + } +} + +[TestClass] +public class ConfigurationCommandAppBuilderExtensions_WithOptionsTests +{ + [TestMethod] + public void WithOptions_WithNullBuilder_ThrowsException() + { + // Arrange + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => + ConfigurationCommandAppBuilderExtensions.WithOptions(null!, "Sample")); + } + + [TestMethod] + public void WithOptions_WithNullSectionName_ThrowsException() + { + // Arrange + var builder = new CommandAppBuilder().WithDIContainer(); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => builder.WithOptions(null!)); + } + + [TestMethod] + public void WithOptions_WithWhitespaceSectionName_ThrowsException() + { + // Arrange + var builder = new CommandAppBuilder().WithDIContainer(); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => builder.WithOptions(" ")); + } + + [TestMethod] + public void WithOptions_WithoutRegistrar_ThrowsException() + { + // Arrange + var builder = new CommandAppBuilder(); + + // Act - Assert + Assert.ThrowsExactly( + [ExcludeFromCodeCoverage] () => builder.WithOptions("Sample")); + } + + [TestMethod] + public void WithOptions_WithDIContainer_ReturnsBuilder() + { + // Arrange + var builder = new CommandAppBuilder().WithDIContainer(); + builder.WithConfiguration(config => + config.AddInMemoryCollection(new Dictionary())); + + // Act + var result = builder.WithOptions("Sample"); + + // Assert + Assert.AreSame(builder, result); + } + + [TestMethod] + public void WithOptions_WithBoundSection_ResolvesTypedOptions() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + var values = new Dictionary + { + ["Sample:Name"] = "bound-name", + ["Sample:Count"] = "7", + }; + + // Act + builder.WithConfiguration(config => config.AddInMemoryCollection(values)) + .WithOptions("Sample"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + Assert.AreEqual("bound-name", options.Value.Name); + Assert.AreEqual(7, options.Value.Count); + } + + [TestMethod] + [ExcludeFromCodeCoverage] + public void WithOptions_WithInvalidValues_ThrowsOnResolve() + { + // Arrange + var services = new ServiceCollection(); + var builder = new CommandAppBuilder().WithDIContainer(services); + var values = new Dictionary + { + ["Sample:Name"] = string.Empty, + }; + builder.WithConfiguration(config => config.AddInMemoryCollection(values)) + .WithOptions("Sample"); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + // Act - Assert + Assert.ThrowsExactly( + () => _ = options.Value); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/Fakes/SampleOptions.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/Fakes/SampleOptions.cs new file mode 100644 index 0000000..84f4643 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Configuration/Fakes/SampleOptions.cs @@ -0,0 +1,15 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using System.ComponentModel.DataAnnotations; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Configuration.Fakes; + +internal sealed class SampleOptions +{ + [Required] + [MinLength(1)] + public string Name { get; set; } = string.Empty; + + public int Count { get; set; } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj index 3562813..3e1b84d 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj @@ -18,6 +18,7 @@ + diff --git a/D20Tek.Spectre.Console.Extensions.sln b/D20Tek.Spectre.Console.Extensions.sln index 514979d..9847e4d 100644 --- a/D20Tek.Spectre.Console.Extensions.sln +++ b/D20Tek.Spectre.Console.Extensions.sln @@ -1,13 +1,12 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.9.12120.119 stable +VisualStudioVersion = 18.9.12120.119 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".items", ".items", "{1E2AFA48-704D-4E7B-8A90-BB8D79F69E80}" ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md LICENSE = LICENSE README.md = README.md - ReleaseNotes.md = ReleaseNotes.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "D20Tek.Spectre.Console.Extensions", "D20Tek.Spectre.Console.Extensions\D20Tek.Spectre.Console.Extensions.csproj", "{D01A1C74-5D45-4761-8BD2-B34004D6C708}" @@ -41,10 +40,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lamar.Cli", "samples\Lamar. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "D20Tek.Spectre.Console.Extensions.MoreContainers", "D20Tek.Spectre.Console.Extensions.MoreContainers\D20Tek.Spectre.Console.Extensions.MoreContainers.csproj", "{0FB8FC0F-B6F1-40E4-9C47-D43CCC6F6746}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "D20Tek.Spectre.Console.Extensions.Configuration", "D20Tek.Spectre.Console.Extensions.Configuration\D20Tek.Spectre.Console.Extensions.Configuration.csproj", "{D0530068-17BE-4379-95A3-4DBD21E7BA00}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InteractivePrompt.Cli", "samples\InteractivePrompt.Cli\InteractivePrompt.Cli.csproj", "{3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging.Cli", "samples\Logging.Cli\Logging.Cli.csproj", "{9903BE21-C7A3-4D12-8919-6DCD04644544}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Configuration.Cli", "samples\Configuration.Cli\Configuration.Cli.csproj", "{17E61A95-44CC-4BCB-9504-A9C3336929B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -91,6 +94,10 @@ Global {0FB8FC0F-B6F1-40E4-9C47-D43CCC6F6746}.Debug|Any CPU.Build.0 = Debug|Any CPU {0FB8FC0F-B6F1-40E4-9C47-D43CCC6F6746}.Release|Any CPU.ActiveCfg = Release|Any CPU {0FB8FC0F-B6F1-40E4-9C47-D43CCC6F6746}.Release|Any CPU.Build.0 = Release|Any CPU + {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Release|Any CPU.Build.0 = Release|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Debug|Any CPU.Build.0 = Debug|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -99,6 +106,10 @@ Global {9903BE21-C7A3-4D12-8919-6DCD04644544}.Debug|Any CPU.Build.0 = Debug|Any CPU {9903BE21-C7A3-4D12-8919-6DCD04644544}.Release|Any CPU.ActiveCfg = Release|Any CPU {9903BE21-C7A3-4D12-8919-6DCD04644544}.Release|Any CPU.Build.0 = Release|Any CPU + {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -114,6 +125,7 @@ Global {55CFC515-C6AB-4BCF-8558-F226FF5CC8C2} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {9903BE21-C7A3-4D12-8919-6DCD04644544} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} + {17E61A95-44CC-4BCB-9504-A9C3336929B3} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7D1641E6-17C4-4467-946F-C057C45F4A62} diff --git a/Directory.Packages.props b/Directory.Packages.props index fd0a0c7..4d8fba3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,13 @@ + + + + + + diff --git a/README.md b/README.md index 79f3355..d5e2a81 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,38 @@ You can also register the provider directly against an `ILoggingBuilder` using ` services.AddLogging(logging => logging.AddSpectreConsole(VerbosityLevel.Normal)); ``` +### Configuration and Options Binding +The separate `D20Tek.Spectre.Console.Extensions.Configuration` package adds Microsoft.Extensions.Configuration and Options binding to the builder without pulling those dependencies into the core package. After configuring a DI container, call `WithConfiguration` to build and register an `IConfiguration`, then `WithOptions` to bind a section to a strongly typed, validated options class: +```csharp +return await new CommandAppBuilder() + .WithDIContainer() + .WithConfiguration() + .WithOptions(GreetingOptions.SectionName) + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` +By default `WithConfiguration` reads from an optional `appsettings.json` file and environment variables. Pass a configure delegate to customize the configuration sources. `WithOptions` binds the named section and validates any data annotations on the options class. Any command can then inject `IConfiguration` or `IOptions` through its constructor. Configuration values remain separate from command-line `CommandSettings`. + +You do not have to bind to a strongly typed options class. A command can also inject `IConfiguration` directly and read individual keys or sections: +```csharp +internal sealed class InfoCommand(IConfiguration configuration, IAnsiConsole console) : Command +{ + protected override int Execute(CommandContext context, CancellationToken cancellation) + { + var title = configuration["App:Title"]; + var version = configuration.GetValue("App:Version"); + var features = configuration.GetSection("App:Features").Get() ?? []; + + console.MarkupLineInterpolated($"[bold]{title}[/] v[yellow]{version}[/]"); + console.MarkupLineInterpolated($"Features: [green]{string.Join(", ", features)}[/]"); + return 0; + } +} +``` + + ### Samples: For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, please review the following samples: @@ -190,6 +222,7 @@ For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, plea * [NoDI.Cli](samples/NoDI.Cli) - Use the CommandAppBuilder to configure a console app that does not use a DI framework. * [InteractivePrompt.Cli](samples/InteractivePrompt.Cli) - Create an interactive prompt that can run other registered commands while remaining in the prompt. * [Logging.Cli](samples/Logging.Cli) - Use WithLogging to enable verbosity-aware, Spectre-rendered logging and inject an ILogger<T> into a command. +* [Configuration.Cli](samples/Configuration.Cli) - Use WithConfiguration and WithOptions<T> to bind configuration and inject IOptions<T> into a command. ### Testing Infrastructure This library also provides testing classes that help in building your CommandApp unit tests. Using the CommandAppTestContext allows you to easily configure and run commands in isolation. diff --git a/samples/Configuration.Cli/Configuration.Cli.csproj b/samples/Configuration.Cli/Configuration.Cli.csproj new file mode 100644 index 0000000..dfceec9 --- /dev/null +++ b/samples/Configuration.Cli/Configuration.Cli.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + + + + + + + + + PreserveNewest + + + + diff --git a/samples/Configuration.Cli/GreetCommand.cs b/samples/Configuration.Cli/GreetCommand.cs new file mode 100644 index 0000000..591ac85 --- /dev/null +++ b/samples/Configuration.Cli/GreetCommand.cs @@ -0,0 +1,48 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Options; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace Configuration.Cli; + +internal sealed class GreetCommand(IOptions options, IAnsiConsole console) + : Command +{ + private readonly GreetingOptions _options = (options ?? throw new ArgumentNullException(nameof(options))).Value; + private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "[NAME]")] + [Description("The name to greet.")] + [DefaultValue("world")] + public string Name { get; set; } = "world"; + + [CommandOption("-r|--repeat ")] + [Description("How many times to repeat the greeting. When not specified, the configured MaxRepeat value is used.")] + [DefaultValue(0)] + public int Repeat { get; set; } + } + + protected override int Execute( + [NotNull] CommandContext context, + [NotNull] Settings settings, + CancellationToken cancellation) + { + // GreetingOptions is bound from appsettings.json and validated via data annotations. + // Command-line Settings remain separate from configuration-driven options. When no + // --repeat override is supplied, the configured MaxRepeat value drives the count. + var repeat = settings.Repeat > 0 ? settings.Repeat : _options.MaxRepeat; + + for (var i = 0; i < repeat; i++) + { + _console.MarkupLineInterpolated($"[green]{_options.Message}[/], [yellow]{settings.Name}[/]{_options.Punctuation}"); + } + + return 0; + } +} diff --git a/samples/Configuration.Cli/GreetingOptions.cs b/samples/Configuration.Cli/GreetingOptions.cs new file mode 100644 index 0000000..0af108c --- /dev/null +++ b/samples/Configuration.Cli/GreetingOptions.cs @@ -0,0 +1,20 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using System.ComponentModel.DataAnnotations; + +namespace Configuration.Cli; + +internal sealed class GreetingOptions +{ + public const string SectionName = "Greeting"; + + [Required] + [MinLength(1)] + public string Message { get; set; } = string.Empty; + + public string Punctuation { get; set; } = "!"; + + [Range(1, 10)] + public int MaxRepeat { get; set; } = 1; +} diff --git a/samples/Configuration.Cli/InfoCommand.cs b/samples/Configuration.Cli/InfoCommand.cs new file mode 100644 index 0000000..ce0b1f8 --- /dev/null +++ b/samples/Configuration.Cli/InfoCommand.cs @@ -0,0 +1,36 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Spectre.Console.Cli; +using System.Diagnostics.CodeAnalysis; + +namespace Configuration.Cli; + +internal sealed class InfoCommand(IConfiguration configuration, IAnsiConsole console) : Command +{ + private readonly IConfiguration _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); + + public sealed class Settings : CommandSettings { } + + protected override int Execute( + [NotNull] CommandContext context, + [NotNull] Settings settings, + CancellationToken cancellation) + { + // Instead of binding to a strongly typed options class, this command reads values + // directly through the IConfiguration interface that WithConfiguration registered in + // the container. Individual keys are read with indexer/GetValue, and a whole section + // can be accessed with GetSection. + var title = _configuration["App:Title"] ?? "(unknown)"; + var version = _configuration.GetValue("App:Version") ?? "(unknown)"; + var features = _configuration.GetSection("App:Features").Get() ?? []; + + _console.MarkupLineInterpolated($"[bold]{title}[/] v[yellow]{version}[/]"); + _console.MarkupLineInterpolated($"Features: [green]{string.Join(", ", features)}[/]"); + + return 0; + } +} diff --git a/samples/Configuration.Cli/Program.cs b/samples/Configuration.Cli/Program.cs new file mode 100644 index 0000000..2b5701a --- /dev/null +++ b/samples/Configuration.Cli/Program.cs @@ -0,0 +1,20 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Configuration.Cli; +using D20Tek.Spectre.Console.Extensions; +using D20Tek.Spectre.Console.Extensions.Configuration; + +// WithConfiguration builds an IConfiguration from appsettings.json plus environment +// variables and registers it in the container. WithOptions binds the "Greeting" section +// to a strongly typed GreetingOptions class (validated via data annotations) so it can be +// injected as IOptions. Configuration values stay separate from the +// command-line CommandSettings. +return await new CommandAppBuilder() + .WithDIContainer() + .WithConfiguration() + .WithOptions(GreetingOptions.SectionName) + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); diff --git a/samples/Configuration.Cli/Properties/launchSettings.json b/samples/Configuration.Cli/Properties/launchSettings.json new file mode 100644 index 0000000..d89c523 --- /dev/null +++ b/samples/Configuration.Cli/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "profiles": { + "Configuration.Cli": { + "commandName": "Project", + "commandLineArgs": "greet Bob --repeat 2" + }, + "Configuration.Cli blank": { + "commandName": "Project", + "commandLineArgs": "greet John" + }, + "Configuration.Cli info": { + "commandName": "Project", + "commandLineArgs": "info" + } + } +} diff --git a/samples/Configuration.Cli/Startup.cs b/samples/Configuration.Cli/Startup.cs new file mode 100644 index 0000000..34e7e38 --- /dev/null +++ b/samples/Configuration.Cli/Startup.cs @@ -0,0 +1,35 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions; +using Spectre.Console.Cli; + +namespace Configuration.Cli; + +internal sealed class Startup : StartupBase +{ + public override void ConfigureServices(ITypeRegistrar registrar) + { + // Configuration and options are wired up through the CommandAppBuilder using + // WithConfiguration and WithOptions in Program.cs, which makes IConfiguration + // and IOptions available for injection into any command. + } + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.CaseSensitivity(CaseSensitivity.None); + config.SetApplicationName("Configuration.Cli"); + config.ValidateExamples(); + + config.AddCommand("greet") + .WithDescription("Greets the given name using messages bound from configuration.") + .WithExample(["greet", "Linus"]) + .WithExample(["greet", "Linus", "--repeat", "2"]); + + config.AddCommand("info") + .WithDescription("Shows app metadata read directly through the IConfiguration interface.") + .WithExample(["info"]); + + return config; + } +} diff --git a/samples/Configuration.Cli/appsettings.json b/samples/Configuration.Cli/appsettings.json new file mode 100644 index 0000000..d9fd912 --- /dev/null +++ b/samples/Configuration.Cli/appsettings.json @@ -0,0 +1,13 @@ +{ + "Greeting": { + "Message": "Hello", + "Punctuation": "!", + "MaxRepeat": 3 + }, + "App": { + "Title": "Configuration Sample CLI", + "Version": "1.0.0", + "Features": [ "Configuration", "Options" ] + } +} + From aa24f2c8c44ea5093193d2fb5302f60aad4442ba Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 23:24:15 -0700 Subject: [PATCH 06/10] Implement new package for support generic host integration with Spectre.Console.Cli... with a CommandAppBuilder pattern. Added HostTypeRegistrar and HostTypeResolver to manage the host's service collection and Spectre registered types. Implemented unit tests. Added GenericHost.Cli sample app. --- .plans/future-features.md | 30 ++- CHANGELOG.md | 2 + .../CompositeServiceProvider.cs | 41 +++++ ....Spectre.Console.Extensions.Hosting.csproj | 43 +++++ .../HostCommandAppBuilder.cs | 113 ++++++++++++ .../HostCommandAppExtensions.cs | 89 +++++++++ .../HostRegistration.cs | 100 ++++++++++ .../HostTypeRegistrar.cs | 87 +++++++++ .../HostTypeResolver.cs | 43 +++++ .../README.md | 174 ++++++++++++++++++ ...pectre.Console.Extensions.UnitTests.csproj | 1 + .../Hosting/CompositeServiceProviderTests.cs | 61 ++++++ .../Hosting/Fakes/DependentService.cs | 20 ++ .../Hosting/Fakes/GreetCommand.cs | 19 ++ .../Hosting/Fakes/GreetingService.cs | 14 ++ .../Hosting/HostCommandAppBuilderTests.cs | 161 ++++++++++++++++ .../Hosting/HostCommandAppExtensionsTests.cs | 160 ++++++++++++++++ .../Hosting/HostRegistrationTests.cs | 96 ++++++++++ .../Hosting/HostTypeRegistrarTests.cs | 172 +++++++++++++++++ .../Hosting/HostTypeResolverTests.cs | 169 +++++++++++++++++ D20Tek.Spectre.Console.Extensions.sln | 13 ++ Directory.Packages.props | 1 + README.md | 5 + .../GenericHost.Cli/GenericHost.Cli.csproj | 19 ++ samples/GenericHost.Cli/GreetCommand.cs | 54 ++++++ samples/GenericHost.Cli/GreetingOptions.cs | 15 ++ samples/GenericHost.Cli/Program.cs | 42 +++++ samples/GenericHost.Cli/appsettings.json | 7 + 28 files changed, 1743 insertions(+), 8 deletions(-) create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/CompositeServiceProvider.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostRegistration.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostTypeRegistrar.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostTypeResolver.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/README.md create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/CompositeServiceProviderTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/DependentService.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetCommand.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetingService.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppBuilderTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostRegistrationTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeRegistrarTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeResolverTests.cs create mode 100644 samples/GenericHost.Cli/GenericHost.Cli.csproj create mode 100644 samples/GenericHost.Cli/GreetCommand.cs create mode 100644 samples/GenericHost.Cli/GreetingOptions.cs create mode 100644 samples/GenericHost.Cli/Program.cs create mode 100644 samples/GenericHost.Cli/appsettings.json diff --git a/.plans/future-features.md b/.plans/future-features.md index f012b13..7b85113 100644 --- a/.plans/future-features.md +++ b/.plans/future-features.md @@ -23,7 +23,7 @@ The library's strongest, genuinely differentiating areas are: - Spectre.Console coverage: None. Spectre.Console.Cli does not ship ILogger wiring. - Important clarification: Basic logger injection already works today with no new code. The DependencyInjectionTypeRegistrar exposes the underlying IServiceCollection via its Services property, and the resolver forwards to IServiceProvider.GetService. A consumer can already call registrar.WithLifetimes().Services.AddLogging(...) in ConfigureServices, and any command can then inject ILogger through its constructor. This feature is therefore about verbosity integration, Spectre-rendered output, and a fluent builder hook, not about enabling injection. -### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) - DONE +### 2. Configuration and Options Binding (Microsoft.Extensions.Configuration) [DONE] - What it adds: A new separate package (D20Tek.Spectre.Console.Extensions.Configuration) that wires Microsoft.Extensions.Configuration and Options into the CommandAppBuilder. Two builder hooks: - WithConfiguration(...): builds an IConfiguration (appsettings.json plus environment variables by default, with an optional configure delegate) and registers it in the container. - WithOptions<T>(sectionName): binds a configuration section to a strongly typed options class, resolvable as IOptions<T>. @@ -34,7 +34,20 @@ The library's strongest, genuinely differentiating areas are: - Implementation note: The builder hooks need access to the container. DONE - CommandAppBuilder now exposes a public ITypeRegistrar? Registrar getter and a public GetServiceCollection() helper that returns the registrar's IServiceCollection (throwing if no DI container is configured). Add-on extension packages (logging, configuration, and future ones) should call GetServiceCollection() rather than reaching through WithLifetimes().Services. The existing WithLogging hook was refactored to use this accessor. - Status: DONE - Package implemented with WithConfiguration and WithOptions<T> (data-annotation validated), covered by unit tests, and demonstrated by the Configuration.Cli sample. -### 3. Additional Prompt Controls +### 3. Generic Host Integration (Microsoft.Extensions.Hosting) [DONE] +- What it adds: A new separate package (D20Tek.Spectre.Console.Extensions.Hosting) that bridges the .NET Generic Host (HostApplicationBuilder / IHostBuilder) to Spectre.Console.Cli. Consumers configure configuration, options, logging, and services through the standard host model, then run a CommandApp whose types resolve from the host's already-built IServiceProvider. This is a sibling to CommandAppBuilder for teams that want the full .NET app model. +- Why it matters: Generic Host is the standard .NET app-composition model and unlocks hosted services, host lifetime, and the layered configuration/logging defaults with no bespoke wiring. It complements the existing lean CommandAppBuilder rather than replacing it. +- Spectre.Console coverage: None. Spectre.Console.Cli does not ship Generic Host wiring. +- Packaging decision: Separate package that references the core package (reuses the builder pattern and DI-bridge conventions, consistent with the MoreContainers and Configuration splits). Adds a Microsoft.Extensions.Hosting dependency, so it stays out of the core package. +- Key design point: Spectre registers its own types (command types, IAnsiConsole, its config) at Run(), which is after host.Build(). Because the host provider is already immutable by then, the bridge does not try to mutate it. Instead: + - HostTypeRegistrar accepts Spectre's run-time Register/RegisterInstance/RegisterLazy calls into an internal registration map (it does not throw after build). + - HostTypeResolver fuses the two sources: it first tries host.Services.GetService(type); if that is null and the type is in the map, it constructs the instance with ActivatorUtilities.CreateInstance(host.Services, impl) so command constructor dependencies (IOptions, ILogger, IConfiguration, user services) are injected from the host provider. Instance and factory registrations are honored directly from the map. +- API surface: Both a low-level path (HostTypeRegistrar plus an IHost.RunCommandAppAsync(args, configure) extension) and a fluent HostCommandAppBuilder that mirrors CommandAppBuilder (WithDefaultCommand, ConfigureCommands, Build/RunAsync). +- Sample: A GenericHost.Cli sample using HostApplicationBuilder as the active code path, with the equivalent IHostBuilder (Host.CreateDefaultBuilder) style shown as comments in Program.cs. +- Deliverables: New package project, HostTypeResolver, HostTypeRegistrar, HostCommandAppExtensions, HostCommandAppBuilder, exhaustive unit tests with fakes, the GenericHost.Cli sample, and README / CHANGELOG / future-features updates. +- Status: DONE - Package implemented with HostTypeRegistrar, HostTypeResolver, HostRegistration, HostCommandAppExtensions, and the HostCommandAppBuilder fluent builder. Covered by exhaustive unit tests (48 tests, all passing) and demonstrated by the GenericHost.Cli sample. README and CHANGELOG updated. + +### 4. Additional Prompt Controls Round out the "Controls" story with a themed family of culture-aware, validated prompts that follow the existing CurrencyPrompt pattern (IPrompt plus IHasCulture, with a validator and presenter split). - DatePrompt / DateRangePrompt: Culture-aware date entry with format hints and range validation. @@ -46,29 +59,29 @@ Round out the "Controls" story with a themed family of culture-aware, validated ## Tier 2 - Minor Value-Add -### 4. CompositeCommandInterceptor +### 5. CompositeCommandInterceptor - What it adds: A helper that composes multiple ICommandInterceptor instances into a chain (for example, timing plus logging plus telemetry). - Why it matters: Spectre.Console.Cli's SetInterceptor registers a single interceptor. Composing several currently requires custom code. - Spectre.Console coverage: The interceptor mechanism (ICommandInterceptor, SetInterceptor) already exists. Only the multi-interceptor composition is additive, and the value is modest. -### 5. Async Cancellation Ergonomics +### 6. Async Cancellation Ergonomics - What it adds: Out-of-the-box Ctrl+C wiring (Console.CancelKeyPress linked to a CancellationToken) provided through the CommandAppBuilder so long-running commands cancel cleanly. - Why it matters: InteractiveCommandBase already accepts a CancellationToken. Providing the cancellation plumbing by default removes boilerplate. - Spectre.Console coverage: Cancellation tokens are supported, but the default Ctrl+C linkage is left to the consumer. ## Tier 3 - Polish for a 1.0 Feel -### 6. Fluent Assertions for Testing +### 7. Fluent Assertions for Testing - What it adds: A fluent assertion helper set over CommandAppResult, for example result.ShouldSucceed().AndOutputContains(...). - Why it matters: Complements the differentiating testing infrastructure and improves the test authoring experience. - Spectre.Console coverage: None. -### 7. Command Registration Analyzer or Source Generator (stretch) +### 8. Command Registration Analyzer or Source Generator (stretch) - What it adds: Auto-discovery of ICommandConfiguration and commands via attributes to reduce startup wiring. - Why it matters: Cuts boilerplate for larger command sets. - Spectre.Console coverage: None. This is a larger investment and is intentionally a stretch goal. -### 8. Documentation and Changelog Parity +### 9. Documentation and Changelog Parity - What it adds: An api-reference documentation set under docs/ to complement the existing CHANGELOG.md. - Why it matters: Contributor guidelines require both api-reference docs and changelog entries whenever the public API changes. A public launch should include this structure. The repository now has a CHANGELOG.md following the Keep a Changelog format, but still lacks a docs/ folder. @@ -77,6 +90,7 @@ Round out the "Controls" story with a themed family of culture-aware, validated For the initial public release, prioritize the items that fill genuine gaps and extend existing strengths: 1. Verbosity-aware logging integration (verbosity bridge, Spectre-rendered output, and a builder hook; note that basic logger injection already works today). 2. Configuration and options binding. -3. One or two new prompt controls, starting with DatePrompt, then PathPrompt. +3. Generic Host integration (a sibling to CommandAppBuilder for teams that want the full .NET app model). +4. One or two new prompt controls, starting with DatePrompt, then PathPrompt. This produces a coherent launch narrative: a complete toolkit for building, configuring, testing, and polishing Spectre.Console CLI apps. diff --git a/CHANGELOG.md b/CHANGELOG.md index e6fe8ce..b1ecf6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `Logging.Cli` sample that demonstrates enabling verbosity-aware logging with `WithLogging` and injecting `ILogger` into a command. - New `D20Tek.Spectre.Console.Extensions.Configuration` package that adds Microsoft.Extensions.Configuration and Options binding to the builder. New public API includes `ConfigurationCommandAppBuilderExtensions.WithConfiguration` and `ConfigurationCommandAppBuilderExtensions.WithOptions`. - New `Configuration.Cli` sample that demonstrates binding configuration with `WithConfiguration` and injecting `IOptions` bound via `WithOptions` into a command. +- New `D20Tek.Spectre.Console.Extensions.Hosting` package that bridges Spectre.Console.Cli to the .NET Generic Host (`Microsoft.Extensions.Hosting`). New public API includes `HostCommandAppExtensions.RunCommandAppAsync`, `HostCommandAppExtensions.RunCommandApp`, `HostCommandAppExtensions.CreateCommandApp`, `HostCommandAppExtensions.CreateCommandAppBuilder`, and the `HostCommandAppBuilder` fluent builder. Run-time registrations captured from Spectre resolve through a composite provider, so a Spectre-registered type can depend on another Spectre-registered type while host services still take precedence. +- New `GenericHost.Cli` sample that demonstrates bridging Spectre.Console.Cli to the .NET Generic Host so command types resolve from the host's service provider. ### Changed - Upgraded Spectre dependencies to latest version 0.57.2. diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/CompositeServiceProvider.cs b/D20Tek.Spectre.Console.Extensions.Hosting/CompositeServiceProvider.cs new file mode 100644 index 0000000..968822d --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/CompositeServiceProvider.cs @@ -0,0 +1,41 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// An that fuses the host's already-built provider with the +/// run-time registrations captured by a . Host-owned services +/// are resolved first; otherwise a captured is used. +/// +/// +/// Design note: this composite is what hands to +/// when constructing +/// an implementation type. Passing the raw host provider would only resolve constructor +/// dependencies that the host knows about, so a Spectre-registered type that depends on another +/// Spectre-registered type would fail. Resolving through this composite lets those chained +/// run-time registrations satisfy each other while host services still take precedence. +/// +internal sealed class CompositeServiceProvider( + IServiceProvider hostProvider, + IReadOnlyDictionary registrations) : IServiceProvider +{ + private readonly IServiceProvider _hostProvider = hostProvider; + private readonly IReadOnlyDictionary _registrations = registrations; + + public object? GetService(Type serviceType) + { + var fromHost = _hostProvider.GetService(serviceType); + if (fromHost is not null) + { + return fromHost; + } + + if (_registrations.TryGetValue(serviceType, out var registration)) + { + return registration.Resolve(this); + } + + return null; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj new file mode 100644 index 0000000..36bd02e --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj @@ -0,0 +1,43 @@ + + + + net9.0;net10.0 + True + Spectre.Console Generic Host Extensions + 1.2.1 + d20Tek + d20Tek + Extensions for common code and patterns when using Spectre.Console CLI app framework. + +The current release bridges the .NET Generic Host (HostApplicationBuilder / IHostBuilder) to Spectre.Console.Cli, so CLI commands resolve from the host's already-built IServiceProvider and can inject IConfiguration, IOptions<T>, ILogger<T>, and any hosted services. It provides a low-level IHost.RunCommandAppAsync extension and a fluent HostCommandAppBuilder. This capability lives in a separate package to keep the core package's dependencies minimal. + Copyright (c) d20Tek. + https://github.com/d20Tek/Spectre.Console.Extensions + README.md + https://github.com/d20Tek/Spectre.Console.Extensions + git + Spectre; Spectre.Console; CLI; hosting; generic host; IHost; HostApplicationBuilder; dependency injection + Added .NET Generic Host integration for Spectre.Console.Cli in a separate package to keep the core package's dependencies minimal. + MIT + True + + + + + + + + + True + \ + + + + + + + + + + + + diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs new file mode 100644 index 0000000..2e5db61 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs @@ -0,0 +1,113 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Hosting; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Fluent builder that creates a Spectre.Console.Cli bridged to an +/// already-built .NET Generic Host. It mirrors the ergonomics of CommandAppBuilder while +/// letting the host own configuration, options, logging, services, and lifetime. Command types +/// resolve from the host's via a . +/// +public sealed class HostCommandAppBuilder +{ + private readonly IHost _host; + private Action? _configureCommands; + private Action? _setDefaultCommand; + + internal CommandApp? App { get; private set; } + + /// + /// Constructor that takes the built host used to resolve command types. + /// + /// The built host whose service provider resolves command types. + /// When host is null. + public HostCommandAppBuilder(IHost host) + { + ArgumentNullException.ThrowIfNull(host, nameof(host)); + _host = host; + } + + /// + /// Gets the host associated with this builder. + /// + public IHost Host => _host; + + /// + /// Sets the default command to run when no command name is specified. + /// + /// The default command type. + /// Returns the HostCommandAppBuilder. + public HostCommandAppBuilder WithDefaultCommand() where TDefault : class, ICommand + { + _setDefaultCommand = app => app.SetDefaultCommand(); + return this; + } + + /// + /// Configures the CommandApp's commands. + /// + /// Delegate to configure commands. + /// Returns the HostCommandAppBuilder. + /// When configure is null. + public HostCommandAppBuilder ConfigureCommands(Action configure) + { + ArgumentNullException.ThrowIfNull(configure, nameof(configure)); + _configureCommands = configure; + return this; + } + + /// + /// Builds the CommandApp bridged to the host, applying the default command and command + /// configuration specified on this builder. + /// + /// Returns the HostCommandAppBuilder. + public HostCommandAppBuilder Build() + { + var registrar = new HostTypeRegistrar(_host.Services); + var app = new CommandApp(registrar); + + _setDefaultCommand?.Invoke(app); + + if (_configureCommands is not null) + { + app.Configure(_configureCommands); + } + + App = app; + return this; + } + + /// + /// Runs the built CommandApp asynchronously. Calls automatically when + /// the app has not yet been built. + /// + /// Command line arguments to run with. + /// The application's exit code. + /// When args is null. + public Task RunAsync(string[] args) + { + ArgumentNullException.ThrowIfNull(args, nameof(args)); + + App ??= Build().App; + return App!.RunAsync(args); + } + + /// + /// Runs the built CommandApp synchronously. Calls automatically when + /// the app has not yet been built. + /// + /// Command line arguments to run with. + /// The application's exit code. + /// When args is null. + public int Run(string[] args) + { + ArgumentNullException.ThrowIfNull(args, nameof(args)); + + App ??= Build().App; + return App!.Run(args); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs new file mode 100644 index 0000000..e6ac073 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs @@ -0,0 +1,89 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Hosting; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Extension methods that run a Spectre.Console.Cli using an +/// already-built .NET Generic Host. Command types resolve from the host's +/// , so they can inject configuration, options, logging, and any +/// hosted services registered with the host. +/// +public static class HostCommandAppExtensions +{ + /// + /// Creates a bridged to the host and runs it asynchronously. + /// + /// The built host whose service provider resolves command types. + /// Command line arguments to run with. + /// Delegate to configure the CommandApp's commands. + /// The application's exit code. + /// When host, args, or configure is null. + public static Task RunCommandAppAsync( + this IHost host, + string[] args, + Action configure) + { + ArgumentNullException.ThrowIfNull(host, nameof(host)); + ArgumentNullException.ThrowIfNull(args, nameof(args)); + ArgumentNullException.ThrowIfNull(configure, nameof(configure)); + + var app = host.CreateCommandApp(configure); + return app.RunAsync(args); + } + + /// + /// Creates a bridged to the host and runs it synchronously. + /// + /// The built host whose service provider resolves command types. + /// Command line arguments to run with. + /// Delegate to configure the CommandApp's commands. + /// The application's exit code. + /// When host, args, or configure is null. + public static int RunCommandApp( + this IHost host, + string[] args, + Action configure) + { + ArgumentNullException.ThrowIfNull(host, nameof(host)); + ArgumentNullException.ThrowIfNull(args, nameof(args)); + ArgumentNullException.ThrowIfNull(configure, nameof(configure)); + + var app = host.CreateCommandApp(configure); + return app.Run(args); + } + + /// + /// Creates a bridged to the host using a + /// , applying the supplied command configuration. + /// + /// The built host whose service provider resolves command types. + /// Delegate to configure the CommandApp's commands. + /// The configured CommandApp. + /// When host or configure is null. + public static CommandApp CreateCommandApp(this IHost host, Action configure) + { + ArgumentNullException.ThrowIfNull(host, nameof(host)); + ArgumentNullException.ThrowIfNull(configure, nameof(configure)); + + var registrar = new HostTypeRegistrar(host.Services); + var app = new CommandApp(registrar); + app.Configure(configure); + return app; + } + + /// + /// Creates a fluent bridged to the host. + /// + /// The built host whose service provider resolves command types. + /// A new . + /// When host is null. + public static HostCommandAppBuilder CreateCommandAppBuilder(this IHost host) + { + ArgumentNullException.ThrowIfNull(host, nameof(host)); + return new HostCommandAppBuilder(host); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostRegistration.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostRegistration.cs new file mode 100644 index 0000000..7f3f1df --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostRegistration.cs @@ -0,0 +1,100 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Represents a single run-time registration captured by . +/// A registration is one of three kinds: an implementation type constructed on demand, a +/// pre-built instance, or a factory delegate. Implementation types are created with +/// so their constructor dependencies are injected from the +/// host provider. +/// +/// +/// Design note: this type exists instead of a secondary / +/// because Spectre.Console.Cli performs its registrations at +/// run time, after the host has already been built. By then the host's provider is immutable, +/// so there is no collection left to add to and rebuild. More importantly, command types that +/// Spectre registers (for example a command that injects IOptions<T>, ILogger<T>, +/// IConfiguration, or user services) must be constructed from the host's provider so those +/// dependencies resolve. A separate would be an isolated +/// container that cannot see the host's registrations, would create duplicate singletons with +/// independent lifetimes, and would own its own disposal. This lightweight capture keeps the +/// host provider as the single source of truth for lifetimes: defers +/// construction to +/// against the host provider, while and +/// return the caller-supplied object directly. +/// +public sealed class HostRegistration +{ + private readonly Type? _implementationType; + private readonly object? _instance; + private readonly Func? _factory; + + private HostRegistration(Type? implementationType, object? instance, Func? factory) + { + _implementationType = implementationType; + _instance = instance; + _factory = factory; + } + + /// + /// Creates a registration for an implementation type that is constructed on demand. + /// + /// The concrete implementation type to construct. + /// A new . + public static HostRegistration ForType(Type implementationType) + { + ArgumentNullException.ThrowIfNull(implementationType, nameof(implementationType)); + return new HostRegistration(implementationType, instance: null, factory: null); + } + + /// + /// Creates a registration for a pre-built instance. + /// + /// The instance to return on resolution. + /// A new . + public static HostRegistration ForInstance(object instance) + { + ArgumentNullException.ThrowIfNull(instance, nameof(instance)); + return new HostRegistration(implementationType: null, instance, factory: null); + } + + /// + /// Creates a registration for a factory delegate that produces the instance lazily. + /// + /// The factory that creates the instance. + /// A new . + public static HostRegistration ForFactory(Func factory) + { + ArgumentNullException.ThrowIfNull(factory, nameof(factory)); + return new HostRegistration(implementationType: null, instance: null, factory); + } + + /// + /// Resolves the registration against the supplied provider. When constructing an + /// implementation type, the provider is expected to be a composite that fuses the host + /// provider with the captured registrations, so constructor dependencies on other run-time + /// registrations are also satisfied. + /// + /// The provider used to construct implementation types. + /// The resolved instance. + public object Resolve(IServiceProvider provider) + { + ArgumentNullException.ThrowIfNull(provider, nameof(provider)); + + if (_instance is not null) + { + return _instance; + } + + if (_factory is not null) + { + return _factory(); + } + + return ActivatorUtilities.CreateInstance(provider, _implementationType!); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeRegistrar.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeRegistrar.cs new file mode 100644 index 0000000..f207b75 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeRegistrar.cs @@ -0,0 +1,87 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Type registrar for Spectre.Console that bridges an already-built Generic Host +/// to Spectre.Console.Cli. Because the host provider is +/// immutable once the host is built, this registrar captures the run-time registrations that +/// Spectre.Console.Cli performs (for example command types) into an internal map instead of +/// mutating the provider. The paired fuses the map with the +/// host provider so command constructor dependencies are injected from the host. +/// +/// +/// Design note: a secondary +/// was intentionally avoided. Spectre registers its types after host.Build(), when the +/// host provider is already immutable, and its command types depend on host-owned services +/// (options, logging, configuration, user services). Building a separate provider would create +/// an isolated container that cannot resolve those host dependencies and would duplicate +/// singleton lifetimes and disposal. Instead each registration is captured as a +/// and resolved lazily against the host provider, keeping the +/// host as the single container. +/// +public sealed class HostTypeRegistrar : ITypeRegistrar +{ + private readonly IServiceProvider _provider; + private readonly Dictionary _registrations = []; + + /// + /// Constructor that takes the host's already-built service provider. + /// + /// The host service provider used for resolution. + public HostTypeRegistrar(IServiceProvider provider) + { + ArgumentNullException.ThrowIfNull(provider, nameof(provider)); + _provider = provider; + } + + /// + /// Builds the type resolver representing the host provider fused with the captured + /// run-time registrations. + /// + /// A type resolver. + public ITypeResolver Build() => new HostTypeResolver(_provider, _registrations); + + /// + /// Registers the specified service and implementation type. The implementation is + /// constructed on demand from the host provider when resolved. + /// + /// The service type. + /// The implementation type. + public void Register(Type service, Type implementation) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(implementation, nameof(implementation)); + + _registrations[service] = HostRegistration.ForType(implementation); + } + + /// + /// Registers the specified pre-built instance for a service type. + /// + /// The service type. + /// The instance. + public void RegisterInstance(Type service, object implementation) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(implementation, nameof(implementation)); + + _registrations[service] = HostRegistration.ForInstance(implementation); + } + + /// + /// Registers the specified service using a factory delegate evaluated lazily. + /// + /// The service type. + /// The factory that creates the implementation. + public void RegisterLazy(Type service, Func factoryMethod) + { + ArgumentNullException.ThrowIfNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(factoryMethod, nameof(factoryMethod)); + + _registrations[service] = HostRegistration.ForFactory(factoryMethod); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeResolver.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeResolver.cs new file mode 100644 index 0000000..c940e91 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostTypeResolver.cs @@ -0,0 +1,43 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Type resolver for Spectre.Console that fuses an already-built Generic Host +/// with the run-time registrations captured by a +/// . Host-owned services are resolved directly from the +/// provider, while types registered by Spectre.Console.Cli at run time (for example command +/// types) are constructed with so their constructor +/// dependencies are injected from the host provider. +/// +public sealed class HostTypeResolver : ITypeResolver +{ + private readonly CompositeServiceProvider _composite; + + /// + /// Constructor that takes the host service provider and the captured registrations. + /// + /// The host's already-built service provider. + /// The registrations captured by the registrar. + public HostTypeResolver(IServiceProvider provider, IReadOnlyDictionary registrations) + { + ArgumentNullException.ThrowIfNull(provider, nameof(provider)); + ArgumentNullException.ThrowIfNull(registrations, nameof(registrations)); + + _composite = new CompositeServiceProvider(provider, registrations); + } + + /// + /// Resolves an instance of the specified type. Resolution is delegated to a composite + /// provider that resolves host-owned services first and otherwise constructs captured + /// run-time registrations with , so nested dependencies on + /// other run-time registrations are also satisfied. + /// + /// The type to resolve. + /// An instance of the specified type, or null if it cannot be resolved. + public object? Resolve(Type? type) => type is null ? null : _composite.GetService(type); +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/README.md b/D20Tek.Spectre.Console.Extensions.Hosting/README.md new file mode 100644 index 0000000..ef1dc62 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/README.md @@ -0,0 +1,174 @@ +# D20Tek.Spectre.Console.Extensions.Hosting + +`D20Tek.Spectre.Console.Extensions.Hosting` bridges [Spectre.Console.Cli](https://spectreconsole.net/cli/) to the .NET Generic Host (`Microsoft.Extensions.Hosting`). It lets the host own configuration, options binding, logging, hosted services, and application lifetime, while your command types resolve from the host's service provider. + +This is a separate package that references the core `D20Tek.Spectre.Console.Extensions` package. It keeps the `Microsoft.Extensions.Hosting` dependency out of the core package, consistent with the other add-on packages in this library. + +## Why use the Generic Host? + +The core library ships a lean `CommandAppBuilder` that is ideal for small, self-contained CLI tools. The Generic Host is the standard .NET app-composition model, and this package is a sibling path for teams that want the full .NET application stack: + +- Layered configuration defaults (`appsettings.json`, environment variables, command-line, user secrets) with no bespoke wiring. +- Options binding and validation through `IOptions`, `IOptionsSnapshot`, and `IOptionsMonitor`. +- Logging providers configured through `ILoggingBuilder`, injected as `ILogger`. +- Hosted services (`IHostedService`), host lifetime, and graceful shutdown. +- Reuse of existing service registrations shared with the rest of a larger application. + +It complements the core `CommandAppBuilder` rather than replacing it. + +## Installation + +```shell +dotnet add package D20Tek.Spectre.Console.Extensions.Hosting +``` + +## How it works + +Spectre.Console.Cli registers its own types (command types, `IAnsiConsole`, its configuration) through an `ITypeRegistrar` when the app runs, which happens *after* the host has already been built. Because the host's `IServiceProvider` is immutable at that point, this package does not try to mutate it. Instead: + +- `HostTypeRegistrar` accepts Spectre's run-time `Register` / `RegisterInstance` / `RegisterLazy` calls into an internal registration map. It does not throw after the host is built. +- `HostTypeResolver` fuses the two sources. It first tries `host.Services.GetService(type)`. If that returns `null` and the type is in the registration map, it constructs the instance with `ActivatorUtilities.CreateInstance(compositeProvider, implementationType)`, so command constructor dependencies (`IOptions`, `ILogger`, `IConfiguration`, and your own services) are injected from the host's provider. The composite provider resolves host services first and falls back to the registration map, so a Spectre-registered type can also depend on another Spectre-registered type. Instance and factory registrations are honored directly from the map. + +The result is that command types Spectre discovers at run time are still fully constructor-injected from the host container. + +## Usage + +### Fluent builder + +Build a standard host, then call `CreateCommandAppBuilder` to get a fluent builder that mirrors the core `CommandAppBuilder`: + +```csharp +using D20Tek.Spectre.Console.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Spectre.Console; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure( + builder.Configuration.GetSection(GreetingOptions.SectionName)); +builder.Services.AddSingleton(AnsiConsole.Console); + +var host = builder.Build(); + +return await host.CreateCommandAppBuilder() + .WithDefaultCommand() + .RunAsync(args); +``` + +You can also register named commands through `ConfigureCommands`: + +```csharp +return await host.CreateCommandAppBuilder() + .ConfigureCommands(config => + { + config.AddCommand("greet"); + config.AddCommand("bye"); + }) + .RunAsync(args); +``` + +`Build` is optional. `RunAsync` and `Run` call it automatically when the app has not been built yet, so you can call `Build` explicitly only when you want to inspect or reuse the configured app. + +### IHost extension methods + +For lower-level control, the package also provides extension methods directly on `IHost`: + +```csharp +using D20Tek.Spectre.Console.Extensions.Hosting; + +var host = Host.CreateApplicationBuilder(args).Build(); + +// Async +var exitCode = await host.RunCommandAppAsync( + args, + config => config.AddCommand("greet")); + +// Synchronous +var exitCode = host.RunCommandApp( + args, + config => config.AddCommand("greet")); + +// Create the CommandApp without running it +var app = host.CreateCommandApp(config => config.AddCommand("greet")); +``` + +### Using the IHostBuilder style + +The same wiring works with the classic `Host.CreateDefaultBuilder` / `IHostBuilder` model: + +```csharp +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + services.Configure( + context.Configuration.GetSection(GreetingOptions.SectionName)); + services.AddSingleton(AnsiConsole.Console); + }) + .Build(); + +return await host.CreateCommandAppBuilder() + .WithDefaultCommand() + .RunAsync(args); +``` + +### Injecting host services into a command + +Because command types resolve from the host's service provider, they can inject anything the host registered, exactly like a regular hosted class: + +```csharp +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; + +internal sealed class GreetCommand( + IOptions options, + IAnsiConsole console, + ILogger logger) + : Command +{ + private readonly GreetingOptions _options = options.Value; + private readonly IAnsiConsole _console = console; + private readonly ILogger _logger = logger; + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "[NAME]")] + [Description("The name to greet.")] + [DefaultValue("world")] + public string Name { get; set; } = "world"; + } + + protected override int Execute( + CommandContext context, + Settings settings, + CancellationToken cancellation) + { + _logger.LogInformation("Greeting {Name}.", settings.Name); + _console.MarkupLineInterpolated( + $"[green]{_options.Message}[/], [yellow]{settings.Name}[/]{_options.Punctuation}"); + return 0; + } +} +``` + +Command-line `CommandSettings` remain separate from host-driven configuration and options, so each command decides precedence explicitly. + +## Public API + +- `HostCommandAppExtensions.CreateCommandAppBuilder(this IHost)` - creates a fluent `HostCommandAppBuilder`. +- `HostCommandAppExtensions.CreateCommandApp(this IHost, Action)` - creates a configured `CommandApp` without running it. +- `HostCommandAppExtensions.RunCommandAppAsync(this IHost, string[], Action)` - creates and runs a `CommandApp` asynchronously. +- `HostCommandAppExtensions.RunCommandApp(this IHost, string[], Action)` - creates and runs a `CommandApp` synchronously. +- `HostCommandAppBuilder` - fluent builder with `WithDefaultCommand`, `ConfigureCommands`, `Build`, `RunAsync`, and `Run`. +- `HostTypeRegistrar` / `HostTypeResolver` / `HostRegistration` - the bridge types that capture Spectre's run-time registrations and resolve them from the host provider. + +## Sample + +See the [GenericHost.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/GenericHost.Cli) sample for a complete, runnable example that binds configuration, injects `IOptions`, `IAnsiConsole`, and `ILogger`, and runs a default command through the Generic Host. + +## Feedback + +If you have any feedback, questions, or issues, please open an issue on the [GitHub repository](https://github.com/d20Tek/Spectre.Console.Extensions). diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj index 3e1b84d..d75c079 100644 --- a/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/D20Tek.Spectre.Console.Extensions.UnitTests.csproj @@ -19,6 +19,7 @@ + diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/CompositeServiceProviderTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/CompositeServiceProviderTests.cs new file mode 100644 index 0000000..4b56727 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/CompositeServiceProviderTests.cs @@ -0,0 +1,61 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class CompositeServiceProviderTests +{ + [TestMethod] + public void GetService_WithHostRegisteredService_ResolvesFromHostProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var composite = new CompositeServiceProvider(provider, new Dictionary()); + + // Act + var result = composite.GetService(typeof(IGreetingService)); + + // Assert + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public void GetService_WithMappedService_ResolvesFromRegistrationMap() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var instance = new GreetingService(); + var registrations = new Dictionary + { + [typeof(IGreetingService)] = HostRegistration.ForInstance(instance), + }; + var composite = new CompositeServiceProvider(provider, registrations); + + // Act + var result = composite.GetService(typeof(IGreetingService)); + + // Assert + Assert.AreSame(instance, result); + } + + [TestMethod] + public void GetService_WithUnknownService_ReturnsNull() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var composite = new CompositeServiceProvider(provider, new Dictionary()); + + // Act + var result = composite.GetService(typeof(IGreetingService)); + + // Assert + Assert.IsNull(result); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/DependentService.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/DependentService.cs new file mode 100644 index 0000000..bef4efa --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/DependentService.cs @@ -0,0 +1,20 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; + +internal interface IDependentService +{ + string Describe(); +} + +[ExcludeFromCodeCoverage] +internal sealed class DependentService(IGreetingService greetingService) : IDependentService +{ + private readonly IGreetingService _greetingService = + greetingService ?? throw new ArgumentNullException(nameof(greetingService)); + + public string Describe() => _greetingService.Greet("dependency"); +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetCommand.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetCommand.cs new file mode 100644 index 0000000..48ed65d --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetCommand.cs @@ -0,0 +1,19 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Spectre.Console; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; + +internal sealed class GreetCommand(IGreetingService greetingService, IAnsiConsole console) : Command +{ + private readonly IGreetingService _greetingService = greetingService; + private readonly IAnsiConsole _console = console; + + protected override int Execute(CommandContext context, CancellationToken cancellation) + { + _console.WriteLine(_greetingService.Greet("World")); + return 0; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetingService.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetingService.cs new file mode 100644 index 0000000..0b56ff6 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetingService.cs @@ -0,0 +1,14 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; + +internal interface IGreetingService +{ + string Greet(string name); +} + +internal sealed class GreetingService : IGreetingService +{ + public string Greet(string name) => $"Hello, {name}!"; +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppBuilderTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppBuilderTests.cs new file mode 100644 index 0000000..92a6f54 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppBuilderTests.cs @@ -0,0 +1,161 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.Testing; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Spectre.Console; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostCommandAppBuilderTests +{ + private static IHost CreateHost(IAnsiConsole console) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(console); + return builder.Build(); + } + + [TestMethod] + public void Constructor_WithNullHost_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new HostCommandAppBuilder(null!)); + } + + [TestMethod] + public void Host_ReturnsProvidedHost() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act + var builder = new HostCommandAppBuilder(host); + + // Assert + Assert.AreSame(host, builder.Host); + } + + [TestMethod] + public void ConfigureCommands_WithNullConfigure_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + var builder = new HostCommandAppBuilder(host); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + builder.ConfigureCommands(null!)); + } + + [TestMethod] + public async Task RunAsync_WithNullArgs_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + var builder = new HostCommandAppBuilder(host); + + // Act - Assert + await Assert.ThrowsExactlyAsync( + [ExcludeFromCodeCoverage] () => builder.RunAsync(null!)); + } + + [TestMethod] + public void Run_WithNullArgs_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + var builder = new HostCommandAppBuilder(host); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + builder.Run(null!)); + } + + [TestMethod] + public void Build_ReturnsSameBuilderInstance() + { + // Arrange + using var host = CreateHost(new TestConsole()); + var builder = new HostCommandAppBuilder(host); + + // Act + var result = builder.WithDefaultCommand().Build(); + + // Assert + Assert.AreSame(builder, result); + } + + [TestMethod] + public async Task RunAsync_WithDefaultCommand_ResolvesFromHostAndSucceeds() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + var builder = new HostCommandAppBuilder(host).WithDefaultCommand(); + + // Act + var result = await builder.RunAsync([]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void Run_WithConfigureCommands_ResolvesFromHostAndSucceeds() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + var builder = new HostCommandAppBuilder(host) + .ConfigureCommands(config => config.AddCommand("greet")); + + // Act + var result = builder.Run(["greet"]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public async Task RunAsync_WithoutExplicitBuild_BuildsAutomatically() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + var builder = new HostCommandAppBuilder(host).WithDefaultCommand(); + + // Act + var result = await builder.RunAsync([]); + + // Assert + Assert.AreEqual(0, result); + } + + [TestMethod] + public void Run_AfterExplicitBuild_ReusesBuiltApp() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + var builder = new HostCommandAppBuilder(host) + .WithDefaultCommand() + .Build(); + + // Act + var result = builder.Run([]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppExtensionsTests.cs new file mode 100644 index 0000000..33a3444 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostCommandAppExtensionsTests.cs @@ -0,0 +1,160 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.Testing; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Spectre.Console; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostCommandAppExtensionsTests +{ + private static IHost CreateHost(IAnsiConsole console) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(console); + return builder.Build(); + } + + [TestMethod] + public void CreateCommandApp_WithNullHost_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + HostCommandAppExtensions.CreateCommandApp(null!, _ => { })); + } + + [TestMethod] + public void CreateCommandApp_WithNullConfigure_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + host.CreateCommandApp(null!)); + } + + [TestMethod] + public void CreateCommandAppBuilder_WithNullHost_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + HostCommandAppExtensions.CreateCommandAppBuilder(null!)); + } + + [TestMethod] + public void CreateCommandAppBuilder_WithHost_ReturnsBuilder() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act + var builder = host.CreateCommandAppBuilder(); + + // Assert + Assert.IsInstanceOfType(builder); + Assert.AreSame(host, builder.Host); + } + + [TestMethod] + public async Task RunCommandAppAsync_WithNullHost_ThrowsException() + { + // Arrange - Act - Assert + await Assert.ThrowsExactlyAsync( + [ExcludeFromCodeCoverage] () => + HostCommandAppExtensions.RunCommandAppAsync(null!, [], _ => { })); + } + + [TestMethod] + public async Task RunCommandAppAsync_WithNullArgs_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act - Assert + await Assert.ThrowsExactlyAsync( + [ExcludeFromCodeCoverage] () => host.RunCommandAppAsync(null!, [ExcludeFromCodeCoverage](_) => { })); + } + + [TestMethod] + public async Task RunCommandAppAsync_WithNullConfigure_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act - Assert + await Assert.ThrowsExactlyAsync( + [ExcludeFromCodeCoverage] () => host.RunCommandAppAsync([], null!)); + } + + [TestMethod] + public async Task RunCommandAppAsync_WithDefaultCommand_ResolvesFromHostAndSucceeds() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + + // Act + var result = await host.RunCommandAppAsync( + ["greet"], + config => config.AddCommand("greet")); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void RunCommandApp_WithNullHost_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + HostCommandAppExtensions.RunCommandApp(null!, [], _ => { })); + } + + [TestMethod] + public void RunCommandApp_WithNullArgs_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + host.RunCommandApp(null!, [ExcludeFromCodeCoverage](_) => { })); + } + + [TestMethod] + public void RunCommandApp_WithNullConfigure_ThrowsException() + { + // Arrange + using var host = CreateHost(new TestConsole()); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + host.RunCommandApp([], null!)); + } + + [TestMethod] + public void RunCommandApp_WithDefaultCommand_ResolvesFromHostAndSucceeds() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHost(console); + + // Act + var result = host.RunCommandApp( + ["greet"], + config => config.AddCommand("greet")); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostRegistrationTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostRegistrationTests.cs new file mode 100644 index 0000000..0c90126 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostRegistrationTests.cs @@ -0,0 +1,96 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostRegistrationTests +{ + [TestMethod] + public void ForType_WithNullType_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => HostRegistration.ForType(null!)); + } + + [TestMethod] + public void ForInstance_WithNullInstance_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => HostRegistration.ForInstance(null!)); + } + + [TestMethod] + public void ForFactory_WithNullFactory_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => HostRegistration.ForFactory(null!)); + } + + [TestMethod] + public void Resolve_WithNullProvider_ThrowsException() + { + // Arrange + var registration = HostRegistration.ForInstance(new GreetingService()); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => registration.Resolve(null!)); + } + + [TestMethod] + public void Resolve_WithInstanceRegistration_ReturnsSameInstance() + { + // Arrange + var instance = new GreetingService(); + var registration = HostRegistration.ForInstance(instance); + var provider = new ServiceCollection().BuildServiceProvider(); + + // Act + var result = registration.Resolve(provider); + + // Assert + Assert.AreSame(instance, result); + } + + [TestMethod] + public void Resolve_WithFactoryRegistration_InvokesFactory() + { + // Arrange + var instance = new GreetingService(); + var registration = HostRegistration.ForFactory(() => instance); + var provider = new ServiceCollection().BuildServiceProvider(); + + // Act + var result = registration.Resolve(provider); + + // Assert + Assert.AreSame(instance, result); + } + + [TestMethod] + public void Resolve_WithTypeRegistration_ConstructsUsingProviderDependencies() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var registration = HostRegistration.ForType(typeof(DependentService)); + + // Act + var result = registration.Resolve(provider); + + // Assert + Assert.IsInstanceOfType(result); + Assert.IsNotNull(((DependentService)result).GreetingService); + } + + private sealed class DependentService(IGreetingService greetingService) + { + public IGreetingService GreetingService { get; } = greetingService; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeRegistrarTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeRegistrarTests.cs new file mode 100644 index 0000000..4bddda3 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeRegistrarTests.cs @@ -0,0 +1,172 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostTypeRegistrarTests +{ + [TestMethod] + public void Constructor_WithNullProvider_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => new HostTypeRegistrar(null!)); + } + + [TestMethod] + public void Build_ReturnsHostTypeResolver() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act + var resolver = registrar.Build(); + + // Assert + Assert.IsInstanceOfType(resolver); + } + + [TestMethod] + public void Register_WithNullService_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.Register(null!, typeof(GreetingService))); + } + + [TestMethod] + public void Register_WithNullImplementation_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.Register(typeof(IGreetingService), null!)); + } + + [TestMethod] + public void RegisterInstance_WithNullService_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.RegisterInstance(null!, new GreetingService())); + } + + [TestMethod] + public void RegisterInstance_WithNullImplementation_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.RegisterInstance(typeof(IGreetingService), null!)); + } + + [TestMethod] + public void RegisterLazy_WithNullService_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.RegisterLazy(null!, [ExcludeFromCodeCoverage]() => new GreetingService())); + } + + [TestMethod] + public void RegisterLazy_WithNullFactory_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + registrar.RegisterLazy(typeof(IGreetingService), null!)); + } + + [TestMethod] + public void Register_ThenResolve_ResolvesRegisteredType() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + registrar.Register(typeof(IGreetingService), typeof(GreetingService)); + + // Act + var resolver = registrar.Build(); + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public void RegisterInstance_ThenResolve_ReturnsInstance() + { + // Arrange + var instance = new GreetingService(); + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + registrar.RegisterInstance(typeof(IGreetingService), instance); + + // Act + var resolver = registrar.Build(); + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.AreSame(instance, result); + } + + [TestMethod] + public void RegisterLazy_ThenResolve_InvokesFactory() + { + // Arrange + var instance = new GreetingService(); + var provider = new ServiceCollection().BuildServiceProvider(); + var registrar = new HostTypeRegistrar(provider); + registrar.RegisterLazy(typeof(IGreetingService), () => instance); + + // Act + var resolver = registrar.Build(); + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.AreSame(instance, result); + } + + [TestMethod] + public void ImplementsITypeRegistrar() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + + // Act + var registrar = new HostTypeRegistrar(provider); + + // Assert + Assert.IsInstanceOfType(registrar); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeResolverTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeResolverTests.cs new file mode 100644 index 0000000..479564b --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostTypeResolverTests.cs @@ -0,0 +1,169 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostTypeResolverTests +{ + [TestMethod] + public void Constructor_WithNullProvider_ThrowsException() + { + // Arrange + var registrations = new Dictionary(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new HostTypeResolver(null!, registrations)); + } + + [TestMethod] + public void Constructor_WithNullRegistrations_ThrowsException() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + + // Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + new HostTypeResolver(provider, null!)); + } + + [TestMethod] + public void Resolve_WithNullType_ReturnsNull() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var resolver = new HostTypeResolver(provider, new Dictionary()); + + // Act + var result = resolver.Resolve(null); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Resolve_WithHostRegisteredService_ResolvesFromProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var resolver = new HostTypeResolver(provider, new Dictionary()); + + // Act + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public void Resolve_WithMappedType_ConstructsFromHostProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new Extensions.Testing.TestConsole()); + var provider = services.BuildServiceProvider(); + var registrations = new Dictionary + { + [typeof(GreetCommand)] = HostRegistration.ForType(typeof(GreetCommand)), + }; + var resolver = new HostTypeResolver(provider, registrations); + + // Act + var result = resolver.Resolve(typeof(GreetCommand)); + + // Assert - GreetCommand depends on IGreetingService (host) and IAnsiConsole. + Assert.IsNotNull(result); + } + + [TestMethod] + public void Resolve_WithUnknownType_ReturnsNull() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var resolver = new HostTypeResolver(provider, new Dictionary()); + + // Act + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.IsNull(result); + } + + [TestMethod] + public void Resolve_PrefersHostProviderOverRegistrationMap() + { + // Arrange + var hostInstance = new GreetingService(); + var mapInstance = new GreetingService(); + var services = new ServiceCollection(); + services.AddSingleton(hostInstance); + var provider = services.BuildServiceProvider(); + var registrations = new Dictionary + { + [typeof(IGreetingService)] = HostRegistration.ForInstance(mapInstance), + }; + var resolver = new HostTypeResolver(provider, registrations); + + // Act + var result = resolver.Resolve(typeof(IGreetingService)); + + // Assert + Assert.AreSame(hostInstance, result); + } + + [TestMethod] + public void Resolve_WithMappedTypeDependingOnAnotherMappedType_ResolvesFromComposite() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + var registrations = new Dictionary + { + // DependentService depends on IGreetingService (host), and IDependentService is + // itself only in the registration map; the consumer type below depends on it. + [typeof(IDependentService)] = HostRegistration.ForType(typeof(DependentService)), + }; + var resolver = new HostTypeResolver(provider, registrations); + + // Act + var result = resolver.Resolve(typeof(IDependentService)); + + // Assert + Assert.IsInstanceOfType(result); + Assert.AreEqual("Hello, dependency!", ((IDependentService)result!).Describe()); + } + + [TestMethod] + public void Resolve_WithChainedMappedRegistrations_SatisfiesNestedMapDependency() + { + // Arrange + var provider = new ServiceCollection().BuildServiceProvider(); + var greeting = new GreetingService(); + var registrations = new Dictionary + { + // IGreetingService lives ONLY in the map (not in the host provider), and + // DependentService depends on it. This fails when ActivatorUtilities uses the raw + // host provider, and succeeds when it uses the composite provider. + [typeof(IGreetingService)] = HostRegistration.ForInstance(greeting), + [typeof(IDependentService)] = HostRegistration.ForType(typeof(DependentService)), + }; + var resolver = new HostTypeResolver(provider, registrations); + + // Act + var result = resolver.Resolve(typeof(IDependentService)); + + // Assert + Assert.IsInstanceOfType(result); + Assert.AreEqual("Hello, dependency!", ((IDependentService)result!).Describe()); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.sln b/D20Tek.Spectre.Console.Extensions.sln index 9847e4d..4c1d48a 100644 --- a/D20Tek.Spectre.Console.Extensions.sln +++ b/D20Tek.Spectre.Console.Extensions.sln @@ -42,12 +42,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "D20Tek.Spectre.Console.Exte EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "D20Tek.Spectre.Console.Extensions.Configuration", "D20Tek.Spectre.Console.Extensions.Configuration\D20Tek.Spectre.Console.Extensions.Configuration.csproj", "{D0530068-17BE-4379-95A3-4DBD21E7BA00}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "D20Tek.Spectre.Console.Extensions.Hosting", "D20Tek.Spectre.Console.Extensions.Hosting\D20Tek.Spectre.Console.Extensions.Hosting.csproj", "{65DF1E28-3B82-4636-87CB-9AFAD2B2673E}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InteractivePrompt.Cli", "samples\InteractivePrompt.Cli\InteractivePrompt.Cli.csproj", "{3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logging.Cli", "samples\Logging.Cli\Logging.Cli.csproj", "{9903BE21-C7A3-4D12-8919-6DCD04644544}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Configuration.Cli", "samples\Configuration.Cli\Configuration.Cli.csproj", "{17E61A95-44CC-4BCB-9504-A9C3336929B3}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GenericHost.Cli", "samples\GenericHost.Cli\GenericHost.Cli.csproj", "{A5B29380-384F-47DB-901C-FF6DD6C2E618}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -98,6 +102,10 @@ Global {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Debug|Any CPU.Build.0 = Debug|Any CPU {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0530068-17BE-4379-95A3-4DBD21E7BA00}.Release|Any CPU.Build.0 = Release|Any CPU + {65DF1E28-3B82-4636-87CB-9AFAD2B2673E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65DF1E28-3B82-4636-87CB-9AFAD2B2673E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65DF1E28-3B82-4636-87CB-9AFAD2B2673E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65DF1E28-3B82-4636-87CB-9AFAD2B2673E}.Release|Any CPU.Build.0 = Release|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Debug|Any CPU.Build.0 = Debug|Any CPU {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -110,6 +118,10 @@ Global {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {17E61A95-44CC-4BCB-9504-A9C3336929B3}.Release|Any CPU.Build.0 = Release|Any CPU + {A5B29380-384F-47DB-901C-FF6DD6C2E618}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5B29380-384F-47DB-901C-FF6DD6C2E618}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5B29380-384F-47DB-901C-FF6DD6C2E618}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5B29380-384F-47DB-901C-FF6DD6C2E618}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -126,6 +138,7 @@ Global {3F8EAAF6-6E7B-4AE0-B63A-D0900643E840} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {9903BE21-C7A3-4D12-8919-6DCD04644544} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} {17E61A95-44CC-4BCB-9504-A9C3336929B3} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} + {A5B29380-384F-47DB-901C-FF6DD6C2E618} = {7DADA67F-CBE3-4664-B544-7D0FEA8E5081} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7D1641E6-17C4-4467-946F-C057C45F4A62} diff --git a/Directory.Packages.props b/Directory.Packages.props index 4d8fba3..e507ce4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + diff --git a/README.md b/README.md index d5e2a81..c7cfa71 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ Note: Only Microsoft.Extensions.DependencyInjection is implemented in the core e This libraries are NuGet packages so they are easy to add to your project. To install these packages into your solution, you can use the NuGet Package Manager. In PM, please use the following command: ``` PM > Install-Package D20Tek.Spectre.Console.Extensions -Version 1.57.1 +PM > Install-Package D20Tek.Spectre.Console.Extensions.Configuration -Version 1.57.1 +PM > Install-Package D20Tek.Spectre.Console.Extensions.Hosting -Version 1.57.1 PM > Install-Package D20Tek.Spectre.Console.Extensions.MoreContainers -Version 1.57.1 ``` @@ -208,6 +210,8 @@ internal sealed class InfoCommand(IConfiguration configuration, IAnsiConsole con } ``` +### Generic Host Integration +The separate `D20Tek.Spectre.Console.Extensions.Hosting` package bridges Spectre.Console.Cli to the .NET Generic Host (`Microsoft.Extensions.Hosting`), so the host can own configuration, options, logging, hosted services, and lifetime while command types resolve from the host's service provider. See the [package README](D20Tek.Spectre.Console.Extensions.Hosting/README.md) for full usage, and the [GenericHost.Cli](samples/GenericHost.Cli) sample for a runnable example. ### Samples: For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, please review the following samples: @@ -223,6 +227,7 @@ For more detailed examples on how to use D20Tek.Spectre.Console.Extensions, plea * [InteractivePrompt.Cli](samples/InteractivePrompt.Cli) - Create an interactive prompt that can run other registered commands while remaining in the prompt. * [Logging.Cli](samples/Logging.Cli) - Use WithLogging to enable verbosity-aware, Spectre-rendered logging and inject an ILogger<T> into a command. * [Configuration.Cli](samples/Configuration.Cli) - Use WithConfiguration and WithOptions<T> to bind configuration and inject IOptions<T> into a command. +* [GenericHost.Cli](samples/GenericHost.Cli) - Bridge Spectre.Console.Cli to the .NET Generic Host so commands resolve from the host's service provider, injecting IOptions<T>, IAnsiConsole, and ILogger<T>. ### Testing Infrastructure This library also provides testing classes that help in building your CommandApp unit tests. Using the CommandAppTestContext allows you to easily configure and run commands in isolation. diff --git a/samples/GenericHost.Cli/GenericHost.Cli.csproj b/samples/GenericHost.Cli/GenericHost.Cli.csproj new file mode 100644 index 0000000..d922d02 --- /dev/null +++ b/samples/GenericHost.Cli/GenericHost.Cli.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + + + + + + + + + PreserveNewest + + + + diff --git a/samples/GenericHost.Cli/GreetCommand.cs b/samples/GenericHost.Cli/GreetCommand.cs new file mode 100644 index 0000000..97fdbb0 --- /dev/null +++ b/samples/GenericHost.Cli/GreetCommand.cs @@ -0,0 +1,54 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Spectre.Console; +using Spectre.Console.Cli; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +namespace GenericHost.Cli; + +internal sealed class GreetCommand( + IOptions options, + IAnsiConsole console, + ILogger logger) + : Command +{ + private readonly GreetingOptions _options = (options ?? throw new ArgumentNullException(nameof(options))).Value; + private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + public sealed class Settings : CommandSettings + { + [CommandArgument(0, "[NAME]")] + [Description("The name to greet.")] + [DefaultValue("world")] + public string Name { get; set; } = "world"; + + [CommandOption("-r|--repeat ")] + [Description("How many times to repeat the greeting. When not specified, the configured MaxRepeat value is used.")] + [DefaultValue(0)] + public int Repeat { get; set; } + } + + protected override int Execute( + [NotNull] CommandContext context, + [NotNull] Settings settings, + CancellationToken cancellation) + { + // GreetingOptions, IAnsiConsole, and ILogger are all resolved from the .NET Generic Host's + // service provider. The command type itself is registered by Spectre.Console.Cli at run + // time, and the HostTypeRegistrar bridges those registrations to the host container. + var repeat = settings.Repeat > 0 ? settings.Repeat : _options.MaxRepeat; + _logger.LogInformation("Greeting {Name} {Repeat} time(s).", settings.Name, repeat); + + for (var i = 0; i < repeat; i++) + { + _console.MarkupLineInterpolated($"[green]{_options.Message}[/], [yellow]{settings.Name}[/]{_options.Punctuation}"); + } + + return 0; + } +} diff --git a/samples/GenericHost.Cli/GreetingOptions.cs b/samples/GenericHost.Cli/GreetingOptions.cs new file mode 100644 index 0000000..ae8e351 --- /dev/null +++ b/samples/GenericHost.Cli/GreetingOptions.cs @@ -0,0 +1,15 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +namespace GenericHost.Cli; + +internal sealed class GreetingOptions +{ + public const string SectionName = "Greeting"; + + public string Message { get; set; } = "Hello"; + + public string Punctuation { get; set; } = "!"; + + public int MaxRepeat { get; set; } = 1; +} diff --git a/samples/GenericHost.Cli/Program.cs b/samples/GenericHost.Cli/Program.cs new file mode 100644 index 0000000..6d96562 --- /dev/null +++ b/samples/GenericHost.Cli/Program.cs @@ -0,0 +1,42 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using GenericHost.Cli; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Spectre.Console; + +// Build a standard .NET Generic Host. Configuration (appsettings.json + environment variables), +// options binding, logging, and any other hosted services are configured through the host in the +// usual way. The host owns the service provider and application lifetime. +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure(builder.Configuration.GetSection(GreetingOptions.SectionName)); +builder.Services.AddSingleton(AnsiConsole.Console); + +var host = builder.Build(); + +// CreateCommandAppBuilder bridges the built host to Spectre.Console.Cli. Command types are +// registered by Spectre at run time and resolved from the host's service provider, so GreetCommand +// can inject IOptions, IAnsiConsole, and ILogger without any extra wiring. +return await host.CreateCommandAppBuilder() + .WithDefaultCommand() + .RunAsync(args); + + +///////////////////////////////////////////////////////////////////////////////////////////////// +// The equivalent IHostBuilder (Host.CreateDefaultBuilder) style looks like this: +// +// var host = Host.CreateDefaultBuilder(args) +// .ConfigureServices((context, services) => +// { +// services.Configure( +// context.Configuration.GetSection(GreetingOptions.SectionName)); +// services.AddSingleton(AnsiConsole.Console); +// }) +// .Build(); +// +// return await host.CreateCommandAppBuilder() +// .WithDefaultCommand() +// .RunAsync(args); diff --git a/samples/GenericHost.Cli/appsettings.json b/samples/GenericHost.Cli/appsettings.json new file mode 100644 index 0000000..e0d9bfa --- /dev/null +++ b/samples/GenericHost.Cli/appsettings.json @@ -0,0 +1,7 @@ +{ + "Greeting": { + "Message": "Hello", + "Punctuation": "!", + "MaxRepeat": 3 + } +} From 0438613087b97b39433a5cda8c60a939622706b8 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Thu, 3 Sep 2026 23:49:22 -0700 Subject: [PATCH 07/10] Added WithStartup extension to command app with a HostStartupBase-derived class. Added unit tests for those classes. --- CHANGELOG.md | 1 + .../HostCommandAppBuilder.cs | 10 +- .../HostCommandAppExtensions.cs | 10 +- .../HostStartupBase.cs | 38 ++++++ .../HostStartupExtensions.cs | 38 ++++++ .../README.md | 40 ++++++ .../Hosting/Fakes/GreetStartup.cs | 28 ++++ .../Hosting/HostStartupExtensionsTests.cs | 65 ++++++++++ .../Hosting/HostStartupIntegrationTests.cs | 122 ++++++++++++++++++ 9 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostStartupBase.cs create mode 100644 D20Tek.Spectre.Console.Extensions.Hosting/HostStartupExtensions.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetStartup.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupExtensionsTests.cs create mode 100644 D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupIntegrationTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ecf6a..f391850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `D20Tek.Spectre.Console.Extensions.Configuration` package that adds Microsoft.Extensions.Configuration and Options binding to the builder. New public API includes `ConfigurationCommandAppBuilderExtensions.WithConfiguration` and `ConfigurationCommandAppBuilderExtensions.WithOptions`. - New `Configuration.Cli` sample that demonstrates binding configuration with `WithConfiguration` and injecting `IOptions` bound via `WithOptions` into a command. - New `D20Tek.Spectre.Console.Extensions.Hosting` package that bridges Spectre.Console.Cli to the .NET Generic Host (`Microsoft.Extensions.Hosting`). New public API includes `HostCommandAppExtensions.RunCommandAppAsync`, `HostCommandAppExtensions.RunCommandApp`, `HostCommandAppExtensions.CreateCommandApp`, `HostCommandAppExtensions.CreateCommandAppBuilder`, and the `HostCommandAppBuilder` fluent builder. Run-time registrations captured from Spectre resolve through a composite provider, so a Spectre-registered type can depend on another Spectre-registered type while host services still take precedence. +- New `HostStartupBase` and `HostStartupExtensions.WithStartup` in the Hosting package, providing a host-aware startup that splits `ConfigureServices` (run pre-build against the host's `IServiceCollection`) from `ConfigureCommands` (applied post-build when the CommandApp is built). - New `GenericHost.Cli` sample that demonstrates bridging Spectre.Console.Cli to the .NET Generic Host so command types resolve from the host's service provider. ### Changed diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs index 2e5db61..ab678d5 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppBuilder.cs @@ -1,6 +1,7 @@ //--------------------------------------------------------------------------------------------------------------------- // Copyright (c) d20Tek. All rights reserved. //--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Spectre.Console.Cli; @@ -62,7 +63,8 @@ public HostCommandAppBuilder ConfigureCommands(Action configure) /// /// Builds the CommandApp bridged to the host, applying the default command and command - /// configuration specified on this builder. + /// configuration specified on this builder. When a was + /// registered on the host (via WithStartup), its ConfigureCommands is applied as well. /// /// Returns the HostCommandAppBuilder. public HostCommandAppBuilder Build() @@ -72,6 +74,12 @@ public HostCommandAppBuilder Build() _setDefaultCommand?.Invoke(app); + var startups = _host.Services.GetServices(); + foreach (var startup in startups) + { + app.Configure(config => startup.ConfigureCommands(config)); + } + if (_configureCommands is not null) { app.Configure(_configureCommands); diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs index e6ac073..235391f 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostCommandAppExtensions.cs @@ -1,6 +1,7 @@ //--------------------------------------------------------------------------------------------------------------------- // Copyright (c) d20Tek. All rights reserved. //--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Spectre.Console.Cli; @@ -58,7 +59,8 @@ public static int RunCommandApp( /// /// Creates a bridged to the host using a - /// , applying the supplied command configuration. + /// , applying any registered + /// command configuration followed by the supplied command configuration. /// /// The built host whose service provider resolves command types. /// Delegate to configure the CommandApp's commands. @@ -71,6 +73,12 @@ public static CommandApp CreateCommandApp(this IHost host, Action var registrar = new HostTypeRegistrar(host.Services); var app = new CommandApp(registrar); + + foreach (var startup in host.Services.GetServices()) + { + app.Configure(config => startup.ConfigureCommands(config)); + } + app.Configure(configure); return app; } diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupBase.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupBase.cs new file mode 100644 index 0000000..efdde35 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupBase.cs @@ -0,0 +1,38 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Abstract base class for a host-aware startup that splits its two responsibilities across the +/// Generic Host lifecycle: runs before the host is built (against +/// the host's ), and runs after +/// the host is built (against the Spectre.Console.Cli ). +/// +/// +/// This is the host-model counterpart to the core library's StartupBase. The core StartupBase +/// configures services through an , which does not fit the Generic +/// Host because the host owns the container and it is immutable once built. +/// instead configures services directly on the during the +/// pre-build phase, so registrations participate in the host provider like any other service. +/// +public abstract class HostStartupBase +{ + /// + /// Override this method to register application services on the host's service collection. + /// This runs before the host is built. + /// + /// The host service collection to add registrations to. + public abstract void ConfigureServices(IServiceCollection services); + + /// + /// Override this method to configure the console commands for this application. This runs + /// after the host is built, when the CommandApp is created. + /// + /// The configurator to use. + /// The configurator that was used. + public abstract IConfigurator ConfigureCommands(IConfigurator config); +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupExtensions.cs b/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupExtensions.cs new file mode 100644 index 0000000..ce9ae77 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Hosting/HostStartupExtensions.cs @@ -0,0 +1,38 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace D20Tek.Spectre.Console.Extensions.Hosting; + +/// +/// Extension methods that wire a into the Generic Host lifecycle. +/// +public static class HostStartupExtensions +{ + /// + /// Registers a with the host builder. The startup's + /// runs immediately against the host's + /// service collection (pre-build), and the startup instance is registered so that + /// is applied automatically when the + /// CommandApp is built by or the + /// methods (post-build). + /// + /// The startup type, which must derive from + /// and have a public parameterless constructor. + /// The host application builder to configure. + /// The same host application builder. + /// When builder is null. + public static IHostApplicationBuilder WithStartup(this IHostApplicationBuilder builder) + where TStartup : HostStartupBase, new() + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + + var startup = new TStartup(); + startup.ConfigureServices(builder.Services); + builder.Services.AddSingleton(startup); + + return builder; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/README.md b/D20Tek.Spectre.Console.Extensions.Hosting/README.md index ef1dc62..047fe88 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/README.md +++ b/D20Tek.Spectre.Console.Extensions.Hosting/README.md @@ -156,6 +156,44 @@ internal sealed class GreetCommand( Command-line `CommandSettings` remain separate from host-driven configuration and options, so each command decides precedence explicitly. +### Organizing setup with a startup class + +For larger apps, `HostStartupBase` keeps service registration and command configuration in one reusable place. Because the host owns the container and is immutable once built, the startup's two responsibilities run in different phases: `ConfigureServices` runs before the host is built (against the host's `IServiceCollection`), and `ConfigureCommands` runs after the host is built (when the CommandApp is created). + +```csharp +using D20Tek.Spectre.Console.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +internal sealed class AppStartup : HostStartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.AddCommand("greet"); + return config; + } +} +``` + +Register it on the host builder with `WithStartup()`. Its `ConfigureServices` runs immediately, and its `ConfigureCommands` is applied automatically when the CommandApp is built: + +```csharp +var builder = Host.CreateApplicationBuilder(args); +builder.WithStartup(); + +var host = builder.Build(); + +return await host.CreateCommandAppBuilder() + .RunAsync(args); +``` + +`WithStartup` works with the `IHost` extension methods too, and you can still add more commands through `ConfigureCommands` or the `configure` delegate; startup commands are applied first. + ## Public API - `HostCommandAppExtensions.CreateCommandAppBuilder(this IHost)` - creates a fluent `HostCommandAppBuilder`. @@ -163,6 +201,8 @@ Command-line `CommandSettings` remain separate from host-driven configuration an - `HostCommandAppExtensions.RunCommandAppAsync(this IHost, string[], Action)` - creates and runs a `CommandApp` asynchronously. - `HostCommandAppExtensions.RunCommandApp(this IHost, string[], Action)` - creates and runs a `CommandApp` synchronously. - `HostCommandAppBuilder` - fluent builder with `WithDefaultCommand`, `ConfigureCommands`, `Build`, `RunAsync`, and `Run`. +- `HostStartupBase` - host-aware startup base with `ConfigureServices(IServiceCollection)` (pre-build) and `ConfigureCommands(IConfigurator)` (post-build). +- `HostStartupExtensions.WithStartup(this IHostApplicationBuilder)` - registers a `HostStartupBase` and runs its `ConfigureServices` pre-build. - `HostTypeRegistrar` / `HostTypeResolver` / `HostRegistration` - the bridge types that capture Spectre's run-time registrations and resolve them from the host provider. ## Sample diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetStartup.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetStartup.cs new file mode 100644 index 0000000..9e39022 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/Fakes/GreetStartup.cs @@ -0,0 +1,28 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; + +internal sealed class GreetStartup : HostStartupBase +{ + public bool ConfigureServicesCalled { get; private set; } + + public bool ConfigureCommandsCalled { get; private set; } + + public override void ConfigureServices(IServiceCollection services) + { + ConfigureServicesCalled = true; + services.AddSingleton(); + } + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + ConfigureCommandsCalled = true; + config.AddCommand("greet"); + return config; + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupExtensionsTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupExtensionsTests.cs new file mode 100644 index 0000000..7196097 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupExtensionsTests.cs @@ -0,0 +1,65 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Diagnostics.CodeAnalysis; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostStartupExtensionsTests +{ + [TestMethod] + public void WithStartup_WithNullBuilder_ThrowsException() + { + // Arrange - Act - Assert + Assert.ThrowsExactly([ExcludeFromCodeCoverage] () => + HostStartupExtensions.WithStartup(null!)); + } + + [TestMethod] + public void WithStartup_ReturnsSameBuilder() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + + // Act + var result = builder.WithStartup(); + + // Assert + Assert.AreSame(builder, result); + } + + [TestMethod] + public void WithStartup_RunsConfigureServicesPreBuild() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + + // Act + builder.WithStartup(); + using var host = builder.Build(); + + // Assert - IGreetingService was registered by the startup's ConfigureServices. + var service = host.Services.GetService(); + Assert.IsInstanceOfType(service); + } + + [TestMethod] + public void WithStartup_RegistersStartupInstance() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + + // Act + builder.WithStartup(); + using var host = builder.Build(); + + // Assert - the startup instance is registered so ConfigureCommands can run post-build. + var startup = host.Services.GetService(); + Assert.IsInstanceOfType(startup); + } +} diff --git a/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupIntegrationTests.cs b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupIntegrationTests.cs new file mode 100644 index 0000000..2499351 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.UnitTests/Hosting/HostStartupIntegrationTests.cs @@ -0,0 +1,122 @@ +//--------------------------------------------------------------------------------------------------------------------- +// Copyright (c) d20Tek. All rights reserved. +//--------------------------------------------------------------------------------------------------------------------- +using D20Tek.Spectre.Console.Extensions.Hosting; +using D20Tek.Spectre.Console.Extensions.Testing; +using D20Tek.Spectre.Console.Extensions.UnitTests.Hosting.Fakes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Spectre.Console; + +namespace D20Tek.Spectre.Console.Extensions.UnitTests.Hosting; + +[TestClass] +public class HostStartupIntegrationTests +{ + private static IHost CreateHostWithStartup(IAnsiConsole console) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(console); + builder.WithStartup(); + return builder.Build(); + } + + [TestMethod] + public void HostCommandAppBuilder_WithStartup_AppliesConfiguredCommands() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHostWithStartup(console); + + // Act - the "greet" command was registered by the startup's ConfigureCommands. + var result = host.CreateCommandAppBuilder().Run(["greet"]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public async Task HostCommandAppBuilder_WithStartup_AppliesConfiguredCommandsAsync() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHostWithStartup(console); + + // Act + var result = await host.CreateCommandAppBuilder().RunAsync(["greet"]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void HostCommandAppBuilder_WithStartupAndAdditionalCommands_AppliesBoth() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHostWithStartup(console); + + // Act - startup registers "greet"; ConfigureCommands adds another alias. + var result = host.CreateCommandAppBuilder() + .ConfigureCommands(config => config.AddCommand("hello")) + .Run(["hello"]); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void CreateCommandApp_WithStartup_AppliesConfiguredCommands() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHostWithStartup(console); + + // Act + var app = host.CreateCommandApp(config => config.AddCommand("hello")); + var result = app.Run(["greet"], CancellationToken.None); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void RunCommandApp_WithStartup_AppliesConfiguredCommands() + { + // Arrange + var console = new TestConsole(); + using var host = CreateHostWithStartup(console); + + // Act + var result = host.RunCommandApp( + ["greet"], + config => config.AddCommand("hello")); + + // Assert + Assert.AreEqual(0, result); + Assert.Contains("Hello, World!", console.Output); + } + + [TestMethod] + public void WithStartup_InvokesBothStartupPhases() + { + // Arrange + var console = new TestConsole(); + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSingleton(console); + builder.WithStartup(); + using var host = builder.Build(); + + // Act + host.CreateCommandAppBuilder().Run(["greet"]); + + // Assert - both phases ran on the same registered startup instance. + var startup = (GreetStartup)host.Services.GetRequiredService(); + Assert.IsTrue(startup.ConfigureServicesCalled); + Assert.IsTrue(startup.ConfigureCommandsCalled); + } +} From 2472e5958c8b566d953e0e24a0cd6e4ab67ab0c5 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Fri, 4 Sep 2026 12:04:37 -0700 Subject: [PATCH 08/10] Fixed up missing xml documentation on public types. Turned on symbol link for nuget packages. Consolidated some common package properties. Split up main readme to package specific files. --- CHANGELOG.md | 6 + ...re.Console.Extensions.Configuration.csproj | 8 +- .../README.md | 105 +++++++++++++++ ....Spectre.Console.Extensions.Hosting.csproj | 6 +- .../README.md | 2 + ...e.Console.Extensions.MoreContainers.csproj | 6 +- .../Injection/CommandAppBuilderExtensions.cs | 2 + .../Injection/LamarTypeRegistrar.cs | 1 + .../Injection/LamarTypeResolver.cs | 4 +- .../README.md | 127 ++++++++++++++++++ .../D20Tek.Spectre.Console.Extensions.csproj | 3 - Directory.Build.props | 20 +++ Directory.Build.targets | 19 +++ Directory.Packages.props | 1 + README.md | 2 + icon.png | Bin 0 -> 18788 bytes 16 files changed, 292 insertions(+), 20 deletions(-) create mode 100644 D20Tek.Spectre.Console.Extensions.Configuration/README.md create mode 100644 D20Tek.Spectre.Console.Extensions.MoreContainers/README.md create mode 100644 Directory.Build.targets create mode 100644 icon.png diff --git a/CHANGELOG.md b/CHANGELOG.md index f391850..d99ab24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated other dependencies to latest versions. - `LoggingCommandAppBuilderExtensions.WithLogging` now uses the new `GetServiceCollection()` accessor instead of reaching through the internal registrar. - `SpectreLoggingExtensions.AddSpectreConsole` now sets the logging builder's minimum level from the mapped verbosity, so Debug and Trace entries are emitted when a more detailed verbosity is requested. +- The Configuration and MoreContainers packages now ship dedicated, package-specific README files (packed as the NuGet package readme) instead of the root repository README. +- Enabled SourceLink, symbol packages (snupkg), deterministic builds, and a shared package icon across all four NuGet packages, and consolidated shared package metadata and version into `Directory.Build.props`. + +### Fixed +- Corrected XML documentation on `LamarTypeResolver`, which previously referred to SimpleInjector instead of Lamar. +- Enabled XML documentation generation for the MoreContainers package and documented the previously undocumented public members so all four packages ship complete API docs. ## Release v1.56.1 * Upgraded Spectre dependencies to latest version 0.56. diff --git a/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj index 556ff8c..781cc5f 100644 --- a/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj +++ b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj @@ -4,13 +4,9 @@ net9.0;net10.0 True Spectre.Console Configuration Extensions - 1.2.1 - d20Tek - d20Tek Extensions for common code and patterns when using Spectre.Console CLI app framework. -The current release wires Microsoft.Extensions.Configuration and Options binding into the CommandAppBuilder, so CLI commands can inject IConfiguration and IOptions<T> alongside the existing dependency injection container. It provides fluent WithConfiguration and WithOptions builder hooks. This capability lives in a separate package to keep the core package's dependencies minimal. - Copyright (c) d20Tek. +<T> alongside the existing dependency injection container. It provides fluent WithConfiguration and WithOptions builder hooks. This capability lives in a separate package to keep the core package's dependencies minimal. https://github.com/d20Tek/Spectre.Console.Extensions README.md https://github.com/d20Tek/Spectre.Console.Extensions @@ -22,7 +18,7 @@ The current release wires Microsoft.Extensions.Configuration and Options binding - + True \ diff --git a/D20Tek.Spectre.Console.Extensions.Configuration/README.md b/D20Tek.Spectre.Console.Extensions.Configuration/README.md new file mode 100644 index 0000000..1952470 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.Configuration/README.md @@ -0,0 +1,105 @@ +[![NuGet](https://img.shields.io/nuget/v/D20Tek.Spectre.Console.Extensions.Configuration.svg)](https://www.nuget.org/packages/D20Tek.Spectre.Console.Extensions.Configuration) + +# D20Tek.Spectre.Console.Extensions.Configuration + +`D20Tek.Spectre.Console.Extensions.Configuration` adds [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/core/extensions/configuration) and Options binding to the core library's `CommandAppBuilder`. Commands can then inject `IConfiguration` or strongly typed `IOptions` through their constructors, alongside the existing dependency injection container. + +This is a separate package that references the core `D20Tek.Spectre.Console.Extensions` package. It keeps the `Microsoft.Extensions.Configuration` dependencies out of the core package, consistent with the other add-on packages in this library. + +## Why a separate package? + +The core library ships a lean `CommandAppBuilder` with a minimal dependency footprint. Configuration and Options binding are opt-in concerns that not every CLI tool needs, so they live in this add-on package. Add it only when you want layered configuration sources and validated, strongly typed options. + +## Installation + +```shell +dotnet add package D20Tek.Spectre.Console.Extensions.Configuration +``` + +## How it works + +The package extends `CommandAppBuilder` with two fluent hooks that operate on the builder's service collection: + +- `WithConfiguration` builds an `IConfiguration` and registers it as a singleton in the DI container. By default it reads from an optional `appsettings.json` file and environment variables. Pass a configure delegate to customize the configuration sources. +- `WithOptions` binds a configuration section to a strongly typed options class, registered so it can be injected as `IOptions`. Data annotations on the options class are validated. + +Both hooks require that a DI container has already been configured, for example by calling `WithDIContainer` first. Configuration values remain separate from command-line `CommandSettings`, so each command decides precedence explicitly. + +## Usage + +### Binding a strongly typed options class + +After configuring a DI container, call `WithConfiguration` to build and register an `IConfiguration`, then `WithOptions` to bind a section to a validated options class: + +```csharp +using D20Tek.Spectre.Console.Extensions; +using D20Tek.Spectre.Console.Extensions.Configuration; + +return await new CommandAppBuilder() + .WithDIContainer() + .WithConfiguration() + .WithOptions(GreetingOptions.SectionName) + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +By default `WithConfiguration` reads from an optional `appsettings.json` file and environment variables. `WithOptions` binds the named section and validates any data annotations on the options class. Any command can then inject `IOptions` through its constructor. + +### Customizing configuration sources + +Pass a configure delegate to `WithConfiguration` to add or replace configuration sources: + +```csharp +return await new CommandAppBuilder() + .WithDIContainer() + .WithConfiguration(config => + { + config.SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables() + .AddCommandLine(args); + }) + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +### Injecting IConfiguration directly + +You do not have to bind to a strongly typed options class. A command can inject `IConfiguration` directly and read individual keys or sections: + +```csharp +using Microsoft.Extensions.Configuration; +using Spectre.Console; +using Spectre.Console.Cli; + +internal sealed class InfoCommand(IConfiguration configuration, IAnsiConsole console) : Command +{ + protected override int Execute(CommandContext context, CancellationToken cancellation) + { + var title = configuration["App:Title"]; + var version = configuration.GetValue("App:Version"); + var features = configuration.GetSection("App:Features").Get() ?? []; + + console.MarkupLineInterpolated($"[bold]{title}[/] v[yellow]{version}[/]"); + console.MarkupLineInterpolated($"Features: [green]{string.Join(", ", features)}[/]"); + return 0; + } +} +``` + +## Public API + +- `ConfigurationCommandAppBuilderExtensions.WithConfiguration(this CommandAppBuilder, Action?)` - builds an `IConfiguration` and registers it in the builder's DI container. +- `ConfigurationCommandAppBuilderExtensions.WithOptions(this CommandAppBuilder, string sectionName)` - binds a configuration section to a validated options class for injection as `IOptions`. + +## Sample + +See the [Configuration.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/Configuration.Cli) sample for a complete, runnable example that binds configuration and injects `IOptions` into a command. + +## Feedback + +If you have any feedback, questions, or issues, please open an issue on the [GitHub repository](https://github.com/d20Tek/Spectre.Console.Extensions). diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj index 36bd02e..316f097 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj +++ b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj @@ -4,13 +4,9 @@ net9.0;net10.0 True Spectre.Console Generic Host Extensions - 1.2.1 - d20Tek - d20Tek Extensions for common code and patterns when using Spectre.Console CLI app framework. -The current release bridges the .NET Generic Host (HostApplicationBuilder / IHostBuilder) to Spectre.Console.Cli, so CLI commands resolve from the host's already-built IServiceProvider and can inject IConfiguration, IOptions<T>, ILogger<T>, and any hosted services. It provides a low-level IHost.RunCommandAppAsync extension and a fluent HostCommandAppBuilder. This capability lives in a separate package to keep the core package's dependencies minimal. - Copyright (c) d20Tek. +<T>, ILogger<T>, and any hosted services. It provides a low-level IHost.RunCommandAppAsync extension and a fluent HostCommandAppBuilder. This capability lives in a separate package to keep the core package's dependencies minimal. https://github.com/d20Tek/Spectre.Console.Extensions README.md https://github.com/d20Tek/Spectre.Console.Extensions diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/README.md b/D20Tek.Spectre.Console.Extensions.Hosting/README.md index 047fe88..1a36cca 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/README.md +++ b/D20Tek.Spectre.Console.Extensions.Hosting/README.md @@ -1,3 +1,5 @@ +[![NuGet](https://img.shields.io/nuget/v/D20Tek.Spectre.Console.Extensions.Hosting.svg)](https://www.nuget.org/packages/D20Tek.Spectre.Console.Extensions.Hosting) + # D20Tek.Spectre.Console.Extensions.Hosting `D20Tek.Spectre.Console.Extensions.Hosting` bridges [Spectre.Console.Cli](https://spectreconsole.net/cli/) to the .NET Generic Host (`Microsoft.Extensions.Hosting`). It lets the host own configuration, options binding, logging, hosted services, and application lifetime, while your command types resolve from the host's service provider. diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj index d3c9bfb..517de40 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj @@ -4,9 +4,6 @@ net9.0;net10.0 True Spectre.Console Container Extensions - 1.2.1 - d20Tek - DarthPedro Extensions for common code and patterns when using Spectre.Console CLI app framework. The current release contain implementations of ITypeRegistrar and ITypeResolver to integrate the Autofac, Lamar, LightInject, Ninject, and SimpleInjector dependency injection frameworks with Spectre.Console. @@ -17,10 +14,11 @@ The current release contain implementations of ITypeRegistrar and ITypeResolver git Spectre; Spectre.Console; CLI; dependency injection; autofac; lamar; LightInject; Ninject Split off additional dependency injection containers into this package to minimize dependencies in the core package. + True - + True \ diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/CommandAppBuilderExtensions.cs b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/CommandAppBuilderExtensions.cs index 14408ce..e7e7471 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/CommandAppBuilderExtensions.cs +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/CommandAppBuilderExtensions.cs @@ -63,6 +63,7 @@ public static CommandAppBuilder WithAutofacContainer( /// /// [Optional] Provide pre-registered services container. Creates new instance when not specified. /// + /// ServiceLifetime for all Register methods, defaults to Singleton. /// Returns the CommandAppBuilder public static CommandAppBuilder WithLightInjectContainer( this CommandAppBuilder builder, @@ -99,6 +100,7 @@ private static IServiceRegistry SetLightInjectScoped(this ServiceContainer conta /// /// [Optional] Provide pre-registered services registry. Creates new instance when not specified. /// + /// ServiceLifetime for all Register methods, defaults to Singleton. /// Returns the CommandAppBuilder public static CommandAppBuilder WithLamarContainer( this CommandAppBuilder builder, diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeRegistrar.cs b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeRegistrar.cs index 6ddbd8b..b34d2b9 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeRegistrar.cs +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeRegistrar.cs @@ -16,6 +16,7 @@ public sealed class LamarTypeRegistrar : ITypeRegistrar, ISupportLifetimes private readonly ServiceLifetime _defaultLifetime; private readonly ServiceRegistry _registry; + /// public IServiceCollection Services => _registry; /// diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeResolver.cs b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeResolver.cs index 9128dd7..0e52f40 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeResolver.cs +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/Injection/LamarTypeResolver.cs @@ -7,14 +7,14 @@ namespace D20Tek.Spectre.Console.Extensions.Injection; /// -/// Type resolver for Spectre.Console that uses the SimpleInjector framework. +/// Type resolver for Spectre.Console that uses the Lamar framework. /// public sealed class LamarTypeResolver : ITypeResolver, IDisposable { private readonly Container _container; /// - /// Constructor that takes a container for SimpleInjector. + /// Constructor that takes a container for Lamar. /// /// Container to use in type resolution. public LamarTypeResolver(Container container) diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/README.md b/D20Tek.Spectre.Console.Extensions.MoreContainers/README.md new file mode 100644 index 0000000..285d5c3 --- /dev/null +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/README.md @@ -0,0 +1,127 @@ +[![NuGet](https://img.shields.io/nuget/v/D20Tek.Spectre.Console.Extensions.MoreContainers.svg)](https://www.nuget.org/packages/D20Tek.Spectre.Console.Extensions.MoreContainers) + +# D20Tek.Spectre.Console.Extensions.MoreContainers + +`D20Tek.Spectre.Console.Extensions.MoreContainers` provides additional dependency injection container integrations for [Spectre.Console.Cli](https://spectreconsole.net/cli/). It supplies `ITypeRegistrar` and `ITypeResolver` implementations and fluent `CommandAppBuilder` hooks for the Autofac, Lamar, LightInject, and Ninject containers. + +This is a separate package that references the core `D20Tek.Spectre.Console.Extensions` package. It keeps the third-party container dependencies out of the core package, so applications that use the built-in Microsoft.Extensions.DependencyInjection container do not pay for them. + +## Why a separate package? + +The core library integrates the Microsoft.Extensions.DependencyInjection container out of the box. Not every application needs an alternative container, and each supported container brings its own transitive dependencies. Isolating these integrations here keeps the core package lean while still giving teams a first-class option for their preferred container. + +## Installation + +```shell +dotnet add package D20Tek.Spectre.Console.Extensions.MoreContainers +``` + +## Supported containers + +The package adds fluent `CommandAppBuilder` extension methods for the following containers: + +- Autofac - `WithAutofacContainer` +- Lamar - `WithLamarContainer` +- LightInject - `WithLightInjectContainer` +- Ninject - `WithNinjectContainer` + +Each method sets the builder's type registrar to the container-specific implementation, so Spectre.Console resolves command types and their dependencies from that container. + +## Usage + +Call the container-specific hook on `CommandAppBuilder` instead of the core `WithDIContainer` method. Each hook optionally accepts a pre-configured container instance; when omitted, a new empty container is created. + +### Autofac + +```csharp +using Autofac; +using D20Tek.Spectre.Console.Extensions; + +return await new CommandAppBuilder() + .WithAutofacContainer() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +### Lamar + +`WithLamarContainer` also accepts an optional default `ServiceLifetime` (defaults to `Singleton`) that applies to its registrations: + +```csharp +using D20Tek.Spectre.Console.Extensions; +using Lamar; +using Microsoft.Extensions.DependencyInjection; + +return await new CommandAppBuilder() + .WithLamarContainer(lifetime: ServiceLifetime.Singleton) + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +### LightInject + +`WithLightInjectContainer` accepts an optional default `ServiceLifetime` (defaults to `Singleton`): + +```csharp +using D20Tek.Spectre.Console.Extensions; +using Microsoft.Extensions.DependencyInjection; + +return await new CommandAppBuilder() + .WithLightInjectContainer(lifetime: ServiceLifetime.Singleton) + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +### Ninject + +```csharp +using D20Tek.Spectre.Console.Extensions; + +return await new CommandAppBuilder() + .WithNinjectContainer() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +### Providing a pre-configured container + +Each hook accepts an existing container so you can register your own services before Spectre.Console adds its command types: + +```csharp +using Autofac; +using D20Tek.Spectre.Console.Extensions; + +var container = new ContainerBuilder(); +container.RegisterType().As().SingleInstance(); + +return await new CommandAppBuilder() + .WithAutofacContainer(container) + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +## Public API + +- `CommandAppBuilderExtensions.WithAutofacContainer(this CommandAppBuilder, ContainerBuilder?)` - uses an Autofac `ContainerBuilder` as the type registrar. +- `CommandAppBuilderExtensions.WithLamarContainer(this CommandAppBuilder, ServiceRegistry?, ServiceLifetime)` - uses a Lamar `ServiceRegistry` as the type registrar. +- `CommandAppBuilderExtensions.WithLightInjectContainer(this CommandAppBuilder, ServiceContainer?, ServiceLifetime)` - uses a LightInject `ServiceContainer` as the type registrar. +- `CommandAppBuilderExtensions.WithNinjectContainer(this CommandAppBuilder, StandardKernel?)` - uses a Ninject `StandardKernel` as the type registrar. +- `AutofacTypeRegistrar` / `AutofacTypeResolver`, `LamarTypeRegistrar` / `LamarTypeResolver`, `LightInjectTypeRegistrar` / `LightInjectTypeResolver`, `NinjectTypeRegistrar` / `NinjectTypeResolver` - the container-specific bridge types. + +## Samples + +For runnable examples, see the container-specific samples in the repository: + +- [Autofac.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/Autofac.Cli) +- [Lamar.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/Lamar.Cli) +- [LightInject.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/LightInject.Cli) +- [Ninject.Cli](https://github.com/d20Tek/Spectre.Console.Extensions/tree/main/samples/Ninject.Cli) + +## Feedback + +If you have any feedback, questions, or issues, please open an issue on the [GitHub repository](https://github.com/d20Tek/Spectre.Console.Extensions). diff --git a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj index c9d19b8..bac1032 100644 --- a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj +++ b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj @@ -3,8 +3,6 @@ net9.0;net10.0 True - d20Tek - Copyright (c) d20Tek. Extensions for common code and patterns when using Spectre.Console CLI app framework. The current releases contain implementations of ITypeRegistrar and ITypeResolver to integrate the Microsoft.Extensions.DependencyInjection container with Spectre.Console. @@ -19,7 +17,6 @@ The new Extensions.Testing namespace support test infrastructure classes to easi MIT latest True - 1.2.1 Spectre.Console Extensions README.md diff --git a/Directory.Build.props b/Directory.Build.props index 10b58ab..24ddf62 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,4 +7,24 @@ 5 + + + 1.2.1 + d20Tek + DarthPedro + Copyright (c) d20Tek. + + + + + true + true + true + + + + + true + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..7daade1 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,19 @@ + + + + + icon.png + true + snupkg + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index e507ce4..16abc7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/README.md b/README.md index c7cfa71..2f25418 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,8 @@ return await new CommandAppBuilder() ``` By default `WithConfiguration` reads from an optional `appsettings.json` file and environment variables. Pass a configure delegate to customize the configuration sources. `WithOptions` binds the named section and validates any data annotations on the options class. Any command can then inject `IConfiguration` or `IOptions` through its constructor. Configuration values remain separate from command-line `CommandSettings`. +See the [package README](D20Tek.Spectre.Console.Extensions.Configuration/README.md) for full usage, and the [Configuration.Cli](samples/Configuration.Cli) sample for a runnable example. + You do not have to bind to a strongly typed options class. A command can also inject `IConfiguration` directly and read individual keys or sections: ```csharp internal sealed class InfoCommand(IConfiguration configuration, IAnsiConsole console) : Command diff --git a/icon.png b/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f9b1d2aeff5508a80f25503e32f85062ec847eb7 GIT binary patch literal 18788 zcmZ5{1ymeQ@a8Tq!95TpxO;%$?he5vxCFPw9TGH1a3{FCyA#~qT^F|nZh!x~_wL>8 z+u7-!>7A*Ts`~ot>R&2K(r73jQ2+n{nyie38uT3S-+_bx{p}O_{ttQrx~NHu0V*d* zj-eSiOHoBp0H7uo74jV(nn!k&(QyF)(0l(ofOvFr0nm#?u9DiW>JApJ9^akK0V?0E z?Oj=vBsIvnS=d?FIdFi|uFyiw=Gw9rva$>SBxnX6@DT_P076r8(1ZB@r2i9#4hZ`{ z*I}S(b|4G@3wqXt9@gP-|8xBZH2vS_+8qcug8jee|NpH30QBZ>L;xJLEE@+W8!+d8 zKli_P!sq<&XC*nb&B6Vz46IRmj`P4 z?O!!Ah;Uu0?-i5%uQ!lv{j8~$-2MUp|Cb-9J9y`Q;EW%N%D(J}ObkHEE&2e`aoTXG zIRBC{{yrhI0NBq`yA4)2*%`BXrJsWF61XF8%W1ASxpCe%O>|GHdT;vs5Y?1(M_mZ8 z0LB2UM~!Lw3ZzK_AWV!EE_nqPc?j)b9x8d1h&s^g^OpS4n#vAzxbjv*{zyA>-2~H! z)@#qGH=0+r@14;BXZ@Y78l>_cx5EkCQ;8mgQv8_X;veF_KF5Y0Mur$+UUy>=85@)5 zz#aN9((-Tj-ddNA0q~~&{I>p}a#Xbal-G@fltAc^C;?#$z=ENP@1I1&Zh`@H+eW^( z4B~HnLvA;2C@Shmal=mxfTt#y3xO-K2LMA-Ydu@Q@3_Ft46-ddUIiH{}vg&X(OZLgH`YIUQu1W!rRlr5~ z000VCy0~Er-K?JwEb#l76_A7R^`urJEl+TM=SEBp76AbphHZ7B91BJ4C%%0yIotw* zAF~B(nvBkgA!JfSaHAVfJ_8fc+ny%hJL~#Wb$P$Hkw+eu2>JINKb#i%slJYSX0J89 zj)2>zr%?kw5J>c5+CvU$$^iiITdf^2RaI+-W5z&QR_l1n^;ja*HWcih013fTve^7J z;O$@R-}@E2a{@hk6HLCh+6Hm9HP>e)|Gf@aT%1~~cFA?%JVl=5!SAp3`9bom{9%83 z(8>O{6FmTMX4Lr_rxyQQt%F%{5H)Alva11uzJ*MIsd&xPH;XOF^AlMp-U%jI1g{=q z6+f$^t;;&B)^c*kL$cawOVQYem&M8wRrN`W`HK?KP^vhUwl3we--D6X9=msDKk7FZ zuahr_ylJpIntoL!Y4Y`Hmkjz^E%X9{s$7MkWDz4O>%Z!-bqM}^t)sUaxN zN8=~R`<~H@@m-*S7c|PN^l2vw7XWD7e7&9FQY^=T>#>nlUQcRzt_?}dIS$k^a`U*; zAL1HkUagpi-_-~*X8pa3?fci5n8eSA>#(YsLAb2ijGt9@KaF*2%g^^vtU#y{?0-GW zUy*FDTiyOR-J-0#!FJdPc4b3=;kYow1Vjyi;D{J1P_?uQl$O|Rb$SL%xu1v`$;LLd zpNs>qysrm6xo5v=?#BqYrSRYjcg^7&y9uyrkC&9JW^n(a7hQe!cxZ0*BCra>jvB&- zV+0ob`W9~dRwF-s$mXm3yMP{v&LL~9!SwrT<5DkTz`E{D)wAS%HB-cULE3<@vL9%o zcFMKmy$>KJj`3c_{uf|~Q?kcg7)?{R?^n0{9I*Vux7!R-Xkl&VU;7wwJh4+uX->xE z>r}E_HSZ+g(D1k5hkU)qRO3aa;6+Rpyu$oie4nmCb)`*)-0x!esb53LH|&3)j;=E;MF}ZhU)m%kTKA$lcnIUr&<#qSD=N7b zY{~Go6S6c)nB-g7bG}E+?DrB@uZL}CQXHXYShd%|H9!_>eb_H(XH^-xYM)QaYGtxz;-n%fQYo#SeqDAAVz?v()SPJ;ZzwXJW=O>5$otD!FFCMHbkgW^JNr7f)j=O5( z?xBG>JpG{Tt=n&!N_m8ZY1RJS$(5S7x!kX0OY;Pj)wIGf(`3F#^+kKG^=)0Sast{w zvum}`2Ext8#)MHKhvOhZ((c6>665J}5^*&OWXBJ01<*!}C#%AwGdL#Strnbt}YL7MHe2wl%c6_MJ)h-h^OnX|!fLw)4@p?Zu{G z=z+qtG5bq^W5f8;PKRs4`7`l*O9(LOJNG|~?e`no1CD!}k~+bzGG8Pe-bD!sVu`;h z^gNe_^NG?~Bzobwkz;0&vi_&*AB5bB3r>fppGu9ESyT4tQ=bM_>n>7BG?6#THd?u4 zw@)Mtt9578n)a!9f2=jWWeQeiZJZJnXLJ6W59OYf@4E?cKFU0ADlzmAwo0Cx#c&OmXC@y7<$9Wd*o3XeKOw}h zLc-mIAPoK$neFXT}KPc;W*^%D70CPa%`!pjK6$eBq>y!3E?X}-sKv`C} zmDzW6|C~we4-l&`NabaKfSiXBaprUB^usbCb73>52-<mVek?tJP_GO8R)! zOWTQ8ClDlF#T9|Sw%nv{0^dHd%8&rzw6>Wz)Wc5-=H^Yodc04!kRttVZn%8!|a zBFQeJL$>~;x9Rz4{F2$5z48W-<7n_-H4IBtE5ok8wxzxYs=VxKjirWSaPeY3=-E~6H7)B9!u#i?Kw?oNu%PmbJ|%TAd)cRWZI zEv1h^Et6rEz<`;R2zhy1#tW8;ibq3hJIRTS@J+ss{mQP`ZKczo*g12^=t&vPvbvh# zjBRJp^Iqw2Z`bWKJWs@z7tSc)MkiGCb+T+QtuTiN4tshjvZ(_2(P5(vvfn%*UOiH+ zS5{KmaNp20ZtZmxJsE7h3&kEs{@0H357VYMnq9SfJdT$P1wh3 zXl`Z7CyqRX4I!Y&Y%cLC-~dA;->BGra|WmEUIO>+EcAy!b_OkG!FNZov%~Y6CEp6rS{5NmqCqgmuJY6IHUEg@Lf)kXYO%3 zb5!wO<2MfwbAM2l1YJc%Gh+AdU{?pA96$y51StG6&n1I3bC8Rpbl9=pd5%Oucn>%|I7kzm3^|A#Y{8g($+C&_t5Q`& zyT#aW-w9mYC3tHsRjgfCtFYF!_4j^Rs|*(4s!`BN{;lbv@SoN|9Qg{`lEoOig%o5^Ode~>5wOL1>V&4x%w#qI>iS zt^c}G)i;~3+Fa?JVAZ6d9^KgdmH*Jqeu-c1kvD;cJh7(;y~)*Yn#<@0;l2rL@~O=S z@%@*B)I)dE+heS}d~>2AHmkPXaS7`n3ad9{z+k1PVAWw>ptA@u7njkx@!_V(zy-7u z#uwRp6EhI9QpO9A9qbBExB1AAG&}@=4A#(7EKmN_!C++~P`KXx26k}tb88r-llaqd zy_4zxqyyT}W;>cqr&0P!l!YJNyE3X6G_IB@u_s!jY&g~V7W}h!Xc;=<9bYeGejKCAE!nX4vFR^8I9dOkwh^ZOisic$eB99q;YKq+^R&h_d;$L)8JE}eM;XAxWVCbk$$!AHr>5x?PY$O{UL5LmyAl}+?gl>m;PUywZy z%>$D`x0z>%lP=Z#=j0&)?2UNcu~4PRzyI+j?kGuY`L0--V!x;1^>ux6(sQ@dE<4mR zNc9E^iqi7O?Cy(Eg>-eulEGLjsKeOF zX^&FM4{VYacREt|BH{CZgT-5wk1+wyns5RrKaRhpBq$#7b0aA?Ar?{*wGS)9^fjrK zMWEktkqG0y8N5?8=S91`(da1fs7cy=@9@15ZuNiY0V_;-exCj)BBs}w09Lb^u!?=IUr4P;yYo$De5?xR|?NxC(=bETcthMG&Nmb>^59V zaLMK>tnproTHuorl8`_y8#SZ-(Upbu5*NPdqixCd#=*>2nF+|f0-8r@AI zIfT?>;zm4sh{j*u4x;1CHy%k4gFlo!p73?yF&CzDQBUpI*rqSO4G&?3VgRfoi_HJx ze@&+=sE|%-=in0%B;ENIK<-ig61YF2*%iqlHE?fEWFfqM)Nk zGdaK6URDBdSU_N%Gy_tMR{4bhpe+KfgPJ}66ShVKQSJeNPCA( zDTS9w9?iz9NN!zS$H?R#0b99gowXMqewM~qM(#)-yi5KL)iV(6_)}rK;lnN9+YasQ zH8f+`?Vr?J=s9&I1X=bN-B@7k*!G)R@@4Z6S)?MX1O@=IaBwgif+O6Mdabb%ci;Sh z*uS2M+FqQvBcv?9i5(*AB5Pq?0S&ipRO2U|>rpL0|0fOXJ>g1#I_cBNt`JU( z^d0=iG>_7`jdpgZGF-Al!7@x8_=DIpC|s9wM8@k8t^ zA$s#Ib0E|>iq*;<3G)TSh_AQesnScNhm_Z9q$G^@h{R>1l639d{6Rz38Rp!+9&&ip zBOq%|eVSjyU|sjq1bGQi=`ta^Ozhkwyz-?^UeUD3-67s{U9LEN8q`+6imqKrH8M3; z#*EoR=ksC?9vnr2VM91;+-mQ$-6WYd+pB2Z6Hs>^ zMR4p#v4G3P#zfyz&F;EjN~H!d8gsc^kITCYEdLj%S@aWNXKYlnt4f> z3&=?Wb&(5mrFlalD$+`zn?XVgR`~Zg#YL5`NsKpjkwI5m+fB|8_^X zH0978)$fXR-SIYIC+gc|;8R5u3b3p9Hra=6vt;sNu`h)7iWLmkP;(pm^}wkR*RFV_ zUvFGqL(-1O$~N7@xo_Cs8>TUK%k;NJzL3X;e-xull zQ&Y}4u$_MI!<}!bIcijge7_BoP)gP6eOHi`t2~yfrU-uyQ?zm6Ehv;3mVRKx5%co_ z&6nlxAWVehp=qX5l@;i!&;QR{VLdkL19(`+O|^xa*%y%(g-@vIu&CqW!bmnxmP(3F z51(_^x-y7zQs7w&&r%N)W}{S(LST&SBm0%PJKgFg|Gc#AEh@+0mc{|?MTe0kV6*;sl)o! zi#sctVr;*TdO+`;S3Lu2F&Q`Wx)8H~AU@8b<_dkj5l^8_7XSD3D`!WYJ)Sxp7LnNFp&0o=j_GEQ=Xu#5VH zgbav`%Wpo5=&GW`Uw_)V^`3e0>JunXJC`21@HZXHf@ILzuYgd3|D{N+Kc$)XOq}?~ z5MEZT+}>TI_ZVI7ZfOX1wwgYiN60_&5~HXzciJTUm|guFcz@rLNi0m_0-#KUinr!iTGW9SUw*wG>W*?K=8cGVU94NcKQ&AIJ zXhwdyq0yXOE4MW6nGW9|p@5|OKlNQJ&o_Fp4rS=fL53DDo`xR8W`|59oNAPbJPV(Q zQ~}>nH17sDLXLPQIoafL)ZkO;kAD~&5ht}|`Dr)ml$I1W-ZwRjpMQwt{S^7@)FO@E z^aZE@0JlNC+bWbuAVsJq0U#-dnOud3wWnYuje`aA*&lI;hek*1fZH;F?g79BO~{d) zLQo(2pf9IyHzI(#yFnpV(r4W2;%!=E_OT*P>r{1%&fa{G$Gc_6!qJGYk~b2D~k78Be`qONsu) zqw3kN7iU905?lzc>=O*b;BZFP>5)_#M(5-GwTEI2)42(4`ta?HuhgfE$>1;YpXhEt zUN2RG9TtC=J=Qo!QdPC}CnLo?yLZ=pemq_1MP19MU=_Gu1=q===dDs*lb}|nm`U(h zC^+~5QP_IBIeV$wtn_9ny(tkw`XjgD-iW(zx7dxkFqpI5jwu`Jf1O z=%F!)Ue{z7Vu=&ln_(e!;i;n{fTHBHgd)`^_lF}TL18#61AU`EoMG@12&pb9bPCy; z&~cd`;97pmQUI0SwH^AldOcK5x&B*E$l#M4xNE2T*t^MXzfGwRe5*>SuT+~MTn7e4 zbnd=Bi(nwz=p!*mF8t-9+NaJvv|?>dd+#&!7qG%@IW+Hyw-6mVG-%~U(UKYMmKDfI zn7yl{Fcr2fwT>cm0Bxj=GF9pL&>6|BY*g=MzoRSu(--32OiI10&2dm{dRnT@%nvbo z+bTy+RNdZ66Eq4TjU9YF`F z49o+TUa4}uYKL(9=I1C(k_iYuHsPP^2LXK}2xa*irytOK`{TVdi+k8%$!)BbX)(^M zh1-7&y@#NZ0e)B?GlFE9Fdls-!%+RPyOZrQUeZM(T4WV#eEcQ~%Zwfe?KBVKvR-{XwB7q6e z6$va_3=T{Js#JY_ZYM8AnEoyn&q<^fkDSpRZ2b|2MHpcfVe8ZxRzSFQj!=b-nK<4+ zYBdnOxm6(lk9xtd5bY-@Dj+m}kU*g!ip-O?eAh|bTSY)OwfmHYy zk&WXXb^g()npsvhs<$Z0Sn8J)4!i1u0&-J17L=-#fWN)C9TwsFHZqxszk-OUWim66 zZDU{$J}@#Zqf;eYkUMy>*Rl>H%7h5Q&yvy_2&yh+3gIu}+ zF4_M1`=CVzscrLG6^fMp%bVKAPlotieX0Gca0w7W!=x(YEP~2^yC!ny?I=9T^2ih) z*`GTpThZZK#YYGw0uDG<|DiFyuj6?|r{Iee$S})@>FRu3J7=jUIU>}Fj=lbhU zjYy&#))+C1Xmo1=d%Mn?DmC4BNu|#W+qao(C6k^JIychlG7;7B5;U2^ibzW?5O``v zrYp<~M54ecQ6Q_MYbQqxV%nM;vIC<%N;4emR0Bd|C!Q0iMmC8^&NA1(-Q54Ie!5wl zOg(Rq;peMXM=3B2BpBU7)$oQHeIcR1zKsV=cGIMB+%Igt#QJ5R!4K=X>_;C^4b4(D zFP0h}h)12saUk@RZFqq@pRp*7TVZ|^x1zfpyFf&@+18ct41dE;Gv)0kxPzX3C{dHy z_mo+~`1-zZy-(5wAE=9h*1g~yLLA}VNHLV!og~N%3qPpEM?sFM$@AWXw~@FcgAX*j z|G|KCkd|=*a(4UF33-=!Y}6Pkc!^4=OB@@}^6rce>8OQJQNaAF@m<1fOFV7)7Pqax z=>MZZjdqf(jN>H-5?+$+Vw)8N6FJoa+PSsXtdyJun85<WOb}8(G30KbCm%UbJ=fu1EriSJ2&i=ZR**Y;a}y+|9BQ(3O)Bi-eB}*E=~7 zl=k8@vtae?IdGK$28epwC$52xaLei$)n60B%CXR_c(0zZib>(8=xPfOdQ~$D88M`D zT4Al0JzxS!SwUrRhxI-wGt6E{1kzil*IRq(($Xj?!1m$XtzHsGf$#*f#?M=-VVS+0 z8Ud?*p3)cLC&vh^(=^Wr2o{;8o|Gm?k75Borql0{&eyC4D*!DRhEDm^%U4{JH&i;? zryDxgVrw{qrgSMuro7tcj&7dPi~(^Ni4-loXjwuSFzcl23T3#3 zOpMnojHDB#su@1JbkMnL0Bl4hQ|Q+3XhRs$Lt5q|;T3opm>jj?5({Woeu10l#*Nq& zg-7AMNj0~YVNnpt6f)rF0qVSB zW1(ep82`hg8=sJMz#Fp!yB8Hoh8NwFWD)oy8H*6yGyh5QF~D#ZgzhWdhIMoU%-gQu zL`YcYy5#||jfzCu=UOFor-msCXmT~T>G#>SclT_bhVGzz&?NYL%|@Fl?ZLe3?5^$MGX5%Iz1|@ZF1bHcl(dAqFWFu`ZkJjP zuaiid*HQMM{_`Tkg&J_E37;AU=`k=Zezd(DXT=n&%GbSInmy~(XBVfYtJuM-+hQ(lFOhG z1k<1R33dvu2Gpe}Dd6dJz2~C~p}G};d1h>1V?%eQjT`sj?@=sQRu})w!Tpl#0vy+@lbYK%;}vDNK^@9g zpd(5T{~fT+_aW;-d=-)Q?p{B?+Jf)5i<2MNDfh#p6XcGPk~tuc;w8vu2{j&Cc*x~Q zaPC*7^6G6N4M{Y`rH>$2$Bj$ARv%^;2H0*%jwP*0^Y_4Lz+%(k(uKip%wN*TT!we|L&Y5v*x-P+8;#PB&eCEcz=wA~gUGD@vHaseeC4i;%LQ362vYVHKC2 z@i*3fAM4x)5q<}L6&%h;jM)pKx+T37w^VmbbNj%BGC@WDz9S}6l9bC16hqQ4dY*co zmR-j}VgStVzTH?R9tF_??reGAp0TX_+?G(RKB81+0Qd%uP_TpUW0g#|B%(&>OhMX> z|Eyl>VS_7J%#tnp8v%=b-vmr2zD-kUF$isD9XuN>4%Gdov0Q$7O1dq_i^4pWk^TiR z77tGT@BxOGCARL>WOC+k1NDHHQDGf4?y>$kvmg+iH4_~_hn-`X@TbTxJ+~)PduS+4 zKX{n}1uV<&a{-888HGo3G%;eFQ~53-UV!aLu*7aGlFC0qHOyY}Z9uXWT=G4ONZd%Bwl&5zph+IH90pYPMv);j_Yd+B z^16|GNghI8K!x_3x4Q}^@v~`sr#Dj0qThT4p(zskMda?(=62wYnz7s$Q6=u!_z}!w z%t+SEmv$=-&5}0}@f*D>Epjn+_RHHk_N%YodM*XZe9Tw&HAIU}jjsI4KSNFfX z-QP}<-ri2$qu+n~P~QZh0YwukW0Ju`>ox3d`w4L3;(75s;u@Jk0&cc~nQ{=f%+N<> zM?zODe#77Tu1i|L*xGKu#ke{5+!%3&_HR%!=s#8us73BBDxN(|-`oyYs6cFGwK@hU zJzAgy!6UqutYXx6`#J;21GraXG9poe#FNQGM4XTp^pZBM-ybHv!zb zZ%LBW6K53Lh=a^N8L~JkD*P_L&Z_6HLb`LW<}E+_z{>H$q^m}T6Zj4yQb^&lPX;wK zB&`)kH*`*{zJI%|=xGk|_bA~kYE~fVyc`ROy<$OUF3;4ln;92M8Pa;4?qJP!nj~*1 zJqicNAt`U2U*#Qwj35TWfIO<8XAQV#Uljz1%FC+?!fhAqs}S9wH20f_8@kvH5v3m- zfRgaXv9$3NV(;xhA(Ke%lg6WW$C2%Y*icc0aYW4rK|>@=aA~!rwrEr(RRD(9#~9jn ztsrw%s5n+)g8uk6e!rWu_FlM~MlP42!E^x!fiqM+Cb}O08-al*>Sad4l-p2R*bEkg zF9QF8c5HoL>R(ajFTDCvhbR0+9CkonO(`$Qcr&{2Z4kL2$ykc*&(r5z6@=-}C#zE< z;`u=z1B_ssX$9!GJ|0LGC|NpL3$h~bM+|kfcs?D0y00*k;ioG9y`&rMe8Ub*9~s$k zh3V$u1r7Fw8{YV1Y?Tdm83OXG@Op4jj2yqQWr-br01mwTqVfsR4<|&y#Pw-^z(SdQ zMLyKxAYTn1>zZ83gkS?`p|u=ZyTk-AdEoPBQk(a23#WlE=^PXNZf4vjY-!fB*~_RN zo>S{KRu`*z4q_}!g=T_U9Tcs1*0Z(gp7N_&JmKX~jPi|V`~|le3|wv-nh!Gazt6n| z1Jt0-ptFfPb061hF&zrwnPIITf47w~^;JqJ_ z5kA82{z5|VeZd_}(fsZ$T;^Lr=EN76SR4W%Ep=Qb^8EJdMxWt&+qP_aVL*%+e+3MP zO|$v93Lr6w60MbkV!q(K$c*9ar0BDeW0?`R?hLBaNM`!kala`u=~L5pT4SljZ~5Yi z7_^RTv?DSjF62nYhd=)z5d8cmuTaxPL$mB2DcfjZ5^tWI@?-tHDMA<}i$F_|VU_p6 zLjk{&6;q5mpbnw>BH!Dn6nQe44ob{^4tM}4z)#tot^0@8q;E`-b_U6^3Ap&;Dyef7 z8{$Ps2Rg6;su%+Tw2*6XTWh7cZ+`Wr7@mdUeTKmpVe0;oK)y;X^9?yj7QlKXE{Ig9 zsuCJRAl;4yh6~6K>kqFDW#>%C>}i$)%ZJpR_DGYIhWl01?&^0+@d0-i9|}!tP0H23?WC|k~EBkn0xid9xVRp zy+rTkISQYw5V^B$J;vu4A20y0U&uLg*CJ(6fzU>&PB_qdaT1H zo-~|1`q@qcck&MHv?s$`i~di+RsNN30-peXVC^Rhx#0cJc3T8`&IMhqUei@K`?0C~ z;Fs9E|5(M@dUjp5Wizk77J+Y7P;4z(;z%05X7yyU@@`O&P)t?OE)sz6Rg*pTU%0FK z*DMqiG6K%x#i2mbYnCEIR!SCkpXw(i@$twc~4 zQt3gtKe*Nxd=jIVZ~aH}Ao}@-vrP%4Z9zfOdcSG3lSQc^gs^n`z+kHx#inHn@mg8T z@z!G+0pyQ!^HDV%V#9W93m>e2>s^GU%PbxAM6+Eg^h_ldW=e9wR`@-1{7NGBg8DZn zr<9yh7ALP-U)N<9ykx}qt{Blm&rfR)mXKQHL?IBoIsg^j8)Qnt<9?&)#I{oKlu16*V>lqPKfyh-rJ z<2b@k-AfWcUf*gCk)?PY;3W3Q(OkO=!xFap@f0OHQ(q?UoAqxMik5anE;41U)+m;U?fm; z1#6n!`29A`p(P= z=Jq7!oj4KhOCzJI%-z!7gA(#4q($IKBjJZr*l&J3W>;-~agZgxx;>ef%EIEd=vyul zb*P7b=Wi%gp(ZI+7_mqx|Ew?Qg=zg+t{a&o-uWyCWq$FN){%t^WrT+EVJ&LQ>)js4 z=Rf5sbP))u>id%N=Q>C)hX6Vu7r&{@L^XvE+$+ygwU(D(mI6(bf(8%4lAnPud}$Dh zZO&Lm(e24D^N7D`1JZQbmZcaxDC9zQrExF7DX~$W84n z?<+eDw>VKe579Fg8AB*@U&I=H>hE(7LUTlb21%v04;Fw$U-&(bUf2L^FKag+y?iX8ip`GCk80>4tGnRRb@vUm*vT60xEOtyg&-rDbBld=bf|JNR`=2bwb)i{L zng!8CTBh9xOV$Q9WSxZBVE#aRN^(5tT1YlRofCZ^Sb8oI321zN77Qlpc8E0AKBaNqe~Al z<@O{sP5%EX`^Z+S#_CXOeOj#I?W^_E+^ll0LOO$2^m!vJCOt*D{%IbM)xUG7)f->p zT*f#T2k#x(y?s-~{qf`oc{(4MT*u0y9gf1Qo2kXmdN~jBG)nbiIKS!R#oSyKHxYD~ znAqfX__fG1r#dQ-b+KWqx6qkc4gM4(#nmYfYiZbv!Q6Q^G`L{&LfGhPEPK`Qb20JHWPG-Vgv7}uC8dRKP{mCWnxoe+{A5ak{kdD>>jd_e$h(j@guww49V?P z&Yd~PzVmxXk|%5bVnd`UbU$vZ3wJ4>_SP!xi2K{i_mC9Cc3pNL_G=Zf_<5Np`4&Zk zz|Y~mImE$`i-Kaih`Fm;U<9*sbH6f@+ULlx#{rowR@HqJy1Mk{sl&#%_tOtZ>}y5E z5K1|OVChLi6$vM`j0m?k3W5UPmt+ScUATWyCL2OUDNU0sy9o!0E&Dn(A=w>XUIo;O z1bz&EzPz6uF|$JpuWuNJB6x5Ef%8^fo4YY9^&!}@m(osmI6SO;g9s-eOf=Z6Cqx_f z=%qFmQYH^&M+<@^=m@6xKvA8VN{{{z4o)_?B4F_ zMy#!TV~yj2zM-{}{00L`5HsS!xc|aMd%+g1mIN~;pn6?1K3!G&Ii6hpkW=$CzS~=) z>S05i(c_&`z=3M89&kg}brmz~IFE&}r@mUTi`Ds5sb=Z^9XfIQwg1K&l3nAY>uIBO zk8X(R45@q!6IfJ!Np_`(7m9tUJs`vhSlep`93d#NrOX5#mwf z@5IfNKJ!W4#-**3vl8nd|Fs{n+Vu38(wnWDmH4Nk>#BFZlE21TS-+Qcoz1dui#y?qVrT7-mk~il zUxT*vKUrbBtbf1ZsrAOmxkMn>>V%XHp{k%5?!}$C9(4pHiN3yyd_itGwCK&SK_GMi zJ}N=2D{F5TDqsVU;_}Tp7rHM`GU1r`nwrR=?W1xG-|NR;8PGio71s26lA`tdeVv~2 zBZYcKkix*TD7Q%8O^2rRd|wa*jN09JSuTZTG*ExH9Yhc_%idce!5(#QDPFR8 z(|_OAyGT77V?jyt-Au9bd%iB&k74Q$1g61iFBbl~jr6m-C+WNBMPm6@R;MCd@sCn#pD(w%+dj2EFDM7s3s^CA zy6Oo{`)k62JL!|AahfjH1=QT0WpqCqVU~CHEem&2r z=@RJ$Uy0P-&4hNtiY_5`{l|w;xP}V&K@*~&;p0_IU=F0$5jRk*?nkVM&5`=u5 zPN}YR#&Kx+re4}&Aoz;9LK-nCI#JS6J`wHjf(br~Hev zBxkPp$&$tgx?%V*IC;}bQOOSh4t#sR>FPz&%frgx{xy9Y+soUr=0bg)$ZqJPNI6V$5uG_mYW*iU)ySGWb9s zj1Hcj>UNrUrGwkxMX z_Vh;Vg0#Qmu8}2#J_+sDh(Fj@p?68~CQ0Ii;Rm1e#*&g|(TSrtD&0w(L5H$L+c<3$YOi*#Sr-#fDMSE+}jZ^Gh8QjM=7kxag^?{reVGv&HK2DWFRul=~n4=q8)N~kLak@cv z=U)5ncNRW_C}@PM|DDy=&-v+6D)k1r0gq!kKc-J>uiP4H8Q9JU0b8|yIP1LB6x&jm z{tGwnt4VfekuX)D#-tVa@=k{_<83vm`LEzE-kTj5TY6AD7Ar)7L!q^P7W# zJ9QKK0Q0!c7erS1ePFW3O$}a6sPJ76O3~!^btQN|xq56kz;szT#MSc^0lLsksB<&) z<>j!lgihVBH+ImzI}cNo(->0mmR;g9Y(V5hqW%ja*quI|L`yLuC5oru7DI3 z?>}RKZJB|ZFkk?v6Uwp8y+LwuS-#S0(T#0Rr2lmu*oES0zD8_?4cGA(%wdV1^DZ=t zC-i9A8y6q3D?LH!(Dsb>zQk$Pn95Bd_{=!V^V%fE@g@t44yNI&c?c?Lv7=W)#a&u= zvaR1fb??$Dpg;FR7~U4qwu0Z?a;(Ky9a=N_s zYNy8`*K5PVza?JbW4sDry|cubTQsqr7o~E=?c3ZuMwevD+L==VR&zb$IcFda2XVYu z78U0bng135h%ENo$uC)SiJ3d+vfV{X=R^!~k4De0n_9XKADXB60tY0sTvalA?iN%= zR)P=i-~IW~6}$h?eE(}B-tIAEcZWh-BB9UiMtV6!kV_t#V+eD8?bp(n-Sz?%IA_4)e`c)w<2b6{7mHh%gbsB<68f%qa4eqe%*-n^grC^HIr^JS;$y-? zmGjE$uKBp|ff>`n(@y;A^A>YU*F4Uz91u-XRq?#JTkJPGlfv@_8CE-EjbRK3LbkOH zCwSO+L(6y>;}%8|{7%ML+~@H$xOMEA*SJtDizhoX@(T4i2d_WjUd3Sdk$|xSATyeQ zs4z*m!Y`8Zm?T{go(2FPJ+`;S)Y?6t_|;DYlT?*I_54x8$w3%`K`Bh%5h)6cF|d$5 z3M0dCrf*D+TaY7&^E=Z4K}d6~1_y8gH{;?*7mhjU5f6^VWzOuN@W(keK5_EpR}+p) z2G5nvcY*H&CZ%+jrd5HOBN{k-zPq}$7*ZkZ7ej_Pxq8F?jokV%wTv65vMC!O=i zyt>J?RTZJfgPgObvK&^^UztWv2^u@nE2xi6eR%#2;rT)#ao8{~WqQ~ciLNNa4gdga zSW+lP9%e-PMniWg$4^2EFkJCt-&7D2CnXboZwNq_lDS7@33a(baV$BBazEEjuT5%%XhLq@0+es1`p2`hUW_s%@YrY5WQ)PDYMY6 z3}cnPkts?qFeLrVh;<7Q2%4qvAOSIC^gYc*>TDkdL{MF&a$LclrRX43@z^pM}J)7?I|%BdYMFw0%Fkj zSp&v$0)y9x?>om!%v%U2)`Laj)%#y;69#;@5yk+OzW?o|FWnQKCmw8zCp)tXO7vHz zyS-z#`9>oft!O@9wP&YY)_$$hQ=mM9;b6fqA*PdBxj@153vqb{=A=dmjTSC=C{SafDF`jb3?p zQo@kXJBzi~>QH5iEaIT(X%q#CmxL4`cq>t?Qgp^~kKHCVkC&t=s!w_8*nPWV&WDE+ zSG@>kVMFyXCUNCrLqDQILtkhSv(eG7L!+572fd($r$?`S}5hLw`U_5lntJJ!9OX|t4&0`SAZ zEO8I!5P*X|_!aj#F!p6&x*QXq>|pwHF|erT=2C!wNFa~_V~#5d!FwQMtfd44X9y(f zvkJyrBncqG5}rB?W5IimJnzCW!UF{1uSiOih!>AWnkJrLKJ{6fM&e$5s%e3CO;O*q zGA^ziGlMu7`HWYzrcBkreVWQ`FZAi4=1>RZ!_Z zPN7Xs-V$?r+cZdBR+an2!NjP02%KZiYPC@QJ(Ei6aXk7Y6tB|VP_!>B8jHBuq!eIp zs{X*?W4)&v!_)p>BxVhZ#<>Y9Xed7)=Xb^+u6ucW{faT0thLKmMzu_#TsDTqvBr5% qU-Bhi@+DvLC13I-U-Bg*`2PUsy&4_n*XI@h0000 Date: Fri, 4 Sep 2026 14:28:03 -0700 Subject: [PATCH 09/10] wrote a full project document set with intro, getting started and api references. --- CHANGELOG.md | 1 + Directory.Packages.props | 2 +- README.md | 6 + docs/api-reference-configuration.md | 22 ++ docs/api-reference-core.md | 355 +++++++++++++++++++++++++++ docs/api-reference-hosting.md | 80 ++++++ docs/api-reference-morecontainers.md | 41 ++++ docs/api-reference-test.md | 104 ++++++++ docs/api-reference.md | 25 ++ docs/getting-started-detailed.md | 89 +++++++ docs/guide-command-app-builder.md | 49 ++++ docs/guide-configuration.md | 65 +++++ docs/guide-controls.md | 85 +++++++ docs/guide-dependency-injection.md | 57 +++++ docs/guide-generic-host.md | 67 +++++ docs/guide-more-containers.md | 52 ++++ docs/guide-testing-cli-apps.md | 60 +++++ docs/guide-verbosity-logging.md | 119 +++++++++ docs/introduction.md | 127 ++++++++++ 19 files changed, 1405 insertions(+), 1 deletion(-) create mode 100644 docs/api-reference-configuration.md create mode 100644 docs/api-reference-core.md create mode 100644 docs/api-reference-hosting.md create mode 100644 docs/api-reference-morecontainers.md create mode 100644 docs/api-reference-test.md create mode 100644 docs/api-reference.md create mode 100644 docs/getting-started-detailed.md create mode 100644 docs/guide-command-app-builder.md create mode 100644 docs/guide-configuration.md create mode 100644 docs/guide-controls.md create mode 100644 docs/guide-dependency-injection.md create mode 100644 docs/guide-generic-host.md create mode 100644 docs/guide-more-containers.md create mode 100644 docs/guide-testing-cli-apps.md create mode 100644 docs/guide-verbosity-logging.md create mode 100644 docs/introduction.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d99ab24..738e230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `D20Tek.Spectre.Console.Extensions.Hosting` package that bridges Spectre.Console.Cli to the .NET Generic Host (`Microsoft.Extensions.Hosting`). New public API includes `HostCommandAppExtensions.RunCommandAppAsync`, `HostCommandAppExtensions.RunCommandApp`, `HostCommandAppExtensions.CreateCommandApp`, `HostCommandAppExtensions.CreateCommandAppBuilder`, and the `HostCommandAppBuilder` fluent builder. Run-time registrations captured from Spectre resolve through a composite provider, so a Spectre-registered type can depend on another Spectre-registered type while host services still take precedence. - New `HostStartupBase` and `HostStartupExtensions.WithStartup` in the Hosting package, providing a host-aware startup that splits `ConfigureServices` (run pre-build against the host's `IServiceCollection`) from `ConfigureCommands` (applied post-build when the CommandApp is built). - New `GenericHost.Cli` sample that demonstrates bridging Spectre.Console.Cli to the .NET Generic Host so command types resolve from the host's service provider. +- New `docs/` documentation site with a flat structure: an introduction, a detailed getting-started guide, targeted `guide-*.md` task guides, and an `api-reference.md` hub with per-topic and per-package `api-reference-*.md` references covering the core, Configuration, Hosting, and MoreContainers packages. ### Changed - Upgraded Spectre dependencies to latest version 0.57.2. diff --git a/Directory.Packages.props b/Directory.Packages.props index 16abc7d..df3b315 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - + diff --git a/README.md b/README.md index 2f25418..dec07ee 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ To install in the Visual Studio UI, go to the Tools menu > "Manage NuGet Package Read more about the current release in our [Changelog](CHANGELOG.md). +## Documentation +Full documentation lives in the [docs](docs) folder: +- [Introduction](docs/introduction.md) - what the packages are and the problems they solve. +- [Getting Started](docs/getting-started-detailed.md) - an end-to-end walkthrough, with links to targeted [guides](docs/getting-started-detailed.md#guides). +- [API Reference](docs/api-reference.md) - the complete public surface, split per topic and package. + ## Usage Once you've installed the NuGet package, you can start using it in your Spectre.Console projects. If you would like basic information about how to build Spectre.Console CommandApps, please read: https://darthpedro.net/lessons-cli/. diff --git a/docs/api-reference-configuration.md b/docs/api-reference-configuration.md new file mode 100644 index 0000000..72dceb3 --- /dev/null +++ b/docs/api-reference-configuration.md @@ -0,0 +1,22 @@ +# API Reference: Configuration + +Package: `D20Tek.Spectre.Console.Extensions.Configuration` +Namespace: `D20Tek.Spectre.Console.Extensions.Configuration` + +This document covers the extensions that add `Microsoft.Extensions.Configuration` and Options binding to a `CommandAppBuilder`. Both require a DI container, so call `WithDIContainer` first. + +## Contents + +- [ConfigurationCommandAppBuilderExtensions](#configurationcommandappbuilderextensions) + +## ConfigurationCommandAppBuilderExtensions + +| Member | Signature | Description | +|---|---|---| +| `WithConfiguration` | `static CommandAppBuilder WithConfiguration(this CommandAppBuilder builder, Action? configure = null)` | Builds an `IConfiguration` and registers it in the container. By default reads from an optional `appsettings.json` and environment variables; the delegate can add or replace sources. Throws `ArgumentNullException` when builder is null and `InvalidOperationException` when no DI container is configured. | +| `WithOptions` | `static CommandAppBuilder WithOptions(this CommandAppBuilder builder, string sectionName)` where `TOptions : class` | Binds a configuration section to a strongly typed options class registered as `IOptions`, validating data annotations. Throws `ArgumentNullException` when builder is null, `ArgumentException` when the section name is null or whitespace, and `InvalidOperationException` when no DI container is configured. | + +## Related + +- [Guide: Configuration and Options Binding](guide-configuration.md) +- [API Reference hub](api-reference.md) diff --git a/docs/api-reference-core.md b/docs/api-reference-core.md new file mode 100644 index 0000000..a58ae44 --- /dev/null +++ b/docs/api-reference-core.md @@ -0,0 +1,355 @@ +# API Reference: Core + +Package: `D20Tek.Spectre.Console.Extensions` + +This document covers the full public surface of the core package: the types that create and configure a `CommandApp`, the dependency-injection bridge, verbosity-aware logging, the verbosity output service, and the additional prompt and console controls. The `Testing` namespace is documented separately in [API Reference: Testing](api-reference-test.md). + +## Contents + +- [Application Building](#application-building) + - [CommandAppBuilder](#commandappbuilder) + - [StartupBase](#startupbase) + - [ConfiguratorExtensions](#configuratorextensions) + - [ICommandConfiguration](#icommandconfiguration) +- [Dependency Injection](#dependency-injection) + - [CommandAppBuilderExtensions](#commandappbuilderextensions) + - [DependencyInjectionTypeRegistrar](#dependencyinjectiontyperegistrar) + - [DependencyInjectionTypeResolver](#dependencyinjectiontyperesolver) + - [ISupportLifetimes](#isupportlifetimes) + - [LifetimeExtensions](#lifetimeextensions) + - [TypeRegistrarExtensions](#typeregistrarextensions) +- [Logging](#logging) + - [LoggingCommandAppBuilderExtensions](#loggingcommandappbuilderextensions) + - [SpectreLoggingExtensions](#spectreloggingextensions) + - [SpectreConsoleLogger](#spectreconsolelogger) + - [SpectreConsoleLoggerProvider](#spectreconsoleloggerprovider) + - [SpectreConsoleLoggerOptions](#spectreconsoleloggeroptions) + - [VerbosityLevelExtensions](#verbositylevelextensions) +- [Verbosity Output](#verbosity-output) + - [VerbosityLevel](#verbositylevel) + - [VerbositySettings](#verbositysettings) + - [IVerbosityWriter](#iverbositywriter) + - [ConsoleVerbosityWriter](#consoleverbositywriter) +- [Controls](#controls) + - [CurrencyPrompt](#currencyprompt) + - [CurrencyPresenter](#currencypresenter) + - [HistoryTextPrompt<T>](#historytextpromptt) + - [HistoryTextPromptExtensions](#historytextpromptextensions) + - [TableExtensions](#tableextensions) + - [AnsiConsoleExtensions](#ansiconsoleextensions) + +--- + +## Application Building + +Namespace: `D20Tek.Spectre.Console.Extensions` + +### CommandAppBuilder + +A builder for creating, configuring, and running a `CommandApp`. + +| Member | Signature | Description | +|---|---|---| +| `Registrar` | `ITypeRegistrar? Registrar { get; }` | The type registrar configured for this builder, or null if none has been set. Exposed so add-on extension packages can reach the underlying DI container. | +| `GetServiceCollection` | `IServiceCollection GetServiceCollection()` | Gets the registrar's underlying service collection. Throws `InvalidOperationException` when no registrar has been configured. | +| `WithStartup` | `CommandAppBuilder WithStartup()` where `TStartup : StartupBase, new()` | Sets the startup class used to configure services and commands. | +| `SetRegistrar` | `CommandAppBuilder SetRegistrar(ITypeRegistrar registrar)` | Sets a custom type registrar. Throws `ArgumentNullException` when registrar is null. | +| `WithDefaultCommand` | `CommandAppBuilder WithDefaultCommand()` where `TDefault : class, ICommand` | Sets the command that runs when no command name is supplied. | +| `Build` | `CommandAppBuilder Build()` | Configures services, creates the CommandApp, applies the default command, and configures commands. Throws `ArgumentNullException` when no startup was set. | +| `RunAsync` | `Task RunAsync(string[] args)` | Runs the CommandApp asynchronously and returns the exit code. | +| `Run` | `int Run(string[] args)` | Runs the CommandApp synchronously and returns the exit code. | + +### StartupBase + +Abstract base class for defining a startup class that configures services and commands. + +| Member | Signature | Description | +|---|---|---| +| `ConfigureServices` | `abstract void ConfigureServices(ITypeRegistrar registrar)` | Override to register application services in the type registrar. | +| `ConfigureCommands` | `abstract IConfigurator ConfigureCommands(IConfigurator config)` | Override to configure console commands. Returns the configurator that was used. | + +### ConfiguratorExtensions + +Extension methods for the Spectre.Console.Cli `IConfigurator`. + +| Member | Signature | Description | +|---|---|---| +| `ApplyConfiguration` | `static IConfigurator ApplyConfiguration(this IConfigurator configurator, ICommandConfiguration config)` | Applies the specified command configuration instance to the configurator and returns it. | + +### ICommandConfiguration + +Interface for classes that encapsulate configuration for a grouped set of commands. + +| Member | Signature | Description | +|---|---|---| +| `Configure` | `void Configure(IConfigurator config)` | Configures this group's commands on the specified configurator. | + +--- + +## Dependency Injection + +Namespace: `D20Tek.Spectre.Console.Extensions.Injection` (the `WithDIContainer` extension is in `D20Tek.Spectre.Console.Extensions`) + +### CommandAppBuilderExtensions + +Extension methods that supply a DI container to a `CommandAppBuilder`. + +| Member | Signature | Description | +|---|---|---| +| `WithDIContainer` | `static CommandAppBuilder WithDIContainer(this CommandAppBuilder builder, IServiceCollection? services = null, ServiceLifetime lifetime = ServiceLifetime.Singleton)` | Configures the `Microsoft.Extensions.DependencyInjection` registrar, optionally with pre-registered services and a default lifetime. | + +### DependencyInjectionTypeRegistrar + +A sealed `ITypeRegistrar` and `ISupportLifetimes` backed by an `IServiceCollection`. + +| Member | Signature | Description | +|---|---|---| +| `Services` | `IServiceCollection Services { get; }` | The underlying service collection. | +| `Build` | `ITypeResolver Build()` | Builds a type resolver over the registered services. | +| `Register` | `void Register(Type service, Type implementation)` | Registers a service and implementation type. | +| `RegisterInstance` | `void RegisterInstance(Type service, object implementation)` | Registers an existing instance for a service type. | +| `RegisterLazy` | `void RegisterLazy(Type service, Func factoryMethod)` | Registers a lazily created instance via a factory. | + +### DependencyInjectionTypeResolver + +A sealed `ITypeResolver` and `IDisposable` over an `IServiceProvider`. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `DependencyInjectionTypeResolver(IServiceProvider provider)` | Creates the resolver over the given provider. | +| `Resolve` | `object? Resolve(Type? type)` | Resolves a service of the requested type. | +| `Dispose` | `void Dispose()` | Disposes the underlying provider scope. | + +### ISupportLifetimes + +Interface implemented by registrars that support service lifetimes. + +### LifetimeExtensions + +Lifetime-aware registration helpers on `ISupportLifetimes`. + +| Member | Signature | Description | +|---|---|---| +| `RegisterSingleton` | `static ISupportLifetimes RegisterSingleton(this ISupportLifetimes registrar)` | Registers a singleton service and implementation. | +| `RegisterSingleton` | `static ISupportLifetimes RegisterSingleton(this ISupportLifetimes registrar, TService instance)` | Registers a singleton instance. | +| `RegisterSingleton` | `static ISupportLifetimes RegisterSingleton(this ISupportLifetimes registrar, Func implementationFactory)` | Registers a singleton service created by a factory. | +| `RegisterScoped` | `static ISupportLifetimes RegisterScoped(this ISupportLifetimes registrar)` | Registers a scoped service and implementation. | +| `RegisterScoped` | `static ISupportLifetimes RegisterScoped(this ISupportLifetimes registrar, Func implementationFactory)` | Registers a scoped service created by a factory. | +| `RegisterTransient` | `static ISupportLifetimes RegisterTransient(this ISupportLifetimes registrar)` | Registers a transient service and implementation. | +| `RegisterTransient` | `static ISupportLifetimes RegisterTransient(this ISupportLifetimes registrar, Func implementationFactory)` | Registers a transient service created by a factory. | + +### TypeRegistrarExtensions + +| Member | Signature | Description | +|---|---|---| +| `WithLifetimes` | `static ISupportLifetimes WithLifetimes(this ITypeRegistrar registrar)` | Returns the registrar as `ISupportLifetimes` so lifetime helpers can be used. Throws when the registrar does not support lifetimes. | + +--- + +## Logging + +Namespaces: `D20Tek.Spectre.Console.Extensions`, `D20Tek.Spectre.Console.Extensions.Logging` + +### LoggingCommandAppBuilderExtensions + +Extension methods that add verbosity-aware logging to a `CommandAppBuilder`. + +| Member | Signature | Description | +|---|---|---| +| `WithLogging` | `static CommandAppBuilder WithLogging(this CommandAppBuilder builder, VerbosityLevel minimumVerbosity = VerbosityLevel.Normal, IAnsiConsole? console = null, Action? configure = null)` | Adds logging that renders through an `IAnsiConsole`, with the minimum log level derived from the verbosity. Requires a container that supports lifetimes. Throws `ArgumentNullException` when builder is null and `InvalidOperationException` when no suitable registrar is configured. | + +### SpectreLoggingExtensions + +Extension methods for `ILoggingBuilder`. + +| Member | Signature | Description | +|---|---|---| +| `AddSpectreConsole` | `static ILoggingBuilder AddSpectreConsole(this ILoggingBuilder builder, VerbosityLevel minimumVerbosity, Action? configure = null)` | Registers the Spectre console logger provider and sets the builder's minimum level from the mapped verbosity. | + +### SpectreConsoleLogger + +A sealed `ILogger` that renders log entries through an `IAnsiConsole`. + +| Member | Signature | Description | +|---|---|---| +| `BeginScope` | `IDisposable? BeginScope(TState state)` where `TState : notnull` | Begins a logical operation scope. Returns null (scopes are not tracked). | +| `IsEnabled` | `bool IsEnabled(LogLevel logLevel)` | Indicates whether the given log level is enabled. | + +### SpectreConsoleLoggerProvider + +A sealed `ILoggerProvider` that creates `SpectreConsoleLogger` instances. + +### SpectreConsoleLoggerOptions + +Options controlling how log entries are rendered. + +| Member | Signature | Description | +|---|---|---| +| `IncludeLevelLabel` | `bool IncludeLevelLabel { get; set; }` | Whether to include the log level label. Defaults to true. | +| `IncludeCategory` | `bool IncludeCategory { get; set; }` | Whether to include the category name. | +| `IncludeTimestamp` | `bool IncludeTimestamp { get; set; }` | Whether to include a timestamp. | +| `TimestampFormat` | `string TimestampFormat { get; set; }` | The timestamp format string. Defaults to `"HH:mm:ss"`. | + +### VerbosityLevelExtensions + +Mapping helpers between `VerbosityLevel` and `LogLevel`. + +| Member | Signature | Description | +|---|---|---| +| `ToLogLevel` | `static LogLevel ToLogLevel(this VerbosityLevel verbosity)` | Maps a verbosity level to the corresponding minimum log level. | +| `ToVerbosityLevel` | `static VerbosityLevel ToVerbosityLevel(this LogLevel logLevel)` | Maps a log level to the corresponding verbosity level. | + +--- + +## Verbosity Output + +Namespaces: `D20Tek.Spectre.Console.Extensions.Settings`, `D20Tek.Spectre.Console.Extensions.Services` + +### VerbosityLevel + +Enum that describes the amount of output an application should emit, shared across many CLI tools. Ordered from least to most output: `Quiet` (0), `Minimal` (1), `Normal` (2), `Detailed` (3), and `Diagnostic` (4). Each level also has a shorthand alias that maps to the same value: `Q`, `M`, `N`, `D`, and `Diag`. + +### VerbositySettings + +A `CommandSettings` base class that adds a verbosity option. + +| Member | Signature | Description | +|---|---|---| +| `Verbosity` | `VerbosityLevel Verbosity { get; set; }` | The requested verbosity level. Defaults to `VerbosityLevel.Normal`. | + +### IVerbosityWriter + +Service that writes plain text or Spectre markup only when the current verbosity allows it. + +| Member | Signature | Description | +|---|---|---| +| `Verbosity` | `VerbosityLevel Verbosity { get; set; }` | The current verbosity threshold. | +| `MarkupSummary` | `void MarkupSummary(string text = "")` | Writes markup at the Minimal level. | +| `MarkupNormal` | `void MarkupNormal(string text = "")` | Writes markup at the Normal level. | +| `MarkupDetailed` | `void MarkupDetailed(string text = "")` | Writes markup at the Detailed level. | +| `MarkupDiagnostics` | `void MarkupDiagnostics(string text = "")` | Writes markup at the Diagnostic level. | +| `WriteSummary` | `void WriteSummary(string text = "")` | Writes plain text at the Minimal level. | +| `WriteNormal` | `void WriteNormal(string text = "")` | Writes plain text at the Normal level. | +| `WriteDetailed` | `void WriteDetailed(string text = "")` | Writes plain text at the Detailed level. | +| `WriteDiagnostics` | `void WriteDiagnostics(string text = "")` | Writes plain text at the Diagnostic level. | + +### ConsoleVerbosityWriter + +The default `IVerbosityWriter` implementation that renders through an `IAnsiConsole`. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `ConsoleVerbosityWriter(IAnsiConsole console)` | Creates the writer over the given console. | +| `Verbosity` | `VerbosityLevel Verbosity { get; set; }` | The current verbosity threshold. Defaults to `VerbosityLevel.Normal`. | + +The `Markup*` and `Write*` members match the `IVerbosityWriter` contract above. + +--- + +## Controls + +Namespace: `D20Tek.Spectre.Console.Extensions.Controls` + +### CurrencyPrompt + +A culture-aware text prompt that validates currency input and converts it to a `decimal`. Implements `IPrompt` and `IHasCulture`. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `CurrencyPrompt(string promptLabel)` | Creates the prompt with a label. Throws `ArgumentNullException` when the label is null or empty. | +| `Culture` | `CultureInfo? Culture { get; set; }` | The culture used for parsing and formatting; defaults to the current culture. | +| `WithCulture` | `CurrencyPrompt WithCulture(CultureInfo culture)` | Sets the culture used by the prompt. | +| `WithDefaultValue` | `CurrencyPrompt WithDefaultValue(decimal value)` | Sets the default value used when input is empty. | +| `WithMinValue` | `CurrencyPrompt WithMinValue(decimal min)` | Sets the minimum allowed value. | +| `WithMaxValue` | `CurrencyPrompt WithMaxValue(decimal max)` | Sets the maximum allowed value. | +| `WithExampleHint` | `CurrencyPrompt WithExampleHint(decimal value)` | Sets example hint text formatted for the current culture. | +| `WithErrorMessage` | `CurrencyPrompt WithErrorMessage(string message)` | Sets a custom validation error message. | +| `WithPromptStyle` | `CurrencyPrompt WithPromptStyle(Style promptStyle)` | Sets the style used for the prompt label. | +| `WithValidator` | `CurrencyPrompt WithValidator(Func validator)` | Sets a custom validation function used by the prompt. | +| `Show` | `decimal Show(IAnsiConsole console)` | Shows the prompt and returns the entered value. | +| `ShowAsync` | `Task ShowAsync(IAnsiConsole console, CancellationToken token)` | Shows the prompt asynchronously and returns the entered value. | + +### CurrencyPresenter + +Static helper for culture-aware currency display. + +| Member | Signature | Description | +|---|---|---| +| `Render` | `static string Render(this decimal value, string? positiveStyle = null, string? negativeStyle = null)` | Formats a decimal as culture-aware currency markup, with optional positive and negative styles. | +| `RenderAbbreviated` | `static string RenderAbbreviated(this decimal value, string? positiveStyle = null, string? negativeStyle = null)` | Formats a decimal as culture-aware currency markup using an abbreviated presentation for large values, with optional positive and negative styles. | + +### HistoryTextPrompt<T> + +A sealed text prompt with shell-style history navigation and tab auto-completion. Implements `IPrompt` and `IHasCulture`. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `HistoryTextPrompt(string prompt, StringComparer? comparer = null)` | Creates the prompt with markup text and an optional comparer used for choices. Throws `ArgumentNullException` when prompt is null. | +| `PromptStyle` | `Style? PromptStyle { get; set; }` | The prompt style. | +| `Choices` | `List Choices { get; }` | The list of auto-complete choices. | +| `Culture` | `CultureInfo? Culture { get; set; }` | The culture used by the prompt. | +| `InvalidChoiceMessage` | `string InvalidChoiceMessage { get; set; }` | The message shown for invalid choices. | +| `IsSecret` | `bool IsSecret { get; set; }` | Whether input is hidden. | +| `Mask` | `char? Mask { get; set; }` | The character used to mask a secret prompt. Defaults to `'*'`. | +| `ValidationErrorMessage` | `string ValidationErrorMessage { get; set; }` | The message shown for invalid input. | +| `ShowChoices` | `bool ShowChoices { get; set; }` | Whether choices are shown. Defaults to true. | +| `ShowDefaultValue` | `bool ShowDefaultValue { get; set; }` | Whether the default value is shown. Defaults to true. | +| `AllowEmpty` | `bool AllowEmpty { get; set; }` | Whether an empty result is valid. | +| `Converter` | `Func Converter { get; set; }` | Converts a value to its display string. Defaults to the type's `TypeConverter`. | +| `Validator` | `Func? Validator { get; set; }` | The validator applied to input. | +| `DefaultValueStyle` | `Style? DefaultValueStyle { get; set; }` | The style for the default value. Defaults to green when null. | +| `ChoicesStyle` | `Style? ChoicesStyle { get; set; }` | The style for the choices list. Defaults to blue when null. | +| `History` | `List History { get; }` | The history used for up/down arrow selection of previous entries. | +| `Show` | `T Show(IAnsiConsole console)` | Shows the prompt and returns the captured value. | +| `ShowAsync` | `Task ShowAsync(IAnsiConsole console, CancellationToken cancellationToken)` | Shows the prompt asynchronously and returns the captured value. | + +### HistoryTextPromptExtensions + +Fluent extension methods for `HistoryTextPrompt`. + +| Member | Signature | Description | +|---|---|---| +| `AllowEmpty` | `HistoryTextPrompt AllowEmpty(this HistoryTextPrompt obj)` | Permits empty input. | +| `PromptStyle` | `HistoryTextPrompt PromptStyle(this HistoryTextPrompt obj, Style style)` | Sets the prompt style. | +| `ShowChoices` | `HistoryTextPrompt ShowChoices(this HistoryTextPrompt obj, bool show)` / `ShowChoices(this HistoryTextPrompt obj)` | Controls whether choices are displayed. | +| `HideChoices` | `HistoryTextPrompt HideChoices(this HistoryTextPrompt obj)` | Hides choices. | +| `ShowDefaultValue` | `HistoryTextPrompt ShowDefaultValue(this HistoryTextPrompt obj, bool show = true)` | Controls default value display. | +| `HideDefaultValue` | `HistoryTextPrompt HideDefaultValue(this HistoryTextPrompt obj)` | Hides the default value. | +| `ValidationErrorMessage` | `HistoryTextPrompt ValidationErrorMessage(this HistoryTextPrompt obj, string message)` | Sets the validation error message. | +| `InvalidChoiceMessage` | `HistoryTextPrompt InvalidChoiceMessage(this HistoryTextPrompt obj, string message)` | Sets the invalid choice message. | +| `DefaultValue` | `HistoryTextPrompt DefaultValue(this HistoryTextPrompt obj, T value)` | Sets the default value. | +| `Validate` | `HistoryTextPrompt Validate(this HistoryTextPrompt obj, Func validator, string? message = null)` | Adds a boolean validator with an optional error message. | +| `Validate` | `HistoryTextPrompt Validate(this HistoryTextPrompt obj, Func validator)` | Adds a custom validator. | +| `AddChoice` | `HistoryTextPrompt AddChoice(this HistoryTextPrompt obj, T choice)` | Adds a single choice. | +| `AddChoices` | `HistoryTextPrompt AddChoices(this HistoryTextPrompt obj, IEnumerable choices)` | Adds multiple choices. | +| `Secret` | `HistoryTextPrompt Secret(this HistoryTextPrompt obj, char? mask = '*')` | Masks input for secrets. | +| `WithDisplayConverter` | `HistoryTextPrompt WithDisplayConverter(this HistoryTextPrompt obj, Func displaySelector)` | Sets how values are displayed. | +| `DefaultValueStyle` | `HistoryTextPrompt DefaultValueStyle(this HistoryTextPrompt obj, Style? style)` | Sets the default value style. | +| `ChoicesStyle` | `HistoryTextPrompt ChoicesStyle(this HistoryTextPrompt obj, Style? style)` | Sets the choices style. | +| `AddHistory` | `HistoryTextPrompt AddHistory(this HistoryTextPrompt obj, IEnumerable history)` | Seeds the navigable history list. | + +### TableExtensions + +Extension methods for Spectre.Console `Table` controls. + +| Member | Signature | Description | +|---|---|---| +| `AddSeparatorRow` | `static void AddSeparatorRow(this Table table, int[] columnWidths, string style = "grey", char separatorChar = '─')` | Adds a separator row using the given column widths, style, and separator character. | + +### AnsiConsoleExtensions + +Extension methods for `IAnsiConsole`. + +| Member | Signature | Description | +|---|---|---| +| `WriteMessages` | `static void WriteMessages(this IAnsiConsole console, params string[] messages)` | Writes multiple markup messages. | +| `WriteMessagesConditional` | `static void WriteMessagesConditional(this IAnsiConsole console, bool condition, params string[] messages)` | Writes multiple markup messages only when the condition is true. | + +## Related + +- [Guide: Building CommandApps with CommandAppBuilder](guide-command-app-builder.md) +- [Guide: Dependency Injection and Lifetimes](guide-dependency-injection.md) +- [Guide: Verbosity and Logging](guide-verbosity-logging.md) +- [Guide: Controls](guide-controls.md) +- [API Reference: Testing](api-reference-test.md) +- [API Reference hub](api-reference.md) diff --git a/docs/api-reference-hosting.md b/docs/api-reference-hosting.md new file mode 100644 index 0000000..8de3449 --- /dev/null +++ b/docs/api-reference-hosting.md @@ -0,0 +1,80 @@ +# API Reference: Hosting + +Package: `D20Tek.Spectre.Console.Extensions.Hosting` +Namespace: `D20Tek.Spectre.Console.Extensions.Hosting` + +This document covers bridging Spectre.Console.Cli to the .NET Generic Host, the host-aware startup, and the host command-app extensions and builder. + +## Contents + +- [HostCommandAppExtensions](#hostcommandappextensions) +- [HostCommandAppBuilder](#hostcommandappbuilder) +- [HostStartupBase](#hoststartupbase) +- [HostStartupExtensions](#hoststartupextensions) +- [HostRegistration](#hostregistration) +- [HostTypeResolver](#hosttyperesolver) + +## HostCommandAppExtensions + +Extension methods on `IHost` that create and run a bridged `CommandApp`. + +| Member | Signature | Description | +|---|---|---| +| `RunCommandAppAsync` | `static Task RunCommandAppAsync(this IHost host, string[] args, Action configure)` | Creates a bridged CommandApp and runs it asynchronously. Throws `ArgumentNullException` when host, args, or configure is null. | +| `RunCommandApp` | `static int RunCommandApp(this IHost host, string[] args, Action configure)` | Creates a bridged CommandApp and runs it synchronously. Throws `ArgumentNullException` when host, args, or configure is null. | +| `CreateCommandApp` | `static CommandApp CreateCommandApp(this IHost host, Action configure)` | Creates a bridged CommandApp using a `HostTypeRegistrar`, applying any registered `HostStartupBase` command configuration followed by the supplied configuration. Throws `ArgumentNullException` when host or configure is null. | +| `CreateCommandAppBuilder` | `static HostCommandAppBuilder CreateCommandAppBuilder(this IHost host)` | Creates a fluent `HostCommandAppBuilder` bridged to the host. Throws `ArgumentNullException` when host is null. | + +## HostCommandAppBuilder + +A sealed fluent builder for a host-bridged CommandApp. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `HostCommandAppBuilder(IHost host)` | Creates the builder bridged to the given host. Throws `ArgumentNullException` when host is null. | +| `Host` | `IHost Host { get; }` | The host associated with this builder. | +| `WithDefaultCommand` | `HostCommandAppBuilder WithDefaultCommand()` where `TDefault : class, ICommand` | Sets the default command. | +| `ConfigureCommands` | `HostCommandAppBuilder ConfigureCommands(Action configure)` | Adds command configuration. Throws `ArgumentNullException` when configure is null. | +| `Build` | `HostCommandAppBuilder Build()` | Builds the bridged CommandApp. | +| `RunAsync` | `Task RunAsync(string[] args)` | Runs the app asynchronously, building it first when needed. Throws `ArgumentNullException` when args is null. | +| `Run` | `int Run(string[] args)` | Runs the app synchronously, building it first when needed. Throws `ArgumentNullException` when args is null. | + +## HostStartupBase + +Abstract host-aware startup that splits service registration from command configuration. + +| Member | Signature | Description | +|---|---|---| +| `ConfigureServices` | `abstract void ConfigureServices(IServiceCollection services)` | Override to register services against the host's service collection (pre-build). | +| `ConfigureCommands` | `abstract IConfigurator ConfigureCommands(IConfigurator config)` | Override to configure commands, applied post-build when the CommandApp is created. | + +## HostStartupExtensions + +| Member | Signature | Description | +|---|---|---| +| `WithStartup` | `static IHostApplicationBuilder WithStartup(this IHostApplicationBuilder builder)` where `TStartup : HostStartupBase, new()` | Registers a `HostStartupBase`, running its `ConfigureServices` immediately and registering it so `ConfigureCommands` is applied when the CommandApp is built. Throws `ArgumentNullException` when builder is null. | + +## HostRegistration + +A sealed descriptor for a runtime registration captured from Spectre and resolved through the host's provider. + +| Member | Signature | Description | +|---|---|---| +| `ForType` | `static HostRegistration ForType(Type implementationType)` | Creates a registration for an implementation type. | +| `ForInstance` | `static HostRegistration ForInstance(object instance)` | Creates a registration for an existing instance. | +| `ForFactory` | `static HostRegistration ForFactory(Func factory)` | Creates a registration backed by a factory. | +| `Resolve` | `object Resolve(IServiceProvider provider)` | Resolves the registration using the given provider. | + +## HostTypeResolver + +A sealed `ITypeResolver` that resolves types through a composite of the host provider and captured registrations. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `HostTypeResolver(IServiceProvider provider, IReadOnlyDictionary registrations)` | Creates the resolver over the provider and captured registrations. | +| `Resolve` | `object? Resolve(Type? type)` | Resolves a type, with host services taking precedence over captured registrations. | + +## Related + +- [Guide: Generic Host Integration](guide-generic-host.md) +- [API Reference hub](api-reference.md) diff --git a/docs/api-reference-morecontainers.md b/docs/api-reference-morecontainers.md new file mode 100644 index 0000000..b7fef87 --- /dev/null +++ b/docs/api-reference-morecontainers.md @@ -0,0 +1,41 @@ +# API Reference: MoreContainers + +Package: `D20Tek.Spectre.Console.Extensions.MoreContainers` +Namespace: `D20Tek.Spectre.Console.Extensions.Injection` (builder extensions in `D20Tek.Spectre.Console.Extensions`) + +This document covers `ITypeRegistrar`/`ITypeResolver` support for Autofac, Lamar, LightInject, and Ninject, along with the builder extensions that select each container. + +## Contents + +- [CommandAppBuilderExtensions](#commandappbuilderextensions) +- [Registrars and Resolvers](#registrars-and-resolvers) + +## CommandAppBuilderExtensions + +Extension methods that configure a specific container on a `CommandAppBuilder`. + +| Member | Signature | Description | +|---|---|---| +| `WithNinjectContainer` | `static CommandAppBuilder WithNinjectContainer(this CommandAppBuilder builder, StandardKernel? container = null)` | Configures the Ninject registrar, creating a new `StandardKernel` when none is provided. | +| `WithAutofacContainer` | `static CommandAppBuilder WithAutofacContainer(this CommandAppBuilder builder, ContainerBuilder? container = null)` | Configures the Autofac registrar, creating a new `ContainerBuilder` when none is provided. | +| `WithLightInjectContainer` | `static CommandAppBuilder WithLightInjectContainer(this CommandAppBuilder builder, ServiceContainer? container = null, ServiceLifetime lifetime = ServiceLifetime.Singleton)` | Configures the LightInject registrar with an optional container and default lifetime. | +| `WithLamarContainer` | `static CommandAppBuilder WithLamarContainer(this CommandAppBuilder builder, ServiceRegistry? serviceRegistry = null, ServiceLifetime lifetime = ServiceLifetime.Singleton)` | Configures the Lamar registrar with an optional service registry and default lifetime. | + +## Registrars and Resolvers + +Each container has a registrar (`ITypeRegistrar`) and resolver (`ITypeResolver`) that bridge Spectre.Console.Cli to that framework. They are configured for you by the builder extensions above; you typically do not construct them directly. + +| Container | Registrar | Resolver | +|---|---|---| +| Ninject | `NinjectTypeRegistrar(StandardKernel kernel)` | `NinjectTypeResolver(StandardKernel provider)` | +| Autofac | `AutofacTypeRegistrar(ContainerBuilder container)` | `AutofacTypeResolver(ILifetimeScope scope)` | +| LightInject | `LightInjectTypeRegistrar(ServiceContainer container)` | `LightInjectTypeResolver(IServiceFactory container)` | +| Lamar | `LamarTypeRegistrar(ServiceRegistry registry, ServiceLifetime lifetime = ServiceLifetime.Singleton)` (also implements `ISupportLifetimes`) | `LamarTypeResolver(Container container)` | + +Each registrar implements the standard `ITypeRegistrar` members: `Build`, `Register`, `RegisterInstance`, and `RegisterLazy`. Each resolver implements `Resolve` and, where applicable, `IDisposable.Dispose`. + +## Related + +- [Guide: Using Additional DI Containers](guide-more-containers.md) +- [API Reference: Core](api-reference-core.md#dependency-injection) +- [API Reference hub](api-reference.md) diff --git a/docs/api-reference-test.md b/docs/api-reference-test.md new file mode 100644 index 0000000..f872a85 --- /dev/null +++ b/docs/api-reference-test.md @@ -0,0 +1,104 @@ +# API Reference: Testing + +Package: `D20Tek.Spectre.Console.Extensions` +Namespace: `D20Tek.Spectre.Console.Extensions.Testing` (with extensions in `D20Tek.Spectre.Console.Extensions`) + +This document covers the test context classes, the end-to-end runner, and the result types used to test Spectre.Console CLIs. The rest of the core package is documented in [API Reference: Core](api-reference-core.md). + +## Contents + +- [CommandAppBuilderTestContext](#commandappbuildertestcontext) +- [CommandAppTestContext](#commandapptestcontext) +- [CommandConfigurationTestContext](#commandconfigurationtestcontext) +- [CommandAppE2ERunner](#commandappe2erunner) +- [CommandAppResult](#commandappresult) +- [CommandAppBasicResult](#commandappbasicresult) +- [CommandMetadata](#commandmetadata) +- [CommandAppBuilderTestExtensions](#commandappbuildertestextensions) + +## CommandAppBuilderTestContext + +Wraps a `CommandAppBuilder` and a `TestConsole` for testing builder-based apps. + +| Member | Signature | Description | +|---|---|---| +| `Console` | `TestConsole Console { get; }` | The test console capturing output. | +| `Builder` | `CommandAppBuilder Builder { get; }` | The builder under test. | +| Constructor | `CommandAppBuilderTestContext()` | Creates the context with a test console. | +| `Run` | `CommandAppResult Run(string[] args)` | Runs the app synchronously. | +| `RunAsync` | `Task RunAsync(string[] args)` | Runs the app asynchronously. | +| `RunWithException` | `CommandAppResult RunWithException(string[] args)` | Runs and captures an expected exception of type `T`. | +| `RunWithExceptionAsync` | `Task RunWithExceptionAsync(string[] args)` | Async variant of `RunWithException`. | + +## CommandAppTestContext + +Sets up a type registrar and a `TestConsole` for testing without the builder. + +| Member | Signature | Description | +|---|---|---| +| `Registrar` | `ITypeRegistrar Registrar { get; }` | The registrar used to configure the app. | +| `Console` | `TestConsole Console { get; }` | The test console capturing output. | +| Constructor | `CommandAppTestContext()` | Creates the context. | +| `Configure` | `void Configure(Action action)` | Configures the app's commands. | +| `Run` | `CommandAppResult Run(string[] args)` | Runs the app synchronously. | +| `RunAsync` | `Task RunAsync(string[] args)` | Runs the app asynchronously. | +| `RunWithException` | `CommandAppResult RunWithException(string[] args)` | Runs and captures an expected exception of type `T`. | +| `RunWithExceptionAsync` | `Task RunWithExceptionAsync(string[] args)` | Async variant of `RunWithException`. | + +## CommandConfigurationTestContext + +Exposes a registrar, resolver, and test configurator for asserting on command configuration. + +| Member | Signature | Description | +|---|---|---| +| `Registrar` | `ITypeRegistrar Registrar { get; }` | The registrar used during configuration. | +| `Resolver` | `ITypeResolver Resolver { get; }` | The resolver built from the registrar. | +| `Configurator` | `ITestConfigurator Configurator { get; }` | The test configurator capturing command metadata. | +| Constructor | `CommandConfigurationTestContext()` | Creates the context. | + +## CommandAppE2ERunner + +Static runner that invokes a real `Main` entry point and captures its output. The entry point may be synchronous (`Func`) or asynchronous (`Func>`). + +| Member | Signature | Description | +|---|---|---| +| `Run` | `static CommandAppBasicResult Run(Func mainEntryPoint, string commandLine)` | Runs the synchronous entry point with a command-line string. | +| `Run` | `static CommandAppBasicResult Run(Func mainEntryPoint, string[] args)` | Runs the synchronous entry point with pre-split arguments. | +| `RunAsync` | `static Task RunAsync(Func> mainEntryPointAsync, string commandLine)` | Runs the asynchronous entry point with a command-line string. | +| `RunAsync` | `static Task RunAsync(Func> mainEntryPointAsync, string[] args)` | Runs the asynchronous entry point with pre-split arguments. | + +## CommandAppResult + +Result of a context-based run. Derives from `CommandAppBasicResult`, adding the captured command context and settings. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `CommandAppResult(int exitCode, string output, CommandContext? context, CommandSettings? settings)` | Creates the result. | +| `Context` | `CommandContext? Context { get; }` | The command context for this execution result. | +| `Settings` | `CommandSettings? Settings { get; }` | The command settings for this execution result. | + +## CommandAppBasicResult + +Result of an end-to-end run. + +| Member | Signature | Description | +|---|---|---| +| Constructor | `CommandAppBasicResult(int exitCode, string? output)` | Creates the result. | +| `ExitCode` | `int ExitCode { get; }` | The process exit code. | +| `Output` | `string Output { get; }` | The captured output, or an empty string when none. | + +## CommandMetadata + +Describes a configured command or branch for assertions in configuration tests. Exposes properties such as `Name`, `Aliases`, `Description`, `Data`, `CommandType`, `SettingsType`, `Delegate`, `AsyncDelegate`, `IsDefaultCommand`, `IsHidden`, `Children`, and `Examples`, plus factory methods `FromBranch`, `FromBranch`, `FromType`, `FromDelegate`, and `FromAsyncDelegate`. + +## CommandAppBuilderTestExtensions + +| Member | Signature | Description | +|---|---|---| +| `WithTestConfiguration` | `static CommandAppBuilder WithTestConfiguration(this CommandAppBuilder builder, Action action)` | Applies additional test configuration to the CommandApp after it is built. | + +## Related + +- [Guide: Testing CLI Applications](guide-testing-cli-apps.md) +- [API Reference: Core](api-reference-core.md) +- [API Reference hub](api-reference.md) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..272d845 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,25 @@ +# API Reference + +This is the API reference hub for D20Tek.Spectre.Console.Extensions. Because the library spans a core package and several add-ons, the reference is split into focused documents that mirror the namespaces and packages. Start here, then follow the link for the area you are working in. + +## Core Package Reference + +| Document | Namespace | Covers | +|---|---|---| +| [Core](api-reference-core.md) | `D20Tek.Spectre.Console.Extensions` | The `CommandAppBuilder` and startup types, the DI type registrar/resolver and lifetime helpers, verbosity-aware logging, the verbosity output service, and the extra prompt and console controls. | +| [Testing](api-reference-test.md) | `D20Tek.Spectre.Console.Extensions.Testing` | The test context classes, the end-to-end runner, and the result types. | + +## Add-on Package Reference + +| Document | Package | Covers | +|---|---|---| +| [Configuration](api-reference-configuration.md) | `D20Tek.Spectre.Console.Extensions.Configuration` | The `WithConfiguration` and `WithOptions` builder extensions. | +| [Hosting](api-reference-hosting.md) | `D20Tek.Spectre.Console.Extensions.Hosting` | Generic Host bridging, `HostStartupBase`, and the host command-app extensions and builder. | +| [MoreContainers](api-reference-morecontainers.md) | `D20Tek.Spectre.Console.Extensions.MoreContainers` | The Autofac, Lamar, LightInject, and Ninject registrars, resolvers, and builder extensions. | + +## Conventions + +- The core package targets `net9.0` and `net10.0` and depends only on `Microsoft.Extensions.DependencyInjection`; other containers are additive through the MoreContainers package. +- Extension methods are documented under the type they extend or the package that provides them. +- Types and members marked `internal` are excluded from this reference; only the public surface is documented. +- For task-oriented walkthroughs, see the [guides](getting-started-detailed.md#guides). diff --git a/docs/getting-started-detailed.md b/docs/getting-started-detailed.md new file mode 100644 index 0000000..858ea5d --- /dev/null +++ b/docs/getting-started-detailed.md @@ -0,0 +1,89 @@ +# Getting Started + +This guide walks you from an empty project to a running Spectre.Console CLI built with D20Tek.Spectre.Console.Extensions. It covers installation, the `CommandAppBuilder`, a `StartupBase` class, dependency injection, and running the app. Deeper topics are covered in the targeted [guides](#guides) at the end. + +## Installation + +The library ships as NuGet packages. Install the core package, and add the optional packages you need: + +``` +PM > Install-Package D20Tek.Spectre.Console.Extensions +PM > Install-Package D20Tek.Spectre.Console.Extensions.Configuration +PM > Install-Package D20Tek.Spectre.Console.Extensions.Hosting +PM > Install-Package D20Tek.Spectre.Console.Extensions.MoreContainers +``` + +The core package depends only on `Microsoft.Extensions.DependencyInjection`. The other packages are additive: install them only when you need configuration binding, Generic Host integration, or an alternative DI container. + +## Build a CommandApp with CommandAppBuilder + +`CommandAppBuilder` is the recommended entry point. It creates the `CommandApp`, wires up the DI container, and runs your commands. A minimal `Program.cs` looks like this: + +```csharp +using D20Tek.Spectre.Console.Extensions; + +namespace MyCli; + +public static class Program +{ + public static Task Main(string[] args) => + new CommandAppBuilder() + .WithDIContainer() + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +} +``` + +- `WithDIContainer` configures the `Microsoft.Extensions.DependencyInjection` registrar. +- `WithStartup` registers your startup class. +- `WithDefaultCommand` sets the command that runs when no command name is supplied. +- `Build` creates the `CommandApp` and applies your service and command configuration. +- `RunAsync` (or `Run`) executes the app and returns its exit code. + +## Configure services and commands with StartupBase + +Derive from `StartupBase` to keep service registration and command configuration together: + +```csharp +using D20Tek.Spectre.Console.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(ITypeRegistrar registrar) + { + registrar.WithLifetimes() + .RegisterSingleton(); + } + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.AddCommand("greet"); + return config; + } +} +``` + +`ConfigureServices` runs against the type registrar during `Build`, and `ConfigureCommands` runs against the Spectre `IConfigurator`. Your commands can then take dependencies through their constructors, resolved from the container. + +## Run the app + +With the builder configured, `RunAsync(args)` executes the app and returns the process exit code. Pass that value back from `Main` so the CLI reports the correct status to the shell. + +## Next steps + +Once the basics work, layer in the features you need. Each targeted guide is focused on a single task, and the [API Reference](api-reference.md) documents the complete public surface. + +## Guides + +- [Building CommandApps with CommandAppBuilder](guide-command-app-builder.md) +- [Dependency Injection and Lifetimes](guide-dependency-injection.md) +- [Verbosity and Logging](guide-verbosity-logging.md) +- [Controls](guide-controls.md) +- [Testing CLI Applications](guide-testing-cli-apps.md) +- [Configuration and Options Binding](guide-configuration.md) +- [Generic Host Integration](guide-generic-host.md) +- [Using Additional DI Containers](guide-more-containers.md) diff --git a/docs/guide-command-app-builder.md b/docs/guide-command-app-builder.md new file mode 100644 index 0000000..6ec3d44 --- /dev/null +++ b/docs/guide-command-app-builder.md @@ -0,0 +1,49 @@ +# Guide: Building CommandApps with CommandAppBuilder + +`CommandAppBuilder` is the fluent entry point for creating, configuring, and running a Spectre.Console `CommandApp`. This guide covers the common configuration steps. + +## Create and run + +The typical flow chains a DI container, a startup class, an optional default command, `Build`, and `RunAsync`: + +```csharp +using D20Tek.Spectre.Console.Extensions; + +return await new CommandAppBuilder() + .WithDIContainer() + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +``` + +## Choose a DI container + +`WithDIContainer` configures the built-in `Microsoft.Extensions.DependencyInjection` registrar. You can pass a pre-populated `IServiceCollection` and a default `ServiceLifetime`: + +```csharp +var services = new ServiceCollection(); +services.AddSingleton(); + +var builder = new CommandAppBuilder() + .WithDIContainer(services, ServiceLifetime.Singleton); +``` + +To use Autofac, Lamar, LightInject, or Ninject, install the MoreContainers package and call the matching extension (see [Using Additional DI Containers](guide-more-containers.md)). To supply a custom registrar directly, call `SetRegistrar`. + +## Set a default command + +`WithDefaultCommand` registers the command that runs when the user does not specify a command name. `TDefault` must implement `ICommand`. + +## Access the container from extensions + +Add-on packages reach the builder's container through `GetServiceCollection`, which returns the registrar's `IServiceCollection`. This throws `InvalidOperationException` if no container has been configured, so always call `WithDIContainer` (or a container extension) first. The `Registrar` property exposes the underlying `ITypeRegistrar` when an extension needs it. + +## Build and run + +`Build` configures services, creates the `CommandApp`, applies the default command, and configures commands. After building, call `RunAsync(args)` or `Run(args)`; both return the application exit code. `Build` throws `ArgumentNullException` if no startup class was set with `WithStartup`. + +## Related + +- [Getting Started](getting-started-detailed.md) +- [API Reference: Core](api-reference-core.md) diff --git a/docs/guide-configuration.md b/docs/guide-configuration.md new file mode 100644 index 0000000..75a4a1c --- /dev/null +++ b/docs/guide-configuration.md @@ -0,0 +1,65 @@ +# Guide: Configuration and Options Binding + +The `D20Tek.Spectre.Console.Extensions.Configuration` package adds `Microsoft.Extensions.Configuration` and strongly typed options binding to a `CommandAppBuilder`. Both hooks require a DI container, so call `WithDIContainer` first. + +## Add configuration + +`WithConfiguration` builds an `IConfiguration` and registers it in the container. By default it reads from an optional `appsettings.json` file and environment variables: + +```csharp +using D20Tek.Spectre.Console.Extensions; +using D20Tek.Spectre.Console.Extensions.Configuration; + +var builder = new CommandAppBuilder() + .WithDIContainer() + .WithConfiguration() + .WithStartup(); +``` + +Pass a delegate to customize the configuration sources: + +```csharp +builder.WithConfiguration(config => +{ + config.SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: true) + .AddJsonFile("appsettings.Development.json", optional: true) + .AddEnvironmentVariables(); +}); +``` + +`WithConfiguration` throws `InvalidOperationException` if no DI container has been configured. + +## Bind strongly typed options + +`WithOptions` binds a configuration section to an options class and registers it so it can be injected as `IOptions`. Data annotations on the options class are validated: + +```csharp +public sealed class GreetingOptions +{ + [Required] + public string DefaultName { get; init; } = string.Empty; +} + +builder.WithConfiguration() + .WithOptions("Greeting"); +``` + +Call `WithConfiguration` first so an `IConfiguration` is available in the container. `WithOptions` throws `ArgumentException` when the section name is null or whitespace. + +## Inject configuration and options + +Inject `IConfiguration` or `IOptions` into your commands: + +```csharp +public sealed class GreetCommand : Command +{ + private readonly GreetingOptions _options; + + public GreetCommand(IOptions options) => _options = options.Value; +} +``` + +## Related + +- [API Reference: Configuration](api-reference-configuration.md) diff --git a/docs/guide-controls.md b/docs/guide-controls.md new file mode 100644 index 0000000..23d8cca --- /dev/null +++ b/docs/guide-controls.md @@ -0,0 +1,85 @@ +# Guide: Controls + +The core package adds extra Spectre.Console controls: a culture-aware currency prompt and presenter, and a history-enabled text prompt with recall and auto-completion. + +## Currency Prompt and Presenter + +Culture-aware controls for working with currency values: `CurrencyPrompt` for validated input and `CurrencyPresenter` for formatted display. + +### Prompt for a currency value + +`CurrencyPrompt` implements Spectre's `IPrompt` and validates input against culture-specific formatting before converting it to a `decimal`. Configure it with the fluent methods: + +```csharp +using D20Tek.Spectre.Console.Extensions.Controls; + +var prompt = new CurrencyPrompt("Enter an amount:") + .WithCulture(CultureInfo.GetCultureInfo("en-US")) + .WithDefaultValue(9.99m) + .WithMinValue(0m) + .WithMaxValue(1000m) + .WithExampleHint(19.95m) + .WithErrorMessage("Please enter a valid amount.") + .WithPromptStyle(new Style(foreground: Color.Green)); + +decimal amount = AnsiConsole.Prompt(prompt); +``` + +The fluent configuration methods are: + +- `WithCulture(CultureInfo)` - set the culture used for parsing and formatting. +- `WithDefaultValue(decimal)` - value used when the user presses Enter. +- `WithMinValue(decimal)` / `WithMaxValue(decimal)` - allowed range. +- `WithExampleHint(decimal)` - example text shown with the prompt. +- `WithErrorMessage(string)` - custom validation error message. +- `WithPromptStyle(Style)` - style for the prompt label. + +### Display a currency value + +`CurrencyPresenter.Render` is an extension on `decimal` that formats a value in a culture-aware way, including abbreviations for large values, with optional styles for positive and negative amounts: + +```csharp +using D20Tek.Spectre.Console.Extensions.Controls; + +string text = 1234.56m.Render(positiveStyle: "green", negativeStyle: "red"); +AnsiConsole.MarkupLine(text); +``` + +## History Text Prompt + +`HistoryTextPrompt` extends Spectre's text prompt with shell-style history navigation (arrow up/down) and tab auto-completion. It implements `IPrompt`, so it works anywhere a Spectre prompt does. + +### Basic usage + +Create the prompt, seed it with prior entries, and prompt for a value: + +```csharp +using D20Tek.Spectre.Console.Extensions.Controls; + +var prompt = new HistoryTextPrompt("Command:") + .AddHistory(new[] { "build", "test", "publish" }); + +string value = AnsiConsole.Prompt(prompt); +``` + +Use the up and down arrow keys to move through the seeded history, and Tab to auto-complete against the available choices. + +### Configure behavior + +The prompt exposes a set of fluent extension methods: + +- `AddHistory(IEnumerable)` - seed the navigable history list. +- `AddChoice(T)` / `AddChoices(IEnumerable)` - add auto-complete choices. +- `ShowChoices()` / `HideChoices()` - control whether choices are displayed. +- `ShowDefaultValue()` / `HideDefaultValue()` - control default value display. +- `DefaultValue(T)` - set the value used when input is empty. +- `AllowEmpty()` - permit empty input. +- `Validate(Func)` - add custom validation. +- `ValidationErrorMessage(string)` / `InvalidChoiceMessage(string)` - customize error text. +- `Secret(char?)` - mask input for secrets. +- `WithDisplayConverter(Func)` - control how values are displayed. +- `PromptStyle(Style)`, `DefaultValueStyle(Style?)`, `ChoicesStyle(Style?)` - styling. + +## Related + +- [API Reference: Core](api-reference-core.md#controls) diff --git a/docs/guide-dependency-injection.md b/docs/guide-dependency-injection.md new file mode 100644 index 0000000..2b6a101 --- /dev/null +++ b/docs/guide-dependency-injection.md @@ -0,0 +1,57 @@ +# Guide: Dependency Injection and Lifetimes + +The core package integrates Spectre.Console.Cli with `Microsoft.Extensions.DependencyInjection` through the `DependencyInjectionTypeRegistrar` and `DependencyInjectionTypeResolver`. This guide shows how to register services and control their lifetimes. + +## Configure the container + +Call `WithDIContainer` on the builder to set up the DI registrar. You can pass an existing `IServiceCollection` and the default `ServiceLifetime` used by the registrar's `Register` calls: + +```csharp +var builder = new CommandAppBuilder() + .WithDIContainer(lifetime: ServiceLifetime.Singleton); +``` + +## Register services in a startup class + +Inside `StartupBase.ConfigureServices`, use the type registrar to register your services. The `WithLifetimes` extension exposes lifetime-aware registration helpers: + +```csharp +public override void ConfigureServices(ITypeRegistrar registrar) +{ + registrar.WithLifetimes() + .RegisterSingleton() + .RegisterScoped() + .RegisterTransient(); +} +``` + +The lifetime helpers available on `ISupportLifetimes` are: + +- `RegisterSingleton()` and `RegisterSingleton(TService instance)` +- `RegisterScoped()` +- `RegisterTransient()` + +## Resolve services in commands + +Commands and their dependencies are resolved from the container. Constructor-inject the services your command needs, and Spectre resolves them through the `DependencyInjectionTypeResolver`: + +```csharp +public sealed class GreetCommand : AsyncCommand +{ + private readonly IGreetingService _greetings; + + public GreetCommand(IGreetingService greetings) => _greetings = greetings; + + protected override Task ExecuteAsync(CommandContext context) => + Task.FromResult(_greetings.Greet()); +} +``` + +## Access the service collection directly + +When you need to register services outside a startup class (for example from an extension method), call `CommandAppBuilder.GetServiceCollection` to get the underlying `IServiceCollection`. + +## Related + +- [Using Additional DI Containers](guide-more-containers.md) +- [API Reference: Core](api-reference-core.md#dependency-injection) diff --git a/docs/guide-generic-host.md b/docs/guide-generic-host.md new file mode 100644 index 0000000..bea6804 --- /dev/null +++ b/docs/guide-generic-host.md @@ -0,0 +1,67 @@ +# Guide: Generic Host Integration + +The `D20Tek.Spectre.Console.Extensions.Hosting` package bridges Spectre.Console.Cli to the .NET Generic Host (`Microsoft.Extensions.Hosting`), so command types resolve from the host's service provider while Spectre-registered types still work. + +## Run a CommandApp from a host + +Build a host, then create and run a `CommandApp` bridged to it: + +```csharp +using D20Tek.Spectre.Console.Extensions.Hosting; +using Microsoft.Extensions.Hosting; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddSingleton(); +var host = builder.Build(); + +return await host.RunCommandAppAsync(args, config => +{ + config.AddCommand("greet"); +}); +``` + +`RunCommandAppAsync` and `RunCommandApp` create the app, apply your command configuration, and run it, returning the exit code. + +## Use the fluent host builder + +`CreateCommandAppBuilder` returns a `HostCommandAppBuilder` for a fluent flow: + +```csharp +return await host.CreateCommandAppBuilder() + .WithDefaultCommand() + .ConfigureCommands(config => config.AddCommand("greet")) + .Build() + .RunAsync(args); +``` + +`CreateCommandApp` returns a configured `CommandApp` directly when you want to run it yourself. + +## Split startup with HostStartupBase + +`HostStartupBase` separates host service registration from command configuration. Register it with `WithStartup` on the host application builder; `ConfigureServices` runs immediately (pre-build), and `ConfigureCommands` is applied automatically when the CommandApp is built (post-build): + +```csharp +public sealed class AppStartup : HostStartupBase +{ + public override void ConfigureServices(IServiceCollection services) => + services.AddSingleton(); + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.AddCommand("greet"); + return config; + } +} + +var builder = Host.CreateApplicationBuilder(args); +builder.WithStartup(); +var host = builder.Build(); + +return await host.RunCommandAppAsync(args, _ => { }); +``` + +Runtime registrations captured from Spectre resolve through a composite provider, so a Spectre-registered type can depend on another Spectre-registered type while host services take precedence. + +## Related + +- [API Reference: Hosting](api-reference-hosting.md) diff --git a/docs/guide-more-containers.md b/docs/guide-more-containers.md new file mode 100644 index 0000000..b0e3c7a --- /dev/null +++ b/docs/guide-more-containers.md @@ -0,0 +1,52 @@ +# Guide: Using Additional DI Containers + +The core package uses `Microsoft.Extensions.DependencyInjection`. The `D20Tek.Spectre.Console.Extensions.MoreContainers` package adds `ITypeRegistrar`/`ITypeResolver` support for Autofac, Lamar, LightInject, and Ninject, so you can keep the core package's dependencies minimal and only pull in another container when you need it. + +## Choose a container + +Each container has a `CommandAppBuilder` extension that configures the matching registrar. Call it in place of `WithDIContainer`: + +```csharp +using D20Tek.Spectre.Console.Extensions; + +// Ninject +new CommandAppBuilder().WithNinjectContainer(); + +// Autofac +new CommandAppBuilder().WithAutofacContainer(); + +// LightInject +new CommandAppBuilder().WithLightInjectContainer(); + +// Lamar +new CommandAppBuilder().WithLamarContainer(); +``` + +## Supply a pre-populated container + +Each extension accepts an optional, pre-configured container instance so you can register services with the container's native API first: + +```csharp +var kernel = new StandardKernel(); +kernel.Bind().To(); + +var builder = new CommandAppBuilder() + .WithNinjectContainer(kernel) + .WithStartup(); +``` + +- `WithNinjectContainer(StandardKernel?)` +- `WithAutofacContainer(ContainerBuilder?)` +- `WithLightInjectContainer(ServiceContainer?, ServiceLifetime)` +- `WithLamarContainer(ServiceRegistry?, ServiceLifetime)` + +The LightInject and Lamar extensions also accept a default `ServiceLifetime` applied to `Register` calls. + +## Register services + +After selecting a container, register services in your `StartupBase.ConfigureServices` through the `ITypeRegistrar`, or configure the native container instance before passing it in. Commands resolve from the chosen container the same way regardless of the framework. + +## Related + +- [Dependency Injection and Lifetimes](guide-dependency-injection.md) +- [API Reference: MoreContainers](api-reference-morecontainers.md) diff --git a/docs/guide-testing-cli-apps.md b/docs/guide-testing-cli-apps.md new file mode 100644 index 0000000..ca6abf6 --- /dev/null +++ b/docs/guide-testing-cli-apps.md @@ -0,0 +1,60 @@ +# Guide: Testing CLI Applications + +The `D20Tek.Spectre.Console.Extensions.Testing` namespace provides context classes and an end-to-end runner that reduce the boilerplate of testing Spectre.Console CLIs. It captures console output and exit codes so you can assert on them. + +## Test a CommandAppBuilder-based app + +`CommandAppBuilderTestContext` wraps a `CommandAppBuilder` and a `TestConsole`. Configure the builder as your app does, then run and assert: + +```csharp +var context = new CommandAppBuilderTestContext(); +context.Builder + .WithDIContainer() + .WithStartup() + .Build(); + +var result = await context.RunAsync(new[] { "greet", "World" }); + +Assert.AreEqual(0, result.ExitCode); +StringAssert.Contains(result.Output, "Hello, World"); +``` + +Use `Run`/`RunAsync` for normal execution and `RunWithException`/`RunWithExceptionAsync` when you expect the app to throw a specific exception type. + +## Test with a type registrar directly + +`CommandAppTestContext` sets up a registrar and a `TestConsole` without the builder. Configure commands through its `Configure` method: + +```csharp +var context = new CommandAppTestContext(); +context.Configure(config => config.AddCommand("greet")); + +var result = context.Run(new[] { "greet", "World" }); +Assert.AreEqual(0, result.ExitCode); +``` + +## Test command configuration + +`CommandConfigurationTestContext` exposes a registrar, a resolver, and an `ITestConfigurator` so you can assert on how commands and branches are configured without running them. + +## End-to-end runs + +`CommandAppE2ERunner` invokes a real `Main` entry point and captures its output as a `CommandAppBasicResult`: + +```csharp +var result = CommandAppE2ERunner.Run(Program.Main, "greet World"); + +Assert.AreEqual(0, result.ExitCode); +StringAssert.Contains(result.Output, "Hello, World"); +``` + +`Run` has overloads that accept a command-line string or a pre-split `string[]`. + +## Result types + +- `CommandAppResult` - exposes the exit code and captured console output for context-based runs. +- `CommandAppBasicResult` - exposes `ExitCode` and `Output` for end-to-end runs. + +## Related + +- [API Reference: Testing](api-reference-test.md) diff --git a/docs/guide-verbosity-logging.md b/docs/guide-verbosity-logging.md new file mode 100644 index 0000000..8022b3f --- /dev/null +++ b/docs/guide-verbosity-logging.md @@ -0,0 +1,119 @@ +# Guide: Verbosity and Logging + +The core package ties output verbosity and logging together: a shared `VerbosityLevel` controls both how much output an application emits through `IVerbosityWriter` and the minimum level of logging that renders through Spectre's `IAnsiConsole`. + +## Verbosity Levels and Output + +Many CLI applications let users control how much output they see. The core package provides a `VerbosityLevel` enum, a `VerbositySettings` base class for command settings, and an `IVerbosityWriter` service that writes or marks up text only when the current verbosity allows it. + +### Accept a verbosity option + +Derive your command settings from `VerbositySettings` to add a `--verbosity` option that binds to the `Verbosity` property: + +```csharp +public sealed class GreetSettings : VerbositySettings +{ + [CommandArgument(0, "")] + public string Name { get; init; } = string.Empty; +} +``` + +The `Verbosity` property defaults to `VerbosityLevel.Normal`. + +### Write verbosity-aware output + +Register and inject `IVerbosityWriter` (the default implementation is `ConsoleVerbosityWriter`) to emit messages that respect the configured verbosity. Set the writer's `Verbosity` from the command settings, then call the level-specific methods: + +```csharp +public sealed class GreetCommand : Command +{ + private readonly IVerbosityWriter _writer; + + public GreetCommand(IVerbosityWriter writer) => _writer = writer; + + protected override int Execute(CommandContext context, GreetSettings settings) + { + _writer.Verbosity = settings.Verbosity; + + _writer.WriteSummary("Starting."); // shown at Minimal and above + _writer.MarkupNormal($"Hello, [green]{settings.Name}[/]!"); + _writer.WriteDetailed("Extra detail."); // shown at Detailed and above + _writer.WriteDiagnostics("Diagnostics."); // shown at Diagnostic + + return 0; + } +} +``` + +Each level has a plain-text `Write*` method and a Spectre markup `Markup*` method: + +- `WriteSummary` / `MarkupSummary` (Minimal) +- `WriteNormal` / `MarkupNormal` (Normal) +- `WriteDetailed` / `MarkupDetailed` (Detailed) +- `WriteDiagnostics` / `MarkupDiagnostics` (Diagnostic) + +A message is emitted only when the writer's `Verbosity` is at or above the message's level. + +## Verbosity-Aware Logging + +The same `VerbosityLevel` drives logging that renders through Spectre's `IAnsiConsole` and derives its minimum log level from the requested verbosity. + +### Enable logging + +Call `WithLogging` on the builder after configuring a DI container. It registers the Spectre console logger provider in the container: + +```csharp +using D20Tek.Spectre.Console.Extensions; + +var builder = new CommandAppBuilder() + .WithDIContainer() + .WithLogging(minimumVerbosity: VerbosityLevel.Normal) + .WithStartup(); +``` + +`WithLogging` requires a container that supports lifetimes, so call `WithDIContainer` (or a container extension) first. It throws `InvalidOperationException` otherwise. + +### Configure rendering + +Pass a configuration delegate to control how entries are rendered through `SpectreConsoleLoggerOptions`: + +```csharp +builder.WithLogging( + minimumVerbosity: VerbosityLevel.Detailed, + configure: options => + { + options.IncludeLevelLabel = true; + options.IncludeCategory = true; + options.IncludeTimestamp = true; + options.TimestampFormat = "HH:mm:ss"; + }); +``` + +You can also pass a specific `IAnsiConsole` to render to; when omitted, `AnsiConsole.Console` is used. + +### Inject loggers into commands + +Once logging is enabled, inject `ILogger` into your commands like any other service: + +```csharp +public sealed class GreetCommand : AsyncCommand +{ + private readonly ILogger _logger; + + public GreetCommand(ILogger logger) => _logger = logger; + + protected override Task ExecuteAsync(CommandContext context) + { + _logger.LogInformation("Greeting the user."); + return Task.FromResult(0); + } +} +``` + +### Verbosity to log level mapping + +The minimum verbosity is mapped to a `LogLevel` through `VerbosityLevelExtensions.ToLogLevel`, so a more detailed verbosity emits Debug and Trace entries. This is the same `VerbosityLevel` that `VerbositySettings` and `IVerbosityWriter` use, so a single option can control both console output and log level. + +## Related + +- [API Reference: Core](api-reference-core.md#verbosity-output) diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..f5d8341 --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,127 @@ +# Introducing D20Tek.Spectre.Console.Extensions + +D20Tek.Spectre.Console.Extensions is a family of packages that removes the repetitive plumbing you write around a real Spectre.Console.Cli application. A command-line tool is rarely just a set of commands: it needs dependency injection, a predictable startup sequence, configuration binding, logging that respects a verbosity flag, richer prompts, and a way to test the whole thing end to end. Spectre gives you the command model and the extension points; this library gives you the wiring that most applications end up writing by hand. The idea is simple, but doing it well and consistently across every project is where the time goes. + +[Spectre.Console](https://github.com/spectreconsole/spectre.console) is one of the best things to happen to .NET command-line development in years. Its `CommandApp` model, rich prompts, tables, and styling turn ordinary console programs into polished, testable tools. This library exists only because the Spectre.Console team did such a great job on the foundation, and because they deliberately designed the framework to be extended through public abstractions like `ITypeRegistrar`, `ITypeResolver`, and `IPrompt`. A sincere thank you to the Spectre.Console maintainers and contributors: D20Tek.Spectre.Console.Extensions is possible only because of your framework, and everything here is meant to complement it, not replace it. + +## What these packages do + +The library is organized as a small core package plus focused add-ons, so you only take on the dependencies you actually use. + +The **core package** (`D20Tek.Spectre.Console.Extensions`) provides a fluent `CommandAppBuilder` that creates, configures, and runs a `CommandApp`, and a `StartupBase` class that cleanly separates service registration (`ConfigureServices`) from command configuration (`ConfigureCommands`). It integrates `Microsoft.Extensions.DependencyInjection` through purpose-built `ITypeRegistrar` and `ITypeResolver` implementations, with lifetime-aware registration helpers. It adds verbosity-aware logging that renders through Spectre's `IAnsiConsole` and maps a shared `VerbosityLevel` onto the standard `LogLevel`, plus an `IVerbosityWriter` service and a `VerbositySettings` base class so users can dial output up or down with a single option. + +It also ships extra controls: a culture-aware `CurrencyPrompt` and `CurrencyPresenter`, a history-enabled `HistoryTextPrompt` with arrow-key recall and tab completion, and table helpers. + +Finally, a `Testing` namespace provides context classes and an end-to-end runner that capture console output and exit codes so commands are straightforward to unit test. + +The **add-on packages** extend the same builder without adding weight to the core: + +- `D20Tek.Spectre.Console.Extensions.Configuration` adds `Microsoft.Extensions.Configuration` and strongly typed options binding through `WithConfiguration` and `WithOptions`. +- `D20Tek.Spectre.Console.Extensions.Hosting` bridges Spectre.Console.Cli to the .NET Generic Host, so command types resolve from the host's service provider while Spectre-registered types still work, and adds a host-aware `HostStartupBase`. +- `D20Tek.Spectre.Console.Extensions.MoreContainers` adds `ITypeRegistrar`/`ITypeResolver` support for Autofac, Lamar, LightInject, and Ninject, so teams already invested in one of those containers can keep using it. + +## Why not just wire it up yourself + +Everything this library does is possible with Spectre.Console.Cli directly. Spectre exposes `ITypeRegistrar` and `ITypeResolver` precisely so that you can plug in a container of your choice, and you can absolutely hand-write that bridge, build your own startup convention, add a logging provider, and stand up test harnesses per project. The question is whether you want to write and maintain that plumbing repeatedly. I know I set this up a few times for some CLI apps and quickly started building this library because I was tired of repeating the same patterns and making the same mistakes. + +The bridge between a DI container and Spectre's registrar/resolver contract is easy to get subtly wrong, especially around lifetimes and instance registration. Startup ordering matters: services must be configured before the `CommandApp` is created, and commands after. Logging should honor the same verbosity the user requested rather than a separate switch. Configuration and options binding follow a well-known pattern that is tedious to repeat. And testing a CLI usually means capturing console output and exit codes through a fake console. This library encodes those decisions once, as a small set of composable methods, so each new application starts from working infrastructure instead of a blank `Program.cs`. It never hides Spectre from you: you still author `Command`/`AsyncCommand` classes and settings exactly as the framework intends. + +## Problems it solves + +**Repetitive DI bridging.** `WithDIContainer` and the container-specific extensions build the correct `ITypeRegistrar` for you, so you never hand-write the Spectre registrar/resolver bridge or its lifetime handling. The core package depends only on `Microsoft.Extensions.DependencyInjection`; other containers are additive through MoreContainers. + +**Scattered startup code.** `StartupBase` keeps `ConfigureServices` and `ConfigureCommands` together in one class, and `CommandAppBuilder.Build` invokes them in the correct order, so the setup sequence is consistent across every project. + +**Logging that ignores verbosity.** `WithLogging` maps the user's requested `VerbosityLevel` to a minimum `LogLevel` and renders entries through the same `IAnsiConsole` as the rest of your output, so `--verbosity detailed` actually surfaces Debug and Trace messages. + +**Configuration without ceremony.** `WithConfiguration` and `WithOptions` add `IConfiguration` and validated `IOptions` to the container with a single call each, following the standard configuration and data-annotations validation patterns. + +**Host integration.** The Hosting package bridges Spectre.Console.Cli to the Generic Host, so command types resolve from the host container, Spectre-registered types continue to work through a composite provider, and a host-aware `HostStartupBase` splits pre-build service registration from post-build command configuration. + +**Plain prompts.** The extra controls fill common gaps: culture-aware currency input and display, and a history-enabled text prompt with recall and auto-completion. + +**Hard-to-test CLIs.** `CommandAppTestContext`, `CommandAppBuilderTestContext`, and `CommandAppE2ERunner` drive an app through a fake console and expose the exit code and captured output, so you can assert on real behavior without spawning a process. + +## What it does not try to do + +This library is a set of builders, extensions, and helpers, not a framework that takes ownership of your application. It does not replace Spectre.Console.Cli or hide its command model; you continue to write your commands, settings, and configurators against Spectre directly, and you can drop down to the raw `CommandApp` at any time. It does not impose a container choice: the core package stays lean with a single DI dependency, and alternative containers are opt-in. It also does not add features that belong to Spectre itself; when the framework already does something well, this library gets out of the way and lets you use it. + +## Getting started + +The packages target .NET 9.0 and .NET 10.0. Install the core package, and add only the optional packages you need: + +``` +PM > Install-Package D20Tek.Spectre.Console.Extensions +PM > Install-Package D20Tek.Spectre.Console.Extensions.Configuration +PM > Install-Package D20Tek.Spectre.Console.Extensions.Hosting +PM > Install-Package D20Tek.Spectre.Console.Extensions.MoreContainers +``` + +Create a `Program.cs` that builds and runs a `CommandApp` through the fluent builder: + +```csharp +using D20Tek.Spectre.Console.Extensions; + +namespace MyCli; + +public static class Program +{ + public static Task Main(string[] args) => + new CommandAppBuilder() + .WithDIContainer() + .WithStartup() + .WithDefaultCommand() + .Build() + .RunAsync(args); +} +``` + +Put service registration and command configuration in a `StartupBase` class: + +```csharp +using D20Tek.Spectre.Console.Extensions; +using Spectre.Console.Cli; + +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(ITypeRegistrar registrar) => + registrar.WithLifetimes() + .RegisterSingleton(); + + public override IConfigurator ConfigureCommands(IConfigurator config) + { + config.AddCommand("greet"); + return config; + } +} +``` + +Then author commands as usual for Spectre.Console.Cli, taking dependencies through the constructor: + +```csharp +using Spectre.Console; +using Spectre.Console.Cli; + +public sealed class GreetCommand : Command +{ + private readonly IGreetingService _greetings; + + public GreetCommand(IGreetingService greetings) => _greetings = greetings; + + protected override int Execute(CommandContext context) + { + AnsiConsole.MarkupLine(_greetings.Greet()); + return 0; + } +} +``` + +That is the entire setup. From here you can layer in configuration binding, verbosity-aware logging, Generic Host integration, an alternative DI container, or the extra prompt controls, each through a single additional call. The [Getting Started](getting-started-detailed.md) guide walks through these in order, and the targeted [guides](getting-started-detailed.md#guides) cover each feature in depth. + +## Links + +- **Getting Started:** [End-to-end walkthrough](getting-started-detailed.md) +- **API Reference:** [Complete API Reference](api-reference.md) +- **Changelog:** [Release history](../CHANGELOG.md) +- **Source and samples:** [GitHub repository](https://github.com/d20Tek/Spectre.Console.Extensions) +- **Spectre.Console:** [The framework this library extends](https://github.com/spectreconsole/spectre.console) From f77c4c2f9534c9ed5d5bec588c81039d57edad63 Mon Sep 17 00:00:00 2001 From: Pedro Silva Date: Fri, 4 Sep 2026 14:56:04 -0700 Subject: [PATCH 10/10] added new packages to nuget-release script. clean up in preparation for release. --- .github/workflows/nuget-release.yml | 20 +++++++++++++++++++ CHANGELOG.md | 2 +- ...re.Console.Extensions.Configuration.csproj | 1 + ....Spectre.Console.Extensions.Hosting.csproj | 1 + ...e.Console.Extensions.MoreContainers.csproj | 1 + D20Tek.Spectre.Console.Extensions.sln | 3 +++ .../D20Tek.Spectre.Console.Extensions.csproj | 1 + Directory.Build.targets | 1 - Directory.Packages.props | 2 +- 9 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/nuget-release.yml b/.github/workflows/nuget-release.yml index 2a33df7..e4b954e 100644 --- a/.github/workflows/nuget-release.yml +++ b/.github/workflows/nuget-release.yml @@ -49,6 +49,16 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish Spectre.Console.Extensions.Configuration (local) + run: dotnet nuget push D20Tek.Spectre.Console.Extensions.Configuration.${VERSION}.nupkg --source https://nuget.pkg.github.com/d20Tek/index.json --api-key ${GITHUB_TOKEN} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish Spectre.Console.Extensions.Hosting (local) + run: dotnet nuget push D20Tek.Spectre.Console.Extensions.Hosting.${VERSION}.nupkg --source https://nuget.pkg.github.com/d20Tek/index.json --api-key ${GITHUB_TOKEN} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish Spectre.Console.Extensions (nuget.org) run: dotnet nuget push D20Tek.Spectre.Console.Extensions.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} env: @@ -58,3 +68,13 @@ jobs: run: dotnet nuget push D20Tek.Spectre.Console.Extensions.MoreContainers.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} env: NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + - name: Publish Spectre.Console.Extensions.Configuration (nuget.org) + run: dotnet nuget push D20Tek.Spectre.Console.Extensions.Configuration.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + + - name: Publish Spectre.Console.Extensions.Hosting (nuget.org) + run: dotnet nuget push D20Tek.Spectre.Console.Extensions.Hosting.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_API_KEY} + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 738e230..35986d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## Release v1.57.1 ### Added - Public `CommandAppBuilder.Registrar` getter and `CommandAppBuilder.GetServiceCollection()` helper so add-on extension packages can access the builder's DI container. - Verbosity-aware logging that renders through Spectre.Console. New public API includes `LoggingCommandAppBuilderExtensions.WithLogging`, `SpectreLoggingExtensions.AddSpectreConsole`, `SpectreConsoleLoggerProvider`, `SpectreConsoleLogger`, `SpectreConsoleLoggerOptions`, and the `VerbosityLevel`/`LogLevel` mapping extensions. diff --git a/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj index 781cc5f..dce2f0e 100644 --- a/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj +++ b/D20Tek.Spectre.Console.Extensions.Configuration/D20Tek.Spectre.Console.Extensions.Configuration.csproj @@ -35,6 +35,7 @@ + diff --git a/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj index 316f097..5164761 100644 --- a/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj +++ b/D20Tek.Spectre.Console.Extensions.Hosting/D20Tek.Spectre.Console.Extensions.Hosting.csproj @@ -30,6 +30,7 @@ + diff --git a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj index 517de40..d86a805 100644 --- a/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj +++ b/D20Tek.Spectre.Console.Extensions.MoreContainers/D20Tek.Spectre.Console.Extensions.MoreContainers.csproj @@ -36,6 +36,7 @@ The current release contain implementations of ITypeRegistrar and ITypeResolver + diff --git a/D20Tek.Spectre.Console.Extensions.sln b/D20Tek.Spectre.Console.Extensions.sln index 4c1d48a..bc3dfff 100644 --- a/D20Tek.Spectre.Console.Extensions.sln +++ b/D20Tek.Spectre.Console.Extensions.sln @@ -5,6 +5,9 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".items", ".items", "{1E2AFA48-704D-4E7B-8A90-BB8D79F69E80}" ProjectSection(SolutionItems) = preProject CHANGELOG.md = CHANGELOG.md + Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets + Directory.Packages.props = Directory.Packages.props LICENSE = LICENSE README.md = README.md EndProjectSection diff --git a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj index bac1032..b58f07d 100644 --- a/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj +++ b/D20Tek.Spectre.Console.Extensions/D20Tek.Spectre.Console.Extensions.csproj @@ -34,6 +34,7 @@ The new Extensions.Testing namespace support test infrastructure classes to easi + diff --git a/Directory.Build.targets b/Directory.Build.targets index 7daade1..4ab07e6 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -13,7 +13,6 @@ - diff --git a/Directory.Packages.props b/Directory.Packages.props index df3b315..8137a9b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,7 @@ - +