Skip to main content

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}