safe-migrate 0.4.0

Lint PostgreSQL migrations against live database statistics to prevent blocking locks
Documentation
// FILE: src/report/reporter.rs
use crate::analysis::state::Confidence;
use crate::report::violations::{Violation, ViolationTier};
use comfy_table::Table;
use owo_colors::{OwoColorize, Style};

/// Four-way verdict classification based on violation tiers.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
    Halt,         // any Tier 1
    Cautious,     // Tier 2 present, no Tier 1
    SafeWithRisk, // Tier 3 irreversible present, no Tier 1 or 2
    Safe,         // all Tier 3 non-irreversible or no findings
}

impl Verdict {
    pub fn label(&self) -> &'static str {
        match self {
            Verdict::Halt => "HALT",
            Verdict::Cautious => "CAUTIOUS",
            Verdict::SafeWithRisk => "SAFE WITH RISK",
            Verdict::Safe => "SAFE",
        }
    }

    pub fn recommendation(&self) -> &'static str {
        match self {
            Verdict::Halt => "do not deploy",
            Verdict::Cautious => "review warnings before deploy",
            Verdict::SafeWithRisk => "irreversible operations present — ensure backups exist",
            Verdict::Safe => "safe to deploy",
        }
    }
}

/// Compute the overall verdict from a set of violations.
pub fn compute_verdict(violations: &[Violation]) -> Verdict {
    let has_tier1 = violations.iter().any(|v| v.tier == ViolationTier::Tier1);
    let has_tier2 = violations.iter().any(|v| v.tier == ViolationTier::Tier2);
    let has_irreversible_tier3 = violations
        .iter()
        .any(|v| v.tier == ViolationTier::Tier3 && v.rule_id == "irreversible-migration");

    match (has_tier1, has_tier2, has_irreversible_tier3) {
        (true, _, _) => Verdict::Halt,
        (false, true, _) => Verdict::Cautious,
        (false, false, true) => Verdict::SafeWithRisk,
        (false, false, false) => Verdict::Safe,
    }
}

fn no_color() -> bool {
    std::env::var("NO_COLOR").is_ok()
}
pub(crate) fn tier_label_colored(tier: &ViolationTier) -> String {
    let label = match tier {
        ViolationTier::Tier1 => "HALT",
        ViolationTier::Tier2 => "WARN",
        ViolationTier::Tier3 => "SAFE",
    };
    if no_color() {
        label.to_string()
    } else {
        match tier {
            ViolationTier::Tier1 => label.style(Style::new().red().bold()).to_string(),
            ViolationTier::Tier2 => label.style(Style::new().yellow().bold()).to_string(),
            ViolationTier::Tier3 => label.style(Style::new().green().bold()).to_string(),
        }
    }
}

fn terminal_width() -> usize {
    terminal_size::terminal_size()
        .map(|(w, _)| w.0 as usize)
        .unwrap_or(80)
        .max(60)
}

pub struct Reporter;

impl Reporter {
    pub fn print_json_report(violations: &[Violation], confidence: &Confidence) {
        let verdict = compute_verdict(violations);
        let output = serde_json::json!({
            "confidence": match confidence {
                Confidence::Exact => "Exact",
                Confidence::Tainted => "Tainted",
            },
            "verdict": verdict.label(),
            "violations": violations,
        });
        println!("{}", serde_json::to_string_pretty(&output).unwrap());
    }

