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
385 changes: 385 additions & 0 deletions FirstClassErrors.Cli.UnitTests/CatalogCommandsEndToEndTests.cs

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions FirstClassErrors.Cli.UnitTests/GenerateCommandEndToEndTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,36 @@ public void AFailingGenerationIsReportedWithFullDetailOnDebug() {
Check.That(logger.Debugs).HasSize(1);
}

[Fact(DisplayName = "A configured snapshot path is written relative to the configuration file, not the current directory.")]
public void AConfiguredSnapshotPathResolvesRelativeToTheConfigFile() {
// Setup: a nested fce.json declaring a RELATIVE snapshot path. The configured path must resolve against that
// file's directory (like renderer references and the catalog baseline), not the process working directory.
string directory = Path.Combine(Path.GetTempPath(), $"fce-snapshot-cfg-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try {
string configPath = Path.Combine(directory, "fce.json");
File.WriteAllText(configPath, """{ "snapshot": "errors-baseline.json" }""");

RecordingGenerator generator = new(Sample());
GenerateCommand command = new(generator, new RecordingOutputSink(), _ => new RecordingLogger());
GenerateSettings settings = new() {
ConfigPath = configPath,
AssemblyPaths = ["a.dll"],
Format = "json"
};

// Exercise
int exitCode = command.Run(settings, CancellationToken.None);

// Verify: the snapshot lands beside fce.json, never in the process working directory.
Check.That(exitCode).IsEqualTo(0);
Check.That(File.Exists(Path.Combine(directory, "errors-baseline.json"))).IsTrue();
Check.That(File.Exists(Path.Combine(Directory.GetCurrentDirectory(), "errors-baseline.json"))).IsFalse();
} finally {
Directory.Delete(directory, recursive: true);
}
}

#region Helpers

private static ErrorDocumentation Sample() {
Expand Down
51 changes: 51 additions & 0 deletions FirstClassErrors.Cli.UnitTests/TestDoubles.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#region Usings declarations

using FirstClassErrors.GenDoc;
using FirstClassErrors.GenDoc.Versioning;

#endregion

Expand Down Expand Up @@ -103,6 +104,56 @@ public IEnumerable<ErrorDocumentation> GetErrorDocumentationFromAssemblies(IRead

}

/// <summary>
/// A catalog snapshot source that returns a fixed snapshot and records how it was called (the settings and the
/// cancellation token), so catalog-command tests can assert wiring without spawning a real extraction.
/// </summary>
internal sealed class RecordingSnapshotSource : ICatalogSnapshotSource {

private readonly CatalogSnapshot _snapshot;

public RecordingSnapshotSource(CatalogSnapshot snapshot) {
_snapshot = snapshot;
}

public bool WasInvoked { get; private set; }
public CatalogSettings? Settings { get; private set; }
public CancellationToken CancellationToken { get; private set; }

public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
WasInvoked = true;
Settings = settings;
CancellationToken = cancellationToken;

return _snapshot;
}

}

/// <summary>A snapshot source that throws an arbitrary (non-cancellation) failure, to exercise the error path.</summary>
internal sealed class FailingSnapshotSource : ICatalogSnapshotSource {

private readonly Exception _failure;

public FailingSnapshotSource(Exception failure) {
_failure = failure;
}

public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
throw _failure;
}

}

/// <summary>A snapshot source that always reports the run was cancelled, to exercise cancellation handling.</summary>
internal sealed class CancellingSnapshotSource : ICatalogSnapshotSource {

public CatalogSnapshot Extract(CatalogSettings settings, CliConfiguration configuration, IGenerationLogger logger, CancellationToken cancellationToken) {
throw new OperationCanceledException();
}

}

