use std::collections::HashSet;
use crate::core::agent_manifest::{atomic_write, checksum};
use crate::core::bundle;
use crate::core::error::Result;
use crate::core::paths::FrameworkPaths;
const STAMP_FILE_NAME: &str = ".bundle-stamp";
pub fn skill_bundle_stamp() -> String {
let mut buf = String::new();
for artifact in bundle::ALL
.iter()
.filter(|a| a.rel_path.starts_with("skills/"))
{
buf.push_str(artifact.rel_path);
buf.push('\0');
buf.push_str(artifact.contents);
buf.push('\n');
}
checksum(&buf)
}
pub fn materialize_skill_artifacts(paths: &FrameworkPaths) -> Result<Vec<String>> {
std::fs::create_dir_all(&paths.skills)?;
let mut written = Vec::new();
let mut keep: HashSet<String> = HashSet::new();
for artifact in bundle::ALL
.iter()
.filter(|a| a.rel_path.starts_with("skills/"))
{
let basename = artifact
.rel_path
.strip_prefix("skills/")
.unwrap_or(artifact.rel_path);
let dest = paths.skills.join(basename);
atomic_write(&dest, artifact.contents)?;
keep.insert(basename.to_string());
written.push(basename.to_string());
}
for entry in std::fs::read_dir(&paths.skills)? {
let entry = entry?;
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
continue;
};
if name.starts_with('.') || !name.ends_with(".md") {
continue;
}
if !keep.contains(&name) {
std::fs::remove_file(entry.path())?;
}
}
Ok(written)
}
pub fn ensure_skill_source_fresh(paths: &FrameworkPaths) -> Result<bool> {
if paths.skill_source_dir() != paths.skills {
return Ok(false);
}
let stamp_path = paths.skills.join(STAMP_FILE_NAME);
let current = skill_bundle_stamp();
let on_disk = std::fs::read_to_string(&stamp_path).ok();
if on_disk.as_deref() == Some(current.as_str()) {
return Ok(false);
}
materialize_skill_artifacts(paths)?;
atomic_write(&stamp_path, ¤t)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_bundle_stamp_is_stable_across_calls() {
assert_eq!(skill_bundle_stamp(), skill_bundle_stamp());
}
#[test]
fn materialize_skill_artifacts_writes_all_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = FrameworkPaths::under(tmp.path());
let written = materialize_skill_artifacts(&paths).unwrap();
let expected: Vec<&str> = bundle::ALL
.iter()
.filter(|a| a.rel_path.starts_with("skills/"))
.map(|a| a.rel_path.strip_prefix("skills/").unwrap())
.collect();
assert_eq!(written.len(), expected.len());
for name in expected {
let content = std::fs::read_to_string(paths.skills.join(name))
.unwrap_or_else(|e| panic!("missing {name}: {e}"));
let artifact = bundle::ALL
.iter()
.find(|a| a.rel_path == format!("skills/{name}"))
.unwrap();
assert_eq!(content, artifact.contents);
}
}
#[test]
fn materialize_skill_artifacts_prunes_files_not_in_table() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = FrameworkPaths::under(tmp.path());
std::fs::create_dir_all(&paths.skills).unwrap();
std::fs::write(paths.skills.join("mpm-old-skill.md"), "stale\n").unwrap();
materialize_skill_artifacts(&paths).unwrap();
assert!(!paths.skills.join("mpm-old-skill.md").exists());
assert!(paths.skills.join("tm-doctor.md").exists());
}
#[test]
fn ensure_skill_source_fresh_materializes_when_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = FrameworkPaths::under(tmp.path());
assert!(!paths.skills.exists());
let refreshed = ensure_skill_source_fresh(&paths).unwrap();
assert!(refreshed, "a missing source dir must trigger a refresh");
assert!(paths.skills.join("tm-doctor.md").exists());
assert!(paths.skills.join(STAMP_FILE_NAME).exists());
}
#[test]
fn ensure_skill_source_fresh_is_noop_when_current() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = FrameworkPaths::under(tmp.path());
assert!(ensure_skill_source_fresh(&paths).unwrap());
let marker = paths.skills.join("tm-doctor.md");
let before = std::fs::metadata(&marker).unwrap().modified().unwrap();
let refreshed = ensure_skill_source_fresh(&paths).unwrap();
assert!(!refreshed, "an already-current source dir must be a no-op");
let after = std::fs::metadata(&marker).unwrap().modified().unwrap();
assert_eq!(before, after, "a no-op refresh must not rewrite files");
}
#[test]
fn ensure_skill_source_fresh_prunes_renamed_files() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = FrameworkPaths::under(tmp.path());
std::fs::create_dir_all(&paths.skills).unwrap();
std::fs::write(paths.skills.join("mpm-old-skill.md"), "stale\n").unwrap();
std::fs::write(paths.skills.join(STAMP_FILE_NAME), "stale-stamp").unwrap();
let refreshed = ensure_skill_source_fresh(&paths).unwrap();
assert!(refreshed, "a stale stamp must trigger a refresh");
assert!(!paths.skills.join("mpm-old-skill.md").exists());
assert!(paths.skills.join("tm-doctor.md").exists());
}
#[test]
fn ensure_skill_source_fresh_skips_submodule_source() {
let tmp = tempfile::TempDir::new().unwrap();
let submodule_root = tempfile::TempDir::new().unwrap();
let submodule_skills = submodule_root.path().join("agents").join("skills");
std::fs::create_dir_all(&submodule_skills).unwrap();
let mut paths = FrameworkPaths::under(tmp.path());
paths.trusty_mpm_root = Some(submodule_root.path().to_path_buf());
assert_eq!(paths.skill_source_dir(), submodule_skills);
let refreshed = ensure_skill_source_fresh(&paths).unwrap();
assert!(!refreshed);
assert!(
!paths.skills.exists(),
"must never create paths.skills when a submodule source is in play"
);
}
}