use crate::skill_fs::SkillFile;
pub fn reconcile_skill_version(files: &[SkillFile], version: &str) -> Vec<SkillFile> {
files
.iter()
.map(|f| {
if f.rel_path.as_os_str() == "SKILL.md"
&& let Ok(text) = std::str::from_utf8(&f.bytes)
{
let reconciled = set_metadata_version(text, version);
return SkillFile {
rel_path: f.rel_path.clone(),
bytes: reconciled.into_bytes(),
};
}
f.clone()
})
.collect()
}
fn set_metadata_version(content: &str, version: &str) -> String {
let value = format!("\"{version}\"");
let (open, after_open) = if let Some(rest) = content.strip_prefix("---\n") {
("---\n", rest)
} else if let Some(rest) = content.strip_prefix("---\r\n") {
("---\r\n", rest)
} else {
return content.to_string();
};
let Some(fence_start) = find_closing_fence(after_open) else {
return content.to_string();
};
let inner = &after_open[..fence_start];
let closing_and_rest = &after_open[fence_start..];
let new_inner = reconcile_inner(inner, &value);
let mut out = String::with_capacity(content.len() + value.len() + 16);
out.push_str(open);
out.push_str(&new_inner);
out.push_str(closing_and_rest);
out
}
fn find_closing_fence(after_open: &str) -> Option<usize> {
let mut line_start = 0;
loop {
let rest = &after_open[line_start..];
let (line, has_newline) = match rest.find('\n') {
Some(p) => (&rest[..p], true),
None => (rest, false),
};
if line.trim_end_matches('\r') == "---" {
return Some(line_start);
}
if !has_newline {
return None;
}
line_start += line.len() + 1;
}
}
fn reconcile_inner(inner: &str, value: &str) -> String {
let mut lines: Vec<String> = inner.split_inclusive('\n').map(str::to_string).collect();
lines.retain(|l| !is_top_level_key(l, "version"));
if let Some(meta_idx) = lines.iter().position(|l| is_top_level_key(l, "metadata")) {
set_version_in_metadata_block(&mut lines, meta_idx, value);
} else {
ensure_trailing_newline(&mut lines);
lines.push("metadata:\n".to_string());
lines.push(format!(" version: {value}\n"));
}
lines.concat()
}
fn set_version_in_metadata_block(lines: &mut Vec<String>, meta_idx: usize, value: &str) {
let mut first_child_indent: Option<String> = None;
let mut version_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate().skip(meta_idx + 1) {
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.trim().is_empty() {
continue; }
let indent = leading_ws(trimmed);
if indent.is_empty() {
break; }
if first_child_indent.is_none() {
first_child_indent = Some(indent.to_string());
}
if trimmed.trim_start().starts_with("version:") {
version_idx = Some(i);
break;
}
}
if let Some(vi) = version_idx {
let indent = leading_ws(&lines[vi]).to_string();
lines[vi] = format!("{indent}version: {value}\n");
} else {
let indent = first_child_indent.unwrap_or_else(|| " ".to_string());
lines.insert(meta_idx + 1, format!("{indent}version: {value}\n"));
}
}
fn is_top_level_key(line: &str, key: &str) -> bool {
let trimmed = line.trim_end_matches(['\n', '\r']);
trimmed.starts_with(key)
&& trimmed[key.len()..].starts_with(':')
&& !line.starts_with([' ', '\t'])
}
fn leading_ws(s: &str) -> &str {
&s[..s.len() - s.trim_start_matches([' ', '\t']).len()]
}
fn ensure_trailing_newline(lines: &mut [String]) {
if let Some(last) = lines.last_mut()
&& !last.ends_with('\n')
{
last.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
fn version_via_reader(content: &str) -> Option<String> {
let fm = content.strip_prefix("---\n")?;
let end = fm.find("\n---")?;
let fm = &fm[..end];
let mut in_metadata = false;
for line in fm.lines() {
if !line.starts_with([' ', '\t']) {
in_metadata = line.trim_end() == "metadata:";
if let Some(rest) = line.strip_prefix("version:") {
return Some(unquote(rest.trim()));
}
} else if in_metadata && let Some(rest) = line.trim_start().strip_prefix("version:") {
return Some(unquote(rest.trim()));
}
}
None
}
fn unquote(s: &str) -> String {
s.trim_matches('"').to_string()
}
#[test]
fn replaces_existing_metadata_version() {
let src = "---\nname: foo\nmetadata:\n version: \"0.0.0\"\n---\n# Body\n";
let out = set_metadata_version(src, "1.2.3");
assert_eq!(version_via_reader(&out).as_deref(), Some("1.2.3"));
assert!(out.contains("name: foo"), "other keys preserved");
assert!(out.contains("# Body"), "body preserved");
}
#[test]
fn adds_version_to_metadata_block_without_one() {
let src = "---\nname: foo\nmetadata:\n author: me\n---\n# Body\n";
let out = set_metadata_version(src, "2.0.0");
assert_eq!(version_via_reader(&out).as_deref(), Some("2.0.0"));
assert!(out.contains("author: me"), "sibling metadata key preserved");
}
#[test]
fn appends_metadata_block_when_absent() {
let src = "---\nname: foo\ndescription: bar\n---\n# Body\n";
let out = set_metadata_version(src, "3.1.0");
assert_eq!(version_via_reader(&out).as_deref(), Some("3.1.0"));
assert!(out.contains("metadata:\n version: \"3.1.0\""));
assert!(out.contains("description: bar"));
}
#[test]
fn removes_shadowing_top_level_version() {
let src = "---\nname: foo\nversion: 9.9.9\nmetadata:\n author: me\n---\n# Body\n";
let out = set_metadata_version(src, "1.0.0");
assert!(!out.contains("version: 9.9.9"), "top-level version removed");
assert_eq!(version_via_reader(&out).as_deref(), Some("1.0.0"));
}
#[test]
fn preserves_folded_description_block() {
let src = "---\nname: foo\ndescription: >\n line one\n line two\nmetadata:\n version: \"0.0.0\"\n---\n# Body\n";
let out = set_metadata_version(src, "4.5.6");
assert!(out.contains("description: >\n line one\n line two"), "folded block intact");
assert_eq!(version_via_reader(&out).as_deref(), Some("4.5.6"));
}
#[test]
fn preserves_comment_before_version() {
let src = "---\nmetadata:\n # placeholder\n version: \"0.0.0\"\n---\n# Body\n";
let out = set_metadata_version(src, "1.1.1");
assert!(out.contains("# placeholder"), "comment preserved");
assert_eq!(version_via_reader(&out).as_deref(), Some("1.1.1"));
}
#[test]
fn idempotent_second_pass_is_a_no_op() {
let src = "---\nname: foo\ndescription: bar\n---\n# Body\n";
let once = set_metadata_version(src, "3.1.0");
let twice = set_metadata_version(&once, "3.1.0");
assert_eq!(once, twice, "stamping an already-stamped skill must not change it");
}
#[test]
fn no_frontmatter_returns_unchanged() {
let src = "# Just a heading\n\nNo frontmatter here.\n";
assert_eq!(set_metadata_version(src, "1.0.0"), src);
}
#[test]
fn unterminated_frontmatter_returns_unchanged() {
let src = "---\nname: foo\n# never closed\n";
assert_eq!(set_metadata_version(src, "1.0.0"), src);
}
#[test]
fn does_not_match_versionish_keys() {
let src = "---\nversioning: semver\ndescription: bar\n---\n# Body\n";
let out = set_metadata_version(src, "1.0.0");
assert!(out.contains("versioning: semver"), "look-alike key preserved");
assert_eq!(version_via_reader(&out).as_deref(), Some("1.0.0"));
}
#[test]
fn crlf_frontmatter_is_reconciled() {
let src = "---\r\nname: foo\r\nmetadata:\r\n version: \"0.0.0\"\r\n---\r\n# Body\r\n";
let out = set_metadata_version(src, "7.7.7");
assert!(out.contains("version: \"7.7.7\""));
}
#[test]
fn reconcile_skill_version_only_touches_skill_md() {
let files = vec![
SkillFile::text("SKILL.md", "---\nname: foo\n---\n# Body\n"),
SkillFile::text("references/x.md", "# untouched version: 1\n"),
];
let out = reconcile_skill_version(&files, "2.2.2");
let skill = std::str::from_utf8(&out[0].bytes).unwrap();
assert_eq!(version_via_reader(skill).as_deref(), Some("2.2.2"));
assert_eq!(out[1].bytes, files[1].bytes, "non-SKILL.md file unchanged");
}
}