use serde_yaml_ng::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSeg {
Key(String),
Index(usize),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PathError {
#[error("manifest path must not be empty")]
Empty,
#[error("invalid path segment near `{0}`")]
BadSegment(String),
#[error("cannot descend into scalar at `{0}`")]
DescendScalar(String),
#[error("index {index} out of bounds (length {len}) at `{at}`")]
IndexOutOfBounds {
index: usize,
len: usize,
at: String,
},
#[error("expected sequence index `[N]` but got key `{key}` at `{at}`")]
KeyOnSequence { key: String, at: String },
#[error("expected mapping key but got index `[{index}]` at `{at}`")]
IndexOnMapping { index: usize, at: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteOutcome {
Removed,
NotPresent,
}
pub fn parse_path(raw: &str) -> Result<Vec<PathSeg>, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
}
let mut segments: Vec<PathSeg> = Vec::new();
let mut buf = String::new();
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
match c {
'.' => {
if buf.is_empty() {
if !matches!(segments.last(), Some(PathSeg::Index(_))) {
return Err(PathError::BadSegment(raw.to_owned()));
}
continue;
}
segments.push(PathSeg::Key(std::mem::take(&mut buf)));
}
'[' => {
if !buf.is_empty() {
segments.push(PathSeg::Key(std::mem::take(&mut buf)));
}
let mut num = String::new();
let mut closed = false;
for d in chars.by_ref() {
if d == ']' {
closed = true;
break;
}
num.push(d);
}
if !closed || num.is_empty() {
return Err(PathError::BadSegment(raw.to_owned()));
}
let idx: usize = num
.parse()
.map_err(|_| PathError::BadSegment(raw.to_owned()))?;
segments.push(PathSeg::Index(idx));
if let Some(&peek) = chars.peek() {
if peek != '.' && peek != '[' {
return Err(PathError::BadSegment(raw.to_owned()));
}
}
}
']' => {
return Err(PathError::BadSegment(raw.to_owned()));
}
other => buf.push(other),
}
}
if !buf.is_empty() {
segments.push(PathSeg::Key(buf));
}
if segments.is_empty() {
return Err(PathError::Empty);
}
Ok(segments)
}
#[must_use]
pub fn get_value<'a>(root: &'a Value, path: &[PathSeg]) -> Option<&'a Value> {
let mut cur = root;
for seg in path {
match seg {
PathSeg::Key(k) => {
cur = cur.as_mapping()?.get(Value::String(k.clone()))?;
}
PathSeg::Index(i) => {
cur = cur.as_sequence()?.get(*i)?;
}
}
}
Some(cur)
}
#[must_use]
pub fn get_value_json<'a>(
root: &'a serde_json::Value,
path: &[PathSeg],
) -> Option<&'a serde_json::Value> {
let mut cur = root;
for seg in path {
match seg {
PathSeg::Key(k) => {
cur = cur.as_object()?.get(k)?;
}
PathSeg::Index(i) => {
cur = cur.as_array()?.get(*i)?;
}
}
}
Some(cur)
}
pub fn set_value(root: &mut Value, path: &[PathSeg], value: Value) -> Result<(), PathError> {
if path.is_empty() {
return Err(PathError::Empty);
}
set_inner(root, path, value, &mut String::new())
}
fn set_inner(
cur: &mut Value,
path: &[PathSeg],
value: Value,
breadcrumb: &mut String,
) -> Result<(), PathError> {
let (head, tail) = path.split_first().expect("non-empty checked by caller");
let is_last = tail.is_empty();
match head {
PathSeg::Key(k) => {
push_crumb(breadcrumb, head);
if cur.is_null() {
*cur = Value::Mapping(serde_yaml_ng::Mapping::new());
}
let is_sequence = cur.is_sequence();
let Some(map) = cur.as_mapping_mut() else {
return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
};
if is_last {
map.insert(Value::String(k.clone()), value);
return Ok(());
}
let key = Value::String(k.clone());
if !map.contains_key(&key) {
map.insert(key.clone(), empty_for_next(tail));
}
let next = map.get_mut(&key).expect("just inserted");
set_inner(next, tail, value, breadcrumb)
}
PathSeg::Index(i) => {
push_crumb(breadcrumb, head);
let is_mapping = cur.is_mapping();
let Some(seq) = cur.as_sequence_mut() else {
return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
};
let len = seq.len();
if *i > len {
return Err(PathError::IndexOutOfBounds {
index: *i,
len,
at: breadcrumb.clone(),
});
}
if is_last {
if *i == len {
seq.push(value);
} else {
seq[*i] = value;
}
return Ok(());
}
if *i == len {
seq.push(empty_for_next(tail));
}
set_inner(&mut seq[*i], tail, value, breadcrumb)
}
}
}
fn empty_for_next(tail: &[PathSeg]) -> Value {
match tail.first() {
Some(PathSeg::Index(_)) => Value::Sequence(serde_yaml_ng::Sequence::new()),
_ => Value::Mapping(serde_yaml_ng::Mapping::new()),
}
}
pub fn delete_value(root: &mut Value, path: &[PathSeg]) -> Result<DeleteOutcome, PathError> {
if path.is_empty() {
return Err(PathError::Empty);
}
delete_inner(root, path, &mut String::new())
}
fn delete_inner(
cur: &mut Value,
path: &[PathSeg],
breadcrumb: &mut String,
) -> Result<DeleteOutcome, PathError> {
let (head, tail) = path.split_first().expect("non-empty checked by caller");
let is_last = tail.is_empty();
match head {
PathSeg::Key(k) => {
push_crumb(breadcrumb, head);
let is_null = cur.is_null();
let is_sequence = cur.is_sequence();
let Some(map) = cur.as_mapping_mut() else {
if is_null {
return Ok(DeleteOutcome::NotPresent);
}
return Err(mapping_kind_mismatch_err(is_sequence, head, breadcrumb));
};
let key = Value::String(k.clone());
if is_last {
return Ok(if map.remove(&key).is_some() {
DeleteOutcome::Removed
} else {
DeleteOutcome::NotPresent
});
}
let Some(next) = map.get_mut(&key) else {
return Ok(DeleteOutcome::NotPresent);
};
delete_inner(next, tail, breadcrumb)
}
PathSeg::Index(i) => {
push_crumb(breadcrumb, head);
let is_null = cur.is_null();
let is_mapping = cur.is_mapping();
let Some(seq) = cur.as_sequence_mut() else {
if is_null {
return Ok(DeleteOutcome::NotPresent);
}
return Err(sequence_kind_mismatch_err(is_mapping, head, breadcrumb));
};
if *i >= seq.len() {
return Ok(DeleteOutcome::NotPresent);
}
if is_last {
seq.remove(*i);
return Ok(DeleteOutcome::Removed);
}
delete_inner(&mut seq[*i], tail, breadcrumb)
}
}
}
fn push_crumb(breadcrumb: &mut String, seg: &PathSeg) {
match seg {
PathSeg::Key(k) => {
if !breadcrumb.is_empty() {
breadcrumb.push('.');
}
breadcrumb.push_str(k);
}
PathSeg::Index(i) => {
use std::fmt::Write;
let _ = write!(breadcrumb, "[{i}]");
}
}
}
fn mapping_kind_mismatch_err(
parent_is_sequence: bool,
seg: &PathSeg,
breadcrumb: &str,
) -> PathError {
let at = trim_one_segment(breadcrumb, seg);
if parent_is_sequence {
if let PathSeg::Key(k) = seg {
return PathError::KeyOnSequence { key: k.clone(), at };
}
}
PathError::DescendScalar(at)
}
fn sequence_kind_mismatch_err(
parent_is_mapping: bool,
seg: &PathSeg,
breadcrumb: &str,
) -> PathError {
let at = trim_one_segment(breadcrumb, seg);
if parent_is_mapping {
if let PathSeg::Index(i) = seg {
return PathError::IndexOnMapping { index: *i, at };
}
}
PathError::DescendScalar(at)
}
fn trim_one_segment(breadcrumb: &str, seg: &PathSeg) -> String {
match seg {
PathSeg::Key(k) => {
let with_dot = format!(".{k}");
breadcrumb.strip_suffix(&with_dot).map_or_else(
|| breadcrumb.trim_start_matches(k).to_owned(),
str::to_owned,
)
}
PathSeg::Index(i) => {
let bracketed = format!("[{i}]");
breadcrumb
.strip_suffix(&bracketed)
.map_or_else(|| breadcrumb.to_owned(), str::to_owned)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_yaml_ng::Value;
fn sample() -> Value {
serde_yaml_ng::from_str(
"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",
)
.unwrap()
}
#[test]
fn parse_path_handles_keys_and_indices() {
assert_eq!(
parse_path("name").unwrap(),
vec![PathSeg::Key("name".into())]
);
assert_eq!(
parse_path("dependencies.skills[0]").unwrap(),
vec![
PathSeg::Key("dependencies".into()),
PathSeg::Key("skills".into()),
PathSeg::Index(0),
]
);
assert_eq!(
parse_path("dependencies.mcp[1].agents").unwrap(),
vec![
PathSeg::Key("dependencies".into()),
PathSeg::Key("mcp".into()),
PathSeg::Index(1),
PathSeg::Key("agents".into()),
]
);
assert_eq!(parse_path("[0]").unwrap(), vec![PathSeg::Index(0)]);
}
#[test]
fn parse_path_rejects_malformed_input() {
assert!(matches!(parse_path("").unwrap_err(), PathError::Empty));
assert!(matches!(
parse_path(".foo").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a..b").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a[").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a[]").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a[abc]").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a]b").unwrap_err(),
PathError::BadSegment(_)
));
assert!(matches!(
parse_path("a[0]b").unwrap_err(),
PathError::BadSegment(_)
));
}
#[test]
fn get_value_resolves_keys_indices_and_returns_none_on_miss() {
let root = sample();
let path = parse_path("description").unwrap();
assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("a demo"));
let path = parse_path("dependencies.skills[0]").unwrap();
assert_eq!(
get_value(&root, &path).unwrap().as_str(),
Some("alice/bob@0.1.0")
);
let path = parse_path("dependencies.skills[99]").unwrap();
assert!(get_value(&root, &path).is_none());
let path = parse_path("nope").unwrap();
assert!(get_value(&root, &path).is_none());
}
#[test]
fn set_value_overwrites_existing_scalar() {
let mut root = sample();
let path = parse_path("description").unwrap();
set_value(&mut root, &path, Value::String("new desc".into())).unwrap();
assert_eq!(get_value(&root, &path).unwrap().as_str(), Some("new desc"));
}
#[test]
fn set_value_pushes_when_index_equals_len() {
let mut root = sample();
let path = parse_path("dependencies.skills[2]").unwrap();
set_value(&mut root, &path, Value::String("eve/frank@0.2.0".into())).unwrap();
assert_eq!(
get_value(&root, &path).unwrap().as_str(),
Some("eve/frank@0.2.0")
);
let p0 = parse_path("dependencies.skills[0]").unwrap();
assert_eq!(
get_value(&root, &p0).unwrap().as_str(),
Some("alice/bob@0.1.0")
);
}
#[test]
fn set_value_rejects_gap_past_len() {
let mut root = sample();
let path = parse_path("dependencies.skills[5]").unwrap();
let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
assert!(matches!(err, PathError::IndexOutOfBounds { .. }));
}
#[test]
fn set_value_creates_intermediate_mappings_on_fresh_root() {
let mut root: Value = serde_yaml_ng::from_str("name: demo\nversion: 0.1.0\n").unwrap();
let path = parse_path("metadata.repo.url").unwrap();
set_value(
&mut root,
&path,
Value::String("https://example.test".into()),
)
.unwrap();
assert_eq!(
get_value(&root, &path).unwrap().as_str(),
Some("https://example.test")
);
}
#[test]
fn set_value_scaffolds_sequences_when_next_segment_is_index() {
let mut root: Value = serde_yaml_ng::from_str("name: demo\n").unwrap();
let path = parse_path("foo[0][0]").unwrap();
set_value(&mut root, &path, Value::String("bar".into())).unwrap();
let foo = root
.as_mapping()
.unwrap()
.get(Value::String("foo".into()))
.unwrap();
let outer = foo.as_sequence().expect("foo must be a sequence");
assert_eq!(outer.len(), 1);
let inner = outer[0]
.as_sequence()
.expect("foo[0] must be a sequence (auto-extended on Index recursion)");
assert_eq!(inner.len(), 1);
assert_eq!(inner[0].as_str(), Some("bar"));
}
#[test]
fn set_value_scaffolds_mapping_after_index_when_next_is_key() {
let mut root: Value = serde_yaml_ng::from_str("name: demo\n").unwrap();
let path = parse_path("foo[0].name").unwrap();
set_value(&mut root, &path, Value::String("bar".into())).unwrap();
let foo = root
.as_mapping()
.unwrap()
.get(Value::String("foo".into()))
.unwrap();
let outer = foo.as_sequence().expect("foo must be a sequence");
assert_eq!(outer.len(), 1);
let elem = outer[0].as_mapping().expect("foo[0] must be a mapping");
let name = elem.get(Value::String("name".into())).unwrap();
assert_eq!(name.as_str(), Some("bar"));
}
#[test]
fn set_value_refuses_to_descend_into_scalar() {
let mut root = sample();
let path = parse_path("description.foo").unwrap();
let err = set_value(&mut root, &path, Value::String("x".into())).unwrap_err();
assert!(matches!(
err,
PathError::DescendScalar(_) | PathError::KeyOnSequence { .. }
));
}
#[test]
fn delete_value_removes_existing_key() {
let mut root = sample();
let path = parse_path("description").unwrap();
assert_eq!(
delete_value(&mut root, &path).unwrap(),
DeleteOutcome::Removed
);
assert!(get_value(&root, &path).is_none());
}
#[test]
fn delete_value_removes_existing_index_and_shifts_remaining() {
let mut root = sample();
let path = parse_path("dependencies.skills[0]").unwrap();
assert_eq!(
delete_value(&mut root, &path).unwrap(),
DeleteOutcome::Removed
);
let p0 = parse_path("dependencies.skills[0]").unwrap();
assert_eq!(get_value(&root, &p0).unwrap().as_str(), Some("carol/dave"));
let p1 = parse_path("dependencies.skills[1]").unwrap();
assert!(get_value(&root, &p1).is_none());
}
#[test]
fn delete_value_returns_not_present_for_missing_path() {
let mut root = sample();
let path = parse_path("missing.deep.key").unwrap();
assert_eq!(
delete_value(&mut root, &path).unwrap(),
DeleteOutcome::NotPresent
);
}
#[test]
fn delete_value_returns_not_present_for_out_of_bounds_index() {
let mut root = sample();
let path = parse_path("dependencies.skills[99]").unwrap();
assert_eq!(
delete_value(&mut root, &path).unwrap(),
DeleteOutcome::NotPresent
);
}
#[test]
fn get_value_json_resolves_keys_indices_and_scalars() {
let root: serde_json::Value = serde_json::json!({
"id": "alice/hello",
"description": "a demo",
"versions": [
{"version": "0.1.0", "sha256": "aaa"},
{"version": "0.1.1", "sha256": "bbb"},
],
});
let p = parse_path("description").unwrap();
assert_eq!(get_value_json(&root, &p).unwrap().as_str(), Some("a demo"));
let p = parse_path("versions[1].version").unwrap();
assert_eq!(get_value_json(&root, &p).unwrap().as_str(), Some("0.1.1"));
let p = parse_path("versions[0]").unwrap();
assert!(get_value_json(&root, &p).unwrap().is_object());
let p = parse_path("versions").unwrap();
assert!(get_value_json(&root, &p).unwrap().is_array());
}
#[test]
fn get_value_json_returns_none_on_miss() {
let root: serde_json::Value = serde_json::json!({
"id": "alice/hello",
"versions": [{"version": "0.1.0"}],
});
let p = parse_path("nope").unwrap();
assert!(get_value_json(&root, &p).is_none());
let p = parse_path("versions[99]").unwrap();
assert!(get_value_json(&root, &p).is_none());
let p = parse_path("id.deep").unwrap();
assert!(get_value_json(&root, &p).is_none());
}
}