Skip to main content

schema_risk/
output.rs

1//! Terminal output renderer – pretty-prints analysis results to stdout.
2
3use crate::drift::DriftReport;
4use crate::graph::{SchemaEdge, SchemaGraph, SchemaNode};
5use crate::impact::ImpactReport;
6use crate::locks::{LockMode, MigrationTimeline};
7use crate::parser::ParsedStatement;
8use crate::recommendation::{FixSeverity, FixSuggestion};
9use crate::types::{DetectedOperation, MigrationReport, RiskLevel};
10use colored::Colorize;
11use petgraph::visit::EdgeRef;
12
13// ─────────────────────────────────────────────
14// Colours
15// ─────────────────────────────────────────────
16
17fn risk_color(level: RiskLevel) -> colored::ColoredString {
18    match level {
19        RiskLevel::Low => level.to_string().green().bold(),
20        RiskLevel::Medium => level.to_string().yellow().bold(),
21        RiskLevel::High => level.to_string().truecolor(255, 140, 0).bold(),
22        RiskLevel::Critical => level.to_string().red().bold(),
23    }
24}
25
26// ─────────────────────────────────────────────
27// Main render function
28// ─────────────────────────────────────────────
29
30pub fn render(report: &MigrationReport, verbose: bool) {
31    let separator = "─".repeat(60);
32
33    println!("\n{}", separator.dimmed());
34    println!("{}  {}", " SchemaRisk Analysis".bold(), report.file.cyan());
35    println!("{}", separator.dimmed());
36
37    // Overall risk badge
38    println!(
39        "\n  Migration Risk:  {}   (score: {})",
40        risk_color(report.overall_risk),
41        report.score.to_string().bold()
42    );
43
44    // Affected tables
45    if !report.affected_tables.is_empty() {
46        println!(
47            "\n  {} {}",
48            "Tables affected:".bold(),
49            report.affected_tables.join(", ").cyan()
50        );
51    }
52
53    // Lock estimate
54    if let Some(secs) = report.estimated_lock_seconds {
55        let lock_str = if secs >= 60 {
56            format!("~{} min {} sec", secs / 60, secs % 60)
57        } else {
58            format!("~{} sec", secs)
59        };
60        let colored = if secs > 30 {
61            lock_str.red()
62        } else if secs > 5 {
63            lock_str.yellow()
64        } else {
65            lock_str.green()
66        };
67        println!("  {} {}", "Estimated lock duration:".bold(), colored);
68    }
69
70    // Index rebuild
71    if report.index_rebuild_required {
72        println!(
73            "  {} {}",
74            "Index rebuild required:".bold(),
75            "YES".red().bold()
76        );
77    }
78
79    // Maintenance window
80    if report.requires_maintenance_window {
81        println!(
82            "  {} {}",
83            "Requires maintenance window:".bold(),
84            "YES".red().bold()
85        );
86    }
87
88    // Foreign key impacts
89    if !report.fk_impacts.is_empty() {
90        println!("\n  {}:", "Foreign Key Impact".bold().underline());
91        for fk in &report.fk_impacts {
92            let cascade_note = if fk.cascade {
93                " (ON DELETE CASCADE!)".red().to_string()
94            } else {
95                String::new()
96            };
97            println!(
98                "    {} {} → {}{}",
99                "•".dimmed(),
100                fk.constraint_name.yellow(),
101                fk.to_table.cyan(),
102                cascade_note
103            );
104        }
105    }
106
107    // Detected operations
108    if verbose && !report.operations.is_empty() {
109        println!("\n  {}:", "Detected Operations".bold().underline());
110        for op in &report.operations {
111            println!(
112                "    {} [{}] {}",
113                "•".dimmed(),
114                risk_color(op.risk_level),
115                op.description
116            );
117            if op.acquires_lock {
118                println!("       {} acquires table lock", "⚠".yellow());
119            }
120            if op.index_rebuild {
121                println!("       {} triggers index rebuild", "⟳".yellow());
122            }
123        }
124    }
125
126    // Warnings
127    if !report.warnings.is_empty() {
128        println!("\n  {}:", "Warnings".bold().underline());
129        for w in &report.warnings {
130            println!("    {} {}", "!".yellow().bold(), w);
131        }
132    }
133
134    // Recommendations
135    if !report.recommendations.is_empty() {
136        println!("\n  {}:", "Recommendations".bold().underline());
137        for r in &report.recommendations {
138            println!("    {} {}", "→".green(), r);
139        }
140    }
141
142    println!("\n{}", separator.dimmed());
143
144    // Summary line for CI logs
145    let stamp = if report.requires_maintenance_window {
146        "  ⛔ This migration should NOT be deployed without review".red()
147    } else if report.overall_risk >= RiskLevel::Medium {
148        "  ⚠  Review recommended before deploying".yellow()
149    } else {
150        "  ✓  Migration looks safe".green()
151    };
152    println!("{}\n", stamp);
153}
154
155pub fn render_statement_breakdown(stmts: &[ParsedStatement], operations: &[DetectedOperation]) {
156    let separator = "─".repeat(60);
157
158    println!("\n{}", separator.dimmed());
159    println!("{}", " Statement-by-Statement Breakdown".bold());
160    println!("{}", separator.dimmed());
161
162    for (index, stmt) in stmts.iter().enumerate() {
163        let preview = statement_preview(stmt);
164        println!("\n  [{:02}] {}", index + 1, preview);
165
166        if let Some(op) = operations.get(index) {
167            if !op.description.is_empty() {
168                println!("       {} {}", "→".green(), op.description.cyan());
169            }
170            if let Some(warning) = &op.warning {
171                println!("       {} {}", "⚠".yellow(), warning.yellow());
172            }
173        }
174    }
175
176    println!("\n{}", separator.dimmed());
177}
178
179pub fn render_graph_text(graph: &SchemaGraph) {
180    let separator = "─".repeat(60);
181
182    println!("\n{}", separator.dimmed());
183    println!("{}", " Schema Dependency Graph".bold());
184    println!("{}", separator.dimmed());
185
186    let mut tables: Vec<String> = graph.table_index.keys().cloned().collect();
187    tables.sort();
188
189    if tables.is_empty() {
190        println!("\n  {} No tables found in migration input.", "ℹ".cyan());
191        println!("\n{}", separator.dimmed());
192        return;
193    }
194
195    println!("\n  {}", "Tables".bold().underline());
196    for table in &tables {
197        println!("    {} {}", "•".dimmed(), table.cyan());
198    }
199
200    let mut fk_lines: Vec<String> = Vec::new();
201    for edge in graph.graph.edge_references() {
202        if let SchemaEdge::ForeignKey {
203            constraint_name,
204            from_columns,
205            to_columns,
206            cascade_delete,
207            ..
208        } = edge.weight()
209        {
210            let Some(SchemaNode::Table {
211                name: from_table, ..
212            }) = graph.graph.node_weight(edge.source())
213            else {
214                continue;
215            };
216            let Some(SchemaNode::Table { name: to_table, .. }) =
217                graph.graph.node_weight(edge.target())
218            else {
219                continue;
220            };
221
222            let from_col = from_columns.first().map(String::as_str).unwrap_or("*");
223            let to_col = to_columns.first().map(String::as_str).unwrap_or("*");
224            let fk_name = constraint_name.as_deref().unwrap_or("unnamed_fk");
225            let cascade_note = if *cascade_delete {
226                " [ON DELETE CASCADE]"
227            } else {
228                ""
229            };
230
231            fk_lines.push(format!(
232                "{}.{from_col} → {}.{to_col} ({fk_name}){cascade_note}",
233                from_table, to_table
234            ));
235        }
236    }
237    fk_lines.sort();
238
239    println!("\n  {}", "Foreign keys".bold().underline());
240    if fk_lines.is_empty() {
241        println!("    {} none", "•".dimmed());
242    } else {
243        for relation in fk_lines {
244            println!("    {} {}", "•".dimmed(), relation);
245        }
246    }
247
248    println!(
249        "\n  {} {}",
250        "Total tables:".bold(),
251        tables.len().to_string().cyan()
252    );
253    println!("{}\n", separator.dimmed());
254}
255
256fn statement_preview(stmt: &ParsedStatement) -> String {
257    match stmt {
258        ParsedStatement::CreateTable { table, .. } => format!("CREATE TABLE {}", table),
259        ParsedStatement::DropTable { tables, .. } => format!("DROP TABLE {}", tables.join(", ")),
260        ParsedStatement::AlterTableAddColumn { table, column } => {
261            format!(
262                "ALTER TABLE {} ADD COLUMN {} {}",
263                table, column.name, column.data_type
264            )
265        }
266        ParsedStatement::AlterTableDropColumn { table, column, .. } => {
267            format!("ALTER TABLE {} DROP COLUMN {}", table, column)
268        }
269        ParsedStatement::AlterTableAlterColumnType {
270            table,
271            column,
272            new_type,
273        } => format!(
274            "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
275            table, column, new_type
276        ),
277        ParsedStatement::AlterTableSetNotNull { table, column } => {
278            format!("ALTER TABLE {} ALTER COLUMN {} SET NOT NULL", table, column)
279        }
280        ParsedStatement::AlterTableAddForeignKey { table, fk } => {
281            let from_cols = if fk.columns.is_empty() {
282                "*".to_string()
283            } else {
284                fk.columns.join(", ")
285            };
286            let to_cols = if fk.ref_columns.is_empty() {
287                "*".to_string()
288            } else {
289                fk.ref_columns.join(", ")
290            };
291            format!(
292                "ALTER TABLE {} ADD FOREIGN KEY ({}) REFERENCES {} ({})",
293                table, from_cols, fk.ref_table, to_cols
294            )
295        }
296        ParsedStatement::AlterTableDropConstraint {
297            table, constraint, ..
298        } => format!("ALTER TABLE {} DROP CONSTRAINT {}", table, constraint),
299        ParsedStatement::AlterTableRenameColumn { table, old, new } => {
300            format!("ALTER TABLE {} RENAME COLUMN {} TO {}", table, old, new)
301        }
302        ParsedStatement::AlterTableRenameTable { old, new } => {
303            format!("ALTER TABLE {} RENAME TO {}", old, new)
304        }
305        ParsedStatement::CreateIndex {
306            index_name,
307            table,
308            columns,
309            unique,
310            concurrently,
311        } => {
312            let name = index_name.as_deref().unwrap_or("<unnamed>");
313            let unique_str = if *unique { "UNIQUE " } else { "" };
314            let concurrently_str = if *concurrently { " CONCURRENTLY" } else { "" };
315            format!(
316                "CREATE {}INDEX{} {} ON {} ({})",
317                unique_str,
318                concurrently_str,
319                name,
320                table,
321                columns.join(", ")
322            )
323        }
324        ParsedStatement::DropIndex { names, .. } => format!("DROP INDEX {}", names.join(", ")),
325        ParsedStatement::AlterTableAddPrimaryKey { table, columns } => {
326            format!(
327                "ALTER TABLE {} ADD PRIMARY KEY ({})",
328                table,
329                columns.join(", ")
330            )
331        }
332        ParsedStatement::AlterTableDropPrimaryKey { table } => {
333            format!("ALTER TABLE {} DROP PRIMARY KEY", table)
334        }
335        ParsedStatement::AlterTableAlterColumnDefault {
336            table,
337            column,
338            drop_default,
339        } => {
340            if *drop_default {
341                format!("ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT", table, column)
342            } else {
343                format!(
344                    "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT ...",
345                    table, column
346                )
347            }
348        }
349        ParsedStatement::Reindex {
350            target_type,
351            target_name,
352            concurrently,
353        } => {
354            let conc = if *concurrently { " CONCURRENTLY" } else { "" };
355            format!("REINDEX{} {} {}", conc, target_type, target_name)
356        }
357        ParsedStatement::Cluster { table, index } => match (table, index) {
358            (Some(t), Some(i)) => format!("CLUSTER {} USING {}", t, i),
359            (Some(t), None) => format!("CLUSTER {}", t),
360            _ => "CLUSTER".to_string(),
361        },
362        ParsedStatement::Truncate { tables, cascade } => {
363            let cascade_str = if *cascade { " CASCADE" } else { "" };
364            format!("TRUNCATE TABLE {}{}", tables.join(", "), cascade_str)
365        }
366        ParsedStatement::Other { raw } => {
367            let trimmed = raw.trim();
368            if trimmed.len() > 100 {
369                format!("{}…", &trimmed[..99])
370            } else {
371                trimmed.to_string()
372            }
373        }
374    }
375}
376
377// ─────────────────────────────────────────────
378// Terminal capability detection (B-05 fix)
379// ─────────────────────────────────────────────
380
381/// Returns the appropriate `comfy-table` preset based on terminal Unicode support.
382///
383/// On xterm-compatible terminals and Unix systems, uses the richer UTF-8 preset.
384/// On Windows terminals without full Unicode support, falls back to ASCII.
385fn table_preset() -> &'static str {
386    let unicode_ok = std::env::var("TERM")
387        .map(|t| t.contains("xterm") || t.contains("rxvt") || t.contains("screen"))
388        .unwrap_or(false)
389        || cfg!(target_os = "linux")
390        || cfg!(target_os = "macos");
391
392    if unicode_ok {
393        comfy_table::presets::UTF8_FULL_CONDENSED
394    } else {
395        comfy_table::presets::ASCII_FULL
396    }
397}
398
399// ─────────────────────────────────────────────
400// Multi-file summary table
401// ─────────────────────────────────────────────
402
403/// Render an aligned summary table using `comfy-table` for multi-file analysis.
404pub fn render_summary_table(reports: &[MigrationReport]) {
405    use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
406
407    println!();
408    let mut table = Table::new();
409    table
410        .load_preset(table_preset())
411        .set_content_arrangement(ContentArrangement::Dynamic)
412        .set_header(vec![
413            Cell::new("File").add_attribute(Attribute::Bold),
414            Cell::new("Risk").add_attribute(Attribute::Bold),
415            Cell::new("Score")
416                .add_attribute(Attribute::Bold)
417                .set_alignment(CellAlignment::Right),
418            Cell::new("Lock Duration")
419                .add_attribute(Attribute::Bold)
420                .set_alignment(CellAlignment::Right),
421            Cell::new("Maint. Window").add_attribute(Attribute::Bold),
422            Cell::new("Tables").add_attribute(Attribute::Bold),
423        ]);
424
425    for r in reports {
426        let (risk_text, risk_color) = match r.overall_risk {
427            RiskLevel::Critical => ("CRITICAL", Color::Red),
428            RiskLevel::High => ("HIGH", Color::Yellow),
429            RiskLevel::Medium => ("MEDIUM", Color::Cyan),
430            RiskLevel::Low => ("LOW", Color::Green),
431        };
432        let duration = r
433            .estimated_lock_seconds
434            .map(|s| {
435                if s >= 60 {
436                    format!("~{}m {}s", s / 60, s % 60)
437                } else {
438                    format!("~{}s", s)
439                }
440            })
441            .unwrap_or_else(|| "—".to_string());
442        let window = if r.requires_maintenance_window {
443            Cell::new("YES")
444                .fg(Color::Red)
445                .add_attribute(Attribute::Bold)
446        } else {
447            Cell::new("no").fg(Color::Green)
448        };
449        let tables_str = if r.affected_tables.is_empty() {
450            "—".to_string()
451        } else {
452            r.affected_tables
453                .iter()
454                .take(3)
455                .cloned()
456                .collect::<Vec<_>>()
457                .join(", ")
458                + if r.affected_tables.len() > 3 {
459                    " …"
460                } else {
461                    ""
462                }
463        };
464        table.add_row(vec![
465            Cell::new(shorten(&r.file, 40)),
466            Cell::new(risk_text)
467                .fg(risk_color)
468                .add_attribute(Attribute::Bold),
469            Cell::new(r.score.to_string()).set_alignment(CellAlignment::Right),
470            Cell::new(duration).set_alignment(CellAlignment::Right),
471            window,
472            Cell::new(tables_str),
473        ]);
474    }
475
476    println!("{table}");
477
478    let max_risk = reports
479        .iter()
480        .map(|r| r.overall_risk)
481        .max()
482        .unwrap_or(RiskLevel::Low);
483    println!(
484        "\n  Highest risk across all files: {}\n",
485        risk_color(max_risk)
486    );
487}
488
489fn shorten(s: &str, max: usize) -> String {
490    if s.len() <= max {
491        s.to_string()
492    } else {
493        format!("…{}", &s[s.len().saturating_sub(max - 1)..])
494    }
495}
496
497// ─────────────────────────────────────────────
498// Lock table & timeline renderer
499// ─────────────────────────────────────────────
500
501pub fn render_timeline(timeline: &MigrationTimeline) {
502    let sep = "─".repeat(70);
503    println!("\n{}", sep.dimmed());
504    println!("  {}", "Lock Simulation & Migration Timeline".bold());
505    println!("{}", sep.dimmed());
506
507    println!("\n  Lock Risk:         {}", risk_color(timeline.lock_risk));
508    println!(
509        "  Total duration:    ~{} sec",
510        timeline.total_secs.to_string().cyan()
511    );
512    println!(
513        "  Max lock hold:     {} sec",
514        if timeline.max_lock_hold_secs > 30 {
515            timeline.max_lock_hold_secs.to_string().red().bold()
516        } else if timeline.max_lock_hold_secs > 5 {
517            timeline.max_lock_hold_secs.to_string().yellow()
518        } else {
519            timeline.max_lock_hold_secs.to_string().green()
520        }
521    );
522
523    // Per-operation lock table
524    if !timeline.lock_events.is_empty() {
525        println!("\n  {}:", "Operations and their locks".bold().underline());
526        println!(
527            "  {:<45} {:<26} {:<8} {}",
528            "Statement".dimmed(),
529            "Lock Mode".dimmed(),
530            "Hold(s)".dimmed(),
531            "Impact".dimmed()
532        );
533        println!("  {}", "·".repeat(110).dimmed());
534
535        for ev in &timeline.lock_events {
536            let stmt = shorten(&ev.statement, 44);
537            let lock_str = lock_mode_color(ev.lock_mode);
538            let hold_str = if ev.estimated_hold_secs > 30 {
539                ev.estimated_hold_secs.to_string().red().bold()
540            } else if ev.estimated_hold_secs > 5 {
541                ev.estimated_hold_secs.to_string().yellow()
542            } else {
543                ev.estimated_hold_secs.to_string().green()
544            };
545
546            println!(
547                "  {:<45} {:<35} {:<8} {}",
548                stmt, lock_str, hold_str, ev.impact
549            );
550
551            if let Some(alt) = &ev.safe_alternative {
552                println!(
553                    "    {} {}",
554                    "Safe alternative:".green().bold(),
555                    alt.lines().next().unwrap_or(alt)
556                );
557                for extra_line in alt.lines().skip(1) {
558                    println!("      {}", extra_line.dimmed());
559                }
560            }
561        }
562    }
563
564    // Timeline steps
565    println!("\n  {}:", "Execution timeline".bold().underline());
566    for step in &timeline.steps {
567        let lock_badge = match step.lock {
568            Some(LockMode::AccessExclusive) => " [LOCKED: reads+writes]".red().to_string(),
569            Some(LockMode::Share) => " [LOCKED: writes only]".yellow().to_string(),
570            Some(LockMode::ShareUpdateExclusive) => {
571                " [LOCK: allows reads+writes]".cyan().to_string()
572            }
573            Some(m) => format!(" [{}]", m.name()).dimmed().to_string(),
574            None => String::new(),
575        };
576        println!(
577            "  {:>6}s  {}{}",
578            step.offset_secs,
579            step.event.dimmed(),
580            lock_badge
581        );
582    }
583
584    println!("\n{}", sep.dimmed());
585}
586
587fn lock_mode_color(mode: LockMode) -> colored::ColoredString {
588    match mode {
589        LockMode::AccessExclusive => mode.name().red().bold(),
590        LockMode::Exclusive => mode.name().red(),
591        LockMode::ShareRowExclusive | LockMode::Share => mode.name().yellow(),
592        LockMode::ShareUpdateExclusive => mode.name().cyan(),
593        _ => mode.name().green(),
594    }
595}
596
597// ─────────────────────────────────────────────
598// Query impact renderer
599// ─────────────────────────────────────────────
600
601pub fn render_impact(report: &ImpactReport) {
602    let sep = "─".repeat(60);
603    println!("\n{}", sep.dimmed());
604    println!("{}", " SchemaRisk Impact Report".bold());
605    println!("{}", sep.dimmed());
606
607    println!(
608        "\n  Files scanned:      {}",
609        report.files_scanned.to_string().cyan()
610    );
611    println!(
612        "  Impacted files:     {}",
613        if report.impacted_files.is_empty() {
614            "0 (none found)".green().to_string()
615        } else {
616            report
617                .impacted_files
618                .len()
619                .to_string()
620                .yellow()
621                .bold()
622                .to_string()
623        }
624    );
625
626    if report.impacted_files.is_empty() {
627        println!(
628            "\n  {} No source files reference the affected schema objects.",
629            "✓".green()
630        );
631    } else {
632        println!("\n  {}:", "Impacted files".bold().underline());
633        for f in &report.impacted_files {
634            println!("\n    {}", f.path.yellow().bold());
635            if !f.tables_referenced.is_empty() {
636                println!("      Tables:  {}", f.tables_referenced.join(", ").cyan());
637            }
638            if !f.columns_referenced.is_empty() {
639                println!("      Columns: {}", f.columns_referenced.join(", ").cyan());
640            }
641            // Show up to 5 hits
642            for hit in f.hits.iter().take(5) {
643                println!(
644                    "      {:>5}: {}",
645                    format!("L{}", hit.line).dimmed(),
646                    hit.snippet.dimmed()
647                );
648            }
649            if f.hits.len() > 5 {
650                println!(
651                    "      {} more matches…",
652                    (f.hits.len() - 5).to_string().dimmed()
653                );
654            }
655        }
656    }
657
658    println!("{}", sep.dimmed());
659}
660
661// ─────────────────────────────────────────────
662// Drift report renderer
663// ─────────────────────────────────────────────
664
665pub fn render_drift(report: &DriftReport) {
666    let sep = "─".repeat(60);
667    println!("\n{}", sep.dimmed());
668    println!("{}", " SchemaRisk Drift Report".bold());
669    println!("{}", sep.dimmed());
670
671    if report.in_sync {
672        println!(
673            "\n  {} Schema is in sync — no drift detected.\n",
674            "✓".green().bold()
675        );
676        println!("{}", sep.dimmed());
677        return;
678    }
679
680    println!("\n  Overall drift:    {}", risk_color(report.overall_drift));
681    println!(
682        "  Total findings:   {}\n",
683        report.total_findings.to_string().red().bold()
684    );
685
686    // Print findings grouped by severity
687    for (label, severity, bullet_str) in [
688        ("CRITICAL", RiskLevel::Critical, "✗"),
689        ("HIGH", RiskLevel::High, "!"),
690        ("MEDIUM", RiskLevel::Medium, "·"),
691        ("LOW", RiskLevel::Low, "·"),
692    ] {
693        let items: Vec<_> = report
694            .findings
695            .iter()
696            .filter(|f| f.severity() == severity)
697            .collect();
698        if items.is_empty() {
699            continue;
700        }
701        let label_colored = match severity {
702            RiskLevel::Critical => label.red().bold().to_string(),
703            RiskLevel::High => label.truecolor(255, 140, 0).bold().to_string(),
704            RiskLevel::Medium => label.yellow().to_string(),
705            _ => label.dimmed().to_string(),
706        };
707        println!("  {}:", label_colored);
708        for finding in items {
709            let bullet = match severity {
710                RiskLevel::Critical => bullet_str.red().to_string(),
711                RiskLevel::High => bullet_str.yellow().to_string(),
712                _ => bullet_str.dimmed().to_string(),
713            };
714            println!("    {} {}", bullet, finding.description());
715        }
716        println!();
717    }
718
719    println!("{}", sep.dimmed());
720}
721
722// ─────────────────────────────────────────────
723// Fix suggestion renderer
724// ─────────────────────────────────────────────
725
726/// Pretty-print a list of `FixSuggestion`s from the recommendation engine.
727pub fn render_fix_suggestions(fixes: &[FixSuggestion]) {
728    use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
729
730    let sep = "─".repeat(60);
731    println!("\n{}", sep.dimmed());
732    println!("{}", " SchemaRisk Fix Suggestions".bold());
733    println!("{}", sep.dimmed());
734
735    // ── Compact summary table ─────────────────────────────────────────────
736    let mut table = Table::new();
737    table
738        .load_preset(table_preset())
739        .set_content_arrangement(ContentArrangement::Dynamic)
740        .set_header(vec![
741            Cell::new("ID").add_attribute(Attribute::Bold),
742            Cell::new("Severity").add_attribute(Attribute::Bold),
743            Cell::new("Title").add_attribute(Attribute::Bold),
744            Cell::new("Auto-Fix").add_attribute(Attribute::Bold),
745        ]);
746
747    for fix in fixes {
748        let (sev_text, sev_color) = match fix.severity {
749            FixSeverity::Blocking => ("BLOCKING", Color::Red),
750            FixSeverity::Warning => ("WARNING", Color::Yellow),
751            FixSeverity::Info => ("INFO", Color::Cyan),
752        };
753        let auto_fix = if fix.auto_fixable {
754            Cell::new("yes").fg(Color::Green)
755        } else {
756            Cell::new("manual").fg(Color::Yellow)
757        };
758        table.add_row(vec![
759            Cell::new(&fix.rule_id),
760            Cell::new(sev_text)
761                .fg(sev_color)
762                .add_attribute(Attribute::Bold),
763            Cell::new(&fix.title),
764            auto_fix,
765        ]);
766    }
767    println!("{table}");
768
769    // ── Full detail for each fix ──────────────────────────────────────────
770    for fix in fixes {
771        let severity_badge = match fix.severity {
772            FixSeverity::Blocking => format!("[{}]", "BLOCKING".red().bold()),
773            FixSeverity::Warning => format!("[{}]", "WARNING".yellow().bold()),
774            FixSeverity::Info => format!("[{}]", "INFO".cyan()),
775        };
776
777        println!(
778            "\n  {} {} {}",
779            fix.rule_id.bold(),
780            severity_badge,
781            fix.title.bold()
782        );
783        println!();
784        // Wrap long explanation at 72 chars
785        for chunk in wrap_text(&fix.explanation, 72) {
786            println!("    {chunk}");
787        }
788        println!();
789
790        if let Some(sql) = &fix.fixed_sql {
791            println!("  {}", "Fixed SQL:".green().bold());
792            for line in sql.lines() {
793                println!("    {}", line.green());
794            }
795            println!();
796        }
797
798        if let Some(steps) = &fix.migration_steps {
799            println!("  {}", "Migration steps:".cyan().bold());
800            for step in steps {
801                if step.is_empty() {
802                    println!();
803                } else {
804                    println!("    {}", step.dimmed());
805                }
806            }
807            println!();
808        }
809
810        if let Some(url) = &fix.docs_url {
811            println!("  {} {}", "Docs:".dimmed(), url.dimmed());
812            println!();
813        }
814
815        println!("{}", sep.dimmed());
816    }
817}
818
819/// Word-wrap a string at `width` columns, returning a vector of lines.
820fn wrap_text(text: &str, width: usize) -> Vec<String> {
821    let mut lines = Vec::new();
822    for paragraph in text.split("\n") {
823        let mut line = String::new();
824        for word in paragraph.split_whitespace() {
825            if line.is_empty() {
826                line.push_str(word);
827            } else if line.len() + 1 + word.len() <= width {
828                line.push(' ');
829                line.push_str(word);
830            } else {
831                lines.push(line.clone());
832                line = word.to_string();
833            }
834        }
835        if !line.is_empty() {
836            lines.push(line);
837        }
838    }
839    lines
840}