1use diffguard_types::{CheckReceipt, Finding};
7
8const CSV_HEADER: &str = "file,line,rule_id,severity,message,snippet";
10
11pub fn render_csv_for_receipt(receipt: &CheckReceipt) -> String {
15 let mut out = String::new();
16
17 out.push_str(CSV_HEADER);
19 out.push('\n');
20
21 for f in &receipt.findings {
23 out.push_str(&render_csv_row(f));
24 }
25
26 out
27}
28
29pub fn render_tsv_for_receipt(receipt: &CheckReceipt) -> String {
33 let mut out = String::new();
34
35 out.push_str(&CSV_HEADER.replace(',', "\t"));
37 out.push('\n');
38
39 for f in &receipt.findings {
41 out.push_str(&render_tsv_row(f));
42 }
43
44 out
45}
46
47fn 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
60fn 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
73fn 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
88fn 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 #[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 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 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 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 assert!(csv.contains("\"let s = \"\"hello"));
273 }
274
275 #[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 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 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 assert!(tsv.contains("hello\\nworld"));
316 }
317
318 #[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 #[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}