Skip to main content

safe_migrate/report/
reporter.rs

1// FILE: src/report/reporter.rs
2use crate::analysis::state::Confidence;
3use crate::report::violations::{ReportFinding, Violation, ViolationTier};
4use crate::rules::destructive::IRREVERSIBLE_MIGRATION_RULE_ID;
5use comfy_table::Table;
6use owo_colors::{OwoColorize, Style};
7
8/// Four-way verdict classification based on violation tiers.
9#[derive(Debug, PartialEq, Eq)]
10pub enum Verdict {
11    Halt,         // any Tier 1
12    Cautious,     // Tier 2 present, no Tier 1
13    SafeWithRisk, // Tier 3 irreversible present, no Tier 1 or 2
14    Safe,         // all Tier 3 non-irreversible or no findings
15}
16
17impl Verdict {
18    pub fn label(&self) -> &'static str {
19        match self {
20            Verdict::Halt => "HALT",
21            Verdict::Cautious => "CAUTIOUS",
22            Verdict::SafeWithRisk => "SAFE WITH RISK",
23            Verdict::Safe => "SAFE",
24        }
25    }
26
27    pub fn recommendation(&self, confidence: &Confidence) -> &'static str {
28        if confidence == &Confidence::Tainted {
29            return match self {
30                Verdict::Halt => "do not deploy",
31                Verdict::SafeWithRisk => {
32                    "irreversible operations present and baseline evidence is uncertain — ensure backups exist and review before deploying"
33                }
34                _ => {
35                    "no blocking finding, but baseline evidence is uncertain — review before deploying"
36                }
37            };
38        }
39        match self {
40            Verdict::Halt => "do not deploy",
41            Verdict::Cautious => "review warnings before deploy",
42            Verdict::SafeWithRisk => "irreversible operations present — ensure backups exist",
43            Verdict::Safe => "no modeled blocking findings",
44        }
45    }
46}
47
48/// Compute the overall verdict from a set of violations.
49pub fn compute_verdict(violations: &[Violation]) -> Verdict {
50    let has_tier1 = violations.iter().any(|v| v.tier == ViolationTier::Tier1);
51    let has_tier2 = violations.iter().any(|v| v.tier == ViolationTier::Tier2);
52    let has_irreversible_tier3 = violations
53        .iter()
54        .any(|v| v.tier == ViolationTier::Tier3 && v.rule_id == IRREVERSIBLE_MIGRATION_RULE_ID);
55
56    match (has_tier1, has_tier2, has_irreversible_tier3) {
57        (true, _, _) => Verdict::Halt,
58        (false, true, _) => Verdict::Cautious,
59        (false, false, true) => Verdict::SafeWithRisk,
60        (false, false, false) => Verdict::Safe,
61    }
62}
63
64fn no_color() -> bool {
65    std::env::var("NO_COLOR").is_ok()
66}
67pub(crate) fn tier_label_colored(tier: &ViolationTier) -> String {
68    let label = match tier {
69        ViolationTier::Tier1 => "HALT",
70        ViolationTier::Tier2 => "WARN",
71        ViolationTier::Tier3 => "SAFE",
72    };
73    if no_color() {
74        label.to_string()
75    } else {
76        match tier {
77            ViolationTier::Tier1 => label.style(Style::new().red().bold()).to_string(),
78            ViolationTier::Tier2 => label.style(Style::new().yellow().bold()).to_string(),
79            ViolationTier::Tier3 => label.style(Style::new().green().bold()).to_string(),
80        }
81    }
82}
83
84fn terminal_width() -> usize {
85    terminal_size::terminal_size()
86        .map(|(w, _)| w.0 as usize)
87        .unwrap_or(80)
88        .max(60)
89}
90
91pub struct Reporter;
92
93impl Reporter {
94    pub const JSON_SCHEMA_VERSION: u32 = 1;
95
96    pub fn json_report(violations: &[Violation], confidence: &Confidence) -> serde_json::Value {
97        let verdict = compute_verdict(violations);
98        serde_json::json!({
99            "schema_version": Self::JSON_SCHEMA_VERSION,
100            "confidence": match confidence {
101                Confidence::Exact => "Exact",
102                Confidence::Tainted => "Tainted",
103            },
104            "verdict": verdict.label(),
105            "violations": violations,
106        })
107    }
108
109    /// Additive JSON rendering that includes file/line locations when analysis
110    /// was invoked with source-aware reporting.
111    pub fn json_report_with_locations(
112        findings: &[ReportFinding],
113        confidence: &Confidence,
114    ) -> serde_json::Value {
115        let violations: Vec<_> = findings
116            .iter()
117            .map(|finding| finding.violation.clone())
118            .collect();
119        let mut report = Self::json_report(&violations, confidence);
120        report["violations"] =
121            serde_json::to_value(findings).expect("Report findings must always serialize to JSON");
122        report
123    }
124
125    /// Deterministic Markdown rendering for pull-request artifacts. It uses
126    /// the same verdict, confidence, tier, and finding data as JSON output.
127    pub fn markdown_report(findings: &[ReportFinding], confidence: &Confidence) -> String {
128        let violations: Vec<_> = findings
129            .iter()
130            .map(|finding| finding.violation.clone())
131            .collect();
132        let verdict = compute_verdict(&violations);
133        let confidence = match confidence {
134            Confidence::Exact => "Exact",
135            Confidence::Tainted => "Tainted",
136        };
137        let tier1 = violations
138            .iter()
139            .filter(|violation| violation.tier == ViolationTier::Tier1)
140            .count();
141        let tier2 = violations
142            .iter()
143            .filter(|violation| violation.tier == ViolationTier::Tier2)
144            .count();
145        let tier3 = violations
146            .iter()
147            .filter(|violation| violation.tier == ViolationTier::Tier3)
148            .count();
149
150        let mut output = format!(
151            "# safe-migrate report\n\n**Verdict:** {}  \n**Confidence:** {}\n\n| Severity | Findings |\n| --- | ---: |\n| HALT (Tier 1) | {} |\n| WARN (Tier 2) | {} |\n| SAFE (Tier 3) | {} |\n",
152            verdict.label(),
153            confidence,
154            tier1,
155            tier2,
156            tier3
157        );
158
159        if findings.is_empty() {
160            output.push_str("\nNo findings detected.\n");
161            return output;
162        }
163
164        output.push_str("\n## Findings\n");
165        for finding in findings {
166            let violation = &finding.violation;
167            output.push_str(&format!(
168                "\n### {} — `{}`\n\n",
169                markdown_tier_label(&violation.tier),
170                markdown_code(violation.rule_id)
171            ));
172            if let Some(location) = &finding.location {
173                output.push_str(&format!(
174                    "**Location:** `{}:{}:{}`  \n",
175                    markdown_code(&location.file),
176                    location.line,
177                    location.column
178                ));
179            }
180            output.push_str(&format!(
181                "**Object:** {} {}  \n**Reason:** {}  \n**Recommendation:** {}\n",
182                violation.object_kind,
183                markdown_escape(&violation.object_name),
184                markdown_escape(&violation.reason),
185                markdown_escape(
186                    &violation
187                        .recipe
188                        .lines()
189                        .map(str::trim)
190                        .filter(|line| !line.is_empty())
191                        .collect::<Vec<_>>()
192                        .join(" ")
193                )
194            ));
195            if let Some(sql) = &violation.sql
196                && !sql.trim().is_empty()
197            {
198                output.push_str(&markdown_sql_block(sql.trim()));
199            }
200        }
201        output
202    }
203
204    pub fn should_halt(violations: &[Violation]) -> bool {
205        compute_verdict(violations) == Verdict::Halt
206    }
207
208    pub fn print_report(violations: &[Violation], confidence: &Confidence) -> bool {
209        let mut tier1 = 0usize;
210        let mut tier2 = 0usize;
211        let mut tier3 = 0usize;
212
213        for v in violations {
214            match v.tier {
215                ViolationTier::Tier1 => tier1 += 1,
216                ViolationTier::Tier2 => tier2 += 1,
217                ViolationTier::Tier3 => tier3 += 1,
218            }
219        }
220
221        let verdict = compute_verdict(violations);
222        let conf_str = match confidence {
223            Confidence::Exact => "Exact",
224            Confidence::Tainted => "Tainted",
225        };
226
227        let width = terminal_width();
228
229        // Header box using comfy-table
230        let mut header_table = Table::new();
231        header_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
232        header_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
233        header_table.set_width(width as u16);
234        header_table.set_header(vec!["safe-migrate lint"]);
235        header_table.add_row(vec![format!(
236            "Verdict: {}   Confidence: {}",
237            verdict.label(),
238            conf_str
239        )]);
240        header_table.add_row(vec![format!(
241            "HALT: {}   WARN: {}   SAFE: {}",
242            tier1, tier2, tier3
243        )]);
244        println!("{}", header_table);
245
246        if violations.is_empty() {
247            println!("\n  No violations detected.\n");
248            return false;
249        }
250
251        println!();
252
253        // Separator width: 80-85% of terminal width
254        let sep_width = (width as f32 * 0.82) as usize;
255
256        // Group violations by sql key (same sql text + same object_name = same statement)
257        // Each group is (primary_idx, Vec<secondary_idxs>)
258        let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
259        let mut sql_to_group_idx: std::collections::HashMap<(&str, &str), usize> =
260            std::collections::HashMap::new();
261
262        for (i, v) in violations.iter().enumerate() {
263            if let Some(sql) = &v.sql {
264                let key = (sql.as_str(), v.object_name.as_str());
265                if let Some(&gi) = sql_to_group_idx.get(&key) {
266                    groups[gi].1.push(i);
267                    continue;
268                }
269
270                let new_gi = groups.len();
271                groups.push((i, Vec::new()));
272                sql_to_group_idx.insert(key, new_gi);
273            } else {
274                // If sql is None, it never groups
275                groups.push((i, Vec::new()));
276            }
277        }
278
279        for (gi, (primary_idx, secondary_idxs)) in groups.iter().enumerate() {
280            let v = &violations[*primary_idx];
281            let tier_str = tier_label_colored(&v.tier);
282
283            println!(" [{}] {}", tier_str, v.rule_id);
284
285            let display_name = match &v.object_kind {
286                crate::report::violations::ObjectKind::Database
287                | crate::report::violations::ObjectKind::Role
288                | crate::report::violations::ObjectKind::Publication
289                | crate::report::violations::ObjectKind::Subscription => {
290                    let step1 = if let Some(idx) = v.object_name.find('.') {
291                        &v.object_name[idx + 1..]
292                    } else {
293                        &v.object_name
294                    };
295                    step1
296                        .strip_suffix(" (inferred)")
297                        .unwrap_or(step1)
298                        .to_string()
299                }
300                _ => v.object_name.clone(),
301            };
302
303            if v.object_kind == crate::report::violations::ObjectKind::Unknown {
304                println!("   object : {}", display_name);
305            } else {
306                println!("   object : {} {}", v.object_kind, display_name);
307            }
308
309            println!("   reason : {}", v.reason);
310
311            // recipe: clean up multi-line strings
312            let clean_recipe = v
313                .recipe
314                .lines()
315                .map(|l| l.trim())
316                .filter(|l| !l.is_empty())
317                .collect::<Vec<_>>()
318                .join(" ");
319            println!("   recipe : {}", clean_recipe);
320
321            if let Some(sql) = &v.sql {
322                let sql_trimmed = sql.trim();
323                if !sql_trimmed.is_empty() {
324                    println!("   sql    : {}", sql_trimmed);
325                }
326            }
327
328            // Print 'also :' for secondary violations on same statement
329            for &sec_idx in secondary_idxs {
330                let sv = &violations[sec_idx];
331                println!(
332                    "   also   : [{}] {}",
333                    tier_label_colored(&sv.tier),
334                    sv.rule_id
335                );
336            }
337
338            if gi < groups.len() - 1 {
339                println!();
340                println!(" {}", "─".repeat(sep_width));
341                println!();
342            }
343        }
344
345        println!();
346
347        // Summary box using comfy-table
348        let mut summary_table = Table::new();
349        summary_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
350        summary_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
351        summary_table.set_width(width as u16);
352        summary_table.set_header(vec!["SUMMARY", ""]);
353        summary_table.add_row(vec!["Verdict", &format!(": {}", verdict.label())]);
354        summary_table.add_row(vec![
355            "Recommendation",
356            &format!(": {}", verdict.recommendation(confidence)),
357        ]);
358        summary_table.add_row(vec!["HALT (Tier 1)", &format!(": {}", tier1)]);
359        summary_table.add_row(vec!["WARN (Tier 2)", &format!(": {}", tier2)]);
360        summary_table.add_row(vec!["SAFE (Tier 3)", &format!(": {}", tier3)]);
361        println!("{}", summary_table);
362
363        Self::should_halt(violations)
364    }
365}
366
367fn markdown_tier_label(tier: &ViolationTier) -> &'static str {
368    match tier {
369        ViolationTier::Tier1 => "HALT",
370        ViolationTier::Tier2 => "WARN",
371        ViolationTier::Tier3 => "SAFE",
372    }
373}
374
375fn markdown_escape(value: &str) -> String {
376    value.replace('\\', "\\\\").replace('|', "\\|")
377}
378
379fn markdown_code(value: &str) -> String {
380    value.replace('`', "'")
381}
382
383fn markdown_sql_block(sql: &str) -> String {
384    let longest_backtick_run = sql
385        .split(|character| character != '`')
386        .map(str::len)
387        .max()
388        .unwrap_or(0);
389    let fence = "`".repeat(longest_backtick_run.max(2) + 1);
390    format!("\n{fence}sql\n{sql}\n{fence}\n")
391}