Skip to main content

pjson_rs_domain/value_objects/
json_path.rs

1//! Canonical JSON Path value object for addressing nodes in JSON structures.
2//!
3//! This is the single `JsonPath` type shared across the domain, application,
4//! and infrastructure layers (issue #379 consolidated two divergent copies).
5//! A path is a segmented sequence (`Vec<PathSegment>`); the root path is the
6//! empty sequence. `Display`/`FromStr` render/parse the JSONPath-like textual
7//! form (`$.key[0]`) and are also used for serde, so the textual form is the
8//! wire format.
9
10use crate::{DomainError, DomainResult};
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::fmt;
13use std::str::FromStr;
14
15/// Type-safe JSON path for addressing nodes in JSON structures.
16///
17/// Represented as a sequence of [`PathSegment`]s; the root path (`$`) is the
18/// empty sequence. Every constructor funnels object keys through the same
19/// validation rule (see `validate_key`), so a `JsonPath` can never contain
20/// a key that would make `Display`/`FromStr` ambiguous — see the invariant
21/// documented on [`JsonPath`]'s `Display` impl (JP-1).
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct JsonPath {
24    segments: Vec<PathSegment>,
25}
26
27/// Single segment of a [`JsonPath`].
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29#[non_exhaustive]
30pub enum PathSegment {
31    /// Object property key.
32    Key(String),
33    /// Array index.
34    Index(usize),
35}
36
37/// A key is valid iff it is non-empty and contains none of `.`, `[`, `]`.
38///
39/// This single predicate backs `append_key`, `new`/`FromStr`, and
40/// `from_segments`, so the three constructors can never disagree about what
41/// a valid key looks like. It is intentionally Unicode-aware (any non-empty,
42/// delimiter-free `String` is accepted) rather than restricted to ASCII
43/// alphanumerics.
44fn validate_key(key: &str) -> DomainResult<()> {
45    if key.is_empty() {
46        return Err(DomainError::InvalidPath("Key cannot be empty".to_string()));
47    }
48    if key.contains('.') || key.contains('[') || key.contains(']') {
49        return Err(DomainError::InvalidPath(format!(
50            "Key '{key}' contains invalid characters"
51        )));
52    }
53    Ok(())
54}
55
56impl JsonPath {
57    /// Create the root path (`$`), i.e. the empty segment sequence.
58    pub fn root() -> Self {
59        Self {
60            segments: Vec::new(),
61        }
62    }
63
64    /// Parse a JSON path from its textual form (e.g. `"$.users[0].name"`).
65    ///
66    /// # Examples
67    /// ```
68    /// use pjson_rs_domain::value_objects::JsonPath;
69    ///
70    /// let path = JsonPath::new("$.users[0].name").unwrap();
71    /// assert_eq!(path.depth(), 3);
72    /// assert_eq!(path.to_string(), "$.users[0].name");
73    ///
74    /// assert!(JsonPath::new("$.key[not_a_number]").is_err());
75    /// ```
76    pub fn new(path: impl Into<String>) -> DomainResult<Self> {
77        path.into().parse()
78    }
79
80    /// Build a path directly from segments, validating every [`PathSegment::Key`]
81    /// with the same rule as [`JsonPath::append_key`].
82    ///
83    /// # Examples
84    /// ```
85    /// use pjson_rs_domain::value_objects::{JsonPath, PathSegment};
86    ///
87    /// let path = JsonPath::from_segments(vec![
88    ///     PathSegment::Key("users".to_string()),
89    ///     PathSegment::Index(0),
90    /// ])
91    /// .unwrap();
92    /// assert_eq!(path.to_string(), "$.users[0]");
93    ///
94    /// // A key containing a delimiter is rejected, just like `append_key`.
95    /// let invalid = JsonPath::from_segments(vec![PathSegment::Key("a.b".to_string())]);
96    /// assert!(invalid.is_err());
97    /// ```
98    pub fn from_segments(segments: impl IntoIterator<Item = PathSegment>) -> DomainResult<Self> {
99        let segments: Vec<PathSegment> = segments.into_iter().collect();
100        for segment in &segments {
101            if let PathSegment::Key(key) = segment {
102                validate_key(key)?;
103            }
104        }
105        Ok(Self { segments })
106    }
107
108    /// Append a key segment, producing a new path.
109    ///
110    /// # Examples
111    /// ```
112    /// use pjson_rs_domain::value_objects::JsonPath;
113    ///
114    /// let path = JsonPath::root().append_key("users").unwrap();
115    /// assert_eq!(path.to_string(), "$.users");
116    ///
117    /// // Keys containing '.', '[', ']', or the empty key are rejected.
118    /// assert!(JsonPath::root().append_key("").is_err());
119    /// assert!(JsonPath::root().append_key("a.b").is_err());
120    /// ```
121    pub fn append_key(&self, key: &str) -> DomainResult<Self> {
122        validate_key(key)?;
123        let mut segments = self.segments.clone();
124        segments.push(PathSegment::Key(key.to_string()));
125        Ok(Self { segments })
126    }
127
128    /// Append an array index segment, producing a new path.
129    pub fn append_index(&self, index: usize) -> Self {
130        let mut segments = self.segments.clone();
131        segments.push(PathSegment::Index(index));
132        Self { segments }
133    }
134
135    /// Borrow the path's segments.
136    pub fn segments(&self) -> &[PathSegment] {
137        &self.segments
138    }
139
140    /// Number of segments in the path (`0` for root).
141    pub fn depth(&self) -> usize {
142        self.segments.len()
143    }
144
145    /// Get the parent path, or `None` if this is the root.
146    ///
147    /// O(1) on the segmented representation. This corrects a bug in the
148    /// previous string-based implementation, which returned root for any
149    /// path ending in an index segment following a key (e.g. `$.users[0]`
150    /// incorrectly produced `$` instead of `$.users`).
151    pub fn parent(&self) -> Option<Self> {
152        if self.segments.is_empty() {
153            return None;
154        }
155        Some(Self {
156            segments: self.segments[..self.segments.len() - 1].to_vec(),
157        })
158    }
159
160    /// Get the last segment of the path, or `None` at root.
161    ///
162    /// Distinct from [`JsonPath::last_key`]: this returns the literal final
163    /// segment, whether it is a key or an index.
164    pub fn last_segment(&self) -> Option<&PathSegment> {
165        self.segments.last()
166    }
167
168    /// Get the last `Key` segment, skipping any trailing `Index` segments.
169    ///
170    /// Distinct from [`JsonPath::last_segment`]: for `$.arr[5]` this returns
171    /// `Some("arr")`, not `None`. Preserves the WASM/HTTP priority-heuristic
172    /// parity fixed in #242 — do not conflate the two methods.
173    pub fn last_key(&self) -> Option<&str> {
174        self.segments
175            .iter()
176            .rev()
177            .find_map(|segment| match segment {
178                PathSegment::Key(key) => Some(key.as_str()),
179                PathSegment::Index(_) => None,
180            })
181    }
182
183    /// Check whether `self` is a strict prefix of `other` (self-prefix is `false`).
184    pub fn is_prefix_of(&self, other: &JsonPath) -> bool {
185        self.segments.len() < other.segments.len() && other.segments.starts_with(&self.segments)
186    }
187
188    /// Convert to a JSON Pointer (RFC 6901) string.
189    ///
190    /// Does not escape `~` or `/` within keys; see follow-up issue for #379.
191    ///
192    /// # Examples
193    /// ```
194    /// use pjson_rs_domain::value_objects::JsonPath;
195    ///
196    /// let path = JsonPath::new("$.users[0].name").unwrap();
197    /// assert_eq!(path.to_json_pointer(), "/users/0/name");
198    /// assert_eq!(JsonPath::root().to_json_pointer(), "/");
199    /// ```
200    pub fn to_json_pointer(&self) -> String {
201        if self.segments.is_empty() {
202            return "/".to_string();
203        }
204        let mut pointer = String::new();
205        for segment in &self.segments {
206            pointer.push('/');
207            match segment {
208                PathSegment::Key(key) => pointer.push_str(key),
209                PathSegment::Index(idx) => pointer.push_str(&idx.to_string()),
210            }
211        }
212        pointer
213    }
214}
215
216/// **INVARIANT (JP-1):** `Display` is injective and total over representable
217/// `JsonPath` values. It holds *only because* key validation (`validate_key`)
218/// excludes `.`, `[`, `]`, and the empty key: those delimiters cannot occur
219/// inside a valid key, so the boundary between a key and the next segment
220/// marker is always unambiguous, and every rendered path re-parses via
221/// [`FromStr`] to the same segments. Any future change that widens the key
222/// alphabet to admit `.`, `[`, `]`, or the empty string **must** add an
223/// escaping grammar (e.g. bracket-quote form with backslash-escaping) and a
224/// round-trip proptest in the same change, or `Display`/`FromStr` become a
225/// path-forgery primitive (see issue #333).
226impl fmt::Display for JsonPath {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(f, "$")?;
229        for segment in &self.segments {
230            match segment {
231                PathSegment::Key(key) => write!(f, ".{key}")?,
232                PathSegment::Index(index) => write!(f, "[{index}]")?,
233            }
234        }
235        Ok(())
236    }
237}
238
239/// Parses the textual form produced by [`JsonPath`]'s `Display` impl.
240/// See the injectivity/totality invariant documented there (JP-1).
241impl FromStr for JsonPath {
242    type Err = DomainError;
243
244    fn from_str(path: &str) -> Result<Self, Self::Err> {
245        if path.is_empty() {
246            return Err(DomainError::InvalidPath("Path cannot be empty".to_string()));
247        }
248
249        if !path.starts_with('$') {
250            return Err(DomainError::InvalidPath(
251                "Path must start with '$'".to_string(),
252            ));
253        }
254
255        if path.len() == 1 {
256            return Ok(Self::root());
257        }
258
259        let mut segments = Vec::new();
260        let mut chars = path.chars().skip(1).peekable();
261
262        while let Some(ch) = chars.next() {
263            match ch {
264                '.' => {
265                    let mut key = String::new();
266                    while let Some(&next_ch) = chars.peek() {
267                        if next_ch == '.' || next_ch == '[' {
268                            break;
269                        }
270                        key.push(next_ch);
271                        chars.next();
272                    }
273                    validate_key(&key)?;
274                    segments.push(PathSegment::Key(key));
275                }
276                '[' => {
277                    let mut index_str = String::new();
278                    for ch in chars.by_ref() {
279                        if ch == ']' {
280                            break;
281                        }
282                        index_str.push(ch);
283                    }
284
285                    if index_str.is_empty() {
286                        return Err(DomainError::InvalidPath("Empty array index".to_string()));
287                    }
288
289                    let index = index_str.parse::<usize>().map_err(|_| {
290                        DomainError::InvalidPath(format!("Invalid array index '{index_str}'"))
291                    })?;
292                    segments.push(PathSegment::Index(index));
293                }
294                _ => {
295                    return Err(DomainError::InvalidPath(format!(
296                        "Unexpected character '{ch}' in path"
297                    )));
298                }
299            }
300        }
301
302        Ok(Self { segments })
303    }
304}
305
306impl Serialize for JsonPath {
307    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
308    where
309        S: Serializer,
310    {
311        self.to_string().serialize(serializer)
312    }
313}
314
315impl<'de> Deserialize<'de> for JsonPath {
316    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317    where
318        D: Deserializer<'de>,
319    {
320        let s = String::deserialize(deserializer)?;
321        s.parse().map_err(serde::de::Error::custom)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn test_valid_paths() {
331        assert!(JsonPath::new("$").is_ok());
332        assert!(JsonPath::new("$.key").is_ok());
333        assert!(JsonPath::new("$.key.nested").is_ok());
334        assert!(JsonPath::new("$.key[0]").is_ok());
335        assert!(JsonPath::new("$.array[123].field").is_ok());
336    }
337
338    #[test]
339    fn test_invalid_paths() {
340        assert!(JsonPath::new("").is_err());
341        assert!(JsonPath::new("key").is_err());
342        assert!(JsonPath::new("$.").is_err());
343        assert!(JsonPath::new("$.key.").is_err());
344        assert!(JsonPath::new("$.key[]").is_err());
345        assert!(JsonPath::new("$.key[abc]").is_err());
346        // S7: validation is widened to match `append_key`'s rule (non-empty,
347        // no '.', '[', ']'), so spaces are now permitted in keys.
348        assert!(JsonPath::new("$.key with spaces").is_ok());
349    }
350
351    #[test]
352    fn test_path_operations() {
353        let root = JsonPath::root();
354        let path = root
355            .append_key("users")
356            .unwrap()
357            .append_index(0)
358            .append_key("name")
359            .unwrap();
360
361        assert_eq!(path.to_string(), "$.users[0].name");
362        assert_eq!(path.depth(), 3);
363    }
364
365    #[test]
366    fn test_parent_path() {
367        let path = JsonPath::new("$.users[0].name").unwrap();
368        let parent = path.parent().unwrap();
369        assert_eq!(parent.to_string(), "$.users[0]");
370
371        let root = JsonPath::root();
372        assert!(root.parent().is_none());
373    }
374
375    /// M2: pins the parent() bug fix — the previous string-based
376    /// implementation incorrectly returned root `$` for `$.users[0]`,
377    /// discarding the `users` segment.
378    #[test]
379    fn test_parent_path_after_index_preserves_key() {
380        let path = JsonPath::new("$.users[0]").unwrap();
381        let parent = path.parent().unwrap();
382        assert_eq!(parent.to_string(), "$.users");
383
384        let short = JsonPath::new("$.a").unwrap();
385        assert_eq!(short.parent().unwrap(), JsonPath::root());
386    }
387
388    #[test]
389    fn test_last_segment() {
390        let path1 = JsonPath::new("$.users").unwrap();
391        assert_eq!(
392            path1.last_segment(),
393            Some(&PathSegment::Key("users".to_string()))
394        );
395
396        let path2 = JsonPath::new("$.array[42]").unwrap();
397        assert_eq!(path2.last_segment(), Some(&PathSegment::Index(42)));
398
399        let root = JsonPath::root();
400        assert_eq!(root.last_segment(), None);
401    }
402
403    /// M5: `last_segment` and `last_key` are not interchangeable —
404    /// `last_key` skips trailing index segments.
405    #[test]
406    fn test_last_key_skips_trailing_index() {
407        let path = JsonPath::new("$.arr[5]").unwrap();
408        assert_eq!(path.last_segment(), Some(&PathSegment::Index(5)));
409        assert_eq!(path.last_key(), Some("arr"));
410    }
411
412    #[test]
413    fn test_prefix() {
414        let parent = JsonPath::new("$.users").unwrap();
415        let child = JsonPath::new("$.users.name").unwrap();
416
417        assert!(parent.is_prefix_of(&child));
418        assert!(!child.is_prefix_of(&parent));
419    }
420
421    /// M3: self-prefix must be `false` — a bare `starts_with` would wrongly
422    /// make every path a prefix of itself.
423    #[test]
424    fn test_is_prefix_of_self_is_false() {
425        let path = JsonPath::new("$.users.name").unwrap();
426        assert!(!path.is_prefix_of(&path));
427    }
428
429    #[test]
430    fn test_display_from_str_round_trip() {
431        let path = JsonPath::root()
432            .append_key("users")
433            .unwrap()
434            .append_index(0)
435            .append_key("name")
436            .unwrap();
437        let rendered = path.to_string();
438        let parsed: JsonPath = rendered.parse().unwrap();
439        assert_eq!(path, parsed);
440    }
441
442    #[test]
443    fn test_serde_round_trip() {
444        let path = JsonPath::new("$.users[0].name").unwrap();
445        let json = serde_json::to_string(&path).unwrap();
446        assert_eq!(json, "\"$.users[0].name\"");
447        let restored: JsonPath = serde_json::from_str(&json).unwrap();
448        assert_eq!(path, restored);
449    }
450
451    proptest::proptest! {
452        /// JP-1: `append_key` either rejects an arbitrary key outright, or
453        /// the resulting path round-trips exactly through `Display` ->
454        /// `FromStr`. This is the guard that fails loudly if validation is
455        /// ever widened without adding an escaping grammar.
456        #[test]
457        fn json_path_round_trips_over_arbitrary_keys(key in ".*") {
458            if let Ok(path) = JsonPath::root().append_key(&key) {
459                let rendered = path.to_string();
460                let parsed: JsonPath = rendered.parse().expect("rendered path must re-parse");
461                proptest::prop_assert_eq!(path, parsed);
462            }
463        }
464    }
465}