/// <summary>A logger that records each message per level, so tests can assert what the command reported.</summary>
internal sealed class RecordingLogger : IGenerationLogger {

Expand Down
55 changes: 55 additions & 0 deletions FirstClassErrors.Cli/BaselineStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#region Usings declarations

using FirstClassErrors.GenDoc.Versioning;

#endregion

namespace FirstClassErrors.Cli;

/// <summary>
/// Reads and writes the catalog baseline file (<c>errors-baseline.json</c> by default) — the committed snapshot
/// of the error contract that the <c>catalog</c> commands compare against.
/// </summary>
internal static class BaselineStore {

#region Statics members declarations

public const string DefaultFileName = "errors-baseline.json";

/// <summary>
/// Resolves the baseline path with the same portability contract as the rest of the configuration: a
/// command-line value wins and is resolved against the current directory (the caller's vantage point); a value
/// from <c>fce.json</c> is resolved against the configuration file's directory, so a configuration checked in
/// under, say, <c>ci/fce.json</c> keeps pointing at <c>ci/errors-baseline.json</c> wherever it is invoked from.
/// With neither, <see cref="DefaultFileName" /> in the current directory is used.
/// </summary>
/// <param name="optionPath">The <c>--baseline</c> command-line value, if any.</param>
/// <param name="configuredPath">The <c>baseline</c> value from the configuration file, if any.</param>
/// <param name="configDirectory">The directory containing the configuration file, configured paths resolve against it.</param>
public static string Resolve(string? optionPath, string? configuredPath, string configDirectory) {
return ConfigRelativePath.Resolve(optionPath, configuredPath, configDirectory)
?? Path.GetFullPath(DefaultFileName);
}

/// <summary>Determines whether a baseline exists at the given (already resolved) path.</summary>
public static bool Exists(string path) {
return File.Exists(path);
}

/// <summary>Loads and validates the baseline at the given path.</summary>
/// <exception cref="InvalidOperationException">Thrown when the file is not a valid snapshot.</exception>
public static CatalogSnapshot Load(string path) {
return CatalogSnapshotSerializer.Deserialize(File.ReadAllText(path));
}

/// <summary>Writes the snapshot to the given path in its canonical form (creating parent directories as needed).</summary>
public static void Save(string path, CatalogSnapshot snapshot) {
string? directory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(directory) is false) { Directory.CreateDirectory(directory); }

File.WriteAllText(path, CatalogSnapshotSerializer.Serialize(snapshot));
}

#endregion

}
151 changes: 151 additions & 0 deletions FirstClassErrors.Cli/CatalogDiffCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#region Usings declarations

using System.ComponentModel;

using FirstClassErrors.GenDoc;
using FirstClassErrors.GenDoc.Versioning;

using Spectre.Console.Cli;

#endregion

namespace FirstClassErrors.Cli;

/// <summary>Settings for <c>fce catalog diff</c>.</summary>
internal sealed class CatalogDiffSettings : CatalogSettings {

[CommandOption("--against <PATH>")]
[Description("Compare the baseline against this snapshot file instead of extracting the catalog from the source.")]
public string? AgainstPath { get; set; }

[CommandOption("--fail-on <IMPACT>")]
[Description("Exit with code 2 when changes at or above this impact exist: breaking, any or none. Default: breaking.")]
public string? FailOn { get; set; }

[CommandOption("--report <FORMAT>")]
[Description("Report format written to standard output: text, markdown (alias: md) or json. Default: text.")]
public string? Report { get; set; }

}

/// <summary>
/// Compares the current catalog against the committed baseline and reports every change, classified by impact
/// (breaking, compatible, informational). The report goes to standard output — so it can be piped or posted as a
/// pull-request comment — and diagnostics go to standard error.
/// </summary>
/// <remarks>
/// Exit codes: <c>0</c> when no change reaches the <c>--fail-on</c> threshold, <c>2</c> when at least one does,
/// and <c>1</c> on an execution error (missing baseline, failed extraction, …); <c>130</c> on cancellation. CI
/// pipelines can therefore distinguish "the contract changed" from "the tool failed".
/// </remarks>
internal sealed class CatalogDiffCommand : Command<CatalogDiffSettings> {

#region Fields

private readonly ICatalogSnapshotSource _snapshotSource;
private readonly Func<bool, IGenerationLogger> _loggerFactory;
private readonly TextWriter _output;

#endregion

#region Constructors & Destructor

/// <summary>Production constructor used by the CLI host: wires the real extraction pipeline, console logger and stdout.</summary>
public CatalogDiffCommand() : this(
new SolutionCatalogSnapshotSource(),
verbose => new ConsoleGenerationLogger(verbose),
Console.Out) { }

/// <summary>Test seam: injects the collaborators so they can be substituted by fakes.</summary>
internal CatalogDiffCommand(ICatalogSnapshotSource snapshotSource, Func<bool, IGenerationLogger> loggerFactory, TextWriter output) {
_snapshotSource = snapshotSource;
_loggerFactory = loggerFactory;
_output = output;
}

#endregion

protected override int Execute(CommandContext context, CatalogDiffSettings settings, CancellationToken cancellationToken) {
// The command body uses no CommandContext state, so it lives in a context-free seam that tests drive directly.
return Run(settings, cancellationToken);
}

internal int Run(CatalogDiffSettings settings, CancellationToken cancellationToken) {
IGenerationLogger logger = _loggerFactory(settings.Verbose);

try {
string configPath = ConfigurationStore.Resolve(settings.ConfigPath);
CliConfiguration configuration = ConfigurationStore.Load(configPath);
string configDir = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory();

// Validate the policy and report options before the (expensive) extraction runs, so a typo fails fast.
string failOn = (settings.FailOn ?? "breaking").Trim().ToLowerInvariant();
if (failOn is not ("breaking" or "any" or "none")) {
logger.Error($"Unknown --fail-on value '{settings.FailOn}'. Use breaking, any or none.");

return 1;
}

string report = NormalizeReport(settings.Report ?? "text");
if (report is not ("text" or "markdown" or "json")) {
logger.Error($"Unknown --report value '{settings.Report}'. Use text, markdown (alias: md) or json.");

return 1;
}

string baselinePath = BaselineStore.Resolve(settings.BaselinePath, configuration.Baseline, configDir);
if (BaselineStore.Exists(baselinePath) is false) {
logger.Error($"No baseline at '{baselinePath}'. Run 'fce catalog update' to create it.");

return 1;
}

CatalogSnapshot baseline = BaselineStore.Load(baselinePath);
CatalogSnapshot current = string.IsNullOrWhiteSpace(settings.AgainstPath)
? _snapshotSource.Extract(settings, configuration, logger, cancellationToken)
: BaselineStore.Load(Path.GetFullPath(settings.AgainstPath));

CatalogDiff diff = CatalogDiffer.Diff(baseline, current);

_output.Write(report switch {
"markdown" => CatalogDiffFormatter.ToMarkdown(diff),
"json" => CatalogDiffFormatter.ToJson(diff),
_ => CatalogDiffFormatter.ToText(diff)
});

bool violated = failOn switch {
"breaking" => diff.HasChangesAtOrAbove(CatalogChangeImpact.Breaking),
"any" => diff.IsEmpty is false,
_ => false
};

if (violated) {
logger.Error(failOn == "breaking"
? $"The catalog has {diff.BreakingChanges.Count} breaking change(s) against the baseline. Fix them, or accept them deliberately with 'fce catalog update'."
: $"The catalog has {diff.Changes.Count} change(s) against the baseline. Accept them with 'fce catalog update'.");

return 2;
}

return 0;
} catch (OperationCanceledException) {
// Cancellation (Ctrl+C) is an abort, not a failure: report it with the conventional SIGINT exit code.
logger.Error("Catalog diff canceled.");

return 130;
} catch (Exception exception) {
// Report expected failures (missing solution, worker crash, invalid baseline, …) as a terse line.
logger.Error(exception.Message);
logger.Debug(exception.ToString());

return 1;
}
}

private static string NormalizeReport(string report) {
string normalized = report.Trim().ToLowerInvariant();

return normalized == "md" ? "markdown" : normalized;
}

}
60 changes: 60 additions & 0 deletions FirstClassErrors.Cli/CatalogSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#region Usings declarations

