From cd6011fe0be0e3b52645d0559a63120b8e49a544 Mon Sep 17 00:00:00 2001 From: OluwaVader Date: Sun, 1 Mar 2026 15:51:00 +0100 Subject: [PATCH] support specification for multiple assemblies to skip auto discovery and building --- README.md | 32 ++- .../Commands/SnapshotCommand.cs | 199 ++++++++++++------ 2 files changed, 165 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 30fdc6c..917b32f 100644 --- a/README.md +++ b/README.md @@ -160,22 +160,29 @@ With explicit project and configuration: swaggerdiff snapshot --project ./src/MyApi/MyApi.csproj -c Release --output Docs/Versions ``` -Or point directly at a pre-built assembly: +Or point directly at pre-built assemblies (skips build and project discovery entirely): ```bash +# Single assembly swaggerdiff snapshot --assembly ./bin/Release/net8.0/MyApi.dll + +# Multiple assemblies +swaggerdiff snapshot \ + --assembly ./src/AdminApi/bin/Release/net9.0/AdminApi.dll \ + --assembly ./src/TenantApi/bin/Release/net9.0/TenantApi.dll ``` | Option | Default | Description | |--------|---------|-------------| | `--project` | auto-discover | Path to one or more `.csproj` files. Repeat for multiple projects | -| `--assembly` | — | Direct path to a built DLL. Overrides `--project` and skips the build step (single project only) | +| `--assembly` | — | Direct path to built DLL(s). Overrides `--project` and skips build + project discovery. Repeat for multiple | | `-c`, `--configuration` | `Debug` | Build configuration (used with `--project`) | | `--no-build` | `false` | Skip the build step (assumes the project was already built) | | `--output` | `Docs/Versions` | Directory where snapshots are written (relative to each project directory) | | `--doc-name` | `v1` | Swagger document name passed to `ISwaggerProvider.GetSwagger()` | | `--exclude` | — | Project names to exclude from auto-discovery (without `.csproj`). Repeat for multiple | | `--exclude-dir` | — | Directory names to exclude from auto-discovery. Repeat for multiple | +| `--parallelism` | `0` | Maximum concurrent snapshot subprocesses. `0` means unlimited (one per project) | The command will: @@ -214,6 +221,27 @@ src/ The tool skips `bin/`, `obj/`, `.git/`, and other well-known non-project directories automatically. +### CI / Docker optimisation + +For CI pipelines or Docker builds where the solution is already built, you can combine `--assembly` with `--no-build` and `--parallelism` to skip all discovery and build overhead: + +```bash +# Pre-build the solution once, then snapshot all APIs +dotnet build MySolution.sln -c Release + +swaggerdiff snapshot --no-build -c Release \ + --assembly ./src/AdminApi/bin/Release/net9.0/AdminApi.dll \ + --assembly ./src/AuthApi/bin/Release/net9.0/AuthApi.dll \ + --assembly ./src/TenantApi/bin/Release/net9.0/TenantApi.dll \ + --parallelism 2 +``` + +When `--assembly` is used, the tool skips project discovery and MSBuild property evaluation entirely — it goes straight to launching snapshot subprocesses. The output directory for each assembly is inferred by navigating up from `bin/{Config}/{TFM}/` to the project root. + +When `--no-build` is used with `--project` (instead of `--assembly`), MSBuild property resolution runs in parallel since it's read-only. + +Use `--parallelism` to cap concurrent subprocesses. The default (`0`) runs all concurrently. On resource-constrained environments like CI runners with 2 vCPUs, `--parallelism 2` avoids CPU oversubscription. + ### Dry-run mode — skipping external dependencies When the CLI tool loads your application to generate a snapshot, your `Program.cs` entry point runs in full. This means any startup code that connects to external services (secret vaults, databases, message brokers, etc.) will execute and may fail if those services are unreachable. diff --git a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs index 0fe4a13..71c995f 100644 --- a/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs +++ b/src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs @@ -11,7 +11,7 @@ namespace SwaggerDiff.Tool.Commands; /// optionally builds them, then re-invokes itself via dotnet exec with each target app's /// deps.json and runtimeconfig.json so that all assembly dependencies resolve correctly. /// -internal sealed class SnapshotCommand : Command +internal sealed class SnapshotCommand : AsyncCommand { /// /// Well-known directories that are always skipped during auto-discovery. @@ -28,8 +28,8 @@ public sealed class Settings : CommandSettings public string[]? Project { get; set; } [CommandOption("--assembly ")] - [Description("Direct path to a built assembly DLL. Overrides --project (skips build). Single project only.")] - public string? Assembly { get; set; } + [Description("Direct path to built assembly DLL(s). Skips build and project discovery. Repeat for multiple.")] + public string[]? Assembly { get; set; } [CommandOption("-c|--configuration ")] [Description("Build configuration.")] @@ -59,39 +59,53 @@ public sealed class Settings : CommandSettings [Description("Directory names to exclude from auto-discovery. Repeat for multiple.")] public string[]? ExcludeDir { get; set; } + [CommandOption("--parallelism ")] + [Description("Maximum concurrent snapshot subprocesses. Defaults to number of projects.")] + [DefaultValue(0)] + public int Parallelism { get; set; } + public override ValidationResult Validate() { - if (!string.IsNullOrWhiteSpace(Assembly)) - { - var fullPath = Path.GetFullPath(Assembly); - if (!File.Exists(fullPath)) - return ValidationResult.Error($"Assembly not found: {fullPath}"); - } - - if (Project != null) + if (Assembly is { Length: > 0 }) { - foreach (var project in Project) + foreach (var assembly in Assembly) { - var fullPath = Path.GetFullPath(project); + var fullPath = Path.GetFullPath(assembly); if (!File.Exists(fullPath)) - return ValidationResult.Error($"Project file not found: {fullPath}"); + return ValidationResult.Error($"Assembly not found: {fullPath}"); } } + if (Project == null) return ValidationResult.Success(); + + foreach (var project in Project) + { + var fullPath = Path.GetFullPath(project); + if (!File.Exists(fullPath)) + return ValidationResult.Error($"Project file not found: {fullPath}"); + } + return ValidationResult.Success(); } } - public override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) + public override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { - // Single assembly mode — unchanged behavior - if (!string.IsNullOrWhiteSpace(settings.Assembly)) + // Multi-assembly mode — skip discovery and build entirely + if (settings.Assembly is { Length: > 0 }) { - AnsiConsole.MarkupLine($"[grey]Using assembly:[/] {settings.Assembly}"); - var assemblyPath = Path.GetFullPath(settings.Assembly); - var assemblyDir = Path.GetDirectoryName(assemblyPath)!; - var outputDir = Path.GetFullPath(settings.Output); - return RunSnapshotSubprocess(assemblyPath, assemblyDir, outputDir, settings.DocName); + var assemblies = settings.Assembly.Select(a => + { + var assemblyPath = Path.GetFullPath(a); + var assemblyDir = Path.GetDirectoryName(assemblyPath)!; + var projectDir = Path.GetFullPath(Path.Combine(assemblyDir, "..", "..", "..")); + var outputDir = Path.Combine(projectDir, settings.Output); + var name = Path.GetFileNameWithoutExtension(assemblyPath); + return (name, assemblyPath, assemblyDir, outputDir); + }).ToList(); + + AnsiConsole.MarkupLine($"[grey]Using {assemblies.Count} pre-built assembly(ies)[/]"); + return await RunSnapshotsAsync(assemblies, settings, cancellationToken); } // Resolve one or more projects @@ -99,40 +113,78 @@ public override int Execute(CommandContext context, Settings settings, Cancellat if (projects == null || projects.Count == 0) return 1; - // ── Phase 1: Build and resolve assemblies (sequential) ── - // Shared project dependencies (ApiBase, Core, Models, etc.) can cause file lock - // conflicts if multiple dotnet build invocations run concurrently. - var resolved = new List<(string name, string assemblyPath, string assemblyDir, string outputDir)>(); - var buildFailures = 0; + // ── Phase 1: Build and resolve assemblies ── + // When --no-build is set, BuildAndResolveAssembly only evaluates MSBuild + // properties (read-only) — safe to parallelize. Builds are always sequential + // because shared project dependencies can cause file lock conflicts. + List<(string name, string assemblyPath, string assemblyDir, string outputDir)> resolved; + int buildFailures; - foreach (var projectPath in projects) + if (settings.NoBuild && projects.Count > 1) { - var projectName = Path.GetFileNameWithoutExtension(projectPath); - var projectDir = Path.GetDirectoryName(projectPath)!; - - if (projects.Count > 1) - AnsiConsole.MarkupLine($"\n[bold]── {projectName.EscapeMarkup()} ──[/]"); + AnsiConsole.MarkupLine($"[grey]Resolving {projects.Count} assemblies in parallel (--no-build)...[/]"); + var resolvedArray = new (string projectPath, string? assemblyPath)[projects.Count]; + Parallel.For(0, projects.Count, i => + { + resolvedArray[i] = (projects[i], BuildAndResolveAssembly(projects[i], settings, silent: true)); + }); - var assemblyPath = BuildAndResolveAssembly(projectPath, settings); - if (assemblyPath == null) + resolved = []; + buildFailures = 0; + foreach (var (projectPath, assemblyPath) in resolvedArray) { - buildFailures++; - continue; + if (assemblyPath == null) + { + buildFailures++; + continue; + } + + var projectName = Path.GetFileNameWithoutExtension(projectPath); + var projectDir = Path.GetDirectoryName(projectPath)!; + resolved.Add((projectName, assemblyPath, Path.GetDirectoryName(assemblyPath)!, Path.Combine(projectDir, settings.Output))); } + } + else + { + resolved = []; + buildFailures = 0; + + foreach (var projectPath in projects) + { + var projectName = Path.GetFileNameWithoutExtension(projectPath); + var projectDir = Path.GetDirectoryName(projectPath)!; - resolved.Add((projectName, assemblyPath, Path.GetDirectoryName(assemblyPath)!, Path.Combine(projectDir, settings.Output))); + if (projects.Count > 1) + AnsiConsole.MarkupLine($"\n[bold]── {projectName.EscapeMarkup()} ──[/]"); + + var assemblyPath = BuildAndResolveAssembly(projectPath, settings); + if (assemblyPath == null) + { + buildFailures++; + continue; + } + + resolved.Add((projectName, assemblyPath, Path.GetDirectoryName(assemblyPath)!, Path.Combine(projectDir, settings.Output))); + } } if (resolved.Count == 0) return 1; - // ── Phase 2: Generate snapshots ── + return await RunSnapshotsAsync(resolved, settings, cancellationToken, buildFailures); + } + + private async Task RunSnapshotsAsync( + List<(string name, string assemblyPath, string assemblyDir, string outputDir)> resolved, + Settings settings, + CancellationToken cancellationToken, + int priorFailures = 0) + { var succeeded = 0; - var failed = buildFailures; + var failed = priorFailures; if (resolved.Count == 1) { - // Single project — run directly (no buffering overhead) var p = resolved[0]; var exitCode = RunSnapshotSubprocess(p.assemblyPath, p.assemblyDir, p.outputDir, settings.DocName); if (exitCode == 0) succeeded++; @@ -140,15 +192,15 @@ public override int Execute(CommandContext context, Settings settings, Cancellat } else { - // Multiple projects — run subprocesses concurrently. - // Each subprocess is a fully independent OS process (own deps.json, runtimeconfig, - // working directory) so there are no shared resources or ordering constraints. var sw = Stopwatch.StartNew(); - AnsiConsole.MarkupLine($"\n[grey]Generating {resolved.Count} snapshots concurrently...[/]"); + var maxParallelism = settings.Parallelism > 0 ? settings.Parallelism : resolved.Count; + AnsiConsole.MarkupLine($"\n[grey]Generating {resolved.Count} snapshots concurrently (parallelism={maxParallelism})...[/]"); + var semaphore = new SemaphoreSlim(maxParallelism); var results = new SnapshotResult[resolved.Count]; - var tasks = resolved.Select((p, i) => Task.Run(() => + var tasks = resolved.Select((p, i) => Task.Run(async () => { + await semaphore.WaitAsync(cancellationToken); try { results[i] = RunSnapshotSubprocessBuffered(p.assemblyPath, p.assemblyDir, p.outputDir, settings.DocName); @@ -157,12 +209,15 @@ public override int Execute(CommandContext context, Settings settings, Cancellat { results[i] = new SnapshotResult(1, "", $"Error: {ex.Message}\n"); } + finally + { + semaphore.Release(); + } }, cancellationToken)).ToArray(); - Task.WhenAll(tasks).GetAwaiter().GetResult(); + await Task.WhenAll(tasks); sw.Stop(); - // Print buffered output in project order for (var i = 0; i < resolved.Count; i++) { var p = resolved[i]; @@ -173,7 +228,7 @@ public override int Execute(CommandContext context, Settings settings, Cancellat if (!string.IsNullOrWhiteSpace(result.Stdout)) Console.Write(result.Stdout); if (!string.IsNullOrWhiteSpace(result.Stderr)) - Console.Error.Write(result.Stderr); + await Console.Error.WriteAsync(result.Stderr); if (result.ExitCode == 0) succeeded++; else failed++; @@ -182,10 +237,9 @@ public override int Execute(CommandContext context, Settings settings, Cancellat AnsiConsole.MarkupLine($"\n[grey]Completed in {sw.Elapsed.TotalSeconds:F1}s[/]"); } - // Print summary for multi-project runs - if (projects.Count > 1) + var total = succeeded + failed; + if (total > 1) { - var total = succeeded + failed; if (failed == 0) AnsiConsole.MarkupLine($"[green]Snapshots complete:[/] {succeeded}/{total} succeeded"); else @@ -194,7 +248,11 @@ public override int Execute(CommandContext context, Settings settings, Cancellat return failed > 0 ? 1 : 0; } - + + // ───────────────────────────────────────────────────────────────── + // Project resolution + // ───────────────────────────────────────────────────────────────── + /// /// Resolves the list of projects to process from explicit flags or auto-discovery. /// @@ -313,21 +371,30 @@ private static bool IsWebProject(string csprojPath) } } + // ───────────────────────────────────────────────────────────────── + // Build and resolve + // ───────────────────────────────────────────────────────────────── + /// /// Builds a project (unless --no-build) and resolves its output assembly DLL path. + /// When is true, suppresses console output (used during parallel resolution). /// - private static string? BuildAndResolveAssembly(string projectPath, Settings settings) + private static string? BuildAndResolveAssembly(string projectPath, Settings settings, bool silent = false) { - AnsiConsole.MarkupLine($"[grey]Using project:[/] {projectPath}"); + if (!silent) + AnsiConsole.MarkupLine($"[grey]Using project:[/] {projectPath}"); if (!settings.NoBuild) { - AnsiConsole.MarkupLine($"[grey]Building[/] ({settings.Configuration})..."); + if (!silent) + AnsiConsole.MarkupLine($"[grey]Building[/] ({settings.Configuration})..."); var buildResult = RunProcess("dotnet", - $"build {EscapePath(projectPath)} --configuration {settings.Configuration} --nologo -v q"); + $"build {EscapePath(projectPath)} --configuration {settings.Configuration} --nologo -v q", + silent); if (buildResult != 0) { - AnsiConsole.MarkupLine("[red]Error:[/] Build failed."); + if (!silent) + AnsiConsole.MarkupLine("[red]Error:[/] Build failed."); return null; } } @@ -335,7 +402,8 @@ private static bool IsWebProject(string csprojPath) var targetPath = GetMsBuildProperty(projectPath, "TargetPath", settings.Configuration); if (string.IsNullOrWhiteSpace(targetPath) || !File.Exists(targetPath)) { - AnsiConsole.MarkupLine("[red]Error:[/] Could not resolve assembly path from project. Try using --assembly directly."); + if (!silent) + AnsiConsole.MarkupLine("[red]Error:[/] Could not resolve assembly path from project. Try using --assembly directly."); return null; } @@ -455,7 +523,7 @@ private static int RunSnapshotSubprocess(string assemblyPath, string assemblyDir return process.ExitCode == 0 ? output : null; } - private static int RunProcess(string fileName, string arguments) + private static int RunProcess(string fileName, string arguments, bool silent = false) { var process = new Process { @@ -476,10 +544,13 @@ private static int RunProcess(string fileName, string arguments) process.WaitForExit(); - if (!string.IsNullOrWhiteSpace(stdout)) - Console.Write(stdout); - if (!string.IsNullOrWhiteSpace(stderr)) - Console.Error.Write(stderr); + if (!silent) + { + if (!string.IsNullOrWhiteSpace(stdout)) + Console.Write(stdout); + if (!string.IsNullOrWhiteSpace(stderr)) + Console.Error.Write(stderr); + } return process.ExitCode; }