1use 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#[derive(Debug, PartialEq, Eq)]
10pub enum Verdict {
11 Halt, Cautious, SafeWithRisk, Safe, }
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
48pub 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 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 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 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 let sep_width = (width as f32 * 0.82) as usize;
255
256 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 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 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 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 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}