ktstr/scenario/snapshot/
json.rs

1//! [`JsonField`] — terminal accessor for a typed read out of a
2//! [`serde_json::Value`] (the scheduler-stats JSON the relay
3//! captures), plus the [`stats_path`] entry point and the
4//! [`walk_json_path`] / [`describe_json_kind`] / [`json_to_u64`] /
5//! [`json_to_i64`] / [`json_to_f64`] helpers it funnels through.
6
7use super::{SnapshotError, SnapshotResult};
8
9/// One value's view at the leaf of a dotted-path walk over a
10/// [`serde_json::Value`]. Returned by [`stats_path`] / `StatsValue::get`.
11///
12/// Mirrors the [`super::SnapshotField`] shape so test authors who already
13/// know the BPF-snapshot accessor surface get the same `as_u64` /
14/// `as_i64` / `as_f64` / `as_bool` / `as_str` terminals on the
15/// scx_stats JSON projection. Errors flow through the same
16/// [`SnapshotError`] variants — `FieldNotFound` carries the
17/// available object keys, `NotAStruct` flags a non-object cursor,
18/// `TypeMismatch` reports the actual JSON shape — so failure-path
19/// rendering in temporal assertions is identical regardless of
20/// which side of the
21/// [`Sample`](crate::scenario::sample::Sample) bundle the lookup
22/// originated on.
23#[derive(Debug, Clone)]
24#[must_use = "JsonField is a borrowed view; call as_u64 / as_i64 / etc. to extract"]
25#[non_exhaustive]
26pub enum JsonField<'a> {
27    /// Resolved JSON value at the leaf of the path walk.
28    Value(&'a serde_json::Value),
29    /// Path could not be resolved.
30    Missing(SnapshotError),
31}
32
33impl<'a> JsonField<'a> {
34    /// True when the path resolved.
35    pub fn is_present(&self) -> bool {
36        !matches!(self, JsonField::Missing(_))
37    }
38
39    /// Underlying JSON value if present.
40    pub fn raw(&self) -> Option<&'a serde_json::Value> {
41        match self {
42            JsonField::Value(v) => Some(*v),
43            JsonField::Missing(_) => None,
44        }
45    }
46
47    /// Error reference when the path could not be resolved.
48    pub fn error(&self) -> Option<&SnapshotError> {
49        match self {
50            JsonField::Missing(err) => Some(err),
51            _ => None,
52        }
53    }
54
55    /// Walk further into a sub-field. Composable with the result of
56    /// [`stats_path`] — `stats_path(v, "layers").get("batch.util")`
57    /// is the canonical "drill into a periodic-stats object" shape.
58    /// Mirrors [`super::SnapshotField::get`] so a test author moves
59    /// between the BTF-rendered and JSON-rendered surfaces without
60    /// re-learning the navigator method name.
61    pub fn get(&self, path: &str) -> JsonField<'a> {
62        match self {
63            JsonField::Value(v) => walk_json_path(v, path),
64            JsonField::Missing(err) => JsonField::Missing(err.clone()),
65        }
66    }
67
68    /// Read as `u64`. Accepts JSON integers (positive only), JSON
69    /// booleans (true → 1, false → 0), JSON strings whose
70    /// content parses as a u64 (scx_stats sometimes stringifies
71    /// large counters to avoid 53-bit float collapse), and JSON
72    /// floats that are integral and non-negative (`5.0` → 5;
73    /// fractional, negative, or non-finite floats error). Returns
74    /// [`SnapshotError::TypeMismatch`] otherwise.
75    pub fn as_u64(&self) -> SnapshotResult<u64> {
76        match self {
77            JsonField::Value(v) => json_to_u64(v),
78            JsonField::Missing(err) => Err(err.clone()),
79        }
80    }
81
82    /// Read as `i64`. Accepts JSON integers (any sign), JSON
83    /// booleans (true → 1, false → 0), JSON strings whose
84    /// content parses as an i64, and integral finite JSON floats
85    /// (`9.0` → 9; fractional or non-finite floats error).
86    pub fn as_i64(&self) -> SnapshotResult<i64> {
87        match self {
88            JsonField::Value(v) => json_to_i64(v),
89            JsonField::Missing(err) => Err(err.clone()),
90        }
91    }
92
93    /// Read as `f64`. Accepts JSON numbers (integers and
94    /// floating-point) and JSON strings whose content parses as
95    /// f64.
96    pub fn as_f64(&self) -> SnapshotResult<f64> {
97        match self {
98            JsonField::Value(v) => json_to_f64(v),
99            JsonField::Missing(err) => Err(err.clone()),
100        }
101    }
102
103    /// Read as `bool`. Accepts JSON booleans directly; rejects
104    /// everything else. Distinct from `as_u64() != 0` so the call
105    /// site reads honestly: a `bool` claim wants a JSON `true`/
106    /// `false`, not a stringified `"1"` that happens to parse.
107    pub fn as_bool(&self) -> SnapshotResult<bool> {
108        match self {
109            JsonField::Value(serde_json::Value::Bool(b)) => Ok(*b),
110            JsonField::Value(other) => Err(SnapshotError::TypeMismatch {
111                expected: "bool".to_string(),
112                actual: describe_json_kind(other),
113                requested: String::new(),
114            }),
115            JsonField::Missing(err) => Err(err.clone()),
116        }
117    }
118
119    /// Read as `&str`. Accepts JSON strings only.
120    pub fn as_str(&self) -> SnapshotResult<&'a str> {
121        match self {
122            JsonField::Value(serde_json::Value::String(s)) => Ok(s.as_str()),
123            JsonField::Value(other) => Err(SnapshotError::TypeMismatch {
124                expected: "str".to_string(),
125                actual: describe_json_kind(other),
126                requested: String::new(),
127            }),
128            JsonField::Missing(err) => Err(err.clone()),
129        }
130    }
131
132    /// Read as `Vec<u64>` from a [`serde_json::Value::Array`] whose
133    /// every element coerces via [`Self::as_u64`]'s rules. Mirrors
134    /// [`super::SnapshotField::as_u64_array`] so JSON-side stats
135    /// reads use the same method name as BTF-side BPF reads.
136    pub fn as_u64_array(&self) -> SnapshotResult<Vec<u64>> {
137        json_to_typed_array(self, json_to_u64, "u64")
138    }
139
140    /// Read as `Vec<u32>` from a JSON array. Mirrors
141    /// [`super::SnapshotField::as_u32_array`]; out-of-range values
142    /// error rather than silently truncate.
143    pub fn as_u32_array(&self) -> SnapshotResult<Vec<u32>> {
144        json_to_typed_array(
145            self,
146            |v| {
147                json_to_u64(v).and_then(|x| {
148                    u32::try_from(x).map_err(|_| SnapshotError::TypeMismatch {
149                        expected: "u32".to_string(),
150                        actual: "Uint(>u32::MAX)".to_string(),
151                        requested: String::new(),
152                    })
153                })
154            },
155            "u32",
156        )
157    }
158
159    /// Read as `Vec<i64>` from a JSON array. Mirrors
160    /// [`super::SnapshotField::as_i64_array`].
161    pub fn as_i64_array(&self) -> SnapshotResult<Vec<i64>> {
162        json_to_typed_array(self, json_to_i64, "i64")
163    }
164
165    /// Read as `Vec<f64>` from a JSON array. Mirrors
166    /// [`super::SnapshotField::as_f64_array`].
167    pub fn as_f64_array(&self) -> SnapshotResult<Vec<f64>> {
168        json_to_typed_array(self, json_to_f64, "f64")
169    }
170
171    /// Read as `Vec<bool>` from a JSON array of booleans. Mirrors
172    /// [`super::SnapshotField::as_bool_array`]; rejects mixed
173    /// arrays (no implicit truthiness coercion — JSON-side `bool`
174    /// already has a wire shape).
175    pub fn as_bool_array(&self) -> SnapshotResult<Vec<bool>> {
176        json_to_typed_array(
177            self,
178            |v| match v {
179                serde_json::Value::Bool(b) => Ok(*b),
180                other => Err(SnapshotError::TypeMismatch {
181                    expected: "bool".to_string(),
182                    actual: describe_json_kind(other),
183                    requested: String::new(),
184                }),
185            },
186            "bool",
187        )
188    }
189
190    /// Iterate the elements of a JSON array as [`JsonField`]s so
191    /// chained navigation composes for arrays-of-objects:
192    /// `field.iter_members().filter_map(|el| el.get("name").as_u64().ok())`.
193    /// Mirrors [`super::SnapshotField::iter_members`].
194    ///
195    /// Yields nothing for non-array values or missing fields —
196    /// the empty iterator is the natural "no elements" shape when
197    /// the chain just wants to fold over what's there. Callers
198    /// needing to distinguish "absent" from "empty" check
199    /// [`Self::is_present`] or [`Self::error`] explicitly.
200    pub fn iter_members(&self) -> impl Iterator<Item = JsonField<'a>> + '_ {
201        let elements: &[serde_json::Value] = match self {
202            JsonField::Value(serde_json::Value::Array(a)) => a.as_slice(),
203            _ => &[],
204        };
205        elements.iter().map(JsonField::Value)
206    }
207}
208
209/// Shared typed-array coercion used by [`JsonField::as_u64_array`]
210/// and siblings. Mirrors the SnapshotField helper so callers see
211/// uniform diagnostics across surfaces.
212fn json_to_typed_array<T, F>(
213    field: &JsonField<'_>,
214    coerce: F,
215    type_name: &'static str,
216) -> SnapshotResult<Vec<T>>
217where
218    F: Fn(&serde_json::Value) -> SnapshotResult<T>,
219{
220    let value = match field {
221        JsonField::Value(v) => *v,
222        JsonField::Missing(err) => return Err(err.clone()),
223    };
224    let elements = match value {
225        serde_json::Value::Array(a) => a.as_slice(),
226        other => {
227            return Err(SnapshotError::TypeMismatch {
228                expected: format!("[{type_name}]"),
229                actual: describe_json_kind(other),
230                requested: String::new(),
231            });
232        }
233    };
234    let mut out = Vec::with_capacity(elements.len());
235    for element in elements {
236        out.push(coerce(element)?);
237    }
238    Ok(out)
239}
240
241/// Build a [`JsonField`] view rooted at `value` and walk along the
242/// dotted path. An empty path returns the root unchanged so a
243/// caller writing `stats_path(v, "").as_f64()` (e.g. for a
244/// scalar-rooted stats response) hits the typed scalar accessor
245/// directly.
246///
247/// Mirrors [`super::Snapshot::var`] / [`super::SnapshotEntry::get`] in error
248/// shape: typos and missing keys surface as
249/// [`SnapshotError::FieldNotFound`] with the available sibling
250/// keys at the failing depth — the same diagnostic experience the
251/// BPF-snapshot side already provides. scx_stats payloads commonly
252/// nest layer / cgroup / cpu maps under top-level keys, so the
253/// dotted form `"layers.batch.util"` is the canonical drill-down
254/// for layered scheduler stats.
255pub fn stats_path<'a>(value: &'a serde_json::Value, path: &str) -> JsonField<'a> {
256    walk_json_path(value, path)
257}
258
259fn walk_json_path<'a>(root: &'a serde_json::Value, path: &str) -> JsonField<'a> {
260    if path.is_empty() {
261        return JsonField::Value(root);
262    }
263    let mut cursor: &serde_json::Value = root;
264    let mut walked = String::new();
265    for component in path.split('.') {
266        if component.is_empty() {
267            return JsonField::Missing(SnapshotError::EmptyPathComponent {
268                requested: path.to_string(),
269            });
270        }
271        match cursor {
272            serde_json::Value::Object(map) => {
273                let Some(next) = map.get(component) else {
274                    let mut available: Vec<String> = map.keys().cloned().collect();
275                    available.sort();
276                    return JsonField::Missing(SnapshotError::FieldNotFound {
277                        requested: path.to_string(),
278                        walked: walked.clone(),
279                        component: component.to_string(),
280                        available,
281                    });
282                };
283                cursor = next;
284            }
285            other => {
286                return JsonField::Missing(SnapshotError::NotAStruct {
287                    requested: path.to_string(),
288                    walked: walked.clone(),
289                    component: component.to_string(),
290                    kind: describe_json_kind(other),
291                });
292            }
293        }
294        if !walked.is_empty() {
295            walked.push('.');
296        }
297        walked.push_str(component);
298    }
299    JsonField::Value(cursor)
300}
301
302fn describe_json_kind(v: &serde_json::Value) -> String {
303    match v {
304        serde_json::Value::Null => "Null",
305        serde_json::Value::Bool(_) => "Bool",
306        serde_json::Value::Number(_) => "Number",
307        serde_json::Value::String(_) => "String",
308        serde_json::Value::Array(_) => "Array",
309        serde_json::Value::Object(_) => "Object",
310    }
311    .to_string()
312}
313
314fn json_to_u64(v: &serde_json::Value) -> SnapshotResult<u64> {
315    match v {
316        serde_json::Value::Number(n) => {
317            if let Some(u) = n.as_u64() {
318                Ok(u)
319            } else if let Some(i) = n.as_i64() {
320                if i < 0 {
321                    Err(SnapshotError::TypeMismatch {
322                        expected: "u64".to_string(),
323                        actual: "Int(negative)".to_string(),
324                        requested: String::new(),
325                    })
326                } else {
327                    Ok(i as u64)
328                }
329            } else if let Some(f) = n.as_f64() {
330                if !f.is_finite() || f < 0.0 {
331                    Err(SnapshotError::TypeMismatch {
332                        expected: "u64".to_string(),
333                        actual: "Float(non-coercible)".to_string(),
334                        requested: String::new(),
335                    })
336                } else if f.fract() != 0.0 {
337                    Err(SnapshotError::TypeMismatch {
338                        expected: "integer".to_string(),
339                        actual: "non-integer float".to_string(),
340                        requested: String::new(),
341                    })
342                } else {
343                    Ok(f as u64)
344                }
345            } else {
346                Err(SnapshotError::TypeMismatch {
347                    expected: "u64".to_string(),
348                    actual: "Number(unrepresentable)".to_string(),
349                    requested: String::new(),
350                })
351            }
352        }
353        serde_json::Value::Bool(b) => Ok(u64::from(*b)),
354        serde_json::Value::String(s) => s.parse::<u64>().map_err(|_| SnapshotError::TypeMismatch {
355            expected: "u64".to_string(),
356            actual: "String(non-numeric)".to_string(),
357            requested: String::new(),
358        }),
359        other => Err(SnapshotError::TypeMismatch {
360            expected: "u64".to_string(),
361            actual: describe_json_kind(other),
362            requested: String::new(),
363        }),
364    }
365}
366
367fn json_to_i64(v: &serde_json::Value) -> SnapshotResult<i64> {
368    match v {
369        serde_json::Value::Number(n) => {
370            if let Some(i) = n.as_i64() {
371                Ok(i)
372            } else if let Some(u) = n.as_u64() {
373                if u > i64::MAX as u64 {
374                    Err(SnapshotError::TypeMismatch {
375                        expected: "i64".to_string(),
376                        actual: "Uint(>i64::MAX)".to_string(),
377                        requested: String::new(),
378                    })
379                } else {
380                    Ok(u as i64)
381                }
382            } else if let Some(f) = n.as_f64() {
383                if !f.is_finite() {
384                    Err(SnapshotError::TypeMismatch {
385                        expected: "i64".to_string(),
386                        actual: "Float(non-finite)".to_string(),
387                        requested: String::new(),
388                    })
389                } else if f.fract() != 0.0 {
390                    Err(SnapshotError::TypeMismatch {
391                        expected: "integer".to_string(),
392                        actual: "non-integer float".to_string(),
393                        requested: String::new(),
394                    })
395                } else {
396                    Ok(f as i64)
397                }
398            } else {
399                Err(SnapshotError::TypeMismatch {
400                    expected: "i64".to_string(),
401                    actual: "Number(unrepresentable)".to_string(),
402                    requested: String::new(),
403                })
404            }
405        }
406        serde_json::Value::Bool(b) => Ok(i64::from(*b)),
407        serde_json::Value::String(s) => s.parse::<i64>().map_err(|_| SnapshotError::TypeMismatch {
408            expected: "i64".to_string(),
409            actual: "String(non-numeric)".to_string(),
410            requested: String::new(),
411        }),
412        other => Err(SnapshotError::TypeMismatch {
413            expected: "i64".to_string(),
414            actual: describe_json_kind(other),
415            requested: String::new(),
416        }),
417    }
418}
419
420fn json_to_f64(v: &serde_json::Value) -> SnapshotResult<f64> {
421    match v {
422        serde_json::Value::Number(n) => n.as_f64().ok_or(SnapshotError::TypeMismatch {
423            expected: "f64".to_string(),
424            actual: "Number(unrepresentable)".to_string(),
425            requested: String::new(),
426        }),
427        serde_json::Value::String(s) => s.parse::<f64>().map_err(|_| SnapshotError::TypeMismatch {
428            expected: "f64".to_string(),
429            actual: "String(non-numeric)".to_string(),
430            requested: String::new(),
431        }),
432        other => Err(SnapshotError::TypeMismatch {
433            expected: "f64".to_string(),
434            actual: describe_json_kind(other),
435            requested: String::new(),
436        }),
437    }
438}
439
440#[cfg(test)]
441mod tests_coercion {
442    //! Host-pure coverage for the JSON coercion helpers
443    //! ([`json_to_u64`] / [`json_to_i64`] / [`json_to_f64`] /
444    //! [`describe_json_kind`]) and the [`JsonField`] terminal
445    //! accessors. The dotted-path walk is exercised by the snapshot
446    //! integration tests; this module pins the per-branch coercion
447    //! rules that decide whether a stats read SUCCEEDS or surfaces a
448    //! typed [`SnapshotError`] — the silent-wrong-answer surface where
449    //! a swapped arm would coerce a value the device should reject.
450    use super::*;
451    use serde_json::json;
452
453    // ---- describe_json_kind: every Value discriminant ----
454
455    #[test]
456    fn describe_json_kind_names_each_value_shape() {
457        assert_eq!(describe_json_kind(&json!(null)), "Null");
458        assert_eq!(describe_json_kind(&json!(true)), "Bool");
459        assert_eq!(describe_json_kind(&json!(1)), "Number");
460        assert_eq!(describe_json_kind(&json!("s")), "String");
461        assert_eq!(describe_json_kind(&json!([])), "Array");
462        assert_eq!(describe_json_kind(&json!({})), "Object");
463    }
464
465    // ---- json_to_u64: each accepted/rejected branch ----
466
467    #[test]
468    fn json_to_u64_accepts_uint_bool_and_numeric_string() {
469        assert_eq!(json_to_u64(&json!(42u64)).unwrap(), 42);
470        assert_eq!(json_to_u64(&json!(true)).unwrap(), 1);
471        assert_eq!(json_to_u64(&json!(false)).unwrap(), 0);
472        // scx_stats stringifies large counters to dodge 53-bit float collapse.
473        assert_eq!(
474            json_to_u64(&json!("18446744073709551615")).unwrap(),
475            u64::MAX
476        );
477    }
478
479    #[test]
480    fn json_to_u64_accepts_integral_float_rejects_fractional_and_negative() {
481        // A JSON float that happens to be integral coerces.
482        assert_eq!(json_to_u64(&json!(5.0)).unwrap(), 5);
483        // A fractional float is not an integer.
484        match json_to_u64(&json!(2.5)) {
485            Err(SnapshotError::TypeMismatch {
486                expected, actual, ..
487            }) => {
488                assert_eq!(expected, "integer");
489                assert_eq!(actual, "non-integer float");
490            }
491            other => panic!("expected non-integer-float TypeMismatch, got {other:?}"),
492        }
493        // A negative float cannot be a u64.
494        match json_to_u64(&json!(-2.5)) {
495            Err(SnapshotError::TypeMismatch {
496                expected, actual, ..
497            }) => {
498                assert_eq!(expected, "u64");
499                assert_eq!(actual, "Float(non-coercible)");
500            }
501            other => panic!("expected negative-float TypeMismatch, got {other:?}"),
502        }
503    }
504
505    #[test]
506    fn json_to_u64_rejects_negative_int_nonnumeric_string_and_other_shapes() {
507        match json_to_u64(&json!(-5)) {
508            Err(SnapshotError::TypeMismatch { actual, .. }) => {
509                assert_eq!(actual, "Int(negative)");
510            }
511            other => panic!("expected negative-int TypeMismatch, got {other:?}"),
512        }
513        match json_to_u64(&json!("abc")) {
514            Err(SnapshotError::TypeMismatch { actual, .. }) => {
515                assert_eq!(actual, "String(non-numeric)");
516            }
517            other => panic!("expected non-numeric-string TypeMismatch, got {other:?}"),
518        }
519        // null / array / object fall through to the describe_json_kind arm.
520        assert!(
521            json_to_u64(&json!(null))
522                .unwrap_err()
523                .to_string()
524                .contains("Null")
525        );
526        assert!(matches!(
527            json_to_u64(&json!({"k": 1})),
528            Err(SnapshotError::TypeMismatch { .. })
529        ));
530    }
531
532    // ---- json_to_i64: signed-specific branches ----
533
534    #[test]
535    fn json_to_i64_accepts_signed_bool_string_and_integral_float() {
536        assert_eq!(json_to_i64(&json!(-7)).unwrap(), -7);
537        assert_eq!(json_to_i64(&json!(true)).unwrap(), 1);
538        assert_eq!(json_to_i64(&json!("-123")).unwrap(), -123);
539        assert_eq!(json_to_i64(&json!(9.0)).unwrap(), 9);
540    }
541
542    #[test]
543    fn json_to_i64_rejects_uint_over_i64_max_and_fractional_float() {
544        // u64::MAX is stored as a u64 Number; it overflows i64.
545        match json_to_i64(&json!(u64::MAX)) {
546            Err(SnapshotError::TypeMismatch { actual, .. }) => {
547                assert_eq!(actual, "Uint(>i64::MAX)");
548            }
549            other => panic!("expected over-i64::MAX TypeMismatch, got {other:?}"),
550        }
551        match json_to_i64(&json!(2.5)) {
552            Err(SnapshotError::TypeMismatch {
553                expected, actual, ..
554            }) => {
555                assert_eq!(expected, "integer");
556                assert_eq!(actual, "non-integer float");
557            }
558            other => panic!("expected fractional-float TypeMismatch, got {other:?}"),
559        }
560    }
561
562    #[test]
563    fn json_to_i64_rejects_nonnumeric_string_and_other_shapes() {
564        match json_to_i64(&json!("nope")) {
565            Err(SnapshotError::TypeMismatch { actual, .. }) => {
566                assert_eq!(actual, "String(non-numeric)");
567            }
568            other => panic!("expected non-numeric-string TypeMismatch, got {other:?}"),
569        }
570        assert!(matches!(
571            json_to_i64(&json!([1, 2])),
572            Err(SnapshotError::TypeMismatch { .. })
573        ));
574    }
575
576    // ---- json_to_f64 ----
577
578    #[test]
579    fn json_to_f64_accepts_number_and_numeric_string_rejects_others() {
580        assert_eq!(json_to_f64(&json!(1.5)).unwrap(), 1.5);
581        // An integer Number coerces to f64.
582        assert_eq!(json_to_f64(&json!(3)).unwrap(), 3.0);
583        assert_eq!(json_to_f64(&json!("2.25")).unwrap(), 2.25);
584        match json_to_f64(&json!("x")) {
585            Err(SnapshotError::TypeMismatch { actual, .. }) => {
586                assert_eq!(actual, "String(non-numeric)");
587            }
588            other => panic!("expected non-numeric-string TypeMismatch, got {other:?}"),
589        }
590        assert!(matches!(
591            json_to_f64(&json!(null)),
592            Err(SnapshotError::TypeMismatch { .. })
593        ));
594    }
595
596    // ---- JsonField terminal accessors + Missing propagation ----
597
598    #[test]
599    fn json_field_is_present_raw_and_error_reflect_variant() {
600        let v = json!({"a": 1});
601        let present = stats_path(&v, "a");
602        assert!(present.is_present());
603        assert!(present.raw().is_some());
604        assert!(present.error().is_none());
605
606        let missing = stats_path(&v, "nope");
607        assert!(!missing.is_present());
608        assert!(missing.raw().is_none());
609        assert!(missing.error().is_some());
610    }
611
612    #[test]
613    fn json_field_as_bool_accepts_bool_rejects_other_and_propagates_missing() {
614        let t = json!({"on": true});
615        assert!(stats_path(&t, "on").as_bool().unwrap());
616        // A stringified "1" is NOT a bool — honest typing.
617        let n = json!({"on": "1"});
618        assert!(matches!(
619            stats_path(&n, "on").as_bool(),
620            Err(SnapshotError::TypeMismatch { .. })
621        ));
622        // A missing path propagates the FieldNotFound, not a type error.
623        assert!(matches!(
624            stats_path(&n, "absent").as_bool(),
625            Err(SnapshotError::FieldNotFound { .. })
626        ));
627    }
628
629    #[test]
630    fn json_field_as_str_accepts_string_rejects_other_and_propagates_missing() {
631        let v = json!({"name": "batch"});
632        assert_eq!(stats_path(&v, "name").as_str().unwrap(), "batch");
633        let num = json!({"name": 7});
634        assert!(matches!(
635            stats_path(&num, "name").as_str(),
636            Err(SnapshotError::TypeMismatch { .. })
637        ));
638        assert!(matches!(
639            stats_path(&v, "absent").as_str(),
640            Err(SnapshotError::FieldNotFound { .. })
641        ));
642    }
643
644    #[test]
645    fn json_field_scalar_accessors_propagate_missing_error() {
646        let v = json!({"x": 1});
647        let missing = stats_path(&v, "absent");
648        // Every typed terminal returns the SAME FieldNotFound, not a coercion error.
649        assert!(matches!(
650            missing.as_u64(),
651            Err(SnapshotError::FieldNotFound { .. })
652        ));
653        assert!(matches!(
654            missing.as_i64(),
655            Err(SnapshotError::FieldNotFound { .. })
656        ));
657        assert!(matches!(
658            missing.as_f64(),
659            Err(SnapshotError::FieldNotFound { .. })
660        ));
661    }
662
663    #[test]
664    fn json_field_get_on_missing_stays_missing() {
665        let v = json!({"x": 1});
666        // Drilling into an already-failed lookup keeps the original error.
667        let chained = stats_path(&v, "absent").get("deeper");
668        assert!(matches!(
669            chained.error(),
670            Some(SnapshotError::FieldNotFound { .. })
671        ));
672    }
673
674    #[test]
675    fn json_field_iter_members_yields_elements_and_empty_for_nonarray() {
676        let arr = json!([10, 20, 30]);
677        let got: Vec<u64> = stats_path(&arr, "")
678            .iter_members()
679            .map(|el| el.as_u64().unwrap())
680            .collect();
681        assert_eq!(got, vec![10, 20, 30]);
682        // A non-array value yields nothing (the natural "no elements" shape).
683        let scalar = json!(5);
684        assert_eq!(stats_path(&scalar, "").iter_members().count(), 0);
685        // A missing field also yields nothing.
686        let obj = json!({"a": 1});
687        assert_eq!(stats_path(&obj, "absent").iter_members().count(), 0);
688    }
689
690    // ---- typed-array coercion: success, out-of-range, non-array, Missing ----
691
692    #[test]
693    fn json_field_typed_arrays_extract_each_element_type() {
694        let v = json!({
695            "u": [1, 2, 3],
696            "i": [-1, 0, 5],
697            "f": [1.5, 2.0],
698            "b": [true, false, true],
699        });
700        assert_eq!(
701            stats_path(&v, "u").as_u64_array().unwrap(),
702            vec![1u64, 2, 3]
703        );
704        assert_eq!(
705            stats_path(&v, "i").as_i64_array().unwrap(),
706            vec![-1i64, 0, 5]
707        );
708        assert_eq!(stats_path(&v, "f").as_f64_array().unwrap(), vec![1.5, 2.0]);
709        assert_eq!(
710            stats_path(&v, "b").as_bool_array().unwrap(),
711            vec![true, false, true]
712        );
713    }
714
715    #[test]
716    fn json_field_as_u32_array_rejects_out_of_range_element() {
717        // An element exceeding u32::MAX must error, not silently truncate.
718        let v = json!({"c": [1, 4294967296u64]});
719        match stats_path(&v, "c").as_u32_array() {
720            Err(SnapshotError::TypeMismatch {
721                expected, actual, ..
722            }) => {
723                assert_eq!(expected, "u32");
724                assert_eq!(actual, "Uint(>u32::MAX)");
725            }
726            other => panic!("expected u32 out-of-range TypeMismatch, got {other:?}"),
727        }
728    }
729
730    #[test]
731    fn json_field_typed_array_on_nonarray_value_errors_with_element_type() {
732        // The "expected" diagnostic names the element type in brackets.
733        let v = json!({"scalar": 7});
734        match stats_path(&v, "scalar").as_u64_array() {
735            Err(SnapshotError::TypeMismatch {
736                expected, actual, ..
737            }) => {
738                assert_eq!(expected, "[u64]");
739                assert_eq!(actual, "Number");
740            }
741            other => panic!("expected non-array TypeMismatch, got {other:?}"),
742        }
743    }
744
745    #[test]
746    fn json_field_typed_array_propagates_missing_error() {
747        let v = json!({"x": 1});
748        assert!(matches!(
749            stats_path(&v, "absent").as_u64_array(),
750            Err(SnapshotError::FieldNotFound { .. })
751        ));
752    }
753}