1use diffguard_types::{
2 CheckReceipt, Finding, REASON_GIT_UNAVAILABLE, REASON_MISSING_BASE, REASON_NO_DIFF_INPUT,
3 REASON_TOOL_ERROR, REASON_TRUNCATED, VerdictStatus,
4};
5
6const RENDERABLE_META_REASONS: &[&str] = &[
9 REASON_TRUNCATED,
10 REASON_MISSING_BASE,
11 REASON_NO_DIFF_INPUT,
12 REASON_GIT_UNAVAILABLE,
13 REASON_TOOL_ERROR,
14];
15
16pub fn render_markdown_for_receipt(receipt: &CheckReceipt) -> String {
17 let status = match receipt.verdict.status {
18 VerdictStatus::Pass => "PASS",
19 VerdictStatus::Warn => "WARN",
20 VerdictStatus::Fail => "FAIL",
21 VerdictStatus::Skip => "SKIP",
22 };
23
24 let mut out = String::new();
25 out.push_str(&format!("## diffguard — {status}\n\n"));
26
27 out.push_str(&format!(
28 "Scanned **{}** file(s), **{}** line(s) (scope: `{}`, base: `{}`, head: `{}`)\n\n",
29 receipt.diff.files_scanned,
30 receipt.diff.lines_scanned,
31 receipt.diff.scope.as_str(),
32 receipt.diff.base,
33 receipt.diff.head
34 ));
35
36 let meta_reasons: Vec<&String> = receipt
37 .verdict
38 .reasons
39 .iter()
40 .filter(|r| RENDERABLE_META_REASONS.contains(&r.as_str()))
41 .collect();
42 if !meta_reasons.is_empty() {
43 out.push_str("**Verdict reasons:**\n");
44 for r in &meta_reasons {
45 out.push_str(&format!("- {r}\n"));
46 }
47 out.push('\n');
48 }
49
50 if receipt.verdict.counts.suppressed > 0 {
51 out.push_str(&format!(
52 "**Note:** {} finding(s) suppressed via inline directives.\n\n",
53 receipt.verdict.counts.suppressed
54 ));
55 }
56
57 if receipt.findings.is_empty() {
58 out.push_str("No findings.\n");
59 return out;
60 }
61
62 out.push_str("| Severity | Rule | Location | Message | Snippet |\n");
63 out.push_str("|---|---|---|---|---|\n");
64
65 for f in &receipt.findings {
66 out.push_str(&render_finding_row(f));
67 }
68
69 out.push('\n');
70 out
71}
72
73fn render_finding_row(f: &Finding) -> String {
74 let sev = f.severity.as_str();
75 let loc = format!("{}:{}", escape_md(&f.path), f.line);
76 let msg = escape_md(&f.message);
77 let snippet = escape_md(&f.snippet);
78
79 format!(
80 "| {sev} | `{rule}` | `{loc}` | {msg} | `{snippet}` |\n",
81 sev = sev,
82 rule = escape_md(&f.rule_id),
83 loc = loc,
84 msg = msg,
85 snippet = snippet
86 )
87}
88
89fn escape_md(s: &str) -> String {
90 s.replace('|', "\\|").replace('`', "\\`")
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn renders_markdown_table() {
99 let receipt = CheckReceipt {
100 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
101 tool: diffguard_types::ToolMeta {
102 name: "diffguard".to_string(),
103 version: "0.1.0".to_string(),
104 },
105 diff: diffguard_types::DiffMeta {
106 base: "main".to_string(),
107 head: "HEAD".to_string(),
108 context_lines: 0,
109 scope: diffguard_types::Scope::Added,
110 files_scanned: 1,
111 lines_scanned: 1,
112 },
113 findings: vec![Finding {
114 rule_id: "r".to_string(),
115 severity: diffguard_types::Severity::Warn,
116 message: "m".to_string(),
117 path: "src/lib.rs".to_string(),
118 line: 1,
119 column: Some(3),
120 match_text: "unwrap".to_string(),
121 snippet: "x.unwrap()".to_string(),
122 }],
123 verdict: diffguard_types::Verdict {
124 status: VerdictStatus::Warn,
125 counts: diffguard_types::VerdictCounts {
126 info: 0,
127 warn: 1,
128 error: 0,
129 ..Default::default()
130 },
131 reasons: vec![],
132 },
133 timing: None,
134 };
135
136 let md = render_markdown_for_receipt(&receipt);
137 assert!(md.contains("| Severity | Rule"));
138 assert!(md.contains("src/lib.rs"));
139 }
140
141 #[test]
142 fn render_finding_row_escapes_pipes_and_backticks() {
143 let finding = Finding {
144 rule_id: "rule|id`tick".to_string(),
145 severity: diffguard_types::Severity::Warn,
146 message: "message with | and `ticks`".to_string(),
147 path: "src/lib|name`.rs".to_string(),
148 line: 7,
149 column: Some(1),
150 match_text: "match".to_string(),
151 snippet: "snippet with `code` | pipe".to_string(),
152 };
153
154 let row = render_finding_row(&finding);
155
156 assert!(row.contains("rule\\|id\\`tick"));
157 assert!(row.contains("src/lib\\|name\\`.rs:7"));
158 assert!(row.contains("message with \\| and \\`ticks\\`"));
159 assert!(row.contains("snippet with \\`code\\` \\| pipe"));
160 }
161
162 fn create_test_receipt_with_findings() -> CheckReceipt {
164 CheckReceipt {
165 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
166 tool: diffguard_types::ToolMeta {
167 name: "diffguard".to_string(),
168 version: "0.1.0".to_string(),
169 },
170 diff: diffguard_types::DiffMeta {
171 base: "origin/main".to_string(),
172 head: "HEAD".to_string(),
173 context_lines: 0,
174 scope: diffguard_types::Scope::Added,
175 files_scanned: 3,
176 lines_scanned: 42,
177 },
178 findings: vec![
179 Finding {
180 rule_id: "rust.no_unwrap".to_string(),
181 severity: diffguard_types::Severity::Error,
182 message: "Avoid unwrap/expect in production code.".to_string(),
183 path: "src/lib.rs".to_string(),
184 line: 15,
185 column: Some(10),
186 match_text: ".unwrap()".to_string(),
187 snippet: "let value = result.unwrap();".to_string(),
188 },
189 Finding {
190 rule_id: "rust.no_dbg".to_string(),
191 severity: diffguard_types::Severity::Warn,
192 message: "Remove dbg!/println! before merging.".to_string(),
193 path: "src/main.rs".to_string(),
194 line: 23,
195 column: Some(5),
196 match_text: "dbg!".to_string(),
197 snippet: " dbg!(config);".to_string(),
198 },
199 Finding {
200 rule_id: "python.no_print".to_string(),
201 severity: diffguard_types::Severity::Warn,
202 message: "Remove print() before merging.".to_string(),
203 path: "scripts/deploy.py".to_string(),
204 line: 8,
205 column: None,
206 match_text: "print(".to_string(),
207 snippet: "print(\"Deploying...\")".to_string(),
208 },
209 ],
210 verdict: diffguard_types::Verdict {
211 status: VerdictStatus::Fail,
212 counts: diffguard_types::VerdictCounts {
213 info: 0,
214 warn: 2,
215 error: 1,
216 ..Default::default()
217 },
218 reasons: vec![],
219 },
220 timing: None,
221 }
222 }
223
224 fn create_test_receipt_empty() -> CheckReceipt {
226 CheckReceipt {
227 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
228 tool: diffguard_types::ToolMeta {
229 name: "diffguard".to_string(),
230 version: "0.1.0".to_string(),
231 },
232 diff: diffguard_types::DiffMeta {
233 base: "origin/main".to_string(),
234 head: "HEAD".to_string(),
235 context_lines: 0,
236 scope: diffguard_types::Scope::Added,
237 files_scanned: 5,
238 lines_scanned: 120,
239 },
240 findings: vec![],
241 verdict: diffguard_types::Verdict {
242 status: VerdictStatus::Pass,
243 counts: diffguard_types::VerdictCounts {
244 info: 0,
245 warn: 0,
246 error: 0,
247 suppressed: 0,
248 },
249 reasons: vec![],
250 },
251 timing: None,
252 }
253 }
254
255 fn create_test_receipt_warn_verdict() -> CheckReceipt {
257 CheckReceipt {
258 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
259 tool: diffguard_types::ToolMeta {
260 name: "diffguard".to_string(),
261 version: "0.1.0".to_string(),
262 },
263 diff: diffguard_types::DiffMeta {
264 base: "feature/branch".to_string(),
265 head: "HEAD".to_string(),
266 context_lines: 3,
267 scope: diffguard_types::Scope::Changed,
268 files_scanned: 2,
269 lines_scanned: 35,
270 },
271 findings: vec![Finding {
272 rule_id: "js.no_console".to_string(),
273 severity: diffguard_types::Severity::Warn,
274 message: "Remove console.log before merging.".to_string(),
275 path: "src/utils.ts".to_string(),
276 line: 42,
277 column: Some(3),
278 match_text: "console.log".to_string(),
279 snippet: " console.log(\"debug info\");".to_string(),
280 }],
281 verdict: diffguard_types::Verdict {
282 status: VerdictStatus::Warn,
283 counts: diffguard_types::VerdictCounts {
284 info: 0,
285 warn: 1,
286 error: 0,
287 ..Default::default()
288 },
289 reasons: vec![],
290 },
291 timing: None,
292 }
293 }
294
295 #[test]
298 fn snapshot_markdown_with_findings() {
299 let receipt = create_test_receipt_with_findings();
300 let md = render_markdown_for_receipt(&receipt);
301 insta::assert_snapshot!(md);
302 }
303
304 #[test]
307 fn snapshot_markdown_no_findings() {
308 let receipt = create_test_receipt_empty();
309 let md = render_markdown_for_receipt(&receipt);
310 insta::assert_snapshot!(md);
311 }
312
313 #[test]
316 fn snapshot_verdict_rendering() {
317 let receipt = create_test_receipt_warn_verdict();
318 let md = render_markdown_for_receipt(&receipt);
319 insta::assert_snapshot!(md);
320 }
321
322 fn create_test_receipt_with_suppressions() -> CheckReceipt {
324 CheckReceipt {
325 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
326 tool: diffguard_types::ToolMeta {
327 name: "diffguard".to_string(),
328 version: "0.1.0".to_string(),
329 },
330 diff: diffguard_types::DiffMeta {
331 base: "origin/main".to_string(),
332 head: "HEAD".to_string(),
333 context_lines: 0,
334 scope: diffguard_types::Scope::Added,
335 files_scanned: 2,
336 lines_scanned: 30,
337 },
338 findings: vec![Finding {
339 rule_id: "rust.no_dbg".to_string(),
340 severity: diffguard_types::Severity::Warn,
341 message: "Remove dbg!/println! before merging.".to_string(),
342 path: "src/main.rs".to_string(),
343 line: 10,
344 column: Some(5),
345 match_text: "dbg!".to_string(),
346 snippet: " dbg!(value);".to_string(),
347 }],
348 verdict: diffguard_types::Verdict {
349 status: VerdictStatus::Warn,
350 counts: diffguard_types::VerdictCounts {
351 info: 0,
352 warn: 1,
353 error: 0,
354 suppressed: 3,
355 },
356 reasons: vec![],
357 },
358 timing: None,
359 }
360 }
361
362 #[test]
364 fn markdown_shows_suppressed_count() {
365 let receipt = create_test_receipt_with_suppressions();
366 let md = render_markdown_for_receipt(&receipt);
367 assert!(md.contains("3 finding(s) suppressed via inline directives"));
368 }
369
370 #[test]
372 fn markdown_hides_suppressed_when_zero() {
373 let receipt = create_test_receipt_empty();
374 let md = render_markdown_for_receipt(&receipt);
375 assert!(!md.contains("suppressed"));
376 }
377
378 #[test]
380 fn snapshot_markdown_with_suppressions() {
381 let receipt = create_test_receipt_with_suppressions();
382 let md = render_markdown_for_receipt(&receipt);
383 insta::assert_snapshot!(md);
384 }
385
386 #[test]
388 fn markdown_filters_non_meta_reasons() {
389 let receipt = CheckReceipt {
390 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
391 tool: diffguard_types::ToolMeta {
392 name: "diffguard".to_string(),
393 version: "0.1.0".to_string(),
394 },
395 diff: diffguard_types::DiffMeta {
396 base: "origin/main".to_string(),
397 head: "HEAD".to_string(),
398 context_lines: 0,
399 scope: diffguard_types::Scope::Added,
400 files_scanned: 1,
401 lines_scanned: 1,
402 },
403 findings: vec![],
404 verdict: diffguard_types::Verdict {
405 status: VerdictStatus::Fail,
406 counts: diffguard_types::VerdictCounts {
407 info: 0,
408 warn: 0,
409 error: 1,
410 suppressed: 0,
411 },
412 reasons: vec![
413 diffguard_types::REASON_HAS_ERROR.to_string(),
414 diffguard_types::REASON_HAS_WARNING.to_string(),
415 "unknown_future_reason".to_string(),
416 ],
417 },
418 timing: None,
419 };
420
421 let md = render_markdown_for_receipt(&receipt);
422 assert!(
423 !md.contains("Verdict reasons"),
424 "non-meta reasons should not render"
425 );
426 assert!(!md.contains("has_error"));
427 assert!(!md.contains("has_warning"));
428 assert!(!md.contains("unknown_future_reason"));
429 }
430
431 #[test]
433 fn markdown_renders_all_meta_reasons() {
434 let receipt = CheckReceipt {
435 schema: diffguard_types::CHECK_SCHEMA_V1.to_string(),
436 tool: diffguard_types::ToolMeta {
437 name: "diffguard".to_string(),
438 version: "0.1.0".to_string(),
439 },
440 diff: diffguard_types::DiffMeta {
441 base: "origin/main".to_string(),
442 head: "HEAD".to_string(),
443 context_lines: 0,
444 scope: diffguard_types::Scope::Added,
445 files_scanned: 0,
446 lines_scanned: 0,
447 },
448 findings: vec![],
449 verdict: diffguard_types::Verdict {
450 status: VerdictStatus::Skip,
451 counts: diffguard_types::VerdictCounts::default(),
452 reasons: vec![
453 REASON_TRUNCATED.to_string(),
454 REASON_MISSING_BASE.to_string(),
455 REASON_NO_DIFF_INPUT.to_string(),
456 REASON_GIT_UNAVAILABLE.to_string(),
457 REASON_TOOL_ERROR.to_string(),
458 ],
459 },
460 timing: None,
461 };
462
463 let md = render_markdown_for_receipt(&receipt);
464 assert!(md.contains("Verdict reasons"), "meta reasons should render");
465 assert!(md.contains("- truncated"));
466 assert!(md.contains("- missing_base"));
467 assert!(md.contains("- no_diff_input"));
468 assert!(md.contains("- git_unavailable"));
469 assert!(md.contains("- tool_error"));
470 }
471}