Skip to main content

big_code_analysis/output/
csv.rs

1//! CSV writer for [`FuncSpace`] trees.
2//!
3//! Emits one row per space (function, class, struct, unit, etc.),
4//! flattened depth-first from the root. Each row carries the source
5//! path, space name and kind, line range, and every leaf metric
6//! value. The header order is fixed by [`CSV_HEADER`] so downstream
7//! tools (Pandas, Excel, awk) can rely on positional access.
8//!
9//! Empty / non-finite metric values render as empty CSV cells (not
10//! `0`, not `NaN`) — `f64::NAN` and `f64::INFINITY` mean "not
11//! applicable for this space" in the underlying metric structs, and
12//! we keep that signal across the format boundary.
13//!
14//! RFC 4180 quoting (commas, double-quotes, newlines in values) is
15//! handled by the [`csv`] crate; nothing in this module hand-rolls
16//! escaping.
17//!
18//! If the source path is not valid UTF-8, the writer emits the
19//! header row only (no data rows) and warns to stderr. There is no
20//! useful fallback for the CSV `path` column, mirroring the
21//! convention established by the Checkstyle writer.
22
23use 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
31// Compile-time guarantee that the metric tuple matches CSV_HEADER —
32// catches drift the moment a metric is added to one without the other.
33const _: () = assert!(IDENTITY_COLUMNS + METRIC_COUNT == CSV_HEADER.len());
34
35/// File extension used when writing CSV output to a file path.
36pub const CSV_EXTENSION: &str = ".csv";
37
38/// Fixed column order for [`write_csv`] output. Asserted by tests so
39/// downstream consumers can rely on positional access. Metric column
40/// names use dotted JSON-style paths (`loc.lloc`, `halstead.volume`)
41/// so a single name addresses the metric in both JSON and CSV.
42pub const CSV_HEADER: &[&str] = &[
43    // Identity columns
44    "path",
45    "space_name",
46    "space_kind",
47    "start_line",
48    "end_line",
49    // cognitive
50    "cognitive.sum",
51    "cognitive.average",
52    "cognitive.min",
53    "cognitive.max",
54    // cyclomatic
55    "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
64    "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
79    "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
100    "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
111    "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 (serialized as "nexits" in JSON)
122    "nexits.sum",
123    "nexits.average",
124    "nexits.min",
125    "nexits.max",
126    // tokens
127    "tokens.sum",
128    "tokens.average",
129    "tokens.min",
130    "tokens.max",
131    // abc
132    "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
146    "wmc.class_wmc_sum",
147    "wmc.interface_wmc_sum",
148    "wmc.total",
149    // npm
150    "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
160    "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
170    "mi.original",
171    "mi.sei",
172    "mi.visual_studio",
173    // loc, continued: appended at the end of the row, as a column added
174    // within 2.x must be — the header is positional, so a column placed
175    // among the other `loc.*` ones would move every column after it
176    // (STABILITY.md, "CSV columns"; #1597).
177    "loc.dloc",
178    "loc.dloc_average",
179    "loc.dloc_min",
180    "loc.dloc_max",
181];
182
183/// Write a CSV document for the metric tree rooted at `space`. The
184/// `source_path` is recorded in the `path` column of every row; if it
185/// is not valid UTF-8 the entire document is skipped (header + zero
186/// rows) and a warning is emitted to stderr — there is no useful
187/// fallback for a CSV identifier.
188///
189/// # Errors
190///
191/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`]
192/// while emitting the header row or any of the per-`FuncSpace` data
193/// rows. The error preserves the cause from the wrapped `writer`.
194pub 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) // we drive the header manually so it stays in lock-step with CSV_HEADER
197        .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
209/// Write multiple metric trees into ONE CSV document under a single shared
210/// header row.
211///
212/// [`write_csv`] emits [`CSV_HEADER`] on every call, so concatenating its
213/// output for several files would repeat the header before each file's rows
214/// — a structurally invalid CSV that downstream parsers ingest as data. This
215/// emits the header exactly once, then every file's rows, for the
216/// `--output <FILE>` aggregate path (#669). A non-UTF-8 source path skips
217/// that file's rows with a stderr warning, matching [`write_csv`].
218///
219/// # Errors
220///
221/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`].
222pub 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    // `path` and `space_name` are free-text identifiers derived from
261    // source content; defang any leading spreadsheet-formula trigger so a
262    // function or file named `=cmd|'...'` cannot execute as a formula in
263    // Excel / Google Sheets (CWE-1236, #703). The remaining cells need no
264    // guard: `space_kind` is a fixed enum and the line numbers are
265    // non-negative integers, so neither can begin with a trigger. The
266    // numeric metric cells *can* begin with `-` (a `FORMULA_TRIGGERS`
267    // char) — `mi.original()` / `mi.sei()` may be negative, and
268    // `CellMetric` renders e.g. `-1` — but they are still safe: each is a
269    // self-contained numeric literal (`-1`, `-1.5`) with no attacker-
270    // controlled operator or function call following the sign, so a
271    // spreadsheet parses it as the number it is, never as a formula. The
272    // injection vector OWASP's leading-`-`/`+` rule targets is text where
273    // a payload follows the sign (`-2+cmd|'/calc'!A1`), which a pure
274    // numeric cell can never produce.
275    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
288/// Leading bytes that a spreadsheet application treats as the start of a
289/// formula. A cell beginning with any of these is interpreted as a
290/// formula by Excel / LibreOffice / Google Sheets, so an identifier such
291/// as `=HYPERLINK(...)` or `@SUM(...)` taken from source content would
292/// execute on open. The tab and carriage-return cases are included per
293/// the OWASP CSV-injection guidance (some importers strip a leading tab
294/// then re-evaluate the next character).
295const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
296
297/// Defang a free-text CSV cell against formula injection (CWE-1236) by
298/// prefixing a single quote when the cell begins with a spreadsheet
299/// formula trigger (`=`, `+`, `-`, `@`, tab, or carriage return), the
300/// OWASP-recommended mitigation. The quote makes a spreadsheet treat the
301/// whole cell as a literal string; the RFC 4180 quoting the `csv` crate
302/// applies does *not* defang formulas (a quoted `"=cmd"` still evaluates
303/// on import). Cells that do not start with a trigger — the
304/// overwhelming majority — are returned unchanged with no allocation
305/// beyond the owned `String` the row requires.
306///
307/// Apply this to every free-text, user-controlled CSV cell whose value
308/// can be derived from repository content (file paths, identifier names).
309/// Numeric or enum-valued cells that can never begin with a trigger
310/// character need no defang. This is the shared helper behind both the
311/// [`FuncSpace`] CSV writer here and the VCS-report CSV writer in the
312/// CLI crate; do not duplicate the trigger list.
313#[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    // csv::Error wraps an io::Error for I/O failures; propagate
327    // unchanged so callers see the original errno. Other variants
328    // collapse into InvalidData since they are protocol-level
329    // problems, not I/O. csv::Error has no public From<ErrorKind>
330    // constructor, so format the kind via Debug to retain diagnostic
331    // detail.
332    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        // The `--output <FILE>` aggregate (#669) concatenates several files'
384        // rows into one document and MUST emit the header once. A naive
385        // per-file `write_csv` loop repeats it before every file, which
386        // downstream CSV parsers ingest as data rows (regression guard).
387        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        // One header + one data row per file (each Unit space is one row).
404        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        // Pins CSV_HEADER to the exact column list documented in
414        // STABILITY.md ("Output report formats" -> "CSV columns") so the
415        // written contract and the code cannot drift. A column added in
416        // a minor bump must be appended both here and in STABILITY.md;
417        // reordering or renaming is a 2.0 break (#559).
418        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        // A bare unit space must never emit `NaN` / `inf` in any cell.
546        // Since #438 guarded the npa/npm accessibility ratios, a default
547        // space no longer produces non-finite averages, so this asserts
548        // the durable end-to-end invariant rather than the (now absent)
549        // empty-cell columns. The non-finite -> empty-string rendering
550        // itself is covered directly by
551        // `numfmt::tests::cell_renders_non_finite_as_empty`.
552        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) // header
578            .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        // Names with commas, double-quotes and newlines must round-trip
586        // through the csv crate's quoting; we never hand-roll escapes.
587        let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
588        let out = render(&space, Path::new("p.rs"));
589        // The `csv` crate doubles embedded `"` and wraps the field in `"`s.
590        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        // A function (or path) named with a leading formula trigger must
602        // not execute as a spreadsheet formula (CWE-1236, #703). The
603        // `space_name` cell must be prefixed with `'` so Excel / Sheets
604        // treat it as a literal string. The csv crate then RFC-4180
605        // quotes the comma, but the leading `'` defang survives inside.
606        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        // The name cell is the second column; because it embeds a comma
611        // the csv crate quotes it, so check the raw substring instead.
612        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        // The `path` cell is user-controlled too — a checked-in file
626        // literally named `=cmd…` is the likelier attacker vector — so it
627        // must be defanged with the same leading `'` (#703).
628        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        // Each OWASP-listed trigger character is defanged; a plain cell
644        // and an empty cell are returned unchanged (no spurious quote).
645        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        // A bare trigger character alone is still defanged.
651        assert_eq!(defang_formula("="), "'=");
652        // Plain identifiers and the empty string are untouched.
653        assert_eq!(defang_formula("compute"), "compute");
654        assert_eq!(defang_formula(""), "");
655        // A trigger in a non-leading position is harmless and untouched.
656        assert_eq!(defang_formula("a=b"), "a=b");
657        // A leading non-ASCII identifier is not a trigger.
658        assert_eq!(defang_formula("café"), "café");
659    }
660
661    /// A path with no UTF-8 rendering, constructible only on Unix.
662    ///
663    /// `#[cfg]` sits on the callers' `fn`, not around a test body: an
664    /// empty `#[test]` body is a *passing* test, so the inner-block form
665    /// reports green on Windows having asserted nothing
666    /// (`.claude/rules/testing.md`).
667    #[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        // The aggregate path `continue`s past an undecodable path rather
691        // than abandoning the document, so the *other* files' rows must
692        // still be there — a check `non_utf8_path_skips_data_rows`
693        // cannot make, since a single-file document that skips its only
694        // file and one that bails outright are the same bytes.
695        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        // Match the JSON serializer convention: integer-valued f64s
714        // render as `42`, not `42.0`.
715        let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
716        // Force a known LOC value through the crate API. A span whose last
717        // occupied line is 42, starting at row 0, covers 42 rows.
718        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        // Find the sloc column by header position.
723        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}