Skip to main content

pakx_core/manifest/
path.rs

1//! Dot-path access into the raw YAML tree backing `agents.yml`.
2//!
3//! Companion to [`crate::manifest::mutate`]: where that module rewrites
4//! the **typed** [`crate::manifest::Manifest`] (with full schema
5//! validation), this module operates on `serde_yaml_ng::Value` so the
6//! `pakx manifest get/set/delete` surface can address arbitrary fields
7//! — including ones the typed schema doesn't model (e.g. unknown keys,
8//! forward-compat fields, the per-version `sponsors` list once a
9//! manifest grows one). `pakx manifest set` is a pure-text mutator by
10//! design: schema validation happens at `pakx pack` / `pakx test`
11//! time, not here.
12//!
13//! Path syntax mirrors `npm pkg get/set/delete`:
14//!   - `description` — top-level key
15//!   - `dependencies.skills` — nested key
16//!   - `dependencies.skills[0]` — first array element
17//!   - `dependencies.mcp[1].agents` — keys + indices interleave freely
18//!
19//! Caveats locked in until v1:
20//!   - YAML round-tripping does NOT preserve comments. The `serde_yaml_ng`
21//!     loader drops them at parse time, so `pakx manifest set` will
22//!     strip any comments the source carried. The `manifest` subcommand
23//!     surfaces this in its help text.
24//!   - Negative indices are not supported (`npm pkg` rejects them too).
25
26use serde_yaml_ng::Value;
27
28/// One step in a parsed path. Either a YAML mapping key or a sequence
29/// index. Indices may only appear after a sequence-valued parent;
30/// validation happens at `apply` time, not at parse time.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathSeg {
33    /// `foo`, `bar` — a YAML mapping key.
34    Key(String),
35    /// `[N]` — a YAML sequence index. Stored as `usize` so the
36    /// callers (get/set/delete) never have to range-check at use site;
37    /// negative indices are rejected in [`parse_path`].
38    Index(usize),
39}
40
41/// Failure cases for [`parse_path`] and the apply helpers. Each variant
42/// carries a short, user-facing message so the CLI can render the same
43/// diagnostic across get/set/delete without reformatting.
44#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum PathError {
46    /// Empty input — `pakx manifest get ""` and friends.
47    #[error("manifest path must not be empty")]
48    Empty,
49    /// Malformed bracket syntax: missing closer, non-numeric body, or
50    /// the bracket opening before a key segment (e.g. `[0].name` is
51    /// allowed and means "index into the top-level sequence", but the
52    /// **top-level** is always a mapping in `agents.yml` so this is
53    /// still rejected at the apply layer rather than the parser).
54    #[error("invalid path segment near `{0}`")]
55    BadSegment(String),
56    /// Caller asked to descend through a scalar (`description.foo`).
57    /// The set/delete paths can't intuit what to overwrite, so we bail
58    /// rather than silently clobbering.
59    #[error("cannot descend into scalar at `{0}`")]
60    DescendScalar(String),
61    /// Index out of bounds for the sequence at this point in the path.
62    /// Surfaced for get/delete; set may extend a sequence by exactly
63    /// one (push-on-end) and only raises this for any further gap.
64    #[error("index {index} out of bounds (length {len}) at `{at}`")]
65    IndexOutOfBounds {
66        index: usize,
67        len: usize,
68        at: String,
69    },
70    /// A key segment was applied to a sequence (`skills.0` instead of
71    /// `skills[0]`). Surfaced explicitly so the user knows to use
72    /// bracket syntax for indexing rather than guessing.
73    #[error("expected sequence index `[N]` but got key `{key}` at `{at}`")]
74    KeyOnSequence { key: String, at: String },
75    /// An index segment was applied to a mapping. Mirror of
76    /// `KeyOnSequence` for the opposite mismatch.
77    #[error("expected mapping key but got index `[{index}]` at `{at}`")]
78    IndexOnMapping { index: usize, at: String },
79}
80
81/// Outcome of [`delete_value`]. The CLI uses this to differentiate
82/// "removed something" (silent success) from "nothing to remove"
83/// (idempotent warning on stderr; exit 0).
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum DeleteOutcome {
86    /// The path resolved to a real entry that was removed.
87    Removed,
88    /// The path didn't resolve to anything. Caller chooses how to
89    /// surface the no-op.
90    NotPresent,
91}
92
93/// Parse a dot-path with optional `[N]` indices into a segment list.
94///
95/// Returns an error for empty input, malformed brackets, or negative
96/// indices. Does NOT consult the manifest tree — semantic validation
97/// (e.g. "this key exists") happens in [`get_value`] / [`set_value`] /
98/// [`delete_value`].
99pub fn parse_path(raw: &str) -> Result<Vec<PathSeg>, PathError> {
100    if raw.is_empty() {
101        return Err(PathError::Empty);
102    }
103    let mut segments: Vec<PathSeg> = Vec::new();
104    let mut buf = String::new();
105    let mut chars = raw.chars().peekable();
106    while let Some(c) = chars.next() {
107        match c {
108            '.' => {
109                // A `.` either finishes a key segment or separates an
110                // index segment from the next key (`mcp[1].agents`).
111                // Empty buf is only legal immediately after a `]`
112                // segment — guard against double-dot / leading-dot.
113                if buf.is_empty() {
114                    if !matches!(segments.last(), Some(PathSeg::Index(_))) {
115                        return Err(PathError::BadSegment(raw.to_owned()));
116                    }
117                    continue;
118                }
119                segments.push(PathSeg::Key(std::mem::take(&mut buf)));
120            }
121            '[' => {
122                // Close out any pending key first.
123                if !buf.is_empty() {
124                    segments.push(PathSeg::Key(std::mem::take(&mut buf)));
125                }
126                // Collect digits until `]`.
127                let mut num = String::new();
128                let mut closed = false;
129                for d in chars.by_ref() {
130                    if d == ']' {
131                        closed = true;
132                        break;
133                    }
134                    num.push(d);
135                }
136                if !closed || num.is_empty() {
137                    return Err(PathError::BadSegment(raw.to_owned()));
138                }
139                let idx: usize = num
140                    .parse()
141                    .map_err(|_| PathError::BadSegment(raw.to_owned()))?;
142                segments.push(PathSeg::Index(idx));
143                // After `]` we expect either end-of-input, a `.`, or
144                // another `[`. Anything else (e.g. `[0]foo`) is
145                // malformed.
146                if let Some(&peek) = chars.peek() {
147                    if peek != '.' && peek != '[' {
148                        return Err(PathError::BadSegment(raw.to_owned()));
149                    }
150                }
151            }
152            ']' => {
153                // A bare `]` without a matching `[`.
154                return Err(PathError::BadSegment(raw.to_owned()));
155            }
156            other => buf.push(other),
157        }
158    }
159    if !buf.is_empty() {
160        segments.push(PathSeg::Key(buf));
161    }
162    if segments.is_empty() {
163        return Err(PathError::Empty);
164    }
165    Ok(segments)
166}
167
168/// Resolve `path` against `root`.
169///
170/// Returns `None` if any segment doesn't exist — the no-error
171/// "missing" case the CLI maps to exit 1 (or `null` under `--json`).
172/// Real malformed-path errors surface from [`parse_path`] before we
173/// ever get here.
174#[must_use]
175pub fn get_value<'a>(root: &'a Value, path: &[PathSeg]) -> Option<&'a Value> {
176    let mut cur = root;
177    for seg in path {
178        match seg {
179            PathSeg::Key(k) => {
180                cur = cur.as_mapping()?.get(Value::String(k.clone()))?;
181            }
182            PathSeg::Index(i) => {
183                cur = cur.as_sequence()?.get(*i)?;
184            }
185        }
186    }
187    Some(cur)
188}
189
190/// Resolve `path` against a `serde_json::Value` tree.
191///
192/// Mirror of [`get_value`] for JSON shapes — the `pakx info <id>
193/// <field>` field-query path walks the registry's JSON response, not
194/// `agents.yml`. Path syntax and semantics are identical (segments
195/// reuse [`PathSeg`] and [`parse_path`]); the only difference is the
196/// underlying value type.
197///
198/// Returns `None` for any missing segment (out-of-bounds index, absent
199/// key, or descending through a scalar) — the caller (CLI field-query
200/// surface) maps that to exit 1 + a `null` stdout under `--json`.
201#[must_use]
202pub fn get_value_json<'a>(
203    root: &'a serde_json::Value,
204    path: &[PathSeg],
205) -> Option<&'a serde_json::Value> {
206    let mut cur = root;
207    for seg in path {
208        match seg {
209            PathSeg::Key(k) => {
210                cur = cur.as_object()?.get(k)?;
211            }
212            PathSeg::Index(i) => {
213                cur = cur.as_array()?.get(*i)?;
214            }
215        }
216    }
217    Some(cur)
218}
219
220/// Write `value` at `path` inside `root`. Creates intermediate
221/// mappings as needed; refuses to fabricate intermediate sequences
222/// (the user must point at an existing sequence with `[N]` syntax).
223///
224/// Setting an index `N` where `N == len` pushes onto the end of an
225/// existing sequence. Setting `N > len` is rejected with
226/// [`PathError::IndexOutOfBounds`] — `npm pkg set` behaves the same
227/// way and the alternative (auto-padding with nulls) breeds invalid
228/// manifests.
229pub fn set_value(root: &mut Value, path: &[PathSeg], value: Value) -> Result<(), PathError> {
230    if path.is_empty() {
231        return Err(PathError::Empty);
232    }
233    set_inner(root, path, value, &mut String::new())
234}
235
236fn set_inner(
237    cur: &mut Value,
238    path: &[PathSeg],
239    value: Value,
240    breadcrumb: &mut String,
241) -> Result<(), PathError> {
242    let (head, tail) = path.split_first().expect("non-empty checked by caller");
243    let is_last = tail.is_empty();
244    match head {
245        PathSeg::Key(k) => {
246            push_crumb(breadcrumb, head);
247            // Replace a `Null` parent with a fresh mapping so newly
248            // initialised manifests (or freshly-set deep paths) don't
249            // require manual scaffolding. Refusing the conversion
250            // would force the user to set every intermediate key
251            // individually, which is exactly the friction `pakx
252            // manifest set` is meant to remove.
253            if cur.is_null() {
254                *cur = Value::Mapping(serde_yaml_ng::Mapping::new());
255            }
256            // Snapshot the kind before taking the `&mut Mapping` so
257            // the error-builder closure can read it without overlapping
258            // borrows.
259            let is_sequence = cur.is_sequence();
260            let Some(map) = cur.as_mapping_mut() else {
261                return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
262            };
263            if is_last {
264                map.insert(Value::String(k.clone()), value);
265                return Ok(());
266            }
267            // Recurse — create the right empty intermediate based on
268            // the **next** segment so deep paths work on a fresh
269            // manifest. If the next segment is an `Index`, scaffold
270            // an empty sequence; otherwise (the next segment is a
271            // `Key`) scaffold an empty mapping. Always scaffolding a
272            // mapping would force a follow-up `Index` recursion to
273            // bottom out in `IndexOnMapping`, breaking ergonomic deep
274            // sets like `foo[0]` on a fresh manifest.
275            let key = Value::String(k.clone());
276            if !map.contains_key(&key) {
277                map.insert(key.clone(), empty_for_next(tail));
278            }
279            let next = map.get_mut(&key).expect("just inserted");
280            set_inner(next, tail, value, breadcrumb)
281        }
282        PathSeg::Index(i) => {
283            push_crumb(breadcrumb, head);
284            let is_mapping = cur.is_mapping();
285            let Some(seq) = cur.as_sequence_mut() else {
286                return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
287            };
288            let len = seq.len();
289            if *i > len {
290                return Err(PathError::IndexOutOfBounds {
291                    index: *i,
292                    len,
293                    at: breadcrumb.clone(),
294                });
295            }
296            if is_last {
297                if *i == len {
298                    seq.push(value);
299                } else {
300                    seq[*i] = value;
301                }
302                return Ok(());
303            }
304            if *i == len {
305                // Auto-extend with the shape the **next** segment
306                // demands. Same reasoning as the mapping branch above
307                // — picking `Mapping` unconditionally breaks
308                // `foo[0][0]` style paths because the inner `[0]`
309                // would land on a mapping and fail with
310                // `IndexOnMapping`. Pick the empty container by
311                // peeking at the next segment.
312                seq.push(empty_for_next(tail));
313            }
314            set_inner(&mut seq[*i], tail, value, breadcrumb)
315        }
316    }
317}
318
319/// Pick the right empty intermediate container to scaffold when a
320/// `set` path passes through a missing segment.
321///
322/// `tail` is the remaining path past the current segment. The first
323/// element of `tail` tells us what shape the next recursion expects:
324/// a `PathSeg::Index` recursion needs a `Value::Sequence`, a
325/// `PathSeg::Key` recursion needs a `Value::Mapping`. Empty `tail`
326/// (i.e. the current segment is the last one) never reaches this
327/// helper — the leaf branches insert the user-supplied value
328/// directly.
329fn empty_for_next(tail: &[PathSeg]) -> Value {
330    match tail.first() {
331        Some(PathSeg::Index(_)) => Value::Sequence(serde_yaml_ng::Sequence::new()),
332        _ => Value::Mapping(serde_yaml_ng::Mapping::new()),
333    }
334}
335
336/// Remove the entry at `path`. Returns [`DeleteOutcome::NotPresent`]
337/// when any segment along the way doesn't exist (idempotent — the
338/// caller treats it as a warning, not an error).
339pub fn delete_value(root: &mut Value, path: &[PathSeg]) -> Result<DeleteOutcome, PathError> {
340    if path.is_empty() {
341        return Err(PathError::Empty);
342    }
343    delete_inner(root, path, &mut String::new())
344}
345
346fn delete_inner(
347    cur: &mut Value,
348    path: &[PathSeg],
349    breadcrumb: &mut String,
350) -> Result<DeleteOutcome, PathError> {
351    let (head, tail) = path.split_first().expect("non-empty checked by caller");
352    let is_last = tail.is_empty();
353    match head {
354        PathSeg::Key(k) => {
355            push_crumb(breadcrumb, head);
356            // Snapshot before borrowing mutably.
357            let is_null = cur.is_null();
358            let is_sequence = cur.is_sequence();
359            let Some(map) = cur.as_mapping_mut() else {
360                // Refusing-to-descend on a scalar is a real error.
361                // Missing-mapping-on-Null is a soft no-op (the value
362                // simply doesn't exist).
363                if is_null {
364                    return Ok(DeleteOutcome::NotPresent);
365                }
366                return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
367            };
368            let key = Value::String(k.clone());
369            if is_last {
370                return Ok(if map.remove(&key).is_some() {
371                    DeleteOutcome::Removed
372                } else {
373                    DeleteOutcome::NotPresent
374                });
375            }
376            let Some(next) = map.get_mut(&key) else {
377                return Ok(DeleteOutcome::NotPresent);
378            };
379            delete_inner(next, tail, breadcrumb)
380        }
381        PathSeg::Index(i) => {
382            push_crumb(breadcrumb, head);
383            let is_null = cur.is_null();
384            let is_mapping = cur.is_mapping();
385            let Some(seq) = cur.as_sequence_mut() else {
386                if is_null {
387                    return Ok(DeleteOutcome::NotPresent);
388                }
389                return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
390            };
391            if *i >= seq.len() {
392                return Ok(DeleteOutcome::NotPresent);
393            }
394            if is_last {
395                seq.remove(*i);
396                return Ok(DeleteOutcome::Removed);
397            }
398            delete_inner(&mut seq[*i], tail, breadcrumb)
399        }
400    }
401}
402
403fn push_crumb(breadcrumb: &mut String, seg: &PathSeg) {
404    match seg {
405        PathSeg::Key(k) => {
406            if !breadcrumb.is_empty() {
407                breadcrumb.push('.');
408            }
409            breadcrumb.push_str(k);
410        }
411        PathSeg::Index(i) => {
412            use std::fmt::Write;
413            let _ = write!(breadcrumb, "[{i}]");
414        }
415    }
416}
417
418/// Build the "expected a mapping but found something else" error.
419///
420/// `parent_is_sequence` should be true when the parent value is a
421/// YAML sequence — that promotes the generic [`PathError::DescendScalar`]
422/// to the more precise [`PathError::KeyOnSequence`] so the user knows
423/// to switch to bracket-index syntax. The breadcrumb already includes
424/// the offending segment thanks to `push_crumb` in the caller, so we
425/// trim it back to the parent for the error message (so the user reads
426/// "at `dependencies`" not "at `dependencies.skills`").
427fn mapping_kind_mismatch_err(
428    parent_is_sequence: bool,
429    seg: &PathSeg,
430    breadcrumb: &str,
431) -> PathError {
432    let at = trim_one_segment(breadcrumb, seg);
433    if parent_is_sequence {
434        if let PathSeg::Key(k) = seg {
435            return PathError::KeyOnSequence { key: k.clone(), at };
436        }
437    }
438    PathError::DescendScalar(at)
439}
440
441/// Mirror of [`mapping_kind_mismatch_err`] for the index-on-non-sequence
442/// case. `parent_is_mapping` true → upgrade to
443/// [`PathError::IndexOnMapping`].
444fn sequence_kind_mismatch_err(
445    parent_is_mapping: bool,
446    seg: &PathSeg,
447    breadcrumb: &str,
448) -> PathError {
449    let at = trim_one_segment(breadcrumb, seg);
450    if parent_is_mapping {
451        if let PathSeg::Index(i) = seg {
452            return PathError::IndexOnMapping { index: *i, at };
453        }
454    }
455    PathError::DescendScalar(at)
456}
457
458/// Strip the trailing `.<seg>` or `[<seg>]` so error messages point at
459/// the parent path rather than the offending leaf.
460fn trim_one_segment(breadcrumb: &str, seg: &PathSeg) -> String {
461    match seg {
462        PathSeg::Key(k) => {
463            let with_dot = format!(".{k}");
464            breadcrumb.strip_suffix(&with_dot).map_or_else(
465                || breadcrumb.trim_start_matches(k).to_owned(),
466                str::to_owned,
467            )
468        }
469        PathSeg::Index(i) => {
470            let bracketed = format!("[{i}]");
471            breadcrumb
472                .strip_suffix(&bracketed)
473                .map_or_else(|| breadcrumb.to_owned(), str::to_owned)
474        }
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use serde_yaml_ng::Value;
482
483    fn sample() -> Value {
484        serde_yaml_ng::from_str(
485            "name: demo\nversion: 0.1.0\ndescription: a demo\ndependencies:\n  skills:\n    - alice/bob@0.1.0\n    - carol/dave\n  mcp:\n    - registry: official\n      name: filesystem\n",
486        )
487        .unwrap()
488    }
489
490    #[test]
491    fn parse_path_handles_keys_and_indices() {
492        assert_eq!(
493            parse_path("name").unwrap(),
494            vec![PathSeg::Key("name".into())]
495        );
496        assert_eq!(
497            parse_path("dependencies.skills[0]").unwrap(),
498            vec![
499                PathSeg::Key("dependencies".into()),
500                PathSeg::Key("skills".into()),
501                PathSeg::Index(0),
502            ]
503        );
504        assert_eq!(
505            parse_path("dependencies.mcp[1].agents").unwrap(),
506            vec![
507                PathSeg::Key("dependencies".into()),
508                PathSeg::Key("mcp".into()),
509                PathSeg::Index(1),
510                PathSeg::Key("agents".into()),
511            ]
512        );
513        assert_eq!(parse_path("[0]").unwrap(), vec![PathSeg::Index(0)]);
514    }
515
516    #[test]
517    fn parse_path_rejects_malformed_input() {
518        assert!(matches!(parse_path("").unwrap_err(), PathError::Empty));
519        assert!(matches!(
520            parse_path(".foo").unwrap_err(),
521            PathError::BadSegment(_)
522        ));
523        assert!(matches!(
524            parse_path("a..b").unwrap_err(),
525            PathError::BadSegment(_)
526        ));
527        assert!(matches!(
528            parse_path("a[").unwrap_err(),
529            PathError::BadSegment(_)
530        ));
531        assert!(matches!(
532            parse_path("a[]").unwrap_err(),
533            PathError::BadSegment(_)
534        ));
535        assert!(matches!(
536            parse_path("a[abc]").unwrap_err(),
537            PathError::BadSegment(_)
538        ));
539        assert!(matches!(
540            parse_path("a]b").unwrap_err(),
541            PathError::BadSegment(_)
542        ));
543        assert!(matches!(
544            parse_path("a[0]b").unwrap_err(),
545            PathError::BadSegment(_)
546        ));
547    }
548
549    #[test]
550    fn get_value_resolves_keys_indices_and_returns_none_on_miss() {
551        let root = sample();
552        let path = parse_path("description").unwrap();
553        assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("a demo"));
554
555        let path = parse_path("dependencies.skills[0]").unwrap();
556        assert_eq!(
557            get_value(&root, &path).unwrap().as_str(),
558            Some("alice/bob@0.1.0")
559        );
560
561        let path = parse_path("dependencies.skills[99]").unwrap();
562        assert!(get_value(&root, &path).is_none());
563
564        let path = parse_path("nope").unwrap();
565        assert!(get_value(&root, &path).is_none());
566    }
567
568    #[test]
569    fn set_value_overwrites_existing_scalar() {
570        let mut root = sample();
571        let path = parse_path("description").unwrap();
572        set_value(&mut root, &path, Value::String("new desc".into())).unwrap();
573        assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("new desc"));
574    }
575
576    #[test]
577    fn set_value_pushes_when_index_equals_len() {
578        let mut root = sample();
579        let path = parse_path("dependencies.skills[2]").unwrap();
580        set_value(&mut root, &path, Value::String("eve/frank@0.2.0".into())).unwrap();
581        assert_eq!(
582            get_value(&root, &path).unwrap().as_str(),
583            Some("eve/frank@0.2.0")
584        );
585        // Earlier entries untouched.
586        let p0 = parse_path("dependencies.skills[0]").unwrap();
587        assert_eq!(
588            get_value(&root, &p0).unwrap().as_str(),
589            Some("alice/bob@0.1.0")
590        );
591    }
592
593    #[test]
594    fn set_value_rejects_gap_past_len() {
595        let mut root = sample();
596        let path = parse_path("dependencies.skills[5]").unwrap();
597        let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
598        assert!(matches!(err, PathError::IndexOutOfBounds { .. }));
599    }
600
601    #[test]
602    fn set_value_creates_intermediate_mappings_on_fresh_root() {
603        let mut root: Value = serde_yaml_ng::from_str("name: demo\nversion: 0.1.0\n").unwrap();
604        let path = parse_path("metadata.repo.url").unwrap();
605        set_value(
606            &mut root,
607            &path,
608            Value::String("https://example.test".into()),
609        )
610        .unwrap();
611        assert_eq!(
612            get_value(&root, &path).unwrap().as_str(),
613            Some("https://example.test")
614        );
615    }
616
617    /// Round-47 regression: `set_value` used to scaffold every missing
618    /// intermediate as an empty mapping. When the **next** path
619    /// segment was an `Index` (e.g. `foo[0]` where `foo` is missing,
620    /// or `foo[0][0]` where the auto-pushed sequence element is also
621    /// missing), the recursion bottomed out in
622    /// `PathError::IndexOnMapping` and the user got a confusing error
623    /// for a path that ought to "just work" on a fresh manifest.
624    ///
625    /// The fix: peek the next segment when scaffolding and pick a
626    /// `Value::Sequence` instead of a `Value::Mapping` whenever the
627    /// next recursion will expect an index.
628    #[test]
629    fn set_value_scaffolds_sequences_when_next_segment_is_index() {
630        // Fresh empty manifest — every intermediate is missing.
631        let mut root: Value = serde_yaml_ng::from_str("name: demo\n").unwrap();
632        let path = parse_path("foo[0][0]").unwrap();
633        set_value(&mut root, &path, Value::String("bar".into())).unwrap();
634
635        // Expected shape:  foo: [[bar]]
636        let foo = root
637            .as_mapping()
638            .unwrap()
639            .get(Value::String("foo".into()))
640            .unwrap();
641        let outer = foo.as_sequence().expect("foo must be a sequence");
642        assert_eq!(outer.len(), 1);
643        let inner = outer[0]
644            .as_sequence()
645            .expect("foo[0] must be a sequence (auto-extended on Index recursion)");
646        assert_eq!(inner.len(), 1);
647        assert_eq!(inner[0].as_str(), Some("bar"));
648    }
649
650    /// Sibling pin: `foo[0].name = bar` on a fresh manifest must
651    /// still scaffold `foo` as a sequence and `foo[0]` as a mapping
652    /// (because the next-after-the-index segment is a `Key`). Makes
653    /// sure the empty-for-next heuristic flips correctly when the
654    /// shape switches mid-path.
655    #[test]
656    fn set_value_scaffolds_mapping_after_index_when_next_is_key() {
657        let mut root: Value = serde_yaml_ng::from_str("name: demo\n").unwrap();
658        let path = parse_path("foo[0].name").unwrap();
659        set_value(&mut root, &path, Value::String("bar".into())).unwrap();
660
661        let foo = root
662            .as_mapping()
663            .unwrap()
664            .get(Value::String("foo".into()))
665            .unwrap();
666        let outer = foo.as_sequence().expect("foo must be a sequence");
667        assert_eq!(outer.len(), 1);
668        let elem = outer[0].as_mapping().expect("foo[0] must be a mapping");
669        let name = elem.get(Value::String("name".into())).unwrap();
670        assert_eq!(name.as_str(), Some("bar"));
671    }
672
673    #[test]
674    fn set_value_refuses_to_descend_into_scalar() {
675        let mut root = sample();
676        let path = parse_path("description.foo").unwrap();
677        let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
678        assert!(matches!(
679            err,
680            PathError::DescendScalar(_) | PathError::KeyOnSequence { .. }
681        ));
682    }
683
684    #[test]
685    fn delete_value_removes_existing_key() {
686        let mut root = sample();
687        let path = parse_path("description").unwrap();
688        assert_eq!(
689            delete_value(&mut root, &path).unwrap(),
690            DeleteOutcome::Removed
691        );
692        assert!(get_value(&root, &path).is_none());
693    }
694
695    #[test]
696    fn delete_value_removes_existing_index_and_shifts_remaining() {
697        let mut root = sample();
698        let path = parse_path("dependencies.skills[0]").unwrap();
699        assert_eq!(
700            delete_value(&mut root, &path).unwrap(),
701            DeleteOutcome::Removed
702        );
703        // What was [1] is now [0].
704        let p0 = parse_path("dependencies.skills[0]").unwrap();
705        assert_eq!(get_value(&root, &p0).unwrap().as_str(), Some("carol/dave"));
706        // And the sequence shrank.
707        let p1 = parse_path("dependencies.skills[1]").unwrap();
708        assert!(get_value(&root, &p1).is_none());
709    }
710
711    #[test]
712    fn delete_value_returns_not_present_for_missing_path() {
713        let mut root = sample();
714        let path = parse_path("missing.deep.key").unwrap();
715        assert_eq!(
716            delete_value(&mut root, &path).unwrap(),
717            DeleteOutcome::NotPresent
718        );
719    }
720
721    #[test]
722    fn delete_value_returns_not_present_for_out_of_bounds_index() {
723        let mut root = sample();
724        let path = parse_path("dependencies.skills[99]").unwrap();
725        assert_eq!(
726            delete_value(&mut root, &path).unwrap(),
727            DeleteOutcome::NotPresent
728        );
729    }
730
731    #[test]
732    fn get_value_json_resolves_keys_indices_and_scalars() {
733        let root: serde_json::Value = serde_json::json!({
734            "id": "alice/hello",
735            "description": "a demo",
736            "versions": [
737                {"version": "0.1.0", "sha256": "aaa"},
738                {"version": "0.1.1", "sha256": "bbb"},
739            ],
740        });
741        let p = parse_path("description").unwrap();
742        assert_eq!(get_value_json(&root, &p).unwrap().as_str(), Some("a demo"));
743
744        let p = parse_path("versions[1].version").unwrap();
745        assert_eq!(get_value_json(&root, &p).unwrap().as_str(), Some("0.1.1"));
746
747        let p = parse_path("versions[0]").unwrap();
748        assert!(get_value_json(&root, &p).unwrap().is_object());
749
750        let p = parse_path("versions").unwrap();
751        assert!(get_value_json(&root, &p).unwrap().is_array());
752    }
753
754    #[test]
755    fn get_value_json_returns_none_on_miss() {
756        let root: serde_json::Value = serde_json::json!({
757            "id": "alice/hello",
758            "versions": [{"version": "0.1.0"}],
759        });
760        // Missing top-level key.
761        let p = parse_path("nope").unwrap();
762        assert!(get_value_json(&root, &p).is_none());
763        // Out-of-bounds index.
764        let p = parse_path("versions[99]").unwrap();
765        assert!(get_value_json(&root, &p).is_none());
766        // Descending into a scalar via a key segment.
767        let p = parse_path("id.deep").unwrap();
768        assert!(get_value_json(&root, &p).is_none());
769    }
770}