Skip to main content

safe_migrate/_internal/report/
reporter.rs

1use crate::_internal::analysis::evidence::EvidenceRecord;
2use crate::_internal::analysis::outcome::AnalysisOutcome;
3use crate::_internal::analysis::state::Confidence;
4use crate::_internal::report::violations::{ReportFinding, Violation, ViolationTier};
5use crate::_internal::rules::destructive::IRREVERSIBLE_MIGRATION_RULE_ID;
6use crate::_internal::rules::registry;
7use comfy_table::Table;
8use owo_colors::{OwoColorize, Style};
9
10/// Four-way verdict classification based on violation tiers.
11#[derive(Debug, PartialEq, Eq)]
12pub enum Verdict {
13    Halt,         // any Tier 1
14    Cautious,     // Tier 2 present, no Tier 1
15    SafeWithRisk, // Tier 3 irreversible present, no Tier 1 or 2
16    Safe,         // all Tier 3 non-irreversible or no findings
17}
18
19impl Verdict {
20    pub fn label(&self) -> &'static str {
21        match self {
22            Verdict::Halt => "HALT",
23            Verdict::Cautious => "CAUTIOUS",
24            Verdict::SafeWithRisk => "SAFE WITH RISK",
25            Verdict::Safe => "SAFE",
26        }
27    }
28
29    pub fn recommendation(&self, confidence: &Confidence) -> &'static str {
30        if confidence == &Confidence::Tainted {
31            return match self {
32                Verdict::Halt => "do not deploy",
33                Verdict::SafeWithRisk => {
34                    "irreversible operations present and baseline evidence is uncertain — ensure backups exist and review before deploying"
35                }
36                _ => {
37                    "no blocking finding, but baseline evidence is uncertain — review before deploying"
38                }
39            };
40        }
41        match self {
42            Verdict::Halt => "do not deploy",
43            Verdict::Cautious => "review warnings before deploy",
44            Verdict::SafeWithRisk => "irreversible operations present — ensure backups exist",
45            Verdict::Safe => "no modeled blocking findings",
46        }
47    }
48}
49
50/// Compute the overall verdict from a set of violations.
51pub fn compute_verdict(violations: &[Violation]) -> Verdict {
52    let has_tier1 = violations.iter().any(|v| v.tier == ViolationTier::Tier1);
53    let has_tier2 = violations.iter().any(|v| v.tier == ViolationTier::Tier2);
54    let has_irreversible_tier3 = violations
55        .iter()
56        .any(|v| v.tier == ViolationTier::Tier3 && v.rule_id == IRREVERSIBLE_MIGRATION_RULE_ID);
57
58    match (has_tier1, has_tier2, has_irreversible_tier3) {
59        (true, _, _) => Verdict::Halt,
60        (false, true, _) => Verdict::Cautious,
61        (false, false, true) => Verdict::SafeWithRisk,
62        (false, false, false) => Verdict::Safe,
63    }
64}
65
66fn no_color() -> bool {
67    std::env::var("NO_COLOR").is_ok()
68}
69pub(crate) fn tier_label_colored(tier: &ViolationTier) -> String {
70    let label = match tier {
71        ViolationTier::Tier1 => "HALT",
72        ViolationTier::Tier2 => "WARN",
73        ViolationTier::Tier3 => "SAFE",
74    };
75    if no_color() {
76        label.to_string()
77    } else {
78        match tier {
79            ViolationTier::Tier1 => label.style(Style::new().red().bold()).to_string(),
80            ViolationTier::Tier2 => label.style(Style::new().yellow().bold()).to_string(),
81            ViolationTier::Tier3 => label.style(Style::new().green().bold()).to_string(),
82        }
83    }
84}
85
86fn terminal_width() -> usize {
87    terminal_size::terminal_size()
88        .map(|(w, _)| w.0 as usize)
89        .unwrap_or(80)
90        .max(60)
91}
92
93pub struct Reporter;
94
95impl Reporter {
96    pub const JSON_SCHEMA_VERSION: u32 = 2;
97
98    pub fn json_report(violations: &[Violation], confidence: &Confidence) -> serde_json::Value {
99        let verdict = compute_verdict(violations);
100        let tier1 = violations
101            .iter()
102            .filter(|violation| violation.tier == ViolationTier::Tier1)
103            .count();
104        let tier2 = violations
105            .iter()
106            .filter(|violation| violation.tier == ViolationTier::Tier2)
107            .count();
108        let tier3 = violations
109            .iter()
110            .filter(|violation| violation.tier == ViolationTier::Tier3)
111            .count();
112        serde_json::json!({
113            "schema_version": Self::JSON_SCHEMA_VERSION,
114            "confidence": match confidence {
115                Confidence::Exact => "Exact",
116                Confidence::Tainted => "Tainted",
117            },
118            "verdict": verdict.label(),
119            "summary": {
120                "total": violations.len(),
121                "tier1": tier1,
122                "tier2": tier2,
123                "tier3": tier3,
124            },
125            "evidence": [],
126            "violations": violations,
127        })
128    }
129
130    /// Serialize a complete immutable analysis outcome. Schema v2 adds stable
131    /// evidence alongside the existing finding contract.
132    pub fn json_outcome_with_locations(
133        outcome: &AnalysisOutcome<ReportFinding>,
134    ) -> serde_json::Value {
135        let mut report = Self::json_report_with_locations(&outcome.findings, &outcome.confidence);
136        report["evidence"] = serde_json::to_value(&outcome.evidence)
137            .expect("analysis evidence is always serializable");
138        report
139    }
140
141    /// Additive JSON rendering that includes file/line locations when analysis
142    /// was invoked with source-aware reporting.
143    pub fn json_report_with_locations(
144        findings: &[ReportFinding],
145        confidence: &Confidence,
146    ) -> serde_json::Value {
147        let violations: Vec<_> = findings
148            .iter()
149            .map(|finding| finding.violation.clone())
150            .collect();
151        let mut report = Self::json_report(&violations, confidence);
152        report["violations"] = serde_json::Value::Array(
153            findings
154                .iter()
155                .map(|finding| {
156                    let mut value = serde_json::to_value(finding).unwrap_or_else(|error| {
157                        serde_json::json!({
158                            "rule_id": finding.violation.rule_id,
159                            "message": "Failed to serialize report finding",
160                            "serialization_error": error.to_string(),
161                        })
162                    });
163                    if let Some(descriptor) = registry::find_primary_rule(finding.violation.rule_id)
164                        && let Some(object) = value.as_object_mut()
165                    {
166                        object.insert("rule_title".into(), descriptor.title.into());
167                        object.insert("rule_summary".into(), descriptor.summary.into());
168                        object.insert("impact".into(), descriptor.impact.into());
169                    }
170                    value
171                })
172                .collect(),
173        );
174        report
175    }
176
177    /// Deterministic Markdown rendering for pull-request artifacts. It uses
178    /// the same verdict, confidence, tier, and finding data as JSON output.
179    pub fn markdown_report(findings: &[ReportFinding], confidence: &Confidence) -> String {
180        let violations: Vec<_> = findings
181            .iter()
182            .map(|finding| finding.violation.clone())
183            .collect();
184        let verdict = compute_verdict(&violations);
185        let confidence = match confidence {
186            Confidence::Exact => "Exact",
187            Confidence::Tainted => "Tainted",
188        };
189        let tier1 = violations
190            .iter()
191            .filter(|violation| violation.tier == ViolationTier::Tier1)
192            .count();
193        let tier2 = violations
194            .iter()
195            .filter(|violation| violation.tier == ViolationTier::Tier2)
196            .count();
197        let tier3 = violations
198            .iter()
199            .filter(|violation| violation.tier == ViolationTier::Tier3)
200            .count();
201
202        let mut output = format!(
203            "# 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",
204            verdict.label(),
205            confidence,
206            tier1,
207            tier2,
208            tier3
209        );
210
211        if findings.is_empty() {
212            output.push_str("\nNo findings detected.\n");
213            return output;
214        }
215
216        output.push_str("\n## Findings\n");
217        for finding in findings {
218            let violation = &finding.violation;
219            output.push_str(&format!(
220                "\n### {} — {} (`{}`)\n\n",
221                markdown_tier_label(&violation.tier),
222                registry::find_primary_rule(violation.rule_id)
223                    .map(|descriptor| descriptor.title)
224                    .unwrap_or(violation.rule_id),
225                markdown_code(violation.rule_id)
226            ));
227            if let Some(descriptor) = registry::find_primary_rule(violation.rule_id) {
228                output.push_str(&format!(
229                    "**Impact:** {}  \n**Rule summary:** {}  \n",
230                    markdown_escape(descriptor.impact),
231                    markdown_escape(descriptor.summary)
232                ));
233            }
234            if let Some(location) = &finding.location {
235                output.push_str(&format!(
236                    "**Location:** `{}:{}:{}`  \n",
237                    markdown_code(&location.file),
238                    location.line,
239                    location.column
240                ));
241            }
242            if let Some(statement_index) = finding.statement_index {
243                output.push_str(&format!("**Statement:** {}  \n", statement_index));
244            }
245            output.push_str(&format!(
246                "**Object:** {} {}  \n**Reason:** {}  \n**Recommendation:** {}\n",
247                violation.object_kind,
248                markdown_escape(&violation.object_name),
249                markdown_escape(&violation.reason),
250                markdown_escape(
251                    &violation
252                        .recipe
253                        .lines()
254                        .map(str::trim)
255                        .filter(|line| !line.is_empty())
256                        .collect::<Vec<_>>()
257                        .join(" ")
258                )
259            ));
260            if let Some(sql) = &violation.sql
261                && !sql.trim().is_empty()
262            {
263                output.push_str(&markdown_sql_block(sql.trim()));
264            }
265        }
266        output
267    }
268
269    /// Render findings and structured conservative-analysis evidence.
270    pub fn markdown_outcome(outcome: &AnalysisOutcome<ReportFinding>) -> String {
271        let mut output = Self::markdown_report(&outcome.findings, &outcome.confidence);
272        append_markdown_evidence(&mut output, &outcome.evidence);
273        output
274    }
275
276    pub fn should_halt(violations: &[Violation]) -> bool {
277        compute_verdict(violations) == Verdict::Halt
278    }
279
280    pub fn print_report(violations: &[Violation], confidence: &Confidence) -> bool {
281        let mut tier1 = 0usize;
282        let mut tier2 = 0usize;
283        let mut tier3 = 0usize;
284
285        for v in violations {
286            match v.tier {
287                ViolationTier::Tier1 => tier1 += 1,
288                ViolationTier::Tier2 => tier2 += 1,
289                ViolationTier::Tier3 => tier3 += 1,
290            }
291        }
292
293        let verdict = compute_verdict(violations);
294        let conf_str = match confidence {
295            Confidence::Exact => "Exact",
296            Confidence::Tainted => "Tainted",
297        };
298
299        let width = terminal_width();
300
301        let mut header_table = Table::new();
302        header_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
303        header_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
304        header_table.set_width(width as u16);
305        header_table.set_header(vec!["safe-migrate lint"]);
306        header_table.add_row(vec![format!(
307            "Verdict: {}   Confidence: {}",
308            verdict.label(),
309            conf_str
310        )]);
311        header_table.add_row(vec![format!(
312            "HALT: {}   WARN: {}   SAFE: {}",
313            tier1, tier2, tier3
314        )]);
315        println!("{}", header_table);
316
317        if violations.is_empty() {
318            println!("\n  No violations detected.\n");
319            return false;
320        }
321
322        println!();
323
324        let sep_width = (width as f32 * 0.82) as usize;
325
326        let mut groups: Vec<(usize, Vec<usize>)> = Vec::new();
327        let mut sql_to_group_idx: std::collections::HashMap<(&str, &str), usize> =
328            std::collections::HashMap::new();
329
330        for (i, v) in violations.iter().enumerate() {
331            if let Some(sql) = &v.sql {
332                let key = (sql.as_str(), v.object_name.as_str());
333                if let Some(&gi) = sql_to_group_idx.get(&key) {
334                    groups[gi].1.push(i);
335                    continue;
336                }
337
338                let new_gi = groups.len();
339                groups.push((i, Vec::new()));
340                sql_to_group_idx.insert(key, new_gi);
341            } else {
342                groups.push((i, Vec::new()));
343            }
344        }
345
346        for (gi, (primary_idx, secondary_idxs)) in groups.iter().enumerate() {
347            let v = &violations[*primary_idx];
348            let tier_str = tier_label_colored(&v.tier);
349
350            let descriptor = registry::find_primary_rule(v.rule_id);
351            let rule_label = descriptor
352                .map(|descriptor| format!("{} ({})", descriptor.title, v.rule_id))
353                .unwrap_or_else(|| v.rule_id.to_string());
354            println!(" [{}] {}", tier_str, rule_label);
355            if let Some(descriptor) = descriptor {
356                println!("   impact : {}", descriptor.impact);
357                println!("   summary: {}", descriptor.summary);
358            }
359
360            let display_name = match &v.object_kind {
361                crate::_internal::report::violations::ObjectKind::Database
362                | crate::_internal::report::violations::ObjectKind::Role
363                | crate::_internal::report::violations::ObjectKind::Publication
364                | crate::_internal::report::violations::ObjectKind::Subscription => {
365                    let step1 = if let Some(idx) = v.object_name.find('.') {
366                        &v.object_name[idx + 1..]
367                    } else {
368                        &v.object_name
369                    };
370                    step1
371                        .strip_suffix(" (inferred)")
372                        .unwrap_or(step1)
373                        .to_string()
374                }
375                _ => v.object_name.clone(),
376            };
377
378            if v.object_kind == crate::_internal::report::violations::ObjectKind::Unknown {
379                println!("   object : {}", display_name);
380            } else {
381                println!("   object : {} {}", v.object_kind, display_name);
382            }
383
384            println!("   reason : {}", v.reason);
385
386            let clean_recipe = v
387                .recipe
388                .lines()
389                .map(|l| l.trim())
390                .filter(|l| !l.is_empty())
391                .collect::<Vec<_>>()
392                .join(" ");
393            println!("   recipe : {}", clean_recipe);
394
395            if let Some(sql) = &v.sql {
396                let sql_trimmed = sql.trim();
397                if !sql_trimmed.is_empty() {
398                    println!("   sql    : {}", sql_trimmed);
399                }
400            }
401
402            for &sec_idx in secondary_idxs {
403                let sv = &violations[sec_idx];
404                println!(
405                    "   also   : [{}] {}",
406                    tier_label_colored(&sv.tier),
407                    sv.rule_id
408                );
409            }
410
411            if gi < groups.len() - 1 {
412                println!();
413                println!(" {}", "─".repeat(sep_width));
414                println!();
415            }
416        }
417
418        println!();
419
420        let mut summary_table = Table::new();
421        summary_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY);
422        summary_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth);
423        summary_table.set_width(width as u16);
424        summary_table.set_header(vec!["SUMMARY", ""]);
425        summary_table.add_row(vec!["Verdict", &format!(": {}", verdict.label())]);
426        summary_table.add_row(vec![
427            "Recommendation",
428            &format!(": {}", verdict.recommendation(confidence)),
429        ]);
430        summary_table.add_row(vec!["HALT (Tier 1)", &format!(": {}", tier1)]);
431        summary_table.add_row(vec!["WARN (Tier 2)", &format!(": {}", tier2)]);
432        summary_table.add_row(vec!["SAFE (Tier 3)", &format!(": {}", tier3)]);
433        println!("{}", summary_table);
434
435        Self::should_halt(violations)
436    }
437
438    /// Print findings and a compact, deterministic evidence summary.
439    pub fn print_outcome(outcome: &AnalysisOutcome<ReportFinding>) -> bool {
440        let violations: Vec<_> = outcome
441            .findings
442            .iter()
443            .map(|finding| finding.violation.clone())
444            .collect();
445        let should_halt = Self::print_report(&violations, &outcome.confidence);
446        if !outcome.evidence.is_empty() {
447            println!("Analysis evidence:");
448            for evidence in &outcome.evidence {
449                let location = evidence
450                    .location
451                    .as_ref()
452                    .map_or_else(String::new, |location| {
453                        format!(
454                            " ({} statement {})",
455                            location.file, location.statement_index
456                        )
457                    });
458                println!("  - {}{}", evidence.summary, location);
459            }
460            println!();
461        }
462        should_halt
463    }
464}
465
466fn append_markdown_evidence(output: &mut String, evidence: &[EvidenceRecord]) {
467    if evidence.is_empty() {
468        return;
469    }
470    output.push_str("\n## Analysis evidence\n");
471    for record in evidence {
472        output.push_str(&format!(
473            "\n- `{}`: {}",
474            record.code.as_str(),
475            record.summary
476        ));
477        if let Some(location) = &record.location {
478            output.push_str(&format!(
479                " ({} statement {})",
480                markdown_code(&location.file),
481                location.statement_index
482            ));
483        }
484    }
485    output.push('\n');
486}
487
488fn markdown_tier_label(tier: &ViolationTier) -> &'static str {
489    match tier {
490        ViolationTier::Tier1 => "HALT",
491        ViolationTier::Tier2 => "WARN",
492        ViolationTier::Tier3 => "SAFE",
493    }
494}
495
496fn markdown_escape(value: &str) -> String {
497    value.replace('\\', "\\\\").replace('|', "\\|")
498}
499
500fn markdown_code(value: &str) -> String {
501    value.replace('`', "'")
502}
503
504fn markdown_sql_block(sql: &str) -> String {
505    let longest_backtick_run = sql
506        .split(|character| character != '`')
507        .map(str::len)
508        .max()
509        .unwrap_or(0);
510    let fence = "`".repeat(longest_backtick_run.max(2) + 1);
511    format!("\n{fence}sql\n{sql}\n{fence}\n")
512}