diff --git a/WrapGod.Cli/MigrateCommandBuilder.cs b/WrapGod.Cli/MigrateCommandBuilder.cs index c0b4b98..10cdcb1 100644 --- a/WrapGod.Cli/MigrateCommandBuilder.cs +++ b/WrapGod.Cli/MigrateCommandBuilder.cs @@ -14,6 +14,7 @@ public static Command Build() { MigrateInitCommand.CreateSubCommand(), MigrateGenerateCommand.Create(), + MigrateStatusCommand.Create(), }; return migrateCommand; diff --git a/WrapGod.Cli/MigrateStatusCommand.cs b/WrapGod.Cli/MigrateStatusCommand.cs new file mode 100644 index 0000000..9e53c1a --- /dev/null +++ b/WrapGod.Cli/MigrateStatusCommand.cs @@ -0,0 +1,405 @@ +using System.CommandLine; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using WrapGod.Migration; +using WrapGod.Migration.Engine.State; + +namespace WrapGod.Cli; + +/// +/// Implements wrap-god migrate status — reads the state file sibling to a schema +/// and reports progress without running any migration. +/// +internal static class MigrateStatusCommand +{ + private static readonly JsonSerializerOptions JsonOutputOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + // NOTE: progressPct, library, from, to may be null and MUST be emitted as + // explicit JSON null so downstream tooling can distinguish "missing" from + // "not applicable" (e.g. progressPct == null means 0-rules schema). + }; + + public static Command Create() + { + var schemaOption = new Option( + ["--schema", "-s"], + "Path to the migration schema JSON file. The state file is the sibling .state.json.") + { + IsRequired = true, + }; + + var projectDirOption = new Option( + ["--project-dir", "-p"], + "Project directory used to resolve a relative --schema path (default: current directory)."); + + var jsonOption = new Option( + "--json", + "Emit output as JSON instead of human-readable text."); + + var verboseOption = new Option( + ["--verbose", "-v"], + "Include per-rule details and per-file applied lists in human-readable mode."); + + var command = new Command("status", "Report migration progress from the state file without running any migration") + { + schemaOption, + projectDirOption, + jsonOption, + verboseOption, + }; + + command.SetHandler((context) => + { + var schema = context.ParseResult.GetValueForOption(schemaOption)!; + var projectDir = context.ParseResult.GetValueForOption(projectDirOption); + var json = context.ParseResult.GetValueForOption(jsonOption); + var verbose = context.ParseResult.GetValueForOption(verboseOption); + + context.ExitCode = Handle(schema, projectDir, json, verbose); + }); + + return command; + } + + private static int Handle( + string schemaArg, + string? projectDir, + bool jsonOutput, + bool verbose) + { + // ── Resolve schema path ────────────────────────────────────────────────────────────── + var baseDir = string.IsNullOrWhiteSpace(projectDir) + ? Directory.GetCurrentDirectory() + : projectDir; + + var schemaPath = Path.IsPathRooted(schemaArg) + ? schemaArg + : Path.GetFullPath(Path.Combine(baseDir, schemaArg)); + + if (!File.Exists(schemaPath)) + { + Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Error: schema file not found: {0}", schemaPath)); + return 1; + } + + // ── Load schema (for library/from/to metadata + total rule count) ─────────────────── + MigrationSchema? schema = null; + string? currentHash = null; + try + { + var schemaJson = File.ReadAllText(schemaPath, System.Text.Encoding.UTF8); + currentHash = MigrationStateStore.ComputeSchemaHash(schemaJson); + try + { + schema = MigrationSchemaSerializer.Deserialize(schemaJson); + } + catch (JsonException) + { + // Schema couldn't be parsed — non-fatal, we just don't show library/from/to or totalRules + schema = null; + } + } + catch (IOException) + { + // Non-fatal — schema read failed; skip hash and metadata + } + + // ── Load state ─────────────────────────────────────────────────────────────────────── + var state = MigrationStateStore.Load(schemaPath, out var wasCorrupt, out var backupPath); + + if (wasCorrupt) + { + var bakMsg = backupPath is not null + ? string.Format(CultureInfo.InvariantCulture, "Corrupt state file was archived to: {0}", backupPath) + : "Corrupt state file could not be archived (file may be locked)."; + + Console.Error.WriteLine("Error: The migration state file is corrupt and could not be parsed."); + Console.Error.WriteLine(bakMsg); + return 1; + } + + if (state is null) + { + // State file simply doesn't exist — info-only, not an error. + if (jsonOutput) + { + var sentinel = new + { + status = "no-runs-recorded", + library = schema?.Library, + from = schema?.From, + to = schema?.To, + }; + Console.WriteLine(JsonSerializer.Serialize(sentinel, JsonOutputOptions)); + } + else + { + Console.WriteLine("No migration runs recorded for this schema."); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Run `wrap-god migrate apply --schema \"{0}\"` to apply migrations.", + Path.GetFileName(schemaPath))); + } + return 0; + } + + var schemaChanged = currentHash is not null && state.SchemaHasChanged(currentHash); + + // ── Detect synthetic SkippedRewrite (corruption recovery marker from #197) ── + var hasStateRecoveryEntry = state.Skipped.Any(s => + string.Equals(s.RuleId, "", StringComparison.Ordinal)); + + // ── Progress calculation ───────────────────────────────────────────────────────────── + // totalRules from the schema (source of truth); appliedRules = distinct rule IDs in state.Applied + var totalRules = schema?.Rules.Count ?? 0; + var appliedRules = state.Applied + .Select(a => a.RuleId) + .Distinct(StringComparer.Ordinal) + .Count(); + double? progressPct = totalRules == 0 + ? (double?)null + : (double)appliedRules / totalRules; + + // ── Emit output ────────────────────────────────────────────────────────────────────── + if (jsonOutput) + { + return EmitJson(state, schema, schemaChanged, currentHash, hasStateRecoveryEntry, + totalRules, appliedRules, progressPct); + } + else + { + return EmitHumanReadable(state, schema, schemaChanged, hasStateRecoveryEntry, verbose, + totalRules, appliedRules, progressPct); + } + } + + // ── JSON output ────────────────────────────────────────────────────────────────────────── + + private static int EmitJson( + MigrationState state, + MigrationSchema? schema, + bool schemaChanged, + string? currentHash, + bool hasStateRecoveryEntry, + int totalRules, + int appliedRules, + double? progressPct) + { + var appliedByRule = state.Applied + .GroupBy(a => a.RuleId, StringComparer.Ordinal) + .Select(g => new + { + ruleId = g.Key, + fileCount = g.Select(a => a.File).Distinct(StringComparer.OrdinalIgnoreCase).Count(), + }) + .ToList(); + + var skippedByReason = state.Skipped + .Where(s => !string.Equals(s.RuleId, "", StringComparison.Ordinal)) + .GroupBy(s => s.Reason, StringComparer.OrdinalIgnoreCase) + .Select(g => new { reason = g.Key, count = g.Count() }) + .ToList(); + + var manualEntries = state.Manual + .Select(m => new + { + ruleId = m.RuleId, + note = m.Note, + matchedFiles = m.MatchedFiles.Count == 0 + ? (object)"(no files matched yet)" + : m.MatchedFiles, + }) + .ToList(); + + var output = new + { + library = schema?.Library, + from = schema?.From, + to = schema?.To, + schema = state.Schema, + schemaHash = state.SchemaHash, + schemaChanged, + currentSchemaHash = currentHash, + startedAt = state.StartedAt, + lastRunAt = state.LastRunAt, + totalRules, + appliedRules, + progressPct, + summary = new + { + total = state.Summary.TotalRules, + applied = state.Summary.Applied, + skipped = state.Summary.Skipped, + manual = state.Summary.Manual, + }, + applied = appliedByRule, + skipped = skippedByReason, + manual = manualEntries, + stateRecoveryOccurred = hasStateRecoveryEntry, + }; + + Console.WriteLine(JsonSerializer.Serialize(output, JsonOutputOptions)); + return state.Manual.Count > 0 ? 2 : 0; + } + + // ── Human-readable output ──────────────────────────────────────────────────────────────── + + private static int EmitHumanReadable( + MigrationState state, + MigrationSchema? schema, + bool schemaChanged, + bool hasStateRecoveryEntry, + bool verbose, + int totalRules, + int appliedRules, + double? progressPct) + { + Console.WriteLine("WrapGod migrate status"); + Console.WriteLine(new string('-', 40)); + + // Migration metadata header (library + from → to) + if (schema is not null && !string.IsNullOrEmpty(schema.Library)) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Migration: {0} {1} -> {2}", schema.Library, schema.From, schema.To)); + } + + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "Schema: {0}", state.Schema)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Started: {0:yyyy-MM-dd HH:mm:ss} UTC", state.StartedAt.UtcDateTime)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Last run: {0:yyyy-MM-dd HH:mm:ss} UTC", state.LastRunAt.UtcDateTime)); + Console.WriteLine(); + + // Progress ratio line + if (totalRules > 0 && progressPct.HasValue) + { + var pctInt = (int)Math.Round(progressPct.Value * 100.0, MidpointRounding.AwayFromZero); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Progress: {0} / {1} rules applied ({2}%)", + appliedRules, totalRules, pctInt)); + } + else + { + // Zero-rules edge case — display "n/a" + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Progress: {0} / {1} rules applied (n/a)", + appliedRules, totalRules)); + } + Console.WriteLine(); + + // Schema hash line + if (schemaChanged) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Schema hash: {0} (WARNING: schema has changed since last apply)", state.SchemaHash)); + } + else if (!string.IsNullOrEmpty(state.SchemaHash)) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Schema hash: {0} (matches current schema)", state.SchemaHash)); + } + + Console.WriteLine(); + + // State recovery highlight + if (hasStateRecoveryEntry) + { + Console.WriteLine(" [!] State recovery occurred — a previous state was corrupt and this run started fresh."); + Console.WriteLine(); + } + + // Counts + var appliedFiles = state.Applied + .Select(a => a.File) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Applied: {0,5} (across {1} file(s))", state.Summary.Applied, appliedFiles)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Skipped: {0,5}", state.Summary.Skipped)); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + "Manual: {0,5}", state.Summary.Manual)); + + // ── Verbose: per-rule applied breakdown ───────────────────────────────────── + if (verbose && state.Applied.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Applied rules:"); + foreach (var group in state.Applied + .GroupBy(a => a.RuleId, StringComparer.Ordinal) + .OrderBy(g => g.Key, StringComparer.Ordinal)) + { + var fileCount = group + .Select(a => a.File) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count(); + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0}: {1} file(s)", group.Key, fileCount)); + } + } + + // ── Skipped rules ──────────────────────────────────────────────────────────── + var visibleSkipped = state.Skipped + .Where(s => !string.Equals(s.RuleId, "", StringComparison.Ordinal)) + .ToList(); + + if (visibleSkipped.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Skipped rules:"); + if (verbose) + { + foreach (var skip in visibleSkipped.OrderBy(s => s.RuleId, StringComparer.Ordinal)) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0} {1}:{2} {3}", skip.RuleId, skip.File, skip.Line, skip.Reason)); + } + } + else + { + // Group by reason + foreach (var group in visibleSkipped + .GroupBy(s => s.Reason, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(g => g.Count())) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0}: {1}", group.Key, group.Count())); + } + } + } + + // ── Manual rules ───────────────────────────────────────────────────────────── + if (state.Manual.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Manual rules (require human intervention):"); + foreach (var manual in state.Manual.OrderBy(m => m.RuleId, StringComparer.Ordinal)) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0} {1}", manual.RuleId, manual.Note)); + if (verbose) + { + if (manual.MatchedFiles.Count == 0) + { + Console.WriteLine(" (no files matched yet)"); + } + else + { + foreach (var file in manual.MatchedFiles) + { + Console.WriteLine(string.Format(CultureInfo.InvariantCulture, + " {0}", file)); + } + } + } + } + } + + return state.Manual.Count > 0 ? 2 : 0; + } +} diff --git a/WrapGod.Cli/WrapGod.Cli.csproj b/WrapGod.Cli/WrapGod.Cli.csproj index df7c481..3b7613c 100644 --- a/WrapGod.Cli/WrapGod.Cli.csproj +++ b/WrapGod.Cli/WrapGod.Cli.csproj @@ -21,6 +21,7 @@ + diff --git a/WrapGod.Tests/CliCommandTests.cs b/WrapGod.Tests/CliCommandTests.cs index 76ee22d..603f7b1 100644 --- a/WrapGod.Tests/CliCommandTests.cs +++ b/WrapGod.Tests/CliCommandTests.cs @@ -9,7 +9,9 @@ public sealed class CliCommandTests private static readonly string[] ExpectedRootCommands = ["analyze", "ci", "doctor", "explain", "extract", "generate", "init", "migrate"]; - private static readonly string[] ExpectedMigrateCommands = ["generate", "init"]; + // TODO: when #199 / PR #215 (`migrate apply`) lands on main, add "apply" to this list. + // The order is alphabetical to match the assertion in RootCommand_WiresExpectedCommands. + private static readonly string[] ExpectedMigrateCommands = ["generate", "init", "status"]; private static readonly string[] ExpectedCiCommands = ["bootstrap", "parity"]; diff --git a/WrapGod.Tests/MigrateStatusCliTests.cs b/WrapGod.Tests/MigrateStatusCliTests.cs new file mode 100644 index 0000000..5f54dfe --- /dev/null +++ b/WrapGod.Tests/MigrateStatusCliTests.cs @@ -0,0 +1,619 @@ +using System.CommandLine; +using System.Text.Json; +using TinyBDD; +using WrapGod.Cli; +using WrapGod.Migration.Engine; +using WrapGod.Migration.Engine.State; + +namespace WrapGod.Tests; + +/// +/// In-process BDD-style tests for wrap-god migrate status. +/// Uses the same helper pattern as MigrateGenerateCliTests (Console redirect + Command.InvokeAsync). +/// +[Feature("CLI: migrate status command reports migration progress from state file")] +[Collection("CLI")] +public sealed class MigrateStatusCliTests +{ + // ── Helper: invoke via migrate parent ──────────────────────────────────────────────────── + + private static async Task<(int ExitCode, string StdOut, string StdErr)> InvokeAsync(string args) + { + var command = MigrateCommandBuilder.Build(); + var previousOut = Console.Out; + var previousErr = Console.Error; + var previousExitCode = Environment.ExitCode; + using var stdOut = new StringWriter(); + using var stdErr = new StringWriter(); + + try + { + Console.SetOut(stdOut); + Console.SetError(stdErr); + Environment.ExitCode = 0; + + var invokeCode = await command.InvokeAsync(args); + var effectiveExitCode = Environment.ExitCode == 0 ? invokeCode : Environment.ExitCode; + return (effectiveExitCode, stdOut.ToString(), stdErr.ToString()); + } + finally + { + Console.SetOut(previousOut); + Console.SetError(previousErr); + Environment.ExitCode = previousExitCode; + } + } + + // ── Fixture helpers ────────────────────────────────────────────────────────────────────── + + private static string CreateTempDir() + { + var path = Path.Combine(Path.GetTempPath(), $"wrapgod-status-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static void SafeDelete(string path) + { + try { Directory.Delete(path, recursive: true); } catch { /* best-effort */ } + } + + /// Writes a minimal schema JSON file and returns its path. + private static async Task WriteSchemaAsync(string dir, string fileName = "schema.wrapgod-migration.json") + { + var schemaPath = Path.Combine(dir, fileName); + await File.WriteAllTextAsync(schemaPath, """ + { + "schemaVersion": "1.0", + "library": "TestLib", + "from": "1.0.0", + "to": "2.0.0", + "rules": [] + } + """); + return schemaPath; + } + + /// Writes a state file sibling to the schema and returns the schema path. + private static async Task WriteStateAsync( + string dir, + MigrationState state, + string schemaFileName = "schema.wrapgod-migration.json") + { + var schemaPath = await WriteSchemaAsync(dir, schemaFileName); + MigrationStateStore.Save(schemaPath, state); + return schemaPath; + } + + private static MigrationState BuildState( + int appliedCount = 38, + int skippedCount = 6, + int manualCount = 3, + string schemaHash = "sha256:aabbccdd") + { + var applied = Enumerable.Range(1, appliedCount) + .Select(i => new AppliedRewrite($"R-{i:D3}", $"src/File{i}.cs", i * 10, $"OldType{i}", $"NewType{i}")) + .ToList(); + + var skipped = Enumerable.Range(1, skippedCount) + .Select(i => new SkippedRewrite($"S-{i:D3}", $"src/Skip{i}.cs", i * 5, $"Ambiguous: reason {i}")) + .ToList(); + + var manual = Enumerable.Range(1, manualCount) + .Select(i => new ManualRewrite($"M-{i:D3}", $"Manual action {i}", [$"src/Manual{i}A.cs", $"src/Manual{i}B.cs"])) + .ToList(); + + return new MigrationState + { + Schema = "schema.wrapgod-migration.json", + SchemaHash = schemaHash, + StartedAt = new DateTimeOffset(2026, 4, 1, 12, 0, 0, TimeSpan.Zero), + LastRunAt = new DateTimeOffset(2026, 4, 2, 9, 14, 33, TimeSpan.Zero), + Summary = new MigrationStateSummary + { + TotalRules = appliedCount + skippedCount + manualCount, + Applied = appliedCount, + Skipped = skippedCount, + Manual = manualCount, + }, + Applied = applied, + Skipped = skipped, + Manual = manual, + }; + } + + // ════════════════════════════════════════════════════════════════════════════ + // Group: happy + // ════════════════════════════════════════════════════════════════════════════ + + /// + /// SCENARIO: Happy-01 — state with applied/skipped/manual entries exits 2 and shows counts. + /// + [Scenario("Happy-01: status with applied/skipped/manual entries exits 2, stdout has counts")] + [Fact] + public async Task Status_HappyPath_PrintsProgress() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(38, 6, 3)); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(2, exitCode); + Assert.Contains("38", stdout); + Assert.Contains("6", stdout); + Assert.Contains("3", stdout); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Happy-02 — --json flag produces valid JSON with required fields. + /// + [Scenario("Happy-02: --json flag produces valid JSON with required fields")] + [Fact] + public async Task Status_Json_ParsesAsJson() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(38, 6, 3)); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json"); + + Assert.Equal(2, exitCode); + var doc = JsonDocument.Parse(stdout); // throws if not valid JSON + Assert.True(doc.RootElement.TryGetProperty("applied", out _), "JSON must have 'applied' field"); + Assert.True(doc.RootElement.TryGetProperty("skipped", out _), "JSON must have 'skipped' field"); + Assert.True(doc.RootElement.TryGetProperty("manual", out _), "JSON must have 'manual' field"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Happy-03 — state with 0 manual entries exits 0. + /// + [Scenario("Happy-03: state with zero manual entries exits 0")] + [Fact] + public async Task Status_NoManual_ExitsZero() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(10, 2, 0)); + var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(0, exitCode); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Happy-04 — state with manual entries exits 2. + /// + [Scenario("Happy-04: state with manual entries exits 2")] + [Fact] + public async Task Status_ManualPresent_ExitsTwo() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(5, 1, 3)); + var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(2, exitCode); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Happy-05 — --verbose includes per-rule detail lines (rule IDs from applied/skipped/manual). + /// + [Scenario("Happy-05: --verbose includes per-rule detail lines")] + [Fact] + public async Task Status_Verbose_IncludesRuleDetails() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(3, 1, 1)); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --verbose"); + + Assert.Equal(2, exitCode); + Assert.Contains("R-001", stdout); // applied rule + Assert.Contains("S-001", stdout); // skipped rule + Assert.Contains("M-001", stdout); // manual rule + } + finally { SafeDelete(dir); } + } + + // ════════════════════════════════════════════════════════════════════════════ + // Group: sad + // ════════════════════════════════════════════════════════════════════════════ + + /// + /// SCENARIO: Sad-01 — no state file exits 0 with friendly message. + /// + [Scenario("Sad-01: missing state file exits 0 with friendly message")] + [Fact] + public async Task Status_NoState_PrintsFriendlyMessage() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteSchemaAsync(dir); // schema exists, but no .state.json + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(0, exitCode); + Assert.Contains("No migration runs recorded", stdout, StringComparison.OrdinalIgnoreCase); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Sad-02 — corrupt state file exits 1. + /// + [Scenario("Sad-02: corrupt state file exits 1")] + [Fact] + public async Task Status_CorruptState_Fails() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteSchemaAsync(dir); + var statePath = MigrationStateStore.GetStatePath(schemaPath); + await File.WriteAllTextAsync(statePath, "{ this is not valid json !!!"); + + var (exitCode, stdout, stderr) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(1, exitCode); + // Must mention corrupt/invalid somewhere + var combined = stdout + stderr; + Assert.True( + combined.Contains("corrupt", StringComparison.OrdinalIgnoreCase) || + combined.Contains("invalid", StringComparison.OrdinalIgnoreCase), + $"Expected 'corrupt' or 'invalid' in output. Actual output: {combined}"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Sad-03 — schema file does not exist exits 1. + /// + [Scenario("Sad-03: non-existent schema path exits 1")] + [Fact] + public async Task Status_MissingSchema_Fails() + { + var (exitCode, _, _) = await InvokeAsync("status --schema does-not-exist-schema.json"); + Assert.Equal(1, exitCode); + } + + /// + /// SCENARIO: Sad-04 — missing --schema flag exits with non-zero (parse error). + /// + [Scenario("Sad-04: missing --schema flag causes parse error exit")] + [Fact] + public async Task Status_NoSchemaFlag_Fails() + { + var (exitCode, _, _) = await InvokeAsync("status"); + Assert.NotEqual(0, exitCode); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Group: edge + // ════════════════════════════════════════════════════════════════════════════ + + /// + /// SCENARIO: Edge-01 — schema hash mismatch warns user. + /// + [Scenario("Edge-01: schema hash mismatch produces schema-changed warning")] + [Fact] + public async Task Status_SchemaHashMismatch_Warns() + { + var dir = CreateTempDir(); + try + { + // Write a state with a hash that won't match the actual schema content + var state = BuildState(10, 2, 0); + state.SchemaHash = "sha256:deadbeef00000000000000000000000000000000000000000000000000000000"; + var schemaPath = await WriteStateAsync(dir, state); + + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(0, exitCode); // no manual + Assert.True( + stdout.Contains("schema has changed", StringComparison.OrdinalIgnoreCase) || + stdout.Contains("schema changed", StringComparison.OrdinalIgnoreCase), + $"Expected schema-change warning in output. Actual: {stdout}"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-02 — empty state (0 applied, 0 skipped, 0 manual) exits 0. + /// + [Scenario("Edge-02: empty state (all zero counts) exits 0")] + [Fact] + public async Task Status_EmptyState_ExitsZero() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(0, 0, 0)); + var (exitCode, _, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(0, exitCode); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-03 — manual rule with empty MatchedFiles shows placeholder. + /// + [Scenario("Edge-03: manual rule with empty MatchedFiles shows placeholder text")] + [Fact] + public async Task Status_ManualWithEmptyMatched_PrintsPlaceholder() + { + var dir = CreateTempDir(); + try + { + var state = new MigrationState + { + Schema = "schema.wrapgod-migration.json", + SchemaHash = "sha256:aabbccdd", + StartedAt = DateTimeOffset.UtcNow, + LastRunAt = DateTimeOffset.UtcNow, + Summary = new MigrationStateSummary { TotalRules = 1, Applied = 0, Skipped = 0, Manual = 1 }, + Applied = [], + Skipped = [], + Manual = [new ManualRewrite("M-001", "Manual action with no files", [])], + }; + + var schemaPath = await WriteStateAsync(dir, state); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --verbose"); + + Assert.Equal(2, exitCode); + Assert.Contains("no files matched", stdout, StringComparison.OrdinalIgnoreCase); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-04 — --json with no state file outputs sentinel JSON. + /// + [Scenario("Edge-04: --json with no state file outputs sentinel JSON { status: no-runs-recorded }")] + [Fact] + public async Task Status_Json_NoState_OutputsSentinel() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteSchemaAsync(dir); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json"); + + Assert.Equal(0, exitCode); + var doc = JsonDocument.Parse(stdout); + Assert.True( + doc.RootElement.TryGetProperty("status", out var statusEl) && + statusEl.GetString() == "no-runs-recorded", + $"Expected JSON {{ \"status\": \"no-runs-recorded\" }}. Actual: {stdout}"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-05 — state with synthetic <state> SkippedRewrite highlights recovery. + /// + [Scenario("Edge-05: state with synthetic SkippedRewrite highlights state recovery")] + [Fact] + public async Task Status_SyntheticStateSkippedRewrite_HighlightsRecovery() + { + var dir = CreateTempDir(); + try + { + var state = new MigrationState + { + Schema = "schema.wrapgod-migration.json", + SchemaHash = "sha256:aabbccdd", + StartedAt = DateTimeOffset.UtcNow, + LastRunAt = DateTimeOffset.UtcNow, + Summary = new MigrationStateSummary { TotalRules = 2, Applied = 1, Skipped = 1, Manual = 0 }, + Applied = [new AppliedRewrite("R-001", "src/File.cs", 5, "OldType", "NewType")], + Skipped = [new SkippedRewrite("", "", 0, "Previous state was corrupt; recovery run from scratch.")], + Manual = [], + }; + + var schemaPath = await WriteStateAsync(dir, state); + var (exitCode, stdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(0, exitCode); // no manual + Assert.True( + stdout.Contains("state recovery", StringComparison.OrdinalIgnoreCase) || + stdout.Contains("recovery", StringComparison.OrdinalIgnoreCase) || + stdout.Contains("", StringComparison.OrdinalIgnoreCase), + $"Expected recovery mention in output. Actual: {stdout}"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-06 — help text lists expected flags. + /// + [Scenario("Edge-06: help text lists --schema, --json, --verbose, --project-dir flags")] + [Fact] + public async Task Status_Help_ListsAllFlags() + { + var (exitCode, stdout, _) = await InvokeAsync("status --help"); + Assert.Equal(0, exitCode); + Assert.Contains("--schema", stdout); + Assert.Contains("--json", stdout); + Assert.Contains("--verbose", stdout); + Assert.Contains("--project-dir", stdout); + } + + /// + /// SCENARIO: Edge-07 — corrupt state archived to .bak is reported. + /// MigrationStateStore.Load archives corrupt state to a .bak file. + /// The status command should detect wasCorrupt==true and report it. + /// + [Scenario("Edge-07: corrupt state archived to .bak — exit 1 with backup/corrupt mention")] + [Fact] + public async Task Status_CorruptState_MentionsBackup() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteSchemaAsync(dir); + var statePath = MigrationStateStore.GetStatePath(schemaPath); + await File.WriteAllTextAsync(statePath, "{ CORRUPT JSON "); + + var (exitCode, stdout, stderr) = await InvokeAsync($"status --schema \"{schemaPath}\""); + + Assert.Equal(1, exitCode); + var combined = stdout + stderr; + Assert.True( + combined.Contains(".bak", StringComparison.OrdinalIgnoreCase) || + combined.Contains("backup", StringComparison.OrdinalIgnoreCase) || + combined.Contains("archived", StringComparison.OrdinalIgnoreCase) || + combined.Contains("corrupt", StringComparison.OrdinalIgnoreCase), + $"Expected .bak/backup/corrupt mention. Actual: {combined}"); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-08 — --project-dir resolves relative --schema. + /// + [Scenario("Edge-08: --project-dir resolves relative --schema path")] + [Fact] + public async Task Status_ProjectDir_ResolvesRelativeSchema() + { + var dir = CreateTempDir(); + try + { + var schemaPath = await WriteStateAsync(dir, BuildState(5, 1, 0)); + var schemaFileName = Path.GetFileName(schemaPath); + + var (exitCode, stdout, _) = await InvokeAsync( + $"status --schema \"{schemaFileName}\" --project-dir \"{dir}\""); + + Assert.Equal(0, exitCode); // no manual + Assert.Contains("5", stdout); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-09 — schema with zero rules: human output shows "n/a", JSON + /// has progressPct: null. Per plan §200.c table row "Status_ZeroRules_HandlesNa". + /// + [Scenario("Edge-09: schema with zero rules -> human output 'n/a', JSON progressPct null")] + [Fact] + public async Task Status_ZeroRules_HandlesNa() + { + var dir = CreateTempDir(); + try + { + // Default schema fixture in WriteSchemaAsync already has "rules": [] (0 rules) + // and the BuildState helper here uses (0,0,0) to match. + var schemaPath = await WriteStateAsync(dir, BuildState(0, 0, 0)); + + // Human-readable mode → "n/a" + var (humanExit, humanStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\""); + Assert.Equal(0, humanExit); + Assert.Contains("n/a", humanStdout, StringComparison.OrdinalIgnoreCase); + Assert.Contains("0 / 0", humanStdout); + + // JSON mode → progressPct is null + var (jsonExit, jsonStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json"); + Assert.Equal(0, jsonExit); + using var doc = JsonDocument.Parse(jsonStdout); + Assert.True(doc.RootElement.TryGetProperty("progressPct", out var pctEl), + "JSON must have 'progressPct' field"); + Assert.Equal(JsonValueKind.Null, pctEl.ValueKind); + + // Schema metadata is included + Assert.True(doc.RootElement.TryGetProperty("library", out var libEl), + "JSON must have 'library' field"); + Assert.Equal("TestLib", libEl.GetString()); + Assert.True(doc.RootElement.TryGetProperty("from", out var fromEl), + "JSON must have 'from' field"); + Assert.Equal("1.0.0", fromEl.GetString()); + Assert.True(doc.RootElement.TryGetProperty("to", out var toEl), + "JSON must have 'to' field"); + Assert.Equal("2.0.0", toEl.GetString()); + } + finally { SafeDelete(dir); } + } + + /// + /// SCENARIO: Edge-10 — progressPct + ratio line populated when totalRules > 0. + /// Belt-and-suspenders for plan §200.b Happy-01 + Happy-02. Verifies the + /// "N / M rules applied (P%)" human-mode line and the JSON progressPct numeric value. + /// + [Scenario("Edge-10: progressPct + ratio line — totalRules>0, applied 3.1.1", humanStdout); + + // JSON mode + var (jsonExit, jsonStdout, _) = await InvokeAsync($"status --schema \"{schemaPath}\" --json"); + Assert.Equal(0, jsonExit); + using var doc = JsonDocument.Parse(jsonStdout); + Assert.True(doc.RootElement.TryGetProperty("progressPct", out var pctEl)); + Assert.Equal(JsonValueKind.Number, pctEl.ValueKind); + Assert.Equal(0.6, pctEl.GetDouble(), precision: 5); + Assert.Equal("Serilog", doc.RootElement.GetProperty("library").GetString()); + Assert.Equal("2.12.0", doc.RootElement.GetProperty("from").GetString()); + Assert.Equal("3.1.1", doc.RootElement.GetProperty("to").GetString()); + Assert.Equal(5, doc.RootElement.GetProperty("totalRules").GetInt32()); + Assert.Equal(3, doc.RootElement.GetProperty("appliedRules").GetInt32()); + } + finally { SafeDelete(dir); } + } +} diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 785a44d..e615874 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -20,6 +20,7 @@ wrap-god [command] [options] - [`explain`](#explain) -- Show traceability info for a type or member - [`migrate init`](#migrate-init) -- Analyze a project and generate a migration plan - [`migrate generate`](#migrate-generate) -- Generate a draft migration schema from two library versions +- [`migrate status`](#migrate-status) -- Report migration progress from the state file - [`ci bootstrap`](#ci-bootstrap) -- Generate recommended CI workflow files - [`ci parity`](#ci-parity) -- Compare CI config against the recommended baseline @@ -577,6 +578,134 @@ Output: mudblazor.6.0.0-to-7.0.0.wrapgod-migration.json --- +### `migrate status` + +**Synopsis:** Report migration progress from the state file without running any migration. + +**Usage:** +``` +wrap-god migrate status --schema [options] +``` + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--schema`, `-s` | Path to the migration schema JSON file. The state file is the sibling `.state.json`. | **Required** | +| `--project-dir`, `-p` | Project directory used to resolve a relative `--schema` path | Current directory | +| `--json` | Emit output as JSON instead of human-readable text | `false` | +| `--verbose`, `-v` | Include per-rule details and per-file applied lists in human-readable mode | `false` | + +**Exit codes:** + +| Code | Meaning | +|------|---------| +| `0` | State file exists and has no manual-confidence entries (or state file is missing) | +| `1` | Schema file not found, or state file is corrupt and could not be parsed | +| `2` | State has manual-confidence entries present — human review required | + +**Behavior:** + +1. Resolves `.state.json` as the sibling of `--schema`. +2. If the state file does not exist, prints a friendly message and exits 0 — this is not an error. +3. If the state file is corrupt, the `MigrationStateStore` archives it to `.state.json.bak` and exits 1 with a message referencing the backup path. +4. Computes the current schema hash and warns if the schema has changed since the last `apply` run. +5. Highlights any `` synthetic `SkippedRewrite` entries, which indicate a corruption-recovery run. +6. Exits 2 when any `Manual`-confidence entries are present (signal that human work remains). + +**Examples:** + +```bash +# Human-readable status +wrap-god migrate status --schema mudblazor.6.0-to-7.0.wrapgod-migration.json + +# With project directory (for relative schema path) +wrap-god migrate status \ + --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \ + --project-dir ./src + +# JSON output (useful for tooling / CI) +wrap-god migrate status \ + --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \ + --json + +# Verbose: per-rule applied breakdown, matched files for manual rules +wrap-god migrate status \ + --schema mudblazor.6.0-to-7.0.wrapgod-migration.json \ + --verbose +``` + +**Output (default human-readable):** + +``` +WrapGod migrate status +---------------------------------------- +Migration: MudBlazor 6.0.0 -> 7.0.0 +Schema: mudblazor.6.0-to-7.0.wrapgod-migration.json +Started: 2026-04-01 12:00:00 UTC +Last run: 2026-04-02 09:14:33 UTC + +Progress: 38 / 47 rules applied (81%) + +Schema hash: sha256:ab12cd34... (matches current schema) + +Applied: 38 (across 22 file(s)) +Skipped: 6 +Manual: 3 + +Skipped rules: + Ambiguous: two overloads of Show() in scope: 4 + Conflict with existing declaration: 2 + +Manual rules (require human intervention): + MUD-003 Parameters restructured — requires manual mapping + MUD-007 RemoveMember (obj.Deprecated): review and remove call sites + MUD-012 Namespace moved — update using directives +``` + +**Output (`--json`):** + +```json +{ + "library": "MudBlazor", + "from": "6.0.0", + "to": "7.0.0", + "schema": "mudblazor.6.0-to-7.0.wrapgod-migration.json", + "schemaHash": "sha256:ab12cd34...", + "schemaChanged": false, + "startedAt": "2026-04-01T12:00:00+00:00", + "lastRunAt": "2026-04-02T09:14:33+00:00", + "totalRules": 47, + "appliedRules": 38, + "progressPct": 0.81, + "summary": { + "total": 47, + "applied": 38, + "skipped": 6, + "manual": 3 + }, + "applied": [ + { "ruleId": "MUD-001", "fileCount": 8 }, + { "ruleId": "MUD-002", "fileCount": 4 } + ], + "skipped": [ + { "reason": "Ambiguous: two overloads of Show() in scope", "count": 4 } + ], + "manual": [ + { + "ruleId": "MUD-003", + "note": "Parameters restructured — requires manual mapping", + "matchedFiles": ["src/Dialogs/ConfirmDialog.cs", "src/Dialogs/EditDialog.cs"] + } + ], + "stateRecoveryOccurred": false +} +``` + +> **See also:** [Migration state file format](../migration/state.md) + +--- + ### `ci bootstrap` **Synopsis:** Generate recommended CI workflow files for a WrapGod project. diff --git a/docs/migration/state.md b/docs/migration/state.md index f1d8063..0d02d8f 100644 --- a/docs/migration/state.md +++ b/docs/migration/state.md @@ -189,3 +189,4 @@ representative state file. - [Migration Engine](engine.md) — `MigrationEngine`, `IRuleRewriter`, `RewriteContext` - [Migration Schema](schema.md) — schema model and rule kinds +- [`migrate status` CLI command](../guide/cli.md#migrate-status) — read-only progress report from the state file