use std::path::Path;
use crate::core::agent_manifest::{atomic_write, checksum};
use crate::core::error::Error;
use crate::core::skill_manifest::{SkillManifest, SkillManifestEntry};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeployStats {
pub deployed: Vec<String>,
pub skipped: Vec<String>,
pub unchanged: Vec<String>,
}
fn is_skill_file(name: &str) -> bool {
!name.starts_with('.') && name.ends_with(".md")
}
fn skill_stem(filename: &str) -> &str {
filename.strip_suffix(".md").unwrap_or(filename)
}
pub fn deploy_skills(source: &Path, dest: &Path) -> Result<DeployStats, Error> {
let mut stats = DeployStats::default();
if !source.is_dir() {
return Ok(stats);
}
let mut manifest = SkillManifest::load(dest);
let now = chrono::Utc::now().to_rfc3339();
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if entry.file_type()?.is_file() && is_skill_file(name) {
names.push(name.to_string());
}
}
names.sort_unstable();
for filename in names {
let stem = skill_stem(&filename).to_string();
let source_path = source.join(&filename);
let content = std::fs::read_to_string(&source_path)?;
let skill_dir = dest.join(&stem);
let target_path = skill_dir.join("SKILL.md");
if target_path.exists() {
if !manifest.is_managed(&stem) {
stats.skipped.push(stem);
continue;
}
let current = std::fs::read_to_string(&target_path)?;
if manifest.checksum_matches(&stem, ¤t) {
if checksum(&content) == checksum(¤t) {
stats.unchanged.push(stem);
continue;
}
} else {
stats.skipped.push(stem);
continue;
}
}
std::fs::create_dir_all(&skill_dir)?;
atomic_write(&target_path, &content)?;
manifest.managed.insert(
stem.clone(),
SkillManifestEntry {
checksum: checksum(&content),
deployed_at: now.clone(),
},
);
stats.deployed.push(stem);
}
manifest.save(dest)?;
Ok(stats)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_sources(dir: &Path) {
fs::write(
dir.join("tm-doctor.md"),
"---\nname: tm-doctor\n---\n\n# Doctor\n\nDiagnostic skill.\n",
)
.unwrap();
fs::write(
dir.join("example-skill.md"),
"---\nname: example-skill\n---\n\n# Example\n\nExample skill.\n",
)
.unwrap();
}
#[test]
fn deploy_new_skill() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let stats = deploy_skills(src.path(), tgt.path()).unwrap();
assert_eq!(stats.deployed.len(), 2);
assert!(stats.deployed.contains(&"tm-doctor".to_string()));
assert!(stats.skipped.is_empty());
assert!(stats.unchanged.is_empty());
let doctor = fs::read_to_string(tgt.path().join("tm-doctor").join("SKILL.md")).unwrap();
assert!(doctor.contains("Diagnostic skill."));
let manifest = SkillManifest::load(tgt.path());
assert!(manifest.is_managed("tm-doctor"));
}
#[test]
fn deploy_skips_user_modified() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_skills(src.path(), tgt.path()).unwrap();
fs::write(
tgt.path().join("tm-doctor").join("SKILL.md"),
"---\nname: tm-doctor\n---\n\nUSER HAND-EDIT\n",
)
.unwrap();
let stats = deploy_skills(src.path(), tgt.path()).unwrap();
assert!(stats.skipped.contains(&"tm-doctor".to_string()));
assert!(!stats.deployed.contains(&"tm-doctor".to_string()));
let still = fs::read_to_string(tgt.path().join("tm-doctor").join("SKILL.md")).unwrap();
assert!(still.contains("USER HAND-EDIT"));
}
#[test]
fn deploy_unchanged_no_write() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_skills(src.path(), tgt.path()).unwrap();
let skill_md = tgt.path().join("tm-doctor").join("SKILL.md");
let before = fs::metadata(&skill_md).unwrap().modified().unwrap();
let stats = deploy_skills(src.path(), tgt.path()).unwrap();
assert!(stats.unchanged.contains(&"tm-doctor".to_string()));
assert!(stats.deployed.is_empty());
let after = fs::metadata(&skill_md).unwrap().modified().unwrap();
assert_eq!(before, after, "unchanged file must not be rewritten");
}
#[test]
fn deploy_user_owned_skipped() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
let user_dir = tgt.path().join("tm-doctor");
fs::create_dir_all(&user_dir).unwrap();
fs::write(user_dir.join("SKILL.md"), "USER OWNED — not trusty-mpm's\n").unwrap();
let stats = deploy_skills(src.path(), tgt.path()).unwrap();
assert!(stats.skipped.contains(&"tm-doctor".to_string()));
let content = fs::read_to_string(user_dir.join("SKILL.md")).unwrap();
assert_eq!(content, "USER OWNED — not trusty-mpm's\n");
assert!(stats.deployed.contains(&"example-skill".to_string()));
}
#[test]
fn deploy_refreshes_stale_managed_skill() {
let src = TempDir::new().unwrap();
let tgt = TempDir::new().unwrap();
write_sources(src.path());
deploy_skills(src.path(), tgt.path()).unwrap();
fs::write(
src.path().join("tm-doctor.md"),
"---\nname: tm-doctor\n---\n\n# Doctor v2\n",
)
.unwrap();
let stats = deploy_skills(src.path(), tgt.path()).unwrap();
assert!(stats.deployed.contains(&"tm-doctor".to_string()));
let refreshed = fs::read_to_string(tgt.path().join("tm-doctor").join("SKILL.md")).unwrap();
assert!(refreshed.contains("Doctor v2"));
}
#[test]
fn deploy_missing_source_dir_is_empty_result() {
let tgt = TempDir::new().unwrap();
let stats = deploy_skills(Path::new("/nonexistent/trusty-mpm/skills"), tgt.path()).unwrap();
assert_eq!(stats, DeployStats::default());
}
}