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/// Write `value` at `path` inside `root`. Creates intermediate
191/// mappings as needed; refuses to fabricate intermediate sequences
192/// (the user must point at an existing sequence with `[N]` syntax).
193///
194/// Setting an index `N` where `N == len` pushes onto the end of an
195/// existing sequence. Setting `N > len` is rejected with
196/// [`PathError::IndexOutOfBounds`] — `npm pkg set` behaves the same
197/// way and the alternative (auto-padding with nulls) breeds invalid
198/// manifests.
199pub fn set_value(root: &mut Value, path: &[PathSeg], value: Value) -> Result<(), PathError> {
200    if path.is_empty() {
201        return Err(PathError::Empty);
202    }
203    set_inner(root, path, value, &mut String::new())
204}
205
206fn set_inner(
207    cur: &mut Value,
208    path: &[PathSeg],
209    value: Value,
210    breadcrumb: &mut String,
211) -> Result<(), PathError> {
212    let (head, tail) = path.split_first().expect("non-empty checked by caller");
213    let is_last = tail.is_empty();
214    match head {
215        PathSeg::Key(k) => {
216            push_crumb(breadcrumb, head);
217            // Replace a `Null` parent with a fresh mapping so newly
218            // initialised manifests (or freshly-set deep paths) don't
219            // require manual scaffolding. Refusing the conversion
220            // would force the user to set every intermediate key
221            // individually, which is exactly the friction `pakx
222            // manifest set` is meant to remove.
223            if cur.is_null() {
224                *cur = Value::Mapping(serde_yaml_ng::Mapping::new());
225            }
226            // Snapshot the kind before taking the `&mut Mapping` so
227            // the error-builder closure can read it without overlapping
228            // borrows.
229            let is_sequence = cur.is_sequence();
230            let Some(map) = cur.as_mapping_mut() else {
231                return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
232            };
233            if is_last {
234                map.insert(Value::String(k.clone()), value);
235                return Ok(());
236            }
237            // Recurse — create an empty mapping on a missing
238            // intermediate so deep paths work on a fresh manifest.
239            let key = Value::String(k.clone());
240            if !map.contains_key(&key) {
241                map.insert(key.clone(), Value::Mapping(serde_yaml_ng::Mapping::new()));
242            }
243            let next = map.get_mut(&key).expect("just inserted");
244            set_inner(next, tail, value, breadcrumb)
245        }
246        PathSeg::Index(i) => {
247            push_crumb(breadcrumb, head);
248            let is_mapping = cur.is_mapping();
249            let Some(seq) = cur.as_sequence_mut() else {
250                return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
251            };
252            let len = seq.len();
253            if *i > len {
254                return Err(PathError::IndexOutOfBounds {
255                    index: *i,
256                    len,
257                    at: breadcrumb.clone(),
258                });
259            }
260            if is_last {
261                if *i == len {
262                    seq.push(value);
263                } else {
264                    seq[*i] = value;
265                }
266                return Ok(());
267            }
268            if *i == len {
269                // Auto-extend with an empty mapping so a deep set on a
270                // fresh sequence still works. Symmetry with the
271                // missing-mapping-key branch above.
272                seq.push(Value::Mapping(serde_yaml_ng::Mapping::new()));
273            }
274            set_inner(&mut seq[*i], tail, value, breadcrumb)
275        }
276    }
277}
278
279/// Remove the entry at `path`. Returns [`DeleteOutcome::NotPresent`]
280/// when any segment along the way doesn't exist (idempotent — the
281/// caller treats it as a warning, not an error).
282pub fn delete_value(root: &mut Value, path: &[PathSeg]) -> Result<DeleteOutcome, PathError> {
283    if path.is_empty() {
284        return Err(PathError::Empty);
285    }
286    delete_inner(root, path, &mut String::new())
287}
288
289fn delete_inner(
290    cur: &mut Value,
291    path: &[PathSeg],
292    breadcrumb: &mut String,
293) -> Result<DeleteOutcome, PathError> {
294    let (head, tail) = path.split_first().expect("non-empty checked by caller");
295    let is_last = tail.is_empty();
296    match head {
297        PathSeg::Key(k) => {
298            push_crumb(breadcrumb, head);
299            // Snapshot before borrowing mutably.
300            let is_null = cur.is_null();
301            let is_sequence = cur.is_sequence();
302            let Some(map) = cur.as_mapping_mut() else {
303                // Refusing-to-descend on a scalar is a real error.
304                // Missing-mapping-on-Null is a soft no-op (the value
305                // simply doesn't exist).
306                if is_null {
307                    return Ok(DeleteOutcome::NotPresent);
308                }
309                return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
310            };
311            let key = Value::String(k.clone());
312            if is_last {
313                return Ok(if map.remove(&key).is_some() {
314                    DeleteOutcome::Removed
315                } else {
316                    DeleteOutcome::NotPresent
317                });
318            }
319            let Some(next) = map.get_mut(&key) else {
320                return Ok(DeleteOutcome::NotPresent);
321            };
322            delete_inner(next, tail, breadcrumb)
323        }
324        PathSeg::Index(i) => {
325            push_crumb(breadcrumb, head);
326            let is_null = cur.is_null();
327            let is_mapping = cur.is_mapping();
328            let Some(seq) = cur.as_sequence_mut() else {
329                if is_null {
330                    return Ok(DeleteOutcome::NotPresent);
331                }
332                return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
333            };
334            if *i >= seq.len() {
335                return Ok(DeleteOutcome::NotPresent);
336            }
337            if is_last {
338                seq.remove(*i);
339                return Ok(DeleteOutcome::Removed);
340            }
341            delete_inner(&mut seq[*i], tail, breadcrumb)
342        }
343    }
344}
345
346fn push_crumb(breadcrumb: &mut String, seg: &PathSeg) {
347    match seg {
348        PathSeg::Key(k) => {
349            if !breadcrumb.is_empty() {
350                breadcrumb.push('.');
351            }
352            breadcrumb.push_str(k);
353        }
354        PathSeg::Index(i) => {
355            use std::fmt::Write;
356            let _ = write!(breadcrumb, "[{i}]");
357        }
358    }
359}
360
361/// Build the "expected a mapping but found something else" error.
362///
363/// `parent_is_sequence` should be true when the parent value is a
364/// YAML sequence — that promotes the generic [`PathError::DescendScalar`]
365/// to the more precise [`PathError::KeyOnSequence`] so the user knows
366/// to switch to bracket-index syntax. The breadcrumb already includes
367/// the offending segment thanks to `push_crumb` in the caller, so we
368/// trim it back to the parent for the error message (so the user reads
369/// "at `dependencies`" not "at `dependencies.skills`").
370fn mapping_kind_mismatch_err(
371    parent_is_sequence: bool,
372    seg: &PathSeg,
373    breadcrumb: &str,
374) -> PathError {
375    let at = trim_one_segment(breadcrumb, seg);
376    if parent_is_sequence {
377        if let PathSeg::Key(k) = seg {
378            return PathError::KeyOnSequence { key: k.clone(), at };
379        }
380    }
381    PathError::DescendScalar(at)
382}
383
384/// Mirror of [`mapping_kind_mismatch_err`] for the index-on-non-sequence
385/// case. `parent_is_mapping` true → upgrade to
386/// [`PathError::IndexOnMapping`].
387fn sequence_kind_mismatch_err(
388    parent_is_mapping: bool,
389    seg: &PathSeg,
390    breadcrumb: &str,
391) -> PathError {
392    let at = trim_one_segment(breadcrumb, seg);
393    if parent_is_mapping {
394        if let PathSeg::Index(i) = seg {
395            return PathError::IndexOnMapping { index: *i, at };
396        }
397    }
398    PathError::DescendScalar(at)
399}
400
401/// Strip the trailing `.<seg>` or `[<seg>]` so error messages point at
402/// the parent path rather than the offending leaf.
403fn trim_one_segment(breadcrumb: &str, seg: &PathSeg) -> String {
404    match seg {
405        PathSeg::Key(k) => {
406            let with_dot = format!(".{k}");
407            breadcrumb.strip_suffix(&with_dot).map_or_else(
408                || breadcrumb.trim_start_matches(k).to_owned(),
409                str::to_owned,
410            )
411        }
412        PathSeg::Index(i) => {
413            let bracketed = format!("[{i}]");
414            breadcrumb
415                .strip_suffix(&bracketed)
416                .map_or_else(|| breadcrumb.to_owned(), str::to_owned)
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use serde_yaml_ng::Value;
425
426    fn sample() -> Value {
427        serde_yaml_ng::from_str(
428            "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",
429        )
430        .unwrap()
431    }
432
433    #[test]
434    fn parse_path_handles_keys_and_indices() {
435        assert_eq!(
436            parse_path("name").unwrap(),
437            vec![PathSeg::Key("name".into())]
438        );
439        assert_eq!(
440            parse_path("dependencies.skills[0]").unwrap(),
441            vec![
442                PathSeg::Key("dependencies".into()),
443                PathSeg::Key("skills".into()),
444                PathSeg::Index(0),
445            ]
446        );
447        assert_eq!(
448            parse_path("dependencies.mcp[1].agents").unwrap(),
449            vec![
450                PathSeg::Key("dependencies".into()),
451                PathSeg::Key("mcp".into()),
452                PathSeg::Index(1),
453                PathSeg::Key("agents".into()),
454            ]
455        );
456        assert_eq!(parse_path("[0]").unwrap(), vec![PathSeg::Index(0)]);
457    }
458
459    #[test]
460    fn parse_path_rejects_malformed_input() {
461        assert!(matches!(parse_path("").unwrap_err(), PathError::Empty));
462        assert!(matches!(
463            parse_path(".foo").unwrap_err(),
464            PathError::BadSegment(_)
465        ));
466        assert!(matches!(
467            parse_path("a..b").unwrap_err(),
468            PathError::BadSegment(_)
469        ));
470        assert!(matches!(
471            parse_path("a[").unwrap_err(),
472            PathError::BadSegment(_)
473        ));
474        assert!(matches!(
475            parse_path("a[]").unwrap_err(),
476            PathError::BadSegment(_)
477        ));
478        assert!(matches!(
479            parse_path("a[abc]").unwrap_err(),
480            PathError::BadSegment(_)
481        ));
482        assert!(matches!(
483            parse_path("a]b").unwrap_err(),
484            PathError::BadSegment(_)
485        ));
486        assert!(matches!(
487            parse_path("a[0]b").unwrap_err(),
488            PathError::BadSegment(_)
489        ));
490    }
491
492    #[test]
493    fn get_value_resolves_keys_indices_and_returns_none_on_miss() {
494        let root = sample();
495        let path = parse_path("description").unwrap();
496        assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("a demo"));
497
498        let path = parse_path("dependencies.skills[0]").unwrap();
499        assert_eq!(
500            get_value(&root, &path).unwrap().as_str(),
501            Some("alice/bob@0.1.0")
502        );
503
504        let path = parse_path("dependencies.skills[99]").unwrap();
505        assert!(get_value(&root, &path).is_none());
506
507        let path = parse_path("nope").unwrap();
508        assert!(get_value(&root, &path).is_none());
509    }
510
511    #[test]
512    fn set_value_overwrites_existing_scalar() {
513        let mut root = sample();
514        let path = parse_path("description").unwrap();
515        set_value(&mut root, &path, Value::String("new desc".into())).unwrap();
516        assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("new desc"));
517    }
518
519    #[test]
520    fn set_value_pushes_when_index_equals_len() {
521        let mut root = sample();
522        let path = parse_path("dependencies.skills[2]").unwrap();
523        set_value(&mut root, &path, Value::String("eve/frank@0.2.0".into())).unwrap();
524        assert_eq!(
525            get_value(&root, &path).unwrap().as_str(),
526            Some("eve/frank@0.2.0")
527        );
528        // Earlier entries untouched.
529        let p0 = parse_path("dependencies.skills[0]").unwrap();
530        assert_eq!(
531            get_value(&root, &p0).unwrap().as_str(),
532            Some("alice/bob@0.1.0")
533        );
534    }
535
536    #[test]
537    fn set_value_rejects_gap_past_len() {
538        let mut root = sample();
539        let path = parse_path("dependencies.skills[5]").unwrap();
540        let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
541        assert!(matches!(err, PathError::IndexOutOfBounds { .. }));
542    }
543
544    #[test]
545    fn set_value_creates_intermediate_mappings_on_fresh_root() {
546        let mut root: Value = serde_yaml_ng::from_str("name: demo\nversion: 0.1.0\n").unwrap();
547        let path = parse_path("metadata.repo.url").unwrap();
548        set_value(
549            &mut root,
550            &path,
551            Value::String("https://example.test".into()),
552        )
553        .unwrap();
554        assert_eq!(
555            get_value(&root, &path).unwrap().as_str(),
556            Some("https://example.test")
557        );
558    }
559
560    #[test]
561    fn set_value_refuses_to_descend_into_scalar() {
562        let mut root = sample();
563        let path = parse_path("description.foo").unwrap();
564        let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
565        assert!(matches!(
566            err,
567            PathError::DescendScalar(_) | PathError::KeyOnSequence { .. }
568        ));
569    }
570
571    #[test]
572    fn delete_value_removes_existing_key() {
573        let mut root = sample();
574        let path = parse_path("description").unwrap();
575        assert_eq!(
576            delete_value(&mut root, &path).unwrap(),
577            DeleteOutcome::Removed
578        );
579        assert!(get_value(&root, &path).is_none());
580    }
581
582    #[test]
583    fn delete_value_removes_existing_index_and_shifts_remaining() {
584        let mut root = sample();
585        let path = parse_path("dependencies.skills[0]").unwrap();
586        assert_eq!(
587            delete_value(&mut root, &path).unwrap(),
588            DeleteOutcome::Removed
589        );
590        // What was [1] is now [0].
591        let p0 = parse_path("dependencies.skills[0]").unwrap();
592        assert_eq!(get_value(&root, &p0).unwrap().as_str(), Some("carol/dave"));
593        // And the sequence shrank.
594        let p1 = parse_path("dependencies.skills[1]").unwrap();
595        assert!(get_value(&root, &p1).is_none());
596    }
597
598    #[test]
599    fn delete_value_returns_not_present_for_missing_path() {
600        let mut root = sample();
601        let path = parse_path("missing.deep.key").unwrap();
602        assert_eq!(
603            delete_value(&mut root, &path).unwrap(),
604            DeleteOutcome::NotPresent
605        );
606    }
607
608    #[test]
609    fn delete_value_returns_not_present_for_out_of_bounds_index() {
610        let mut root = sample();
611        let path = parse_path("dependencies.skills[99]").unwrap();
612        assert_eq!(
613            delete_value(&mut root, &path).unwrap(),
614            DeleteOutcome::NotPresent
615        );
616    }
617}