safe_migrate/_internal/report/
reporter.rs1use 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#[derive(Debug, PartialEq, Eq)]
12pub enum Verdict {
13 Halt, Cautious, SafeWithRisk, Safe, }
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
50pub 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 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 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 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 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 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}