Skip to main content

engenho_diff/
path.rs

1//! `JsonPath` — the typed coordinate a divergence lives at.
2//!
3//! Constructed segment-by-segment (a `Vec<PathSeg>`), NEVER built by
4//! `format!()`-ing a string. It is rendered ONLY through the [`Display`]
5//! impl — `write!()` inside a `Display` block is the sanctioned typed
6//! emission surface (★★ TYPED EMISSION). A path is therefore always a
7//! typed value, never free-form text.
8
9use std::fmt;
10
11/// One step along a JSON document.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub enum PathSeg {
14    /// An object key.
15    Key(String),
16    /// An array index.
17    Index(usize),
18    /// Every element of an array — used by masks such as
19    /// `metadata.managedFields[*].time`.
20    Wildcard,
21}
22
23/// A typed path into a JSON document.
24#[derive(Clone, Debug, PartialEq, Eq, Default)]
25pub struct JsonPath(Vec<PathSeg>);
26
27impl JsonPath {
28    /// The document root (empty path).
29    #[must_use]
30    pub fn root() -> Self {
31        Self(Vec::new())
32    }
33
34    /// The segments, in order.
35    #[must_use]
36    pub fn segs(&self) -> &[PathSeg] {
37        &self.0
38    }
39
40    /// Extend with an object key (builder style).
41    #[must_use]
42    pub fn key(mut self, k: impl Into<String>) -> Self {
43        self.0.push(PathSeg::Key(k.into()));
44        self
45    }
46
47    /// Extend with an array index (builder style).
48    #[must_use]
49    pub fn index(mut self, i: usize) -> Self {
50        self.0.push(PathSeg::Index(i));
51        self
52    }
53
54    /// Extend with a wildcard (builder style).
55    #[must_use]
56    pub fn wildcard(mut self) -> Self {
57        self.0.push(PathSeg::Wildcard);
58        self
59    }
60
61    /// Return `self` with `tail`'s segments appended — the FOCUS re-rooting
62    /// operation: a divergence found at `tail` within a focused subtree is
63    /// re-rooted at `focus.concat(tail)` so its report + ratchet signature
64    /// name the true location.
65    #[must_use]
66    pub fn concat(&self, tail: &JsonPath) -> Self {
67        let mut segs = self.0.clone();
68        segs.extend(tail.0.iter().cloned());
69        Self(segs)
70    }
71
72    /// Resolve this path against `v`, returning the subtree it points at
73    /// (or `None` when any segment is absent / mistyped). A [`PathSeg::Wildcard`]
74    /// is not resolvable to a single node, so it yields `None`.
75    ///
76    /// Used by the differ's FOCUS mode: an [`Operation`](crate::op::Operation)
77    /// carrying a `focus` path restricts the object diff to this subtree, so a
78    /// field-level invariant (e.g. "a `/status` PUT left `spec.containers[0].image`
79    /// unchanged") is compared in isolation from a kind's unrelated
80    /// server-side-defaulting divergence.
81    #[must_use]
82    pub fn get<'a>(&self, v: &'a serde_json::Value) -> Option<&'a serde_json::Value> {
83        let mut cur = v;
84        for seg in &self.0 {
85            cur = match seg {
86                PathSeg::Key(k) => cur.get(k)?,
87                PathSeg::Index(i) => cur.get(i)?,
88                PathSeg::Wildcard => return None,
89            };
90        }
91        Some(cur)
92    }
93
94    /// Parse a dotted spec into a typed path. Supports `.key`, `[*]`
95    /// (wildcard) and `[N]` (index) segments — e.g.
96    /// `"metadata.managedFields[*].time"` or `"spec.clusterIPs"`.
97    ///
98    /// This is a convenience for authoring mask paths as compact
99    /// literals; the result is a fully typed `Vec<PathSeg>`, not a
100    /// string that gets re-parsed downstream.
101    #[must_use]
102    pub fn parse(spec: &str) -> Self {
103        let mut p = Self::root();
104        for raw in spec.split('.') {
105            if raw.is_empty() {
106                continue;
107            }
108            if let Some(br_start) = raw.find('[') {
109                let name = &raw[..br_start];
110                if !name.is_empty() {
111                    p.0.push(PathSeg::Key(name.to_string()));
112                }
113                for br in raw[br_start..].split(']') {
114                    let inner = br.trim_start_matches('[');
115                    if inner.is_empty() {
116                        continue;
117                    }
118                    if inner == "*" {
119                        p.0.push(PathSeg::Wildcard);
120                    } else if let Ok(n) = inner.parse::<usize>() {
121                        p.0.push(PathSeg::Index(n));
122                    }
123                }
124            } else {
125                p.0.push(PathSeg::Key(raw.to_string()));
126            }
127        }
128        p
129    }
130}
131
132impl fmt::Display for JsonPath {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        if self.0.is_empty() {
135            return write!(f, ".");
136        }
137        for seg in &self.0 {
138            match seg {
139                PathSeg::Key(k) => write!(f, ".{k}")?,
140                PathSeg::Index(i) => write!(f, "[{i}]")?,
141                PathSeg::Wildcard => write!(f, "[*]")?,
142            }
143        }
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn builder_and_display_round_trip() {
154        let p = JsonPath::root().key("metadata").key("name");
155        assert_eq!(p.to_string(), ".metadata.name");
156        assert_eq!(JsonPath::root().to_string(), ".");
157        let idx = JsonPath::root().key("items").index(0).key("uid");
158        assert_eq!(idx.to_string(), ".items[0].uid");
159    }
160
161    #[test]
162    fn parse_handles_wildcard_and_index() {
163        let p = JsonPath::parse("metadata.managedFields[*].time");
164        assert_eq!(
165            p.segs(),
166            &[
167                PathSeg::Key("metadata".into()),
168                PathSeg::Key("managedFields".into()),
169                PathSeg::Wildcard,
170                PathSeg::Key("time".into()),
171            ]
172        );
173        assert_eq!(p.to_string(), ".metadata.managedFields[*].time");
174
175        let idx = JsonPath::parse("spec.containers[0].image");
176        assert_eq!(idx.to_string(), ".spec.containers[0].image");
177    }
178}