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). Adding a new axis (a `get_bool` for `Value::Bool`,
361/// a `get_object` for `Value::Object`, a `get_array` for
362/// `Value::Array`) lands as ONE new method here + ONE impl arm,
363/// inheriting the naming, `#[must_use]`, and inline discipline the
364/// existing axes pin. Never open a peer trait for a new axis —
365/// keep every READ projection on the ONE substrate owner so a
366/// caller who imports `ValueGetExt` reaches every axis through the
367/// same trait handle.
368///
369/// READ-side counterpart to the three MUTATION-side siblings already in
370/// this module — [`ValueObjectExt::as_object_mut_or`],
371/// [`JsonMapStrExt::insert_str`], [`JsonMapObjectEntryExt::object_slot_mut_or`]
372/// — partitioning the substrate along the (read, mutate) axis on the
373/// same `serde_json::Value` / `serde_json::Map<String, Value>` carrier
374/// pair.
375///
376/// Pre-lift the two-link chain was hand-authored at THREE adjacent
377/// slots inside `tatara-reconciler::boundary::fetch_job_status`, each
378/// projecting one `batch/v1::Job` `status.<counter>` field out of the
379/// fetched `serde_json::Value` object into a private `JobStatusView`
380/// row past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
381///
382/// * `status.get("succeeded").and_then(|v| v.as_i64())` — the
383/// Job-completion counter every `JobAttested` + `ClosedLoopAuth`
384/// postcondition evaluator gates on (`succeeded < 1` short-circuits
385/// to `Satisfaction::Unsatisfied("… still running (…)")`).
386/// * `status.get("failed").and_then(|v| v.as_i64())` — the
387/// Job-failure counter the same evaluators gate on
388/// (`failed > 0` short-circuits to
389/// `Satisfaction::Unsatisfied("… failed (status.failed={n})")`).
390/// * `status.get("active").and_then(|v| v.as_i64())` — the
391/// Job-in-flight counter the "still running" diagnostic tail
392/// reports as `(succeeded={s}, active={a})`.
393///
394/// All THREE sites walked the SAME two-link chain — `.get(<key>)` on a
395/// `serde_json::Value` already known to be the status object, then
396/// `.and_then(|v| v.as_i64())` on the returned `Option<&Value>` — and
397/// each was followed by an `if let Some(...)` write into the
398/// [`JobStatusView`] row initialised from `Default::default()`. Post-
399/// lift each callsite reads `status.get_i64(<key>)` and the two-link
400/// READ chain lives at ONE substrate owner here.
401///
402/// ### Naming — `get_i64`, not `as_i64` or `i64_at`
403///
404/// Same discipline as the three sibling traits above — the trait method
405/// deliberately does NOT collide with `serde_json::Value::as_i64` (the
406/// inherent projection on a single `Value` handle) nor with
407/// `serde_json::Value::get` (the inherent slot-lookup returning
408/// `Option<&Value>`). A name collision would let a caller who has
409/// `ValueGetExt` in scope resolve to one of the inherent methods by
410/// accident (inherent methods win over trait methods in method
411/// resolution) and silently drop half of the paired chain. The
412/// `get_i64(<key>)` shape names the intent: look up the slot at
413/// `<key>`, project the returned handle to `i64`, in ONE call.
414///
415/// ### `#[must_use]`
416///
417/// Every consumer either binds the returned `Option<i64>` into a
418/// downstream `if let Some(n) = ...` / `.unwrap_or_default()` / struct-
419/// field construction. Dropping the return silently discards the
420/// projection entirely, which is never the intended semantic at the
421/// three pre-lift consumers (each downstream write depends on the
422/// returned counter).
423///
424/// ### Composability
425///
426/// * Key slot is `&str` — matches every pre-lift `.get("<literal>")`
427/// callsite and the inherent `serde_json::Value::get`'s primary
428/// `str`-index arm. A caller with a runtime-computed key (a
429/// `String` produced by a template composer) reaches through
430/// `.get_i64(&s)` mechanically via `Deref<Target = str>`.
431/// * Returns `Option<i64>` matching the composed inherent chain's own
432/// return; a consumer wanting the "absent or non-integer → 0"
433/// fallback composes `.unwrap_or_default()` (or `.unwrap_or(0)`) at
434/// the callsite, keeping the "should this counter default to 0 or
435/// fail loud" decision at the caller rather than baking it into the
436/// primitive.
437/// * Non-object receivers (a `Value::String`, a `Value::Null`) return
438/// `None` verbatim via the inherent `Value::get`'s own non-object-
439/// arm behaviour, matching the pre-lift chain's semantics on the
440/// corner where the caller's status blob is malformed.
441///
442/// A future normalization — a per-fleet clamp that rejects negative
443/// counters (the K8s API server never emits them, but a fixture
444/// authoring bug could), a `Value::Number` fallback that accepts
445/// `f64` counters truncated to `i64`, a `checked` overflow arm that
446/// promotes an out-of-range integer to a diagnostic rather than a
447/// silent `None` — lands at THIS ONE substrate primitive and every
448/// downstream Job-status / Deployment-replica / HPA-desired-count
449/// counter reader inherits the upgrade mechanically. No per-site edit
450/// at any of the 3 listed callers or at future consumers (a
451/// Deployment `readyReplicas` projection, an HPA `currentReplicas`
452/// gate, a StatefulSet `updatedReplicas` freshness check).
453///
454/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
455/// two-link `.get(<key>).and_then(|v| v.as_i64())` chain recurred at
456/// three hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
457/// duplication trigger, and is lifted to ONE substrate owner here).
458/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
459/// regression that drifted the projection axis at ONE site — a swap
460/// of `as_i64` for `as_u64` narrowing the accepted range, a swap of
461/// `.get(<key>)` for `.pointer("<key>")` losing the direct-child
462/// semantics — would silently pass every downstream `JobStatusView`
463/// composition and surface as a wrong counter at operator-facing
464/// diagnostic wording; post-lift the projection lives at ONE typed
465/// owner so a regression surfaces at [`tests::get_i64_null_arm_returns_none`]
466/// / peers rather than as silent operator-facing drift).
467pub trait ValueGetExt {
468 /// Look up `key` on this JSON object and project the returned
469 /// handle to `i64`; returns `None` when the slot is absent, when
470 /// the receiver is not a JSON object, or when the slot's variant
471 /// is not integer-shaped.
472 #[must_use = "a JSON i64 projection that isn't bound swallows the counter entirely"]
473 fn get_i64(&self, key: &str) -> Option<i64>;
474
475 /// Look up `key` on this JSON object and project the returned
476 /// handle to `&str`; returns `None` when the slot is absent, when
477 /// the receiver is not a JSON object, or when the slot's variant
478 /// is not `Value::String`.
479 ///
480 /// String-axis sibling of [`Self::get_i64`] on the same
481 /// `.get(<key>).and_then(|v| v.as_<T>())` READ-chain lift. Pre-lift
482 /// the two-link chain was hand-authored at SEVEN production sites
483 /// across two crates past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
484 /// threshold:
485 ///
486 /// * `tatara-process::status::RenderedResourceCoords::from_json`
487 /// — FOUR paired reads (`apiVersion`, `kind`, `metadata.name`,
488 /// `metadata.namespace`) that project the four rendered-resource
489 /// coordinate slots off a `serde_json::Value` rendered manifest
490 /// into the typed `RenderedResourceCoords` row; the required
491 /// three (`apiVersion` / `kind` / `metadata.name`) compose with
492 /// `.ok_or_else(|| anyhow!("rendered resource missing X"))?
493 /// .to_string()`, and the optional `metadata.namespace` composes
494 /// with `.map(str::to_string)`.
495 /// * `tatara-reconciler::ssapply::ready_condition_value` — THREE
496 /// paired reads (`type`, `status`, `message`) inside the
497 /// condition-walker's per-condition classifier, each pulling a
498 /// `Value::String` slot off a K8s Condition object off the
499 /// `status.conditions[]` array.
500 ///
501 /// All seven sites walked the SAME two-link chain — `.get(<key>)`
502 /// on a `serde_json::Value` already known to be an object, then
503 /// `.and_then(|v| v.as_str())` on the returned `Option<&Value>` —
504 /// and each composed different downstream tails (fallible
505 /// `.ok_or_else(...)?.to_string()`, optional `.map(String::from)`,
506 /// pattern-match `Some("True")` / `Some("False")` / `_`). Post-lift
507 /// each callsite reads `<value>.get_str(<key>)` and the two-link
508 /// READ chain lives at ONE substrate owner here.
509 ///
510 /// ### Naming — `get_str`, not `as_str` or `str_at`
511 ///
512 /// Same discipline as [`Self::get_i64`] — the trait method
513 /// deliberately does NOT collide with `serde_json::Value::as_str`
514 /// (the inherent projection on a single `Value` handle) nor with
515 /// `serde_json::Value::get` (the inherent slot-lookup returning
516 /// `Option<&Value>`). A name collision would let a caller who has
517 /// [`ValueGetExt`] in scope resolve to one of the inherent methods
518 /// by accident (inherent methods win over trait methods in method
519 /// resolution) and silently drop half of the paired chain. The
520 /// `get_str(<key>)` shape names the intent: look up the slot at
521 /// `<key>`, project the returned handle to `&str`, in ONE call.
522 ///
523 /// ### `#[must_use]`
524 ///
525 /// Every pre-lift consumer binds the returned `Option<&str>` into
526 /// a downstream `.ok_or_else(...)?.to_string()` / `.map(String::from)`
527 /// / `.map(str::to_string)` / pattern-match arm. Dropping the
528 /// return silently discards the projection entirely, which is
529 /// never the intended semantic at any of the seven pre-lift
530 /// consumers.
531 ///
532 /// ### Return lifetime
533 ///
534 /// The `&str` borrows the same buffer the underlying
535 /// `Value::String` variant owns; the `Option<&str>` is bounded by
536 /// the receiver's lifetime (`&'_ self`), so a caller holding onto
537 /// the returned slice keeps the receiver borrowed. Matches the
538 /// pre-lift chain's own borrow shape (`v.as_str()` borrows through
539 /// the `&Value`).
540 ///
541 /// A future normalization on the projection — a Unicode
542 /// normalization pass (NFC-folding annotation values), a
543 /// per-fleet trim of leading/trailing whitespace, a rejection of
544 /// empty-string arms as "the caller meant absent" — lands at THIS
545 /// ONE substrate primitive and every downstream `apiVersion` /
546 /// `kind` / `metadata.name` / K8s-condition-string reader
547 /// inherits the upgrade mechanically.
548 ///
549 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
550 /// the two-link `.get(<key>).and_then(|v| v.as_str())` chain
551 /// recurred at SEVEN production sites across two crates past the
552 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
553 /// ONE substrate owner here on the string axis of the same
554 /// READ-chain axis-family the `get_i64` sibling opened for the
555 /// integer axis). THEORY.md §II.1 invariant 5 (composition
556 /// preserves proofs — a regression that drifted the projection
557 /// axis at ONE site would silently pass every downstream
558 /// composition and surface as a wrong slot at operator-facing
559 /// diagnostic wording; post-lift the projection lives at ONE
560 /// typed owner so a regression surfaces at
561 /// [`tests::get_str_present_string_slot_returns_the_slice`] /
562 /// peers rather than as silent operator-facing drift).
563 #[must_use = "a JSON &str projection that isn't bound swallows the slot entirely"]
564 fn get_str(&self, key: &str) -> Option<&str>;
565}
566
567impl ValueGetExt for Value {
568 #[inline]
569 fn get_i64(&self, key: &str) -> Option<i64> {
570 self.get(key).and_then(Value::as_i64)
571 }
572
573 #[inline]
574 fn get_str(&self, key: &str) -> Option<&str> {
575 self.get(key).and_then(Value::as_str)
576 }
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582 use serde_json::json;
583
584 // ─── ValueObjectExt::as_object_mut_or substrate pins ─────────────
585 //
586 // Fail-before-pass-after granularity: the `ValueObjectExt::
587 // as_object_mut_or` trait method did not exist before this commit,
588 // so each test below fails to compile pre-lift. Post-lift they
589 // collectively pin the object-guard shape at ONE substrate owner —
590 // a regression that drifts the error message wording, swaps the
591 // `<slot>` slot, wraps the source in a chain-form `source` (which
592 // would change `Display` output when downstream tracing formatters
593 // interpolate `{e}` rather than the chain-walking `{e:#}`), or
594 // promotes the pass-through arm to synthesis (a `None → Ok(&mut
595 // Map::default())` fallthrough that silently swallows a mistyped
596 // slot) surfaces HERE rather than as silent operator-facing skew
597 // across the three `ssapply.rs` pre-lift consumers whose log
598 // output already encoded the flat `"<slot> is not an object"`
599 // shape.
600
601 #[test]
602 fn as_object_mut_or_object_arm_returns_the_inner_map_mutably() {
603 // Ok-arm invariant: a `Value::Object` handle threaded through
604 // `as_object_mut_or("<slot>")` MUST return `Ok(&mut Map)`
605 // whose interior is the SAME `serde_json::Map` the underlying
606 // `serde_json::Value::as_object_mut` would return — no clone,
607 // no reshape, no synthesis. The `&mut` return is load-bearing
608 // at every consumer (each threads a downstream `.entry(...).
609 // or_insert_with(...)` / `.insert(...)` mutation onto the
610 // returned reference), so a regression that returned a fresh
611 // owned `Map` here would silently drop every downstream write.
612 let mut v = json!({ "existing_key": "existing_value" });
613 let map = v.as_object_mut_or("resource").expect("Value::Object");
614 map.insert("new_key".to_string(), json!("new_value"));
615 assert_eq!(v["existing_key"], "existing_value");
616 assert_eq!(v["new_key"], "new_value");
617 }
618
619 #[test]
620 fn as_object_mut_or_null_arm_errors_with_pre_lift_display_bytewise() {
621 // Byte-shape parity pin: the wrap output of `as_object_mut_or
622 // ("<slot>")` on a `Value::Null` handle MUST be `Display`-
623 // identical to the pre-lift hand-authored `.as_object_mut().
624 // ok_or_else(|| anyhow!("<slot> is not an object"))?` chain.
625 // A regression that inserted a synonym (`"<slot> is not a
626 // JSON object"`), reshaped the slot position (`"not an
627 // object: <slot>"`), or dropped the leading `<slot>` slot
628 // surfaces HERE rather than as silent drift at every
629 // downstream log-output consumer.
630 let mut v = Value::Null;
631 let err = v.as_object_mut_or("resource").unwrap_err();
632 assert_eq!(format!("{err}"), "resource is not an object");
633 }
634
635 #[test]
636 fn as_object_mut_or_array_arm_errors_with_pre_lift_display_bytewise() {
637 // Sibling to the null-arm byte-shape pin — a mistyped
638 // `metadata` slot authored as a JSON array (kubectl accepts
639 // `metadata: []` in a YAML manifest with no schema, though the
640 // apiserver later rejects it) surfaces the same guard error.
641 // Pins the "non-object variants ALL error via the same wire
642 // format" invariant — a regression that special-cased the
643 // array variant (returning a fresh empty map, silently
644 // coercing) surfaces HERE.
645 let mut v = json!(["not", "an", "object"]);
646 let err = v.as_object_mut_or("metadata").unwrap_err();
647 assert_eq!(format!("{err}"), "metadata is not an object");
648 }
649
650 #[test]
651 fn as_object_mut_or_string_arm_errors_with_pre_lift_display_bytewise() {
652 // Sibling to the null / array pins — a mistyped `annotations`
653 // slot authored as a JSON string (a common apiserver-layer
654 // authoring bug in kubectl-generated manifests where a
655 // stringified JSON object leaks through) surfaces the same
656 // guard error. Pins the "every non-object variant errors via
657 // the same wire format" invariant across the full
658 // `serde_json::Value` sum.
659 let mut v = json!("stringified");
660 let err = v.as_object_mut_or("annotations").unwrap_err();
661 assert_eq!(format!("{err}"), "annotations is not an object");
662 }
663
664 #[test]
665 fn as_object_mut_or_threads_the_slot_slug_verbatim_across_all_three_pre_lift_labels() {
666 // Cross-slot coherence pin: the three pre-lift consumers in
667 // `tatara-reconciler::ssapply` stamped THREE distinct slot
668 // slugs (`"resource"` / `"metadata"` / `"annotations"`), and
669 // the wrap-shape MUST honor each one verbatim as the leading
670 // slot in the `Display` output. A regression that hard-coded
671 // one slug (say `"resource"`) across every callsite would
672 // pass the first pin above and fail HERE — the three
673 // downstream error-stream greps operators run to bisect a
674 // "which SSA-time mutation faulted" alert would ALL collapse
675 // to the same slug.
676 for slot in ["resource", "metadata", "annotations"] {
677 let mut v = Value::Null;
678 let err = v.as_object_mut_or(slot).unwrap_err();
679 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
680 }
681 }
682
683 #[test]
684 fn as_object_mut_or_object_arm_matches_inherent_as_object_mut_bytewise() {
685 // Cross-substrate coherence pin: on the Ok arm the trait
686 // method MUST return the SAME `&mut Map` the inherent
687 // `serde_json::Value::as_object_mut` would — no diverging
688 // view, no clone, no key-order reshape. A regression that
689 // introduced a normalization pass here (sorting keys,
690 // stripping a null-valued entry, coercing a nested string
691 // to a JSON scalar) would surface as silent per-consumer
692 // schema drift at the SSA-time mutation — an ownerReferences
693 // append that no longer landed in the same slot the apiserver
694 // reads, an annotations insert whose key ordering diverged
695 // from kubectl's canonical form.
696 let mut via_trait = json!({ "key": "value", "nested": { "inner": 1 } });
697 let mut via_inherent = via_trait.clone();
698 assert_eq!(
699 via_trait
700 .as_object_mut_or("resource")
701 .expect("Value::Object")
702 .clone(),
703 via_inherent.as_object_mut().expect("Value::Object").clone(),
704 );
705 }
706
707 // ─── JsonMapStrExt::insert_str substrate pins ─────────────────
708 //
709 // Fail-before-pass-after granularity: the `JsonMapStrExt::insert_str`
710 // trait method did not exist before this commit, so each test below
711 // fails to compile pre-lift. Post-lift they collectively pin the
712 // string-slot write shape at ONE substrate owner — a regression that
713 // dropped the `Value::String` wrap (silently coercing to a bare
714 // `Value::from(&str)` — byte-identical in the `Object` arm today but
715 // divergent for any future non-`&str` numeric caller who reached for
716 // `insert_str(k, n.to_string())`), swapped the key + value slot
717 // orientation, or drifted the return semantics from the inherent
718 // `Map::insert` (which returns the previous value on overwrite —
719 // load-bearing at any future caller that inspects the return) would
720 // surface HERE rather than as silent per-emit skew across the
721 // thirteen pre-lift `ssapply` + `render` + `edges` consumers.
722
723 #[test]
724 fn insert_str_new_key_returns_none_and_stamps_value_string() {
725 // New-key arm: matches inherent `Map::insert` return
726 // semantics — `None` for a fresh key — and stamps a
727 // `Value::String` (NOT `Value::from(&str)`, though they're
728 // byte-identical today) at the slot.
729 let mut m = Map::new();
730 let prev = m.insert_str("key", "value");
731 assert!(prev.is_none(), "new key returns None");
732 assert_eq!(m.get("key"), Some(&Value::String("value".to_string())));
733 assert!(matches!(m.get("key"), Some(Value::String(_))));
734 }
735
736 #[test]
737 fn insert_str_overwrite_returns_prior_value_and_stamps_new() {
738 // Overwrite arm: matches inherent `Map::insert` return
739 // semantics — `Some(prev)` on overwrite. Load-bearing for
740 // any future consumer that inspects the return to detect a
741 // slot collision (a fleet-wide sweep that flagged a
742 // duplicate SSA-time annotation stamp, for example).
743 let mut m = Map::new();
744 m.insert_str("key", "old");
745 let prev = m.insert_str("key", "new");
746 assert_eq!(prev, Some(Value::String("old".to_string())));
747 assert_eq!(m.get("key"), Some(&Value::String("new".to_string())));
748 }
749
750 #[test]
751 fn insert_str_accepts_str_and_owned_string_at_both_slots() {
752 // Composability pin: both slots MUST accept `&str` and
753 // `String` interchangeably — the pre-lift callsite inventory
754 // mixes both (SSA-time `annotations::PID` static + a
755 // `pid.to_string()` runtime String at the value slot;
756 // `spec.insert("interval".into(), Value::String("1m".into()))`
757 // with two `&str` slots). A regression that constrained
758 // either slot to one shape would break the callsite parity
759 // that motivated this substrate primitive.
760 let mut m1 = Map::new();
761 m1.insert_str("a", "b");
762 let mut m2 = Map::new();
763 m2.insert_str(String::from("a"), String::from("b"));
764 let mut m3 = Map::new();
765 m3.insert_str("a", String::from("b"));
766 let mut m4 = Map::new();
767 m4.insert_str(String::from("a"), "b");
768 assert_eq!(m1, m2);
769 assert_eq!(m2, m3);
770 assert_eq!(m3, m4);
771 }
772
773 #[test]
774 fn insert_str_matches_pre_lift_hand_authored_shape_bytewise() {
775 // Byte-shape parity pin: `insert_str(k, v)` MUST emit the
776 // SAME `Map` entry the pre-lift hand-authored `.insert(
777 // <k>.into(), Value::String(<v>.into()))` chain produced.
778 // Sweeps the four (str × String) × (str × String) key/value
779 // shape quadrants so a regression at the primitive that
780 // broke the byte identity with the pre-lift shape at ONE
781 // quadrant surfaces here rather than as a subtle per-emit
782 // divergence at that quadrant.
783 for (k_str, v_str) in [("a", "b"), ("x", ""), ("", "y"), ("", "")] {
784 // (str, str) quadrant
785 let mut via_primitive = Map::new();
786 via_primitive.insert_str(k_str, v_str);
787 let mut via_pre_lift = Map::new();
788 via_pre_lift.insert(k_str.into(), Value::String(v_str.into()));
789 assert_eq!(via_primitive, via_pre_lift);
790
791 // (String, String) quadrant
792 let mut via_primitive = Map::new();
793 via_primitive.insert_str(String::from(k_str), String::from(v_str));
794 let mut via_pre_lift = Map::new();
795 via_pre_lift.insert(String::from(k_str), Value::String(String::from(v_str)));
796 assert_eq!(via_primitive, via_pre_lift);
797 }
798 }
799
800 #[test]
801 fn insert_str_empty_value_stamps_empty_string_not_null() {
802 // Semantic pin: an empty value slot MUST stamp
803 // `Value::String("")`, NEVER `Value::Null`. Load-bearing at
804 // any callsite that stamps a placeholder empty-string
805 // annotation (say a `content_hash` slot pre-derive) where a
806 // `Null` slot would fail-loud at the K8s apiserver's
807 // annotation-value type check.
808 let mut m = Map::new();
809 m.insert_str("empty", "");
810 assert_eq!(m.get("empty"), Some(&Value::String(String::new())));
811 assert!(!matches!(m.get("empty"), Some(Value::Null)));
812 }
813
814 // ─── JsonMapObjectEntryExt::object_slot_mut_or substrate pins ─────
815 //
816 // Fail-before-pass-after granularity: the
817 // `JsonMapObjectEntryExt::object_slot_mut_or` trait method did not
818 // exist before this commit, so each test below fails to compile
819 // pre-lift. Post-lift they collectively pin the compound
820 // seed-then-guard shape at ONE substrate owner — a regression that
821 // dropped the seed step (leaving an absent slot to fall through the
822 // guard as `None → Err`), skipped the guard step (silently returning
823 // an `&mut Value` when the existing slot is a non-object variant),
824 // drifted the entry-key slot vs. the guard-error slot (a copy-paste
825 // typo that stamped `"metadata"` into the entry and `"metadatas"`
826 // into the guard error message), or drifted the empty-seed shape
827 // (a `Value::Null` fallback where `Value::Object(Map::new())` is
828 // load-bearing at the downstream `.entry(...).or_insert_with(...)`
829 // / `.insert(...)` mutation) would surface HERE rather than as
830 // silent per-emit skew across the two pre-lift `ssapply.rs`
831 // consumers.
832
833 #[test]
834 fn object_slot_mut_or_absent_slot_seeds_empty_object_and_returns_it() {
835 // Absent-slot arm: the pre-lift `.entry(<slot>).or_insert_with
836 // (|| Value::Object(Default::default()))` step MUST seed the
837 // slot with an EMPTY `Value::Object` when the slot is not
838 // present in the parent map. The returned handle is the fresh
839 // empty map, MUTABLY, so a downstream `.insert(...)` writes
840 // land in the parent map's `<slot>` object post-return.
841 let mut parent = Map::new();
842 {
843 let child = parent
844 .object_slot_mut_or("metadata")
845 .expect("absent slot seeds an object");
846 assert!(child.is_empty(), "fresh-seeded slot is an empty object");
847 child.insert("name".into(), Value::String("demo".into()));
848 }
849 // The write landed in the parent map's metadata slot.
850 assert_eq!(parent["metadata"]["name"], "demo");
851 assert!(matches!(parent.get("metadata"), Some(Value::Object(_))));
852 }
853
854 #[test]
855 fn object_slot_mut_or_present_object_slot_returns_existing_interior_mutably() {
856 // Present-object-slot arm: when the slot is already populated
857 // with a `Value::Object`, the primitive MUST return the
858 // EXISTING map interior mutably — no synthesis, no reshape, no
859 // key-order rewrite. The downstream `.insert(...)` writes MUST
860 // merge into the pre-existing keys rather than replace them.
861 let mut parent = Map::new();
862 parent.insert(
863 "metadata".into(),
864 serde_json::json!({ "existing_key": "existing_value" }),
865 );
866 {
867 let child = parent
868 .object_slot_mut_or("metadata")
869 .expect("present-object slot returns Ok");
870 assert_eq!(
871 child.get("existing_key"),
872 Some(&Value::String("existing_value".into()))
873 );
874 child.insert("new_key".into(), Value::String("new_value".into()));
875 }
876 assert_eq!(parent["metadata"]["existing_key"], "existing_value");
877 assert_eq!(parent["metadata"]["new_key"], "new_value");
878 }
879
880 #[test]
881 fn object_slot_mut_or_present_non_object_slot_errors_with_pre_lift_display() {
882 // Fail-loud arm: when the slot is present but holds a non-
883 // object variant (a `Value::String` from a hand-authored
884 // YAML manifest where `metadata: "malformed"` slipped past
885 // kubectl's schema check), the primitive MUST fail with a
886 // `Display` byte-identical to the pre-lift
887 // `.as_object_mut_or(<slot>)?` guard — the sibling
888 // [`ValueObjectExt::as_object_mut_or`] guard's wire format.
889 // A regression that special-cased this arm (overwriting the
890 // slot with a fresh empty object, silently coercing) would
891 // silently swallow the operator's authoring error at the
892 // SSA-time re-injection step.
893 let mut parent = Map::new();
894 parent.insert("metadata".into(), Value::String("malformed".into()));
895 let err = parent.object_slot_mut_or("metadata").unwrap_err();
896 assert_eq!(format!("{err}"), "metadata is not an object");
897 }
898
899 #[test]
900 fn object_slot_mut_or_threads_the_slot_slug_verbatim_across_both_pre_lift_labels() {
901 // Cross-slot coherence pin: the TWO pre-lift consumers in
902 // `tatara-reconciler::ssapply` stamped TWO distinct slot slugs
903 // (`"metadata"` at the resource root, `"annotations"` at the
904 // metadata child), and the wrap-shape MUST honor each one
905 // verbatim as the leading slot in the `Display` output. A
906 // regression that hard-coded one slug across every callsite
907 // would pass the fail-loud pin above (on the `"metadata"` slug)
908 // and fail HERE — the two downstream error-stream greps
909 // operators run to bisect a "which SSA-time slot mutation
910 // faulted" alert would ALL collapse to the same slug, hiding
911 // whether the fault was at the resource-root object walk or
912 // the metadata-child annotations walk.
913 for slot in ["metadata", "annotations"] {
914 let mut parent = Map::new();
915 parent.insert(slot.into(), Value::Null);
916 let err = parent.object_slot_mut_or(slot).unwrap_err();
917 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
918 }
919 }
920
921 #[test]
922 fn object_slot_mut_or_present_empty_object_returns_existing_reference_not_synthesized() {
923 // Precedence pin: a present slot holding an EMPTY
924 // `Value::Object` MUST return the pre-existing empty map
925 // interior — not a freshly-synthesized replacement. The
926 // pre-lift `.entry(<slot>).or_insert_with(||...)` step's
927 // short-circuit on the present-slot arm skips the closure
928 // entirely; a regression that always evaluated the closure
929 // (unconditionally overwriting an existing empty-object slot
930 // with a fresh empty object) would type-check silently at
931 // every callsite AND write byte-identical JSON at the empty-
932 // slot corner, but it would break a hypothetical future
933 // consumer that reached the primitive on a map whose slot
934 // was seeded upstream with metadata (a caller intending to
935 // preserve any keys the parent-composer already dropped in).
936 let mut parent = Map::new();
937 parent.insert("metadata".into(), Value::Object(Map::new()));
938 let addr_before = parent.get("metadata").unwrap() as *const Value;
939 {
940 let _child = parent.object_slot_mut_or("metadata").unwrap();
941 }
942 let addr_after = parent.get("metadata").unwrap() as *const Value;
943 assert_eq!(
944 addr_before, addr_after,
945 "present empty-object slot must return the pre-existing reference, not a fresh synthesis",
946 );
947 }
948
949 #[test]
950 fn object_slot_mut_or_matches_pre_lift_hand_authored_compound_shape_bytewise() {
951 // Byte-shape parity pin: `object_slot_mut_or(<slot>)?` MUST
952 // produce the SAME `&mut Map` (and, on the non-object arm, the
953 // SAME `Display`-shaped error) the pre-lift 3-line `.entry
954 // (<slot>).or_insert_with(|| Value::Object(Default::default()))
955 // .as_object_mut_or(<slot>)?` chain produced. Sweeps the three
956 // pre-lift-reachable input corners (absent slot / present
957 // object / present non-object) so a regression at the primitive
958 // that broke byte identity with the pre-lift chain at ONE
959 // corner surfaces here rather than as a subtle per-emit
960 // divergence.
961 for slot in ["metadata", "annotations"] {
962 // (1) Absent-slot corner: both routes seed empty-object at
963 // the slot AND return the same empty map interior.
964 let mut via_primitive = Map::new();
965 let mut via_pre_lift = Map::new();
966 {
967 let _ = via_primitive.object_slot_mut_or(slot).unwrap();
968 let _ = via_pre_lift
969 .entry(slot.to_string())
970 .or_insert_with(|| Value::Object(Map::new()))
971 .as_object_mut_or(slot)
972 .unwrap();
973 }
974 assert_eq!(via_primitive, via_pre_lift);
975
976 // (2) Present-object corner: both routes read back the
977 // same pre-populated interior mutably.
978 let mut via_primitive = Map::new();
979 via_primitive.insert(slot.into(), serde_json::json!({ "k": "v" }));
980 let mut via_pre_lift = via_primitive.clone();
981 {
982 let a = via_primitive.object_slot_mut_or(slot).unwrap();
983 let b = via_pre_lift
984 .entry(slot.to_string())
985 .or_insert_with(|| Value::Object(Map::new()))
986 .as_object_mut_or(slot)
987 .unwrap();
988 assert_eq!(a, b);
989 }
990
991 // (3) Present-non-object corner: both routes fail loud
992 // with the same wire-format Display shape.
993 let mut via_primitive = Map::new();
994 via_primitive.insert(slot.into(), Value::Bool(true));
995 let mut via_pre_lift = via_primitive.clone();
996 let err_primitive = via_primitive.object_slot_mut_or(slot).unwrap_err();
997 let err_pre_lift = via_pre_lift
998 .entry(slot.to_string())
999 .or_insert_with(|| Value::Object(Map::new()))
1000 .as_object_mut_or(slot)
1001 .unwrap_err();
1002 assert_eq!(format!("{err_primitive}"), format!("{err_pre_lift}"));
1003 }
1004 }
1005
1006 // ─── ValueGetExt::get_i64 substrate pins ─────────────────────────
1007 //
1008 // Fail-before-pass-after granularity: the `ValueGetExt::get_i64`
1009 // trait method did not exist before this commit, so each test below
1010 // fails to compile pre-lift. Post-lift they collectively pin the
1011 // paired READ-shape at ONE substrate owner — a regression that
1012 // narrowed the projection to `as_u64` (silently losing every
1013 // negative counter K8s fixtures can carry for a JSON authoring
1014 // bug), swapped the slot lookup to `.pointer(<key>)` (losing the
1015 // direct-child semantics), promoted a present-but-non-integer
1016 // corner to `Some(0)` (silently paving over a malformed status
1017 // blob), or drifted the receiver-non-object arm from `None → Some(default)`
1018 // (silently synthesising a zero counter on a null status blob)
1019 // surfaces HERE rather than as silent operator-facing skew across
1020 // the three `boundary.rs::fetch_job_status` pre-lift consumers
1021 // whose JobStatusView row initialised at `Default::default()` and
1022 // conditionally overwrote each field on `Some(i64)`.
1023
1024 #[test]
1025 fn get_i64_present_integer_slot_returns_the_value() {
1026 // Primary Ok-arm invariant: a `Value::Number(i)` present at the
1027 // slot projects to `Some(i)`. Sweeps the three representative
1028 // counters every pre-lift `JobStatusView` field carried (a
1029 // completed Job's `succeeded=1`, a failed Job's `failed=3`, a
1030 // freshly-scheduled Job's `active=5`) so a regression at ONE
1031 // counter axis surfaces here rather than at the downstream
1032 // diagnostic.
1033 let status = json!({ "succeeded": 1, "failed": 3, "active": 5 });
1034 assert_eq!(status.get_i64("succeeded"), Some(1));
1035 assert_eq!(status.get_i64("failed"), Some(3));
1036 assert_eq!(status.get_i64("active"), Some(5));
1037 }
1038
1039 #[test]
1040 fn get_i64_absent_slot_returns_none() {
1041 // Absent-slot corner: a fresh `batch/v1::Job` before its
1042 // controller has stamped any counter into `status` (the JSON
1043 // is `{}` or missing the counter key). Every pre-lift consumer
1044 // routed this corner through the `if let Some(...)` guard so
1045 // the `JobStatusView` field kept its `Default::default()` `0`
1046 // seed. A regression that returned `Some(0)` on the absent
1047 // corner would collapse the "not yet reported" ↔ "reported
1048 // zero" distinction the K8s status protocol keeps.
1049 let status = json!({});
1050 assert_eq!(status.get_i64("succeeded"), None);
1051 assert_eq!(status.get_i64("any_missing_key"), None);
1052 }
1053
1054 #[test]
1055 fn get_i64_present_but_non_integer_slot_returns_none() {
1056 // Present-but-non-integer corner: a `Value::String`, a
1057 // `Value::Bool`, a `Value::Object`, or a `Value::Array` at the
1058 // slot ALL fall through to `None` — matches the pre-lift
1059 // `.and_then(|v| v.as_i64())` chain exactly. A regression that
1060 // promoted a `Value::String("1")` to `Some(1)` (adding a
1061 // parse-string fallback) would silently accept a malformed
1062 // status blob whose author stringified a counter.
1063 let status = json!({
1064 "stringy": "1",
1065 "boolean": true,
1066 "object": {},
1067 "array": [],
1068 "null_valued": null,
1069 });
1070 assert_eq!(status.get_i64("stringy"), None);
1071 assert_eq!(status.get_i64("boolean"), None);
1072 assert_eq!(status.get_i64("object"), None);
1073 assert_eq!(status.get_i64("array"), None);
1074 assert_eq!(status.get_i64("null_valued"), None);
1075 }
1076
1077 #[test]
1078 fn get_i64_negative_counter_survives_the_projection() {
1079 // Negative-integer corner: `as_i64` accepts negatives; `as_u64`
1080 // does not. A regression that narrowed the projection to
1081 // `as_u64` under a mistaken "K8s counters are always non-
1082 // negative" refactor would silently drop every negative
1083 // counter a JSON authoring bug could stamp — hiding the bug
1084 // rather than surfacing it as a counter the diagnostic reports
1085 // verbatim.
1086 let status = json!({ "n": -1 });
1087 assert_eq!(status.get_i64("n"), Some(-1));
1088 }
1089
1090 #[test]
1091 fn get_i64_non_object_receiver_returns_none_verbatim() {
1092 // Non-object receiver corner: a caller who reached this
1093 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1094 // handle (a malformed fetch response, an upstream default-value
1095 // fallback) MUST get `None` back rather than a panic or a
1096 // synthesized `Some(default)`. Matches the pre-lift chain's
1097 // behaviour: `Value::get` on a non-object receiver returns
1098 // `None`, `and_then` short-circuits.
1099 assert_eq!(Value::Null.get_i64("any"), None);
1100 assert_eq!(Value::Bool(true).get_i64("any"), None);
1101 assert_eq!(json!([1, 2, 3]).get_i64("any"), None);
1102 assert_eq!(json!("scalar").get_i64("any"), None);
1103 }
1104
1105 #[test]
1106 fn get_i64_matches_pre_lift_hand_authored_chain_shape() {
1107 // Byte-shape parity pin: `<value>.get_i64(<key>)` MUST return
1108 // the SAME `Option<i64>` the pre-lift hand-authored
1109 // `.get(<key>).and_then(|v| v.as_i64())` chain produced.
1110 // Sweeps the six pre-lift-reachable input corners (the three
1111 // "value present" + three "value absent/malformed" arms every
1112 // fetch_job_status callsite reached) so a regression at the
1113 // primitive that broke byte identity with the pre-lift chain at
1114 // ONE corner surfaces here rather than as a per-counter
1115 // divergence at the fetched-Job projection.
1116 let status = json!({
1117 "succeeded": 2,
1118 "failed": 0,
1119 "active": 7,
1120 "stringy": "1",
1121 "null_valued": null,
1122 });
1123 for key in [
1124 "succeeded",
1125 "failed",
1126 "active",
1127 "stringy",
1128 "null_valued",
1129 "missing",
1130 ] {
1131 let via_primitive = status.get_i64(key);
1132 let via_pre_lift = status.get(key).and_then(|v| v.as_i64());
1133 assert_eq!(
1134 via_primitive, via_pre_lift,
1135 "corner `{key}` must round-trip through both shapes",
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn get_i64_composes_with_unwrap_or_default_at_default_seed_shape() {
1142 // Downstream composition pin: the canonical caller shape
1143 // post-lift is `<status>.get_i64(<key>).unwrap_or_default()` —
1144 // matches the pre-lift `JobStatusView::default()` seed +
1145 // conditional `if let Some(n)` write pattern. A regression that
1146 // reshaped the return form (an `i64` bare default, a
1147 // `Result<i64, _>` fallible arm) would break this composition.
1148 let status = json!({ "succeeded": 4 });
1149 // Absent slot composes to the type default (0 for i64).
1150 assert_eq!(status.get_i64("missing").unwrap_or_default(), 0_i64);
1151 // Present slot composes to the projected counter.
1152 assert_eq!(status.get_i64("succeeded").unwrap_or_default(), 4_i64);
1153 }
1154
1155 // ─── ValueGetExt::get_str substrate pins ─────────────────────────
1156 //
1157 // Fail-before-pass-after granularity: the `ValueGetExt::get_str`
1158 // trait method did not exist before this commit, so each test below
1159 // fails to compile pre-lift. Post-lift they collectively pin the
1160 // paired READ-shape at ONE substrate owner — a regression that
1161 // narrowed the projection to the wrong variant (accepting
1162 // `Value::Number`-stringified slots via a fallback, or accepting
1163 // `Value::Null` as `Some("")`), swapped the slot lookup to
1164 // `.pointer(<key>)` (losing the direct-child semantics), promoted
1165 // an absent slot to `Some("")` (silently paving over a missing
1166 // required slot), or drifted the receiver-non-object arm from
1167 // `None` (silently synthesising an empty string on a null status
1168 // blob) surfaces HERE rather than as silent operator-facing skew
1169 // across the SEVEN pre-lift consumers (`status::from_json`'s four
1170 // rendered-resource coordinate reads + `ssapply::ready_condition_value`'s
1171 // three K8s Condition slot reads).
1172
1173 #[test]
1174 fn get_str_present_string_slot_returns_the_slice() {
1175 // Primary Ok-arm invariant: a `Value::String(s)` present at the
1176 // slot projects to `Some(s.as_str())`. Sweeps the four
1177 // representative slots the pre-lift `RenderedResourceCoords::
1178 // from_json` consumer walked (`apiVersion`, `kind`,
1179 // `metadata.name`, `metadata.namespace`) so a regression at
1180 // ONE axis surfaces here rather than at the downstream
1181 // typed row's coordinate.
1182 let manifest = json!({
1183 "apiVersion": "helm.toolkit.fluxcd.io/v2",
1184 "kind": "HelmRelease",
1185 "name": "demo-app",
1186 "namespace": "demo",
1187 });
1188 assert_eq!(
1189 manifest.get_str("apiVersion"),
1190 Some("helm.toolkit.fluxcd.io/v2"),
1191 );
1192 assert_eq!(manifest.get_str("kind"), Some("HelmRelease"));
1193 assert_eq!(manifest.get_str("name"), Some("demo-app"));
1194 assert_eq!(manifest.get_str("namespace"), Some("demo"));
1195 }
1196
1197 #[test]
1198 fn get_str_absent_slot_returns_none() {
1199 // Absent-slot corner: a rendered manifest whose author forgot
1200 // the `apiVersion` slot (a common authoring bug) MUST return
1201 // `None` so `RenderedResourceCoords::from_json` fails loud
1202 // rather than silently synthesising an empty apiVersion. A
1203 // regression that returned `Some("")` on the absent corner
1204 // would collapse the "not authored" ↔ "authored empty"
1205 // distinction the fail-loud gate depends on.
1206 let manifest = json!({ "kind": "HelmRelease" });
1207 assert_eq!(manifest.get_str("apiVersion"), None);
1208 assert_eq!(manifest.get_str("any_missing_key"), None);
1209 }
1210
1211 #[test]
1212 fn get_str_present_but_non_string_slot_returns_none() {
1213 // Present-but-non-string corner: a `Value::Number`,
1214 // `Value::Bool`, `Value::Object`, `Value::Array`, or
1215 // `Value::Null` at the slot ALL fall through to `None` —
1216 // matches the pre-lift `.and_then(|v| v.as_str())` chain
1217 // exactly. A regression that stringified a `Value::Number`
1218 // (adding a `to_string()` fallback) would silently accept a
1219 // malformed manifest whose author numeric-typed a
1220 // conventionally-string slot.
1221 let manifest = json!({
1222 "numeric": 1,
1223 "boolean": true,
1224 "object": {},
1225 "array": [],
1226 "null_valued": null,
1227 });
1228 assert_eq!(manifest.get_str("numeric"), None);
1229 assert_eq!(manifest.get_str("boolean"), None);
1230 assert_eq!(manifest.get_str("object"), None);
1231 assert_eq!(manifest.get_str("array"), None);
1232 assert_eq!(manifest.get_str("null_valued"), None);
1233 }
1234
1235 #[test]
1236 fn get_str_empty_string_slot_survives_the_projection() {
1237 // Empty-string corner: a `Value::String("")` present at the
1238 // slot MUST project to `Some("")` — matches the pre-lift
1239 // `.and_then(|v| v.as_str())` chain exactly, keeping the
1240 // "authored empty" arm distinct from the "not authored" arm
1241 // upstream. A regression that promoted `Some("")` to `None`
1242 // under a "reject empty strings" refactor would silently
1243 // collapse the two arms and turn a valid empty `metadata.
1244 // namespace` (a cluster-scoped resource) into a fail-loud
1245 // error at the required-slot gates.
1246 let manifest = json!({ "namespace": "" });
1247 assert_eq!(manifest.get_str("namespace"), Some(""));
1248 }
1249
1250 #[test]
1251 fn get_str_non_object_receiver_returns_none_verbatim() {
1252 // Non-object receiver corner: a caller who reached this
1253 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1254 // handle (a malformed fetch response, an upstream default-value
1255 // fallback, a `serde_json::Value::Null` metadata slot chained
1256 // through `.and_then`) MUST get `None` back rather than a
1257 // panic or a synthesized `Some("")`. Matches the pre-lift
1258 // chain's behaviour: `Value::get` on a non-object receiver
1259 // returns `None`, `and_then` short-circuits.
1260 assert_eq!(Value::Null.get_str("any"), None);
1261 assert_eq!(Value::Bool(true).get_str("any"), None);
1262 assert_eq!(json!([1, 2, 3]).get_str("any"), None);
1263 assert_eq!(json!("scalar").get_str("any"), None);
1264 }
1265
1266 #[test]
1267 fn get_str_matches_pre_lift_hand_authored_chain_shape() {
1268 // Byte-shape parity pin: `<value>.get_str(<key>)` MUST return
1269 // the SAME `Option<&str>` the pre-lift hand-authored
1270 // `.get(<key>).and_then(|v| v.as_str())` chain produced.
1271 // Sweeps every pre-lift-reachable input corner (three
1272 // "value present" + three "value absent/malformed" arms every
1273 // status.rs / ssapply.rs callsite reached) so a regression at
1274 // the primitive that broke byte identity with the pre-lift
1275 // chain at ONE corner surfaces here rather than as a
1276 // per-slot divergence downstream.
1277 let manifest = json!({
1278 "apiVersion": "v1",
1279 "kind": "ConfigMap",
1280 "type": "Ready",
1281 "numeric": 1,
1282 "null_valued": null,
1283 });
1284 for key in [
1285 "apiVersion",
1286 "kind",
1287 "type",
1288 "numeric",
1289 "null_valued",
1290 "missing",
1291 ] {
1292 let via_primitive = manifest.get_str(key);
1293 let via_pre_lift = manifest.get(key).and_then(|v| v.as_str());
1294 assert_eq!(
1295 via_primitive, via_pre_lift,
1296 "corner `{key}` must round-trip through both shapes",
1297 );
1298 }
1299 }
1300
1301 #[test]
1302 fn get_str_composes_with_ok_or_else_at_from_json_shape() {
1303 // Downstream composition pin: the canonical caller shape at
1304 // `RenderedResourceCoords::from_json` is
1305 // `<manifest>.get_str(<key>).ok_or_else(|| anyhow!("rendered
1306 // resource missing X"))?.to_string()`. A regression that
1307 // reshaped the return form (an `&str` bare default, a
1308 // `Result<&str, _>` fallible arm) would break this
1309 // composition. Additionally sweeps the peer
1310 // `.map(String::from)` / `.map(str::to_string)` optional-slot
1311 // arm the `namespace` slot uses.
1312 let manifest = json!({ "apiVersion": "v1" });
1313 let ok_arm: String = manifest
1314 .get_str("apiVersion")
1315 .ok_or_else(|| anyhow::anyhow!("missing"))
1316 .unwrap()
1317 .to_string();
1318 assert_eq!(ok_arm, "v1");
1319 let err_arm = manifest
1320 .get_str("kind")
1321 .ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))
1322 .unwrap_err();
1323 assert_eq!(format!("{err_arm}"), "rendered resource missing kind");
1324 let opt_present: Option<String> = manifest.get_str("apiVersion").map(str::to_string);
1325 assert_eq!(opt_present.as_deref(), Some("v1"));
1326 let opt_absent: Option<String> = manifest.get_str("kind").map(String::from);
1327 assert!(opt_absent.is_none());
1328 }
1329
1330 #[test]
1331 fn get_str_return_lifetime_borrows_receiver_not_owned() {
1332 // Return-lifetime pin: the `&str` MUST borrow the receiver's
1333 // buffer rather than a fresh owned `String`. A regression that
1334 // reshaped the return to `Option<String>` (adding a
1335 // `to_string()` inside the primitive) would inflate every
1336 // callsite's allocation count and break `metadata.and_then(|m|
1337 // m.get_str("name"))`'s per-lookup zero-alloc guarantee. Bind
1338 // the invariant structurally: the borrow reaches back through
1339 // the receiver.
1340 let manifest = json!({ "apiVersion": "helm.toolkit.fluxcd.io/v2" });
1341 let s: &str = manifest.get_str("apiVersion").unwrap();
1342 let raw: &str = manifest.get("apiVersion").and_then(|v| v.as_str()).unwrap();
1343 assert!(std::ptr::eq(s.as_ptr(), raw.as_ptr()));
1344 }
1345
1346 #[test]
1347 fn get_str_axis_family_reaches_i64_and_str_through_one_trait_import() {
1348 // Axis-family pin: a caller who imports `ValueGetExt` reaches
1349 // BOTH the string axis (`get_str`) and the integer axis
1350 // (`get_i64`) through the SAME trait handle. A regression that
1351 // opened a peer `ValueGetStrExt` (or a peer trait per axis)
1352 // would break this — the caller would have to import each
1353 // trait separately and a partial import would silently miss
1354 // one axis at method-resolution time.
1355 //
1356 // Structurally: a bound `T: ValueGetExt` reaches both methods.
1357 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>) {
1358 (t.get_i64("n"), t.get_str("s"))
1359 }
1360 let mixed = json!({ "n": 7, "s": "hello" });
1361 let (n, s) = probe(&mixed);
1362 assert_eq!(n, Some(7));
1363 assert_eq!(s, Some("hello"));
1364 }
1365}