Skip to main content

safe_migrate/report/
reporter.rs

1// FILE: src/report/reporter.rs
2use crate::analysis::state::Confidence;
3use crate::report::violations::{Violation, ViolationTier};
4use comfy_table::Table;
5use owo_colors::{OwoColorize, Style};
6
7/// Four-way verdict classification based on violation tiers.
8#[derive(Debug, PartialEq, Eq)]
9pub enum Verdict {
10    Halt,         // any Tier 1
11    Cautious,     // Tier 2 present, no Tier 1
12    SafeWithRisk, // Tier 3 irreversible present, no Tier 1 or 2
13    Safe,         // all Tier 3 non-irreversible or no findings
14}
15
16impl Verdict {
17    pub fn label(&self) -> &'static str {
18        match self {
19            Verdict::Halt => "HALT",
20            Verdict::Cautious => "CAUTIOUS",
21            Verdict::SafeWithRisk => "SAFE WITH RISK",
22            Verdict::Safe => "SAFE",
23        }
24    }
25
26    pub fn recommendation(&self) -> &'static str {
27        match self {
28            Verdict::Halt => "do not deploy",
29            Verdict::Cautious => "review warnings before deploy",
30            Verdict::SafeWithRisk => "irreversible operations present — ensure backups exist",
31            Verdict::Safe => "safe to deploy",
32        }
33    }
34}
35
36/// Compute the overall verdict from a set of violations.
37pub fn compute_verdict(violations: &[Violation]) -> Verdict {
38    let has_tier1 = violations.iter().any(|v| v.tier == ViolationTier::Tier1);
39    let has_tier2 = violations.iter().any(|v| v.tier == ViolationTier::Tier2);
40    let has_irreversible_tier3 = violations
41        .iter()
42        .any(|v| v.tier == ViolationTier::Tier3 && v.rule_id == "irreversible-migration");
43
44    match (has_tier1, has_tier2, has_irreversible_tier3) {
45        (true, _, _) => Verdict::Halt,
46        (false, true, _) => Verdict::Cautious,
47        (false, false, true) => Verdict::SafeWithRisk,
48        (false, false, false) => Verdict::Safe,
49    }
50}
51
52fn no_color() -> bool {
53    std::env::var("NO_COLOR").is_ok()
54}
55pub(crate) fn tier_label_colored(tier: &ViolationTier) -> String {
56    let label = match tier {
57        ViolationTier::Tier1 => "HALT",
58        ViolationTier::Tier2 => "WARN",
59        ViolationTier::Tier3 => "SAFE",
60    };
61    if no_color() {
62        label.to_string()
63    } else {
64        match tier {
65            ViolationTier::Tier1 => label.style(Style::new().red().bold()).to_string(),
66            ViolationTier::Tier2 => label.style(Style::new().yellow().bold()).to_string(),
67            ViolationTier::Tier3 => label.style(Style::new().green().bold()).to_string(),
68        }
69    }
70}
71
72fn terminal_width() -> usize {
73    terminal_size::terminal_size()
74        .map(|(w, _)| w.0 as usize)
75        .unwrap_or(80)
76        .max(60)
77}
78
79pub struct Reporter;
80
81impl Reporter {
82    pub fn print_json_report(violations: &[Violation], confidence: &Confidence) {
83        let verdict = compute_verdict(violations);
84        let output = serde_json::json!({
85            "confidence": match confidence {
86                Confidence::Exact => "Exact",
87                Confidence::Tainted => "Tainted",
88            },
89            "verdict": verdict.label(),
90            "violations": violations,
91        });
92        println!("{}", serde_json::to_string_pretty(&output).unwrap());
93    }
94
95    pub fn print_report(violations: &[Violation], confidence: &Confidence) -> bool {
96        let mut tier1 = 0usize;
97        let mut tier2 = 0usize;
98        let mut tier3 = 0usize;
99
100        for v in violations {
101            match v.tier {
102                ViolationTier::Tier1 => tier1 += 1,
103                ViolationTier::Tier2 => tier2 += 1,
104                ViolationTier::Tier3 => tier3 += 1,
105            }
106        }
107
108        let verdict = compute_verdict(violations);
109        let conf_str = match confidence {
110            Confidence::Exact => "Exact",
111            Confidence::Tainted => "Tainted",
112        };
113
114        let width = terminal_width();
115
116        // Header box using comfy-table
117        let mut header_table = Table::new();
118        header_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
119        header_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
120        header_table.set_width(width as u16);
121        header_table.set_header(vec!["safe-migrate lint"]);
122        header_table.add_row(vec![format!(
123            "Verdict: {}   Confidence: {}",
124            verdict.label(),
125            conf_str
126        )]);
127        header_table.add_row(vec![format!(
128            "HALT: {}   WARN: {}   SAFE: {}",
129            tier1, tier2, tier3
130        )]);
131        println!("{}", header_table);
132
133        if violations.is_empty() {
134            println!("\n  No violations detected.\n");
135            return false;
136        }
137
138        println!();
139
140        // Separator width: 80-85% of terminal width
141        let sep_width = (width as f32 * 0.82) as usize;
142
143        // Group violations by sql key (same sql text + same object_name = same statement)
144        // Each group is (primary_idx, Vec<secondary_idxs>)
145        let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
146        let mut sql_to_group_idx: std::collections::HashMap<(&str, &str), usize> =
147            std::collections::HashMap::new();
148
149        for (i, v) in violations.iter().enumerate() {
150            if let Some(sql) = &v.sql {
151                let key = (sql.as_str(), v.object_name.as_str());
152                if let Some(&gi) = sql_to_group_idx.get(&key) {
153                    groups[gi].1.push(i);
154                    continue;
155                }
156
157                let new_gi = groups.len();
158                groups.push((i, Vec::new()));
159                sql_to_group_idx.insert(key, new_gi);
160            } else {
161                // If sql is None, it never groups
162                groups.push((i, Vec::new()));
163            }
164        }
165
166        for (gi, (primary_idx, secondary_idxs)) in groups.iter().enumerate() {
167            let v = &violations[*primary_idx];
168            let tier_str = tier_label_colored(&v.tier);
169
170            println!(" [{}] {}", tier_str, v.rule_id);
171
172            let display_name = match &v.object_kind {
173                crate::report::violations::ObjectKind::Database
174                | crate::report::violations::ObjectKind::Role
175                | crate::report::violations::ObjectKind::Publication
176                | crate::report::violations::ObjectKind::Subscription => {
177                    let step1 = if let Some(idx) = v.object_name.find('.') {
178                        &v.object_name[idx + 1..]
179                    } else {
180                        &v.object_name
181                    };
182                    step1
183                        .strip_suffix(" (inferred)")
184                        .unwrap_or(step1)
185                        .to_string()
186                }
187                _ => v.object_name.clone(),
188            };
189
190            if v.object_kind == crate::report::violations::ObjectKind::Unknown {
191                println!("   object : {}", display_name);
192            } else {
193                println!("   object : {} {}", v.object_kind, display_name);
194            }
195
196            println!("   reason : {}", v.reason);
197
198            // recipe: clean up multi-line strings
199            let clean_recipe = v
200                .recipe
201                .lines()
202                .map(|l| l.trim())
203                .filter(|l| !l.is_empty())
204                .collect::<Vec<_>>()
205                .join(" ");
206            println!("   recipe : {}", clean_recipe);
207
208            if let Some(sql) = &v.sql {
209                let sql_trimmed = sql.trim();
210                if !sql_trimmed.is_empty() {
211                    println!("   sql    : {}", sql_trimmed);
212                }
213            }
214
215            // Print 'also :' for secondary violations on same statement
216            for &sec_idx in secondary_idxs {
217                let sv = &violations[sec_idx];
218                println!(
219                    "   also   : [{}] {}",
220                    tier_label_colored(&sv.tier),
221                    sv.rule_id
222                );
223            }
224
225            if gi < groups.len() - 1 {
226                println!();
227                println!(" {}", "─".repeat(sep_width));
228                println!();
229            }
230        }
231
232        println!();
233
234        // Summary box using comfy-table
235        let mut summary_table = Table::new();
236        summary_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
237        summary_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
238        summary_table.set_width(width as u16);
239        summary_table.set_header(vec!["SUMMARY", ""]);
240        summary_table.add_row(vec!["Verdict", &format!(": {}", verdict.label())]);
241        summary_table.add_row(vec![
242            "Recommendation",
243            &format!(": {}", verdict.recommendation()),
244        ]);
245        summary_table.add_row(vec!["HALT (Tier 1)", &format!(": {}", tier1)]);
246        summary_table.add_row(vec!["WARN (Tier 2)", &format!(": {}", tier2)]);
247        summary_table.add_row(vec!["SAFE (Tier 3)", &format!(": {}", tier3)]);
248        println!("{}", summary_table);
249
250        verdict == Verdict::Halt
251    }
252}