flow_ir_core/path.rs
1//! Typed context path (`Path`) — parsed, validated IR for the `$.a.b` /
2//! `ctx.a.b` / RFC 9535-style bracket path syntax used throughout flow.ir
3//! (`Expr::Path.at`, `Node::Fanout.bind`/`.out`, `Node::Let.at`,
4//! `Node::Try.err_at`'s inner `Expr::Path`, ...).
5//!
6//! `Path` is the single authority for path syntax: parsing happens exactly
7//! once, at [`FromStr`]/[`Deserialize`] time (parse-don't-validate). `Node`
8//! / `Expr` fields carrying a path store an already-parsed `Path`, so
9//! evaluation (`read`/`write`) walks the same segment list without ever
10//! re-parsing a string.
11//!
12//! # Syntax
13//!
14//! Every path starts with a **root token** (`$` or `ctx`) followed
15//! immediately by `.`, `[`, or end-of-string. Both root tokens are accepted
16//! by the parser; the distinction between them (read vs. write) is delegated
17//! to the caller (the surrounding `Node` field contract) rather than
18//! encoded here — e.g. `Node::Let.at` uses `ctx.`, while `Expr::Path.at`
19//! (read paths) continues to use `$.`. The [`Display`] impl round-trips the
20//! original root token verbatim, so `Path::from_str(path.to_string())`
21//! always yields an equal `Path`.
22//!
23//! - `$` / `ctx` — root path (empty segment list); [`Path::read`] returns the
24//! whole ctx, [`Path::write`] replaces it wholesale.
25//! - `$.a.b.c` / `ctx.a.b.c` — dot-separated object-key segments.
26//! - `$.a["p.md"]` / `$["x.y"]` / `ctx.a["p.md"]` / `ctx["x.y"]` — RFC 9535
27//! (JSONPath) style bracket segments for keys containing a literal `.`
28//! (double-quoted, no escape support — a key containing `"` cannot be
29//! represented in bracket form). Bracket segments may chain directly
30//! (`$.a["x"]["y"]`) or be followed by a dot segment (`$["x.y"].inner`).
31//!
32//! No array-index support (MVP scope, unchanged from the pre-`Path` parser).
33//!
34//! # Uniform rejections (all [`PathParseError`], surfaced as
35//! [`EvalError::InvalidPath`] by the `read_path` / `write_path` compat
36//! wrappers, or as a deserialize error when a `Path` field is parsed from
37//! JSON)
38//!
39//! - anything not starting with `$` or `ctx` followed immediately by `.`,
40//! `[`, or end-of-string — so `$foo`, `ctxfoo`, and `foo.bar` are all
41//! rejected. `$foo` was silently accepted in the pre-v0.2 parser; `ctxfoo`
42//! / `foo.bar` are new rejections that keep the v0.2.0 typo-suspender
43//! behavior consistent across the enlarged root-token whitelist.
44//! - any empty dot segment: `$.`, `$.a.`, `$.a..b`, `ctx.`, `ctx.a.`, ... —
45//! the pre-v0.2 write-side parser silently *dropped* empty segments
46//! (`.filter(|s| !s.is_empty())`) while the read-side parser only failed
47//! if the ctx happened to have no key `""` at that position
48//! (`EvalError::PathNotFound`, not `EvalError::InvalidPath`) — both were
49//! symptoms of the same missing parse-time check, and both are now
50//! rejected uniformly, up front.
51//! - the existing bracket-notation rejections (unterminated bracket, missing
52//! `"` after `[`, empty key, empty `[""]`, a bracket segment directly
53//! followed by an unseparated plain segment).
54
55use std::fmt;
56use std::str::FromStr;
57
58use serde::{Deserialize, Deserializer, Serialize, Serializer};
59use serde_json::Value;
60use thiserror::Error;
61
62use crate::EvalError;
63
64/// Root token of a parsed [`Path`] — either `$` (canonical read prefix) or
65/// `ctx` (canonical write prefix, e.g. `Node::Let.at`). The parser accepts
66/// both interchangeably; the surrounding `Node` field contract decides
67/// which is meaningful for a given position.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69enum Root {
70 /// `$` — canonical read-path root token (`Expr::Path.at` and every other
71 /// read position).
72 Dollar,
73 /// `ctx` — canonical write-path root token (`Node::Let.at` per the
74 /// canonical `flow.ir` schema).
75 Ctx,
76}
77
78impl Root {
79 fn as_str(self) -> &'static str {
80 match self {
81 Root::Dollar => "$",
82 Root::Ctx => "ctx",
83 }
84 }
85}
86
87/// One resolved path segment. Currently only object-key segments are
88/// supported; the variant is kept non-exhaustive-in-spirit (private, single
89/// arm) so a future `Index(usize)` (array-index support) is a pure addition.
90#[derive(Debug, Clone, PartialEq, Eq, Hash)]
91enum Segment {
92 /// An object-key segment (from either dot form or a quoted bracket
93 /// segment) — never empty (the parser rejects empty segments).
94 Key(String),
95}
96
97/// A parsed, validated context path — the canonical IR for the flow.ir
98/// `$.a.b` / `ctx.a.b` / RFC 9535-style bracket path syntax. The full
99/// syntax and rejection rules are documented on the module-level docs and
100/// on [`Path::read`] / [`Path::write`] and the `FromStr` implementation
101/// below.
102///
103/// Illegal path syntax cannot be represented by this type: the only way to
104/// construct a `Path` is [`FromStr::from_str`] (equivalently `str::parse`)
105/// or [`Deserialize`], both of which reject malformed input up front. Once
106/// you hold a `Path`, [`Path::read`] / [`Path::write`] never re-derive or
107/// re-validate the segment list.
108#[derive(Debug, Clone, PartialEq, Eq, Hash)]
109pub struct Path {
110 root: Root,
111 segments: Vec<Segment>,
112}
113
114/// Error returned by [`Path::from_str`] (and therefore surfaced through
115/// `Path`'s [`Deserialize`] impl, and through the `read_path` / `write_path`
116/// compat wrappers as [`EvalError::InvalidPath`]) on malformed path syntax.
117#[derive(Debug, Clone, PartialEq, Eq, Error)]
118#[error("invalid path syntax '{path}': {reason}")]
119pub struct PathParseError {
120 /// The original (unparseable) path string.
121 pub path: String,
122 /// Human-readable reason the path was rejected.
123 pub reason: String,
124}
125
126impl PathParseError {
127 fn new(path: &str, reason: &str) -> Self {
128 Self {
129 path: path.to_string(),
130 reason: reason.to_string(),
131 }
132 }
133}
134
135/// Try to strip the root token (`$` or `ctx`) off the head of `path`.
136///
137/// Returns `(Root, rest)` on success. `rest` is either empty or begins with
138/// `.` or `[` — a bare continuation such as `$foo` / `ctxfoo` is rejected
139/// here (before the segment parser ever sees the body), consistent with the
140/// v0.2.0 typo-suspender behavior.
141fn strip_root(path: &str) -> Result<(Root, &str), PathParseError> {
142 // Try `ctx` first (longer prefix) so that `ctx...` never gets
143 // partially matched as `$`-less garbage.
144 if let Some(rest) = path.strip_prefix("ctx") {
145 if !accepts_after_root(rest) {
146 return Err(PathParseError::new(
147 path,
148 "expected '.', '[', or end-of-string right after 'ctx'",
149 ));
150 }
151 return Ok((Root::Ctx, rest));
152 }
153 if let Some(rest) = path.strip_prefix('$') {
154 if !accepts_after_root(rest) {
155 return Err(PathParseError::new(
156 path,
157 "expected '.', '[', or end-of-string right after '$'",
158 ));
159 }
160 return Ok((Root::Dollar, rest));
161 }
162 Err(PathParseError::new(
163 path,
164 "path must start with '$' or 'ctx' followed by '.', '[', or end-of-string",
165 ))
166}
167
168/// After stripping the root token, only `.`, `[`, or EOF may follow —
169/// anything else (a bare letter, digit, etc.) means the leading segment is
170/// glued to the root token and the parser rejects it as ambiguous.
171fn accepts_after_root(rest: &str) -> bool {
172 rest.is_empty() || rest.starts_with('.') || rest.starts_with('[')
173}
174
175impl FromStr for Path {
176 type Err = PathParseError;
177
178 fn from_str(path: &str) -> Result<Self, Self::Err> {
179 let (root, rest) = strip_root(path)?;
180 if rest.is_empty() {
181 // Bare `$` / `ctx` — root path, no segments.
182 return Ok(Path {
183 root,
184 segments: Vec::new(),
185 });
186 }
187 let body = match rest.as_bytes()[0] {
188 b'.' => {
189 let after_dot = &rest[1..];
190 if after_dot.is_empty() {
191 return Err(PathParseError::new(
192 path,
193 "trailing '.' with no segment after it",
194 ));
195 }
196 after_dot
197 }
198 b'[' => rest,
199 // strip_root's accepts_after_root check guarantees the head of
200 // `rest` is `.`, `[`, or empty — anything else was rejected there.
201 _ => unreachable!("strip_root guarantees the head byte here"),
202 };
203 let segments = if body.contains('[') {
204 parse_bracket_segments(body, path)?
205 } else {
206 parse_dot_segments(body, path)?
207 };
208 Ok(Path {
209 root,
210 segments: segments.into_iter().map(Segment::Key).collect(),
211 })
212 }
213}
214
215/// Split a (already root-stripped) bracket-free body on `.`, rejecting any
216/// empty segment (leading/trailing/consecutive dots).
217fn parse_dot_segments(body: &str, original: &str) -> Result<Vec<String>, PathParseError> {
218 let mut segments = Vec::new();
219 for part in body.split('.') {
220 if part.is_empty() {
221 return Err(PathParseError::new(
222 original,
223 "empty path segment (leading, trailing, or consecutive '.')",
224 ));
225 }
226 segments.push(part.to_string());
227 }
228 Ok(segments)
229}
230
231/// Parse a (root-stripped) body containing at least one `[` into its
232/// object-key segments. Supports:
233///
234/// - plain segment: any run of chars excluding `.` and `[`, non-empty.
235/// - bracket segment: `["<name>"]`, where `<name>` is one or more chars
236/// excluding `"` (no escape support — a key containing `"` is rejected).
237/// - plain segments are `.`-separated; a bracket segment may follow
238/// directly after the previous segment (`a["x"]`) or after a `.`
239/// (`a.["x"]`), and a bracket segment may itself be followed directly by
240/// another bracket (`a["x"]["y"]`) or by a `.` before the next plain
241/// segment (`a["x"].b`).
242///
243/// Any malformed sequence (unterminated bracket, missing quote, empty key,
244/// empty segment, bracket directly followed by an unseparated plain
245/// segment, ...) raises `PathParseError` — this parser never silently
246/// misparses.
247fn parse_bracket_segments(body: &str, original: &str) -> Result<Vec<String>, PathParseError> {
248 fn invalid(original: &str, reason: &str) -> PathParseError {
249 PathParseError::new(original, reason)
250 }
251
252 let bytes = body.as_bytes();
253 let len = bytes.len();
254 let mut segments = Vec::new();
255 let mut i = 0usize;
256 // true at path start and immediately after a `.`: the next byte must
257 // begin a new segment (plain or bracket), not another `.` or EOF.
258 let mut expect_segment_start = true;
259
260 while i < len {
261 match bytes[i] {
262 b'[' => {
263 if i + 1 >= len || bytes[i + 1] != b'"' {
264 return Err(invalid(original, "expected '\"' after '['"));
265 }
266 let name_start = i + 2;
267 let mut j = name_start;
268 while j < len && bytes[j] != b'"' {
269 j += 1;
270 }
271 if j >= len {
272 return Err(invalid(original, "unterminated bracket segment"));
273 }
274 let name = &body[name_start..j];
275 if name.is_empty() {
276 return Err(invalid(original, "empty bracket key"));
277 }
278 if j + 1 >= len || bytes[j + 1] != b']' {
279 return Err(invalid(original, "missing closing ']' after key"));
280 }
281 segments.push(name.to_string());
282 i = j + 2;
283 expect_segment_start = false;
284 // Only `.` or another `[` (or EOF) may directly follow a
285 // bracket segment — a bare plain-segment continuation
286 // (`a["x"]b`) is ambiguous and rejected.
287 if i < len && bytes[i] != b'.' && bytes[i] != b'[' {
288 return Err(invalid(
289 original,
290 "expected '.' or '[' after bracket segment",
291 ));
292 }
293 }
294 b'.' => {
295 if expect_segment_start {
296 return Err(invalid(original, "empty path segment"));
297 }
298 i += 1;
299 expect_segment_start = true;
300 if i >= len {
301 return Err(invalid(original, "empty path segment"));
302 }
303 }
304 _ => {
305 let start = i;
306 while i < len && bytes[i] != b'.' && bytes[i] != b'[' {
307 i += 1;
308 }
309 segments.push(body[start..i].to_string());
310 expect_segment_start = false;
311 }
312 }
313 }
314
315 if expect_segment_start {
316 return Err(invalid(original, "empty path segment"));
317 }
318
319 Ok(segments)
320}
321
322/// A key is safe to render in dot form iff it cannot be confused with a
323/// path delimiter: no literal `.`, `[`, or `]`. Anything else (including a
324/// key containing `"`, which dot form has no trouble with) still round-trips
325/// through dot form.
326fn is_dot_safe(key: &str) -> bool {
327 !key.is_empty() && !key.contains(['.', '[', ']'])
328}
329
330impl fmt::Display for Path {
331 /// Canonical string form: the original root token (`$` or `ctx`) +
332 /// `.key` for each identifier-safe segment, `["key"]` bracket form
333 /// otherwise. `Path::from_str(path.to_string())` always re-parses to an
334 /// equal `Path` (round-trip law, including root-token preservation) —
335 /// the canonical form may normalize each *segment*'s representation
336 /// (e.g. a segment reachable only via dot form on the way in is still
337 /// rendered via dot form; a segment that required bracket form on the
338 /// way in is rendered via bracket form) without changing the parsed
339 /// segment list.
340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341 f.write_str(self.root.as_str())?;
342 for Segment::Key(key) in &self.segments {
343 if is_dot_safe(key) {
344 write!(f, ".{key}")?;
345 } else {
346 write!(f, "[\"{key}\"]")?;
347 }
348 }
349 Ok(())
350 }
351}
352
353impl Serialize for Path {
354 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
355 where
356 S: Serializer,
357 {
358 serializer.serialize_str(&self.to_string())
359 }
360}
361
362impl<'de> Deserialize<'de> for Path {
363 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364 where
365 D: Deserializer<'de>,
366 {
367 let s = String::deserialize(deserializer)?;
368 s.parse::<Path>().map_err(serde::de::Error::custom)
369 }
370}
371
372impl Path {
373 /// Read the value this path resolves to inside `ctx`.
374 ///
375 /// The root path (`$` / `ctx` with no segments) resolves to `ctx`
376 /// itself. A missing key along the way raises
377 /// [`EvalError::PathNotFound`] — malformed *syntax* is rejected earlier,
378 /// at parse time, so `read` can never raise [`EvalError::InvalidPath`].
379 pub fn read<'a>(&self, ctx: &'a Value) -> Result<&'a Value, EvalError> {
380 let mut cur = ctx;
381 for Segment::Key(key) in &self.segments {
382 cur = cur
383 .get(key)
384 .ok_or_else(|| EvalError::PathNotFound(self.to_string()))?;
385 }
386 Ok(cur)
387 }
388
389 /// Write `value` at the location this path resolves to inside `ctx`,
390 /// mutating `ctx` in place.
391 ///
392 /// The root path (`$` / `ctx` with no segments) replaces `ctx`
393 /// wholesale. Missing intermediate objects along the way are created
394 /// automatically (a `null` — or altogether absent — intermediate
395 /// promotes to an empty object, same as before this type existed). If
396 /// an intermediate segment already holds a concrete non-object value
397 /// (a string, number, bool, or array), the write is rejected with
398 /// [`EvalError::TypeError`] instead of silently clobbering it; `ctx` is
399 /// left byte-for-byte unmodified in that case (a rejected write never
400 /// partially applies, because every intermediate object promotion this
401 /// method performs only ever touches a freshly-created — previously
402 /// `null`/absent — subtree, which by construction cannot itself contain
403 /// a pre-existing conflicting value further down).
404 pub fn write(&self, ctx: &mut Value, value: Value) -> Result<(), EvalError> {
405 if self.segments.is_empty() {
406 *ctx = value;
407 return Ok(());
408 }
409 write_recursive(ctx, &self.segments, value, self)
410 }
411}
412
413fn write_recursive(
414 node: &mut Value,
415 keys: &[Segment],
416 value: Value,
417 full_path: &Path,
418) -> Result<(), EvalError> {
419 let Segment::Key(key) = &keys[0];
420 ensure_writable_object(node, full_path, key)?;
421 let obj = node
422 .as_object_mut()
423 .expect("ensure_writable_object guarantees an object here");
424 if keys.len() == 1 {
425 obj.insert(key.clone(), value);
426 Ok(())
427 } else {
428 let entry = obj.entry(key.clone()).or_insert(Value::Null);
429 write_recursive(entry, &keys[1..], value, full_path)
430 }
431}
432
433/// Ensure `node` is writable as an intermediate/leaf object slot: already an
434/// object is a no-op, `null` (missing/uninitialised) promotes to an empty
435/// object, anything else (string/number/bool/array) is rejected — it would
436/// otherwise be silently clobbered.
437fn ensure_writable_object(node: &mut Value, full_path: &Path, key: &str) -> Result<(), EvalError> {
438 if node.is_object() {
439 return Ok(());
440 }
441 if node.is_null() {
442 *node = Value::Object(serde_json::Map::new());
443 return Ok(());
444 }
445 Err(EvalError::TypeError {
446 op: "path.write".into(),
447 msg: format!(
448 "cannot write path '{full_path}' at segment '{key}': existing value at this \
449 position is not an object ({node:?})"
450 ),
451 })
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use serde_json::json;
458
459 fn path_dollar(segments: Vec<&str>) -> Path {
460 Path {
461 root: Root::Dollar,
462 segments: segments
463 .into_iter()
464 .map(|s| Segment::Key(s.to_string()))
465 .collect(),
466 }
467 }
468
469 fn path_ctx(segments: Vec<&str>) -> Path {
470 Path {
471 root: Root::Ctx,
472 segments: segments
473 .into_iter()
474 .map(|s| Segment::Key(s.to_string()))
475 .collect(),
476 }
477 }
478
479 // ── accept/reject table ────────────────────────────────────────────
480
481 #[test]
482 fn accepts_root_dollar() {
483 let p: Path = "$".parse().unwrap();
484 assert_eq!(p, path_dollar(vec![]));
485 }
486
487 #[test]
488 fn accepts_root_ctx() {
489 let p: Path = "ctx".parse().unwrap();
490 assert_eq!(p, path_ctx(vec![]));
491 }
492
493 #[test]
494 fn accepts_single_dot_segment_dollar() {
495 let p: Path = "$.a".parse().unwrap();
496 assert_eq!(p, path_dollar(vec!["a"]));
497 }
498
499 #[test]
500 fn accepts_single_dot_segment_ctx() {
501 let p: Path = "ctx.a".parse().unwrap();
502 assert_eq!(p, path_ctx(vec!["a"]));
503 }
504
505 #[test]
506 fn accepts_multi_dot_segments() {
507 let p: Path = "$.a.b".parse().unwrap();
508 assert_eq!(p, path_dollar(vec!["a", "b"]));
509 let q: Path = "ctx.a.b".parse().unwrap();
510 assert_eq!(q, path_ctx(vec!["a", "b"]));
511 }
512
513 #[test]
514 fn accepts_bracket_forms_dollar() {
515 assert!("$.a[\"p.md\"]".parse::<Path>().is_ok());
516 assert!("$[\"x.y\"]".parse::<Path>().is_ok());
517 assert!("$[\"x.y\"].inner".parse::<Path>().is_ok());
518 assert!("$.a[\"x\"][\"y\"]".parse::<Path>().is_ok());
519 }
520
521 #[test]
522 fn accepts_bracket_forms_ctx() {
523 assert!("ctx.a[\"p.md\"]".parse::<Path>().is_ok());
524 assert!("ctx[\"x.y\"]".parse::<Path>().is_ok());
525 assert!("ctx[\"x.y\"].inner".parse::<Path>().is_ok());
526 assert!("ctx.a[\"x\"][\"y\"]".parse::<Path>().is_ok());
527 }
528
529 #[test]
530 fn rejects_missing_root_token() {
531 assert!("a.b".parse::<Path>().is_err());
532 assert!("".parse::<Path>().is_err());
533 }
534
535 #[test]
536 fn rejects_dollar_foo_no_dot() {
537 // previously silently accepted as a 1-segment dot path (pre-v0.2)
538 let err = "$foo".parse::<Path>().unwrap_err();
539 assert_eq!(err.path, "$foo");
540 }
541
542 #[test]
543 fn rejects_ctxfoo_no_dot() {
544 // v0.3.0: `ctx` root token is now accepted, but a bare continuation
545 // (`ctxfoo`) is rejected on the same principle as `$foo`.
546 let err = "ctxfoo".parse::<Path>().unwrap_err();
547 assert_eq!(err.path, "ctxfoo");
548 }
549
550 #[test]
551 fn rejects_foo_bar_no_root() {
552 // Neither `$` nor `ctx` — should be rejected outright rather than
553 // reinterpreted as `$.foo.bar`.
554 assert!("foo.bar".parse::<Path>().is_err());
555 }
556
557 #[test]
558 fn rejects_trailing_dot() {
559 assert!("$.".parse::<Path>().is_err());
560 assert!("$.a.".parse::<Path>().is_err());
561 assert!("ctx.".parse::<Path>().is_err());
562 assert!("ctx.a.".parse::<Path>().is_err());
563 }
564
565 #[test]
566 fn rejects_empty_middle_segment() {
567 assert!("$.a..b".parse::<Path>().is_err());
568 assert!("ctx.a..b".parse::<Path>().is_err());
569 }
570
571 #[test]
572 fn rejects_empty_bracket_key() {
573 assert!("$.a[\"\"]".parse::<Path>().is_err());
574 assert!("$.a[]".parse::<Path>().is_err());
575 assert!("ctx.a[\"\"]".parse::<Path>().is_err());
576 }
577
578 #[test]
579 fn rejects_unterminated_bracket() {
580 assert!("$.a[".parse::<Path>().is_err());
581 assert!("$.a[\"x".parse::<Path>().is_err());
582 }
583
584 #[test]
585 fn rejects_unquoted_bracket_key() {
586 assert!("$.a[p.md]".parse::<Path>().is_err());
587 }
588
589 #[test]
590 fn rejects_unseparated_plain_suffix_after_bracket() {
591 assert!("$.a[\"x\"]b".parse::<Path>().is_err());
592 }
593
594 // ── Display round-trip ─────────────────────────────────────────────
595
596 #[test]
597 fn display_round_trip() {
598 for src in [
599 "$",
600 "$.a",
601 "$.a.b.c",
602 "$.a[\"p.md\"]",
603 "$[\"x.y\"]",
604 "$[\"x.y\"].inner",
605 "$.a[\"x\"][\"y\"]",
606 "ctx",
607 "ctx.a",
608 "ctx.a.b.c",
609 "ctx.a[\"p.md\"]",
610 "ctx[\"x.y\"]",
611 ] {
612 let parsed: Path = src.parse().unwrap();
613 let rendered = parsed.to_string();
614 let reparsed: Path = rendered.parse().unwrap_or_else(|e| {
615 panic!("canonical form '{rendered}' (from '{src}') failed to re-parse: {e}")
616 });
617 assert_eq!(
618 parsed, reparsed,
619 "round-trip mismatch for '{src}' -> '{rendered}'"
620 );
621 }
622 }
623
624 #[test]
625 fn display_preserves_root_token() {
626 let p: Path = "$.a".parse().unwrap();
627 assert_eq!(p.to_string(), "$.a");
628 let q: Path = "ctx.a".parse().unwrap();
629 assert_eq!(q.to_string(), "ctx.a");
630 }
631
632 #[test]
633 fn display_dotted_key_renders_bracket_form() {
634 let p: Path = "$.a[\"p.md\"]".parse().unwrap();
635 assert_eq!(p.to_string(), "$.a[\"p.md\"]");
636 let q: Path = "ctx.a[\"p.md\"]".parse().unwrap();
637 assert_eq!(q.to_string(), "ctx.a[\"p.md\"]");
638 }
639
640 // ── read / write ────────────────────────────────────────────────────
641
642 #[test]
643 fn read_root_returns_whole_ctx() {
644 let p: Path = "$".parse().unwrap();
645 let ctx = json!({"a": 1});
646 assert_eq!(p.read(&ctx).unwrap(), &ctx);
647 // `ctx` root token behaves identically as a *reader* (root-token
648 // meaning is delegated to the surrounding Node contract, not the
649 // parser).
650 let q: Path = "ctx".parse().unwrap();
651 assert_eq!(q.read(&ctx).unwrap(), &ctx);
652 }
653
654 #[test]
655 fn read_missing_key_errors_path_not_found() {
656 let p: Path = "$.a.missing".parse().unwrap();
657 let ctx = json!({"a": {}});
658 let err = p.read(&ctx).unwrap_err();
659 assert!(matches!(err, EvalError::PathNotFound(_)), "{err:?}");
660 }
661
662 #[test]
663 fn write_root_replaces_whole_ctx() {
664 let p: Path = "$".parse().unwrap();
665 let mut ctx = json!({"a": 1});
666 p.write(&mut ctx, json!({"b": 2})).unwrap();
667 assert_eq!(ctx, json!({"b": 2}));
668 }
669
670 #[test]
671 fn write_ctx_prefix_is_symmetric() {
672 // `ctx.foo` writes the same way `$.foo` does — the write path
673 // primitive is agnostic to the root token, only the segment list
674 // matters.
675 let p: Path = "ctx.a.b".parse().unwrap();
676 let mut ctx = json!({});
677 p.write(&mut ctx, json!(1)).unwrap();
678 assert_eq!(ctx, json!({"a": {"b": 1}}));
679 }
680
681 #[test]
682 fn write_creates_missing_intermediate_objects() {
683 let p: Path = "$.a.b.c".parse().unwrap();
684 let mut ctx = json!({});
685 p.write(&mut ctx, json!(42)).unwrap();
686 assert_eq!(ctx, json!({"a": {"b": {"c": 42}}}));
687 }
688
689 #[test]
690 fn write_clobber_of_non_object_intermediate_errors_and_leaves_ctx_unchanged() {
691 let p: Path = "$.a.b".parse().unwrap();
692 let mut ctx = json!({"a": "string"});
693 let before = ctx.clone();
694 let err = p.write(&mut ctx, json!(1)).unwrap_err();
695 assert!(matches!(err, EvalError::TypeError { .. }), "{err:?}");
696 assert_eq!(ctx, before, "ctx must be unchanged after a rejected write");
697 }
698
699 #[test]
700 fn write_top_level_non_object_ctx_errors_and_leaves_ctx_unchanged() {
701 let p: Path = "$.a".parse().unwrap();
702 let mut ctx = json!("scalar");
703 let before = ctx.clone();
704 let err = p.write(&mut ctx, json!(1)).unwrap_err();
705 assert!(matches!(err, EvalError::TypeError { .. }), "{err:?}");
706 assert_eq!(ctx, before);
707 }
708
709 #[test]
710 fn write_existing_null_intermediate_still_promotes() {
711 let p: Path = "$.a.b".parse().unwrap();
712 let mut ctx = json!({"a": null});
713 p.write(&mut ctx, json!(1)).unwrap();
714 assert_eq!(ctx, json!({"a": {"b": 1}}));
715 }
716}