use super::*;
use crate::core::content_source::test_support::rc;
use tempfile::TempDir;
const BASE_AGENT: &str = "---\nname: base-agent\nrole: base\n---\n\n# Base\n\nBase content.\n";
const ENGINEER: &str = "---\nname: engineer\nrole: engineer\nextends: base-agent\n---\n\n# Engineer\n\nEngineer content.\n";
const DEMO_SKILL: &str = "---\nname: demo-skill\n---\n\n# Demo\n\nBundled skill text.\n";
fn seeded(base: &std::path::Path) -> FrameworkPaths {
let paths = FrameworkPaths::under(base);
std::fs::create_dir_all(&paths.agents).unwrap();
std::fs::write(paths.agents.join("base-agent.md"), BASE_AGENT).unwrap();
std::fs::write(paths.agents.join("engineer.md"), ENGINEER).unwrap();
std::fs::write(
paths
.agents
.join(crate::core::agent_source::STAMP_FILE_NAME),
crate::core::agent_source::agent_bundle_stamp(
&crate::core::content_source::test_support::repo_roster(),
),
)
.unwrap();
std::fs::create_dir_all(&paths.skills).unwrap();
std::fs::write(paths.skills.join("demo-skill.md"), DEMO_SKILL).unwrap();
std::fs::write(
paths
.skills
.join(crate::core::skill_source::STAMP_FILE_NAME),
crate::core::skill_source::skill_bundle_stamp(rc()),
)
.unwrap();
paths
}
fn standalone_dir(base: &std::path::Path) -> std::path::PathBuf {
base.join(".trusty-mpm").join("claude-config")
}
fn seed_user_skill(paths: &FrameworkPaths) {
let dir = paths.user_skill_source_dir();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("my-skill.md"),
"---\nname: my-skill\n---\n\n# Mine\n\nUser-custom skill text.\n",
)
.unwrap();
}
fn tier<'a>(report: &'a ReinstallReport, label: &str) -> &'a TierReport {
report
.tiers
.iter()
.find(|t| t.label == label)
.unwrap_or_else(|| panic!("no `{label}` tier in {:?}", report.tiers))
}
#[test]
fn targets_cover_the_standalone_tier() {
let base = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let sa = standalone_dir(base.path());
let targets = reinstall_targets(&paths, &sa, None);
let standalone = targets
.iter()
.find(|t| t.label == "standalone")
.expect("the standalone tier must be covered");
assert_eq!(standalone.skills_dir, sa.join("skills"));
assert_eq!(standalone.agents_dir, Some(sa.join("agents")));
}
#[test]
fn targets_attach_agents_to_two_destinations_only() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let targets = reinstall_targets(&paths, &standalone_dir(base.path()), Some(project.path()));
let with_agents: Vec<&str> = targets
.iter()
.filter(|t| t.agents_dir.is_some())
.map(|t| t.label.as_str())
.collect();
assert_eq!(with_agents, vec!["managed config", "standalone"]);
assert_eq!(
tier_dir(&targets, "managed config"),
Some(paths.agent_deploy_dir())
);
}
fn tier_dir(targets: &[ReinstallTarget], label: &str) -> Option<std::path::PathBuf> {
targets
.iter()
.find(|t| t.label == label)
.and_then(|t| t.agents_dir.clone())
}
#[test]
fn targets_deduplicate() {
let base = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let managed_config = paths.agent_deploy_dir().parent().unwrap().to_path_buf();
let targets = reinstall_targets(&paths, &managed_config, None);
assert!(
!targets.iter().any(|t| t.label == "standalone"),
"the standalone tier duplicates the managed one here: {targets:?}"
);
}
#[test]
fn reinstall_reports_every_target() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = seeded(base.path());
let backups = base.path().join("backups");
std::fs::create_dir_all(project.path().join(".claude").join("skills")).unwrap();
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
Some(project.path()),
false,
&backups,
);
assert!(!report.has_failures(), "{report:?}");
assert_eq!(report.tiers.len(), 4, "{:?}", report.tiers);
let managed = tier(&report, "managed config");
assert_eq!(managed.skills.deployed, 1, "{managed:?}");
assert!(
managed
.skills_dir
.join("demo-skill")
.join("SKILL.md")
.is_file()
);
for label in ["operator home", "project", "standalone"] {
let t = tier(&report, label);
assert_eq!(t.skills.deployed, 0, "{label}: {t:?}");
}
for label in ["managed config", "standalone"] {
let t = tier(&report, label);
assert_eq!(t.agents.deployed, 2, "{label}: {t:?}");
let dir = t.agents_dir.as_ref().unwrap();
assert!(dir.join("engineer.md").is_file());
}
assert!(!paths.claude_agents_dir().join("engineer.md").exists());
assert!(
!project
.path()
.join(".claude")
.join("agents")
.join("engineer.md")
.exists()
);
}
#[test]
fn reinstall_deploys_the_bundled_roster_to_the_managed_tier_only() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = seeded(base.path());
seed_user_skill(&paths);
std::fs::create_dir_all(project.path().join(".claude").join("skills")).unwrap();
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
Some(project.path()),
false,
&base.path().join("backups"),
);
assert!(!report.has_failures(), "{report:?}");
assert_eq!(report.tiers.len(), 4, "{:?}", report.tiers);
assert!(
paths
.skill_deploy_dir()
.join("demo-skill")
.join("SKILL.md")
.is_file(),
"the managed tier must still receive the bundled roster"
);
for label in ["operator home", "project", "standalone"] {
let t = tier(&report, label);
assert!(
!t.skills_dir.join("demo-skill").exists(),
"{label} must receive no bundled skill: {t:?}"
);
assert!(
t.skills_dir.join("my-skill").join("SKILL.md").is_file(),
"{label} must still receive the user-custom tier: {t:?}"
);
}
}
#[test]
fn reinstall_creates_a_missing_destination() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
seed_user_skill(&paths);
let sa = standalone_dir(base.path());
assert!(!sa.exists());
let report = reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
assert!(!report.has_failures(), "{report:?}");
assert!(sa.join("agents").join("engineer.md").is_file());
assert!(
sa.join("skills")
.join("my-skill")
.join("SKILL.md")
.is_file()
);
}
#[test]
fn reinstall_refreshes_the_source_first() {
let base = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
None,
false,
&base.path().join("backups"),
);
assert!(report.agent_source_refreshed, "{report:?}");
assert!(report.skill_source_refreshed, "{report:?}");
assert!(paths.agents.join("engineer.md").is_file());
assert!(paths.skills.join("tm-doctor.md").is_file());
assert!(tier(&report, "managed config").agents.deployed > 0);
}
#[test]
fn reinstall_preserves_a_customized_agent_without_force() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let dest = paths.agent_deploy_dir();
let custom = "---\nname: engineer\n---\n\nMINE — KEEP ME\n";
seed_untracked_agent(&dest, custom);
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
None,
false,
&base.path().join("backups"),
);
let t = tier(&report, "managed config");
assert_eq!(t.agents.preserved, 1, "{t:?}");
assert_eq!(
std::fs::read_to_string(dest.join("engineer.md")).unwrap(),
custom
);
assert!(
t.notes.iter().any(|n| n.contains("tm reinstall --force")),
"the preserved note must name the escape hatch: {:?}",
t.notes
);
}
#[test]
fn reinstall_replaces_a_customized_agent_with_force() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let dest = paths.agent_deploy_dir();
seed_untracked_agent(&dest, "---\nname: engineer\n---\n\nMINE\n");
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
None,
true,
&base.path().join("backups"),
);
let t = tier(&report, "managed config");
assert_eq!(t.agents.preserved, 0, "{t:?}");
let deployed = std::fs::read_to_string(dest.join("engineer.md")).unwrap();
assert!(deployed.contains("Engineer content."), "{deployed}");
let backups: Vec<_> = std::fs::read_dir(&dest)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".bak-"))
.collect();
assert_eq!(backups.len(), 1, "the overwrite must be backed up");
}
#[test]
fn reinstall_repairs_a_corrupt_agent_without_force() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let sa = standalone_dir(base.path());
let dest = paths.agent_deploy_dir();
reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
std::fs::write(dest.join("engineer.md"), "---\nname: engineer\n---\n\nv1\n").unwrap();
let report = reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
let t = tier(&report, "managed config");
assert_eq!(t.agents.repaired, 1, "{t:?}");
assert_eq!(t.agents.preserved, 0, "{t:?}");
assert!(
std::fs::read_to_string(dest.join("engineer.md"))
.unwrap()
.contains("Engineer content.")
);
}
fn seed_untracked_agent(dest: &std::path::Path, content: &str) {
std::fs::create_dir_all(dest).unwrap();
std::fs::write(dest.join("engineer.md"), content).unwrap();
}
#[test]
fn reinstall_preserves_a_customized_skill_without_force() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let sa = standalone_dir(base.path());
let dest = paths.agent_deploy_dir().parent().unwrap().join("skills");
reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
let deployed = dest.join("demo-skill").join("SKILL.md");
std::fs::write(&deployed, "MY OWN TEXT\n").unwrap();
let report = reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
let t = tier(&report, "managed config");
assert_eq!(t.skills.preserved, 1, "{t:?}");
assert_eq!(std::fs::read_to_string(&deployed).unwrap(), "MY OWN TEXT\n");
}
#[test]
fn reinstall_replaces_a_customized_skill_with_force() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let sa = standalone_dir(base.path());
let backups = base.path().join("backups");
let dest = paths.agent_deploy_dir().parent().unwrap().join("skills");
reinstall_assets(&paths, &sa, None, false, &backups);
let deployed = dest.join("demo-skill").join("SKILL.md");
std::fs::write(&deployed, "MY OWN TEXT\n").unwrap();
let report = reinstall_assets(&paths, &sa, None, true, &backups);
let t = tier(&report, "managed config");
assert_eq!(t.skills.preserved, 0, "{t:?}");
assert!(
std::fs::read_to_string(&deployed)
.unwrap()
.contains("Bundled skill text.")
);
assert!(
backups.join("moved-paths.log").is_file(),
"the clobber must leave a backup ledger under {}",
backups.display()
);
}
#[test]
fn force_leaves_an_operator_skill_alone() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let sa = standalone_dir(base.path());
let dest = paths.agent_deploy_dir().parent().unwrap().join("skills");
let mine = dest.join("my-own-skill");
std::fs::create_dir_all(&mine).unwrap();
std::fs::write(mine.join("SKILL.md"), "OPERATOR CONTENT\n").unwrap();
reinstall_assets(&paths, &sa, None, true, &base.path().join("backups"));
assert_eq!(
std::fs::read_to_string(mine.join("SKILL.md")).unwrap(),
"OPERATOR CONTENT\n"
);
}
#[test]
fn reinstall_reports_a_failed_destination_and_serves_the_others() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
seed_user_skill(&paths);
let sa = standalone_dir(base.path());
let managed_skills = paths.agent_deploy_dir().parent().unwrap().join("skills");
std::fs::create_dir_all(managed_skills.parent().unwrap()).unwrap();
std::fs::write(&managed_skills, "not a directory").unwrap();
let report = reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
assert!(report.has_failures(), "{report:?}");
assert_eq!(tier(&report, "standalone").skills.deployed, 1);
assert!(
sa.join("skills")
.join("my-skill")
.join("SKILL.md")
.is_file()
);
}
#[test]
fn reinstall_serialises_against_a_held_skill_ledger_lock() {
use std::sync::mpsc;
use trusty_agents_common::agents::manifest::ManifestError;
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
seed_user_skill(&paths);
let sa = standalone_dir(base.path());
let dest = sa.join("skills");
std::fs::create_dir_all(&dest).unwrap();
let (tx, rx) = mpsc::channel();
let paths_moved = paths.clone();
let sa_moved = sa.clone();
let backups = base.path().join("backups");
let marker = dest.join("my-skill").join("SKILL.md");
let handle = trusty_agents_common::skills::manifest::with_skill_manifest_lock::<
_,
ManifestError,
_,
>(&dest, || {
let handle = std::thread::spawn(move || {
tx.send(reinstall_assets(
&paths_moved,
&sa_moved,
None,
false,
&backups,
))
.ok();
});
assert!(
rx.recv_timeout(std::time::Duration::from_secs(2)).is_err(),
"the reinstall finished while another writer held the skill ledger lock"
);
assert!(
!marker.exists(),
"the reinstall wrote a skill file while the ledger lock was held"
);
Ok(handle)
})
.unwrap();
handle.join().unwrap();
let report = rx
.recv_timeout(std::time::Duration::from_secs(30))
.expect("the reinstall must complete once the lock is released");
assert!(marker.is_file(), "{report:?}");
}
#[test]
fn preserved_note_is_empty_when_nothing_was_preserved() {
assert_eq!(preserved_note("agent", &[], ""), None);
}
#[test]
fn preserved_note_names_the_force_flag() {
let note = preserved_note("skill", &["a.md".to_string()], "a.md").unwrap();
assert!(note.contains("1 skill file(s)"), "{note}");
assert!(note.contains("tm reinstall --force"), "{note}");
}
#[test]
fn reinstall_surfaces_a_shadowed_bundled_skill() {
let base = TempDir::new().unwrap();
let paths = seeded(base.path());
let sa = standalone_dir(base.path());
let dest = paths.agent_deploy_dir().parent().unwrap().join("skills");
let stray = dest.join("demo-skill");
std::fs::create_dir_all(&stray).unwrap();
std::fs::write(stray.join("SKILL.md"), "LOST TRACKING\n").unwrap();
let report = reinstall_assets(&paths, &sa, None, false, &base.path().join("backups"));
let t = tier(&report, "managed config");
assert_eq!(t.skills.deployed, 0, "{t:?}");
assert_eq!(t.skills.preserved, 0, "{t:?}");
assert_eq!(t.skills.unchanged, 0, "{t:?}");
assert_eq!(
t.skills.shadowed, 1,
"a skill in none of the other counts must be counted as shadowed: {t:?}"
);
assert!(
t.notes.iter().any(|n| n.contains("demo-skill")),
"the report must NAME it, not just count it: {:?}",
t.notes
);
assert!(
t.notes.iter().any(|n| n.contains("tm reinstall --force")),
"a bundled-named untracked skill is #4605 and has a remedy: {:?}",
t.notes
);
let forced = reinstall_assets(&paths, &sa, None, true, &base.path().join("backups"));
assert_eq!(tier(&forced, "managed config").skills.shadowed, 0);
assert!(
std::fs::read_to_string(stray.join("SKILL.md"))
.unwrap()
.contains("Bundled skill text.")
);
}
#[test]
fn shadowed_note_is_empty_when_nothing_was_shadowed() {
assert_eq!(shadowed_note(&[], &BTreeSet::new()), None);
}
#[test]
fn shadowed_note_points_at_force_only_for_a_bundled_name() {
use trusty_agents_common::skills::tiers::{Shadow, SkillTier};
let bundled: BTreeSet<String> = ["demo-skill".to_string()].into_iter().collect();
let deliberate = [Shadow {
stem: "demo-skill".to_string(),
winner: SkillTier::User,
loser: SkillTier::Bundled,
}];
let note = shadowed_note(&deliberate, &bundled).unwrap();
assert!(note.contains("kept by user-custom"), "{note}");
assert!(!note.contains("tm reinstall --force"), "{note}");
let orphaned = [Shadow {
stem: "demo-skill".to_string(),
winner: SkillTier::Project,
loser: SkillTier::Bundled,
}];
let note = shadowed_note(&orphaned, &bundled).unwrap();
assert!(note.contains("tm reinstall --force"), "{note}");
let operators = [Shadow {
stem: "my-own-skill".to_string(),
winner: SkillTier::Project,
loser: SkillTier::Bundled,
}];
let note = shadowed_note(&operators, &bundled).unwrap();
assert!(!note.contains("tm reinstall --force"), "{note}");
}
#[test]
fn targets_omit_the_project_tier_until_it_exists() {
let base = TempDir::new().unwrap();
let bare = TempDir::new().unwrap();
let paths = FrameworkPaths::under(base.path());
let sa = standalone_dir(base.path());
let targets = reinstall_targets(&paths, &sa, Some(bare.path()));
assert!(
!targets.iter().any(|t| t.label == "project"),
"a bare directory is not a project tier: {targets:?}"
);
std::fs::create_dir_all(bare.path().join(".claude").join("skills")).unwrap();
let targets = reinstall_targets(&paths, &sa, Some(bare.path()));
let project = targets
.iter()
.find(|t| t.label == "project")
.expect("an existing project tier must be refreshed");
assert!(project.optional, "{project:?}");
assert_eq!(project.agents_dir, None, "#4409: never agents in a project");
}
#[test]
fn reinstall_never_writes_into_a_bare_working_directory() {
let base = TempDir::new().unwrap();
let bare = TempDir::new().unwrap();
let paths = seeded(base.path());
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
Some(bare.path()),
false,
&base.path().join("backups"),
);
assert!(!report.has_failures(), "{report:?}");
assert!(
!bare.path().join(".claude").exists(),
"a bare working directory must be left completely untouched"
);
assert_eq!(
std::fs::read_dir(bare.path()).unwrap().count(),
0,
"nothing at all may be created in an arbitrary cwd"
);
}
#[test]
fn reinstall_does_not_fail_the_command_for_an_optional_destination() {
let base = TempDir::new().unwrap();
let project = TempDir::new().unwrap();
let paths = seeded(base.path());
seed_user_skill(&paths);
std::fs::create_dir_all(project.path().join(".claude").join("skills")).unwrap();
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
Some(project.path()),
false,
&base.path().join("backups"),
);
assert!(!report.has_failures(), "healthy baseline: {report:?}");
let deployed_dir = project
.path()
.join(".claude")
.join("skills")
.join("my-skill");
std::fs::remove_dir_all(&deployed_dir).unwrap();
std::fs::write(&deployed_dir, "not a directory").unwrap();
let report = reinstall_assets(
&paths,
&standalone_dir(base.path()),
Some(project.path()),
false,
&base.path().join("backups"),
);
let t = tier(&report, "project");
assert!(t.errored, "the failure must still be reported: {t:?}");
assert!(
!report.has_failures(),
"an optional destination must not fail the command: {report:?}"
);
let standalone = tier(&report, "standalone");
assert!(!standalone.errored, "{standalone:?}");
assert!(
standalone
.skills_dir
.join("my-skill")
.join("SKILL.md")
.is_file()
);
}
#[test]
fn shadowed_note_counts_one_skill_once() {
use trusty_agents_common::skills::tiers::{Shadow, SkillTier};
let bundled: BTreeSet<String> = ["demo-skill".to_string()].into_iter().collect();
let both_losers = [
Shadow {
stem: "demo-skill".to_string(),
winner: SkillTier::Project,
loser: SkillTier::User,
},
Shadow {
stem: "demo-skill".to_string(),
winner: SkillTier::Project,
loser: SkillTier::Bundled,
},
];
assert_eq!(distinct_shadowed(&both_losers).len(), 1);
let note = shadowed_note(&both_losers, &bundled).unwrap();
assert!(note.contains("1 skill(s) were NOT deployed"), "{note}");
assert!(note.contains("1 of them carry a BUNDLED"), "{note}");
assert_eq!(
note.matches("demo-skill").count(),
2,
"once in the name list, once in the actionable list — never twice in either: {note}"
);
}