|
| 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 | +} |
0 commit comments