Skip to main content

big_code_analysis/
wire.rs

1//! Plain, public data-transfer structs mirroring the serialized metric
2//! wire shape — the single source of truth for the JSON / YAML / TOML /
3//! CBOR output format and the only `Deserialize`-capable view of it.
4//!
5//! The compute types ([`crate::spaces::FuncSpace`],
6//! [`crate::spaces::CodeMetrics`], the per-metric `Stats`, [`crate::Ops`],
7//! [`crate::FunctionSpan`]) store *raw* state (e.g. Halstead keeps four
8//! operator/operand counts and derives `volume`/`difficulty`/… on demand;
9//! `cognitive` keeps a sum and a hidden space count and derives
10//! `average`). Their serialized form is therefore a *projection*: a flat
11//! record of already-derived values, several of which (the averages,
12//! ratios, and Halstead/MI scores) cannot be inverted back to the private
13//! state. A plain `#[derive(Deserialize)]` on the compute types is thus
14//! impossible.
15//!
16//! This module defines a parallel struct per metric and per container
17//! whose fields are *exactly* the serialized fields, deriving both
18//! `Serialize` and `Deserialize`. The compute types' own `Serialize`
19//! impls delegate here (via the `From<&Compute>` projections below), so
20//! there is exactly one definition of the wire shape; deserialization
21//! reads into these `wire` structs and round-trips byte-for-byte.
22//!
23//! Delegation materializes an owned projection per serialize (a deep clone
24//! for the recursive `FuncSpace`/`Ops` trees). This is the deliberate cost
25//! of a single source of truth that also round-trips: a borrowing
26//! serialize-only mirror would double the struct set and could not derive
27//! `Deserialize`. Serialization runs once per file and the projection is
28//! dropped immediately, so it is not on a tight inner loop.
29//!
30//! ## Field conventions
31//!
32//! - Integer-valued metrics (counts, sums, min/max) are `u64` (#530).
33//! - Derived / ratio / average fields are `f64` and carry the
34//!   `non_finite` (de)serialization: a non-finite value (`NaN`/`±∞`,
35//!   meaning "not applicable") serializes to a null uniformly across
36//!   formats — native `null` in JSON/YAML/CBOR, an omitted key in TOML —
37//!   and deserializes back to `f64::NAN` (#531). Finite values pass
38//!   through unchanged, so the round-trip is symmetric and needs no
39//!   `Option`.
40//! - [`CodeMetrics`] elides unselected metrics (each is an `Option`
41//!   skipped when `None`); on read, a present key ⇒ selected, absent ⇒
42//!   unselected. [`CodeMetrics::selected`] reconstructs the
43//!   [`MetricSet`] from the present keys.
44
45use serde::{Deserialize, Serialize, Serializer};
46
47use crate::metric_set::{Metric, MetricSet};
48use crate::metrics::{
49    abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, tokens, wmc,
50};
51use crate::spaces::SpaceKind;
52use crate::suppression::SuppressionScope;
53use crate::{function, ops};
54
55// The per-metric and VCS wire structs live in domain submodules; the
56// `pub use` re-exports keep their public `wire::<Struct>` paths intact
57// (these structs are a published deserialization API). The aggregate
58// shapes (`CodeMetrics`, `FuncSpace`, `Ops`, `FunctionSpan`), the
59// shared helpers, and the round-trip tests stay here.
60mod metrics;
61// The VCS arm is wholly `vcs-git`-gated; gating the module (and its
62// re-export) keeps default-feature builds free of unused-import noise.
63#[cfg(feature = "vcs-git")]
64mod vcs;
65
66pub use metrics::*;
67#[cfg(feature = "vcs-git")]
68pub use vcs::*;
69
70/// `serde(default)` for a non-finite-capable `f64` field: a key absent
71/// from the document (TOML omits non-finite values, which have no null
72/// literal there) deserializes back to `NaN`.
73fn nan_default() -> f64 {
74    f64::NAN
75}
76
77/// (De)serialization of a non-finite-capable `f64` for `#[serde(with)]`.
78///
79/// Serialize maps a non-finite value to the format's null
80/// (`serialize_none` → native `null` in JSON/YAML/CBOR, omitted key in
81/// TOML); deserialize maps a `null` (or, paired with
82/// [`nan_default`], an absent key) back to `f64::NAN`. Finite values are
83/// passed through verbatim. This is the structured-output arm of the
84/// non-finite policy (#531); the human-readable arm lives in
85/// `crate::output::numfmt`.
86mod non_finite {
87    use serde::{Deserialize, Deserializer, Serializer};
88
89    // serde's `#[serde(with = ...)]` contract fixes this signature to
90    // `(&T, S)`, so the by-reference `f64` is required, not a choice.
91    #[allow(clippy::trivially_copy_pass_by_ref)]
92    pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
93        if value.is_finite() {
94            serializer.serialize_f64(*value)
95        } else {
96            serializer.serialize_none()
97        }
98    }
99
100    pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
101        Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
102    }
103}
104
105/// A file's `risk_score` at its most-recent present point, or `0.0` if it
106/// has no present point (which the trend builder never produces). Used to
107/// rank files for `top_files` truncation.
108#[cfg(feature = "vcs-git")]
109fn latest_present_risk(points: &[Option<crate::vcs::Stats>]) -> f64 {
110    points
111        .iter()
112        .rev()
113        .find_map(|s| s.as_ref().map(|s| s.risk_score))
114        .unwrap_or(0.0)
115}
116
117#[cfg(all(test, feature = "vcs-git"))]
118mod trend_wire_tests {
119    use super::*;
120
121    // Exact-equality on f64 is intentional: the values are the
122    // exactly-representable literals fed into the fixtures.
123    #[allow(clippy::float_cmp)]
124    fn risk(points: &[Option<f64>]) -> f64 {
125        let owned: Vec<Option<crate::vcs::Stats>> = points
126            .iter()
127            .map(|p| {
128                p.map(|risk_score| crate::vcs::Stats {
129                    risk_score,
130                    ..Default::default()
131                })
132            })
133            .collect();
134        latest_present_risk(&owned)
135    }
136
137    #[test]
138    #[allow(clippy::float_cmp)]
139    fn latest_present_risk_picks_the_newest_present_point() {
140        // Scans from the back: the most-recent present point wins, even
141        // with later `None`s and earlier present points.
142        assert_eq!(risk(&[Some(1.0), None, Some(3.0), None]), 3.0);
143    }
144
145    #[test]
146    #[allow(clippy::float_cmp)]
147    fn latest_present_risk_defaults_to_zero_when_all_absent() {
148        // The documented fallback for a file with no present point (which
149        // the trend builder never produces, but the helper still defines).
150        assert_eq!(risk(&[None, None]), 0.0);
151        assert_eq!(risk(&[]), 0.0);
152    }
153
154    /// A `Vcs` row with finite values plus the optional blocks set.
155    fn sample_vcs() -> Vcs {
156        Vcs {
157            commits_long: 12,
158            commits_recent: 4,
159            churn_long: 340,
160            churn_recent: 90,
161            authors_long: 3,
162            authors_recent: 2,
163            ownership_top_share: 0.625,
164            burst: 0.333,
165            bug_fix_commits: 2,
166            security_fix_commits: 1,
167            revert_commits: 0,
168            age_days: 200,
169            last_modified_days: 5,
170            change_entropy_long: 1.5,
171            change_entropy_recent: 0.5,
172            cochange_entropy_long: 2.0,
173            cochange_entropy_recent: 0.25,
174            risk_score: 7.5,
175            hotspot_score: Some(3.25),
176            author_ids: Some(vec!["deadbeef".to_owned()]),
177        }
178    }
179
180    /// Issue #702: the `Vcs` derived/ratio f64 fields must carry the #531
181    /// `non_finite` (de)serialization — a NaN serializes to a format null
182    /// and round-trips back to NaN, instead of erroring `to_string` (NaN is
183    /// invalid JSON). Covers JSON, YAML, and CBOR.
184    #[test]
185    fn vcs_non_finite_floats_round_trip_as_null() {
186        let mut row = sample_vcs();
187        row.risk_score = f64::NAN;
188        row.burst = f64::INFINITY;
189        row.cochange_entropy_recent = f64::NEG_INFINITY;
190
191        // JSON: serialization must succeed (would error without non_finite)
192        // and the non-finite fields appear as null.
193        let json = serde_json::to_string(&row).expect("serialize Vcs with NaN to JSON");
194        assert!(json.contains("\"risk_score\":null"), "got {json}");
195        let from_json: Vcs = serde_json::from_str(&json).expect("parse Vcs from JSON");
196        assert!(from_json.risk_score.is_nan());
197        assert!(from_json.burst.is_nan());
198        assert!(from_json.cochange_entropy_recent.is_nan());
199        // Finite fields are unchanged.
200        assert_eq!(from_json.commits_long, row.commits_long);
201        assert!((from_json.ownership_top_share - row.ownership_top_share).abs() < 1e-12);
202
203        // YAML round-trip.
204        let yaml = serde_yaml::to_string(&row).expect("serialize Vcs to YAML");
205        let from_yaml: Vcs = serde_yaml::from_str(&yaml).expect("parse Vcs from YAML");
206        assert!(from_yaml.risk_score.is_nan() && from_yaml.burst.is_nan());
207
208        // CBOR round-trip.
209        let mut bytes = Vec::new();
210        ciborium::into_writer(&row, &mut bytes).expect("serialize Vcs to CBOR");
211        let from_cbor: Vcs = ciborium::from_reader(bytes.as_slice()).expect("parse Vcs from CBOR");
212        assert!(from_cbor.risk_score.is_nan() && from_cbor.cochange_entropy_recent.is_nan());
213    }
214
215    /// Issue #702: `VcsTrendPoint` and `VcsTrend` carry the metric block
216    /// under a nested `vcs` key (not `#[serde(flatten)]`). CBOR is a
217    /// *written* trend format but was never *read back* in tests — pin the
218    /// round-trip for both YAML and CBOR.
219    #[test]
220    fn vcs_trend_point_round_trips_through_yaml_and_cbor() {
221        let point = VcsTrendPoint {
222            as_of: 1_700_000_000,
223            vcs: sample_vcs(),
224        };
225
226        let yaml = serde_yaml::to_string(&point).expect("serialize VcsTrendPoint to YAML");
227        let from_yaml: VcsTrendPoint = serde_yaml::from_str(&yaml).expect("parse point from YAML");
228        assert_eq!(from_yaml, point);
229
230        let mut bytes = Vec::new();
231        ciborium::into_writer(&point, &mut bytes).expect("serialize VcsTrendPoint to CBOR");
232        let from_cbor: VcsTrendPoint =
233            ciborium::from_reader(bytes.as_slice()).expect("parse point from CBOR");
234        assert_eq!(from_cbor, point);
235    }
236
237    #[test]
238    fn vcs_trend_round_trips_through_yaml_and_cbor() {
239        let trend = VcsTrend {
240            trend_schema_version: 1,
241            vcs_schema_version: 2,
242            risk_score_version: 2,
243            long_window_days: 365,
244            recent_window_days: 90,
245            truncated_shallow_clone: false,
246            as_of_points: vec![1_699_000_000, 1_700_000_000],
247            files: std::collections::BTreeMap::from([(
248                "src/lib.rs".to_owned(),
249                vec![
250                    None,
251                    Some(VcsTrendPoint {
252                        as_of: 1_700_000_000,
253                        vcs: sample_vcs(),
254                    }),
255                ],
256            )]),
257            deltas: VcsTrendDeltas::default(),
258        };
259
260        let yaml = serde_yaml::to_string(&trend).expect("serialize VcsTrend to YAML");
261        let from_yaml: VcsTrend = serde_yaml::from_str(&yaml).expect("parse VcsTrend from YAML");
262        assert_eq!(from_yaml, trend);
263
264        let mut bytes = Vec::new();
265        ciborium::into_writer(&trend, &mut bytes).expect("serialize VcsTrend to CBOR");
266        let from_cbor: VcsTrend =
267            ciborium::from_reader(bytes.as_slice()).expect("parse VcsTrend from CBOR");
268        assert_eq!(from_cbor, trend);
269    }
270}
271
272/// Wire form of [`crate::spaces::CodeMetrics`].
273///
274/// Each metric is an `Option` skipped when `None`: an unselected metric
275/// (or a class-only metric flagged `is_disabled`) is absent from the
276/// document. On read, a present key ⇒ the metric was selected; absent ⇒
277/// unselected. [`CodeMetrics::selected`] rebuilds the [`MetricSet`].
278///
279/// Field order matches the compute type's `Serialize` order exactly so
280/// the emitted record is byte-identical.
281#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
282pub struct CodeMetrics {
283    /// `NArgs` metric, if selected.
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub nargs: Option<Nargs>,
286    /// `Nexits` metric, if selected.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub nexits: Option<Nexits>,
289    /// `Cognitive` metric, if selected.
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub cognitive: Option<Cognitive>,
292    /// `Cyclomatic` metric, if selected.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub cyclomatic: Option<Cyclomatic>,
295    /// `Halstead` metric, if selected.
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub halstead: Option<Halstead>,
298    /// `Loc` metric, if selected.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub loc: Option<Loc>,
301    /// `Nom` metric, if selected.
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub nom: Option<Nom>,
304    /// `Tokens` metric, if selected.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub tokens: Option<Tokens>,
307    /// `Mi` metric, if selected.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub mi: Option<Mi>,
310    /// `Abc` metric, if selected.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub abc: Option<Abc>,
313    /// `Wmc` metric, if selected and not disabled.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub wmc: Option<Wmc>,
316    /// `Npm` metric, if selected and not disabled.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub npm: Option<Npm>,
319    /// `Npa` metric, if selected and not disabled.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub npa: Option<Npa>,
322    /// Change-history (VCS) metrics, present only for the file-level
323    /// space when a history walk supplied them. Gated behind `vcs-git`.
324    #[cfg(feature = "vcs-git")]
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub vcs: Option<Vcs>,
327}
328
329impl From<&crate::spaces::CodeMetrics> for CodeMetrics {
330    fn from(c: &crate::spaces::CodeMetrics) -> Self {
331        let sel = c.selected;
332        // The class-only metrics carry their own disabled flag (a
333        // non-class language never emits them) alongside the selection
334        // mask, mirroring the compute `Serialize` impl exactly.
335        let on = |m: Metric| sel.contains(m);
336        Self {
337            nargs: on(Metric::Nargs).then(|| Nargs::from(&c.nargs)),
338            nexits: on(Metric::Nexits).then(|| Nexits::from(&c.nexits)),
339            cognitive: on(Metric::Cognitive).then(|| Cognitive::from(&c.cognitive)),
340            cyclomatic: on(Metric::Cyclomatic).then(|| Cyclomatic::from(&c.cyclomatic)),
341            halstead: on(Metric::Halstead).then(|| Halstead::from(&c.halstead)),
342            loc: on(Metric::Loc).then(|| Loc::from(&c.loc)),
343            nom: on(Metric::Nom).then(|| Nom::from(&c.nom)),
344            tokens: on(Metric::Tokens).then(|| Tokens::from(&c.tokens)),
345            mi: on(Metric::Mi).then(|| Mi::from(&c.mi)),
346            abc: on(Metric::Abc).then(|| Abc::from(&c.abc)),
347            wmc: (on(Metric::Wmc) && !c.wmc.is_disabled()).then(|| Wmc::from(&c.wmc)),
348            npm: (on(Metric::Npm) && !c.npm.is_disabled()).then(|| Npm::from(&c.npm)),
349            npa: (on(Metric::Npa) && !c.npa.is_disabled()).then(|| Npa::from(&c.npa)),
350            // VCS data is injected post-analysis, so its presence — not
351            // the selection mask — governs emission.
352            #[cfg(feature = "vcs-git")]
353            vcs: c.vcs.as_ref().map(Vcs::from),
354        }
355    }
356}
357
358impl CodeMetrics {
359    /// Reconstruct the [`MetricSet`] from the metrics present on the wire.
360    ///
361    /// A metric key present in the deserialized document means it was
362    /// selected when the document was produced; absent means it was
363    /// pruned (unselected, or a disabled class-only metric). This is the
364    /// inverse of the selection eliding in [`From`].
365    #[must_use]
366    pub fn selected(&self) -> MetricSet {
367        let mut set = MetricSet::empty();
368        let mut mark = |present: bool, metric: Metric| {
369            if present {
370                set.insert(metric);
371            }
372        };
373        mark(self.nargs.is_some(), Metric::Nargs);
374        mark(self.nexits.is_some(), Metric::Nexits);
375        mark(self.cognitive.is_some(), Metric::Cognitive);
376        mark(self.cyclomatic.is_some(), Metric::Cyclomatic);
377        mark(self.halstead.is_some(), Metric::Halstead);
378        mark(self.loc.is_some(), Metric::Loc);
379        mark(self.nom.is_some(), Metric::Nom);
380        mark(self.tokens.is_some(), Metric::Tokens);
381        mark(self.mi.is_some(), Metric::Mi);
382        mark(self.abc.is_some(), Metric::Abc);
383        mark(self.wmc.is_some(), Metric::Wmc);
384        mark(self.npm.is_some(), Metric::Npm);
385        mark(self.npa.is_some(), Metric::Npa);
386        set
387    }
388}
389
390/// Wire form of [`crate::spaces::FuncSpace`] — a recursive metric tree.
391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
392pub struct FuncSpace {
393    /// The name of the space (file path or AST-derived identifier).
394    pub name: Option<String>,
395    /// The first line of the space.
396    pub start_line: usize,
397    /// The last line of the space.
398    pub end_line: usize,
399    /// The space kind.
400    pub kind: SpaceKind,
401    /// All nested subspaces.
402    pub spaces: Vec<FuncSpace>,
403    /// The metrics of the space.
404    pub metrics: CodeMetrics,
405    /// In-source suppression markers applying to the space (elided when
406    /// empty, matching the compute type's schema).
407    #[serde(default, skip_serializing_if = "SuppressionScope::is_empty")]
408    pub suppressed: SuppressionScope,
409}
410
411impl From<&crate::spaces::FuncSpace> for FuncSpace {
412    fn from(f: &crate::spaces::FuncSpace) -> Self {
413        Self {
414            name: f.name.clone(),
415            start_line: f.start_line,
416            end_line: f.end_line,
417            kind: f.kind,
418            spaces: f.spaces.iter().map(FuncSpace::from).collect(),
419            metrics: CodeMetrics::from(&f.metrics),
420            suppressed: f.suppressed.clone(),
421        }
422    }
423}
424
425/// Wire form of [`crate::Ops`] — a recursive operator/operand tree.
426#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
427pub struct Ops {
428    /// The name of the space (file path or AST-derived identifier).
429    pub name: Option<String>,
430    /// Whether [`Ops::name`] was derived via lossy UTF-8 conversion.
431    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
432    pub name_was_lossy: bool,
433    /// The first line of the space.
434    pub start_line: usize,
435    /// The last line of the space.
436    pub end_line: usize,
437    /// The space kind.
438    pub kind: SpaceKind,
439    /// All nested subspaces.
440    pub spaces: Vec<Ops>,
441    /// The operands in the space.
442    pub operands: Vec<String>,
443    /// The operators in the space.
444    pub operators: Vec<String>,
445}
446
447impl From<&ops::Ops> for Ops {
448    fn from(o: &ops::Ops) -> Self {
449        Self {
450            name: o.name.clone(),
451            name_was_lossy: o.name_was_lossy,
452            start_line: o.start_line,
453            end_line: o.end_line,
454            kind: o.kind,
455            spaces: o.spaces.iter().map(Ops::from).collect(),
456            operands: o.operands.clone(),
457            operators: o.operators.clone(),
458        }
459    }
460}
461
462/// Wire form of [`crate::FunctionSpan`].
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
464pub struct FunctionSpan {
465    /// The function name, or `null` when it could not be resolved.
466    pub name: Option<String>,
467    /// The first line of the function.
468    pub start_line: usize,
469    /// The last line of the function.
470    pub end_line: usize,
471}
472
473impl From<&function::FunctionSpan> for FunctionSpan {
474    fn from(f: &function::FunctionSpan) -> Self {
475        Self {
476            name: f.name.clone(),
477            start_line: f.start_line,
478            end_line: f.end_line,
479        }
480    }
481}
482
483// ---------------------------------------------------------------------------
484// Delegating `Serialize` impls: the compute types serialize *through* the
485// wire projection, so the wire structs above are the single source of the
486// emitted format.
487// ---------------------------------------------------------------------------
488
489/// Implement `Serialize` for a compute type by projecting it to its wire
490/// form and serializing that, keeping the wire struct the sole definition
491/// of the output shape.
492macro_rules! serialize_via_wire {
493    ($compute:ty => $wire:ident) => {
494        impl Serialize for $compute {
495            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
496                $wire::from(self).serialize(serializer)
497            }
498        }
499    };
500}
501
502serialize_via_wire!(abc::Stats => Abc);
503serialize_via_wire!(cognitive::Stats => Cognitive);
504serialize_via_wire!(cyclomatic::Stats => Cyclomatic);
505serialize_via_wire!(nexits::Stats => Nexits);
506serialize_via_wire!(halstead::Stats => Halstead);
507serialize_via_wire!(loc::Stats => Loc);
508serialize_via_wire!(mi::Stats => Mi);
509serialize_via_wire!(nargs::Stats => Nargs);
510serialize_via_wire!(nom::Stats => Nom);
511serialize_via_wire!(npa::Stats => Npa);
512serialize_via_wire!(npm::Stats => Npm);
513serialize_via_wire!(tokens::Stats => Tokens);
514serialize_via_wire!(wmc::Stats => Wmc);
515serialize_via_wire!(crate::spaces::CodeMetrics => CodeMetrics);
516serialize_via_wire!(crate::spaces::FuncSpace => FuncSpace);
517serialize_via_wire!(ops::Ops => Ops);
518serialize_via_wire!(function::FunctionSpan => FunctionSpan);
519
520#[cfg(test)]
521// The round-trip assertions compare floats exactly on purpose: CBOR stores
522// raw IEEE-754 bits, YAML/TOML emit full precision, and serde_json's
523// `float_roundtrip` feature (enabled in Cargo.toml) makes its parser
524// bit-exact — so a value serialized and read back equals the original
525// down to the last bit. Exactness is the property under test.
526#[allow(clippy::float_cmp)]
527mod tests {
528    use super::*;
529    use crate::RustParser;
530    use crate::tools::check_func_space;
531
532    /// A branchy multi-function Rust fixture so several metrics are
533    /// non-trivial (cyclomatic > 1, multiple spaces, real Halstead/MI).
534    const FIXTURE: &str = "\
535fn classify(x: i32) -> i32 {
536    if x > 0 {
537        x * 2
538    } else if x < 0 {
539        -x
540    } else {
541        0
542    }
543}
544
545fn run() {
546    let adder = |a: i32, b: i32| a + b;
547    let _ = adder(classify(3), classify(-4));
548}
549";
550
551    /// Independent oracle of the `FIXTURE` tree's hand-verified integer
552    /// metrics. `assert_eq!(back, fs.to_wire())` alone cannot catch a
553    /// mismapped `From` field — both sides flow through the same projection,
554    /// so a swap corrupts them identically — so the round-trip tests anchor
555    /// against these known values to break the closed loop. (Grammar bumps
556    /// may shift them; update alongside the metric snapshot tests.)
557    fn assert_fixture_oracle(tree: &FuncSpace) {
558        // Two top-level functions: `classify` and `run`.
559        assert_eq!(tree.kind, SpaceKind::Unit);
560        assert_eq!(tree.spaces.len(), 2, "classify + run");
561
562        let m = &tree.metrics;
563        assert_eq!(m.cyclomatic.as_ref().unwrap().sum, 6, "unit cyclomatic.sum");
564        // The unit's *own* cyclomatic is the base 1 (no decisions at file
565        // top level), while `sum` rolls up both functions and the closure
566        // (#958). `value != sum` here is the whole point of the field.
567        assert_eq!(
568            m.cyclomatic.as_ref().unwrap().value,
569            1,
570            "unit cyclomatic.value (own, excludes children)"
571        );
572        assert_eq!(m.cognitive.as_ref().unwrap().sum, 3, "unit cognitive.sum");
573        assert_eq!(
574            m.cognitive.as_ref().unwrap().value,
575            0,
576            "unit cognitive.value (own)"
577        );
578        assert_eq!(m.loc.as_ref().unwrap().sloc, 14, "unit loc.sloc");
579        assert_eq!(m.nom.as_ref().unwrap().total, 3, "unit nom.total");
580        // ABC is finite and distinguishes assignments/branches/conditions —
581        // a swap of those accessors in `From` would surface here.
582        let abc = m.abc.as_ref().unwrap();
583        assert_eq!((abc.assignments, abc.branches, abc.conditions), (2, 3, 4));
584
585        let classify = tree
586            .spaces
587            .iter()
588            .find(|s| s.name.as_deref() == Some("classify"))
589            .expect("classify space");
590        let classify_cyclo = classify.metrics.cyclomatic.as_ref().unwrap();
591        assert_eq!(classify_cyclo.sum, 3, "classify cyclomatic.sum");
592        // `classify` is a leaf, so its own value equals its subtree sum.
593        assert_eq!(classify_cyclo.value, 3, "classify cyclomatic.value (leaf)");
594
595        // `run` is an interior space: it owns the `adder` closure child.
596        // Its own cyclomatic is the base 1, but `sum` (2) folds in the
597        // closure's base 1 — the exact interior-space case #958 closes.
598        let run = tree
599            .spaces
600            .iter()
601            .find(|s| s.name.as_deref() == Some("run"))
602            .expect("run space");
603        let run_cyclo = run.metrics.cyclomatic.as_ref().unwrap();
604        assert_eq!(run_cyclo.sum, 2, "run cyclomatic.sum (run + adder closure)");
605        assert_eq!(
606            run_cyclo.value, 1,
607            "run cyclomatic.value (own, excludes closure)"
608        );
609    }
610
611    /// The acceptance criterion: a `FuncSpace` serialized to JSON parses
612    /// back into a `wire::FuncSpace` that re-serializes byte-for-byte, is
613    /// structurally equal to the source projection, and carries the
614    /// hand-verified metric values.
615    #[test]
616    fn json_round_trips() {
617        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
618            let json = serde_json::to_string(&fs).expect("serialize FuncSpace to JSON");
619            let back: FuncSpace = serde_json::from_str(&json).expect("parse wire::FuncSpace");
620            assert_eq!(
621                back,
622                fs.to_wire(),
623                "deserialized wire tree must equal the projection"
624            );
625            assert_eq!(
626                serde_json::to_string(&back).expect("re-serialize wire"),
627                json,
628                "re-serialized wire must be byte-identical to the original JSON",
629            );
630            // Independent oracle: guards `From`-projection correctness, which
631            // the closed serialize→deserialize loop above cannot.
632            assert_fixture_oracle(&back);
633        });
634    }
635
636    #[test]
637    fn yaml_round_trips() {
638        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
639            let yaml = serde_yaml::to_string(&fs).expect("serialize to YAML");
640            let back: FuncSpace = serde_yaml::from_str(&yaml).expect("parse wire from YAML");
641            assert_eq!(back, fs.to_wire());
642            assert_eq!(serde_yaml::to_string(&back).expect("re-serialize"), yaml);
643        });
644    }
645
646    #[test]
647    fn toml_round_trips() {
648        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
649            let toml = toml::to_string(&fs).expect("serialize to TOML");
650            let back: FuncSpace = toml::from_str(&toml).expect("parse wire from TOML");
651            assert_eq!(back, fs.to_wire());
652            assert_eq!(toml::to_string(&back).expect("re-serialize"), toml);
653        });
654    }
655
656    #[test]
657    fn cbor_round_trips() {
658        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
659            let mut bytes = Vec::new();
660            ciborium::into_writer(&fs, &mut bytes).expect("serialize to CBOR");
661            let back: FuncSpace =
662                ciborium::from_reader(bytes.as_slice()).expect("parse wire from CBOR");
663            assert_eq!(back, fs.to_wire());
664            let mut re = Vec::new();
665            ciborium::into_writer(&back, &mut re).expect("re-serialize");
666            assert_eq!(re, bytes, "CBOR re-serialization must be byte-identical");
667        });
668    }
669
670    /// `FunctionSpan` (#536 shape: `name: Option<String>`, no `error`
671    /// field) round-trips through JSON for both a resolved name and an
672    /// unresolved one (`None` → JSON `null`), and the serialized object
673    /// carries no `error` key.
674    #[test]
675    fn function_span_round_trips() {
676        let resolved = FunctionSpan {
677            name: Some("foo".to_owned()),
678            start_line: 1,
679            end_line: 4,
680        };
681        let unresolved = FunctionSpan {
682            name: None,
683            start_line: 7,
684            end_line: 8,
685        };
686
687        for span in [resolved, unresolved] {
688            let json = serde_json::to_string(&span).expect("serialize FunctionSpan");
689            assert!(
690                !json.contains("error"),
691                "FunctionSpan JSON must not carry an `error` key, got {json}",
692            );
693            let back: FunctionSpan = serde_json::from_str(&json).expect("parse FunctionSpan");
694            assert_eq!(back, span, "FunctionSpan must round-trip through JSON");
695        }
696
697        // The unresolved span emits `name: null`, never an empty string.
698        let json = serde_json::to_string(&FunctionSpan {
699            name: None,
700            start_line: 7,
701            end_line: 8,
702        })
703        .expect("serialize");
704        assert!(
705            json.contains(r#""name":null"#),
706            "unresolved name must serialize to JSON null, got {json}",
707        );
708    }
709
710    /// A non-finite float (`NaN`/`±∞`) serializes to the format's null and
711    /// deserializes back to `NaN`: native `null` (JSON/YAML/CBOR) and an
712    /// omitted key (TOML, which has no null literal, recovered via the
713    /// field default). Mi fields are the simplest plain-`f64` carrier.
714    #[test]
715    fn non_finite_floats_round_trip_as_null_or_omission() {
716        for probe in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
717            let mi = Mi {
718                original: probe,
719                sei: 1.5,
720                visual_studio: 2.0,
721            };
722
723            let json = serde_json::to_string(&mi).expect("JSON");
724            assert!(
725                json.contains(r#""original":null"#),
726                "non-finite must serialize to JSON null, got {json}",
727            );
728            assert!(
729                serde_json::from_str::<Mi>(&json)
730                    .expect("parse")
731                    .original
732                    .is_nan(),
733                "JSON null must deserialize back to NaN",
734            );
735
736            let yaml = serde_yaml::to_string(&mi).expect("YAML");
737            assert!(
738                yaml.contains("original: null"),
739                "non-finite must serialize to YAML null, got {yaml}",
740            );
741            assert!(
742                serde_yaml::from_str::<Mi>(&yaml)
743                    .expect("parse")
744                    .original
745                    .is_nan()
746            );
747
748            let toml = toml::to_string(&mi).expect("TOML");
749            assert!(
750                !toml.contains("original"),
751                "TOML must omit the non-finite key (no null literal), got {toml}",
752            );
753            assert!(
754                toml::from_str::<Mi>(&toml)
755                    .expect("parse")
756                    .original
757                    .is_nan(),
758                "omitted TOML key must default back to NaN",
759            );
760
761            // CBOR: serialize to bytes, confirm the field decodes as a null
762            // token, and that it deserializes back to NaN.
763            let mut cbor = Vec::new();
764            ciborium::into_writer(&mi, &mut cbor).expect("CBOR");
765            let value: ciborium::value::Value =
766                ciborium::from_reader(cbor.as_slice()).expect("parse cbor value");
767            let ciborium::value::Value::Map(map) = &value else {
768                panic!("CBOR root is not a map");
769            };
770            let original_key = ciborium::value::Value::Text("original".to_owned());
771            let original = map
772                .iter()
773                .find_map(|(k, v)| (*k == original_key).then_some(v));
774            assert_eq!(
775                original,
776                Some(&ciborium::value::Value::Null),
777                "non-finite must serialize to CBOR null",
778            );
779            assert!(
780                ciborium::from_reader::<Mi, _>(cbor.as_slice())
781                    .expect("parse")
782                    .original
783                    .is_nan(),
784                "CBOR null must deserialize back to NaN",
785            );
786
787            // Finite siblings are unaffected.
788            let back = serde_json::from_str::<Mi>(&json).expect("parse");
789            assert_eq!(back.sei, 1.5);
790            assert_eq!(back.visual_studio, 2.0);
791        }
792    }
793
794    /// `selected()` reconstructs the `MetricSet` from the metric keys
795    /// present on the wire: a full tree marks every metric, a pruned tree
796    /// (here keeping only `loc`) marks exactly that one.
797    #[test]
798    fn selected_is_inferred_from_present_keys() {
799        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
800            let full = fs.metrics.to_wire();
801            let selected = full.selected();
802            assert!(selected.contains(Metric::Loc));
803            assert!(selected.contains(Metric::Cyclomatic));
804
805            // A pruned document (only `loc` present) infers only `loc`.
806            let json = serde_json::to_string(&full).expect("serialize metrics");
807            let mut value: serde_json::Value = serde_json::from_str(&json).expect("parse value");
808            let obj = value.as_object_mut().expect("metrics object");
809            obj.retain(|k, _| k == "loc");
810            let pruned: CodeMetrics =
811                serde_json::from_value(value).expect("parse pruned wire metrics");
812            let pruned_selected = pruned.selected();
813            assert!(pruned_selected.contains(Metric::Loc));
814            assert!(!pruned_selected.contains(Metric::Cyclomatic));
815            assert!(pruned.cyclomatic.is_none());
816        });
817    }
818}