1use serde_yaml_ng::Value;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum PathSeg {
33 Key(String),
35 Index(usize),
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
45pub enum PathError {
46 #[error("manifest path must not be empty")]
48 Empty,
49 #[error("invalid path segment near `{0}`")]
55 BadSegment(String),
56 #[error("cannot descend into scalar at `{0}`")]
60 DescendScalar(String),
61 #[error("index {index} out of bounds (length {len}) at `{at}`")]
65 IndexOutOfBounds {
66 index: usize,
67 len: usize,
68 at: String,
69 },
70 #[error("expected sequence index `[N]` but got key `{key}` at `{at}`")]
74 KeyOnSequence { key: String, at: String },
75 #[error("expected mapping key but got index `[{index}]` at `{at}`")]
78 IndexOnMapping { index: usize, at: String },
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum DeleteOutcome {
86 Removed,
88 NotPresent,
91}
92
93pub 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 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 if !buf.is_empty() {
124 segments.push(PathSeg::Key(std::mem::take(&mut buf)));
125 }
126 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 if let Some(&peek) = chars.peek() {
147 if peek != '.' && peek != '[' {
148 return Err(PathError::BadSegment(raw.to_owned()));
149 }
150 }
151 }
152 ']' => {
153 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#[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
190pub 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 if cur.is_null() {
224 *cur = Value::Mapping(serde_yaml_ng::Mapping::new());
225 }
226 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 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 seq.push(Value::Mapping(serde_yaml_ng::Mapping::new()));
273 }
274 set_inner(&mut seq[*i], tail, value, breadcrumb)
275 }
276 }
277}
278
279pub 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 let is_null = cur.is_null();
301 let is_sequence = cur.is_sequence();
302 let Some(map) = cur.as_mapping_mut() else {
303 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
361fn 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
384fn 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
401fn 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 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 let p0 = parse_path("dependencies.skills[0]").unwrap();
592 assert_eq!(get_value(&root, &p0).unwrap().as_str(), Some("carol/dave"));
593 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}