use super::*;
use crate::core::update_check::detect_for_framework;
use crate::provisioner::FakeGitBackend;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
fn mark_catalog_repo_as_valid(catalog_root: &Path) {
let git_dir = catalog_root.join("repo/.git");
fs::create_dir_all(&git_dir).unwrap();
fs::write(
git_dir.join("config"),
"[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = https://github.com/bobmatnyc/claude-mpm\n",
)
.unwrap();
}
fn seed_catalog_agent(catalog_root: &Path, stem: &str, body: &str) {
mark_catalog_repo_as_valid(catalog_root);
let dir = catalog_root.join("repo/.claude/agents");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(format!("{stem}.md")),
format!("---\nname: {stem}\nrole: {stem}\n---\n\n# {stem}\n\n{body}\n"),
)
.unwrap();
}
fn seed_catalog_skill(catalog_root: &Path, stem: &str, body: &str) {
mark_catalog_repo_as_valid(catalog_root);
let dir = catalog_root.join("repo/.claude/skills");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(format!("{stem}.md")),
format!("# {stem}\n\n{body}\n"),
)
.unwrap();
}
fn seed_catalog_skill_dir(catalog_root: &Path, stem: &str, body: &str, reference: &str) {
mark_catalog_repo_as_valid(catalog_root);
let dir = catalog_root.join("repo/.claude/skills").join(stem);
fs::create_dir_all(dir.join("references")).unwrap();
fs::write(dir.join("SKILL.md"), format!("# {stem}\n\n{body}\n")).unwrap();
fs::write(dir.join("references/notes.md"), reference).unwrap();
}
fn rewrite_manifest(project_dir: &Path, body: &str) {
fs::write(
project_dir
.join(".trusty-mpm")
.join("framework")
.join("manifest.toml"),
body,
)
.unwrap();
}
fn prune_backup_dir(fw_root: &Path) -> Option<std::path::PathBuf> {
fs::read_dir(fw_root)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("backup-catalog-prune-"))
})
}
fn write_catalog_manifest(project_dir: &Path) {
let dir = project_dir.join(".trusty-mpm").join("framework");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("manifest.toml"),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\n",
)
.unwrap();
}
#[test]
fn apply_redeploys_and_clears_staleness() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
write_catalog_manifest(project.path());
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "rust-engineer", "ENGINEER BODY");
seed_catalog_skill(&catalog_root, "tm-doctor", "DOCTOR BODY");
let report = apply_catalog(
FakeGitBackend::new(),
&fw,
project.path(),
true, false, )
.unwrap();
assert!(
report
.agents_deployed
.contains(&"rust-engineer.md".to_string()),
"agent deployed: {report:?}"
);
assert!(
report.skills_deployed.contains(&"tm-doctor".to_string()),
"skill deployed: {report:?}"
);
assert!(fw.agent_deploy_dir().join("rust-engineer.md").is_file());
assert!(
fw.claude_skills_dir()
.join("tm-doctor")
.join("SKILL.md")
.is_file()
);
let report = detect_for_framework(&fw, project.path());
assert!(!report.stale, "apply must clear staleness: {report:?}");
assert!(!report.unknown, "catalog is synced: {report:?}");
}
#[test]
fn apply_is_stale_before_apply_then_fresh_after() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
write_catalog_manifest(project.path());
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "newcomer", "NEW BODY");
let before = detect_for_framework(&fw, project.path());
assert!(
before.stale,
"new catalog agent is stale before apply: {before:?}"
);
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let after = detect_for_framework(&fw, project.path());
assert!(!after.stale, "apply clears it: {after:?}");
}
#[test]
fn apply_prune_removes_deselected() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "keep-me", "KEEP");
seed_catalog_agent(&catalog_root, "drop-me", "DROP");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
assert!(fw.agent_deploy_dir().join("drop-me.md").is_file());
fs::write(
project
.path()
.join(".trusty-mpm")
.join("framework")
.join("manifest.toml"),
"[agents]\nsource = \"catalog\"\nexclude = [\"drop-me\"]\n\n[skills]\nsource = \"catalog\"\n",
)
.unwrap();
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(
report.agents_pruned.contains(&"drop-me.md".to_string()),
"drop-me must be pruned: {report:?}"
);
assert!(
!fw.agent_deploy_dir().join("drop-me.md").exists(),
"pruned agent file removed"
);
assert!(
fw.agent_deploy_dir().join("keep-me.md").is_file(),
"selected agent retained"
);
let manifest = AgentManifest::load(&fw.agent_deploy_dir());
assert!(!manifest.is_managed("drop-me.md"));
assert!(manifest.is_managed("keep-me.md"));
let backup = prune_backup_dir(&fw.root).expect("a prune that deleted must leave a backup");
assert!(
fs::read_to_string(backup.join("agents/drop-me.md"))
.unwrap()
.contains("DROP"),
"the pruned agent is backed up with its original content"
);
}
#[test]
fn apply_preserves_user_tier_override_deployed_via_launch_path() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
write_catalog_manifest(project.path());
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "tm-doctor", "CATALOG BODY");
let user_source = fw.user_skill_source_dir();
fs::create_dir_all(&user_source).unwrap();
fs::write(
user_source.join("tm-doctor.md"),
"---\nname: tm-doctor\n---\n\nUSER OVERRIDE BODY\n",
)
.unwrap();
let sources = crate::core::manifest::ManifestSources::resolve(project.path(), &catalog_root);
let manifest = crate::core::manifest::resolve_manifest(&sources);
let plan = HarnessPlan::from_manifest(&manifest, &fw, &catalog_root);
crate::core::skill_tiers::deploy_all_skill_tiers(
&plan.skill_source,
&fw.user_skill_source_dir(),
&fw.claude_skills_dir(),
|name| plan.skill_selected(name),
)
.unwrap();
let deployed_path = fw.claude_skills_dir().join("tm-doctor").join("SKILL.md");
let after_launch = fs::read_to_string(&deployed_path).unwrap();
assert!(
after_launch.contains("USER OVERRIDE BODY"),
"user tier must win at launch: {after_launch}"
);
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let after_apply = fs::read_to_string(&deployed_path).unwrap();
assert_eq!(
after_apply, after_launch,
"apply_catalog must preserve the user-tier override byte-identical"
);
assert!(
after_apply.contains("USER OVERRIDE BODY"),
"override must survive apply: {after_apply}"
);
}
#[test]
fn apply_prune_spares_user_owned() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "managed-agent", "BODY");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let user_file = fw.agent_deploy_dir().join("my-own.md");
fs::write(&user_file, "USER OWNED").unwrap();
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(
!report.agents_pruned.contains(&"my-own.md".to_string()),
"user-owned file must not be pruned: {report:?}"
);
assert!(user_file.is_file(), "user-owned file survives prune");
assert_eq!(fs::read_to_string(&user_file).unwrap(), "USER OWNED");
}
#[test]
fn apply_prune_removes_deselected_skill() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "keep-me", "KEEP");
seed_catalog_skill(&catalog_root, "drop-me", "DROP");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
assert!(fw.claude_skills_dir().join("drop-me/SKILL.md").is_file());
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\nexclude = [\"drop-me\"]\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(
report.skills_pruned.contains(&"drop-me".to_string()),
"deselected pristine skill must be pruned: {report:?}"
);
assert!(
!fw.claude_skills_dir().join("drop-me").exists(),
"pruned skill directory removed"
);
assert!(
fw.claude_skills_dir().join("keep-me/SKILL.md").is_file(),
"selected skill retained"
);
let manifest = SkillManifest::load(&fw.claude_skills_dir());
assert!(!manifest.is_managed("drop-me"));
assert!(manifest.is_managed("keep-me"));
}
#[test]
fn apply_prune_skips_hand_edited_skill() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "edited", "ORIGINAL");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let deployed = fw.claude_skills_dir().join("edited/SKILL.md");
fs::write(&deployed, "# edited\n\nMY OWN NOTES\n").unwrap();
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\nexclude = [\"edited\"]\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert_eq!(
fs::read_to_string(&deployed).unwrap(),
"# edited\n\nMY OWN NOTES\n",
"a hand-edited skill must survive --prune byte-identical"
);
assert!(
!report.skills_pruned.contains(&"edited".to_string()),
"hand-edited skill must not be reported pruned: {report:?}"
);
assert!(
SkillManifest::load(&fw.claude_skills_dir()).is_managed("edited"),
"the kept skill stays managed"
);
}
#[test]
fn apply_prune_spares_user_tier_skill() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "bundled-one", "BUNDLED");
let user_source = fw.user_skill_source_dir();
fs::create_dir_all(&user_source).unwrap();
fs::write(user_source.join("mine.md"), "# mine\n\nMY SKILL\n").unwrap();
write_catalog_manifest(project.path());
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\ninclude = [\"bundled-one\"]\n",
);
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let deployed = fw.claude_skills_dir().join("mine/SKILL.md");
assert!(
deployed.is_file(),
"the user tier deploys regardless of the bundled selection"
);
assert!(
SkillManifest::load(&fw.claude_skills_dir()).is_managed("mine"),
"and it lands in the ledger looking exactly like a bundled skill"
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert_eq!(
fs::read_to_string(&deployed).unwrap(),
"# mine\n\nMY SKILL\n",
"a user-custom skill must survive --prune even when pristine"
);
assert!(
!report.skills_pruned.contains(&"mine".to_string()),
"user-tier skill must not be pruned: {report:?}"
);
}
#[test]
fn apply_prune_spares_untracked_file_in_a_managed_skill() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "hosted", "HOSTED");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let stray = fw.claude_skills_dir().join("hosted/my-notes.md");
fs::write(&stray, "USER NOTES").unwrap();
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\nexclude = [\"hosted\"]\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(stray.is_file(), "the user's own file must survive --prune");
assert_eq!(fs::read_to_string(&stray).unwrap(), "USER NOTES");
assert!(
!report.skills_pruned.contains(&"hosted".to_string()),
"a skill holding an untracked file must not be pruned: {report:?}"
);
}
#[test]
fn apply_prune_backs_up_before_removing_a_skill() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill_dir(&catalog_root, "doomed", "DOOMED BODY", "REFERENCE BODY");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
assert!(
fw.claude_skills_dir()
.join("doomed/references/notes.md")
.is_file()
);
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\nexclude = [\"doomed\"]\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(report.skills_pruned.contains(&"doomed".to_string()));
assert!(!fw.claude_skills_dir().join("doomed").exists());
let backup = prune_backup_dir(&fw.root).expect("a prune that deleted must leave a backup");
let entry = backup.join("skills/doomed/SKILL.md");
assert!(entry.is_file(), "backup holds the entry point: {backup:?}");
assert!(
fs::read_to_string(&entry).unwrap().contains("DOOMED BODY"),
"backup holds the ORIGINAL content"
);
assert!(
fs::read_to_string(backup.join("skills/doomed/references/notes.md"))
.unwrap()
.contains("REFERENCE BODY"),
"backup holds the carried reference file too"
);
}
#[test]
fn apply_prune_keeps_the_ledger_entry_for_a_selected_skills_carried_file() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill_dir(&catalog_root, "carrier", "CARRIER BODY", "REFERENCE BODY");
write_catalog_manifest(project.path());
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\ninclude = [\"carrier\"]\n",
);
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let key = "carrier/references/notes.md";
assert!(SkillManifest::load(&fw.claude_skills_dir()).is_managed(key));
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert!(
report.skills_pruned.is_empty(),
"nothing is deselected here: {report:?}"
);
assert!(
fw.claude_skills_dir()
.join("carrier/references/notes.md")
.is_file()
);
assert!(
SkillManifest::load(&fw.claude_skills_dir()).is_managed(key),
"a selected skill's carried file keeps its ledger entry"
);
}
#[test]
fn apply_prune_skips_hand_edited_agent() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "tweaked", "ORIGINAL");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let deployed = fw.agent_deploy_dir().join("tweaked.md");
fs::write(&deployed, "---\nname: tweaked\n---\n\nMY OWN AGENT\n").unwrap();
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\nexclude = [\"tweaked\"]\n\n[skills]\nsource = \"catalog\"\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert_eq!(
fs::read_to_string(&deployed).unwrap(),
"---\nname: tweaked\n---\n\nMY OWN AGENT\n",
"a hand-edited agent must survive --prune byte-identical"
);
assert!(
!report.agents_pruned.contains(&"tweaked.md".to_string()),
"hand-edited agent must not be reported pruned: {report:?}"
);
assert!(AgentManifest::load(&fw.agent_deploy_dir()).is_managed("tweaked.md"));
}
#[test]
fn apply_prune_reports_what_it_kept_and_where_backups_went() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_skill(&catalog_root, "pristine", "PRISTINE");
seed_catalog_skill(&catalog_root, "edited", "ORIGINAL");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
fs::write(
fw.claude_skills_dir().join("edited/SKILL.md"),
"MY OWN NOTES",
)
.unwrap();
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\n\n[skills]\nsource = \"catalog\"\nexclude = [\"pristine\", \"edited\"]\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert_eq!(report.skills_pruned, vec!["pristine".to_string()]);
assert_eq!(report.skills_prune_kept.len(), 1, "{report:?}");
assert!(
report.skills_prune_kept[0].starts_with("edited: "),
"the kept entry names the skill and the reason: {report:?}"
);
let backup = report
.prune_backup_dir
.as_ref()
.expect("something was deleted, so a backup root is reported");
assert!(backup.join("skills/pristine/SKILL.md").is_file());
}
#[test]
fn apply_prune_spares_a_user_origin_agent() {
let fw_root = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let fw = crate::core::paths::FrameworkPaths::from_root(fw_root.path().join(".trusty-mpm"));
let catalog_root = crate::content::catalog_root_for(&fw.root);
seed_catalog_agent(&catalog_root, "operator-owned", "BODY");
write_catalog_manifest(project.path());
apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, false).unwrap();
let deployed = fw.agent_deploy_dir().join("operator-owned.md");
let before = fs::read_to_string(&deployed).unwrap();
let target = fw.agent_deploy_dir();
let mut ledger = AgentManifest::load(&target);
ledger
.managed
.get_mut("operator-owned.md")
.expect("the deploy recorded it")
.origin = crate::core::agent_manifest::Origin::User;
ledger.save(&target).unwrap();
rewrite_manifest(
project.path(),
"[agents]\nsource = \"catalog\"\nexclude = [\"operator-owned\"]\n\n[skills]\nsource = \"catalog\"\n",
);
let report = apply_catalog(FakeGitBackend::new(), &fw, project.path(), true, true).unwrap();
assert_eq!(
fs::read_to_string(&deployed).unwrap(),
before,
"a user-origin agent must survive --prune even while pristine"
);
assert!(
!report
.agents_pruned
.contains(&"operator-owned.md".to_string()),
"user-origin agent must not be pruned: {report:?}"
);
assert_eq!(
report.agents_prune_kept.len(),
1,
"and the operator is told it was kept: {report:?}"
);
assert!(
report.agents_prune_kept[0].starts_with("operator-owned.md: "),
"the kept entry names the agent and the reason: {report:?}"
);
let after = AgentManifest::load(&target);
assert_eq!(
after.managed.get("operator-owned.md").map(|e| e.origin),
Some(crate::core::agent_manifest::Origin::User)
);
}