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];
174
175/// Write a CSV document for the metric tree rooted at `space`. The
176/// `source_path` is recorded in the `path` column of every row; if it
177/// is not valid UTF-8 the entire document is skipped (header + zero
178/// rows) and a warning is emitted to stderr — there is no useful
179/// fallback for a CSV identifier.
180///
181/// # Errors
182///
183/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`]
184/// while emitting the header row or any of the per-`FuncSpace` data
185/// rows. The error preserves the cause from the wrapped `writer`.
186pub 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) // we drive the header manually so it stays in lock-step with CSV_HEADER
189        .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
201/// Write multiple metric trees into ONE CSV document under a single shared
202/// header row.
203///
204/// [`write_csv`] emits [`CSV_HEADER`] on every call, so concatenating its
205/// output for several files would repeat the header before each file's rows
206/// — a structurally invalid CSV that downstream parsers ingest as data. This
207/// emits the header exactly once, then every file's rows, for the
208/// `--output <FILE>` aggregate path (#669). A non-UTF-8 source path skips
209/// that file's rows with a stderr warning, matching [`write_csv`].
210///
211/// # Errors
212///
213/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`].
214pub 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    // `path` and `space_name` are free-text identifiers derived from
253    // source content; defang any leading spreadsheet-formula trigger so a
254    // function or file named `=cmd|'...'` cannot execute as a formula in
255    // Excel / Google Sheets (CWE-1236, #703). The remaining cells need no
256    // guard: `space_kind` is a fixed enum and the line numbers are
257    // non-negative integers, so neither can begin with a trigger. The
258    // numeric metric cells *can* begin with `-` (a `FORMULA_TRIGGERS`
259    // char) — `mi.original()` / `mi.sei()` may be negative, and
260    // `CellMetric` renders e.g. `-1` — but they are still safe: each is a
261    // self-contained numeric literal (`-1`, `-1.5`) with no attacker-
262    // controlled operator or function call following the sign, so a
263    // spreadsheet parses it as the number it is, never as a formula. The
264    // injection vector OWASP's leading-`-`/`+` rule targets is text where
265    // a payload follows the sign (`-2+cmd|'/calc'!A1`), which a pure
266    // numeric cell can never produce.
267    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
280/// Leading bytes that a spreadsheet application treats as the start of a
281/// formula. A cell beginning with any of these is interpreted as a
282/// formula by Excel / LibreOffice / Google Sheets, so an identifier such
283/// as `=HYPERLINK(...)` or `@SUM(...)` taken from source content would
284/// execute on open. The tab and carriage-return cases are included per
285/// the OWASP CSV-injection guidance (some importers strip a leading tab
286/// then re-evaluate the next character).
287const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
288
289/// Defang a free-text CSV cell against formula injection (CWE-1236) by
290/// prefixing a single quote when the cell begins with a spreadsheet
291/// formula trigger (`=`, `+`, `-`, `@`, tab, or carriage return), the
292/// OWASP-recommended mitigation. The quote makes a spreadsheet treat the
293/// whole cell as a literal string; the RFC 4180 quoting the `csv` crate
294/// applies does *not* defang formulas (a quoted `"=cmd"` still evaluates
295/// on import). Cells that do not start with a trigger — the
296/// overwhelming majority — are returned unchanged with no allocation
297/// beyond the owned `String` the row requires.
298///
299/// Apply this to every free-text, user-controlled CSV cell whose value
300/// can be derived from repository content (file paths, identifier names).
301/// Numeric or enum-valued cells that can never begin with a trigger
302/// character need no defang. This is the shared helper behind both the
303/// [`FuncSpace`] CSV writer here and the VCS-report CSV writer in the
304/// CLI crate; do not duplicate the trigger list.
305#[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    // csv::Error wraps an io::Error for I/O failures; propagate
319    // unchanged so callers see the original errno. Other variants
320    // collapse into InvalidData since they are protocol-level
321    // problems, not I/O. csv::Error has no public From<ErrorKind>
322    // constructor, so format the kind via Debug to retain diagnostic
323    // detail.
324    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        // The `--output <FILE>` aggregate (#669) concatenates several files'
376        // rows into one document and MUST emit the header once. A naive
377        // per-file `write_csv` loop repeats it before every file, which
378        // downstream CSV parsers ingest as data rows (regression guard).
379        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        // One header + one data row per file (each Unit space is one row).
396        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        // Pins CSV_HEADER to the exact column list documented in
406        // STABILITY.md ("Output report formats" -> "CSV columns") so the
407        // written contract and the code cannot drift. A column added in
408        // a minor bump must be appended both here and in STABILITY.md;
409        // reordering or renaming is a 2.0 break (#559).
410        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        // A bare unit space must never emit `NaN` / `inf` in any cell.
534        // Since #438 guarded the npa/npm accessibility ratios, a default
535        // space no longer produces non-finite averages, so this asserts
536        // the durable end-to-end invariant rather than the (now absent)
537        // empty-cell columns. The non-finite -> empty-string rendering
538        // itself is covered directly by
539        // `numfmt::tests::cell_renders_non_finite_as_empty`.
540        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) // header
566            .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        // Names with commas, double-quotes and newlines must round-trip
574        // through the csv crate's quoting; we never hand-roll escapes.
575        let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
576        let out = render(&space, Path::new("p.rs"));
577        // The `csv` crate doubles embedded `"` and wraps the field in `"`s.
578        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        // A function (or path) named with a leading formula trigger must
590        // not execute as a spreadsheet formula (CWE-1236, #703). The
591        // `space_name` cell must be prefixed with `'` so Excel / Sheets
592        // treat it as a literal string. The csv crate then RFC-4180
593        // quotes the comma, but the leading `'` defang survives inside.
594        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        // The name cell is the second column; because it embeds a comma
599        // the csv crate quotes it, so check the raw substring instead.
600        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        // The `path` cell is user-controlled too — a checked-in file
614        // literally named `=cmd…` is the likelier attacker vector — so it
615        // must be defanged with the same leading `'` (#703).
616        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        // Each OWASP-listed trigger character is defanged; a plain cell
632        // and an empty cell are returned unchanged (no spurious quote).
633        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        // A bare trigger character alone is still defanged.
639        assert_eq!(defang_formula("="), "'=");
640        // Plain identifiers and the empty string are untouched.
641        assert_eq!(defang_formula("compute"), "compute");
642        assert_eq!(defang_formula(""), "");
643        // A trigger in a non-leading position is harmless and untouched.
644        assert_eq!(defang_formula("a=b"), "a=b");
645        // A leading non-ASCII identifier is not a trigger.
646        assert_eq!(defang_formula("café"), "café");
647    }
648
649    /// A path with no UTF-8 rendering, constructible only on Unix.
650    ///
651    /// `#[cfg]` sits on the callers' `fn`, not around a test body: an
652    /// empty `#[test]` body is a *passing* test, so the inner-block form
653    /// reports green on Windows having asserted nothing
654    /// (`.claude/rules/testing.md`).
655    #[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        // The aggregate path `continue`s past an undecodable path rather
679        // than abandoning the document, so the *other* files' rows must
680        // still be there — a check `non_utf8_path_skips_data_rows`
681        // cannot make, since a single-file document that skips its only
682        // file and one that bails outright are the same bytes.
683        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        // Match the JSON serializer convention: integer-valued f64s
702        // render as `42`, not `42.0`.
703        let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
704        // Force a known LOC value through the crate API. A span whose last
705        // occupied line is 42, starting at row 0, covers 42 rows.
706        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        // Find the sloc column by header position.
711        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}