    pub fn print_report(violations: &[Violation], confidence: &Confidence) -> bool {
        let mut tier1 = 0usize;
        let mut tier2 = 0usize;
        let mut tier3 = 0usize;

        for v in violations {
            match v.tier {
                ViolationTier::Tier1 => tier1 += 1,
                ViolationTier::Tier2 => tier2 += 1,
                ViolationTier::Tier3 => tier3 += 1,
            }
        }

        let verdict = compute_verdict(violations);
        let conf_str = match confidence {
            Confidence::Exact => "Exact",
            Confidence::Tainted => "Tainted",
        };

        let width = terminal_width();

        // Header box using comfy-table
        let mut header_table = Table::new();
        header_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
        header_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
        header_table.set_width(width as u16);
        header_table.set_header(vec!["safe-migrate lint"]);
        header_table.add_row(vec![format!(
            "Verdict: {}   Confidence: {}",
            verdict.label(),
            conf_str
        )]);
        header_table.add_row(vec![format!(
            "HALT: {}   WARN: {}   SAFE: {}",
            tier1, tier2, tier3
        )]);
        println!("{}", header_table);

        if violations.is_empty() {
            println!("\n  No violations detected.\n");
            return false;
        }

        println!();

        // Separator width: 80-85% of terminal width
        let sep_width = (width as f32 * 0.82) as usize;

        // Group violations by sql key (same sql text + same object_name = same statement)
        // Each group is (primary_idx, Vec<secondary_idxs>)
        let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
        let mut used = vec![false; violations.len()];

        for i in 0..violations.len() {
            if used[i] {
                continue;
            }
            used[i] = true;
            let mut secondaries = Vec::new();
            // Find other violations with identical sql (if sql is Some)
            if let Some(sql_i) = &violations[i].sql {
                for j in (i + 1)..violations.len() {
                    if !used[j]
                        && let Some(sql_j) = &violations[j].sql
                        && sql_i == sql_j
                        && violations[j].object_name == violations[i].object_name
                    {
                        used[j] = true;
                        secondaries.push(j);
                    }
                }
            }
            groups.push((i, secondaries));
        }

        for (gi, (primary_idx, secondary_idxs)) in groups.iter().enumerate() {
            let v = &violations[*primary_idx];
            let tier_str = tier_label_colored(&v.tier);

            println!(" [{}] {}", tier_str, v.rule_id);

            let display_name = match &v.object_kind {
                crate::report::violations::ObjectKind::Database
                | crate::report::violations::ObjectKind::Role
                | crate::report::violations::ObjectKind::Publication
                | crate::report::violations::ObjectKind::Subscription => {
                    let step1 = if let Some(idx) = v.object_name.find('.') {
                        &v.object_name[idx + 1..]
                    } else {
                        &v.object_name
                    };
                    step1
                        .strip_suffix(" (inferred)")
                        .unwrap_or(step1)
                        .to_string()
                }
                _ => v.object_name.clone(),
            };

            if v.object_kind == crate::report::violations::ObjectKind::Unknown {
                println!("   object : {}", display_name);
            } else {
                println!("   object : {} {}", v.object_kind, display_name);
            }

            println!("   reason : {}", v.reason);

            // recipe: clean up multi-line strings
            let clean_recipe = v
                .recipe
                .lines()
                .map(|l| l.trim())
                .filter(|l| !l.is_empty())
                .collect::<Vec<_>>()
                .join(" ");
            println!("   recipe : {}", clean_recipe);

            if let Some(sql) = &v.sql {
                let sql_trimmed = sql.trim();
                if !sql_trimmed.is_empty() {
                    println!("   sql    : {}", sql_trimmed);
                }
            }

            // Print 'also :' for secondary violations on same statement
            for &sec_idx in secondary_idxs {
                let sv = &violations[sec_idx];
                println!(
                    "   also   : [{}] {}",
                    tier_label_colored(&sv.tier),
                    sv.rule_id
                );
            }

            if gi < groups.len() - 1 {
                println!();
                println!(" {}", "".repeat(sep_width));
                println!();
            }
        }

        println!();

        // Summary box using comfy-table
        let mut summary_table = Table::new();
        summary_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
        summary_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
        summary_table.set_width(width as u16);
        summary_table.set_header(vec!["SUMMARY", ""]);
        summary_table.add_row(vec!["Verdict", &format!(": {}", verdict.label())]);
        summary_table.add_row(vec![
            "Recommendation",
            &format!(": {}", verdict.recommendation()),
        ]);
        summary_table.add_row(vec!["HALT (Tier 1)", &format!(": {}", tier1)]);
        summary_table.add_row(vec!["WARN (Tier 2)", &format!(": {}", tier2)]);
        summary_table.add_row(vec!["SAFE (Tier 3)", &format!(": {}", tier3)]);
        println!("{}", summary_table);

        verdict == Verdict::Halt
    }
}