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 "loc.dloc",
178 "loc.dloc_average",
179 "loc.dloc_min",
180 "loc.dloc_max",
181];
182
183pub fn write_csv<W: Write>(space: &FuncSpace, source_path: &Path, writer: W) -> io::Result<()> {
195 let mut wtr = csv::WriterBuilder::new()
196 .has_headers(false) .from_writer(writer);
198
199 wtr.write_record(CSV_HEADER).map_err(csv_err)?;
200
201 let Some(path_str) = warn_non_utf8_path("CSV", source_path) else {
202 return wtr.flush();
203 };
204
205 write_space_rows(&mut wtr, path_str, space)?;
206 wtr.flush()
207}
208
209pub fn write_csv_aggregate<'a, W, I>(spaces: I, writer: W) -> io::Result<()>
223where
224 W: Write,
225 I: IntoIterator<Item = (&'a FuncSpace, &'a Path)>,
226{
227 let mut wtr = csv::WriterBuilder::new()
228 .has_headers(false)
229 .from_writer(writer);
230 wtr.write_record(CSV_HEADER).map_err(csv_err)?;
231 for (space, source_path) in spaces {
232 let Some(path_str) = warn_non_utf8_path("CSV", source_path) else {
233 continue;
234 };
235 write_space_rows(&mut wtr, path_str, space)?;
236 }
237 wtr.flush()
238}
239
240fn write_space_rows<W: Write>(
241 wtr: &mut csv::Writer<W>,
242 path_str: &str,
243 space: &FuncSpace,
244) -> io::Result<()> {
245 write_one_row(wtr, path_str, space)?;
246 for child in &space.spaces {
247 write_space_rows(wtr, path_str, child)?;
248 }
249 Ok(())
250}
251
252fn write_one_row<W: Write>(
253 wtr: &mut csv::Writer<W>,
254 path_str: &str,
255 space: &FuncSpace,
256) -> io::Result<()> {
257 let metrics = metric_values(space);
258
259 let mut row: Vec<String> = Vec::with_capacity(CSV_HEADER.len());
260 row.push(defang_formula(path_str));
276 row.push(defang_formula(space.name.as_deref().unwrap_or("")));
277 row.push(space.kind.to_string());
278 row.push(space.start_line.to_string());
279 row.push(space.end_line.to_string());
280
281 for v in metrics {
282 row.push(CellMetric(v).to_string());
283 }
284
285 wtr.write_record(&row).map_err(csv_err)
286}
287
288const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
296
297#[must_use]
314pub fn defang_formula(cell: &str) -> String {
315 if cell.starts_with(FORMULA_TRIGGERS) {
316 let mut out = String::with_capacity(cell.len() + 1);
317 out.push('\'');
318 out.push_str(cell);
319 out
320 } else {
321 cell.to_owned()
322 }
323}
324
325fn csv_err(e: csv::Error) -> io::Error {
326 match e.into_kind() {
333 csv::ErrorKind::Io(io_err) => io_err,
334 other => io::Error::new(io::ErrorKind::InvalidData, format!("{other:?}")),
335 }
336}
337
338#[cfg(test)]
339#[allow(
340 clippy::float_cmp,
341 clippy::cast_precision_loss,
342 clippy::cast_possible_truncation,
343 clippy::cast_sign_loss,
344 clippy::similar_names,
345 clippy::doc_markdown,
346 clippy::needless_raw_string_hashes,
347 clippy::too_many_lines
348)]
349mod tests {
350 use super::*;
351 use crate::spaces::{CodeMetrics, SpaceKind};
352
353 fn empty_space(name: &str, kind: SpaceKind, start: usize, end: usize) -> FuncSpace {
354 FuncSpace {
355 name: Some(name.into()),
356 start_line: start,
357 end_line: end,
358 kind,
359 spaces: Vec::new(),
360 metrics: CodeMetrics::default(),
361 suppressed: crate::SuppressionScope::default(),
362 }
363 }
364
365 fn render(space: &FuncSpace, path: &Path) -> String {
366 let mut buf = Vec::new();
367 write_csv(space, path, &mut buf).expect("writing to Vec is infallible");
368 String::from_utf8(buf).expect("output is UTF-8")
369 }
370
371 #[test]
372 fn header_constant_matches_first_row() {
373 let space = empty_space("root", SpaceKind::Unit, 1, 1);
374 let out = render(&space, Path::new("a.rs"));
375 let first = out.lines().next().expect("at least the header row");
376 let expected: Vec<&str> = CSV_HEADER.to_vec();
377 let got: Vec<&str> = first.split(',').collect();
378 assert_eq!(got, expected);
379 }
380
381 #[test]
382 fn aggregate_emits_exactly_one_shared_header() {
383 let a = empty_space("a", SpaceKind::Unit, 1, 1);
388 let b = empty_space("b", SpaceKind::Unit, 1, 1);
389 let c = empty_space("c", SpaceKind::Unit, 1, 1);
390 let spaces: Vec<(FuncSpace, &Path)> = vec![
391 (a, Path::new("a.rs")),
392 (b, Path::new("b.rs")),
393 (c, Path::new("c.rs")),
394 ];
395 let mut buf = Vec::new();
396 write_csv_aggregate(spaces.iter().map(|(s, p)| (s, *p)), &mut buf)
397 .expect("writing to Vec is infallible");
398 let out = String::from_utf8(buf).expect("output is UTF-8");
399
400 let header_line = CSV_HEADER.join(",");
401 let header_count = out.lines().filter(|l| *l == header_line).count();
402 assert_eq!(header_count, 1, "exactly one header row, got:\n{out}");
403 assert_eq!(out.lines().count(), 4, "header + 3 data rows:\n{out}");
405 assert!(
406 out.lines().next() == Some(header_line.as_str()),
407 "header first"
408 );
409 }
410
411 #[test]
412 fn header_constant_matches_documented_columns() {
413 let documented: &[&str] = &[
419 "path",
420 "space_name",
421 "space_kind",
422 "start_line",
423 "end_line",
424 "cognitive.sum",
425 "cognitive.average",
426 "cognitive.min",
427 "cognitive.max",
428 "cyclomatic.sum",
429 "cyclomatic.average",
430 "cyclomatic.min",
431 "cyclomatic.max",
432 "cyclomatic.modified.sum",
433 "cyclomatic.modified.average",
434 "cyclomatic.modified.min",
435 "cyclomatic.modified.max",
436 "halstead.unique_operators",
437 "halstead.total_operators",
438 "halstead.unique_operands",
439 "halstead.total_operands",
440 "halstead.length",
441 "halstead.estimated_program_length",
442 "halstead.purity_ratio",
443 "halstead.vocabulary",
444 "halstead.volume",
445 "halstead.difficulty",
446 "halstead.level",
447 "halstead.effort",
448 "halstead.time",
449 "halstead.bugs",
450 "loc.sloc",
451 "loc.ploc",
452 "loc.lloc",
453 "loc.cloc",
454 "loc.blank",
455 "loc.sloc_average",
456 "loc.ploc_average",
457 "loc.lloc_average",
458 "loc.cloc_average",
459 "loc.blank_average",
460 "loc.sloc_min",
461 "loc.sloc_max",
462 "loc.cloc_min",
463 "loc.cloc_max",
464 "loc.ploc_min",
465 "loc.ploc_max",
466 "loc.lloc_min",
467 "loc.lloc_max",
468 "loc.blank_min",
469 "loc.blank_max",
470 "nom.functions",
471 "nom.closures",
472 "nom.functions_average",
473 "nom.closures_average",
474 "nom.total",
475 "nom.average",
476 "nom.functions_min",
477 "nom.functions_max",
478 "nom.closures_min",
479 "nom.closures_max",
480 "nargs.function_args",
481 "nargs.closure_args",
482 "nargs.function_args_average",
483 "nargs.closure_args_average",
484 "nargs.total",
485 "nargs.average",
486 "nargs.function_args_min",
487 "nargs.function_args_max",
488 "nargs.closure_args_min",
489 "nargs.closure_args_max",
490 "nexits.sum",
491 "nexits.average",
492 "nexits.min",
493 "nexits.max",
494 "tokens.sum",
495 "tokens.average",
496 "tokens.min",
497 "tokens.max",
498 "abc.assignments",
499 "abc.branches",
500 "abc.conditions",
501 "abc.magnitude",
502 "abc.assignments_average",
503 "abc.branches_average",
504 "abc.conditions_average",
505 "abc.assignments_min",
506 "abc.assignments_max",
507 "abc.branches_min",
508 "abc.branches_max",
509 "abc.conditions_min",
510 "abc.conditions_max",
511 "wmc.class_wmc_sum",
512 "wmc.interface_wmc_sum",
513 "wmc.total",
514 "npm.class_npm_sum",
515 "npm.interface_npm_sum",
516 "npm.class_methods",
517 "npm.interface_methods",
518 "npm.class_coa",
519 "npm.interface_coa",
520 "npm.total",
521 "npm.total_methods",
522 "npm.coa",
523 "npa.class_npa_sum",
524 "npa.interface_npa_sum",
525 "npa.class_attributes",
526 "npa.interface_attributes",
527 "npa.class_cda",
528 "npa.interface_cda",
529 "npa.total",
530 "npa.total_attributes",
531 "npa.cda",
532 "mi.original",
533 "mi.sei",
534 "mi.visual_studio",
535 "loc.dloc",
536 "loc.dloc_average",
537 "loc.dloc_min",
538 "loc.dloc_max",
539 ];
540 assert_eq!(CSV_HEADER, documented);
541 }
542
543 #[test]
544 fn non_finite_metric_values_never_leak() {
545 let space = empty_space("root", SpaceKind::Unit, 1, 1);
553 let out = render(&space, Path::new("a.rs"));
554 assert!(
555 !out.contains("NaN"),
556 "NaN must not leak into CSV output:\n{out}"
557 );
558 assert!(
559 !out.contains("inf"),
560 "infinity must not leak into CSV output:\n{out}"
561 );
562 }
563
564 #[test]
565 fn nested_spaces_flatten_depth_first() {
566 let mut root = empty_space("root", SpaceKind::Unit, 1, 100);
567 let mut outer = empty_space("outer", SpaceKind::Function, 10, 50);
568 let inner = empty_space("inner", SpaceKind::Function, 20, 30);
569 outer.spaces.push(inner);
570 let sibling = empty_space("sibling", SpaceKind::Function, 60, 80);
571 root.spaces.push(outer);
572 root.spaces.push(sibling);
573
574 let out = render(&root, Path::new("a.rs"));
575 let names: Vec<&str> = out
576 .lines()
577 .skip(1) .map(|line| line.split(',').nth(1).unwrap_or(""))
579 .collect();
580 assert_eq!(names, vec!["root", "outer", "inner", "sibling"]);
581 }
582
583 #[test]
584 fn rfc_4180_quoting_handled_by_csv_crate() {
585 let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
588 let out = render(&space, Path::new("p.rs"));
589 assert!(
591 out.contains(
592 r#""a,b""c
593d""#
594 ),
595 "expected RFC 4180 quoting in:\n{out}"
596 );
597 }
598
599 #[test]
600 fn formula_injection_cell_is_defanged() {
601 let space = empty_space("=cmd|'/C calc'!A0", SpaceKind::Function, 1, 1);
607 let out = render(&space, Path::new("p.rs"));
608 let data_row = out.lines().nth(1).expect("data row");
609 let name_cell = data_row.split(',').next().unwrap_or("");
610 assert!(
613 out.contains("'=cmd|"),
614 "formula-trigger name must be prefixed with a quote:\n{out}"
615 );
616 assert!(
617 !data_row.starts_with("p.rs,=cmd"),
618 "the un-defanged `=cmd` must not appear unquoted:\n{out}"
619 );
620 let _ = name_cell;
621 }
622
623 #[test]
624 fn formula_injection_path_cell_is_defanged() {
625 let space = empty_space("f", SpaceKind::Function, 1, 1);
629 let out = render(&space, Path::new("=cmd|'/C calc'!A0.rs"));
630 assert!(
631 out.contains("'=cmd|"),
632 "formula-trigger path must be prefixed with a quote:\n{out}"
633 );
634 let data_row = out.lines().nth(1).expect("data row");
635 assert!(
636 !data_row.starts_with("=cmd"),
637 "the un-defanged path `=cmd` must not lead the row:\n{out}"
638 );
639 }
640
641 #[test]
642 fn defang_formula_guards_every_trigger_but_leaves_plain_cells() {
643 for trigger in ['=', '+', '-', '@', '\t', '\r'] {
646 let cell = format!("{trigger}danger");
647 let out = defang_formula(&cell);
648 assert_eq!(out, format!("'{cell}"), "trigger {trigger:?} must defang");
649 }
650 assert_eq!(defang_formula("="), "'=");
652 assert_eq!(defang_formula("compute"), "compute");
654 assert_eq!(defang_formula(""), "");
655 assert_eq!(defang_formula("a=b"), "a=b");
657 assert_eq!(defang_formula("café"), "café");
659 }
660
661 #[cfg(unix)]
668 fn non_utf8_path() -> std::path::PathBuf {
669 use std::ffi::OsStr;
670 use std::os::unix::ffi::OsStrExt;
671
672 std::path::PathBuf::from(OsStr::from_bytes(b"\xff\xfe.rs"))
673 }
674
675 #[test]
676 #[cfg(unix)]
677 fn non_utf8_path_skips_data_rows() {
678 let space = empty_space("root", SpaceKind::Unit, 1, 1);
679 let out = render(&space, &non_utf8_path());
680 assert_eq!(
681 out.lines().count(),
682 1,
683 "header should be the only line, got:\n{out}"
684 );
685 }
686
687 #[test]
688 #[cfg(unix)]
689 fn aggregate_skips_only_the_non_utf8_file() {
690 let bad = non_utf8_path();
696 let good = empty_space("good", SpaceKind::Unit, 1, 1);
697 let skipped = empty_space("skipped", SpaceKind::Unit, 1, 1);
698 let spaces: Vec<(FuncSpace, &Path)> =
699 vec![(skipped, bad.as_path()), (good, Path::new("good.rs"))];
700
701 let mut buf = Vec::new();
702 write_csv_aggregate(spaces.iter().map(|(s, p)| (s, *p)), &mut buf)
703 .expect("writing to Vec is infallible");
704 let out = String::from_utf8(buf).expect("output is UTF-8");
705
706 assert_eq!(out.lines().count(), 2, "header + the one good row:\n{out}");
707 let row = out.lines().nth(1).expect("data row");
708 assert!(row.starts_with("good.rs,"), "row was: {row}");
709 }
710
711 #[test]
712 fn integral_values_have_no_trailing_dot_zero() {
713 let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
716 space.metrics.loc.init_unit_span(0, 42);
719 let out = render(&space, Path::new("a.rs"));
720 let row = out.lines().nth(1).expect("data row");
721 let cells: Vec<&str> = row.split(',').collect();
722 let sloc_idx = CSV_HEADER
724 .iter()
725 .position(|h| *h == "loc.sloc")
726 .expect("loc.sloc in header");
727 assert_eq!(cells[sloc_idx], "42", "row was: {row}");
728 }
729}