diff --git a/src/ProjGraph.Cli/Program.cs b/src/ProjGraph.Cli/Program.cs index 2ea1b3a..d5a7981 100644 --- a/src/ProjGraph.Cli/Program.cs +++ b/src/ProjGraph.Cli/Program.cs @@ -4,6 +4,7 @@ using ProjGraph.Lib; using Spectre.Console.Cli; using System.Diagnostics.CodeAnalysis; +using System.Reflection; namespace ProjGraph.Cli; @@ -11,10 +12,6 @@ internal static class Program { public static int Main(string[] args) { - const string packageDiagramCommandName = "visualize"; - const string erdCommandName = "erd"; - const string classDiagramCommandName = "classdiagram"; - var services = new ServiceCollection(); // Register Library services @@ -25,44 +22,75 @@ public static int Main(string[] args) var app = CreateApp(new TypeRegistrar(services)); - app.Configure(config => - { - config.SetApplicationName("projgraph"); - - // Spectre's default parser silently ignores unrecognized long options: a typo like - // `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user - // no signal their option was dropped. Strict parsing turns any unknown option into a - // parse error with a non-zero exit code instead. - config.Settings.StrictParsing = true; - - config.AddCommand(packageDiagramCommandName) - .WithDescription("Visualize the dependency graph of a solution or project") - .WithExample(packageDiagramCommandName, "MySolution.sln") - .WithExample(packageDiagramCommandName, "MySolution.sln", "--format", "tree") - .WithExample(packageDiagramCommandName, "MySolution.slnx", "--output", "graph.mmd"); - - config.AddCommand(erdCommandName) - .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") - .WithExample(erdCommandName, "Data/AppDbContext.cs") - .WithExample(erdCommandName, "Data/AppDbContext.cs", "--context", "BlogContext") - .WithExample(erdCommandName, "Data/AppDbContext.cs", "--output", "docs/erd.md"); - - config.AddCommand(classDiagramCommandName) - .WithDescription("Generate a Mermaid Class Diagram for a C# file") - .WithExample(classDiagramCommandName, "Services/UserService.cs") - .WithExample(classDiagramCommandName, "Models/User.cs", "--inheritance", "--dependencies", "--depth", - "10") - .WithExample(classDiagramCommandName, "Models/User.cs", "--output", "user-hierarchy.mmd"); - - config.AddCommand("stats") - .WithDescription("Analyse a solution and print key architectural metrics") - .WithExample("stats", "MySolution.slnx") - .WithExample("stats", "MySolution.slnx", "--top", "10"); - }); + app.Configure(Configure); return app.Run(args); } + /// + /// Applies the application settings and registers the commands. Tests call this directly so they + /// can run the real configuration against their own console. + /// + /// The configurator of the command app. + internal static void Configure(IConfigurator config) + { + const string packageDiagramCommandName = "visualize"; + const string erdCommandName = "erd"; + const string classDiagramCommandName = "classdiagram"; + + config.SetApplicationName("projgraph"); + config.SetApplicationVersion(GetPackageVersion(typeof(Program).Assembly)); + + // Spectre's default parser silently ignores unrecognized long options: a typo like + // `--owned-mod classic` (missing 'e') exits 0 and renders with defaults, giving the user + // no signal their option was dropped. Strict parsing turns any unknown option into a + // parse error with a non-zero exit code instead. + config.Settings.StrictParsing = true; + + config.AddCommand(packageDiagramCommandName) + .WithDescription("Visualize the dependency graph of a solution or project") + .WithExample(packageDiagramCommandName, "MySolution.sln") + .WithExample(packageDiagramCommandName, "MySolution.sln", "--format", "tree") + .WithExample(packageDiagramCommandName, "MySolution.slnx", "--output", "graph.mmd"); + + config.AddCommand(erdCommandName) + .WithDescription("Generate a Mermaid ERD for an Entity Framework Core DbContext") + .WithExample(erdCommandName, "Data/AppDbContext.cs") + .WithExample(erdCommandName, "Data/AppDbContext.cs", "--context", "BlogContext") + .WithExample(erdCommandName, "Data/AppDbContext.cs", "--output", "docs/erd.md"); + + config.AddCommand(classDiagramCommandName) + .WithDescription("Generate a Mermaid Class Diagram for a C# file") + .WithExample(classDiagramCommandName, "Services/UserService.cs") + .WithExample(classDiagramCommandName, "Models/User.cs", "--inheritance", "--dependencies", "--depth", + "10") + .WithExample(classDiagramCommandName, "Models/User.cs", "--output", "user-hierarchy.mmd"); + + config.AddCommand("stats") + .WithDescription("Analyse a solution and print key architectural metrics") + .WithExample("stats", "MySolution.slnx") + .WithExample("stats", "MySolution.slnx", "--top", "10"); + } + + /// + /// Gets the package version an assembly was built with. Spectre's + /// UseAssemblyInformationalVersion reads the entry assembly, which is the test host under tests, + /// and keeps the +commit build metadata the SDK appends, so passes the + /// CLI assembly here and the metadata is trimmed. + /// + /// The assembly to read the version from. + /// The package version, e.g. 1.2.0 or 1.2.0-beta.1. + internal static string GetPackageVersion(Assembly assembly) + { + // The SDK always generates the attribute, but Configure runs for every command, so a build + // without it falls back to the assembly version instead of failing them all. + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString(3) + ?? "0.0.0"; + var metadataStart = version.IndexOf('+', StringComparison.Ordinal); + return metadataStart < 0 ? version : version[..metadataStart]; + } + [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "Spectre.Console.Cli reflects over the command and settings types. ProjGraph.Cli and " + "Spectre.Console.Cli are trimmer root assemblies, so that metadata is kept, and " + diff --git a/src/ProjGraph.Cli/README.md b/src/ProjGraph.Cli/README.md index d6a65e1..cb1a236 100644 --- a/src/ProjGraph.Cli/README.md +++ b/src/ProjGraph.Cli/README.md @@ -10,6 +10,9 @@ class hierarchies, and computing key solution metrics. ```bash dotnet tool install -g ProjGraph.Cli + +# Check the installed version +projgraph --version ``` Installing needs the [.NET 10 SDK](https://dotnet.microsoft.com/download) or later. On Windows x64, Linux x64 and diff --git a/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs new file mode 100644 index 0000000..e1444c8 --- /dev/null +++ b/tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs @@ -0,0 +1,57 @@ +using ProjGraph.Cli; +using Spectre.Console.Cli; +using Spectre.Console.Testing; +using System.Reflection; +using System.Reflection.Emit; +using System.Text.RegularExpressions; + +namespace ProjGraph.Tests.Integration.Cli; + +/// +/// Tests for projgraph --version. They run the real configuration from +/// against a rather than calling +/// , because only one test in the assembly may reach Spectre's cached default +/// console (see ). +/// +[Collection("CLI Tests")] +public sealed partial class VersionOptionTests +{ + [Theory] + [InlineData("--version")] + [InlineData("-v")] + public void VersionOption_PrintsThePackageVersionAndSucceeds(string option) + { + var console = new TestConsole(); + var app = new CommandApp(); + app.Configure(config => + { + Program.Configure(config); + config.Settings.Console = console; + }); + + var exitCode = app.Run([option]); + + exitCode.Should().Be(0, $"output:\n{console.Output}"); + var printed = console.Output.Trim(); + printed.Should().MatchRegex(PackageVersion(), + "the version must be a plain package version, without the + build metadata"); + // The assembly version is the numeric part of the package version, so this ties the printed + // value to the CLI assembly and not to the test host's entry assembly. + var assemblyVersion = typeof(Program).Assembly.GetName().Version!; + printed.Should().StartWith(assemblyVersion.ToString(3)); + } + + [Fact] + public void GetPackageVersion_WithoutInformationalVersionAttribute_FallsBackToTheAssemblyVersion() + { + // Configure runs for every command, so a build without the attribute must not break them all. + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("NoInformationalVersion") { Version = new Version(2, 3, 4, 5) }, + AssemblyBuilderAccess.Run); + + Program.GetPackageVersion(assembly).Should().Be("2.3.4"); + } + + [GeneratedRegex(@"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$")] + private static partial Regex PackageVersion(); +} diff --git a/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs b/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs index 2367001..d6c17a5 100644 --- a/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs +++ b/tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs @@ -179,6 +179,14 @@ public async Task Help_Visualize_ShouldMatchReference() await AssertParityAsync(expectSuccess: true, "visualize", "--help"); } + [AotSmokeFact] + public async Task Version_ShouldMatchReference() + { + // The version is read from an assembly attribute, which the native build must keep. + var reference = await AssertParityAsync(expectSuccess: true, "--version"); + reference.StandardOutput.Should().NotBeNullOrWhiteSpace("the version must be printed"); + } + private string CreateSolutionWithMalformedProject() { _temp.CreateFile("Good/Good.csproj",