using System.ComponentModel;

using Spectre.Console.Cli;

#endregion

namespace FirstClassErrors.Cli;

/// <summary>
/// Common options for the <c>catalog</c> commands (<c>update</c>, <c>diff</c>): the documentation source and
/// build options shared with <c>generate</c>, plus the location of the baseline file. Every value is optional:
/// when omitted, the command falls back to the configuration file (<c>fce.json</c>) and then to a built-in
/// default.
/// </summary>
/// <remarks>
/// The catalog commands have no <c>--format</c>, <c>--layout</c> or <c>--language</c>: they operate on the
/// canonical contract snapshot, which is renderer-independent and always extracted under the <c>en</c> culture so
/// the baseline does not depend on the machine that produces it.
/// </remarks>
internal class CatalogSettings : ConfigScopedSettings {

[CommandOption("-s|--solution <PATH>")]
[Description("Path to the .sln file to snapshot (overrides 'solution' in the configuration).")]
public string? SolutionPath { get; set; }

[CommandOption("-a|--assemblies <PATH>")]
[Description("A built assembly (.dll) to snapshot; repeat to snapshot several (overrides 'assemblies').")]
public string[] AssemblyPaths { get; set; } = [];

[CommandOption("--baseline <PATH>")]
[Description("Path of the baseline file (falls back to 'baseline' in the configuration, then errors-baseline.json).")]
public string? BaselinePath { get; set; }

[CommandOption("-c|--configuration <NAME>")]
[Description("Build configuration used when building a solution. Falls back to the configuration, then Debug.")]
public string? Configuration { get; set; }

[CommandOption("--framework <TFM>")]
[Description("Restrict a multi-target solution to a single target framework.")]
public string? Framework { get; set; }

[CommandOption("--no-build")]
[Description("Do not build the solution; snapshot the existing binaries.")]
public bool NoBuild { get; set; }

[CommandOption("--strict")]
[Description("Abort on the first extraction failure (default: continue and report).")]
public bool Strict { get; set; }

[CommandOption("--worker <PATH>")]
[Description("Path to FirstClassErrors.GenDoc.Worker.dll (default: next to this tool).")]
public string? WorkerPath { get; set; }

[CommandOption("--verbose")] // Long-only to stay clear of Spectre's built-in --version/-h help options.
[Description("Emit diagnostic logging to standard error.")]
public bool Verbose { get; set; }

}
Loading