1use std::io::{self, Write};
24use std::path::Path;
25
26use crate::output::funcspace_row::{IDENTITY_COLUMNS, METRIC_COUNT, metric_values};
27use crate::output::numfmt::CellMetric;
28use crate::spaces::FuncSpace;
29
30const _: () = assert!(IDENTITY_COLUMNS + METRIC_COUNT == CSV_HEADER.len());
33
34pub const CSV_EXTENSION: &str = ".csv";
36
37pub const CSV_HEADER: &[&str] = &[
42 "path",
44 "space_name",
45 "space_kind",
46 "start_line",
47 "end_line",
48 "cognitive.sum",
50 "cognitive.average",
51 "cognitive.min",
52 "cognitive.max",
53 "cyclomatic.sum",
55 "cyclomatic.average",
56 "cyclomatic.min",
57 "cyclomatic.max",
58 "cyclomatic.modified.sum",
59 "cyclomatic.modified.average",
60 "cyclomatic.modified.min",
61 "cyclomatic.modified.max",
62 "halstead.unique_operators",
64 "halstead.total_operators",
65 "halstead.unique_operands",
66 "halstead.total_operands",
67 "halstead.length",
68 "halstead.estimated_program_length",
69 "halstead.purity_ratio",
70 "halstead.vocabulary",
71 "halstead.volume",
72 "halstead.difficulty",
73 "halstead.level",
74 "halstead.effort",
75 "halstead.time",
76 "halstead.bugs",
77 "loc.sloc",
79 "loc.ploc",
80 "loc.lloc",
81 "loc.cloc",
82 "loc.blank",
83 "loc.sloc_average",
84 "loc.ploc_average",
85 "loc.lloc_average",
86 "loc.cloc_average",
87 "loc.blank_average",
88 "loc.sloc_min",
89 "loc.sloc_max",
90 "loc.cloc_min",
91 "loc.cloc_max",
92 "loc.ploc_min",
93 "loc.ploc_max",
94 "loc.lloc_min",
95 "loc.lloc_max",
96 "loc.blank_min",
97 "loc.blank_max",
98 "nom.functions",
100 "nom.closures",
101 "nom.functions_average",
102 "nom.closures_average",
103 "nom.total",
104 "nom.average",
105 "nom.functions_min",
106 "nom.functions_max",
107 "nom.closures_min",
108 "nom.closures_max",
109 "nargs.function_args",
111 "nargs.closure_args",
112 "nargs.function_args_average",
113 "nargs.closure_args_average",
114 "nargs.total",
115 "nargs.average",
116 "nargs.function_args_min",
117 "nargs.function_args_max",
118 "nargs.closure_args_min",
119 "nargs.closure_args_max",
120 "nexits.sum",
122 "nexits.average",
123 "nexits.min",
124 "nexits.max",
125 "tokens.sum",
127 "tokens.average",
128 "tokens.min",
129 "tokens.max",
130 "abc.assignments",
132 "abc.branches",
133 "abc.conditions",
134 "abc.magnitude",
135 "abc.assignments_average",
136 "abc.branches_average",
137 "abc.conditions_average",
138 "abc.assignments_min",
139 "abc.assignments_max",
140 "abc.branches_min",
141 "abc.branches_max",
142 "abc.conditions_min",
143 "abc.conditions_max",
144 "wmc.class_wmc_sum",
146 "wmc.interface_wmc_sum",
147 "wmc.total",
148 "npm.class_npm_sum",
150 "npm.interface_npm_sum",
151 "npm.class_methods",
152 "npm.interface_methods",
153 "npm.class_coa",
154 "npm.interface_coa",
155 "npm.total",
156 "npm.total_methods",
157 "npm.coa",
158 "npa.class_npa_sum",
160 "npa.interface_npa_sum",
161 "npa.class_attributes",
162 "npa.interface_attributes",
163 "npa.class_cda",
164 "npa.interface_cda",
165 "npa.total",
166 "npa.total_attributes",
167 "npa.cda",
168 "mi.original",
170 "mi.sei",
171 "mi.visual_studio",
172];
173
174pub fn write_csv<W: Write>(space: &FuncSpace, source_path: &Path, writer: W) -> io::Result<()> {
186 let mut wtr = csv::WriterBuilder::new()
187 .has_headers(false) .from_writer(writer);
189
190 wtr.write_record(CSV_HEADER).map_err(csv_err)?;
191
192 let Some(path_str) = source_path.to_str() else {
193 eprintln!(
194 "Warning: skipping non-UTF-8 source path in CSV output: {}",
195 source_path.display()
196 );
197 return wtr.flush();
198 };
199
200 write_space_rows(&mut wtr, path_str, space)?;
201 wtr.flush()
202}
203
204pub fn write_csv_aggregate<'a, W, I>(spaces: I, writer: W) -> io::Result<()>
218where
219 W: Write,
220 I: IntoIterator<Item = (&'a FuncSpace, &'a Path)>,
221{
222 let mut wtr = csv::WriterBuilder::new()
223 .has_headers(false)
224 .from_writer(writer);
225 wtr.write_record(CSV_HEADER).map_err(csv_err)?;
226 for (space, source_path) in spaces {
227 let Some(path_str) = source_path.to_str() else {
228 eprintln!(
229 "Warning: skipping non-UTF-8 source path in CSV output: {}",
230 source_path.display()
231 );
232 continue;
233 };
234 write_space_rows(&mut wtr, path_str, space)?;
235 }
236 wtr.flush()
237}
238
239fn write_space_rows<W: Write>(
240 wtr: &mut csv::Writer<W>,
241 path_str: &str,
242 space: &FuncSpace,
243) -> io::Result<()> {
244 write_one_row(wtr, path_str, space)?;
245 for child in &space.spaces {
246 write_space_rows(wtr, path_str, child)?;
247 }
248 Ok(())
249}
250
251fn write_one_row<W: Write>(
252 wtr: &mut csv::Writer<W>,
253 path_str: &str,
254 space: &FuncSpace,
255) -> io::Result<()> {
256 let metrics = metric_values(space);
257
258 let mut row: Vec<String> = Vec::with_capacity(CSV_HEADER.len());
259 row.push(defang_formula(path_str));
275 row.push(defang_formula(space.name.as_deref().unwrap_or("")));
276 row.push(space.kind.to_string());
277 row.push(space.start_line.to_string());
278 row.push(space.end_line.to_string());
279
280 for v in metrics {
281 row.push(CellMetric(v).to_string());
282 }
283
284 wtr.write_record(&row).map_err(csv_err)
285}
286
287const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
295
296#[must_use]
313pub fn defang_formula(cell: &str) -> String {
314 if cell.starts_with(FORMULA_TRIGGERS) {
315 let mut out = String::with_capacity(cell.len() + 1);
316 out.push('\'');
317 out.push_str(cell);
318 out
319 } else {
320 cell.to_owned()
321 }
322}
323
324fn csv_err(e: csv::Error) -> io::Error {
325 match e.into_kind() {
332 csv::ErrorKind::Io(io_err) => io_err,
333 other => io::Error::new(io::ErrorKind::InvalidData, format!("{other:?}")),
334 }
335}
336
337#[cfg(test)]
338#[allow(
339 clippy::float_cmp,
340 clippy::cast_precision_loss,
341 clippy::cast_possible_truncation,
342 clippy::cast_sign_loss,
343 clippy::similar_names,
344 clippy::doc_markdown,
345 clippy::needless_raw_string_hashes,
346 clippy::too_many_lines
347)]
348mod tests {
349 use super::*;
350 use crate::spaces::{CodeMetrics, SpaceKind};
351
352 fn empty_space(name: &str, kind: SpaceKind, start: usize, end: usize) -> FuncSpace {
353 FuncSpace {
354 name: Some(name.into()),
355 start_line: start,
356 end_line: end,
357 kind,
358 spaces: Vec::new(),
359 metrics: CodeMetrics::default(),
360 suppressed: crate::SuppressionScope::default(),
361 }
362 }
363
364 fn render(space: &FuncSpace, path: &Path) -> String {
365 let mut buf = Vec::new();
366 write_csv(space, path, &mut buf).expect("writing to Vec is infallible");
367 String::from_utf8(buf).expect("output is UTF-8")
368 }
369
370 #[test]
371 fn header_constant_matches_first_row() {
372 let space = empty_space("root", SpaceKind::Unit, 1, 1);
373 let out = render(&space, Path::new("a.rs"));
374 let first = out.lines().next().expect("at least the header row");
375 let expected: Vec<&str> = CSV_HEADER.to_vec();
376 let got: Vec<&str> = first.split(',').collect();
377 assert_eq!(got, expected);
378 }
379
380 #[test]
381 fn aggregate_emits_exactly_one_shared_header() {
382 let a = empty_space("a", SpaceKind::Unit, 1, 1);
387 let b = empty_space("b", SpaceKind::Unit, 1, 1);
388 let c = empty_space("c", SpaceKind::Unit, 1, 1);
389 let spaces: Vec<(FuncSpace, &Path)> = vec![
390 (a, Path::new("a.rs")),
391 (b, Path::new("b.rs")),
392 (c, Path::new("c.rs")),
393 ];
394 let mut buf = Vec::new();
395 write_csv_aggregate(spaces.iter().map(|(s, p)| (s, *p)), &mut buf)
396 .expect("writing to Vec is infallible");
397 let out = String::from_utf8(buf).expect("output is UTF-8");
398
399 let header_line = CSV_HEADER.join(",");
400 let header_count = out.lines().filter(|l| *l == header_line).count();
401 assert_eq!(header_count, 1, "exactly one header row, got:\n{out}");
402 assert_eq!(out.lines().count(), 4, "header + 3 data rows:\n{out}");
404 assert!(
405 out.lines().next() == Some(header_line.as_str()),
406 "header first"
407 );
408 }
409
410 #[test]
411 fn header_constant_matches_documented_columns() {
412 let documented: &[&str] = &[
418 "path",
419 "space_name",
420 "space_kind",
421 "start_line",
422 "end_line",
423 "cognitive.sum",
424 "cognitive.average",
425 "cognitive.min",
426 "cognitive.max",
427 "cyclomatic.sum",
428 "cyclomatic.average",
429 "cyclomatic.min",
430 "cyclomatic.max",
431 "cyclomatic.modified.sum",
432 "cyclomatic.modified.average",
433 "cyclomatic.modified.min",
434 "cyclomatic.modified.max",
435 "halstead.unique_operators",
436 "halstead.total_operators",
437 "halstead.unique_operands",
438 "halstead.total_operands",
439 "halstead.length",
440 "halstead.estimated_program_length",
441 "halstead.purity_ratio",
442 "halstead.vocabulary",
443 "halstead.volume",
444 "halstead.difficulty",
445 "halstead.level",
446 "halstead.effort",
447 "halstead.time",
448 "halstead.bugs",
449 "loc.sloc",
450 "loc.ploc",
451 "loc.lloc",
452 "loc.cloc",
453 "loc.blank",
454 "loc.sloc_average",
455 "loc.ploc_average",
456 "loc.lloc_average",
457 "loc.cloc_average",
458 "loc.blank_average",
459 "loc.sloc_min",
460 "loc.sloc_max",
461 "loc.cloc_min",
462 "loc.cloc_max",
463 "loc.ploc_min",
464 "loc.ploc_max",
465 "loc.lloc_min",
466 "loc.lloc_max",
467 "loc.blank_min",
468 "loc.blank_max",
469 "nom.functions",
470 "nom.closures",
471 "nom.functions_average",
472 "nom.closures_average",
473 "nom.total",
474 "nom.average",
475 "nom.functions_min",
476 "nom.functions_max",
477 "nom.closures_min",
478 "nom.closures_max",
479 "nargs.function_args",
480 "nargs.closure_args",
481 "nargs.function_args_average",
482 "nargs.closure_args_average",
483 "nargs.total",
484 "nargs.average",
485 "nargs.function_args_min",
486 "nargs.function_args_max",
487 "nargs.closure_args_min",
488 "nargs.closure_args_max",
489 "nexits.sum",
490 "nexits.average",
491 "nexits.min",
492 "nexits.max",
493 "tokens.sum",
494 "tokens.average",
495 "tokens.min",
496 "tokens.max",
497 "abc.assignments",
498 "abc.branches",
499 "abc.conditions",
500 "abc.magnitude",
501 "abc.assignments_average",
502 "abc.branches_average",
503 "abc.conditions_average",
504 "abc.assignments_min",
505 "abc.assignments_max",
506 "abc.branches_min",
507 "abc.branches_max",
508 "abc.conditions_min",
509 "abc.conditions_max",
510 "wmc.class_wmc_sum",
511 "wmc.interface_wmc_sum",
512 "wmc.total",
513 "npm.class_npm_sum",
514 "npm.interface_npm_sum",
515 "npm.class_methods",
516 "npm.interface_methods",
517 "npm.class_coa",
518 "npm.interface_coa",
519 "npm.total",
520 "npm.total_methods",
521 "npm.coa",
522 "npa.class_npa_sum",
523 "npa.interface_npa_sum",
524 "npa.class_attributes",
525 "npa.interface_attributes",
526 "npa.class_cda",
527 "npa.interface_cda",
528 "npa.total",
529 "npa.total_attributes",
530 "npa.cda",
531 "mi.original",
532 "mi.sei",
533 "mi.visual_studio",
534 ];
535 assert_eq!(CSV_HEADER, documented);
536 }
537
538 #[test]
539 fn non_finite_metric_values_never_leak() {
540 let space = empty_space("root", SpaceKind::Unit, 1, 1);
548 let out = render(&space, Path::new("a.rs"));
549 assert!(
550 !out.contains("NaN"),
551 "NaN must not leak into CSV output:\n{out}"
552 );
553 assert!(
554 !out.contains("inf"),
555 "infinity must not leak into CSV output:\n{out}"
556 );
557 }
558
559 #[test]
560 fn nested_spaces_flatten_depth_first() {
561 let mut root = empty_space("root", SpaceKind::Unit, 1, 100);
562 let mut outer = empty_space("outer", SpaceKind::Function, 10, 50);
563 let inner = empty_space("inner", SpaceKind::Function, 20, 30);
564 outer.spaces.push(inner);
565 let sibling = empty_space("sibling", SpaceKind::Function, 60, 80);
566 root.spaces.push(outer);
567 root.spaces.push(sibling);
568
569 let out = render(&root, Path::new("a.rs"));
570 let names: Vec<&str> = out
571 .lines()
572 .skip(1) .map(|line| line.split(',').nth(1).unwrap_or(""))
574 .collect();
575 assert_eq!(names, vec!["root", "outer", "inner", "sibling"]);
576 }
577
578 #[test]
579 fn rfc_4180_quoting_handled_by_csv_crate() {
580 let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
583 let out = render(&space, Path::new("p.rs"));
584 assert!(
586 out.contains(
587 r#""a,b""c
588d""#
589 ),
590 "expected RFC 4180 quoting in:\n{out}"
591 );
592 }
593
594 #[test]
595 fn formula_injection_cell_is_defanged() {
596 let space = empty_space("=cmd|'/C calc'!A0", SpaceKind::Function, 1, 1);
602 let out = render(&space, Path::new("p.rs"));
603 let data_row = out.lines().nth(1).expect("data row");
604 let name_cell = data_row.split(',').next().unwrap_or("");
605 assert!(
608 out.contains("'=cmd|"),
609 "formula-trigger name must be prefixed with a quote:\n{out}"
610 );
611 assert!(
612 !data_row.starts_with("p.rs,=cmd"),
613 "the un-defanged `=cmd` must not appear unquoted:\n{out}"
614 );
615 let _ = name_cell;
616 }
617
618 #[test]
619 fn formula_injection_path_cell_is_defanged() {
620 let space = empty_space("f", SpaceKind::Function, 1, 1);
624 let out = render(&space, Path::new("=cmd|'/C calc'!A0.rs"));
625 assert!(
626 out.contains("'=cmd|"),
627 "formula-trigger path must be prefixed with a quote:\n{out}"
628 );
629 let data_row = out.lines().nth(1).expect("data row");
630 assert!(
631 !data_row.starts_with("=cmd"),
632 "the un-defanged path `=cmd` must not lead the row:\n{out}"
633 );
634 }
635
636 #[test]
637 fn defang_formula_guards_every_trigger_but_leaves_plain_cells() {
638 for trigger in ['=', '+', '-', '@', '\t', '\r'] {
641 let cell = format!("{trigger}danger");
642 let out = defang_formula(&cell);
643 assert_eq!(out, format!("'{cell}"), "trigger {trigger:?} must defang");
644 }
645 assert_eq!(defang_formula("="), "'=");
647 assert_eq!(defang_formula("compute"), "compute");
649 assert_eq!(defang_formula(""), "");
650 assert_eq!(defang_formula("a=b"), "a=b");
652 assert_eq!(defang_formula("café"), "café");
654 }
655
656 #[test]
657 fn non_utf8_path_skips_data_rows() {
658 #[cfg(unix)]
659 {
660 use std::ffi::OsStr;
661 use std::os::unix::ffi::OsStrExt;
662 use std::path::PathBuf;
663
664 let bad = PathBuf::from(OsStr::from_bytes(b"\xff\xfe.rs"));
665 let space = empty_space("root", SpaceKind::Unit, 1, 1);
666 let out = render(&space, &bad);
667 assert_eq!(
668 out.lines().count(),
669 1,
670 "header should be the only line, got:\n{out}"
671 );
672 }
673 }
674
675 #[test]
676 fn integral_values_have_no_trailing_dot_zero() {
677 let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
680 space.metrics.loc.init_unit_span(0, 42);
683 let out = render(&space, Path::new("a.rs"));
684 let row = out.lines().nth(1).expect("data row");
685 let cells: Vec<&str> = row.split(',').collect();
686 let sloc_idx = CSV_HEADER
688 .iter()
689 .position(|h| *h == "loc.sloc")
690 .expect("loc.sloc in header");
691 assert_eq!(cells[sloc_idx], "42", "row was: {row}");
692 }
693}