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