tatara_process/json_object.rs
1//! Substrate primitive over `serde_json::Value` — the ONE substrate
2//! owner of the `.as_object_mut().ok_or_else(|| anyhow::anyhow!(
3//! "<slot> is not an object"))` guard-shape every JSON-mutating helper
4//! restates by hand at the "walk this `Value` slot into its
5//! `serde_json::Map` interior or fail loud" boundary.
6//!
7//! Peer of the trait family that already lives in this crate on the
8//! wrap-shape axis:
9//!
10//! * [`crate::kube_error::KubeResultExt`] — the `kube::Error → anyhow`
11//! display-prefix wrap.
12//! * [`crate::hostname::HostnameResultExt`] — the `HostnameError →
13//! anyhow` display-prefix wrap.
14//! * [`crate::anyhow_flatten::FlattenCtxExt`] — the `anyhow::Error →
15//! anyhow` display-prefix flatten.
16//! * This module — the `Option<&mut Map> → anyhow::Result<&mut Map>`
17//! type-guard, partitioned from the three above by SOURCE (`None`
18//! from the slot-typecheck, not a lifted error type) but sharing the
19//! `.map_err(|_| anyhow!("<slug>: …"))?` display-prefix wire format.
20//! The module also owns the READ-side [`ValueGetExt`] projector
21//! (`.get_i64(<key>) -> Option<i64>`) — sibling of the three
22//! MUTATION-side traits below on the (read, mutate) axis, closing
23//! the READ half of the `serde_json::Value` substrate the four
24//! traits jointly own.
25//!
26//! Pre-lift the shape was hand-authored at THREE adjacent private
27//! helpers in `tatara-reconciler::ssapply` past the ★★ PRIME-DIRECTIVE
28//! ≥ 2 duplication threshold:
29//!
30//! * `metadata_object_mut(resource)` — the root-guard step
31//! (`resource.as_object_mut().ok_or_else(|| anyhow!("resource is not
32//! an object"))?`) that opens the SSA-time
33//! `resource → &mut metadata` walk shared by `inject_owner_reference`
34//! + `inject_annotations`.
35//! * `metadata_object_mut(resource)` — the metadata-slot type-check
36//! step (`metadata.as_object_mut().ok_or_else(|| anyhow!("metadata
37//! is not an object"))?`) that closes the same walk — a resource
38//! whose author mistyped the `metadata` slot as an array / string
39//! surfaces as an error rather than as a silent
40//! `.as_object_mut() → None → skip` no-op.
41//! * `inject_annotations(resource, process)` — the annotations-slot
42//! type-check step (`annot.as_object_mut().ok_or_else(|| anyhow!(
43//! "annotations is not an object"))?`) that opens the SSA-time
44//! `metadata → &mut annotations` walk before the ownership tag +
45//! observed-* primitive family drops its keys into the map.
46//!
47//! All three restated the SAME 2-line shape verbatim: `.as_object_mut()`
48//! on a `serde_json::Value` handle already known to be non-null, then
49//! `.ok_or_else(|| anyhow!("<slot-name> is not an object"))` wrap
50//! whose slot name matched the walk step's semantic role (`"resource"`
51//! / `"metadata"` / `"annotations"`). THREE byte-for-byte identical
52//! guard blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
53//! differing only in the `&'static str` slot name each callsite
54//! stamped.
55//!
56//! Post-lift each callsite reads
57//! `<value>.as_object_mut_or("<slot>")?` and the guard-shape lives at
58//! ONE substrate owner here. The composed `anyhow::Error`'s `Display`
59//! is byte-identical to the pre-lift chain (`"<slot> is not an
60//! object"`), so operator-facing log output and any error-chain greps
61//! still match bytewise. A regression that drifts the message (a
62//! `"<slot> is not a JSON object"` synonym, a swapped `<slot>` slot,
63//! a promotion to a chain-form `source` that only surfaces via the
64//! alternate `{e:#}` formatter) surfaces at the tests below rather
65//! than as silent operator-facing drift across the three pre-lift
66//! consumers.
67//!
68//! ### Naming — `as_object_mut_or`, not `as_object_mut`
69//!
70//! Same discipline as the three sibling traits above — the trait
71//! method deliberately does NOT share a name with the inherent
72//! `serde_json::Value::as_object_mut` method (which returns
73//! `Option<&mut Map>`), because a name collision would let a caller
74//! who has `ValueObjectExt` in scope resolve to the inherent method
75//! by accident (inherent methods win over trait methods in method
76//! resolution) and silently drop the type-guard wrap altogether. The
77//! `_or` suffix names the intent: guard the `Option → Result` step
78//! at the same call, matching the pre-lift `.as_object_mut().
79//! ok_or_else(...)` chain.
80//!
81//! ### `#[must_use]`
82//!
83//! Every consumer threads the `?` short-circuit onto its handler's
84//! `Result<_, anyhow::Error>` return — dropping the guard swallows
85//! the underlying type-mismatch entirely, which is never the intended
86//! semantic at any of the three pre-lift consumers (each downstream
87//! `md.entry(...).or_insert_with(...)` / `annot.insert(...)` mutation
88//! depends on the returned `&mut Map` reference).
89//!
90//! Theory anchor: THEORY.md §VI.1 (generation over composition — the
91//! `.as_object_mut().ok_or_else(|| anyhow!("<slot> is not an
92//! object"))` guard-shape recurred at three hand-authored sites past
93//! the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
94//! ONE substrate owner here). THEORY.md §II.1 invariant 5 (composition
95//! preserves proofs — a regression that drifts the guard message
96//! wording at ONE site surfaces here at the substrate pin rather than
97//! as silent operator-facing skew across every SSA-time
98//! `metadata_object_mut` + `inject_annotations` mutation).
99
100use serde_json::{Map, Value};
101
102/// Substrate extension trait over `serde_json::Value` — the ONE
103/// substrate owner of the `.as_object_mut().ok_or_else(|| anyhow!(
104/// "<slot> is not an object"))` guard-shape. See the module docs for
105/// the full callsite audit + the naming rationale (why
106/// `as_object_mut_or` and not `as_object_mut`).
107pub trait ValueObjectExt {
108 /// Borrow the [`Value`] as a mutable JSON object [`Map`], or fail
109 /// loud with an [`anyhow::Error`] whose `Display` reads exactly
110 /// `"<slot> is not an object"` — the pre-lift wire format every
111 /// consumer's `tracing::error!(error = %e, ...)` log line already
112 /// encoded.
113 #[must_use = "an object-guard that isn't threaded via `?` swallows the underlying type mismatch"]
114 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>>;
115}
116
117impl ValueObjectExt for Value {
118 #[inline]
119 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>> {
120 self.as_object_mut()
121 .ok_or_else(|| anyhow::anyhow!("{slot} is not an object"))
122 }
123}
124
125/// Substrate extension trait over `serde_json::Map<String, Value>` —
126/// the ONE substrate owner of the `map.insert(<key>.into(),
127/// Value::String(<val>.into()))` string-slot insertion shape every
128/// JSON-mutating helper in the workspace hand-authored at each callsite.
129///
130/// Peer of [`ValueObjectExt`] above on the JSON-mutation axis, split
131/// by SHAPE: [`ValueObjectExt::as_object_mut_or`] owns the "walk into
132/// this `Value`'s object-shape interior or fail loud" guard;
133/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
134/// a string-typed key" write shape that every consumer downstream of
135/// the guard uses to populate the returned `&mut Map`.
136///
137/// Pre-lift the shape was hand-authored at THIRTEEN production emit
138/// sites across `tatara-reconciler` past the ★★ PRIME-DIRECTIVE ≥ 2
139/// duplication threshold:
140///
141/// * `ssapply::inject_annotations` × 4 — the SSA-time observed-*
142/// annotation stamp family (`PID`, `CONTENT_HASH`, `GENERATION`,
143/// `ATTESTATION_ROOT`) each restated the 2-line `annot.insert(
144/// <annotation-const>.to_string(), Value::String(<val>.<coerce>))`
145/// shape verbatim.
146/// * `render::render_flux` × 3 — the Flux `Kustomization.spec` seeds
147/// (`interval`, `path`, `targetNamespace`), each restating the same
148/// `spec.insert("<key>".into(), Value::String(<val>))` shape.
149/// * `render::render_aplicacao` × 3 — the Flux `HelmRelease.spec`
150/// seeds (`releaseName`, `targetNamespace`) plus the values-overlay
151/// `profile` slot, each restating the same insert shape.
152/// * `render::render_export_job` × 2 — the export-Job outer label map
153/// (`ROLE`, `EXPORT_INDEX`) each restating the same insert shape.
154/// * `edges::IngressEdge::render` × 1 — the cert-manager
155/// `cluster-issuer` annotation, restating the same insert shape.
156///
157/// All THIRTEEN pre-lift sites restated the SAME 2-line shape verbatim,
158/// differing only in the `&'static str` / `String` key + the `&str` /
159/// `String` value at each callsite. A copy-paste that dropped the
160/// `Value::String(...)` wrap (a caller who reached for
161/// `.insert(k, v)` after refactoring from a `Value` slot to a plain
162/// `String` value slot) would type-check silently at every callsite —
163/// `Map<String, Value>::insert` expects a `Value`, and `String:
164/// Into<Value>` is provided by `serde_json` via the `Value::String`
165/// arm's `From` impl, so the naive `.insert(k, v.to_string())` compiles
166/// AND writes the byte-identical JSON. Post-lift each callsite reads
167/// `<map>.insert_str(<key>, <val>)` and the string-slot write shape
168/// lives at ONE substrate owner here.
169///
170/// ### Composability
171///
172/// * Key slot accepts any `impl Into<String>`: `&str` (via
173/// `String::from`), `String` (identity), `Cow<'_, str>`, so a
174/// callsite with a static `annotations::PID` (`&'static str`) reads
175/// `insert_str(annotations::PID, …)` with no `.to_string()` per site.
176/// * Value slot accepts any `impl Into<String>`: `&str`, `String`,
177/// `Cow<'_, str>`. Numeric or non-string values still need an
178/// explicit `.to_string()` at the callsite — same as pre-lift, so
179/// the wrapping shape stays visible in the caller's grep footprint.
180/// * Returns `Option<Value>` matching the inherent
181/// `Map<String, Value>::insert` return semantics: `None` on new-key,
182/// `Some(prev)` on overwrite of an existing slot.
183///
184/// ### Naming — `insert_str`, not `insert`
185///
186/// Same discipline as [`ValueObjectExt::as_object_mut_or`] above — the
187/// trait method deliberately does NOT collide with the inherent
188/// `Map::insert` (which takes `(String, Value)` positionally). A name
189/// collision would let a caller who has `JsonMapStrExt` in scope
190/// resolve to the inherent method by accident (inherent methods win
191/// over trait methods in method resolution) and silently drop the
192/// `Value::String` wrap, stamping the value bytes straight into the
193/// map under a different `Value` variant. The `_str` suffix names the
194/// intent: the value slot IS the `Value::String` arm at this write.
195///
196/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
197/// `.insert(<k>.into(), Value::String(<v>.into()))` shape recurred at
198/// THIRTEEN hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
199/// duplication trigger, and is lifted to ONE substrate owner here).
200/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
201/// regression that drifts the string-slot write shape at ONE consumer
202/// surfaces at the substrate pin rather than as silent per-emit skew
203/// across every ssapply / render / edges JSON emit site).
204pub trait JsonMapStrExt {
205 /// Insert a `Value::String(<val>.into())` at `<key>.into()` into
206 /// this JSON object map. Returns `Option<Value>` matching the
207 /// underlying `Map::insert` semantics — `None` for a new key,
208 /// `Some(prev)` for an overwrite.
209 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value>;
210}
211
212impl JsonMapStrExt for Map<String, Value> {
213 #[inline]
214 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value> {
215 self.insert(key.into(), Value::String(value.into()))
216 }
217}
218
219/// Substrate extension trait over `serde_json::Map<String, Value>` —
220/// the ONE substrate owner of the `.entry(<key>).or_insert_with(||
221/// Value::Object(<empty>))` seed-then-guard shape every JSON-mutating
222/// helper hand-authored at the "walk into this object slot on the
223/// parent map, seeding an empty object if the slot is absent, or fail
224/// loud if the slot exists but is a non-object" boundary.
225///
226/// Peer of [`ValueObjectExt::as_object_mut_or`] and
227/// [`JsonMapStrExt::insert_str`] on the JSON-mutation axis; split by
228/// SHAPE + SITE. [`ValueObjectExt::as_object_mut_or`] owns the "guard
229/// a `Value` handle into its object interior" step at ONE level;
230/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
231/// a string-typed key" write shape; this trait owns the compound
232/// "get-or-seed the object at a slot, then guard" step every SSA-time
233/// re-injection walks when the caller intends to reach a nested
234/// object slot without asserting whether the parent has already
235/// populated it (a caller composing a fresh resource-body carries
236/// no `metadata` / `metadata.annotations` slot pre-seed; a caller
237/// composing atop a pre-populated resource does — both paths reach
238/// the same primitive).
239///
240/// Pre-lift the compound shape was hand-authored at TWO adjacent
241/// private helpers in `tatara-reconciler::ssapply` past the ★★
242/// PRIME-DIRECTIVE ≥ 2 duplication threshold, both walking the SAME
243/// 3-step `let X = <map>.entry(<slot>).or_insert_with(|| Value::Object
244/// (<empty>)); X.as_object_mut_or(<slot>)?` incantation:
245///
246/// * `metadata_object_mut(resource)` — the `metadata` slot seed-then-
247/// guard step at the root of every SSA-time re-injection walk
248/// (`inject_owner_reference` + `inject_annotations` reach it).
249/// * `inject_annotations(resource, process)` — the `annotations`
250/// slot seed-then-guard step nested one level deeper under the
251/// `metadata` object the primitive above returned.
252///
253/// Both restated the SAME 3-line shape verbatim: `.entry(<slot>)` on
254/// a `Map<String, Value>` handle known to be an object, then
255/// `.or_insert_with(|| Value::Object(<empty>))` to synthesize an
256/// empty object at the slot when absent, then a `.as_object_mut_or
257/// (<slot>)?` guard on the returned `&mut Value` to fail loud when
258/// the existing slot is a non-object. TWO byte-for-byte identical
259/// blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
260/// differing only in the `&'static str` slot name each callsite
261/// stamped (`"metadata"` / `"annotations"`) — and the slot name is
262/// used at BOTH the entry key AND the guard error message so a
263/// regression that drifted the two apart at one callsite (a typo
264/// stamping `"metadata"` into the entry key + `"metadatas"` into
265/// the error message) would silently pass one pin and fail the
266/// other. Post-lift each callsite reads `<map>.object_slot_mut_or
267/// (<slot>)?` and the compound shape lives at ONE substrate owner
268/// here — the slot name is stamped ONCE per call and reaches both
269/// the entry key and the guard error slot mechanically.
270///
271/// ### Composability
272///
273/// * Slot name is `&'static str` — pre-lift both callsites stamped
274/// `&'static str` literals (`"metadata"` / `"annotations"`); a
275/// dynamic-slot caller (a callsite that reached this primitive
276/// with a `String` key computed at runtime) has no pre-lift
277/// precedent in the ssapply/render axis, so the `&'static str`
278/// bound stays honest to the pre-lift shape. A future caller
279/// needing a runtime slot name can widen this to
280/// `impl Into<String>` at the substrate; the pre-lift consumers
281/// inherit it mechanically.
282/// * Returns `anyhow::Result<&mut Map<String, Value>>` — matches the
283/// sibling [`ValueObjectExt::as_object_mut_or`] shape so the
284/// downstream `.entry(...).or_insert_with(...)` / `.insert(...)`
285/// mutation threads through `?` onto the caller's
286/// `Result<_, anyhow::Error>` return exactly as pre-lift.
287/// * Ok-arm returns the SAME `&mut Map<String, Value>` the pre-lift
288/// `.as_object_mut_or(<slot>)` step returned — no clone, no key-
289/// order reshape, no synthesis.
290///
291/// ### Naming — `object_slot_mut_or`, not `entry_object` or
292/// `get_or_insert_object_mut`
293///
294/// Same discipline as the two sibling traits above — the trait method
295/// deliberately does NOT collide with the inherent `Map::entry` /
296/// `Map::get_mut` / `Map::insert` methods (any of which a caller who
297/// has this trait in scope could resolve to by accident, silently
298/// dropping the type-guard step). The `_or` suffix names the intent
299/// (guard the `Option → Result` step at the same call, matching the
300/// pre-lift `.as_object_mut_or(<slot>)?` guard); `object_slot_mut`
301/// names the target shape (return an `&mut` object-typed `Map` at
302/// the slot). Together they read as "guard the slot into a mutable
303/// object interior or fail loud", matching the pre-lift semantics
304/// exactly.
305///
306/// ### `#[must_use]`
307///
308/// Every consumer threads the `?` short-circuit onto its handler's
309/// `Result<_, anyhow::Error>` return — dropping the guard swallows
310/// the underlying type-mismatch entirely, which is never the intended
311/// semantic at either pre-lift consumer (each downstream
312/// `.entry(...).or_insert_with(...)` / `.insert(...)` mutation
313/// depends on the returned `&mut Map` reference).
314///
315/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
316/// 3-line `.entry(<slot>).or_insert_with(|| Value::Object(<empty>))
317/// .as_object_mut_or(<slot>)?` compound shape recurred at two
318/// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
319/// trigger, and is lifted to ONE substrate owner here). THEORY.md
320/// §II.1 invariant 5 (composition preserves proofs — a regression
321/// that drifted the entry-key slot vs. the guard-error slot at ONE
322/// site would silently pass one downstream pin and fail the other;
323/// post-lift the primitive stamps the slot ONCE per call so the
324/// substrate itself owns the entry-key ↔ guard-error name coherence).
325pub trait JsonMapObjectEntryExt {
326 /// Get-or-seed the object at `slot` in this JSON map, then guard
327 /// that the resulting handle is an object; returns
328 /// `&mut Map<String, Value>` on the object arm, and an
329 /// [`anyhow::Error`] whose `Display` reads
330 /// `"<slot> is not an object"` on the non-object arm (byte-
331 /// identical to the pre-lift `.as_object_mut_or(<slot>)?` guard,
332 /// sourced from the sibling [`ValueObjectExt::as_object_mut_or`]).
333 #[must_use = "an object-slot guard that isn't threaded via `?` swallows the underlying type mismatch"]
334 fn object_slot_mut_or(&mut self, slot: &'static str)
335 -> anyhow::Result<&mut Map<String, Value>>;
336}
337
338impl JsonMapObjectEntryExt for Map<String, Value> {
339 #[inline]
340 fn object_slot_mut_or(
341 &mut self,
342 slot: &'static str,
343 ) -> anyhow::Result<&mut Map<String, Value>> {
344 self.entry(slot)
345 .or_insert_with(|| Value::Object(Map::new()))
346 .as_object_mut_or(slot)
347 }
348}
349
350/// Substrate extension trait over `serde_json::Value` — the ONE
351/// substrate owner of the paired `.get(<key>).and_then(|v| v.as_<T>())`
352/// two-link READ chain every downstream projection walks to pull a
353/// typed leaf off a Kubernetes-status blob (or an equivalent
354/// rendered-resource JSON object) without asserting the slot is
355/// present, without asserting its variant, and without asserting the
356/// slot fits the target scalar type.
357///
358/// The trait carries ONE method per typed READ axis; the axis-family
359/// is [`Self::get_i64`] (integer counters) + [`Self::get_str`]
360/// (string slots) + [`Self::get_array`] (JSON array slots). Adding a
361/// new axis (a `get_bool` for `Value::Bool`, a `get_object` for
362/// `Value::Object`, a `get_f64` for `Value::Number` truncated to
363/// `f64`) lands as ONE new method here + ONE impl arm, inheriting
364/// the naming, `#[must_use]`, and inline discipline the existing
365/// axes pin. Never open a peer trait for a new axis — keep every
366/// READ projection on the ONE substrate owner so a caller who
367/// imports `ValueGetExt` reaches every axis through the same trait
368/// handle.
369///
370/// READ-side counterpart to the three MUTATION-side siblings already in
371/// this module — [`ValueObjectExt::as_object_mut_or`],
372/// [`JsonMapStrExt::insert_str`], [`JsonMapObjectEntryExt::object_slot_mut_or`]
373/// — partitioning the substrate along the (read, mutate) axis on the
374/// same `serde_json::Value` / `serde_json::Map<String, Value>` carrier
375/// pair.
376///
377/// Pre-lift the two-link chain was hand-authored at THREE adjacent
378/// slots inside `tatara-reconciler::boundary::fetch_job_status`, each
379/// projecting one `batch/v1::Job` `status.<counter>` field out of the
380/// fetched `serde_json::Value` object into a private `JobStatusView`
381/// row past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
382///
383/// * `status.get("succeeded").and_then(|v| v.as_i64())` — the
384/// Job-completion counter every `JobAttested` + `ClosedLoopAuth`
385/// postcondition evaluator gates on (`succeeded < 1` short-circuits
386/// to `Satisfaction::Unsatisfied("… still running (…)")`).
387/// * `status.get("failed").and_then(|v| v.as_i64())` — the
388/// Job-failure counter the same evaluators gate on
389/// (`failed > 0` short-circuits to
390/// `Satisfaction::Unsatisfied("… failed (status.failed={n})")`).
391/// * `status.get("active").and_then(|v| v.as_i64())` — the
392/// Job-in-flight counter the "still running" diagnostic tail
393/// reports as `(succeeded={s}, active={a})`.
394///
395/// All THREE sites walked the SAME two-link chain — `.get(<key>)` on a
396/// `serde_json::Value` already known to be the status object, then
397/// `.and_then(|v| v.as_i64())` on the returned `Option<&Value>` — and
398/// each was followed by an `if let Some(...)` write into the
399/// [`JobStatusView`] row initialised from `Default::default()`. Post-
400/// lift each callsite reads `status.get_i64(<key>)` and the two-link
401/// READ chain lives at ONE substrate owner here.
402///
403/// ### Naming — `get_i64`, not `as_i64` or `i64_at`
404///
405/// Same discipline as the three sibling traits above — the trait method
406/// deliberately does NOT collide with `serde_json::Value::as_i64` (the
407/// inherent projection on a single `Value` handle) nor with
408/// `serde_json::Value::get` (the inherent slot-lookup returning
409/// `Option<&Value>`). A name collision would let a caller who has
410/// `ValueGetExt` in scope resolve to one of the inherent methods by
411/// accident (inherent methods win over trait methods in method
412/// resolution) and silently drop half of the paired chain. The
413/// `get_i64(<key>)` shape names the intent: look up the slot at
414/// `<key>`, project the returned handle to `i64`, in ONE call.
415///
416/// ### `#[must_use]`
417///
418/// Every consumer either binds the returned `Option<i64>` into a
419/// downstream `if let Some(n) = ...` / `.unwrap_or_default()` / struct-
420/// field construction. Dropping the return silently discards the
421/// projection entirely, which is never the intended semantic at the
422/// three pre-lift consumers (each downstream write depends on the
423/// returned counter).
424///
425/// ### Composability
426///
427/// * Key slot is `&str` — matches every pre-lift `.get("<literal>")`
428/// callsite and the inherent `serde_json::Value::get`'s primary
429/// `str`-index arm. A caller with a runtime-computed key (a
430/// `String` produced by a template composer) reaches through
431/// `.get_i64(&s)` mechanically via `Deref<Target = str>`.
432/// * Returns `Option<i64>` matching the composed inherent chain's own
433/// return; a consumer wanting the "absent or non-integer → 0"
434/// fallback composes `.unwrap_or_default()` (or `.unwrap_or(0)`) at
435/// the callsite, keeping the "should this counter default to 0 or
436/// fail loud" decision at the caller rather than baking it into the
437/// primitive.
438/// * Non-object receivers (a `Value::String`, a `Value::Null`) return
439/// `None` verbatim via the inherent `Value::get`'s own non-object-
440/// arm behaviour, matching the pre-lift chain's semantics on the
441/// corner where the caller's status blob is malformed.
442///
443/// A future normalization — a per-fleet clamp that rejects negative
444/// counters (the K8s API server never emits them, but a fixture
445/// authoring bug could), a `Value::Number` fallback that accepts
446/// `f64` counters truncated to `i64`, a `checked` overflow arm that
447/// promotes an out-of-range integer to a diagnostic rather than a
448/// silent `None` — lands at THIS ONE substrate primitive and every
449/// downstream Job-status / Deployment-replica / HPA-desired-count
450/// counter reader inherits the upgrade mechanically. No per-site edit
451/// at any of the 3 listed callers or at future consumers (a
452/// Deployment `readyReplicas` projection, an HPA `currentReplicas`
453/// gate, a StatefulSet `updatedReplicas` freshness check).
454///
455/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
456/// two-link `.get(<key>).and_then(|v| v.as_i64())` chain recurred at
457/// three hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
458/// duplication trigger, and is lifted to ONE substrate owner here).
459/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
460/// regression that drifted the projection axis at ONE site — a swap
461/// of `as_i64` for `as_u64` narrowing the accepted range, a swap of
462/// `.get(<key>)` for `.pointer("<key>")` losing the direct-child
463/// semantics — would silently pass every downstream `JobStatusView`
464/// composition and surface as a wrong counter at operator-facing
465/// diagnostic wording; post-lift the projection lives at ONE typed
466/// owner so a regression surfaces at [`tests::get_i64_null_arm_returns_none`]
467/// / peers rather than as silent operator-facing drift).
468pub trait ValueGetExt {
469 /// Look up `key` on this JSON object and project the returned
470 /// handle to `i64`; returns `None` when the slot is absent, when
471 /// the receiver is not a JSON object, or when the slot's variant
472 /// is not integer-shaped.
473 #[must_use = "a JSON i64 projection that isn't bound swallows the counter entirely"]
474 fn get_i64(&self, key: &str) -> Option<i64>;
475
476 /// Look up `key` on this JSON object and project the returned
477 /// handle to `&str`; returns `None` when the slot is absent, when
478 /// the receiver is not a JSON object, or when the slot's variant
479 /// is not `Value::String`.
480 ///
481 /// String-axis sibling of [`Self::get_i64`] on the same
482 /// `.get(<key>).and_then(|v| v.as_<T>())` READ-chain lift. Pre-lift
483 /// the two-link chain was hand-authored at SEVEN production sites
484 /// across two crates past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
485 /// threshold:
486 ///
487 /// * `tatara-process::status::RenderedResourceCoords::from_json`
488 /// — FOUR paired reads (`apiVersion`, `kind`, `metadata.name`,
489 /// `metadata.namespace`) that project the four rendered-resource
490 /// coordinate slots off a `serde_json::Value` rendered manifest
491 /// into the typed `RenderedResourceCoords` row; the required
492 /// three (`apiVersion` / `kind` / `metadata.name`) compose with
493 /// `.ok_or_else(|| anyhow!("rendered resource missing X"))?
494 /// .to_string()`, and the optional `metadata.namespace` composes
495 /// with `.map(str::to_string)`.
496 /// * `tatara-reconciler::ssapply::ready_condition_value` — THREE
497 /// paired reads (`type`, `status`, `message`) inside the
498 /// condition-walker's per-condition classifier, each pulling a
499 /// `Value::String` slot off a K8s Condition object off the
500 /// `status.conditions[]` array.
501 ///
502 /// All seven sites walked the SAME two-link chain — `.get(<key>)`
503 /// on a `serde_json::Value` already known to be an object, then
504 /// `.and_then(|v| v.as_str())` on the returned `Option<&Value>` —
505 /// and each composed different downstream tails (fallible
506 /// `.ok_or_else(...)?.to_string()`, optional `.map(String::from)`,
507 /// pattern-match `Some("True")` / `Some("False")` / `_`). Post-lift
508 /// each callsite reads `<value>.get_str(<key>)` and the two-link
509 /// READ chain lives at ONE substrate owner here.
510 ///
511 /// ### Naming — `get_str`, not `as_str` or `str_at`
512 ///
513 /// Same discipline as [`Self::get_i64`] — the trait method
514 /// deliberately does NOT collide with `serde_json::Value::as_str`
515 /// (the inherent projection on a single `Value` handle) nor with
516 /// `serde_json::Value::get` (the inherent slot-lookup returning
517 /// `Option<&Value>`). A name collision would let a caller who has
518 /// [`ValueGetExt`] in scope resolve to one of the inherent methods
519 /// by accident (inherent methods win over trait methods in method
520 /// resolution) and silently drop half of the paired chain. The
521 /// `get_str(<key>)` shape names the intent: look up the slot at
522 /// `<key>`, project the returned handle to `&str`, in ONE call.
523 ///
524 /// ### `#[must_use]`
525 ///
526 /// Every pre-lift consumer binds the returned `Option<&str>` into
527 /// a downstream `.ok_or_else(...)?.to_string()` / `.map(String::from)`
528 /// / `.map(str::to_string)` / pattern-match arm. Dropping the
529 /// return silently discards the projection entirely, which is
530 /// never the intended semantic at any of the seven pre-lift
531 /// consumers.
532 ///
533 /// ### Return lifetime
534 ///
535 /// The `&str` borrows the same buffer the underlying
536 /// `Value::String` variant owns; the `Option<&str>` is bounded by
537 /// the receiver's lifetime (`&'_ self`), so a caller holding onto
538 /// the returned slice keeps the receiver borrowed. Matches the
539 /// pre-lift chain's own borrow shape (`v.as_str()` borrows through
540 /// the `&Value`).
541 ///
542 /// A future normalization on the projection — a Unicode
543 /// normalization pass (NFC-folding annotation values), a
544 /// per-fleet trim of leading/trailing whitespace, a rejection of
545 /// empty-string arms as "the caller meant absent" — lands at THIS
546 /// ONE substrate primitive and every downstream `apiVersion` /
547 /// `kind` / `metadata.name` / K8s-condition-string reader
548 /// inherits the upgrade mechanically.
549 ///
550 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
551 /// the two-link `.get(<key>).and_then(|v| v.as_str())` chain
552 /// recurred at SEVEN production sites across two crates past the
553 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
554 /// ONE substrate owner here on the string axis of the same
555 /// READ-chain axis-family the `get_i64` sibling opened for the
556 /// integer axis). THEORY.md §II.1 invariant 5 (composition
557 /// preserves proofs — a regression that drifted the projection
558 /// axis at ONE site would silently pass every downstream
559 /// composition and surface as a wrong slot at operator-facing
560 /// diagnostic wording; post-lift the projection lives at ONE
561 /// typed owner so a regression surfaces at
562 /// [`tests::get_str_present_string_slot_returns_the_slice`] /
563 /// peers rather than as silent operator-facing drift).
564 #[must_use = "a JSON &str projection that isn't bound swallows the slot entirely"]
565 fn get_str(&self, key: &str) -> Option<&str>;
566
567 /// Look up `key` on this JSON object and project the returned
568 /// handle to `&Vec<Value>`; returns `None` when the slot is
569 /// absent, when the receiver is not a JSON object, or when the
570 /// slot's variant is not `Value::Array`.
571 ///
572 /// Array-axis sibling of [`Self::get_i64`] + [`Self::get_str`]
573 /// on the same `.get(<key>).and_then(|v| v.as_<T>())` READ-chain
574 /// axis-family. Pre-lift the two-link chain was hand-authored at
575 /// TWO production sites across two crates past the ★★
576 /// PRIME-DIRECTIVE ≥ 2 duplication threshold:
577 ///
578 /// * `tatara-reconciler::ssapply::ready_condition_value` — the
579 /// tail of the `data.get("status").and_then(|s|
580 /// s.get("conditions")).and_then(|c| c.as_array())` walker that
581 /// opens the K8s Condition classifier every DynamicObject
582 /// readiness probe rides through.
583 /// * `tatara-closed-loop-probe::probe::count_jwks_keys` — the
584 /// JWKS-response walker that counts issuer-side public keys off
585 /// the `keys` slot for the closed-loop probe's per-run
586 /// `jwks_key_count` diagnostic.
587 ///
588 /// Both sites walked the SAME two-link chain — `.get(<key>)` on a
589 /// `serde_json::Value` already known to be an object, then
590 /// `.and_then(|v| v.as_array())` on the returned `Option<&Value>`
591 /// — and composed different downstream tails (`Some(conditions)`
592 /// pattern-match on the reconciler side, `.map(|xs| xs.len() as
593 /// u64)` on the probe side). Post-lift each callsite reads
594 /// `<value>.get_array(<key>)` and the two-link READ chain lives at
595 /// ONE substrate owner here. The probe-side variant additionally
596 /// sheds the pre-lift `.get("keys").cloned()` allocation because
597 /// this primitive borrows through the receiver rather than
598 /// cloning.
599 ///
600 /// ### Naming — `get_array`, not `as_array` or `array_at`
601 ///
602 /// Same discipline as [`Self::get_i64`] + [`Self::get_str`] — the
603 /// trait method deliberately does NOT collide with
604 /// `serde_json::Value::as_array` (the inherent projection on a
605 /// single `Value` handle) nor with `serde_json::Value::get` (the
606 /// inherent slot-lookup returning `Option<&Value>`). A name
607 /// collision would let a caller who has [`ValueGetExt`] in scope
608 /// resolve to one of the inherent methods by accident (inherent
609 /// methods win over trait methods in method resolution) and
610 /// silently drop half of the paired chain. The `get_array(<key>)`
611 /// shape names the intent: look up the slot at `<key>`, project
612 /// the returned handle to `&Vec<Value>`, in ONE call.
613 ///
614 /// ### `#[must_use]`
615 ///
616 /// Every pre-lift consumer binds the returned `Option<&Vec<Value>>`
617 /// into a downstream `let Some(...) = ... else { return ... }`
618 /// short-circuit or a `.map(|xs| xs.len() as u64).unwrap_or(0)`
619 /// counter composition. Dropping the return silently discards the
620 /// projection entirely, which is never the intended semantic at
621 /// either pre-lift consumer.
622 ///
623 /// ### Return lifetime
624 ///
625 /// The `&Vec<Value>` borrows the same buffer the underlying
626 /// `Value::Array` variant owns; the `Option<&Vec<Value>>` is
627 /// bounded by the receiver's lifetime (`&'_ self`), so a caller
628 /// iterating the returned slice keeps the receiver borrowed.
629 /// Matches the pre-lift chain's own borrow shape (`v.as_array()`
630 /// borrows through the `&Value`), and in the probe.rs case
631 /// eliminates the pre-lift `.cloned()` on the intermediate
632 /// `Value` that only existed to sidestep the borrow.
633 ///
634 /// A future normalization on the projection — a rejection of
635 /// empty arrays as "the caller meant absent", an accept-scalar
636 /// coercion (a `Value::String` promoted to a one-element array),
637 /// a per-fleet cap on array length that short-circuits pathological
638 /// payloads — lands at THIS ONE substrate primitive and every
639 /// downstream K8s-Condition classifier / JWKS-array counter /
640 /// future array-slot reader inherits the upgrade mechanically.
641 ///
642 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
643 /// the two-link `.get(<key>).and_then(|v| v.as_array())` chain
644 /// recurred at two production sites across two crates past the ★★
645 /// PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE
646 /// substrate owner here on the array axis of the same READ-chain
647 /// axis-family the `get_i64` + `get_str` siblings already own).
648 /// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
649 /// regression that drifted the projection axis at ONE site would
650 /// silently pass every downstream composition and surface as a
651 /// wrong slot at operator-facing diagnostic wording; post-lift the
652 /// projection lives at ONE typed owner so a regression surfaces
653 /// at [`tests::get_array_present_array_slot_returns_the_slice`] /
654 /// peers rather than as silent operator-facing drift).
655 #[must_use = "a JSON array projection that isn't bound swallows the slot entirely"]
656 fn get_array(&self, key: &str) -> Option<&Vec<Value>>;
657}
658
659impl ValueGetExt for Value {
660 #[inline]
661 fn get_i64(&self, key: &str) -> Option<i64> {
662 self.get(key).and_then(Value::as_i64)
663 }
664
665 #[inline]
666 fn get_str(&self, key: &str) -> Option<&str> {
667 self.get(key).and_then(Value::as_str)
668 }
669
670 #[inline]
671 fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
672 self.get(key).and_then(Value::as_array)
673 }
674}
675
676/// Receiver-shape widening of the READ-projection axis-family — the
677/// same three methods extended from `Value` (the `Value::Object` arm's
678/// walker) to `Map<String, Value>` (the object interior itself),
679/// closing the receiver-shape gap so a caller who already holds an
680/// `&Map<String, Value>` handle (via `.as_object().unwrap()`, via
681/// [`ValueObjectExt::as_object_mut_or`], via the two `JsonMap*Ext`
682/// siblings' returns, or via a helper like `ssapply::ownership_kv_pair`
683/// that composes and returns a `Map` directly) reaches the SAME
684/// `get_i64` / `get_str` / `get_array` methods without a
685/// `Value::Object(m)` rewrap detour.
686///
687/// Pre-lift the `.get(<key>).and_then(Value::as_<T>)` two-link chain
688/// was hand-authored at 30 `Map<String, Value>`-receiver sites past the
689/// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold — 19 in
690/// `tatara-reconciler::patch` tests (the phase-status wire-shape pins
691/// walking `obj = v.as_object().unwrap()` and `metadata = obj.get(
692/// "metadata").and_then(Value::as_object).unwrap()` receivers) plus
693/// 11 in `tatara-reconciler::ssapply` tests (the ownership-tag +
694/// composed-coord pins walking the `Map` handles returned by
695/// `ownership_annotations` / `ownership_labels` /
696/// `ownership_annotations_by_coord`). Post-lift each callsite reads
697/// `<map>.get_str(<key>)` / `<map>.get_array(<key>)` and the READ
698/// chain rides through the SAME substrate owner the `Value`-receiver
699/// callers already threaded through.
700///
701/// The axis-family invariant (a caller who imports `ValueGetExt`
702/// reaches every axis through the same trait handle — pinned at
703/// [`tests::get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import`]
704/// and its `get_str` sibling) extends verbatim to the `Map` receiver:
705/// a single `use tatara_process::json_object::ValueGetExt;` unlocks
706/// every axis on both receiver shapes. A future new axis (e.g. a
707/// `get_bool` for `Value::Bool` slots) adds one method on the trait
708/// and inherits both impls; there is no separate `MapGetExt` peer to
709/// keep in sync.
710///
711/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
712/// two-link chain recurred at 30 `Map`-receiver sites past the ★★
713/// PRIME-DIRECTIVE ≥ 2 duplication trigger, and rides through the
714/// same substrate owner the pre-existing `Value`-receiver impl above
715/// already pinned). THEORY.md §II.1 invariant 5 (composition preserves
716/// proofs — the receiver-shape widening carries the axis-family
717/// invariant across without splitting it into two traits).
718impl ValueGetExt for Map<String, Value> {
719 #[inline]
720 fn get_i64(&self, key: &str) -> Option<i64> {
721 self.get(key).and_then(Value::as_i64)
722 }
723
724 #[inline]
725 fn get_str(&self, key: &str) -> Option<&str> {
726 self.get(key).and_then(Value::as_str)
727 }
728
729 #[inline]
730 fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
731 self.get(key).and_then(Value::as_array)
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use serde_json::json;
739
740 // ─── ValueObjectExt::as_object_mut_or substrate pins ─────────────
741 //
742 // Fail-before-pass-after granularity: the `ValueObjectExt::
743 // as_object_mut_or` trait method did not exist before this commit,
744 // so each test below fails to compile pre-lift. Post-lift they
745 // collectively pin the object-guard shape at ONE substrate owner —
746 // a regression that drifts the error message wording, swaps the
747 // `<slot>` slot, wraps the source in a chain-form `source` (which
748 // would change `Display` output when downstream tracing formatters
749 // interpolate `{e}` rather than the chain-walking `{e:#}`), or
750 // promotes the pass-through arm to synthesis (a `None → Ok(&mut
751 // Map::default())` fallthrough that silently swallows a mistyped
752 // slot) surfaces HERE rather than as silent operator-facing skew
753 // across the three `ssapply.rs` pre-lift consumers whose log
754 // output already encoded the flat `"<slot> is not an object"`
755 // shape.
756
757 #[test]
758 fn as_object_mut_or_object_arm_returns_the_inner_map_mutably() {
759 // Ok-arm invariant: a `Value::Object` handle threaded through
760 // `as_object_mut_or("<slot>")` MUST return `Ok(&mut Map)`
761 // whose interior is the SAME `serde_json::Map` the underlying
762 // `serde_json::Value::as_object_mut` would return — no clone,
763 // no reshape, no synthesis. The `&mut` return is load-bearing
764 // at every consumer (each threads a downstream `.entry(...).
765 // or_insert_with(...)` / `.insert(...)` mutation onto the
766 // returned reference), so a regression that returned a fresh
767 // owned `Map` here would silently drop every downstream write.
768 let mut v = json!({ "existing_key": "existing_value" });
769 let map = v.as_object_mut_or("resource").expect("Value::Object");
770 map.insert("new_key".to_string(), json!("new_value"));
771 assert_eq!(v["existing_key"], "existing_value");
772 assert_eq!(v["new_key"], "new_value");
773 }
774
775 #[test]
776 fn as_object_mut_or_null_arm_errors_with_pre_lift_display_bytewise() {
777 // Byte-shape parity pin: the wrap output of `as_object_mut_or
778 // ("<slot>")` on a `Value::Null` handle MUST be `Display`-
779 // identical to the pre-lift hand-authored `.as_object_mut().
780 // ok_or_else(|| anyhow!("<slot> is not an object"))?` chain.
781 // A regression that inserted a synonym (`"<slot> is not a
782 // JSON object"`), reshaped the slot position (`"not an
783 // object: <slot>"`), or dropped the leading `<slot>` slot
784 // surfaces HERE rather than as silent drift at every
785 // downstream log-output consumer.
786 let mut v = Value::Null;
787 let err = v.as_object_mut_or("resource").unwrap_err();
788 assert_eq!(format!("{err}"), "resource is not an object");
789 }
790
791 #[test]
792 fn as_object_mut_or_array_arm_errors_with_pre_lift_display_bytewise() {
793 // Sibling to the null-arm byte-shape pin — a mistyped
794 // `metadata` slot authored as a JSON array (kubectl accepts
795 // `metadata: []` in a YAML manifest with no schema, though the
796 // apiserver later rejects it) surfaces the same guard error.
797 // Pins the "non-object variants ALL error via the same wire
798 // format" invariant — a regression that special-cased the
799 // array variant (returning a fresh empty map, silently
800 // coercing) surfaces HERE.
801 let mut v = json!(["not", "an", "object"]);
802 let err = v.as_object_mut_or("metadata").unwrap_err();
803 assert_eq!(format!("{err}"), "metadata is not an object");
804 }
805
806 #[test]
807 fn as_object_mut_or_string_arm_errors_with_pre_lift_display_bytewise() {
808 // Sibling to the null / array pins — a mistyped `annotations`
809 // slot authored as a JSON string (a common apiserver-layer
810 // authoring bug in kubectl-generated manifests where a
811 // stringified JSON object leaks through) surfaces the same
812 // guard error. Pins the "every non-object variant errors via
813 // the same wire format" invariant across the full
814 // `serde_json::Value` sum.
815 let mut v = json!("stringified");
816 let err = v.as_object_mut_or("annotations").unwrap_err();
817 assert_eq!(format!("{err}"), "annotations is not an object");
818 }
819
820 #[test]
821 fn as_object_mut_or_threads_the_slot_slug_verbatim_across_all_three_pre_lift_labels() {
822 // Cross-slot coherence pin: the three pre-lift consumers in
823 // `tatara-reconciler::ssapply` stamped THREE distinct slot
824 // slugs (`"resource"` / `"metadata"` / `"annotations"`), and
825 // the wrap-shape MUST honor each one verbatim as the leading
826 // slot in the `Display` output. A regression that hard-coded
827 // one slug (say `"resource"`) across every callsite would
828 // pass the first pin above and fail HERE — the three
829 // downstream error-stream greps operators run to bisect a
830 // "which SSA-time mutation faulted" alert would ALL collapse
831 // to the same slug.
832 for slot in ["resource", "metadata", "annotations"] {
833 let mut v = Value::Null;
834 let err = v.as_object_mut_or(slot).unwrap_err();
835 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
836 }
837 }
838
839 #[test]
840 fn as_object_mut_or_object_arm_matches_inherent_as_object_mut_bytewise() {
841 // Cross-substrate coherence pin: on the Ok arm the trait
842 // method MUST return the SAME `&mut Map` the inherent
843 // `serde_json::Value::as_object_mut` would — no diverging
844 // view, no clone, no key-order reshape. A regression that
845 // introduced a normalization pass here (sorting keys,
846 // stripping a null-valued entry, coercing a nested string
847 // to a JSON scalar) would surface as silent per-consumer
848 // schema drift at the SSA-time mutation — an ownerReferences
849 // append that no longer landed in the same slot the apiserver
850 // reads, an annotations insert whose key ordering diverged
851 // from kubectl's canonical form.
852 let mut via_trait = json!({ "key": "value", "nested": { "inner": 1 } });
853 let mut via_inherent = via_trait.clone();
854 assert_eq!(
855 via_trait
856 .as_object_mut_or("resource")
857 .expect("Value::Object")
858 .clone(),
859 via_inherent.as_object_mut().expect("Value::Object").clone(),
860 );
861 }
862
863 // ─── JsonMapStrExt::insert_str substrate pins ─────────────────
864 //
865 // Fail-before-pass-after granularity: the `JsonMapStrExt::insert_str`
866 // trait method did not exist before this commit, so each test below
867 // fails to compile pre-lift. Post-lift they collectively pin the
868 // string-slot write shape at ONE substrate owner — a regression that
869 // dropped the `Value::String` wrap (silently coercing to a bare
870 // `Value::from(&str)` — byte-identical in the `Object` arm today but
871 // divergent for any future non-`&str` numeric caller who reached for
872 // `insert_str(k, n.to_string())`), swapped the key + value slot
873 // orientation, or drifted the return semantics from the inherent
874 // `Map::insert` (which returns the previous value on overwrite —
875 // load-bearing at any future caller that inspects the return) would
876 // surface HERE rather than as silent per-emit skew across the
877 // thirteen pre-lift `ssapply` + `render` + `edges` consumers.
878
879 #[test]
880 fn insert_str_new_key_returns_none_and_stamps_value_string() {
881 // New-key arm: matches inherent `Map::insert` return
882 // semantics — `None` for a fresh key — and stamps a
883 // `Value::String` (NOT `Value::from(&str)`, though they're
884 // byte-identical today) at the slot.
885 let mut m = Map::new();
886 let prev = m.insert_str("key", "value");
887 assert!(prev.is_none(), "new key returns None");
888 assert_eq!(m.get("key"), Some(&Value::String("value".to_string())));
889 assert!(matches!(m.get("key"), Some(Value::String(_))));
890 }
891
892 #[test]
893 fn insert_str_overwrite_returns_prior_value_and_stamps_new() {
894 // Overwrite arm: matches inherent `Map::insert` return
895 // semantics — `Some(prev)` on overwrite. Load-bearing for
896 // any future consumer that inspects the return to detect a
897 // slot collision (a fleet-wide sweep that flagged a
898 // duplicate SSA-time annotation stamp, for example).
899 let mut m = Map::new();
900 m.insert_str("key", "old");
901 let prev = m.insert_str("key", "new");
902 assert_eq!(prev, Some(Value::String("old".to_string())));
903 assert_eq!(m.get("key"), Some(&Value::String("new".to_string())));
904 }
905
906 #[test]
907 fn insert_str_accepts_str_and_owned_string_at_both_slots() {
908 // Composability pin: both slots MUST accept `&str` and
909 // `String` interchangeably — the pre-lift callsite inventory
910 // mixes both (SSA-time `annotations::PID` static + a
911 // `pid.to_string()` runtime String at the value slot;
912 // `spec.insert("interval".into(), Value::String("1m".into()))`
913 // with two `&str` slots). A regression that constrained
914 // either slot to one shape would break the callsite parity
915 // that motivated this substrate primitive.
916 let mut m1 = Map::new();
917 m1.insert_str("a", "b");
918 let mut m2 = Map::new();
919 m2.insert_str(String::from("a"), String::from("b"));
920 let mut m3 = Map::new();
921 m3.insert_str("a", String::from("b"));
922 let mut m4 = Map::new();
923 m4.insert_str(String::from("a"), "b");
924 assert_eq!(m1, m2);
925 assert_eq!(m2, m3);
926 assert_eq!(m3, m4);
927 }
928
929 #[test]
930 fn insert_str_matches_pre_lift_hand_authored_shape_bytewise() {
931 // Byte-shape parity pin: `insert_str(k, v)` MUST emit the
932 // SAME `Map` entry the pre-lift hand-authored `.insert(
933 // <k>.into(), Value::String(<v>.into()))` chain produced.
934 // Sweeps the four (str × String) × (str × String) key/value
935 // shape quadrants so a regression at the primitive that
936 // broke the byte identity with the pre-lift shape at ONE
937 // quadrant surfaces here rather than as a subtle per-emit
938 // divergence at that quadrant.
939 for (k_str, v_str) in [("a", "b"), ("x", ""), ("", "y"), ("", "")] {
940 // (str, str) quadrant
941 let mut via_primitive = Map::new();
942 via_primitive.insert_str(k_str, v_str);
943 let mut via_pre_lift = Map::new();
944 via_pre_lift.insert(k_str.into(), Value::String(v_str.into()));
945 assert_eq!(via_primitive, via_pre_lift);
946
947 // (String, String) quadrant
948 let mut via_primitive = Map::new();
949 via_primitive.insert_str(String::from(k_str), String::from(v_str));
950 let mut via_pre_lift = Map::new();
951 via_pre_lift.insert(String::from(k_str), Value::String(String::from(v_str)));
952 assert_eq!(via_primitive, via_pre_lift);
953 }
954 }
955
956 #[test]
957 fn insert_str_empty_value_stamps_empty_string_not_null() {
958 // Semantic pin: an empty value slot MUST stamp
959 // `Value::String("")`, NEVER `Value::Null`. Load-bearing at
960 // any callsite that stamps a placeholder empty-string
961 // annotation (say a `content_hash` slot pre-derive) where a
962 // `Null` slot would fail-loud at the K8s apiserver's
963 // annotation-value type check.
964 let mut m = Map::new();
965 m.insert_str("empty", "");
966 assert_eq!(m.get("empty"), Some(&Value::String(String::new())));
967 assert!(!matches!(m.get("empty"), Some(Value::Null)));
968 }
969
970 // ─── JsonMapObjectEntryExt::object_slot_mut_or substrate pins ─────
971 //
972 // Fail-before-pass-after granularity: the
973 // `JsonMapObjectEntryExt::object_slot_mut_or` trait method did not
974 // exist before this commit, so each test below fails to compile
975 // pre-lift. Post-lift they collectively pin the compound
976 // seed-then-guard shape at ONE substrate owner — a regression that
977 // dropped the seed step (leaving an absent slot to fall through the
978 // guard as `None → Err`), skipped the guard step (silently returning
979 // an `&mut Value` when the existing slot is a non-object variant),
980 // drifted the entry-key slot vs. the guard-error slot (a copy-paste
981 // typo that stamped `"metadata"` into the entry and `"metadatas"`
982 // into the guard error message), or drifted the empty-seed shape
983 // (a `Value::Null` fallback where `Value::Object(Map::new())` is
984 // load-bearing at the downstream `.entry(...).or_insert_with(...)`
985 // / `.insert(...)` mutation) would surface HERE rather than as
986 // silent per-emit skew across the two pre-lift `ssapply.rs`
987 // consumers.
988
989 #[test]
990 fn object_slot_mut_or_absent_slot_seeds_empty_object_and_returns_it() {
991 // Absent-slot arm: the pre-lift `.entry(<slot>).or_insert_with
992 // (|| Value::Object(Default::default()))` step MUST seed the
993 // slot with an EMPTY `Value::Object` when the slot is not
994 // present in the parent map. The returned handle is the fresh
995 // empty map, MUTABLY, so a downstream `.insert(...)` writes
996 // land in the parent map's `<slot>` object post-return.
997 let mut parent = Map::new();
998 {
999 let child = parent
1000 .object_slot_mut_or("metadata")
1001 .expect("absent slot seeds an object");
1002 assert!(child.is_empty(), "fresh-seeded slot is an empty object");
1003 child.insert("name".into(), Value::String("demo".into()));
1004 }
1005 // The write landed in the parent map's metadata slot.
1006 assert_eq!(parent["metadata"]["name"], "demo");
1007 assert!(matches!(parent.get("metadata"), Some(Value::Object(_))));
1008 }
1009
1010 #[test]
1011 fn object_slot_mut_or_present_object_slot_returns_existing_interior_mutably() {
1012 // Present-object-slot arm: when the slot is already populated
1013 // with a `Value::Object`, the primitive MUST return the
1014 // EXISTING map interior mutably — no synthesis, no reshape, no
1015 // key-order rewrite. The downstream `.insert(...)` writes MUST
1016 // merge into the pre-existing keys rather than replace them.
1017 let mut parent = Map::new();
1018 parent.insert(
1019 "metadata".into(),
1020 serde_json::json!({ "existing_key": "existing_value" }),
1021 );
1022 {
1023 let child = parent
1024 .object_slot_mut_or("metadata")
1025 .expect("present-object slot returns Ok");
1026 assert_eq!(
1027 child.get("existing_key"),
1028 Some(&Value::String("existing_value".into()))
1029 );
1030 child.insert("new_key".into(), Value::String("new_value".into()));
1031 }
1032 assert_eq!(parent["metadata"]["existing_key"], "existing_value");
1033 assert_eq!(parent["metadata"]["new_key"], "new_value");
1034 }
1035
1036 #[test]
1037 fn object_slot_mut_or_present_non_object_slot_errors_with_pre_lift_display() {
1038 // Fail-loud arm: when the slot is present but holds a non-
1039 // object variant (a `Value::String` from a hand-authored
1040 // YAML manifest where `metadata: "malformed"` slipped past
1041 // kubectl's schema check), the primitive MUST fail with a
1042 // `Display` byte-identical to the pre-lift
1043 // `.as_object_mut_or(<slot>)?` guard — the sibling
1044 // [`ValueObjectExt::as_object_mut_or`] guard's wire format.
1045 // A regression that special-cased this arm (overwriting the
1046 // slot with a fresh empty object, silently coercing) would
1047 // silently swallow the operator's authoring error at the
1048 // SSA-time re-injection step.
1049 let mut parent = Map::new();
1050 parent.insert("metadata".into(), Value::String("malformed".into()));
1051 let err = parent.object_slot_mut_or("metadata").unwrap_err();
1052 assert_eq!(format!("{err}"), "metadata is not an object");
1053 }
1054
1055 #[test]
1056 fn object_slot_mut_or_threads_the_slot_slug_verbatim_across_both_pre_lift_labels() {
1057 // Cross-slot coherence pin: the TWO pre-lift consumers in
1058 // `tatara-reconciler::ssapply` stamped TWO distinct slot slugs
1059 // (`"metadata"` at the resource root, `"annotations"` at the
1060 // metadata child), and the wrap-shape MUST honor each one
1061 // verbatim as the leading slot in the `Display` output. A
1062 // regression that hard-coded one slug across every callsite
1063 // would pass the fail-loud pin above (on the `"metadata"` slug)
1064 // and fail HERE — the two downstream error-stream greps
1065 // operators run to bisect a "which SSA-time slot mutation
1066 // faulted" alert would ALL collapse to the same slug, hiding
1067 // whether the fault was at the resource-root object walk or
1068 // the metadata-child annotations walk.
1069 for slot in ["metadata", "annotations"] {
1070 let mut parent = Map::new();
1071 parent.insert(slot.into(), Value::Null);
1072 let err = parent.object_slot_mut_or(slot).unwrap_err();
1073 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
1074 }
1075 }
1076
1077 #[test]
1078 fn object_slot_mut_or_present_empty_object_returns_existing_reference_not_synthesized() {
1079 // Precedence pin: a present slot holding an EMPTY
1080 // `Value::Object` MUST return the pre-existing empty map
1081 // interior — not a freshly-synthesized replacement. The
1082 // pre-lift `.entry(<slot>).or_insert_with(||...)` step's
1083 // short-circuit on the present-slot arm skips the closure
1084 // entirely; a regression that always evaluated the closure
1085 // (unconditionally overwriting an existing empty-object slot
1086 // with a fresh empty object) would type-check silently at
1087 // every callsite AND write byte-identical JSON at the empty-
1088 // slot corner, but it would break a hypothetical future
1089 // consumer that reached the primitive on a map whose slot
1090 // was seeded upstream with metadata (a caller intending to
1091 // preserve any keys the parent-composer already dropped in).
1092 let mut parent = Map::new();
1093 parent.insert("metadata".into(), Value::Object(Map::new()));
1094 let addr_before = parent.get("metadata").unwrap() as *const Value;
1095 {
1096 let _child = parent.object_slot_mut_or("metadata").unwrap();
1097 }
1098 let addr_after = parent.get("metadata").unwrap() as *const Value;
1099 assert_eq!(
1100 addr_before, addr_after,
1101 "present empty-object slot must return the pre-existing reference, not a fresh synthesis",
1102 );
1103 }
1104
1105 #[test]
1106 fn object_slot_mut_or_matches_pre_lift_hand_authored_compound_shape_bytewise() {
1107 // Byte-shape parity pin: `object_slot_mut_or(<slot>)?` MUST
1108 // produce the SAME `&mut Map` (and, on the non-object arm, the
1109 // SAME `Display`-shaped error) the pre-lift 3-line `.entry
1110 // (<slot>).or_insert_with(|| Value::Object(Default::default()))
1111 // .as_object_mut_or(<slot>)?` chain produced. Sweeps the three
1112 // pre-lift-reachable input corners (absent slot / present
1113 // object / present non-object) so a regression at the primitive
1114 // that broke byte identity with the pre-lift chain at ONE
1115 // corner surfaces here rather than as a subtle per-emit
1116 // divergence.
1117 for slot in ["metadata", "annotations"] {
1118 // (1) Absent-slot corner: both routes seed empty-object at
1119 // the slot AND return the same empty map interior.
1120 let mut via_primitive = Map::new();
1121 let mut via_pre_lift = Map::new();
1122 {
1123 let _ = via_primitive.object_slot_mut_or(slot).unwrap();
1124 let _ = via_pre_lift
1125 .entry(slot.to_string())
1126 .or_insert_with(|| Value::Object(Map::new()))
1127 .as_object_mut_or(slot)
1128 .unwrap();
1129 }
1130 assert_eq!(via_primitive, via_pre_lift);
1131
1132 // (2) Present-object corner: both routes read back the
1133 // same pre-populated interior mutably.
1134 let mut via_primitive = Map::new();
1135 via_primitive.insert(slot.into(), serde_json::json!({ "k": "v" }));
1136 let mut via_pre_lift = via_primitive.clone();
1137 {
1138 let a = via_primitive.object_slot_mut_or(slot).unwrap();
1139 let b = via_pre_lift
1140 .entry(slot.to_string())
1141 .or_insert_with(|| Value::Object(Map::new()))
1142 .as_object_mut_or(slot)
1143 .unwrap();
1144 assert_eq!(a, b);
1145 }
1146
1147 // (3) Present-non-object corner: both routes fail loud
1148 // with the same wire-format Display shape.
1149 let mut via_primitive = Map::new();
1150 via_primitive.insert(slot.into(), Value::Bool(true));
1151 let mut via_pre_lift = via_primitive.clone();
1152 let err_primitive = via_primitive.object_slot_mut_or(slot).unwrap_err();
1153 let err_pre_lift = via_pre_lift
1154 .entry(slot.to_string())
1155 .or_insert_with(|| Value::Object(Map::new()))
1156 .as_object_mut_or(slot)
1157 .unwrap_err();
1158 assert_eq!(format!("{err_primitive}"), format!("{err_pre_lift}"));
1159 }
1160 }
1161
1162 // ─── ValueGetExt::get_i64 substrate pins ─────────────────────────
1163 //
1164 // Fail-before-pass-after granularity: the `ValueGetExt::get_i64`
1165 // trait method did not exist before this commit, so each test below
1166 // fails to compile pre-lift. Post-lift they collectively pin the
1167 // paired READ-shape at ONE substrate owner — a regression that
1168 // narrowed the projection to `as_u64` (silently losing every
1169 // negative counter K8s fixtures can carry for a JSON authoring
1170 // bug), swapped the slot lookup to `.pointer(<key>)` (losing the
1171 // direct-child semantics), promoted a present-but-non-integer
1172 // corner to `Some(0)` (silently paving over a malformed status
1173 // blob), or drifted the receiver-non-object arm from `None → Some(default)`
1174 // (silently synthesising a zero counter on a null status blob)
1175 // surfaces HERE rather than as silent operator-facing skew across
1176 // the three `boundary.rs::fetch_job_status` pre-lift consumers
1177 // whose JobStatusView row initialised at `Default::default()` and
1178 // conditionally overwrote each field on `Some(i64)`.
1179
1180 #[test]
1181 fn get_i64_present_integer_slot_returns_the_value() {
1182 // Primary Ok-arm invariant: a `Value::Number(i)` present at the
1183 // slot projects to `Some(i)`. Sweeps the three representative
1184 // counters every pre-lift `JobStatusView` field carried (a
1185 // completed Job's `succeeded=1`, a failed Job's `failed=3`, a
1186 // freshly-scheduled Job's `active=5`) so a regression at ONE
1187 // counter axis surfaces here rather than at the downstream
1188 // diagnostic.
1189 let status = json!({ "succeeded": 1, "failed": 3, "active": 5 });
1190 assert_eq!(status.get_i64("succeeded"), Some(1));
1191 assert_eq!(status.get_i64("failed"), Some(3));
1192 assert_eq!(status.get_i64("active"), Some(5));
1193 }
1194
1195 #[test]
1196 fn get_i64_absent_slot_returns_none() {
1197 // Absent-slot corner: a fresh `batch/v1::Job` before its
1198 // controller has stamped any counter into `status` (the JSON
1199 // is `{}` or missing the counter key). Every pre-lift consumer
1200 // routed this corner through the `if let Some(...)` guard so
1201 // the `JobStatusView` field kept its `Default::default()` `0`
1202 // seed. A regression that returned `Some(0)` on the absent
1203 // corner would collapse the "not yet reported" ↔ "reported
1204 // zero" distinction the K8s status protocol keeps.
1205 let status = json!({});
1206 assert_eq!(status.get_i64("succeeded"), None);
1207 assert_eq!(status.get_i64("any_missing_key"), None);
1208 }
1209
1210 #[test]
1211 fn get_i64_present_but_non_integer_slot_returns_none() {
1212 // Present-but-non-integer corner: a `Value::String`, a
1213 // `Value::Bool`, a `Value::Object`, or a `Value::Array` at the
1214 // slot ALL fall through to `None` — matches the pre-lift
1215 // `.and_then(|v| v.as_i64())` chain exactly. A regression that
1216 // promoted a `Value::String("1")` to `Some(1)` (adding a
1217 // parse-string fallback) would silently accept a malformed
1218 // status blob whose author stringified a counter.
1219 let status = json!({
1220 "stringy": "1",
1221 "boolean": true,
1222 "object": {},
1223 "array": [],
1224 "null_valued": null,
1225 });
1226 assert_eq!(status.get_i64("stringy"), None);
1227 assert_eq!(status.get_i64("boolean"), None);
1228 assert_eq!(status.get_i64("object"), None);
1229 assert_eq!(status.get_i64("array"), None);
1230 assert_eq!(status.get_i64("null_valued"), None);
1231 }
1232
1233 #[test]
1234 fn get_i64_negative_counter_survives_the_projection() {
1235 // Negative-integer corner: `as_i64` accepts negatives; `as_u64`
1236 // does not. A regression that narrowed the projection to
1237 // `as_u64` under a mistaken "K8s counters are always non-
1238 // negative" refactor would silently drop every negative
1239 // counter a JSON authoring bug could stamp — hiding the bug
1240 // rather than surfacing it as a counter the diagnostic reports
1241 // verbatim.
1242 let status = json!({ "n": -1 });
1243 assert_eq!(status.get_i64("n"), Some(-1));
1244 }
1245
1246 #[test]
1247 fn get_i64_non_object_receiver_returns_none_verbatim() {
1248 // Non-object receiver corner: a caller who reached this
1249 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1250 // handle (a malformed fetch response, an upstream default-value
1251 // fallback) MUST get `None` back rather than a panic or a
1252 // synthesized `Some(default)`. Matches the pre-lift chain's
1253 // behaviour: `Value::get` on a non-object receiver returns
1254 // `None`, `and_then` short-circuits.
1255 assert_eq!(Value::Null.get_i64("any"), None);
1256 assert_eq!(Value::Bool(true).get_i64("any"), None);
1257 assert_eq!(json!([1, 2, 3]).get_i64("any"), None);
1258 assert_eq!(json!("scalar").get_i64("any"), None);
1259 }
1260
1261 #[test]
1262 fn get_i64_matches_pre_lift_hand_authored_chain_shape() {
1263 // Byte-shape parity pin: `<value>.get_i64(<key>)` MUST return
1264 // the SAME `Option<i64>` the pre-lift hand-authored
1265 // `.get(<key>).and_then(|v| v.as_i64())` chain produced.
1266 // Sweeps the six pre-lift-reachable input corners (the three
1267 // "value present" + three "value absent/malformed" arms every
1268 // fetch_job_status callsite reached) so a regression at the
1269 // primitive that broke byte identity with the pre-lift chain at
1270 // ONE corner surfaces here rather than as a per-counter
1271 // divergence at the fetched-Job projection.
1272 let status = json!({
1273 "succeeded": 2,
1274 "failed": 0,
1275 "active": 7,
1276 "stringy": "1",
1277 "null_valued": null,
1278 });
1279 for key in [
1280 "succeeded",
1281 "failed",
1282 "active",
1283 "stringy",
1284 "null_valued",
1285 "missing",
1286 ] {
1287 let via_primitive = status.get_i64(key);
1288 let via_pre_lift = status.get(key).and_then(|v| v.as_i64());
1289 assert_eq!(
1290 via_primitive, via_pre_lift,
1291 "corner `{key}` must round-trip through both shapes",
1292 );
1293 }
1294 }
1295
1296 #[test]
1297 fn get_i64_composes_with_unwrap_or_default_at_default_seed_shape() {
1298 // Downstream composition pin: the canonical caller shape
1299 // post-lift is `<status>.get_i64(<key>).unwrap_or_default()` —
1300 // matches the pre-lift `JobStatusView::default()` seed +
1301 // conditional `if let Some(n)` write pattern. A regression that
1302 // reshaped the return form (an `i64` bare default, a
1303 // `Result<i64, _>` fallible arm) would break this composition.
1304 let status = json!({ "succeeded": 4 });
1305 // Absent slot composes to the type default (0 for i64).
1306 assert_eq!(status.get_i64("missing").unwrap_or_default(), 0_i64);
1307 // Present slot composes to the projected counter.
1308 assert_eq!(status.get_i64("succeeded").unwrap_or_default(), 4_i64);
1309 }
1310
1311 // ─── ValueGetExt::get_str substrate pins ─────────────────────────
1312 //
1313 // Fail-before-pass-after granularity: the `ValueGetExt::get_str`
1314 // trait method did not exist before this commit, so each test below
1315 // fails to compile pre-lift. Post-lift they collectively pin the
1316 // paired READ-shape at ONE substrate owner — a regression that
1317 // narrowed the projection to the wrong variant (accepting
1318 // `Value::Number`-stringified slots via a fallback, or accepting
1319 // `Value::Null` as `Some("")`), swapped the slot lookup to
1320 // `.pointer(<key>)` (losing the direct-child semantics), promoted
1321 // an absent slot to `Some("")` (silently paving over a missing
1322 // required slot), or drifted the receiver-non-object arm from
1323 // `None` (silently synthesising an empty string on a null status
1324 // blob) surfaces HERE rather than as silent operator-facing skew
1325 // across the SEVEN pre-lift consumers (`status::from_json`'s four
1326 // rendered-resource coordinate reads + `ssapply::ready_condition_value`'s
1327 // three K8s Condition slot reads).
1328
1329 #[test]
1330 fn get_str_present_string_slot_returns_the_slice() {
1331 // Primary Ok-arm invariant: a `Value::String(s)` present at the
1332 // slot projects to `Some(s.as_str())`. Sweeps the four
1333 // representative slots the pre-lift `RenderedResourceCoords::
1334 // from_json` consumer walked (`apiVersion`, `kind`,
1335 // `metadata.name`, `metadata.namespace`) so a regression at
1336 // ONE axis surfaces here rather than at the downstream
1337 // typed row's coordinate.
1338 let manifest = json!({
1339 "apiVersion": "helm.toolkit.fluxcd.io/v2",
1340 "kind": "HelmRelease",
1341 "name": "demo-app",
1342 "namespace": "demo",
1343 });
1344 assert_eq!(
1345 manifest.get_str("apiVersion"),
1346 Some("helm.toolkit.fluxcd.io/v2"),
1347 );
1348 assert_eq!(manifest.get_str("kind"), Some("HelmRelease"));
1349 assert_eq!(manifest.get_str("name"), Some("demo-app"));
1350 assert_eq!(manifest.get_str("namespace"), Some("demo"));
1351 }
1352
1353 #[test]
1354 fn get_str_absent_slot_returns_none() {
1355 // Absent-slot corner: a rendered manifest whose author forgot
1356 // the `apiVersion` slot (a common authoring bug) MUST return
1357 // `None` so `RenderedResourceCoords::from_json` fails loud
1358 // rather than silently synthesising an empty apiVersion. A
1359 // regression that returned `Some("")` on the absent corner
1360 // would collapse the "not authored" ↔ "authored empty"
1361 // distinction the fail-loud gate depends on.
1362 let manifest = json!({ "kind": "HelmRelease" });
1363 assert_eq!(manifest.get_str("apiVersion"), None);
1364 assert_eq!(manifest.get_str("any_missing_key"), None);
1365 }
1366
1367 #[test]
1368 fn get_str_present_but_non_string_slot_returns_none() {
1369 // Present-but-non-string corner: a `Value::Number`,
1370 // `Value::Bool`, `Value::Object`, `Value::Array`, or
1371 // `Value::Null` at the slot ALL fall through to `None` —
1372 // matches the pre-lift `.and_then(|v| v.as_str())` chain
1373 // exactly. A regression that stringified a `Value::Number`
1374 // (adding a `to_string()` fallback) would silently accept a
1375 // malformed manifest whose author numeric-typed a
1376 // conventionally-string slot.
1377 let manifest = json!({
1378 "numeric": 1,
1379 "boolean": true,
1380 "object": {},
1381 "array": [],
1382 "null_valued": null,
1383 });
1384 assert_eq!(manifest.get_str("numeric"), None);
1385 assert_eq!(manifest.get_str("boolean"), None);
1386 assert_eq!(manifest.get_str("object"), None);
1387 assert_eq!(manifest.get_str("array"), None);
1388 assert_eq!(manifest.get_str("null_valued"), None);
1389 }
1390
1391 #[test]
1392 fn get_str_empty_string_slot_survives_the_projection() {
1393 // Empty-string corner: a `Value::String("")` present at the
1394 // slot MUST project to `Some("")` — matches the pre-lift
1395 // `.and_then(|v| v.as_str())` chain exactly, keeping the
1396 // "authored empty" arm distinct from the "not authored" arm
1397 // upstream. A regression that promoted `Some("")` to `None`
1398 // under a "reject empty strings" refactor would silently
1399 // collapse the two arms and turn a valid empty `metadata.
1400 // namespace` (a cluster-scoped resource) into a fail-loud
1401 // error at the required-slot gates.
1402 let manifest = json!({ "namespace": "" });
1403 assert_eq!(manifest.get_str("namespace"), Some(""));
1404 }
1405
1406 #[test]
1407 fn get_str_non_object_receiver_returns_none_verbatim() {
1408 // Non-object receiver corner: a caller who reached this
1409 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1410 // handle (a malformed fetch response, an upstream default-value
1411 // fallback, a `serde_json::Value::Null` metadata slot chained
1412 // through `.and_then`) MUST get `None` back rather than a
1413 // panic or a synthesized `Some("")`. Matches the pre-lift
1414 // chain's behaviour: `Value::get` on a non-object receiver
1415 // returns `None`, `and_then` short-circuits.
1416 assert_eq!(Value::Null.get_str("any"), None);
1417 assert_eq!(Value::Bool(true).get_str("any"), None);
1418 assert_eq!(json!([1, 2, 3]).get_str("any"), None);
1419 assert_eq!(json!("scalar").get_str("any"), None);
1420 }
1421
1422 #[test]
1423 fn get_str_matches_pre_lift_hand_authored_chain_shape() {
1424 // Byte-shape parity pin: `<value>.get_str(<key>)` MUST return
1425 // the SAME `Option<&str>` the pre-lift hand-authored
1426 // `.get(<key>).and_then(|v| v.as_str())` chain produced.
1427 // Sweeps every pre-lift-reachable input corner (three
1428 // "value present" + three "value absent/malformed" arms every
1429 // status.rs / ssapply.rs callsite reached) so a regression at
1430 // the primitive that broke byte identity with the pre-lift
1431 // chain at ONE corner surfaces here rather than as a
1432 // per-slot divergence downstream.
1433 let manifest = json!({
1434 "apiVersion": "v1",
1435 "kind": "ConfigMap",
1436 "type": "Ready",
1437 "numeric": 1,
1438 "null_valued": null,
1439 });
1440 for key in [
1441 "apiVersion",
1442 "kind",
1443 "type",
1444 "numeric",
1445 "null_valued",
1446 "missing",
1447 ] {
1448 let via_primitive = manifest.get_str(key);
1449 let via_pre_lift = manifest.get(key).and_then(|v| v.as_str());
1450 assert_eq!(
1451 via_primitive, via_pre_lift,
1452 "corner `{key}` must round-trip through both shapes",
1453 );
1454 }
1455 }
1456
1457 #[test]
1458 fn get_str_composes_with_ok_or_else_at_from_json_shape() {
1459 // Downstream composition pin: the canonical caller shape at
1460 // `RenderedResourceCoords::from_json` is
1461 // `<manifest>.get_str(<key>).ok_or_else(|| anyhow!("rendered
1462 // resource missing X"))?.to_string()`. A regression that
1463 // reshaped the return form (an `&str` bare default, a
1464 // `Result<&str, _>` fallible arm) would break this
1465 // composition. Additionally sweeps the peer
1466 // `.map(String::from)` / `.map(str::to_string)` optional-slot
1467 // arm the `namespace` slot uses.
1468 let manifest = json!({ "apiVersion": "v1" });
1469 let ok_arm: String = manifest
1470 .get_str("apiVersion")
1471 .ok_or_else(|| anyhow::anyhow!("missing"))
1472 .unwrap()
1473 .to_string();
1474 assert_eq!(ok_arm, "v1");
1475 let err_arm = manifest
1476 .get_str("kind")
1477 .ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))
1478 .unwrap_err();
1479 assert_eq!(format!("{err_arm}"), "rendered resource missing kind");
1480 let opt_present: Option<String> = manifest.get_str("apiVersion").map(str::to_string);
1481 assert_eq!(opt_present.as_deref(), Some("v1"));
1482 let opt_absent: Option<String> = manifest.get_str("kind").map(String::from);
1483 assert!(opt_absent.is_none());
1484 }
1485
1486 #[test]
1487 fn get_str_return_lifetime_borrows_receiver_not_owned() {
1488 // Return-lifetime pin: the `&str` MUST borrow the receiver's
1489 // buffer rather than a fresh owned `String`. A regression that
1490 // reshaped the return to `Option<String>` (adding a
1491 // `to_string()` inside the primitive) would inflate every
1492 // callsite's allocation count and break `metadata.and_then(|m|
1493 // m.get_str("name"))`'s per-lookup zero-alloc guarantee. Bind
1494 // the invariant structurally: the borrow reaches back through
1495 // the receiver.
1496 let manifest = json!({ "apiVersion": "helm.toolkit.fluxcd.io/v2" });
1497 let s: &str = manifest.get_str("apiVersion").unwrap();
1498 let raw: &str = manifest.get("apiVersion").and_then(|v| v.as_str()).unwrap();
1499 assert!(std::ptr::eq(s.as_ptr(), raw.as_ptr()));
1500 }
1501
1502 #[test]
1503 fn get_str_axis_family_reaches_i64_and_str_through_one_trait_import() {
1504 // Axis-family pin: a caller who imports `ValueGetExt` reaches
1505 // BOTH the string axis (`get_str`) and the integer axis
1506 // (`get_i64`) through the SAME trait handle. A regression that
1507 // opened a peer `ValueGetStrExt` (or a peer trait per axis)
1508 // would break this — the caller would have to import each
1509 // trait separately and a partial import would silently miss
1510 // one axis at method-resolution time.
1511 //
1512 // Structurally: a bound `T: ValueGetExt` reaches both methods.
1513 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>) {
1514 (t.get_i64("n"), t.get_str("s"))
1515 }
1516 let mixed = json!({ "n": 7, "s": "hello" });
1517 let (n, s) = probe(&mixed);
1518 assert_eq!(n, Some(7));
1519 assert_eq!(s, Some("hello"));
1520 }
1521
1522 // ─── ValueGetExt::get_array substrate pins ───────────────────────
1523 //
1524 // Fail-before-pass-after granularity: the `ValueGetExt::get_array`
1525 // trait method did not exist before this commit, so each test below
1526 // fails to compile pre-lift. Post-lift they collectively pin the
1527 // paired READ-shape at ONE substrate owner — a regression that
1528 // narrowed the projection to the wrong variant (accepting an
1529 // object slot via a `.values().collect()` synthesis, promoting an
1530 // absent slot to `Some(&Vec::new())`), swapped the slot lookup to
1531 // `.pointer(<key>)` (losing the direct-child semantics), or
1532 // drifted the receiver-non-object arm from `None` (silently
1533 // synthesising an empty array on a null status blob) surfaces
1534 // HERE rather than as silent operator-facing skew across the two
1535 // pre-lift consumers (`ssapply::ready_condition_value`'s
1536 // `status.conditions` walker + `probe::count_jwks_keys`'s `keys`
1537 // counter).
1538
1539 #[test]
1540 fn get_array_present_array_slot_returns_the_slice() {
1541 // Primary Ok-arm invariant: a `Value::Array` present at the
1542 // slot projects to `Some(&Vec::new())`-shaped borrow. Sweeps
1543 // the two representative shapes the pre-lift consumers walked
1544 // (a K8s `status.conditions` array of Condition objects on the
1545 // reconciler side; a JWKS `keys` array of key objects on the
1546 // probe side).
1547 let status = json!({
1548 "conditions": [
1549 { "type": "Ready", "status": "True" },
1550 { "type": "Progressing", "status": "False" },
1551 ],
1552 });
1553 let via = status.get_array("conditions").expect("Value::Array");
1554 assert_eq!(via.len(), 2);
1555 assert_eq!(via[0]["type"], "Ready");
1556
1557 let jwks = json!({
1558 "keys": [
1559 { "kty": "RSA", "kid": "1" },
1560 { "kty": "RSA", "kid": "2" },
1561 { "kty": "EC", "kid": "3" },
1562 ],
1563 });
1564 assert_eq!(
1565 jwks.get_array("keys").map(Vec::len),
1566 Some(3),
1567 "probe count_jwks_keys composition must reach the same tail as pre-lift",
1568 );
1569 }
1570
1571 #[test]
1572 fn get_array_absent_slot_returns_none() {
1573 // Absent-slot corner: a fresh K8s status blob whose controller
1574 // has not stamped `conditions` yet (the `data.get("status")`
1575 // walker yields an object without the slot) MUST return
1576 // `None` so the caller's `let Some(...) = ... else { return
1577 // ReadyState::Unknown }` short-circuit fires. A regression that
1578 // returned `Some(&Vec::new())` on the absent corner would
1579 // silently drive the caller into an empty for-loop and skip
1580 // the fail-safe.
1581 let status = json!({});
1582 assert_eq!(status.get_array("conditions"), None);
1583 assert_eq!(status.get_array("any_missing_key"), None);
1584 }
1585
1586 #[test]
1587 fn get_array_present_but_non_array_slot_returns_none() {
1588 // Present-but-non-array corner: a `Value::String`,
1589 // `Value::Number`, `Value::Bool`, `Value::Object`, or
1590 // `Value::Null` at the slot ALL fall through to `None` —
1591 // matches the pre-lift `.and_then(|v| v.as_array())` chain
1592 // exactly. A regression that wrapped a scalar in a single-
1593 // element array under a "tolerant" refactor would silently
1594 // accept a malformed status blob whose author collapsed the
1595 // conditions array to a single scalar.
1596 let status = json!({
1597 "stringy": "ready",
1598 "numeric": 1,
1599 "boolean": true,
1600 "object": { "nested": true },
1601 "null_valued": null,
1602 });
1603 assert_eq!(status.get_array("stringy"), None);
1604 assert_eq!(status.get_array("numeric"), None);
1605 assert_eq!(status.get_array("boolean"), None);
1606 assert_eq!(status.get_array("object"), None);
1607 assert_eq!(status.get_array("null_valued"), None);
1608 }
1609
1610 #[test]
1611 fn get_array_empty_array_slot_survives_the_projection() {
1612 // Empty-array corner: a `Value::Array` with zero elements at
1613 // the slot MUST project to `Some(&Vec::new())` — matches the
1614 // pre-lift chain exactly, keeping the "authored empty" arm
1615 // distinct from the "not authored" arm upstream. The probe
1616 // consumer's `.map(|xs| xs.len() as u64).unwrap_or(0)` tail
1617 // depends on this: an authored-empty JWKS array reports 0
1618 // keys, distinct from a JWKS response missing the `keys` slot
1619 // altogether (which the caller could later choose to log
1620 // differently).
1621 let jwks = json!({ "keys": [] });
1622 let arr = jwks.get_array("keys").expect("Value::Array");
1623 assert!(arr.is_empty());
1624 assert_eq!(jwks.get_array("keys").map(Vec::len), Some(0));
1625 }
1626
1627 #[test]
1628 fn get_array_non_object_receiver_returns_none_verbatim() {
1629 // Non-object receiver corner: a caller who reached this
1630 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1631 // handle (a malformed fetch response, an upstream default-value
1632 // fallback, a `serde_json::Value::Null` intermediate chained
1633 // through `.and_then`) MUST get `None` back rather than a
1634 // panic or a synthesized `Some(&Vec::new())`. Matches the
1635 // pre-lift chain's behaviour: `Value::get` on a non-object
1636 // receiver returns `None`, `and_then` short-circuits.
1637 assert_eq!(Value::Null.get_array("any"), None);
1638 assert_eq!(Value::Bool(true).get_array("any"), None);
1639 assert_eq!(json!([1, 2, 3]).get_array("any"), None);
1640 assert_eq!(json!("scalar").get_array("any"), None);
1641 }
1642
1643 #[test]
1644 fn get_array_matches_pre_lift_hand_authored_chain_shape() {
1645 // Byte-shape parity pin: `<value>.get_array(<key>)` MUST return
1646 // the SAME `Option<&Vec<Value>>` the pre-lift hand-authored
1647 // `.get(<key>).and_then(|v| v.as_array())` chain produced.
1648 // Sweeps every pre-lift-reachable input corner (three
1649 // "value present" + three "value absent/malformed" arms
1650 // covering the two pre-lift consumers) so a regression at the
1651 // primitive that broke byte identity with the pre-lift chain
1652 // at ONE corner surfaces here rather than as a per-slot
1653 // divergence downstream.
1654 let manifest = json!({
1655 "conditions": [{ "type": "Ready" }],
1656 "keys": [{ "kid": "1" }, { "kid": "2" }],
1657 "empty": [],
1658 "stringy": "not-an-array",
1659 "null_valued": null,
1660 });
1661 for key in [
1662 "conditions",
1663 "keys",
1664 "empty",
1665 "stringy",
1666 "null_valued",
1667 "missing",
1668 ] {
1669 let via_primitive = manifest.get_array(key);
1670 let via_pre_lift = manifest.get(key).and_then(|v| v.as_array());
1671 assert_eq!(
1672 via_primitive, via_pre_lift,
1673 "corner `{key}` must round-trip through both shapes",
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn get_array_composes_with_len_map_at_probe_count_jwks_keys_shape() {
1680 // Downstream composition pin: the canonical caller shape at
1681 // `probe::count_jwks_keys` is `<body_val>.get_array(<key>).
1682 // map(|xs| xs.len() as u64).unwrap_or(0)` — matches the
1683 // pre-lift `.get(<key>).cloned().and_then(|k| k.as_array().
1684 // map(|xs| xs.len() as u64)).unwrap_or(0)` chain shed of its
1685 // pre-lift `.cloned()` allocation. A regression that reshaped
1686 // the return form (an `Option<Vec<Value>>` owned, a
1687 // `Result<...>` fallible arm) would break this composition
1688 // AND reintroduce the eliminated allocation.
1689 let jwks = json!({ "keys": [{ "kid": "1" }, { "kid": "2" }, { "kid": "3" }] });
1690 let n: u64 = jwks
1691 .get_array("keys")
1692 .map(|xs| xs.len() as u64)
1693 .unwrap_or(0);
1694 assert_eq!(n, 3);
1695 // Missing slot composes to 0 through the same unwrap_or arm.
1696 let empty = json!({});
1697 let z: u64 = empty
1698 .get_array("keys")
1699 .map(|xs| xs.len() as u64)
1700 .unwrap_or(0);
1701 assert_eq!(z, 0);
1702 }
1703
1704 #[test]
1705 fn get_array_composes_with_let_else_short_circuit_at_ready_condition_shape() {
1706 // Downstream composition pin: the canonical caller shape at
1707 // `ssapply::ready_condition_value` is `let Some(conditions) =
1708 // <data>.get("status").and_then(|s| s.get_array("conditions"))
1709 // else { return ReadyState::Unknown; }` — the walker rides
1710 // the `get_array` primitive on the tail of a nested walk. A
1711 // regression that changed the return to `Option<Vec<Value>>`
1712 // owned would break the `for c in conditions` borrow-iterate
1713 // pattern downstream (each `c` borrows through the receiver).
1714 let data = json!({
1715 "status": {
1716 "conditions": [
1717 { "type": "Ready", "status": "True" },
1718 { "type": "Progressing", "status": "False" },
1719 ],
1720 },
1721 });
1722 let conditions = data
1723 .get("status")
1724 .and_then(|s| s.get_array("conditions"))
1725 .expect("nested walk resolves");
1726 assert_eq!(conditions.len(), 2);
1727 // Verifies borrow-through-receiver: iterate without cloning.
1728 let types: Vec<&str> = conditions
1729 .iter()
1730 .filter_map(|c| c.get_str("type"))
1731 .collect();
1732 assert_eq!(types, vec!["Ready", "Progressing"]);
1733 }
1734
1735 #[test]
1736 fn get_array_return_lifetime_borrows_receiver_not_owned() {
1737 // Return-lifetime pin: the `&Vec<Value>` MUST borrow the
1738 // receiver's buffer rather than a fresh owned `Vec`. A
1739 // regression that reshaped the return to `Option<Vec<Value>>`
1740 // (adding a `.clone()` inside the primitive) would inflate
1741 // every callsite's allocation count and — for the
1742 // ssapply.rs caller — reintroduce a per-reconcile clone of
1743 // every K8s Condition on every DynamicObject readiness probe.
1744 // Bind the invariant structurally: the borrow reaches back
1745 // through the receiver.
1746 let manifest = json!({ "keys": [{ "kid": "1" }, { "kid": "2" }] });
1747 let via_primitive: &Vec<Value> = manifest.get_array("keys").unwrap();
1748 let via_raw: &Vec<Value> = manifest.get("keys").and_then(|v| v.as_array()).unwrap();
1749 assert!(std::ptr::eq(via_primitive.as_ptr(), via_raw.as_ptr()));
1750 }
1751
1752 #[test]
1753 fn get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import() {
1754 // Axis-family pin: a caller who imports `ValueGetExt` reaches
1755 // the integer axis (`get_i64`), the string axis (`get_str`),
1756 // AND the array axis (`get_array`) through the SAME trait
1757 // handle. A regression that opened a peer `ValueGetArrayExt`
1758 // (or a peer trait per axis) would break this — the caller
1759 // would have to import each trait separately and a partial
1760 // import would silently miss one axis at method-resolution
1761 // time.
1762 //
1763 // Structurally: a bound `T: ValueGetExt` reaches all three
1764 // methods. This test extends the pre-existing
1765 // `get_str_axis_family_reaches_i64_and_str_through_one_trait_import`
1766 // sibling to cover the new axis; either drops means the
1767 // axis-family invariant no longer holds.
1768 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>) {
1769 (t.get_i64("n"), t.get_str("s"), t.get_array("a"))
1770 }
1771 let mixed = json!({ "n": 7, "s": "hello", "a": [1, 2, 3] });
1772 let (n, s, a) = probe(&mixed);
1773 assert_eq!(n, Some(7));
1774 assert_eq!(s, Some("hello"));
1775 assert_eq!(a.map(Vec::len), Some(3));
1776 }
1777
1778 // ─── ValueGetExt receiver-shape widening — Map impl pins ─────────
1779 //
1780 // Fail-before-pass-after granularity: `impl ValueGetExt for
1781 // Map<String, Value>` did not exist before this commit, so each
1782 // test below fails to compile pre-lift (a bare `Map<String, Value>`
1783 // receiver has no `.get_str(<key>)` inherent method — only the
1784 // upstream `.get(<key>).and_then(Value::as_str)` chain — so the
1785 // callsite fails method resolution). Post-lift they collectively
1786 // pin the widening at ONE substrate owner — a regression that
1787 // dropped the `Map` impl and re-forced every `&Map` receiver into
1788 // a `Value::Object(m.clone())` rewrap detour would surface HERE
1789 // rather than as silent per-emit skew across the 30
1790 // `Map`-receiver pre-lift consumers in `tatara-reconciler::
1791 // {patch,ssapply}` tests.
1792
1793 #[test]
1794 fn map_receiver_reaches_str_i64_and_array_axes_through_the_same_trait() {
1795 // Receiver-parity pin: an `&Map<String, Value>` handle reaches
1796 // the SAME three axes (`get_str`, `get_i64`, `get_array`) the
1797 // `&Value` receiver already exposes. A regression that
1798 // implemented only one axis on the Map arm (a copy-paste
1799 // omission at the impl block) would surface here as one of the
1800 // three assertions failing to compile / returning `None`.
1801 let obj: Map<String, Value> = json!({
1802 "s": "hello",
1803 "n": 42,
1804 "a": [1, 2, 3],
1805 })
1806 .as_object()
1807 .unwrap()
1808 .clone();
1809 assert_eq!(obj.get_str("s"), Some("hello"));
1810 assert_eq!(obj.get_i64("n"), Some(42));
1811 assert_eq!(obj.get_array("a").map(Vec::len), Some(3));
1812 }
1813
1814 #[test]
1815 fn map_receiver_get_str_matches_pre_lift_hand_authored_chain_bytewise() {
1816 // Byte-shape parity pin: `<map>.get_str(<key>)` on a
1817 // `&Map<String, Value>` MUST return the SAME `Option<&str>` the
1818 // pre-lift `.get(<key>).and_then(Value::as_str)` chain
1819 // produced. Sweeps every pre-lift-reachable corner (present
1820 // string, present non-string, absent) so a regression at the
1821 // Map impl that broke byte identity with the pre-lift chain at
1822 // ONE corner surfaces here rather than as a per-slot divergence
1823 // at every `patch::phase_status_*` / `ssapply::ownership_*` pin.
1824 let obj: Map<String, Value> = json!({
1825 "phase": "Running",
1826 "phaseSince": "2026-01-01T00:00:00Z",
1827 "message": "",
1828 "numeric": 7,
1829 "null_valued": null,
1830 })
1831 .as_object()
1832 .unwrap()
1833 .clone();
1834 for key in [
1835 "phase",
1836 "phaseSince",
1837 "message",
1838 "numeric",
1839 "null_valued",
1840 "missing",
1841 ] {
1842 let via_primitive = obj.get_str(key);
1843 let via_pre_lift = obj.get(key).and_then(Value::as_str);
1844 assert_eq!(
1845 via_primitive, via_pre_lift,
1846 "corner `{key}` on Map receiver must round-trip through both shapes",
1847 );
1848 }
1849 }
1850
1851 #[test]
1852 fn map_receiver_get_array_matches_pre_lift_hand_authored_chain_bytewise() {
1853 // Sibling to the `get_str` byte-parity pin on the array axis
1854 // — sweeps present-array / present-non-array / absent so a
1855 // regression at the Map impl's `get_array` arm surfaces here
1856 // rather than as silent drift at
1857 // `patch::finalizers_metadata_patch_wraps_list_in_two_slot_metadata_body`
1858 // and its peers whose `metadata.get("finalizers").and_then(
1859 // Value::as_array)` chain lifts through this substrate.
1860 let obj: Map<String, Value> = json!({
1861 "finalizers": ["tatara.pleme.io/process-finalizer", "other.io/finalizer"],
1862 "fluxResources": [],
1863 "stringy": "not-array",
1864 })
1865 .as_object()
1866 .unwrap()
1867 .clone();
1868 for key in ["finalizers", "fluxResources", "stringy", "missing"] {
1869 let via_primitive = obj.get_array(key);
1870 let via_pre_lift = obj.get(key).and_then(Value::as_array);
1871 assert_eq!(
1872 via_primitive, via_pre_lift,
1873 "corner `{key}` on Map receiver's array axis must round-trip through both shapes",
1874 );
1875 }
1876 }
1877
1878 #[test]
1879 fn map_receiver_get_i64_matches_pre_lift_hand_authored_chain_bytewise() {
1880 // Sibling to the `get_str` / `get_array` byte-parity pins on
1881 // the integer axis — closes the third axis of the family and
1882 // pins that a Map-receiver caller reaching this arm gets the
1883 // SAME `Option<i64>` the pre-lift chain produced.
1884 let obj: Map<String, Value> = json!({
1885 "succeeded": 2,
1886 "failed": 0,
1887 "active": 5,
1888 "stringy": "1",
1889 "null_valued": null,
1890 })
1891 .as_object()
1892 .unwrap()
1893 .clone();
1894 for key in [
1895 "succeeded",
1896 "failed",
1897 "active",
1898 "stringy",
1899 "null_valued",
1900 "missing",
1901 ] {
1902 let via_primitive = obj.get_i64(key);
1903 let via_pre_lift = obj.get(key).and_then(Value::as_i64);
1904 assert_eq!(
1905 via_primitive, via_pre_lift,
1906 "corner `{key}` on Map receiver's integer axis must round-trip through both shapes",
1907 );
1908 }
1909 }
1910
1911 #[test]
1912 fn map_receiver_get_str_matches_value_object_arm_bytewise() {
1913 // Cross-receiver coherence pin: an `&Map<String, Value>`
1914 // receiver's `.get_str(<key>)` MUST return the SAME
1915 // `Option<&str>` that walking the equivalent `Value::Object(m)`
1916 // through the pre-existing `Value` impl would. A regression
1917 // that specialised the Map arm (a slot-name-normalisation
1918 // pass, a per-fleet trim) at ONE receiver but not the other
1919 // would silently split the two receiver shapes' behaviour and
1920 // break the "widening preserves semantics" invariant.
1921 let v: Value = json!({
1922 "apiVersion": "v1",
1923 "kind": "ConfigMap",
1924 "phase": "Running",
1925 });
1926 let m: &Map<String, Value> = v.as_object().unwrap();
1927 for key in ["apiVersion", "kind", "phase", "missing"] {
1928 assert_eq!(
1929 <Map<String, Value> as ValueGetExt>::get_str(m, key),
1930 <Value as ValueGetExt>::get_str(&v, key),
1931 "receiver-shape parity: `{key}` must project identically through both impls",
1932 );
1933 }
1934 }
1935
1936 #[test]
1937 fn map_receiver_axis_family_reaches_all_three_axes_through_one_trait_import() {
1938 // Axis-family + receiver-shape pin combined: a generic
1939 // `T: ValueGetExt` bound reaches ALL THREE axes on the Map
1940 // receiver — the SAME structural invariant the pre-existing
1941 // `get_array_axis_family_reaches_...` sibling pins for the
1942 // `Value` receiver. This test walks the SAME `probe`-style
1943 // generic through the Map arm, so a regression that split the
1944 // trait into per-axis peers would break the invariant on both
1945 // receiver shapes simultaneously.
1946 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>) {
1947 (t.get_i64("n"), t.get_str("s"), t.get_array("a"))
1948 }
1949 let m: Map<String, Value> = json!({ "n": 7, "s": "hello", "a": [1, 2, 3] })
1950 .as_object()
1951 .unwrap()
1952 .clone();
1953 let (n, s, a) = probe(&m);
1954 assert_eq!(n, Some(7));
1955 assert_eq!(s, Some("hello"));
1956 assert_eq!(a.map(Vec::len), Some(3));
1957 }
1958}