Skip to content

Commit 17fb6f0

Browse files
committed
tidy up VisualDiffCommand
1 parent 1bc9e30 commit 17fb6f0

8 files changed

Lines changed: 1926 additions & 0 deletions

File tree

CLI/Commands/VisualDiffCommand.cs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// =============================================================================
2+
// Author: Vladyslav Zaiets | https://sarmkadan.com
3+
// CTO & Software Architect
4+
// =============================================================================
5+
6+
using EfMigrationDiff.Configuration;
7+
using EfMigrationDiff.Interfaces;
8+
using EfMigrationDiff.Models;
9+
using EfMigrationDiff.Services;
10+
11+
namespace EfMigrationDiff.CLI.Commands;
12+
13+
/// <summary>
14+
/// CLI command that drives the v2 visual schema diff feature.
15+
/// </summary>
16+
/// <remarks>
17+
/// Accepts <c>--source</c>, <c>--target</c>, and optionally <c>--base</c> branch name
18+
/// options. When <c>--base</c> is present a full three-way merge editor HTML document is
19+
/// produced; otherwise a two-way side-by-side or unified view is generated depending on
20+
/// the <c>--format</c> option.
21+
///
22+
/// Exit codes:
23+
/// <list type="bullet">
24+
/// <item><description><c>0</c> — diff is clean, or all conflicts are trivially auto-resolvable.</description></item>
25+
/// <item><description><c>1</c> — destructive changes detected, or unresolvable conflicts present.</description></item>
26+
/// </list>
27+
/// </remarks>
28+
public sealed class VisualDiffCommand
29+
{
30+
private readonly SchemaDiffPipelineService _pipeline;
31+
private readonly IMergeEditor _mergeEditor;
32+
private readonly AppSettings _settings;
33+
34+
/// <summary>
35+
/// Initialises the command with required v2 service dependencies.
36+
/// </summary>
37+
/// <param name="pipeline">Pipeline that bridges v1 migration data with the v2 diff engine.</param>
38+
/// <param name="mergeEditor">Editor used when an auto-merge is requested.</param>
39+
/// <param name="settings">Application settings for default branches and output directory.</param>
40+
public VisualDiffCommand(
41+
SchemaDiffPipelineService pipeline,
42+
IMergeEditor mergeEditor,
43+
AppSettings settings)
44+
{
45+
_pipeline = pipeline;
46+
_mergeEditor = mergeEditor;
47+
_settings = settings;
48+
}
49+
50+
// =========================================================================
51+
// Command entry point
52+
// =========================================================================
53+
54+
/// <summary>
55+
/// Executes the visual-diff command, writing the HTML report to disk and printing
56+
/// a summary to the command context's output stream.
57+
/// </summary>
58+
/// <param name="context">Runtime context carrying parsed arguments and options.</param>
59+
/// <returns>
60+
/// <c>0</c> when the diff is clean or auto-resolvable; <c>1</c> when manual review
61+
/// is required.
62+
/// </returns>
63+
public int Execute(CommandContext context)
64+
{
65+
var sourceName = context.GetOption("source") ?? _settings.SourceBranch;
66+
var targetName = context.GetOption("target") ?? _settings.TargetBranch;
67+
var baseName = context.GetOption("base");
68+
var format = context.GetOption("format") ?? "sidebyside";
69+
70+
var outputFile = context.GetOption("output")
71+
?? Path.Combine(
72+
_settings.GetOutputDirectory(),
73+
BuildOutputFilename(sourceName, targetName, baseName));
74+
75+
var diffOptions = new SchemaDiffOptions
76+
{
77+
SourceLabel = sourceName,
78+
TargetLabel = targetName,
79+
BaseLabel = baseName ?? "base",
80+
IncludeSqlContent = true,
81+
IncludeMetadata = context.HasOption("metadata"),
82+
IgnoreWhitespace = context.HasOption("ignore-whitespace")
83+
};
84+
85+
var source = MakeBranchInfo(sourceName);
86+
var target = MakeBranchInfo(targetName);
87+
88+
return baseName is not null
89+
? ExecuteThreeWayDiff(context, MakeBranchInfo(baseName), source, target, diffOptions, outputFile)
90+
: ExecuteTwoWayDiff(context, source, target, diffOptions, format, outputFile);
91+
}
92+
93+
// =========================================================================
94+
// Two-way diff
95+
// =========================================================================
96+
97+
private int ExecuteTwoWayDiff(
98+
CommandContext context,
99+
BranchInfo source,
100+
BranchInfo target,
101+
SchemaDiffOptions options,
102+
string format,
103+
string outputFile)
104+
{
105+
var result = _pipeline.RunTwoWayDiff(source, target, options);
106+
107+
var html = format.Equals("unified", StringComparison.OrdinalIgnoreCase)
108+
? result.UnifiedHtml
109+
: result.SideBySideHtml;
110+
111+
WriteHtmlFile(outputFile, html);
112+
context.WriteOutput($"Visual diff written to: {outputFile}");
113+
114+
if (result.Diff is { } diff)
115+
{
116+
context.WriteOutput(
117+
$" Source-only: {diff.SourceOnlyChanges.Count} " +
118+
$"Target-only: {diff.TargetOnlyChanges.Count} " +
119+
$"Modified: {diff.ModifiedChanges.Count}");
120+
121+
if (result.HasDestructiveChanges)
122+
{
123+
context.WriteColoredOutput(
124+
" WARNING: destructive schema changes detected — review before merging.",
125+
ConsoleColor.Yellow);
126+
return 1;
127+
}
128+
129+
if (diff.IsIdentical)
130+
context.WriteColoredOutput(" Branches are schema-identical.", ConsoleColor.Green);
131+
}
132+
133+
return 0;
134+
}
135+
136+
// =========================================================================
137+
// Three-way merge editor
138+
// =========================================================================
139+
140+
private int ExecuteThreeWayDiff(
141+
CommandContext context,
142+
BranchInfo baseBranch,
143+
BranchInfo source,
144+
BranchInfo target,
145+
SchemaDiffOptions options,
146+
string outputFile)
147+
{
148+
var result = _pipeline.RunThreeWayDiff(baseBranch, source, target, options);
149+
150+
WriteHtmlFile(outputFile, result.MergeEditorHtml);
151+
context.WriteOutput($"Merge editor written to: {outputFile}");
152+
153+
if (result.ThreeWayDiff is { } diff)
154+
{
155+
if (diff.ConflictCount == 0)
156+
{
157+
context.WriteColoredOutput(
158+
" No conflicts — merge can be applied cleanly.",
159+
ConsoleColor.Green);
160+
return 0;
161+
}
162+
163+
if (diff.IsAutoMergeable)
164+
{
165+
context.WriteColoredOutput(
166+
$" {diff.ConflictCount} conflict(s) detected, all trivially auto-resolvable.",
167+
ConsoleColor.Cyan);
168+
return 0;
169+
}
170+
171+
context.WriteColoredOutput(
172+
$" {diff.ConflictCount} conflict(s) require manual resolution — see: {outputFile}",
173+
ConsoleColor.Yellow);
174+
return 1;
175+
}
176+
177+
return 0;
178+
}
179+
180+
// =========================================================================
181+
// Private helpers
182+
// =========================================================================
183+
184+
private static BranchInfo MakeBranchInfo(string branchName) =>
185+
new(branchName, string.Empty);
186+
187+
private static string BuildOutputFilename(string source, string target, string? baseBranch)
188+
{
189+
var kind = baseBranch is not null ? "merge-editor" : "visual-diff";
190+
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
191+
return $"{kind}-{SanitiseName(source)}-vs-{SanitiseName(target)}-{stamp}.html";
192+
}
193+
194+
private static string SanitiseName(string name) =>
195+
name.Replace('/', '-').Replace('\\', '-').TrimStart('-');
196+
197+
private static void WriteHtmlFile(string path, string content)
198+
{
199+
var dir = Path.GetDirectoryName(path);
200+
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
201+
Directory.CreateDirectory(dir);
202+
203+
File.WriteAllText(path, content, System.Text.Encoding.UTF8);
204+
}
205+
}

