Skip to main content

engenho_diff/
verdict.rs

1//! The typed verdict + divergence taxonomy.
2//!
3//! [`Verdict::Parity`] is **proof-by-absence**: its payload is a
4//! [`ParityWitness`] whose only field is private, so NO code outside this
5//! crate can construct a `Parity`. The single way to obtain one is the
6//! differ ([`crate::cotejo`]) returning it on an EMPTY divergence set —
7//! a fabricated "they agree" has no code path (★★ UNREPRESENTABILITY,
8//! the truly-unrepresentable tier for external callers).
9
10use std::fmt::{self, Write as _};
11
12use serde_json::Value;
13
14use crate::observe::HttpMethod;
15use crate::path::JsonPath;
16
17/// Which cluster a fact belongs to.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum Side {
20    Engenho,
21    K3s,
22}
23
24impl Side {
25    /// A stable label.
26    #[must_use]
27    pub fn label(self) -> &'static str {
28        match self {
29            Self::Engenho => "engenho",
30            Self::K3s => "k3s",
31        }
32    }
33}
34
35impl fmt::Display for Side {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        write!(f, "{}", self.label())
38    }
39}
40
41/// An owned Group/Version/Resource triple (the discovery/verb surface
42/// works over dynamic strings, unlike the `&'static str`
43/// `engenho_types::kind::GroupVersionResource`).
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Gvr {
46    pub group: String,
47    pub version: String,
48    pub resource: String,
49}
50
51impl fmt::Display for Gvr {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        if self.group.is_empty() {
54            write!(f, "{}/{}", self.version, self.resource)
55        } else {
56            write!(f, "{}/{}/{}", self.group, self.version, self.resource)
57        }
58    }
59}
60
61/// A Kubernetes API verb.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum Verb {
64    Get,
65    List,
66    Create,
67    Update,
68    Patch,
69    Delete,
70    Watch,
71    DeleteCollection,
72}
73
74impl Verb {
75    /// The upstream verb label as it appears in `APIResource.verbs`.
76    #[must_use]
77    pub fn label(self) -> &'static str {
78        match self {
79            Self::Get => "get",
80            Self::List => "list",
81            Self::Create => "create",
82            Self::Update => "update",
83            Self::Patch => "patch",
84            Self::Delete => "delete",
85            Self::Watch => "watch",
86            Self::DeleteCollection => "deletecollection",
87        }
88    }
89}
90
91impl fmt::Display for Verb {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "{}", self.label())
94    }
95}
96
97/// A validation `Status.details.causes[]` entry — for shape-comparing how
98/// each side rejects an invalid object.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct StatusCause {
101    pub field: String,
102    pub reason: String,
103    pub message: String,
104}
105
106/// Whether a divergence is a hard (unmasked) mismatch or a masked one
107/// re-surfaced in strict mode so the mask stays auditable.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub enum Severity {
110    /// Survives normalization — a real conformance gap.
111    Hard,
112    /// Suppressed by a mask (legitimately-varying field). Surfaced only
113    /// in strict mode.
114    Cosmetic,
115}
116
117impl Severity {
118    #[must_use]
119    pub fn label(self) -> &'static str {
120        match self {
121            Self::Hard => "HARD",
122            Self::Cosmetic => "cosmetic",
123        }
124    }
125}
126
127/// One typed way engenho's surface diverges from k3s. Each arm is
128/// attributed to an owning engenho crate via [`Divergence::owning_crate`].
129#[derive(Clone, Debug, PartialEq)]
130pub enum Divergence {
131    /// Same operation, different HTTP status code.
132    StatusCodeDiff {
133        op: String,
134        method: HttpMethod,
135        engenho: u16,
136        k3s: u16,
137    },
138    /// A resource present on one side and absent (404) on the other.
139    MissingResource { path: JsonPath, present_on: Side },
140    /// A field present on both but with different values.
141    FieldDiff {
142        path: JsonPath,
143        engenho: Value,
144        k3s: Value,
145        severity: Severity,
146    },
147    /// A field k3s populates by default that engenho leaves unset.
148    MissingDefault {
149        path: JsonPath,
150        k3s_value: Value,
151        severity: Severity,
152    },
153    /// A field engenho adds that k3s does not carry.
154    ExtraField {
155        path: JsonPath,
156        side: Side,
157        value: Value,
158        severity: Severity,
159    },
160    /// A verb one side supports on a resource and the other refuses.
161    MissingVerb {
162        gvr: Gvr,
163        verb: Verb,
164        present_on: Side,
165    },
166    /// A subresource (`/status`, `/scale`, `/log`) one side exposes.
167    MissingSubresource {
168        gvr: Gvr,
169        sub: String,
170        present_on: Side,
171    },
172    /// The two sides reject an invalid object with different cause shapes.
173    ValidationShapeDiff {
174        engenho: StatusCause,
175        k3s: StatusCause,
176    },
177    /// The discovery surface (`/api/v1`, `/apis/...`) differs.
178    DiscoveryDiff {
179        group: String,
180        version: String,
181        detail: String,
182    },
183    /// A watch stream emitted a differing event at `index`.
184    WatchEventDiff { index: usize, detail: String },
185}
186
187impl Divergence {
188    /// The severity of this divergence (arms without an explicit severity
189    /// slot are always hard).
190    #[must_use]
191    pub fn severity(&self) -> Severity {
192        match self {
193            Self::FieldDiff { severity, .. }
194            | Self::MissingDefault { severity, .. }
195            | Self::ExtraField { severity, .. } => *severity,
196            _ => Severity::Hard,
197        }
198    }
199
200    /// The engenho crate that owns the fix for this divergence — the
201    /// D-matrix attribution. Lets the report route each gap to its owner
202    /// mechanically instead of by prose.
203    #[must_use]
204    pub fn owning_crate(&self) -> &'static str {
205        match self {
206            // A refused verb / status mismatch is the router + handler
207            // wiring (and the store command it dispatches).
208            Self::StatusCodeDiff { .. } | Self::MissingVerb { .. } => "engenho-apiserver",
209            // A resource missing on engenho's side is either an
210            // uncataloged kind (apiserver) or an un-run controller that
211            // would have auto-created it.
212            Self::MissingResource { .. } => "engenho-apiserver / engenho-controllers",
213            // Server-defaulted fields (namespace finalizers, auto-labels,
214            // status seeding) are stamped by the create handler / a
215            // controller.
216            Self::MissingDefault { .. } => {
217                "engenho-apiserver (handler defaulting) / engenho-controllers"
218            }
219            // An extra field engenho adds is the handler's stamping path.
220            Self::ExtraField { .. } => "engenho-apiserver (handler stamping)",
221            // A value mismatch on a shared field is the store round-trip /
222            // handler.
223            Self::FieldDiff { .. } => "engenho-apiserver / engenho-store",
224            // Subresource wiring is the router.
225            Self::MissingSubresource { .. } => "engenho-apiserver (router subresources)",
226            // Validation shape is admission.
227            Self::ValidationShapeDiff { .. } => "engenho-apiserver (admission/validation)",
228            // Discovery is the discovery module.
229            Self::DiscoveryDiff { .. } => "engenho-apiserver (discovery.rs)",
230            // Watch framing is the store watch + router.
231            Self::WatchEventDiff { .. } => "engenho-store (watch) / engenho-apiserver",
232        }
233    }
234
235    /// Re-root a path-carrying divergence under `focus` — used by the
236    /// differ's FOCUS mode so a divergence found within a focused subtree
237    /// reports (and ratchets) at its TRUE location (`focus + inner-path`),
238    /// not at the subtree-local `.`. Arms without a [`JsonPath`] are returned
239    /// unchanged.
240    #[must_use]
241    pub fn reroot(self, focus: &JsonPath) -> Self {
242        match self {
243            Self::MissingResource { path, present_on } => Self::MissingResource {
244                path: focus.concat(&path),
245                present_on,
246            },
247            Self::FieldDiff {
248                path,
249                engenho,
250                k3s,
251                severity,
252            } => Self::FieldDiff {
253                path: focus.concat(&path),
254                engenho,
255                k3s,
256                severity,
257            },
258            Self::MissingDefault {
259                path,
260                k3s_value,
261                severity,
262            } => Self::MissingDefault {
263                path: focus.concat(&path),
264                k3s_value,
265                severity,
266            },
267            Self::ExtraField {
268                path,
269                side,
270                value,
271                severity,
272            } => Self::ExtraField {
273                path: focus.concat(&path),
274                side,
275                value,
276                severity,
277            },
278            other => other,
279        }
280    }
281
282    /// A stable signature — the divergence's *location* + *kind*,
283    /// independent of the concrete values or severity. This is the ratchet
284    /// key: a baseline records signatures, and the test fails only when a
285    /// signature NOT in the baseline appears. Built via `write!` (the
286    /// sanctioned emission macro), never `format!()` of free text.
287    #[must_use]
288    pub fn signature(&self) -> String {
289        let mut s = String::new();
290        // `write!` into a String is infallible for these arms.
291        let _ = match self {
292            Self::StatusCodeDiff { op, method, .. } => {
293                write!(s, "StatusCodeDiff:{op}:{}", method.label())
294            }
295            Self::MissingResource { path, present_on } => {
296                write!(s, "MissingResource:{path}:{present_on}")
297            }
298            Self::FieldDiff { path, .. } => write!(s, "FieldDiff:{path}"),
299            Self::MissingDefault { path, .. } => write!(s, "MissingDefault:{path}"),
300            Self::ExtraField { path, side, .. } => write!(s, "ExtraField:{path}:{side}"),
301            Self::MissingVerb {
302                gvr,
303                verb,
304                present_on,
305            } => write!(s, "MissingVerb:{gvr}:{verb}:{present_on}"),
306            Self::MissingSubresource {
307                gvr,
308                sub,
309                present_on,
310            } => write!(s, "MissingSubresource:{gvr}:{sub}:{present_on}"),
311            Self::ValidationShapeDiff { .. } => write!(s, "ValidationShapeDiff"),
312            Self::DiscoveryDiff {
313                group,
314                version,
315                detail,
316            } => write!(s, "DiscoveryDiff:{group}/{version}:{detail}"),
317            Self::WatchEventDiff { index, .. } => write!(s, "WatchEventDiff:{index}"),
318        };
319        s
320    }
321}
322
323impl fmt::Display for Divergence {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        let sev = self.severity().label();
326        match self {
327            Self::StatusCodeDiff {
328                op,
329                method,
330                engenho,
331                k3s,
332            } => write!(
333                f,
334                "[{sev}] StatusCodeDiff {op} ({}): engenho={engenho} k3s={k3s}",
335                method.label()
336            ),
337            Self::MissingResource { path, present_on } => {
338                write!(
339                    f,
340                    "[{sev}] MissingResource {path}: present only on {present_on}"
341                )
342            }
343            Self::FieldDiff {
344                path, engenho, k3s, ..
345            } => write!(f, "[{sev}] FieldDiff {path}: engenho={engenho} k3s={k3s}"),
346            Self::MissingDefault {
347                path, k3s_value, ..
348            } => write!(
349                f,
350                "[{sev}] MissingDefault {path}: k3s sets {k3s_value}, engenho unset"
351            ),
352            Self::ExtraField {
353                path, side, value, ..
354            } => write!(f, "[{sev}] ExtraField {path}: {side} adds {value}"),
355            Self::MissingVerb {
356                gvr,
357                verb,
358                present_on,
359            } => write!(
360                f,
361                "[{sev}] MissingVerb {gvr} verb={verb}: present only on {present_on}"
362            ),
363            Self::MissingSubresource {
364                gvr,
365                sub,
366                present_on,
367            } => write!(
368                f,
369                "[{sev}] MissingSubresource {gvr}/{sub}: present only on {present_on}"
370            ),
371            Self::ValidationShapeDiff { engenho, k3s } => write!(
372                f,
373                "[{sev}] ValidationShapeDiff: engenho={engenho:?} k3s={k3s:?}"
374            ),
375            Self::DiscoveryDiff {
376                group,
377                version,
378                detail,
379            } => write!(f, "[{sev}] DiscoveryDiff {group}/{version}: {detail}"),
380            Self::WatchEventDiff { index, detail } => {
381                write!(f, "[{sev}] WatchEventDiff #{index}: {detail}")
382            }
383        }
384    }
385}
386
387/// Proof-by-absence witness for [`Verdict::Parity`]. The `()` field is
388/// private, so a `Parity` cannot be constructed outside this crate — the
389/// only producer is [`crate::cotejo`] on an empty divergence set.
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub struct ParityWitness(());
392
393impl ParityWitness {
394    /// Crate-internal constructor — the differ's sole path to Parity.
395    pub(crate) fn new() -> Self {
396        Self(())
397    }
398}
399
400/// The result of diffing one operation across both targets.
401#[derive(Clone, Debug, PartialEq)]
402pub enum Verdict {
403    /// The two sides agreed (no divergences). Proof-by-absence.
404    Parity(ParityWitness),
405    /// The two sides diverged in the listed ways.
406    Divergent(Vec<Divergence>),
407    /// The k3s reference oracle could not be reached — fail loud, never
408    /// a silent skip.
409    ReferenceUnreachable,
410}
411
412impl Verdict {
413    /// `true` iff this is a `Parity`.
414    #[must_use]
415    pub fn is_parity(&self) -> bool {
416        matches!(self, Self::Parity(_))
417    }
418
419    /// The divergences, or an empty slice for `Parity` / `ReferenceUnreachable`.
420    #[must_use]
421    pub fn divergences(&self) -> &[Divergence] {
422        match self {
423            Self::Divergent(d) => d,
424            _ => &[],
425        }
426    }
427}