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