// ----------------------------------------------------------------------------- // CommandHandlers.Drift.cs // Sprint: SPRINT_3600_0004_0001_ui_evidence_chain // Tasks: UI-019, UI-020, UI-021 // Description: Command handlers for reachability drift CLI. // ----------------------------------------------------------------------------- using System.Text.Json; using Spectre.Console; namespace StellaOps.Cli.Commands; internal static partial class CommandHandlers { private static readonly JsonSerializerOptions DriftJsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Handler for `drift compare` command. /// internal static async Task HandleDriftCompareAsync( IServiceProvider services, string baseId, string? headId, string? image, string? repo, string output, string minSeverity, bool onlyIncreases, bool verbose, CancellationToken cancellationToken) { // TODO: Replace with actual service call when drift API is available var console = AnsiConsole.Console; if (verbose) { console.MarkupLine($"[dim]Comparing drift: base={baseId}, head={headId ?? "(latest)"}[/]"); } // Placeholder: In real implementation, call drift service var driftResult = new DriftResultDto { Id = Guid.NewGuid().ToString("N")[..8], ComparedAt = DateTimeOffset.UtcNow.ToString("O"), BaseGraphId = baseId, HeadGraphId = headId ?? "latest", Summary = new DriftSummaryDto { TotalSinks = 0, IncreasedReachability = 0, DecreasedReachability = 0, UnchangedReachability = 0, NewSinks = 0, RemovedSinks = 0, RiskTrend = "stable", NetRiskDelta = 0 }, DriftedSinks = Array.Empty() }; switch (output) { case "json": await WriteJsonOutputAsync(console, driftResult, cancellationToken); break; case "sarif": await WriteSarifOutputAsync(console, driftResult, cancellationToken); break; default: WriteTableOutput(console, driftResult, onlyIncreases, minSeverity); break; } } /// /// Handler for `drift show` command. /// internal static async Task HandleDriftShowAsync( IServiceProvider services, string id, string output, bool expandPaths, bool verbose, CancellationToken cancellationToken) { var console = AnsiConsole.Console; if (verbose) { console.MarkupLine($"[dim]Showing drift result: {id}[/]"); } // Placeholder: In real implementation, call drift service var driftResult = new DriftResultDto { Id = id, ComparedAt = DateTimeOffset.UtcNow.ToString("O"), BaseGraphId = "base", HeadGraphId = "head", Summary = new DriftSummaryDto { TotalSinks = 0, IncreasedReachability = 0, DecreasedReachability = 0, UnchangedReachability = 0, NewSinks = 0, RemovedSinks = 0, RiskTrend = "stable", NetRiskDelta = 0 }, DriftedSinks = Array.Empty() }; switch (output) { case "json": await WriteJsonOutputAsync(console, driftResult, cancellationToken); break; case "sarif": await WriteSarifOutputAsync(console, driftResult, cancellationToken); break; default: WriteTableOutput(console, driftResult, false, "info"); break; } } // Task: UI-020 - Table output using Spectre.Console private static void WriteTableOutput( IAnsiConsole console, DriftResultDto result, bool onlyIncreases, string minSeverity) { // Header panel var header = new Panel(new Markup($"[bold]Reachability Drift[/] [dim]({result.Id})[/]")) .Border(BoxBorder.Rounded) .Padding(1, 0); console.Write(header); // Summary table var summaryTable = new Table() .Border(TableBorder.Rounded) .AddColumn("Metric") .AddColumn("Value"); summaryTable.AddRow("Trend", FormatTrend(result.Summary.RiskTrend)); summaryTable.AddRow("Net Risk Delta", FormatDelta(result.Summary.NetRiskDelta)); summaryTable.AddRow("Increased", result.Summary.IncreasedReachability.ToString()); summaryTable.AddRow("Decreased", result.Summary.DecreasedReachability.ToString()); summaryTable.AddRow("New Sinks", result.Summary.NewSinks.ToString()); summaryTable.AddRow("Removed Sinks", result.Summary.RemovedSinks.ToString()); console.Write(summaryTable); // Sinks table if (result.DriftedSinks.Length == 0) { console.MarkupLine("[green]No drifted sinks found.[/]"); return; } var sinksTable = new Table() .Border(TableBorder.Rounded) .AddColumn("Severity") .AddColumn("Sink") .AddColumn("CVE") .AddColumn("Bucket Change") .AddColumn("Delta"); var severityOrder = new Dictionary { ["critical"] = 0, ["high"] = 1, ["medium"] = 2, ["low"] = 3, ["info"] = 4 }; var minSevOrder = severityOrder.GetValueOrDefault(minSeverity, 2); foreach (var sink in result.DriftedSinks) { var sevOrder = severityOrder.GetValueOrDefault(sink.Severity ?? "info", 4); if (sevOrder > minSevOrder) continue; if (onlyIncreases && !sink.IsRiskIncrease) continue; sinksTable.AddRow( FormatSeverity(sink.Severity), sink.SinkSymbol ?? "unknown", sink.CveId ?? "-", $"{sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}", FormatDelta(sink.RiskDelta)); } console.Write(sinksTable); } // Task: UI-021 - JSON output private static async Task WriteJsonOutputAsync( IAnsiConsole console, DriftResultDto result, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(result, DriftJsonOptions); console.WriteLine(json); await Task.CompletedTask; } // Task: UI-022, UI-023 - SARIF output (placeholder) private static async Task WriteSarifOutputAsync( IAnsiConsole console, DriftResultDto result, CancellationToken cancellationToken) { // TODO: Implement full SARIF 2.1.0 generation in DriftSarifGenerator var sarif = new { version = "2.1.0", schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", runs = new[] { new { tool = new { driver = new { name = "StellaOps Drift", version = "1.0.0", informationUri = "https://stellaops.io/docs/drift" } }, results = result.DriftedSinks.Select(sink => new { ruleId = sink.CveId ?? $"drift-{sink.SinkSymbol}", level = MapSeverityToSarif(sink.Severity), message = new { text = $"Reachability changed: {sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}" }, locations = Array.Empty() }).ToArray() } } }; var json = JsonSerializer.Serialize(sarif, DriftJsonOptions); console.WriteLine(json); await Task.CompletedTask; } private static string FormatTrend(string trend) => trend switch { "increasing" => "[red]↑ Increasing[/]", "decreasing" => "[green]↓ Decreasing[/]", _ => "[dim]→ Stable[/]" }; private static string FormatDelta(int delta) => delta switch { > 0 => $"[red]+{delta}[/]", < 0 => $"[green]{delta}[/]", _ => "[dim]0[/]" }; private static string FormatSeverity(string? severity) => severity switch { "critical" => "[white on red] CRITICAL [/]", "high" => "[black on darkorange] HIGH [/]", "medium" => "[black on yellow] MEDIUM [/]", "low" => "[black on olive] LOW [/]", _ => "[dim] INFO [/]" }; private static string MapSeverityToSarif(string? severity) => severity switch { "critical" or "high" => "error", "medium" => "warning", _ => "note" }; // DTOs for drift output private sealed record DriftResultDto { public string Id { get; init; } = string.Empty; public string ComparedAt { get; init; } = string.Empty; public string BaseGraphId { get; init; } = string.Empty; public string HeadGraphId { get; init; } = string.Empty; public DriftSummaryDto Summary { get; init; } = new(); public DriftedSinkDto[] DriftedSinks { get; init; } = Array.Empty(); } private sealed record DriftSummaryDto { public int TotalSinks { get; init; } public int IncreasedReachability { get; init; } public int DecreasedReachability { get; init; } public int UnchangedReachability { get; init; } public int NewSinks { get; init; } public int RemovedSinks { get; init; } public string RiskTrend { get; init; } = "stable"; public int NetRiskDelta { get; init; } } private sealed record DriftedSinkDto { public string? SinkSymbol { get; init; } public string? CveId { get; init; } public string? Severity { get; init; } public string? PreviousBucket { get; init; } public string CurrentBucket { get; init; } = string.Empty; public bool IsRiskIncrease { get; init; } public int RiskDelta { get; init; } } }