Skip to main content

diffguard_core/
csv.rs

1//! CSV and TSV output renderers.
2//!
3//! Converts CheckReceipt to CSV or TSV format for spreadsheet/data analysis.
4//! CSV follows RFC 4180 for proper escaping.
5
6use diffguard_types::{CheckReceipt, Finding};
7
8/// CSV header row.
9const CSV_HEADER: &str = "file,line,rule_id,severity,message,snippet";
10
11/// Renders a CheckReceipt as a CSV report (RFC 4180 compliant).
12///
13/// Columns: file, line, rule_id, severity, message, snippet
14pub fn render_csv_for_receipt(receipt: &CheckReceipt) -> String {
15    let mut out = String::new();
16
17    // Header row
18    out.push_str(CSV_HEADER);
19    out.push('\n');
20
21    // Data rows
22    for f in &receipt.findings {
23        out.push_str(&render_csv_row(f));
24    }
25
26    out
27}
28
29/// Renders a CheckReceipt as a TSV report.
30///
31/// Columns: file, line, rule_id, severity, message, snippet
32pub fn render_tsv_for_receipt(receipt: &CheckReceipt) -> String {
33    let mut out = String::new();
34
35    // Header row
36    out.push_str(&CSV_HEADER.replace(',', "\t"));
37    out.push('\n');
38
39    // Data rows
40    for f in &receipt.findings {
41        out.push_str(&render_tsv_row(f));
42    }
43
44    out
45}
46
47/// Renders a single finding as a CSV row.
48fn render_csv_row(f: &Finding) -> String {
49    format!(
50        "{},{},{},{},{},{}\n",
51        escape_csv_field(&f.path),
52        f.line,
53        escape_csv_field(&f.rule_id),
54        f.severity.as_str(),
55        escape_csv_field(&f.message),
56        escape_csv_field(&f.snippet)
57    )
58}
59
60/// Renders a single finding as a TSV row.
61fn render_tsv_row(f: &Finding) -> String {
62    format!(
63        "{}\t{}\t{}\t{}\t{}\t{}\n",
64        escape_tsv_field(&f.path),
65        f.line,
66        escape_tsv_field(&f.rule_id),
67        f.severity.as_str(),
68        escape_tsv_field(&f.message),
69        escape_tsv_field(&f.snippet)
70    )
71}
72
73/// Escapes a field for CSV according to RFC 4180.
74///
75/// Fields containing commas, double quotes, or newlines are quoted.
76/// Double quotes within the field are escaped by doubling them.
77fn escape_csv_field(s: &str) -> String {
78    let needs_quoting = s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r');
79
80    if needs_quoting {
81        let escaped = s.replace('"', "\"\"");
82        format!("\"{}\"", escaped)
83    } else {
84        s.to_string()
85    }
86}
87
88/// Escapes a field for TSV.
89///
90/// Tabs and newlines are escaped with backslash notation.
91fn escape_tsv_field(s: &str) -> String {
92    s.replace('\\', "\\\\")
93        .replace('\t', "\\t")
94        .replace('\n', "\\n")
95        .replace('\r', "\\r")
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use diffguard_types::{
102        CHECK_SCHEMA_V1, CheckReceipt, DiffMeta, Finding, Scope, Severity, ToolMeta, Verdict,
103        VerdictCounts, VerdictStatus,
104    };
105
106    fn create_test_receipt_with_findings() -> CheckReceipt {
107        CheckReceipt {
108            schema: CHECK_SCHEMA_V1.to_string(),
109            tool: ToolMeta {
110                name: "diffguard".to_string(),
111                version: "0.1.0".to_string(),
112            },
113            diff: DiffMeta {
114                base: "origin/main".to_string(),
115                head: "HEAD".to_string(),
116                context_lines: 0,
117                scope: Scope::Added,
118                files_scanned: 3,
119                lines_scanned: 42,
120            },
121            findings: vec![
122                Finding {
123                    rule_id: "rust.no_unwrap".to_string(),
124                    severity: Severity::Error,
125                    message: "Avoid unwrap/expect in production code.".to_string(),
126                    path: "src/lib.rs".to_string(),
127                    line: 15,
128                    column: Some(10),
129                    match_text: ".unwrap()".to_string(),
130                    snippet: "let value = result.unwrap();".to_string(),
131                },
132                Finding {
133                    rule_id: "rust.no_dbg".to_string(),
134                    severity: Severity::Warn,
135                    message: "Remove dbg!/println! before merging.".to_string(),
136                    path: "src/main.rs".to_string(),
137                    line: 23,
138                    column: Some(5),
139                    match_text: "dbg!".to_string(),
140                    snippet: "    dbg!(config);".to_string(),
141                },
142            ],
143            verdict: Verdict {
144                status: VerdictStatus::Fail,
145                counts: VerdictCounts {
146                    info: 0,
147                    warn: 1,
148                    error: 1,
149                    ..Default::default()
150                },
151                reasons: vec![
152                    "1 error-level finding".to_string(),
153                    "1 warning-level finding".to_string(),
154                ],
155            },
156            timing: None,
157        }
158    }
159
160    fn create_test_receipt_empty() -> CheckReceipt {
161        CheckReceipt {
162            schema: CHECK_SCHEMA_V1.to_string(),
163            tool: ToolMeta {
164                name: "diffguard".to_string(),
165                version: "0.1.0".to_string(),
166            },
167            diff: DiffMeta {
168                base: "origin/main".to_string(),
169                head: "HEAD".to_string(),
170                context_lines: 0,
171                scope: Scope::Added,
172                files_scanned: 5,
173                lines_scanned: 120,
174            },
175            findings: vec![],
176            verdict: Verdict {
177                status: VerdictStatus::Pass,
178                counts: VerdictCounts::default(),
179                reasons: vec![],
180            },
181            timing: None,
182        }
183    }
184
185    fn create_test_receipt_with_special_chars() -> CheckReceipt {
186        CheckReceipt {
187            schema: CHECK_SCHEMA_V1.to_string(),
188            tool: ToolMeta {
189                name: "diffguard".to_string(),
190                version: "0.1.0".to_string(),
191            },
192            diff: DiffMeta {
193                base: "origin/main".to_string(),
194                head: "HEAD".to_string(),
195                context_lines: 0,
196                scope: Scope::Added,
197                files_scanned: 1,
198                lines_scanned: 10,
199            },
200            findings: vec![Finding {
201                rule_id: "test.rule".to_string(),
202                severity: Severity::Warn,
203                message: "Message with \"quotes\" and, commas".to_string(),
204                path: "src/file.rs".to_string(),
205                line: 5,
206                column: None,
207                match_text: "test".to_string(),
208                snippet: "let s = \"hello\nworld\";".to_string(),
209            }],
210            verdict: Verdict {
211                status: VerdictStatus::Warn,
212                counts: VerdictCounts {
213                    info: 0,
214                    warn: 1,
215                    error: 0,
216                    ..Default::default()
217                },
218                reasons: vec!["1 warning".to_string()],
219            },
220            timing: None,
221        }
222    }
223
224    // ==================== CSV Tests ====================
225
226    #[test]
227    fn csv_has_header_row() {
228        let receipt = create_test_receipt_empty();
229        let csv = render_csv_for_receipt(&receipt);
230        assert!(csv.starts_with("file,line,rule_id,severity,message,snippet\n"));
231    }
232
233    #[test]
234    fn csv_has_correct_row_count() {
235        let receipt = create_test_receipt_with_findings();
236        let csv = render_csv_for_receipt(&receipt);
237        let lines: Vec<&str> = csv.lines().collect();
238        // 1 header + 2 data rows
239        assert_eq!(lines.len(), 3);
240    }
241
242    #[test]
243    fn csv_empty_receipt_has_header_only() {
244        let receipt = create_test_receipt_empty();
245        let csv = render_csv_for_receipt(&receipt);
246        let lines: Vec<&str> = csv.lines().collect();
247        assert_eq!(lines.len(), 1);
248        assert_eq!(lines[0], "file,line,rule_id,severity,message,snippet");
249    }
250
251    #[test]
252    fn csv_escapes_quotes() {
253        let receipt = create_test_receipt_with_special_chars();
254        let csv = render_csv_for_receipt(&receipt);
255        // Quotes in message should be escaped as ""
256        assert!(csv.contains("\"\"quotes\"\""));
257    }
258
259    #[test]
260    fn csv_escapes_commas() {
261        let receipt = create_test_receipt_with_special_chars();
262        let csv = render_csv_for_receipt(&receipt);
263        // Field with comma should be quoted
264        assert!(csv.contains("\"Message with"));
265    }
266
267    #[test]
268    fn csv_escapes_newlines() {
269        let receipt = create_test_receipt_with_special_chars();
270        let csv = render_csv_for_receipt(&receipt);
271        // Snippet with newline should be quoted
272        assert!(csv.contains("\"let s = \"\"hello"));
273    }
274
275    // ==================== TSV Tests ====================
276
277    #[test]
278    fn tsv_has_header_row() {
279        let receipt = create_test_receipt_empty();
280        let tsv = render_tsv_for_receipt(&receipt);
281        assert!(tsv.starts_with("file\tline\trule_id\tseverity\tmessage\tsnippet\n"));
282    }
283
284    #[test]
285    fn tsv_has_correct_row_count() {
286        let receipt = create_test_receipt_with_findings();
287        let tsv = render_tsv_for_receipt(&receipt);
288        let lines: Vec<&str> = tsv.lines().collect();
289        // 1 header + 2 data rows
290        assert_eq!(lines.len(), 3);
291    }
292
293    #[test]
294    fn tsv_empty_receipt_has_header_only() {
295        let receipt = create_test_receipt_empty();
296        let tsv = render_tsv_for_receipt(&receipt);
297        let lines: Vec<&str> = tsv.lines().collect();
298        assert_eq!(lines.len(), 1);
299    }
300
301    #[test]
302    fn tsv_escapes_tabs() {
303        let mut receipt = create_test_receipt_with_findings();
304        receipt.findings[0].snippet = "let\tx = 1;".to_string();
305        let tsv = render_tsv_for_receipt(&receipt);
306        // Tab in snippet should be escaped
307        assert!(tsv.contains("let\\tx = 1;"));
308    }
309
310    #[test]
311    fn tsv_escapes_newlines() {
312        let receipt = create_test_receipt_with_special_chars();
313        let tsv = render_tsv_for_receipt(&receipt);
314        // Newline in snippet should be escaped as \n
315        assert!(tsv.contains("hello\\nworld"));
316    }
317
318    // ==================== Field Escaping Tests ====================
319
320    #[test]
321    fn escape_csv_field_plain_text() {
322        assert_eq!(escape_csv_field("plain text"), "plain text");
323    }
324
325    #[test]
326    fn escape_csv_field_with_comma() {
327        assert_eq!(escape_csv_field("a,b"), "\"a,b\"");
328    }
329
330    #[test]
331    fn escape_csv_field_with_quote() {
332        assert_eq!(escape_csv_field("say \"hello\""), "\"say \"\"hello\"\"\"");
333    }
334
335    #[test]
336    fn escape_csv_field_with_newline() {
337        assert_eq!(escape_csv_field("line1\nline2"), "\"line1\nline2\"");
338    }
339
340    #[test]
341    fn escape_csv_field_with_carriage_return() {
342        assert_eq!(escape_csv_field("line1\rline2"), "\"line1\rline2\"");
343    }
344
345    #[test]
346    fn escape_tsv_field_plain_text() {
347        assert_eq!(escape_tsv_field("plain text"), "plain text");
348    }
349
350    #[test]
351    fn escape_tsv_field_with_tab() {
352        assert_eq!(escape_tsv_field("a\tb"), "a\\tb");
353    }
354
355    #[test]
356    fn escape_tsv_field_with_newline() {
357        assert_eq!(escape_tsv_field("a\nb"), "a\\nb");
358    }
359
360    #[test]
361    fn escape_tsv_field_with_carriage_return() {
362        assert_eq!(escape_tsv_field("a\rb"), "a\\rb");
363    }
364
365    #[test]
366    fn escape_tsv_field_with_backslash() {
367        assert_eq!(escape_tsv_field("a\\b"), "a\\\\b");
368    }
369
370    // ==================== Snapshot Tests ====================
371
372    #[test]
373    fn snapshot_csv_with_findings() {
374        let receipt = create_test_receipt_with_findings();
375        let csv = render_csv_for_receipt(&receipt);
376        insta::assert_snapshot!(csv);
377    }
378
379    #[test]
380    fn snapshot_csv_no_findings() {
381        let receipt = create_test_receipt_empty();
382        let csv = render_csv_for_receipt(&receipt);
383        insta::assert_snapshot!(csv);
384    }
385
386    #[test]
387    fn snapshot_tsv_with_findings() {
388        let receipt = create_test_receipt_with_findings();
389        let tsv = render_tsv_for_receipt(&receipt);
390        insta::assert_snapshot!(tsv);
391    }
392
393    #[test]
394    fn snapshot_tsv_no_findings() {
395        let receipt = create_test_receipt_empty();
396        let tsv = render_tsv_for_receipt(&receipt);
397        insta::assert_snapshot!(tsv);
398    }
399
400    #[test]
401    fn snapshot_csv_special_chars() {
402        let receipt = create_test_receipt_with_special_chars();
403        let csv = render_csv_for_receipt(&receipt);
404        insta::assert_snapshot!(csv);
405    }
406
407    #[test]
408    fn snapshot_tsv_special_chars() {
409        let receipt = create_test_receipt_with_special_chars();
410        let tsv = render_tsv_for_receipt(&receipt);
411        insta::assert_snapshot!(tsv);
412    }
413}