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::spaces::FuncSpace;
29
30// Compile-time guarantee that the metric tuple matches CSV_HEADER —
31// catches drift the moment a metric is added to one without the other.
32const _: () = assert!(IDENTITY_COLUMNS + METRIC_COUNT == CSV_HEADER.len());
33
34/// File extension used when writing CSV output to a file path.
35pub const CSV_EXTENSION: &str = ".csv";
36
37/// Fixed column order for [`write_csv`] output. Asserted by tests so
38/// downstream consumers can rely on positional access. Metric column
39/// names use dotted JSON-style paths (`loc.lloc`, `halstead.volume`)
40/// so a single name addresses the metric in both JSON and CSV.
41pub const CSV_HEADER: &[&str] = &[
42    // Identity columns
43    "path",
44    "space_name",
45    "space_kind",
46    "start_line",
47    "end_line",
48    // cognitive
49    "cognitive.sum",
50    "cognitive.average",
51    "cognitive.min",
52    "cognitive.max",
53    // cyclomatic
54    "cyclomatic.sum",
55    "cyclomatic.average",
56    "cyclomatic.min",
57    "cyclomatic.max",
58    "cyclomatic.modified.sum",
59    "cyclomatic.modified.average",
60    "cyclomatic.modified.min",
61    "cyclomatic.modified.max",
62    // halstead
63    "halstead.unique_operators",
64    "halstead.total_operators",
65    "halstead.unique_operands",
66    "halstead.total_operands",
67    "halstead.length",
68    "halstead.estimated_program_length",
69    "halstead.purity_ratio",
70    "halstead.vocabulary",
71    "halstead.volume",
72    "halstead.difficulty",
73    "halstead.level",
74    "halstead.effort",
75    "halstead.time",
76    "halstead.bugs",
77    // loc
78    "loc.sloc",
79    "loc.ploc",
80    "loc.lloc",
81    "loc.cloc",
82    "loc.blank",
83    "loc.sloc_average",
84    "loc.ploc_average",
85    "loc.lloc_average",
86    "loc.cloc_average",
87    "loc.blank_average",
88    "loc.sloc_min",
89    "loc.sloc_max",
90    "loc.cloc_min",
91    "loc.cloc_max",
92    "loc.ploc_min",
93    "loc.ploc_max",
94    "loc.lloc_min",
95    "loc.lloc_max",
96    "loc.blank_min",
97    "loc.blank_max",
98    // nom
99    "nom.functions",
100    "nom.closures",
101    "nom.functions_average",
102    "nom.closures_average",
103    "nom.total",
104    "nom.average",
105    "nom.functions_min",
106    "nom.functions_max",
107    "nom.closures_min",
108    "nom.closures_max",
109    // nargs
110    "nargs.function_args",
111    "nargs.closure_args",
112    "nargs.function_args_average",
113    "nargs.closure_args_average",
114    "nargs.total",
115    "nargs.average",
116    "nargs.function_args_min",
117    "nargs.function_args_max",
118    "nargs.closure_args_min",
119    "nargs.closure_args_max",
120    // nexits (serialized as "nexits" in JSON)
121    "nexits.sum",
122    "nexits.average",
123    "nexits.min",
124    "nexits.max",
125    // tokens
126    "tokens.sum",
127    "tokens.average",
128    "tokens.min",
129    "tokens.max",
130    // abc
131    "abc.assignments",
132    "abc.branches",
133    "abc.conditions",
134    "abc.magnitude",
135    "abc.assignments_average",
136    "abc.branches_average",
137    "abc.conditions_average",
138    "abc.assignments_min",
139    "abc.assignments_max",
140    "abc.branches_min",
141    "abc.branches_max",
142    "abc.conditions_min",
143    "abc.conditions_max",
144    // wmc
145    "wmc.class_wmc_sum",
146    "wmc.interface_wmc_sum",
147    "wmc.total",
148    // npm
149    "npm.class_npm_sum",
150    "npm.interface_npm_sum",
151    "npm.class_methods",
152    "npm.interface_methods",
153    "npm.class_coa",
154    "npm.interface_coa",
155    "npm.total",
156    "npm.total_methods",
157    "npm.coa",
158    // npa
159    "npa.class_npa_sum",
160    "npa.interface_npa_sum",
161    "npa.class_attributes",
162    "npa.interface_attributes",
163    "npa.class_cda",
164    "npa.interface_cda",
165    "npa.total",
166    "npa.total_attributes",
167    "npa.cda",
168    // mi
169    "mi.original",
170    "mi.sei",
171    "mi.visual_studio",
172];
173
174/// Write a CSV document for the metric tree rooted at `space`. The
175/// `source_path` is recorded in the `path` column of every row; if it
176/// is not valid UTF-8 the entire document is skipped (header + zero
177/// rows) and a warning is emitted to stderr — there is no useful
178/// fallback for a CSV identifier.
179///
180/// # Errors
181///
182/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`]
183/// while emitting the header row or any of the per-`FuncSpace` data
184/// rows. The error preserves the cause from the wrapped `writer`.
185pub fn write_csv<W: Write>(space: &FuncSpace, source_path: &Path, writer: W) -> io::Result<()> {
186    let mut wtr = csv::WriterBuilder::new()
187        .has_headers(false) // we drive the header manually so it stays in lock-step with CSV_HEADER
188        .from_writer(writer);
189
190    wtr.write_record(CSV_HEADER).map_err(csv_err)?;
191
192    let Some(path_str) = source_path.to_str() else {
193        eprintln!(
194            "Warning: skipping non-UTF-8 source path in CSV output: {}",
195            source_path.display()
196        );
197        return wtr.flush();
198    };
199
200    write_space_rows(&mut wtr, path_str, space)?;
201    wtr.flush()
202}
203
204/// Write multiple metric trees into ONE CSV document under a single shared
205/// header row.
206///
207/// [`write_csv`] emits [`CSV_HEADER`] on every call, so concatenating its
208/// output for several files would repeat the header before each file's rows
209/// — a structurally invalid CSV that downstream parsers ingest as data. This
210/// emits the header exactly once, then every file's rows, for the
211/// `--output <FILE>` aggregate path (#669). A non-UTF-8 source path skips
212/// that file's rows with a stderr warning, matching [`write_csv`].
213///
214/// # Errors
215///
216/// Returns any [`io::Error`] surfaced by the underlying [`csv::Writer`].
217pub fn write_csv_aggregate<'a, W, I>(spaces: I, writer: W) -> io::Result<()>
218where
219    W: Write,
220    I: IntoIterator<Item = (&'a FuncSpace, &'a Path)>,
221{
222    let mut wtr = csv::WriterBuilder::new()
223        .has_headers(false)
224        .from_writer(writer);
225    wtr.write_record(CSV_HEADER).map_err(csv_err)?;
226    for (space, source_path) in spaces {
227        let Some(path_str) = source_path.to_str() else {
228            eprintln!(
229                "Warning: skipping non-UTF-8 source path in CSV output: {}",
230                source_path.display()
231            );
232            continue;
233        };
234        write_space_rows(&mut wtr, path_str, space)?;
235    }
236    wtr.flush()
237}
238
239fn write_space_rows<W: Write>(
240    wtr: &mut csv::Writer<W>,
241    path_str: &str,
242    space: &FuncSpace,
243) -> io::Result<()> {
244    write_one_row(wtr, path_str, space)?;
245    for child in &space.spaces {
246        write_space_rows(wtr, path_str, child)?;
247    }
248    Ok(())
249}
250
251fn write_one_row<W: Write>(
252    wtr: &mut csv::Writer<W>,
253    path_str: &str,
254    space: &FuncSpace,
255) -> io::Result<()> {
256    let metrics = metric_values(space);
257
258    let mut row: Vec<String> = Vec::with_capacity(CSV_HEADER.len());
259    // `path` and `space_name` are free-text identifiers derived from
260    // source content; defang any leading spreadsheet-formula trigger so a
261    // function or file named `=cmd|'...'` cannot execute as a formula in
262    // Excel / Google Sheets (CWE-1236, #703). The remaining cells need no
263    // guard: `space_kind` is a fixed enum and the line numbers are
264    // non-negative integers, so neither can begin with a trigger. The
265    // numeric metric cells *can* begin with `-` (a `FORMULA_TRIGGERS`
266    // char) — `mi.original()` / `mi.sei()` may be negative, and
267    // `CellMetric` renders e.g. `-1` — but they are still safe: each is a
268    // self-contained numeric literal (`-1`, `-1.5`) with no attacker-
269    // controlled operator or function call following the sign, so a
270    // spreadsheet parses it as the number it is, never as a formula. The
271    // injection vector OWASP's leading-`-`/`+` rule targets is text where
272    // a payload follows the sign (`-2+cmd|'/calc'!A1`), which a pure
273    // numeric cell can never produce.
274    row.push(defang_formula(path_str));
275    row.push(defang_formula(space.name.as_deref().unwrap_or("")));
276    row.push(space.kind.to_string());
277    row.push(space.start_line.to_string());
278    row.push(space.end_line.to_string());
279
280    for v in metrics {
281        row.push(CellMetric(v).to_string());
282    }
283
284    wtr.write_record(&row).map_err(csv_err)
285}
286
287/// Leading bytes that a spreadsheet application treats as the start of a
288/// formula. A cell beginning with any of these is interpreted as a
289/// formula by Excel / LibreOffice / Google Sheets, so an identifier such
290/// as `=HYPERLINK(...)` or `@SUM(...)` taken from source content would
291/// execute on open. The tab and carriage-return cases are included per
292/// the OWASP CSV-injection guidance (some importers strip a leading tab
293/// then re-evaluate the next character).
294const FORMULA_TRIGGERS: [char; 6] = ['=', '+', '-', '@', '\t', '\r'];
295
296/// Defang a free-text CSV cell against formula injection (CWE-1236) by
297/// prefixing a single quote when the cell begins with a spreadsheet
298/// formula trigger (`=`, `+`, `-`, `@`, tab, or carriage return), the
299/// OWASP-recommended mitigation. The quote makes a spreadsheet treat the
300/// whole cell as a literal string; the RFC 4180 quoting the `csv` crate
301/// applies does *not* defang formulas (a quoted `"=cmd"` still evaluates
302/// on import). Cells that do not start with a trigger — the
303/// overwhelming majority — are returned unchanged with no allocation
304/// beyond the owned `String` the row requires.
305///
306/// Apply this to every free-text, user-controlled CSV cell whose value
307/// can be derived from repository content (file paths, identifier names).
308/// Numeric or enum-valued cells that can never begin with a trigger
309/// character need no defang. This is the shared helper behind both the
310/// [`FuncSpace`] CSV writer here and the VCS-report CSV writer in the
311/// CLI crate; do not duplicate the trigger list.
312#[must_use]
313pub fn defang_formula(cell: &str) -> String {
314    if cell.starts_with(FORMULA_TRIGGERS) {
315        let mut out = String::with_capacity(cell.len() + 1);
316        out.push('\'');
317        out.push_str(cell);
318        out
319    } else {
320        cell.to_owned()
321    }
322}
323
324fn csv_err(e: csv::Error) -> io::Error {
325    // csv::Error wraps an io::Error for I/O failures; propagate
326    // unchanged so callers see the original errno. Other variants
327    // collapse into InvalidData since they are protocol-level
328    // problems, not I/O. csv::Error has no public From<ErrorKind>
329    // constructor, so format the kind via Debug to retain diagnostic
330    // detail.
331    match e.into_kind() {
332        csv::ErrorKind::Io(io_err) => io_err,
333        other => io::Error::new(io::ErrorKind::InvalidData, format!("{other:?}")),
334    }
335}
336
337#[cfg(test)]
338#[allow(
339    clippy::float_cmp,
340    clippy::cast_precision_loss,
341    clippy::cast_possible_truncation,
342    clippy::cast_sign_loss,
343    clippy::similar_names,
344    clippy::doc_markdown,
345    clippy::needless_raw_string_hashes,
346    clippy::too_many_lines
347)]
348mod tests {
349    use super::*;
350    use crate::spaces::{CodeMetrics, SpaceKind};
351
352    fn empty_space(name: &str, kind: SpaceKind, start: usize, end: usize) -> FuncSpace {
353        FuncSpace {
354            name: Some(name.into()),
355            start_line: start,
356            end_line: end,
357            kind,
358            spaces: Vec::new(),
359            metrics: CodeMetrics::default(),
360            suppressed: crate::SuppressionScope::default(),
361        }
362    }
363
364    fn render(space: &FuncSpace, path: &Path) -> String {
365        let mut buf = Vec::new();
366        write_csv(space, path, &mut buf).expect("writing to Vec is infallible");
367        String::from_utf8(buf).expect("output is UTF-8")
368    }
369
370    #[test]
371    fn header_constant_matches_first_row() {
372        let space = empty_space("root", SpaceKind::Unit, 1, 1);
373        let out = render(&space, Path::new("a.rs"));
374        let first = out.lines().next().expect("at least the header row");
375        let expected: Vec<&str> = CSV_HEADER.to_vec();
376        let got: Vec<&str> = first.split(',').collect();
377        assert_eq!(got, expected);
378    }
379
380    #[test]
381    fn aggregate_emits_exactly_one_shared_header() {
382        // The `--output <FILE>` aggregate (#669) concatenates several files'
383        // rows into one document and MUST emit the header once. A naive
384        // per-file `write_csv` loop repeats it before every file, which
385        // downstream CSV parsers ingest as data rows (regression guard).
386        let a = empty_space("a", SpaceKind::Unit, 1, 1);
387        let b = empty_space("b", SpaceKind::Unit, 1, 1);
388        let c = empty_space("c", SpaceKind::Unit, 1, 1);
389        let spaces: Vec<(FuncSpace, &Path)> = vec![
390            (a, Path::new("a.rs")),
391            (b, Path::new("b.rs")),
392            (c, Path::new("c.rs")),
393        ];
394        let mut buf = Vec::new();
395        write_csv_aggregate(spaces.iter().map(|(s, p)| (s, *p)), &mut buf)
396            .expect("writing to Vec is infallible");
397        let out = String::from_utf8(buf).expect("output is UTF-8");
398
399        let header_line = CSV_HEADER.join(",");
400        let header_count = out.lines().filter(|l| *l == header_line).count();
401        assert_eq!(header_count, 1, "exactly one header row, got:\n{out}");
402        // One header + one data row per file (each Unit space is one row).
403        assert_eq!(out.lines().count(), 4, "header + 3 data rows:\n{out}");
404        assert!(
405            out.lines().next() == Some(header_line.as_str()),
406            "header first"
407        );
408    }
409
410    #[test]
411    fn header_constant_matches_documented_columns() {
412        // Pins CSV_HEADER to the exact column list documented in
413        // STABILITY.md ("Output report formats" -> "CSV columns") so the
414        // written contract and the code cannot drift. A column added in
415        // a minor bump must be appended both here and in STABILITY.md;
416        // reordering or renaming is a 2.0 break (#559).
417        let documented: &[&str] = &[
418            "path",
419            "space_name",
420            "space_kind",
421            "start_line",
422            "end_line",
423            "cognitive.sum",
424            "cognitive.average",
425            "cognitive.min",
426            "cognitive.max",
427            "cyclomatic.sum",
428            "cyclomatic.average",
429            "cyclomatic.min",
430            "cyclomatic.max",
431            "cyclomatic.modified.sum",
432            "cyclomatic.modified.average",
433            "cyclomatic.modified.min",
434            "cyclomatic.modified.max",
435            "halstead.unique_operators",
436            "halstead.total_operators",
437            "halstead.unique_operands",
438            "halstead.total_operands",
439            "halstead.length",
440            "halstead.estimated_program_length",
441            "halstead.purity_ratio",
442            "halstead.vocabulary",
443            "halstead.volume",
444            "halstead.difficulty",
445            "halstead.level",
446            "halstead.effort",
447            "halstead.time",
448            "halstead.bugs",
449            "loc.sloc",
450            "loc.ploc",
451            "loc.lloc",
452            "loc.cloc",
453            "loc.blank",
454            "loc.sloc_average",
455            "loc.ploc_average",
456            "loc.lloc_average",
457            "loc.cloc_average",
458            "loc.blank_average",
459            "loc.sloc_min",
460            "loc.sloc_max",
461            "loc.cloc_min",
462            "loc.cloc_max",
463            "loc.ploc_min",
464            "loc.ploc_max",
465            "loc.lloc_min",
466            "loc.lloc_max",
467            "loc.blank_min",
468            "loc.blank_max",
469            "nom.functions",
470            "nom.closures",
471            "nom.functions_average",
472            "nom.closures_average",
473            "nom.total",
474            "nom.average",
475            "nom.functions_min",
476            "nom.functions_max",
477            "nom.closures_min",
478            "nom.closures_max",
479            "nargs.function_args",
480            "nargs.closure_args",
481            "nargs.function_args_average",
482            "nargs.closure_args_average",
483            "nargs.total",
484            "nargs.average",
485            "nargs.function_args_min",
486            "nargs.function_args_max",
487            "nargs.closure_args_min",
488            "nargs.closure_args_max",
489            "nexits.sum",
490            "nexits.average",
491            "nexits.min",
492            "nexits.max",
493            "tokens.sum",
494            "tokens.average",
495            "tokens.min",
496            "tokens.max",
497            "abc.assignments",
498            "abc.branches",
499            "abc.conditions",
500            "abc.magnitude",
501            "abc.assignments_average",
502            "abc.branches_average",
503            "abc.conditions_average",
504            "abc.assignments_min",
505            "abc.assignments_max",
506            "abc.branches_min",
507            "abc.branches_max",
508            "abc.conditions_min",
509            "abc.conditions_max",
510            "wmc.class_wmc_sum",
511            "wmc.interface_wmc_sum",
512            "wmc.total",
513            "npm.class_npm_sum",
514            "npm.interface_npm_sum",
515            "npm.class_methods",
516            "npm.interface_methods",
517            "npm.class_coa",
518            "npm.interface_coa",
519            "npm.total",
520            "npm.total_methods",
521            "npm.coa",
522            "npa.class_npa_sum",
523            "npa.interface_npa_sum",
524            "npa.class_attributes",
525            "npa.interface_attributes",
526            "npa.class_cda",
527            "npa.interface_cda",
528            "npa.total",
529            "npa.total_attributes",
530            "npa.cda",
531            "mi.original",
532            "mi.sei",
533            "mi.visual_studio",
534        ];
535        assert_eq!(CSV_HEADER, documented);
536    }
537
538    #[test]
539    fn non_finite_metric_values_never_leak() {
540        // A bare unit space must never emit `NaN` / `inf` in any cell.
541        // Since #438 guarded the npa/npm accessibility ratios, a default
542        // space no longer produces non-finite averages, so this asserts
543        // the durable end-to-end invariant rather than the (now absent)
544        // empty-cell columns. The non-finite -> empty-string rendering
545        // itself is covered directly by
546        // `numfmt::tests::cell_renders_non_finite_as_empty`.
547        let space = empty_space("root", SpaceKind::Unit, 1, 1);
548        let out = render(&space, Path::new("a.rs"));
549        assert!(
550            !out.contains("NaN"),
551            "NaN must not leak into CSV output:\n{out}"
552        );
553        assert!(
554            !out.contains("inf"),
555            "infinity must not leak into CSV output:\n{out}"
556        );
557    }
558
559    #[test]
560    fn nested_spaces_flatten_depth_first() {
561        let mut root = empty_space("root", SpaceKind::Unit, 1, 100);
562        let mut outer = empty_space("outer", SpaceKind::Function, 10, 50);
563        let inner = empty_space("inner", SpaceKind::Function, 20, 30);
564        outer.spaces.push(inner);
565        let sibling = empty_space("sibling", SpaceKind::Function, 60, 80);
566        root.spaces.push(outer);
567        root.spaces.push(sibling);
568
569        let out = render(&root, Path::new("a.rs"));
570        let names: Vec<&str> = out
571            .lines()
572            .skip(1) // header
573            .map(|line| line.split(',').nth(1).unwrap_or(""))
574            .collect();
575        assert_eq!(names, vec!["root", "outer", "inner", "sibling"]);
576    }
577
578    #[test]
579    fn rfc_4180_quoting_handled_by_csv_crate() {
580        // Names with commas, double-quotes and newlines must round-trip
581        // through the csv crate's quoting; we never hand-roll escapes.
582        let space = empty_space("a,b\"c\nd", SpaceKind::Function, 1, 1);
583        let out = render(&space, Path::new("p.rs"));
584        // The `csv` crate doubles embedded `"` and wraps the field in `"`s.
585        assert!(
586            out.contains(
587                r#""a,b""c
588d""#
589            ),
590            "expected RFC 4180 quoting in:\n{out}"
591        );
592    }
593
594    #[test]
595    fn formula_injection_cell_is_defanged() {
596        // A function (or path) named with a leading formula trigger must
597        // not execute as a spreadsheet formula (CWE-1236, #703). The
598        // `space_name` cell must be prefixed with `'` so Excel / Sheets
599        // treat it as a literal string. The csv crate then RFC-4180
600        // quotes the comma, but the leading `'` defang survives inside.
601        let space = empty_space("=cmd|'/C calc'!A0", SpaceKind::Function, 1, 1);
602        let out = render(&space, Path::new("p.rs"));
603        let data_row = out.lines().nth(1).expect("data row");
604        let name_cell = data_row.split(',').next().unwrap_or("");
605        // The name cell is the second column; because it embeds a comma
606        // the csv crate quotes it, so check the raw substring instead.
607        assert!(
608            out.contains("'=cmd|"),
609            "formula-trigger name must be prefixed with a quote:\n{out}"
610        );
611        assert!(
612            !data_row.starts_with("p.rs,=cmd"),
613            "the un-defanged `=cmd` must not appear unquoted:\n{out}"
614        );
615        let _ = name_cell;
616    }
617
618    #[test]
619    fn formula_injection_path_cell_is_defanged() {
620        // The `path` cell is user-controlled too — a checked-in file
621        // literally named `=cmd…` is the likelier attacker vector — so it
622        // must be defanged with the same leading `'` (#703).
623        let space = empty_space("f", SpaceKind::Function, 1, 1);
624        let out = render(&space, Path::new("=cmd|'/C calc'!A0.rs"));
625        assert!(
626            out.contains("'=cmd|"),
627            "formula-trigger path must be prefixed with a quote:\n{out}"
628        );
629        let data_row = out.lines().nth(1).expect("data row");
630        assert!(
631            !data_row.starts_with("=cmd"),
632            "the un-defanged path `=cmd` must not lead the row:\n{out}"
633        );
634    }
635
636    #[test]
637    fn defang_formula_guards_every_trigger_but_leaves_plain_cells() {
638        // Each OWASP-listed trigger character is defanged; a plain cell
639        // and an empty cell are returned unchanged (no spurious quote).
640        for trigger in ['=', '+', '-', '@', '\t', '\r'] {
641            let cell = format!("{trigger}danger");
642            let out = defang_formula(&cell);
643            assert_eq!(out, format!("'{cell}"), "trigger {trigger:?} must defang");
644        }
645        // A bare trigger character alone is still defanged.
646        assert_eq!(defang_formula("="), "'=");
647        // Plain identifiers and the empty string are untouched.
648        assert_eq!(defang_formula("compute"), "compute");
649        assert_eq!(defang_formula(""), "");
650        // A trigger in a non-leading position is harmless and untouched.
651        assert_eq!(defang_formula("a=b"), "a=b");
652        // A leading non-ASCII identifier is not a trigger.
653        assert_eq!(defang_formula("café"), "café");
654    }
655
656    #[test]
657    fn non_utf8_path_skips_data_rows() {
658        #[cfg(unix)]
659        {
660            use std::ffi::OsStr;
661            use std::os::unix::ffi::OsStrExt;
662            use std::path::PathBuf;
663
664            let bad = PathBuf::from(OsStr::from_bytes(b"\xff\xfe.rs"));
665            let space = empty_space("root", SpaceKind::Unit, 1, 1);
666            let out = render(&space, &bad);
667            assert_eq!(
668                out.lines().count(),
669                1,
670                "header should be the only line, got:\n{out}"
671            );
672        }
673    }
674
675    #[test]
676    fn integral_values_have_no_trailing_dot_zero() {
677        // Match the JSON serializer convention: integer-valued f64s
678        // render as `42`, not `42.0`.
679        let mut space = empty_space("root", SpaceKind::Unit, 1, 1);
680        // Force a known LOC value via the public API. With `unit=true`
681        // sloc = end - start, so (0, 42) yields 42.
682        space.metrics.loc.init_unit_span(0, 42);
683        let out = render(&space, Path::new("a.rs"));
684        let row = out.lines().nth(1).expect("data row");
685        let cells: Vec<&str> = row.split(',').collect();
686        // Find the sloc column by header position.
687        let sloc_idx = CSV_HEADER
688            .iter()
689            .position(|h| *h == "loc.sloc")
690            .expect("loc.sloc in header");
691        assert_eq!(cells[sloc_idx], "42", "row was: {row}");
692    }
693}