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
32 changes: 30 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
199 changes: 135 additions & 64 deletions src/SwaggerDiff.Tool/Commands/SnapshotCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace SwaggerDiff.Tool.Commands;
/// optionally builds them, then re-invokes itself via <c>dotnet exec</c> with each target app's
/// deps.json and runtimeconfig.json so that all assembly dependencies resolve correctly.
/// </summary>
internal sealed class SnapshotCommand : Command<SnapshotCommand.Settings>
internal sealed class SnapshotCommand : AsyncCommand<SnapshotCommand.Settings>
{
/// <summary>
/// Well-known directories that are always skipped during auto-discovery.
Expand All @@ -28,8 +28,8 @@ public sealed class Settings : CommandSettings
public string[]? Project { get; set; }

[CommandOption("--assembly <PATH>")]
[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 <CONFIG>")]
[Description("Build configuration.")]
Expand Down Expand Up @@ -59,96 +59,148 @@ public sealed class Settings : CommandSettings
[Description("Directory names to exclude from auto-discovery. Repeat for multiple.")]
public string[]? ExcludeDir { get; set; }

[CommandOption("--parallelism <N>")]
[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<int> 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
var projects = ResolveProjects(settings);
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<int> 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++;
else failed++;
}
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);
Expand All @@ -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];
Expand All @@ -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++;
Expand All @@ -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
Expand All @@ -194,7 +248,11 @@ public override int Execute(CommandContext context, Settings settings, Cancellat

return failed > 0 ? 1 : 0;
}


// ─────────────────────────────────────────────────────────────────
// Project resolution
// ─────────────────────────────────────────────────────────────────

/// <summary>
/// Resolves the list of projects to process from explicit flags or auto-discovery.
/// </summary>
Expand Down Expand Up @@ -313,29 +371,39 @@ private static bool IsWebProject(string csprojPath)
}
}

// ─────────────────────────────────────────────────────────────────
// Build and resolve
// ─────────────────────────────────────────────────────────────────

/// <summary>
/// Builds a project (unless --no-build) and resolves its output assembly DLL path.
/// When <paramref name="silent"/> is true, suppresses console output (used during parallel resolution).
/// </summary>
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;
}
}

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;
}

Expand Down Expand Up @@ -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
{
Expand All @@ -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;
}
Expand Down