use std::io::{self, Write};
use std::path::Path;
use crate::output::funcspace_row::{IDENTITY_COLUMNS, METRIC_COUNT, metric_values};
use crate::output::numfmt::CellMetric;
use crate::spaces::FuncSpace;
const _: () = assert!(IDENTITY_COLUMNS + METRIC_COUNT == CSV_HEADER.len());
pub const CSV_EXTENSION: &str = ".csv";
pub const CSV_HEADER: &[&str] = &[
"path",
"space_name",
"space_kind",
"start_line",
"end_line",
"cognitive.sum",
"cognitive.average",
"cognitive.min",
"cognitive.max",
"cyclomatic.sum",
"cyclomatic.average",
"cyclomatic.min",
"cyclomatic.max",
"cyclomatic.modified.sum",
"cyclomatic.modified.average",
"cyclomatic.modified.min",
"cyclomatic.modified.max",
"halstead.unique_operators",
"halstead.total_operators",
"halstead.unique_operands",
"halstead.total_operands",
"halstead.length",
"halstead.estimated_program_length",
"halstead.purity_ratio",
"halstead.vocabulary",
"halstead.volume",
"halstead.difficulty",
"halstead.level",
"halstead.effort",
"halstead.time",
"halstead.bugs",
"loc.sloc",
"loc.ploc",
"loc.lloc",
"loc.cloc",
"loc.blank",
"loc.sloc_average",
"loc.ploc_average",
"loc.lloc_average",
"loc.cloc_average",
"loc.blank_average",
"loc.sloc_min",
"loc.sloc_max",
"loc.cloc_min",
"loc.cloc_max",
"loc.ploc_min",
"loc.ploc_max",
"loc.lloc_min",
"loc.lloc_max",
"loc.blank_min",
"loc.blank_max",
"nom.functions",
"nom.closures",
"nom.functions_average",
"nom.closures_average",
"nom.total",
"nom.average",
"nom.functions_min",
"nom.functions_max",
"nom.closures_min",
"nom.closures_max",
"nargs.function_args",
"nargs.closure_args",
"nargs.function_args_average",
"nargs.closure_args_average",
"nargs.total",
"nargs.average",
"nargs.function_args_min",
"nargs.function_args_max",
"nargs.closure_args_min",
"nargs.closure_args_max",
"nexits.sum",
"nexits.average",
"nexits.min",
"nexits.max",
"tokens.sum",
"tokens.average",
"tokens.min",
"tokens.max",
"abc.assignments",
"abc.branches",
"abc.conditions",
"abc.magnitude",
"abc.assignments_average",
"abc.branches_average",
"abc.conditions_average",
"abc.assignments_min",
"abc.assignments_max",
"abc.branches_min",
"abc.branches_max",
"abc.conditions_min",
"abc.conditions_max",
"wmc.class_wmc_sum",
"wmc.interface_wmc_sum",
"wmc.total",
"npm.class_npm_sum",
"npm.interface_npm_sum",
"npm.class_methods",
"npm.interface_methods",
"npm.class_coa",
"npm.interface_coa",
"npm.total",
"npm.total_methods",
"npm.coa",
"npa.class_npa_sum",
"npa.interface_npa_sum",
"npa.class_attributes",
"npa.interface_attributes",
"npa.class_cda",
"npa.interface_cda",
"npa.total",
"npa.total_attributes",
"npa.cda",
"mi.original",
"mi.sei",
"mi.visual_studio",
];
pub fn write_csv<W: Write>(space: &FuncSpace, source_path: &Path, writer: W) -> io::Result<()> {
let mut wtr = csv::WriterBuilder::new()
.has_headers(false) .from_writer(writer);
wtr.write_record(CSV_HEADER).map_err(csv_err)?;
let Some(path_str) = source_path.to_str() else {
eprintln!(
"Warning: skipping non-UTF-8 source path in CSV output: {}",
source_path.display()
);
return wtr.flush();
};
write_space_rows(&mut wtr, path_str, space)?;
wtr.flush()
}
pub fn write_csv_aggregate<'a, W, I>(spaces: I, writer: W) -> io::Result<()>
where
W: Write,
I: IntoIterator<Item = (&'a FuncSpace, &'a Path)>,
{
let mut wtr = csv::WriterBuilder::new()
.has_headers(false)
.from_writer(writer);
wtr.write_record(CSV_HEADER).map_err(csv_err)?;
for (space, source_path) in spaces {
let Some(path_str) = source_path.to_str() else {
eprintln!(
"Warning: skipping non-UTF-8 source path in CSV output: {}",
source_path.display()
);
continue;
};
write_space_rows(&mut wtr, path_str, space)?;
}
wtr.flush()
}
fn write_space_rows<W: Write>(
wtr: &mut csv::Writer<W>,
path_str: &str,
space: &FuncSpace,
) -> io::Result<()> {
write_one_row(wtr, path_str, space)?;
for child in &space.spaces {
write_space_rows(wtr, path_str, child)?;
}
Ok(())
}
fn write_one_row<W: Write>(
wtr: &mut csv::Writer<W>,
path_str: &str,
space: &FuncSpace,
) -> io::Result<()> {
let metrics = metric_values(space);
let mut row: Vec<String> = Vec::with_capacity(CSV_HEADER.len());
row.push(defang_formula(path_str));
row.push(defang_formula(space.name.as_deref().unwrap_or("")));
row.push(space.kind.to_string());
row.push(space.start_line.to_string());
row.push(space.end_line.to_string());
for v in metrics {
row.push(CellMetric(v).to_string());
}
wtr.write_record(&row).map_err(csv_err)
}
const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
#[must_use]
pub fn defang_formula(cell: &str) -> String {
if cell.starts_with(FORMULA_TRIGGERS) {
let mut out = String::with_capacity(cell.len() + 1);
out.push('\'');
out.push_str(cell);
out
} else {
cell.to_owned()
}
}
fn csv_err(e: csv::Error) -> io::Error {
match e.into_kind() {
csv::ErrorKind::Io(io_err) => io_err,
other => io::Error::new(io::ErrorKind::InvalidData, format!("{other:?}")),
}
}
#[cfg(test)]
#[allow(
clippy::float_cmp,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::similar_names,
clippy::doc_markdown,
clippy::needless_raw_string_hashes,
clippy::too_many_lines
)]
mod tests {
use super::*;
use crate::spaces::{CodeMetrics, SpaceKind};
fn empty_space(name: &str, kind: SpaceKind, start: usize, end: usize) -> FuncSpace {
FuncSpace {
name: Some(name.into()),
start_line: start,
end_line: end,
kind,
spaces: Vec::new(),
metrics: CodeMetrics::default(),
suppressed: crate::SuppressionScope::default(),
}
}
fn render(space: &FuncSpace, path: &Path) -> String {
let mut buf = Vec::new();
write_csv(space, path, &mut buf).expect("writing to Vec is infallible");
String::from_utf8(buf).expect("output is UTF-8")
}
#[test]
fn header_constant_matches_first_row() {
let space = empty_space("root", SpaceKind::Unit, 1, 1);
let out = render(&space, Path::new("a.rs"));
let first = out.lines().next().expect("at least the header row");
let expected: Vec<&str> = CSV_HEADER.to_vec();
let got: Vec<&str> = first.split(',').collect();
assert_eq!(got, expected);
}
#[test]
fn aggregate_emits_exactly_one_shared_header() {
let a = empty_space("a", SpaceKind::Unit, 1, 1);
let b = empty_space("b", SpaceKind::Unit, 1, 1);
let c = empty_space("c", SpaceKind::Unit, 1, 1);
let spaces: Vec<(FuncSpace, &Path)> = vec![
(a, Path::new("a.rs")),
(b, Path::new("b.rs")),
(c, Path::new("c.rs")),
];
let mut buf = Vec::new();
write_csv_aggregate(spaces.iter().map(|(s, p)| (s, *p)), &mut buf)
.expect("writing to Vec is infallible");
let out = String::from_utf8(buf).expect("output is UTF-8");
let header_line = CSV_HEADER.join(",");
let header_count = out.lines().filter(|l| *l == header_line).count();
assert_eq!(header_count, 1, "exactly one header row, got:\n{out}");
assert_eq!(out.lines().count(), 4, "header + 3 data rows:\n{out}");
assert!(
out.lines().next() == Some(header_line.as_str()),
"header first"
);
}
#[test]
fn header_constant_matches_documented_columns() {
let documented: &[&str] = &[
"path",
"space_name",
"space_kind",
"start_line",
"end_line",
"cognitive.sum",
"cognitive.average",
"cognitive.min",
"cognitive.max",
"cyclomatic.sum",
"cyclomatic.average",
"cyclomatic.min",
"cyclomatic.max",
"cyclomatic.modified.sum",
"cyclomatic.modified.average",
"cyclomatic.modified.min",
"cyclomatic.modified.max",
"halstead.unique_operators",
"halstead.total_operators",
"halstead.unique_operands",
"halstead.total_operands",
"halstead.length",
"halstead.estimated_program_length",
"halstead.purity_ratio",
"halstead.vocabulary",
"halstead.volume",
"halstead.difficulty",
"halstead.level",
"halstead.effort",
"halstead.time",
"halstead.bugs",
"loc.sloc",
"loc.ploc",
"loc.lloc",
"loc.cloc",
"loc.blank",
"loc.sloc_average",
"loc.ploc_average",
"loc.lloc_average",
"loc.cloc_average",
"loc.blank_average",
"loc.sloc_min",
"loc.sloc_max",
"loc.cloc_min",
"loc.cloc_max",
"loc.ploc_min",
"loc.ploc_max",
"loc.lloc_min",
"loc.lloc_max",
"loc.blank_min",
"loc.blank_max",
"nom.functions",
"nom.closures",
"nom.functions_average",
"nom.closures_average",
"nom.total",
"nom.average",
"nom.functions_min",
"nom.functions_max",
"nom.closures_min",
"nom.closures_max",
"nargs.function_args",
"nargs.closure_args",
"nargs.function_args_average",
"nargs.closure_args_average",
"nargs.total",
"nargs.average",
"nargs.function_args_min",
"nargs.function_args_max",
"nargs.closure_args_min",
"nargs.closure_args_max",
"nexits.sum",
"nexits.average",
"nexits.min",
"nexits.max",
"tokens.sum",
"tokens.average",
"tokens.min",
"tokens.max",
"abc.assignments",
"abc.branches",
"abc.conditions",
"abc.magnitude",
"abc.assignments_average",
"abc.branches_average",
"abc.conditions_average",
"abc.assignments_min",
"abc.assignments_max",
"abc.branches_min",
"abc.branches_max",
"abc.conditions_min",
"abc.conditions_max",
"wmc.class_wmc_sum",
"wmc.interface_wmc_sum",
"wmc.total",
"npm.class_npm_sum",
"npm.interface_npm_sum",
"npm.class_methods",
"npm.interface_methods",
"npm.class_coa",
"npm.interface_coa",
"npm.total",
"npm.total_methods",
"npm.coa",
"npa.class_npa_sum",
"npa.interface_npa_sum",
"npa.class_attributes",
"npa.interface_attributes",
"npa.class_cda",
"npa.interface_cda",
"npa.total",
"npa.total_attributes",
"npa.cda",
"mi.original",
"mi.sei",
"mi.visual_studio",
];
assert_eq!(CSV_HEADER, documented);
}
#[test]
fn non_finite_metric_values_never_leak() {
let space = empty_space("root", SpaceKind::Unit, 1, 1);
let out = render(&space, Path::new("a.rs"));
assert!(
!out.contains("NaN"),
"NaN must not leak into CSV output:\n{out}"
);
assert!(
!out.contains("inf"),
"infinity must not leak into CSV output:\n{out}"
);
}
#[test]
fn nested_spaces_flatten_depth_first() {
let mut root = empty_space("root", SpaceKind::Unit, 1, 100);
let mut outer = empty_space("outer", SpaceKind::Function, 10, 50);
let inner = empty_space("inner", SpaceKind::Function, 20, 30);
outer.spaces.push(inner);
let sibling = empty_space("sibling", SpaceKind::Function, 60, 80);
root.spaces.push(outer);
root.spaces.push(sibling);
let out = render(&root, Path::new("a.rs"));
let names: Vec<&str> = out
.lines()
.skip(1) .map(|line| line.split(',').nth(1).unwrap_or(""))
.collect();
assert_eq!(names, vec!["root", "outer", "inner", "sibling"]);
}
#[test]
fn rfc_4180_quoting_handled_by_csv_crate() {
let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
let out = render(&space, Path::new("p.rs"));
assert!(
out.contains(
r#""a,b""c
d""#
),
"expected RFC 4180 quoting in:\n{out}"
);
}
#[test]
fn formula_injection_cell_is_defanged() {
let space = empty_space("=cmd|'/C calc'!A0", SpaceKind::Function, 1, 1);
let out = render(&space, Path::new("p.rs"));
let data_row = out.lines().nth(1).expect("data row");
let name_cell = data_row.split(',').next().unwrap_or("");
assert!(
out.contains("'=cmd|"),
"formula-trigger name must be prefixed with a quote:\n{out}"
);
assert!(
!data_row.starts_with("p.rs,=cmd"),
"the un-defanged `=cmd` must not appear unquoted:\n{out}"
);
let _ = name_cell;
}
#[test]
fn formula_injection_path_cell_is_defanged() {
let space = empty_space("f", SpaceKind::Function, 1, 1);
let out = render(&space, Path::new("=cmd|'/C calc'!A0.rs"));
assert!(
out.contains("'=cmd|"),
"formula-trigger path must be prefixed with a quote:\n{out}"
);
let data_row = out.lines().nth(1).expect("data row");
assert!(
!data_row.starts_with("=cmd"),
"the un-defanged path `=cmd` must not lead the row:\n{out}"
);
}
#[test]
fn defang_formula_guards_every_trigger_but_leaves_plain_cells() {
for trigger in ['=', '+', '-', '@', '\t', '\r'] {
let cell = format!("{trigger}danger");
let out = defang_formula(&cell);
assert_eq!(out, format!("'{cell}"), "trigger {trigger:?} must defang");
}
assert_eq!(defang_formula("="), "'=");
assert_eq!(defang_formula("compute"), "compute");
assert_eq!(defang_formula(""), "");
assert_eq!(defang_formula("a=b"), "a=b");
assert_eq!(defang_formula("café"), "café");
}
#[test]
fn non_utf8_path_skips_data_rows() {
#[cfg(unix)]
{
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf;
let bad = PathBuf::from(OsStr::from_bytes(b"\xff\xfe.rs"));
let space = empty_space("root", SpaceKind::Unit, 1, 1);
let out = render(&space, &bad);
assert_eq!(
out.lines().count(),
1,
"header should be the only line, got:\n{out}"
);
}
}
#[test]
fn integral_values_have_no_trailing_dot_zero() {
let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
space.metrics.loc.init_unit_span(0, 42);
let out = render(&space, Path::new("a.rs"));
let row = out.lines().nth(1).expect("data row");
let cells: Vec<&str> = row.split(',').collect();
let sloc_idx = CSV_HEADER
.iter()
.position(|h| *h == "loc.sloc")
.expect("loc.sloc in header");
assert_eq!(cells[sloc_idx], "42", "row was: {row}");
}
}