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}