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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 66 additions & 38 deletions src/ProjGraph.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@
using ProjGraph.Lib;
using Spectre.Console.Cli;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;

namespace ProjGraph.Cli;

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
Expand All @@ -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<VisualizeCommand>(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<ErdCommand>(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<ClassDiagramCommand>(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<StatsCommand>("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);
}

/// <summary>
/// Applies the application settings and registers the commands. Tests call this directly so they
/// can run the real configuration against their own console.
/// </summary>
/// <param name="config">The configurator of the command app.</param>
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<VisualizeCommand>(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<ErdCommand>(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<ClassDiagramCommand>(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<StatsCommand>("stats")
.WithDescription("Analyse a solution and print key architectural metrics")
.WithExample("stats", "MySolution.slnx")
.WithExample("stats", "MySolution.slnx", "--top", "10");
}

/// <summary>
/// Gets the package version an assembly was built with. Spectre's
/// <c>UseAssemblyInformationalVersion</c> reads the entry assembly, which is the test host under tests,
/// and keeps the <c>+commit</c> build metadata the SDK appends, so <see cref="Configure"/> passes the
/// CLI assembly here and the metadata is trimmed.
/// </summary>
/// <param name="assembly">The assembly to read the version from.</param>
/// <returns>The package version, e.g. <c>1.2.0</c> or <c>1.2.0-beta.1</c>.</returns>
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<AssemblyInformationalVersionAttribute>()?.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 " +
Expand Down
3 changes: 3 additions & 0 deletions src/ProjGraph.Cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions tests/ProjGraph.Tests.Integration.Cli/VersionOptionTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <c>projgraph --version</c>. They run the real configuration from
/// <see cref="Program.Configure"/> against a <see cref="TestConsole"/> rather than calling
/// <see cref="Program.Main"/>, because only one test in the assembly may reach Spectre's cached default
/// console (see <see cref="StrictParsingTests"/>).
/// </summary>
[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 +<commit> 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();
}
8 changes: 8 additions & 0 deletions tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading