Skip to main content

diffguard_core/
render.rs

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
6/// Reasons that are meaningful to render in markdown output.
7/// Only meta conditions (truncation, skip reasons, tool errors) should appear.
8const 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    /// Helper to create a test receipt with multiple findings
163    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    /// Helper to create a test receipt with no findings
225    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    /// Helper to create a test receipt for verdict rendering (WARN status)
256    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    /// Snapshot test for markdown output with findings.
296    /// Validates: Requirements 7.1, 7.2
297    #[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    /// Snapshot test for markdown output with no findings.
305    /// Validates: Requirements 7.1, 7.4
306    #[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    /// Snapshot test for verdict rendering (WARN status with reasons).
314    /// Validates: Requirements 7.1, 7.3
315    #[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    /// Helper to create a test receipt with suppressed findings
323    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 that suppressed findings are shown in markdown output.
363    #[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 that suppression note is not shown when count is zero.
371    #[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    /// Snapshot test for markdown output with suppressed findings.
379    #[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 that non-meta reasons (e.g. has_error, has_warning) are filtered out.
387    #[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 that all 5 meta reasons pass through the filter.
432    #[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}