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` tree). 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//! [`Ops`] is the one measured exception: it serializes through a
31//! borrowed mirror instead, for the reasons the private `ops_view`
32//! submodule documents.
33//!
34//! ## Field conventions
35//!
36//! - Integer-valued metrics (counts, sums, min/max) are `u64` (#530).
37//! - Derived / ratio / average fields are `f64` and carry the
38//!   `non_finite` (de)serialization: a non-finite value (`NaN`/`±∞`,
39//!   meaning "not applicable") serializes to a null uniformly across
40//!   formats — native `null` in JSON/YAML/CBOR, an omitted key in TOML —
41//!   and deserializes back to `f64::NAN` (#531). Finite values pass
42//!   through unchanged, so the round-trip is symmetric and needs no
43//!   `Option`.
44//! - [`CodeMetrics`] elides unselected metrics (each is an `Option`
45//!   skipped when `None`); on read, a present key ⇒ selected, absent ⇒
46//!   unselected. [`CodeMetrics::selected`] reconstructs the
47//!   [`MetricSet`] from the present keys.
48
49use serde::{Deserialize, Serialize, Serializer};
50
51use crate::metric_set::{Metric, MetricSet};
52use crate::metrics::{
53    abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, tokens, wmc,
54};
55use crate::spaces::SpaceKind;
56use crate::suppression::SuppressionScope;
57use crate::{function, ops};
58
59// The per-metric and VCS wire structs live in domain submodules; the
60// `pub use` re-exports keep their public `wire::<Struct>` paths intact
61// (these structs are a published deserialization API). The aggregate
62// shapes (`CodeMetrics`, `FuncSpace`, `Ops`, `FunctionSpan`), the
63// shared helpers, and the round-trip tests stay here.
64mod metrics;
65// `crate::Ops`'s borrowed serialize path. Nothing to re-export: it
66// defines no public type, only the `Serialize` impl `Ops` delegates to.
67mod ops_view;
68// The VCS arm is wholly `vcs-git`-gated; gating the module (and its
69// re-export) keeps default-feature builds free of unused-import noise.
70#[cfg(feature = "vcs-git")]
71mod vcs;
72
73pub use metrics::*;
74#[cfg(feature = "vcs-git")]
75pub use vcs::*;
76
77/// `serde(default)` for a non-finite-capable `f64` field: a key absent
78/// from the document (TOML omits non-finite values, which have no null
79/// literal there) deserializes back to `NaN`.
80fn nan_default() -> f64 {
81    f64::NAN
82}
83
84/// (De)serialization of a non-finite-capable `f64` for `#[serde(with)]`.
85///
86/// Serialize maps a non-finite value to the format's null
87/// (`serialize_none` → native `null` in JSON/YAML/CBOR, omitted key in
88/// TOML); deserialize maps a `null` (or, paired with
89/// [`nan_default`], an absent key) back to `f64::NAN`. Finite values are
90/// passed through verbatim. This is the structured-output arm of the
91/// non-finite policy (#531); the human-readable arm lives in
92/// `crate::output::numfmt`.
93mod non_finite {
94    use serde::{Deserialize, Deserializer, Serializer};
95
96    // serde's `#[serde(with = ...)]` contract fixes this signature to
97    // `(&T, S)`, so the by-reference `f64` is required, not a choice.
98    #[allow(clippy::trivially_copy_pass_by_ref)]
99    pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
100        if value.is_finite() {
101            serializer.serialize_f64(*value)
102        } else {
103            serializer.serialize_none()
104        }
105    }
106
107    pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
108        Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
109    }
110}
111
112/// A file's `risk_score` at its most-recent present point, or `0.0` if it
113/// has no present point (which the trend builder never produces). Used to
114/// rank files for `top_files` truncation.
115#[cfg(feature = "vcs-git")]
116fn latest_present_risk(points: &[Option<crate::vcs::Stats>]) -> f64 {
117    points
118        .iter()
119        .rev()
120        .find_map(|s| s.as_ref().map(|s| s.risk_score))
121        .unwrap_or(0.0)
122}
123
124#[cfg(all(test, feature = "vcs-git"))]
125mod trend_wire_tests {
126    use super::*;
127
128    // Exact-equality on f64 is intentional: the values are the
129    // exactly-representable literals fed into the fixtures.
130    #[allow(clippy::float_cmp)]
131    fn risk(points: &[Option<f64>]) -> f64 {
132        let owned: Vec<Option<crate::vcs::Stats>> = points
133            .iter()
134            .map(|p| {
135                p.map(|risk_score| crate::vcs::Stats {
136                    risk_score,
137                    ..Default::default()
138                })
139            })
140            .collect();
141        latest_present_risk(&owned)
142    }
143
144    #[test]
145    #[allow(clippy::float_cmp)]
146    fn latest_present_risk_picks_the_newest_present_point() {
147        // Scans from the back: the most-recent present point wins, even
148        // with later `None`s and earlier present points.
149        assert_eq!(risk(&[Some(1.0), None, Some(3.0), None]), 3.0);
150    }
151
152    #[test]
153    #[allow(clippy::float_cmp)]
154    fn latest_present_risk_defaults_to_zero_when_all_absent() {
155        // The documented fallback for a file with no present point (which
156        // the trend builder never produces, but the helper still defines).
157        assert_eq!(risk(&[None, None]), 0.0);
158        assert_eq!(risk(&[]), 0.0);
159    }
160
161    /// A `Vcs` row with finite values plus the optional blocks set.
162    fn sample_vcs() -> Vcs {
163        Vcs {
164            commits_long: 12,
165            commits_recent: 4,
166            churn_long: 340,
167            churn_recent: 90,
168            authors_long: 3,
169            authors_recent: 2,
170            ownership_top_share: 0.625,
171            burst: 0.333,
172            bug_fix_commits: 2,
173            security_fix_commits: 1,
174            revert_commits: 0,
175            age_days: 200,
176            last_modified_days: 5,
177            change_entropy_long: 1.5,
178            change_entropy_recent: 0.5,
179            cochange_entropy_long: 2.0,
180            cochange_entropy_recent: 0.25,
181            risk_score: 7.5,
182            hotspot_score: Some(3.25),
183            author_ids: Some(vec!["deadbeef".to_owned()]),
184        }
185    }
186
187    /// Issue #702: the `Vcs` derived/ratio f64 fields must carry the #531
188    /// `non_finite` (de)serialization — a NaN serializes to a format null
189    /// and round-trips back to NaN, instead of erroring `to_string` (NaN is
190    /// invalid JSON). Covers JSON, YAML, and CBOR.
191    #[test]
192    fn vcs_non_finite_floats_round_trip_as_null() {
193        let mut row = sample_vcs();
194        row.risk_score = f64::NAN;
195        row.burst = f64::INFINITY;
196        row.cochange_entropy_recent = f64::NEG_INFINITY;
197
198        // JSON: serialization must succeed (would error without non_finite)
199        // and the non-finite fields appear as null.
200        let json = serde_json::to_string(&row).expect("serialize Vcs with NaN to JSON");
201        assert!(json.contains("\"risk_score\":null"), "got {json}");
202        let from_json: Vcs = serde_json::from_str(&json).expect("parse Vcs from JSON");
203        assert!(from_json.risk_score.is_nan());
204        assert!(from_json.burst.is_nan());
205        assert!(from_json.cochange_entropy_recent.is_nan());
206        // Finite fields are unchanged.
207        assert_eq!(from_json.commits_long, row.commits_long);
208        assert!((from_json.ownership_top_share - row.ownership_top_share).abs() < 1e-12);
209
210        // YAML round-trip.
211        let yaml = serde_saphyr::to_string(&row).expect("serialize Vcs to YAML");
212        let from_yaml: Vcs = serde_saphyr::from_str(&yaml).expect("parse Vcs from YAML");
213        assert!(from_yaml.risk_score.is_nan() && from_yaml.burst.is_nan());
214
215        // CBOR round-trip.
216        let mut bytes = Vec::new();
217        ciborium::into_writer(&row, &mut bytes).expect("serialize Vcs to CBOR");
218        let from_cbor: Vcs = ciborium::from_reader(bytes.as_slice()).expect("parse Vcs from CBOR");
219        assert!(from_cbor.risk_score.is_nan() && from_cbor.cochange_entropy_recent.is_nan());
220    }
221
222    /// Issue #702: `VcsTrendPoint` and `VcsTrend` carry the metric block
223    /// under a nested `vcs` key (not `#[serde(flatten)]`). CBOR is a
224    /// *written* trend format but was never *read back* in tests — pin the
225    /// round-trip for both YAML and CBOR.
226    #[test]
227    fn vcs_trend_point_round_trips_through_yaml_and_cbor() {
228        let point = VcsTrendPoint {
229            as_of: 1_700_000_000,
230            vcs: sample_vcs(),
231        };
232
233        let yaml = serde_saphyr::to_string(&point).expect("serialize VcsTrendPoint to YAML");
234        let from_yaml: VcsTrendPoint =
235            serde_saphyr::from_str(&yaml).expect("parse point from YAML");
236        assert_eq!(from_yaml, point);
237
238        let mut bytes = Vec::new();
239        ciborium::into_writer(&point, &mut bytes).expect("serialize VcsTrendPoint to CBOR");
240        let from_cbor: VcsTrendPoint =
241            ciborium::from_reader(bytes.as_slice()).expect("parse point from CBOR");
242        assert_eq!(from_cbor, point);
243    }
244
245    #[test]
246    fn vcs_trend_round_trips_through_yaml_and_cbor() {
247        let trend = VcsTrend {
248            trend_schema_version: 1,
249            vcs_schema_version: 2,
250            risk_score_version: 2,
251            long_window_days: 365,
252            recent_window_days: 90,
253            truncated_shallow_clone: false,
254            as_of_points: vec![1_699_000_000, 1_700_000_000],
255            files: std::collections::BTreeMap::from([(
256                "src/lib.rs".to_owned(),
257                vec![
258                    None,
259                    Some(VcsTrendPoint {
260                        as_of: 1_700_000_000,
261                        vcs: sample_vcs(),
262                    }),
263                ],
264            )]),
265            deltas: VcsTrendDeltas::default(),
266        };
267
268        let yaml = serde_saphyr::to_string(&trend).expect("serialize VcsTrend to YAML");
269        let from_yaml: VcsTrend = serde_saphyr::from_str(&yaml).expect("parse VcsTrend from YAML");
270        assert_eq!(from_yaml, trend);
271
272        let mut bytes = Vec::new();
273        ciborium::into_writer(&trend, &mut bytes).expect("serialize VcsTrend to CBOR");
274        let from_cbor: VcsTrend =
275            ciborium::from_reader(bytes.as_slice()).expect("parse VcsTrend from CBOR");
276        assert_eq!(from_cbor, trend);
277    }
278}
279
280/// Wire form of [`crate::spaces::CodeMetrics`].
281///
282/// Each metric is an `Option` skipped when `None`: an unselected metric
283/// (or a class-only metric flagged `is_disabled`) is absent from the
284/// document. On read, a present key ⇒ the metric was selected; absent ⇒
285/// unselected. [`CodeMetrics::selected`] rebuilds the [`MetricSet`].
286///
287/// Field order matches the compute type's `Serialize` order exactly so
288/// the emitted record is byte-identical.
289#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
290pub struct CodeMetrics {
291    /// `NArgs` metric, if selected.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub nargs: Option<Nargs>,
294    /// `Nexits` metric, if selected.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub nexits: Option<Nexits>,
297    /// `Cognitive` metric, if selected.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub cognitive: Option<Cognitive>,
300    /// `Cyclomatic` metric, if selected.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub cyclomatic: Option<Cyclomatic>,
303    /// `Halstead` metric, if selected.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub halstead: Option<Halstead>,
306    /// `Loc` metric, if selected.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub loc: Option<Loc>,
309    /// `Nom` metric, if selected.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub nom: Option<Nom>,
312    /// `Tokens` metric, if selected.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub tokens: Option<Tokens>,
315    /// `Mi` metric, if selected.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub mi: Option<Mi>,
318    /// `Abc` metric, if selected.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub abc: Option<Abc>,
321    /// `Wmc` metric, if selected and not disabled.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub wmc: Option<Wmc>,
324    /// `Npm` metric, if selected and not disabled.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub npm: Option<Npm>,
327    /// `Npa` metric, if selected and not disabled.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub npa: Option<Npa>,
330    /// Change-history (VCS) metrics, present only for the file-level
331    /// space when a history walk supplied them. Gated behind `vcs-git`.
332    #[cfg(feature = "vcs-git")]
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub vcs: Option<Vcs>,
335}
336
337impl From<&crate::spaces::CodeMetrics> for CodeMetrics {
338    fn from(c: &crate::spaces::CodeMetrics) -> Self {
339        let sel = c.selected;
340        // The class-only metrics carry their own disabled flag (a
341        // non-class language never emits them) alongside the selection
342        // mask, mirroring the compute `Serialize` impl exactly.
343        let on = |m: Metric| sel.contains(m);
344        Self {
345            nargs: on(Metric::Nargs).then(|| Nargs::from(&c.nargs)),
346            nexits: on(Metric::Nexits).then(|| Nexits::from(&c.nexits)),
347            cognitive: on(Metric::Cognitive).then(|| Cognitive::from(&c.cognitive)),
348            cyclomatic: on(Metric::Cyclomatic).then(|| Cyclomatic::from(&c.cyclomatic)),
349            halstead: on(Metric::Halstead).then(|| Halstead::from(&c.halstead)),
350            loc: on(Metric::Loc).then(|| Loc::from(&c.loc)),
351            nom: on(Metric::Nom).then(|| Nom::from(&c.nom)),
352            tokens: on(Metric::Tokens).then(|| Tokens::from(&c.tokens)),
353            mi: on(Metric::Mi).then(|| Mi::from(&c.mi)),
354            abc: on(Metric::Abc).then(|| Abc::from(&c.abc)),
355            wmc: (on(Metric::Wmc) && !c.wmc.is_disabled()).then(|| Wmc::from(&c.wmc)),
356            npm: (on(Metric::Npm) && !c.npm.is_disabled()).then(|| Npm::from(&c.npm)),
357            npa: (on(Metric::Npa) && !c.npa.is_disabled()).then(|| Npa::from(&c.npa)),
358            // VCS data is injected post-analysis, so its presence — not
359            // the selection mask — governs emission.
360            #[cfg(feature = "vcs-git")]
361            vcs: c.vcs.as_ref().map(Vcs::from),
362        }
363    }
364}
365
366impl CodeMetrics {
367    /// Reconstruct the [`MetricSet`] from the metrics present on the wire.
368    ///
369    /// A metric key present in the deserialized document means it was
370    /// selected when the document was produced; absent means it was
371    /// pruned (unselected, or a disabled class-only metric). This is the
372    /// inverse of the selection eliding in [`From`].
373    #[must_use]
374    pub fn selected(&self) -> MetricSet {
375        let mut set = MetricSet::empty();
376        let mut mark = |present: bool, metric: Metric| {
377            if present {
378                set.insert(metric);
379            }
380        };
381        mark(self.nargs.is_some(), Metric::Nargs);
382        mark(self.nexits.is_some(), Metric::Nexits);
383        mark(self.cognitive.is_some(), Metric::Cognitive);
384        mark(self.cyclomatic.is_some(), Metric::Cyclomatic);
385        mark(self.halstead.is_some(), Metric::Halstead);
386        mark(self.loc.is_some(), Metric::Loc);
387        mark(self.nom.is_some(), Metric::Nom);
388        mark(self.tokens.is_some(), Metric::Tokens);
389        mark(self.mi.is_some(), Metric::Mi);
390        mark(self.abc.is_some(), Metric::Abc);
391        mark(self.wmc.is_some(), Metric::Wmc);
392        mark(self.npm.is_some(), Metric::Npm);
393        mark(self.npa.is_some(), Metric::Npa);
394        set
395    }
396}
397
398/// Greatest space-nesting depth [`FuncSpace`] and [`Ops`] will serialize.
399///
400/// Both are trees, and `serde` cannot emit a tree without one native stack
401/// frame per level, so the depth has to be bounded somewhere: past it, the
402/// runtime aborts the process instead of raising a catchable panic
403/// (#1056). A space tree deeper than this fails serialization with an
404/// ordinary serializer error naming the limit.
405///
406/// The value matches the recursion limit `serde_json`'s `Deserializer`
407/// already imposes on the same documents, and is generous against both
408/// ends of that comparison. On the read side, a `FuncSpace` costs *two*
409/// JSON nesting levels (its object plus its `spaces` array), so parsing
410/// one of these documents back caps out near 61 levels — the emit limit
411/// is the more permissive of the two. On the source side, the deepest
412/// space nesting across the 14 450-file corpus under `tests/repositories`
413/// is 10 levels.
414pub const MAX_SPACE_SERIALIZE_DEPTH: usize = 128;
415
416/// Maps a recursive compute-side tree onto its wire form using an explicit
417/// work stack.
418///
419/// The natural `children.iter().map(Self::from).collect()` recursion costs
420/// one stack frame per nesting level and overflows a default 2 MiB thread
421/// stack at roughly 900 levels of nested functions — a `SIGABRT`, not a
422/// catchable panic (#1056). Space nesting is attacker-controlled, so the
423/// conversion is iterative: `build` is called on each node exactly once,
424/// bottom-up, with that node's already-converted children.
425fn map_tree<'a, Src, Dst>(
426    root: &'a Src,
427    children_of: fn(&'a Src) -> &'a [Src],
428    build: fn(&'a Src, Vec<Dst>) -> Dst,
429) -> Dst {
430    // The root frame is held outside the stack so that popping a completed
431    // frame always has somewhere to deposit it, and so the loop needs no
432    // fallible "the stack cannot be empty here" step.
433    let mut root_frame = MapFrame::new(root, children_of);
434    let mut descendants = Vec::new();
435    loop {
436        let frame = match descendants.last_mut() {
437            Some(frame) => frame,
438            None => &mut root_frame,
439        };
440        let source = frame.source;
441        if let Some(child) = children_of(source).get(frame.next_child) {
442            frame.next_child += 1;
443            descendants.push(MapFrame::new(child, children_of));
444            continue;
445        }
446        // The current frame has no children left: fold it into its parent,
447        // or stop once that frame is the root.
448        let Some(done) = descendants.pop() else { break };
449        let converted = build(done.source, done.children);
450        match descendants.last_mut() {
451            Some(parent) => parent.children.push(converted),
452            None => root_frame.children.push(converted),
453        }
454    }
455    build(root_frame.source, root_frame.children)
456}
457
458/// One in-progress node of a [`map_tree`] walk.
459struct MapFrame<'a, Src, Dst> {
460    /// The compute-side node being converted.
461    source: &'a Src,
462    /// How many of `source`'s children have been pushed onto the walk.
463    next_child: usize,
464    /// Wire forms of the children completed so far, in source order.
465    children: Vec<Dst>,
466}
467
468impl<'a, Src, Dst> MapFrame<'a, Src, Dst> {
469    fn new(source: &'a Src, children_of: fn(&'a Src) -> &'a [Src]) -> Self {
470        Self {
471            source,
472            next_child: 0,
473            children: Vec::with_capacity(children_of(source).len()),
474        }
475    }
476}
477
478/// Serializes a [`FuncSpace`]'s children one nesting level deeper,
479/// refusing to descend past [`MAX_SPACE_SERIALIZE_DEPTH`].
480fn serialize_spaces<S: Serializer>(spaces: &[FuncSpace], serializer: S) -> Result<S::Ok, S::Error> {
481    crate::recursion::serialize_bounded(spaces, MAX_SPACE_SERIALIZE_DEPTH, "FuncSpace", serializer)
482}
483
484/// Serializes an [`Ops`] node's children one nesting level deeper,
485/// refusing to descend past [`MAX_SPACE_SERIALIZE_DEPTH`].
486fn serialize_ops_spaces<S: Serializer>(spaces: &[Ops], serializer: S) -> Result<S::Ok, S::Error> {
487    crate::recursion::serialize_bounded(spaces, MAX_SPACE_SERIALIZE_DEPTH, "Ops", serializer)
488}
489
490/// Wire form of [`crate::spaces::FuncSpace`] — a recursive metric tree.
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct FuncSpace {
493    /// The name of the space (file path or AST-derived identifier).
494    pub name: Option<String>,
495    /// The first line of the space.
496    pub start_line: usize,
497    /// The last line of the space.
498    pub end_line: usize,
499    /// The space kind.
500    pub kind: SpaceKind,
501    /// All nested subspaces.
502    #[serde(serialize_with = "serialize_spaces")]
503    pub spaces: Vec<FuncSpace>,
504    /// The metrics of the space.
505    pub metrics: CodeMetrics,
506    /// In-source suppression markers applying to the space (elided when
507    /// empty, matching the compute type's schema).
508    #[serde(default, skip_serializing_if = "SuppressionScope::is_empty")]
509    pub suppressed: SuppressionScope,
510}
511
512// The wire tree mirrors the compute tree's nesting, so its `Drop` needs
513// the same de-recursion (#1056). See [`crate::recursion`].
514crate::recursion::impl_iterative_drop!(FuncSpace, spaces);
515
516impl From<&crate::spaces::FuncSpace> for FuncSpace {
517    fn from(f: &crate::spaces::FuncSpace) -> Self {
518        map_tree(
519            f,
520            |source| &source.spaces,
521            |source, spaces| Self {
522                name: source.name.clone(),
523                start_line: source.start_line,
524                end_line: source.end_line,
525                kind: source.kind,
526                spaces,
527                metrics: CodeMetrics::from(&source.metrics),
528                suppressed: source.suppressed.clone(),
529            },
530        )
531    }
532}
533
534/// Wire form of [`crate::Ops`] — a recursive operator/operand tree.
535#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
536pub struct Ops {
537    /// The name of the space (file path or AST-derived identifier).
538    pub name: Option<String>,
539    /// Whether [`Ops::name`] was derived via lossy UTF-8 conversion.
540    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
541    pub name_was_lossy: bool,
542    /// The first line of the space.
543    pub start_line: usize,
544    /// The last line of the space.
545    pub end_line: usize,
546    /// The space kind.
547    pub kind: SpaceKind,
548    /// All nested subspaces.
549    #[serde(serialize_with = "serialize_ops_spaces")]
550    pub spaces: Vec<Ops>,
551    /// The operands in the space.
552    pub operands: Vec<String>,
553    /// The operators in the space.
554    pub operators: Vec<String>,
555}
556
557// The wire tree mirrors the compute tree's nesting, so its `Drop` needs
558// the same de-recursion (#1056). See [`crate::recursion`].
559crate::recursion::impl_iterative_drop!(Ops, spaces);
560
561// Owned `Ops` projections built on this thread.
562//
563// Both projections emit the same document, so no assertion on the output
564// can tell which path ran and a revert to `serialize_via_wire!` would be
565// silent. This is the observable `serializing_ops_builds_no_owned_projection`
566// reads. One `Cell` bump per whole-tree conversion costs nothing against it.
567crate::observation::counter!(owned_ops_projections);
568
569impl From<&ops::Ops> for Ops {
570    fn from(o: &ops::Ops) -> Self {
571        owned_ops_projections::record();
572        map_tree(
573            o,
574            |source| &source.spaces,
575            |source, spaces| Self {
576                name: source.name.clone(),
577                name_was_lossy: source.name_was_lossy,
578                start_line: source.start_line,
579                end_line: source.end_line,
580                kind: source.kind,
581                spaces,
582                operands: source.operands.clone(),
583                operators: source.operators.clone(),
584            },
585        )
586    }
587}
588
589/// Wire form of [`crate::FunctionSpan`].
590#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591pub struct FunctionSpan {
592    /// The function name, or `null` when it could not be resolved.
593    pub name: Option<String>,
594    /// The first line of the function.
595    pub start_line: usize,
596    /// The last line of the function.
597    pub end_line: usize,
598}
599
600impl From<&function::FunctionSpan> for FunctionSpan {
601    fn from(f: &function::FunctionSpan) -> Self {
602        Self {
603            name: f.name.clone(),
604            start_line: f.start_line,
605            end_line: f.end_line,
606        }
607    }
608}
609
610// ---------------------------------------------------------------------------
611// Delegating `Serialize` impls: the compute types serialize *through* the
612// wire projection, so the wire structs above are the single source of the
613// emitted format.
614// ---------------------------------------------------------------------------
615
616/// Implement `Serialize` for a compute type by projecting it to its wire
617/// form and serializing that, keeping the wire struct the sole definition
618/// of the output shape.
619macro_rules! serialize_via_wire {
620    ($compute:ty => $wire:ident) => {
621        impl Serialize for $compute {
622            fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
623                $wire::from(self).serialize(serializer)
624            }
625        }
626    };
627}
628
629serialize_via_wire!(abc::Stats => Abc);
630serialize_via_wire!(cognitive::Stats => Cognitive);
631serialize_via_wire!(cyclomatic::Stats => Cyclomatic);
632serialize_via_wire!(nexits::Stats => Nexits);
633serialize_via_wire!(halstead::Stats => Halstead);
634serialize_via_wire!(loc::Stats => Loc);
635serialize_via_wire!(mi::Stats => Mi);
636serialize_via_wire!(nargs::Stats => Nargs);
637serialize_via_wire!(nom::Stats => Nom);
638serialize_via_wire!(npa::Stats => Npa);
639serialize_via_wire!(npm::Stats => Npm);
640serialize_via_wire!(tokens::Stats => Tokens);
641serialize_via_wire!(wmc::Stats => Wmc);
642serialize_via_wire!(crate::spaces::CodeMetrics => CodeMetrics);
643serialize_via_wire!(crate::spaces::FuncSpace => FuncSpace);
644// `ops::Ops` is absent on purpose: it serializes through the borrowed
645// mirror in `ops_view`.
646serialize_via_wire!(function::FunctionSpan => FunctionSpan);
647
648// Own file so their prose does not spend this file's `loc.sloc`
649// budget (#1066); `.bcaignore` keeps `*_tests.rs` out of the self-scan.
650#[cfg(test)]
651#[path = "wire_ops_tests.rs"]
652mod ops_tests;
653
654#[cfg(test)]
655// The round-trip assertions compare floats exactly on purpose: CBOR stores
656// raw IEEE-754 bits, YAML/TOML emit full precision, and serde_json's
657// `float_roundtrip` feature (enabled in Cargo.toml) makes its parser
658// bit-exact — so a value serialized and read back equals the original
659// down to the last bit. Exactness is the property under test.
660#[allow(clippy::float_cmp)]
661mod tests {
662    use super::*;
663    use crate::RustParser;
664    use crate::test_support::check_func_space;
665
666    /// A branchy multi-function Rust fixture so several metrics are
667    /// non-trivial (cyclomatic > 1, multiple spaces, real Halstead/MI).
668    #[cfg(feature = "rust")]
669    const FIXTURE: &str = "\
670fn classify(x: i32) -> i32 {
671    if x > 0 {
672        x * 2
673    } else if x < 0 {
674        -x
675    } else {
676        0
677    }
678}
679
680fn run() {
681    let adder = |a: i32, b: i32| a + b;
682    let _ = adder(classify(3), classify(-4));
683}
684";
685
686    /// Independent oracle of the `FIXTURE` tree's hand-verified integer
687    /// metrics. `assert_eq!(back, fs.to_wire())` alone cannot catch a
688    /// mismapped `From` field — both sides flow through the same projection,
689    /// so a swap corrupts them identically — so the round-trip tests anchor
690    /// against these known values to break the closed loop. (Grammar bumps
691    /// may shift them; update alongside the metric snapshot tests.)
692    #[cfg(feature = "rust")]
693    fn assert_fixture_oracle(tree: &FuncSpace) {
694        // Two top-level functions: `classify` and `run`.
695        assert_eq!(tree.kind, SpaceKind::Unit);
696        assert_eq!(tree.spaces.len(), 2, "classify + run");
697
698        let m = &tree.metrics;
699        assert_eq!(m.cyclomatic.as_ref().unwrap().sum, 6, "unit cyclomatic.sum");
700        // The unit's *own* cyclomatic is the base 1 (no decisions at file
701        // top level), while `sum` rolls up both functions and the closure
702        // (#958). `value != sum` here is the whole point of the field.
703        assert_eq!(
704            m.cyclomatic.as_ref().unwrap().value,
705            1,
706            "unit cyclomatic.value (own, excludes children)"
707        );
708        assert_eq!(m.cognitive.as_ref().unwrap().sum, 3, "unit cognitive.sum");
709        assert_eq!(
710            m.cognitive.as_ref().unwrap().value,
711            0,
712            "unit cognitive.value (own)"
713        );
714        assert_eq!(m.loc.as_ref().unwrap().sloc, 14, "unit loc.sloc");
715        assert_eq!(m.nom.as_ref().unwrap().total, 3, "unit nom.total");
716        // ABC is finite and distinguishes assignments/branches/conditions —
717        // a swap of those accessors in `From` would surface here.
718        let abc = m.abc.as_ref().unwrap();
719        assert_eq!((abc.assignments, abc.branches, abc.conditions), (2, 3, 4));
720
721        let classify = tree
722            .spaces
723            .iter()
724            .find(|s| s.name.as_deref() == Some("classify"))
725            .expect("classify space");
726        let classify_cyclo = classify.metrics.cyclomatic.as_ref().unwrap();
727        assert_eq!(classify_cyclo.sum, 3, "classify cyclomatic.sum");
728        // `classify` is a leaf, so its own value equals its subtree sum.
729        assert_eq!(classify_cyclo.value, 3, "classify cyclomatic.value (leaf)");
730
731        // `run` is an interior space: it owns the `adder` closure child.
732        // Its own cyclomatic is the base 1, but `sum` (2) folds in the
733        // closure's base 1 — the exact interior-space case #958 closes.
734        let run = tree
735            .spaces
736            .iter()
737            .find(|s| s.name.as_deref() == Some("run"))
738            .expect("run space");
739        let run_cyclo = run.metrics.cyclomatic.as_ref().unwrap();
740        assert_eq!(run_cyclo.sum, 2, "run cyclomatic.sum (run + adder closure)");
741        assert_eq!(
742            run_cyclo.value, 1,
743            "run cyclomatic.value (own, excludes closure)"
744        );
745    }
746
747    /// The acceptance criterion: a `FuncSpace` serialized to JSON parses
748    /// back into a `wire::FuncSpace` that re-serializes byte-for-byte, is
749    /// structurally equal to the source projection, and carries the
750    /// hand-verified metric values.
751    #[cfg(feature = "rust")]
752    #[test]
753    fn json_round_trips() {
754        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
755            let json = serde_json::to_string(&fs).expect("serialize FuncSpace to JSON");
756            let back: FuncSpace = serde_json::from_str(&json).expect("parse wire::FuncSpace");
757            assert_eq!(
758                back,
759                fs.to_wire(),
760                "deserialized wire tree must equal the projection"
761            );
762            assert_eq!(
763                serde_json::to_string(&back).expect("re-serialize wire"),
764                json,
765                "re-serialized wire must be byte-identical to the original JSON",
766            );
767            // Independent oracle: guards `From`-projection correctness, which
768            // the closed serialize→deserialize loop above cannot.
769            assert_fixture_oracle(&back);
770        });
771    }
772
773    /// A `loc` block written before #1597 carries no `dloc*` keys.
774    /// It must still parse, with the four defaulting to zero / NaN, so a
775    /// stored report from an older `bca` can be read by this version.
776    #[test]
777    fn loc_document_without_dloc_fields_still_parses() {
778        let json = r#"{"sloc": 4, "ploc": 2, "lloc": 1, "cloc": 1, "blank": 1,
779            "sloc_average": 2.0, "ploc_average": 1.0, "lloc_average": 0.5,
780            "cloc_average": 0.5, "blank_average": 0.5,
781            "sloc_min": 2, "sloc_max": 4, "cloc_min": 0, "cloc_max": 1,
782            "ploc_min": 2, "ploc_max": 2, "lloc_min": 1, "lloc_max": 1,
783            "blank_min": 0, "blank_max": 1}"#;
784        let loc: Loc = serde_json::from_str(json).expect("pre-#1597 loc block parses");
785        assert_eq!(
786            (loc.dloc, loc.dloc_min, loc.dloc_max),
787            (0, 0, 0),
788            "absent integer dloc fields default to 0"
789        );
790        assert!(
791            loc.dloc_average.is_nan(),
792            "an absent average defaults to NaN, as the sibling averages do"
793        );
794        // The values that were present are untouched by the defaults.
795        assert_eq!((loc.sloc, loc.cloc, loc.blank_max), (4, 1, 1));
796    }
797
798    #[cfg(feature = "rust")]
799    #[test]
800    fn yaml_round_trips() {
801        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
802            let yaml = serde_saphyr::to_string(&fs).expect("serialize to YAML");
803            let back: FuncSpace = serde_saphyr::from_str(&yaml).expect("parse wire from YAML");
804            assert_eq!(back, fs.to_wire());
805            assert_eq!(serde_saphyr::to_string(&back).expect("re-serialize"), yaml);
806        });
807    }
808
809    #[cfg(feature = "rust")]
810    #[test]
811    fn toml_round_trips() {
812        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
813            let toml = toml::to_string(&fs).expect("serialize to TOML");
814            let back: FuncSpace = toml::from_str(&toml).expect("parse wire from TOML");
815            assert_eq!(back, fs.to_wire());
816            assert_eq!(toml::to_string(&back).expect("re-serialize"), toml);
817        });
818    }
819
820    #[cfg(feature = "rust")]
821    #[test]
822    fn cbor_round_trips() {
823        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
824            let mut bytes = Vec::new();
825            ciborium::into_writer(&fs, &mut bytes).expect("serialize to CBOR");
826            let back: FuncSpace =
827                ciborium::from_reader(bytes.as_slice()).expect("parse wire from CBOR");
828            assert_eq!(back, fs.to_wire());
829            let mut re = Vec::new();
830            ciborium::into_writer(&back, &mut re).expect("re-serialize");
831            assert_eq!(re, bytes, "CBOR re-serialization must be byte-identical");
832        });
833    }
834
835    /// `FunctionSpan` (#536 shape: `name: Option<String>`, no `error`
836    /// field) round-trips through JSON for both a resolved name and an
837    /// unresolved one (`None` → JSON `null`), and the serialized object
838    /// carries no `error` key.
839    #[test]
840    fn function_span_round_trips() {
841        let resolved = FunctionSpan {
842            name: Some("foo".to_owned()),
843            start_line: 1,
844            end_line: 4,
845        };
846        let unresolved = FunctionSpan {
847            name: None,
848            start_line: 7,
849            end_line: 8,
850        };
851
852        for span in [resolved, unresolved] {
853            let json = serde_json::to_string(&span).expect("serialize FunctionSpan");
854            assert!(
855                !json.contains("error"),
856                "FunctionSpan JSON must not carry an `error` key, got {json}",
857            );
858            let back: FunctionSpan = serde_json::from_str(&json).expect("parse FunctionSpan");
859            assert_eq!(back, span, "FunctionSpan must round-trip through JSON");
860        }
861
862        // The unresolved span emits `name: null`, never an empty string.
863        let json = serde_json::to_string(&FunctionSpan {
864            name: None,
865            start_line: 7,
866            end_line: 8,
867        })
868        .expect("serialize");
869        assert!(
870            json.contains(r#""name":null"#),
871            "unresolved name must serialize to JSON null, got {json}",
872        );
873    }
874
875    /// A non-finite float (`NaN`/`±∞`) serializes to the format's null and
876    /// deserializes back to `NaN`: native `null` (JSON/YAML/CBOR) and an
877    /// omitted key (TOML, which has no null literal, recovered via the
878    /// field default). Mi fields are the simplest plain-`f64` carrier.
879    #[test]
880    fn non_finite_floats_round_trip_as_null_or_omission() {
881        for probe in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
882            let mi = Mi {
883                original: probe,
884                sei: 1.5,
885                visual_studio: 2.0,
886            };
887
888            let json = serde_json::to_string(&mi).expect("JSON");
889            assert!(
890                json.contains(r#""original":null"#),
891                "non-finite must serialize to JSON null, got {json}",
892            );
893            assert!(
894                serde_json::from_str::<Mi>(&json)
895                    .expect("parse")
896                    .original
897                    .is_nan(),
898                "JSON null must deserialize back to NaN",
899            );
900
901            let yaml = serde_saphyr::to_string(&mi).expect("YAML");
902            assert!(
903                yaml.contains("original: null"),
904                "non-finite must serialize to YAML null, got {yaml}",
905            );
906            assert!(
907                serde_saphyr::from_str::<Mi>(&yaml)
908                    .expect("parse")
909                    .original
910                    .is_nan()
911            );
912
913            let toml = toml::to_string(&mi).expect("TOML");
914            assert!(
915                !toml.contains("original"),
916                "TOML must omit the non-finite key (no null literal), got {toml}",
917            );
918            assert!(
919                toml::from_str::<Mi>(&toml)
920                    .expect("parse")
921                    .original
922                    .is_nan(),
923                "omitted TOML key must default back to NaN",
924            );
925
926            // CBOR: serialize to bytes, confirm the field decodes as a null
927            // token, and that it deserializes back to NaN.
928            let mut cbor = Vec::new();
929            ciborium::into_writer(&mi, &mut cbor).expect("CBOR");
930            let value: ciborium::value::Value =
931                ciborium::from_reader(cbor.as_slice()).expect("parse cbor value");
932            let ciborium::value::Value::Map(map) = &value else {
933                panic!("CBOR root is not a map");
934            };
935            let original_key = ciborium::value::Value::Text("original".to_owned());
936            let original = map
937                .iter()
938                .find_map(|(k, v)| (*k == original_key).then_some(v));
939            assert_eq!(
940                original,
941                Some(&ciborium::value::Value::Null),
942                "non-finite must serialize to CBOR null",
943            );
944            assert!(
945                ciborium::from_reader::<Mi, _>(cbor.as_slice())
946                    .expect("parse")
947                    .original
948                    .is_nan(),
949                "CBOR null must deserialize back to NaN",
950            );
951
952            // Finite siblings are unaffected.
953            let back = serde_json::from_str::<Mi>(&json).expect("parse");
954            assert_eq!(back.sei, 1.5);
955            assert_eq!(back.visual_studio, 2.0);
956        }
957    }
958
959    /// `selected()` reconstructs the `MetricSet` from the metric keys
960    /// present on the wire: a full tree marks every metric, a pruned tree
961    /// (here keeping only `loc`) marks exactly that one.
962    #[cfg(feature = "rust")]
963    #[test]
964    fn selected_is_inferred_from_present_keys() {
965        check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
966            let full = fs.metrics.to_wire();
967            let selected = full.selected();
968            assert!(selected.contains(Metric::Loc));
969            assert!(selected.contains(Metric::Cyclomatic));
970
971            // A pruned document (only `loc` present) infers only `loc`.
972            let json = serde_json::to_string(&full).expect("serialize metrics");
973            let mut value: serde_json::Value = serde_json::from_str(&json).expect("parse value");
974            let obj = value.as_object_mut().expect("metrics object");
975            obj.retain(|k, _| k == "loc");
976            let pruned: CodeMetrics =
977                serde_json::from_value(value).expect("parse pruned wire metrics");
978            let pruned_selected = pruned.selected();
979            assert!(pruned_selected.contains(Metric::Loc));
980            assert!(!pruned_selected.contains(Metric::Cyclomatic));
981            assert!(pruned.cyclomatic.is_none());
982        });
983    }
984
985    // -----------------------------------------------------------------
986    // Stack-depth regression tests (#1056)
987    //
988    // The #700 / #709 small-stack tests cover the *dump* walk and build
989    // `FuncSpace` values by hand, so nothing exercised the wire
990    // conversion or `Serialize`. These drive `analyze` and then convert /
991    // serialize, because the hazard scales with `FuncSpace` nesting, not
992    // AST depth — nested parentheses reach depth 200 000 while opening a
993    // single space, so testing the wrong shape looks like a pass.
994    // -----------------------------------------------------------------
995
996    /// The size of a `bca` consumer thread and of a `tokio` blocking
997    /// thread — the stack the guarded limits are dimensioned against.
998    #[cfg(feature = "rust")]
999    const PRODUCTION_STACK: usize = 2 * 1024 * 1024;
1000
1001    /// Deliberately far below `PRODUCTION_STACK`: a re-recursed `From` or
1002    /// `Drop` fails loudly here instead of riding on the test runner's
1003    /// generous stack.
1004    const TIGHT_STACK: usize = 512 * 1024;
1005
1006    /// Rust source nesting `depth` functions, one `FuncSpace` per level
1007    /// below the file-level `Unit`.
1008    pub(super) fn nested_functions(depth: usize) -> String {
1009        use std::fmt::Write as _;
1010        let mut source = String::with_capacity(depth * 14);
1011        for level in 0..depth {
1012            let _ = writeln!(source, "fn f{level}() {{");
1013        }
1014        for _ in 0..depth {
1015            source.push_str("}\n");
1016        }
1017        source
1018    }
1019
1020    /// Analyses [`nested_functions`], computing only `loc` so the cost of
1021    /// unrelated metrics does not dominate a deep fixture.
1022    #[cfg(feature = "rust")]
1023    fn analyze_nested(depth: usize) -> crate::FuncSpace {
1024        crate::analyze(
1025            crate::Source::new(crate::LANG::Rust, nested_functions(depth).as_bytes())
1026                .with_name(Some("nested.rs".to_owned())),
1027            crate::MetricsOptions::default().with_only(&[Metric::Loc]),
1028        )
1029        .expect("nested-function fixture must analyse")
1030    }
1031
1032    /// Longest chain of nested spaces in `space`, measured without
1033    /// recursing so the measurement cannot overflow before the code
1034    /// under test does.
1035    fn wire_nesting_depth(space: &FuncSpace) -> usize {
1036        let mut deepest = 0;
1037        let mut stack = vec![(space, 1_usize)];
1038        while let Some((node, depth)) = stack.pop() {
1039            deepest = deepest.max(depth);
1040            for child in &node.spaces {
1041                stack.push((child, depth + 1));
1042            }
1043        }
1044        deepest
1045    }
1046
1047    /// Runs `body` on a thread with an explicit stack so the result does
1048    /// not depend on the test harness's own stack size.
1049    fn on_stack<T: Send + 'static>(bytes: usize, body: impl FnOnce() -> T + Send + 'static) -> T {
1050        std::thread::Builder::new()
1051            .stack_size(bytes)
1052            .spawn(body)
1053            .expect("spawn bounded-stack thread")
1054            .join()
1055            .expect("bounded-stack thread must not overflow")
1056    }
1057
1058    /// A chain of `depth` nested spaces below the root, built directly.
1059    ///
1060    /// `analyze` is the more faithful fixture and the deep tests below
1061    /// use it, but only to a depth the remaining quadratic ancestor walks
1062    /// (#1062) make affordable. This builds the same shape for free, so
1063    /// the stack properties can be pinned an order of magnitude deeper
1064    /// than an analysed fixture could reach in a debug build.
1065    fn space_chain(depth: usize) -> crate::FuncSpace {
1066        let leaf = || crate::FuncSpace {
1067            name: Some("f".to_owned()),
1068            start_line: 1,
1069            end_line: 1,
1070            kind: SpaceKind::Function,
1071            spaces: Vec::new(),
1072            metrics: crate::CodeMetrics::default(),
1073            suppressed: SuppressionScope::default(),
1074        };
1075        let mut root = leaf();
1076        let mut cursor = &mut root;
1077        for _ in 0..depth {
1078            cursor.spaces.push(leaf());
1079            cursor = cursor.spaces.last_mut().expect("just pushed");
1080        }
1081        root
1082    }
1083
1084    #[cfg(feature = "rust")]
1085    #[test]
1086    fn deeply_nested_spaces_convert_to_wire_without_stack_overflow() {
1087        // `From<&spaces::FuncSpace>` walks an explicit work stack: the
1088        // former `spaces.iter().map(FuncSpace::from).collect()` recursed
1089        // once per level and aborted the process at roughly 900 levels on
1090        // a 2 MiB thread — under 100 on this one. Analysed fixture, so
1091        // the whole `analyze` → `to_wire` pipeline is covered.
1092        const DEPTH: usize = 2_000;
1093        let depth = on_stack(TIGHT_STACK, || {
1094            let space = analyze_nested(DEPTH);
1095            wire_nesting_depth(&space.to_wire())
1096        });
1097        // The file-level `Unit` plus one `Function` space per nested `fn`.
1098        assert_eq!(depth, DEPTH + 1, "the whole chain must survive conversion");
1099    }
1100
1101    #[test]
1102    fn a_pathologically_deep_space_chain_converts_and_tears_down() {
1103        // Both `Drop` impls at a depth no recursive teardown survives:
1104        // the compiler-generated glue overflowed this thread's stack at
1105        // roughly 4 000 levels, and the compute tree, the wire tree, and
1106        // the wire tree's own nested `Vec`s all unwind inside it.
1107        const DEPTH: usize = 100_000;
1108        let depth = on_stack(TIGHT_STACK, || {
1109            let space = space_chain(DEPTH);
1110            wire_nesting_depth(&space.to_wire())
1111        });
1112        assert_eq!(depth, DEPTH + 1, "the whole chain must survive conversion");
1113    }
1114
1115    #[cfg(feature = "rust")]
1116    #[test]
1117    fn spaces_deeper_than_the_limit_fail_serialization_rather_than_abort() {
1118        // The reported symptom: `bca metrics -O json` on ~1 000 nested
1119        // functions overflowed the stack, and a stack overflow is a
1120        // `SIGABRT`, not a catchable panic — `bca-web`'s `spawn_blocking`
1121        // wrapper cannot contain it. It must now be an ordinary error.
1122        const DEPTH: usize = 2_000;
1123        let message = on_stack(PRODUCTION_STACK, || {
1124            let space = analyze_nested(DEPTH);
1125            serde_json::to_string(&space)
1126                .expect_err("nesting past the limit must fail, not serialize")
1127                .to_string()
1128        });
1129        assert!(
1130            message.contains("FuncSpace nesting is deeper than the serialization limit of 128"),
1131            "the error must name the type and the limit, got: {message}"
1132        );
1133    }
1134
1135    #[cfg(feature = "rust")]
1136    #[test]
1137    fn space_nesting_at_the_serialize_limit_is_accepted_and_one_deeper_is_not() {
1138        // `depth` counts non-empty child lists, so `n` nested functions
1139        // reach depth `n`: the file-level `Unit` down to the last `fn`
1140        // that still contains another one.
1141        let (accepted, rejected) = on_stack(PRODUCTION_STACK, || {
1142            let at_limit = analyze_nested(MAX_SPACE_SERIALIZE_DEPTH);
1143            let past_limit = analyze_nested(MAX_SPACE_SERIALIZE_DEPTH + 1);
1144            (
1145                [
1146                    serde_json::to_string(&at_limit).is_ok(),
1147                    serde_saphyr::to_string(&at_limit).is_ok(),
1148                    toml::to_string(&at_limit).is_ok(),
1149                    {
1150                        let mut bytes = Vec::new();
1151                        ciborium::into_writer(&at_limit, &mut bytes).is_ok()
1152                    },
1153                ],
1154                [
1155                    serde_json::to_string(&past_limit).is_ok(),
1156                    serde_saphyr::to_string(&past_limit).is_ok(),
1157                    toml::to_string(&past_limit).is_ok(),
1158                    {
1159                        let mut bytes = Vec::new();
1160                        ciborium::into_writer(&past_limit, &mut bytes).is_ok()
1161                    },
1162                ],
1163            )
1164        });
1165        assert_eq!(
1166            accepted, [true; 4],
1167            "exactly {MAX_SPACE_SERIALIZE_DEPTH} levels must serialize in every format"
1168        );
1169        assert_eq!(
1170            rejected, [false; 4],
1171            "one level past the limit must be refused in every format"
1172        );
1173    }
1174
1175    #[cfg(feature = "rust")]
1176    #[test]
1177    fn deeply_nested_ops_convert_and_serialize_without_stack_overflow() {
1178        // `Ops` mirrors `FuncSpace`'s nesting and had the same recursive
1179        // `From` and `Serialize`, so it needs the same coverage.
1180        const DEPTH: usize = 2_000;
1181        let (converted_depth, message) = on_stack(PRODUCTION_STACK, || {
1182            let ops = crate::Ast::parse(crate::Source::new(
1183                crate::LANG::Rust,
1184                nested_functions(DEPTH).as_bytes(),
1185            ))
1186            .expect("nested-function fixture must parse")
1187            .ops()
1188            .expect("nested-function fixture must yield ops");
1189            let wire = Ops::from(&ops);
1190            let mut deepest = 0;
1191            let mut stack = vec![(&wire, 1_usize)];
1192            while let Some((node, depth)) = stack.pop() {
1193                deepest = deepest.max(depth);
1194                for child in &node.spaces {
1195                    stack.push((child, depth + 1));
1196                }
1197            }
1198            let message = serde_json::to_string(&ops)
1199                .expect_err("nesting past the limit must fail, not serialize")
1200                .to_string();
1201            (deepest, message)
1202        });
1203        assert_eq!(converted_depth, DEPTH + 1, "the whole chain must convert");
1204        assert!(
1205            message.contains("Ops nesting is deeper than the serialization limit of 128"),
1206            "the error must name the type and the limit, got: {message}"
1207        );
1208    }
1209}