Configuration/SchemaDiffOptions.cs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// =============================================================================
2+
// Author: Vladyslav Zaiets | https://sarmkadan.com
3+
// CTO & Software Architect
4+
// =============================================================================
5+
6+
namespace EfMigrationDiff.Configuration;
7+
8+
/// <summary>
9+
/// Immutable configuration options that control how schema diffs and merge operations are computed
10+
/// and rendered by <c>SchemaDiffEngine</c> and <c>VisualDiffFormatter</c>.
11+
/// Defined as a record to allow non-destructive <c>with</c>-expression copies inside the engine.
12+
/// </summary>
13+
public sealed record SchemaDiffOptions
14+
{
15+
/// <summary>
16+
/// Display label for the common ancestor (base) branch.
17+
/// Used in three-way diff output and merge editor HTML rendering.
18+
/// Defaults to <c>"base"</c>.
19+
/// </summary>
20+
public string BaseLabel { get; init; } = "base";
21+
22+
/// <summary>
23+
/// Display label for the source branch (left pane in side-by-side views).
24+
/// Defaults to <c>"source"</c>.
25+
/// </summary>
26+
public string SourceLabel { get; init; } = "source";
27+
28+
/// <summary>
29+
/// Display label for the target branch (right pane in side-by-side views).
30+
/// Defaults to <c>"target"</c>.
31+
/// </summary>
32+
public string TargetLabel { get; init; } = "target";
33+
34+
/// <summary>
35+
/// Number of unchanged lines to show before and after each changed region
36+
/// in unified and side-by-side views. Defaults to <c>3</c>.
37+
/// </summary>
38+
public int ContextLines { get; init; } = 3;
39+
40+
/// <summary>
41+
/// When <c>true</c>, the raw SQL statement for each schema change is included
42+
/// as a rendered line in the diff output. Defaults to <c>true</c>.
43+
/// </summary>
44+
public bool IncludeSqlContent { get; init; } = true;
45+
46+
/// <summary>
47+
/// When <c>true</c>, old-value and new-value metadata lines are appended beneath
48+
/// each schema change entry. Defaults to <c>false</c>.
49+
/// </summary>
50+
public bool IncludeMetadata { get; init; } = false;
51+
52+
/// <summary>
53+
/// When <c>true</c>, leading and trailing whitespace differences are normalised
54+
/// before line comparison, reducing cosmetic noise in the diff. Defaults to <c>false</c>.
55+
/// </summary>
56+
public bool IgnoreWhitespace { get; init; } = false;
57+
58+
/// <summary>
59+
/// Maximum number of content lines a single hunk may contain before it is split
60+
/// at the nearest unchanged boundary. A value of <c>0</c> disables splitting.
61+
/// Defaults to <c>0</c>.
62+
/// </summary>
63+
public int MaxHunkLines { get; init; } = 0;
64+
65+
/// <summary>
66+
/// Default options instance with production-safe values.
67+
/// </summary>
68+
public static SchemaDiffOptions Default => new();
69+
70+
/// <summary>
71+
/// Creates options pre-configured for a named two-way branch comparison.
72+
/// </summary>
73+
/// <param name="source">Source branch display name.</param>
74+
/// <param name="target">Target branch display name.</param>
75+
/// <returns>A new <see cref="SchemaDiffOptions"/> with the specified labels.</returns>
76+
public static SchemaDiffOptions ForBranches(string source, string target) => new()
77+
{
78+
SourceLabel = source,
79+
TargetLabel = target
80+
};
81+
82+
/// <summary>
83+
/// Creates options pre-configured for a three-way merge scenario.
84+
/// </summary>
85+
/// <param name="baseBranch">Common ancestor branch display name.</param>
86+
/// <param name="source">Source branch display name.</param>
87+
/// <param name="target">Target branch display name.</param>
88+
/// <returns>A new <see cref="SchemaDiffOptions"/> with all three labels set.</returns>
89+
public static SchemaDiffOptions ForMerge(string baseBranch, string source, string target) => new()
90+
{
91+
BaseLabel = baseBranch,
92+
SourceLabel = source,
93+
TargetLabel = target
94+
};
95+
}

0 commit comments

Comments
 (0)