use serde_yaml::{Mapping, Value};
pub const MOVES: &[(&str, &str)] = &[
("information", "intent.background"),
("pre_execution", "intent.prerequisites"),
("goals", "intent.goals"),
("success", "intent.success"),
("validations", "safety.checks"),
("stop_gates", "safety.gates.stop"),
("constraints", "safety.limits"),
("graph", "execution.graph"),
("providers", "execution.providers"),
("execution_guidelines", "execution.phases"),
("default_skills", "execution.default_skills"),
("skills", "execution.skills"),
("context", "execution.memory"),
("schedules", "execution.triggers.triggers"),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Moved {
pub from: &'static str,
pub to: &'static str,
}
impl std::fmt::Display for Moved {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "`{}` is now `{}`", self.from, self.to)
}
}
pub fn is_legacy(doc: &Value) -> bool {
let Some(map) = doc.as_mapping() else {
return false;
};
MOVES
.iter()
.any(|(from, _)| map.contains_key(Value::from(*from)))
}
pub fn migrate(doc: &Value) -> (Value, Vec<Moved>) {
let Some(src) = doc.as_mapping() else {
return (doc.clone(), Vec::new());
};
let mut out = src.clone();
let mut moved = Vec::new();
for (from, to) in MOVES {
let key = Value::from(*from);
let Some(value) = out.remove(&key) else {
continue;
};
insert_path(&mut out, to, value);
moved.push(Moved { from, to });
}
repair_nodes(&mut out);
repair_triggers(&mut out);
(Value::Mapping(reorder(out)), moved)
}
const KEY_ORDER: &[&str] = &[
"name",
"version",
"description",
"environment",
"features",
"intent",
"execution",
"safety",
"evolution",
];
fn reorder(map: Mapping) -> Mapping {
let mut out = Mapping::new();
let mut rest = map;
for key in KEY_ORDER {
if let Some(v) = rest.remove(Value::from(*key)) {
out.insert(Value::from(*key), v);
}
}
for (k, v) in rest {
out.insert(k, v);
}
out
}
fn at_mut<'a>(root: &'a mut Mapping, path: &str) -> Option<&'a mut Value> {
let mut parts = path.split('.');
let mut cur = root.get_mut(Value::from(parts.next()?))?;
for part in parts {
cur = cur.as_mapping_mut()?.get_mut(Value::from(part))?;
}
Some(cur)
}
fn repair_nodes(out: &mut Mapping) {
let Some(Value::Sequence(nodes)) = at_mut(out, "execution.graph.nodes") else {
return;
};
for node in nodes.iter_mut() {
*node = migrate_node(std::mem::replace(node, Value::Null));
}
}
fn repair_triggers(out: &mut Mapping) {
let Some(Value::Sequence(items)) = at_mut(out, "execution.triggers.triggers") else {
return;
};
for item in items.iter_mut() {
if item
.as_mapping()
.is_some_and(|m| m.contains_key(Value::from("on")))
{
continue;
}
let mut m = Mapping::new();
m.insert(Value::from("on"), std::mem::replace(item, Value::Null));
*item = Value::Mapping(m);
}
}
fn migrate_node(node: Value) -> Value {
let Value::Mapping(mut n) = node else {
return node;
};
let Some(old) = n.remove(Value::from("isolated")) else {
return Value::Mapping(n);
};
if !n.contains_key(Value::from("isolation")) {
let mode = if old.as_bool() == Some(true) {
"worktree"
} else {
"none"
};
let mut iso = Mapping::new();
iso.insert(Value::from("mode"), Value::from(mode));
n.insert(Value::from("isolation"), Value::Mapping(iso));
}
Value::Mapping(n)
}
fn insert_path(root: &mut Mapping, path: &str, value: Value) {
let mut parts = path.split('.').peekable();
let mut cursor = root;
while let Some(part) = parts.next() {
let key = Value::from(part);
if parts.peek().is_none() {
cursor.entry(key).or_insert(value);
return;
}
let slot = cursor
.entry(key)
.or_insert_with(|| Value::Mapping(Mapping::new()));
let Value::Mapping(next) = slot else {
return;
};
cursor = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn yaml(s: &str) -> Value {
serde_yaml::from_str(s).expect("test yaml parses")
}
fn at(doc: &Value, path: &str) -> Option<Value> {
let mut cur = doc.clone();
for part in path.split('.') {
cur = match part.parse::<usize>() {
Ok(i) => cur.as_sequence()?.get(i)?.clone(),
Err(_) => cur.get(part)?.clone(),
};
}
Some(cur)
}
#[test]
fn every_legacy_key_lands_where_the_table_says() {
let doc = yaml(
"name: t\ninformation: []\npre_execution: []\ngoals: []\nvalidations: []\n\
success: []\nstop_gates: {max_iterations: 4}\nschedules: []\nconstraints: {}\n\
execution_guidelines: {}\ndefault_skills: []\ngraph: {}\nproviders: {}\n\
skills: {}\ncontext: {}\n",
);
let (out, moved) = migrate(&doc);
assert_eq!(moved.len(), MOVES.len(), "every key should have moved");
for (_, to) in MOVES {
assert!(at(&out, to).is_some(), "nothing landed at {to}");
}
assert_eq!(at(&out, "safety.gates.stop.max_iterations"), Some(4.into()));
assert_eq!(at(&out, "name"), Some("t".into()));
}
#[test]
fn migrating_an_already_migrated_document_changes_nothing() {
let doc = yaml("name: t\nintent:\n goals: []\nsafety:\n checks: []\n");
let (out, moved) = migrate(&doc);
assert!(moved.is_empty());
assert_eq!(out, doc);
assert!(!is_legacy(&doc));
}
#[test]
fn a_bare_trigger_is_wrapped_and_a_wrapped_one_is_left_alone() {
let doc = yaml(
"name: t\nschedules:\n - {type: cron, expr: \"0 2 * * *\"}\n - on: {type: manual}\n",
);
let (out, _) = migrate(&doc);
let list = at(&out, "execution.triggers.triggers").expect("triggers moved");
let items = list.as_sequence().expect("a sequence");
assert_eq!(items.len(), 2);
for item in items {
assert!(
item.get("on").is_some(),
"every entry should be wrapped exactly once: {item:?}"
);
assert!(
item.get("on").and_then(|o| o.get("on")).is_none(),
"an already-wrapped entry must not be wrapped twice"
);
}
}
#[test]
fn isolated_true_becomes_worktree_and_false_becomes_none() {
let doc = yaml(
"name: t\ngraph:\n nodes:\n - {id: a, isolated: true}\n - {id: b, isolated: false}\n - {id: c}\n",
);
let (out, _) = migrate(&doc);
let nodes = at(&out, "execution.graph.nodes").unwrap();
let nodes = nodes.as_sequence().unwrap();
assert_eq!(nodes[0].get("isolation").unwrap().get("mode").unwrap(), "worktree");
assert_eq!(nodes[1].get("isolation").unwrap().get("mode").unwrap(), "none");
assert!(nodes[2].get("isolation").is_none());
for n in nodes {
assert!(n.get("isolated").is_none(), "the old key must be consumed");
}
}
#[test]
fn a_new_key_wins_over_the_legacy_key_it_replaced() {
let doc = yaml("name: t\nvalidations: [{old: 1}]\nsafety:\n checks: [{new: 1}]\n");
let (out, moved) = migrate(&doc);
assert_eq!(at(&out, "safety.checks.0.new"), Some(1.into()));
assert!(moved.iter().any(|m| m.from == "validations"));
}
#[test]
fn a_migrated_document_is_ordered_the_way_a_person_would_write_it() {
let doc = yaml("validations: []\ngoals: []\nname: t\ngraph: {}\n");
let (out, _) = migrate(&doc);
let keys: Vec<&str> = out
.as_mapping()
.unwrap()
.keys()
.filter_map(|k| k.as_str())
.collect();
assert_eq!(keys, vec!["name", "intent", "execution", "safety"]);
}
#[test]
fn a_key_the_order_does_not_know_about_survives_at_the_end() {
let doc = yaml("goals: []\nname: t\nsomething_new: 1\n");
let (out, _) = migrate(&doc);
let m = out.as_mapping().unwrap();
assert_eq!(m.get("something_new"), Some(&Value::from(1)));
assert_eq!(
m.keys().filter_map(|k| k.as_str()).last(),
Some("something_new")
);
}
#[test]
fn a_document_that_is_not_a_mapping_is_returned_untouched() {
let doc = yaml("- just\n- a list\n");
let (out, moved) = migrate(&doc);
assert_eq!(out, doc);
assert!(moved.is_empty());
}
}