use std::fs;
use crate::core::agents::get_agent;
use crate::core::install::{install_skill, move_skill, sanitize_name, scan_installed};
use crate::core::link::outcome::LinkOutcome;
use crate::core::link::{is_agent_linked, link_agent, private_content, unlink_agent};
use crate::core::test_utils::{env_at, skill_frontmatter, symlink_dir, write_and_parse_skill};
fn sorted(mut v: Vec<String>) -> Vec<String> {
v.sort();
v
}
#[test]
fn link_agent_creates_a_dir_link_to_canonical() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
let agent = get_agent("cursor").unwrap();
let outcome = link_agent(agent, &env);
assert!(
matches!(
outcome,
LinkOutcome::Linked {
ref adopted,
ref quarantined,
ref conflicts,
} if adopted.is_empty() && quarantined.is_empty() && conflicts.is_empty()
),
"got {outcome:?}"
);
let link = tmp.path().join(".cursor/skills");
assert!(link.is_symlink());
let canonical = tmp.path().join(".agents/skills");
assert_eq!(
fs::canonicalize(&link).unwrap(),
fs::canonicalize(&canonical).unwrap()
);
}
#[test]
fn link_agent_skips_when_agent_root_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let agent = get_agent("cursor").unwrap();
assert!(matches!(link_agent(agent, &env), LinkOutcome::Skipped));
assert!(!tmp.path().join(".cursor").exists());
}
#[test]
fn link_agent_claude_code_links_even_without_root() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let agent = get_agent("claude-code").unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert!(tmp.path().join(".claude/skills").is_symlink());
}
#[test]
fn link_agent_native_agent_is_already_linked() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let cline = get_agent("cline").unwrap();
assert!(matches!(
link_agent(cline, &env),
LinkOutcome::AlreadyLinked
));
assert!(is_agent_linked(cline, &env));
assert!(matches!(unlink_agent(cline, &env), LinkOutcome::NotLinked));
}
#[test]
fn link_agent_skipped_when_dir_cannot_be_resolved() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let agent = get_agent("eve").unwrap();
assert!(matches!(link_agent(agent, &env), LinkOutcome::Skipped));
}
#[test]
fn link_agent_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
let agent = get_agent("cursor").unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::AlreadyLinked
));
}
#[test]
fn link_agent_refuses_foreign_symlink() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
fs::create_dir_all(tmp.path().join("elsewhere")).unwrap();
symlink_dir(
&tmp.path().join("elsewhere"),
&tmp.path().join(".cursor/skills"),
);
let agent = get_agent("cursor").unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Refused { .. }
));
}
#[test]
fn link_agent_adopts_existing_skills_and_links() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let existing = tmp.path().join(".claude/skills/my-skill");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("SKILL.md"), "x").unwrap();
fs::create_dir_all(tmp.path().join("hub/hub-skill")).unwrap();
symlink_dir(
&tmp.path().join("hub/hub-skill"),
&tmp.path().join(".claude/skills/hub-skill"),
);
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted,
quarantined,
conflicts,
} => {
assert_eq!(sorted(adopted), vec!["hub-skill", "my-skill"]);
assert!(quarantined.is_empty());
assert!(conflicts.is_empty());
}
other => panic!("expected Linked, got {other:?}"),
}
assert!(tmp.path().join(".agents/skills/my-skill/SKILL.md").exists());
let moved_link = tmp.path().join(".agents/skills/hub-skill");
assert!(moved_link.is_symlink());
assert_eq!(
fs::canonicalize(&moved_link).unwrap(),
fs::canonicalize(tmp.path().join("hub/hub-skill")).unwrap()
);
assert!(tmp.path().join(".claude/skills").is_symlink());
assert!(!tmp.path().join(".agents/skills/.misc").exists());
}
#[test]
fn link_agent_quarantines_non_skill_entries() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".claude/skills/my-skill")).unwrap();
fs::write(
tmp.path().join(".claude/skills/my-skill/SKILL.md"),
skill_frontmatter("my-skill"),
)
.unwrap();
fs::write(tmp.path().join(".claude/skills/README.txt"), "x").unwrap();
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted,
quarantined,
conflicts,
} => {
assert_eq!(adopted, vec!["my-skill"]);
assert_eq!(quarantined, vec!["README.txt"]);
assert!(conflicts.is_empty());
}
other => panic!("expected Linked, got {other:?}"),
}
assert!(tmp.path().join(".agents/skills/my-skill/SKILL.md").exists());
assert!(
tmp.path()
.join(".agents/skills/.misc/claude-code/README.txt")
.exists()
);
assert!(tmp.path().join(".claude/skills").is_symlink());
let scanned: Vec<String> = scan_installed(&env)
.iter()
.map(|s| s.name.clone())
.collect();
assert_eq!(scanned, vec!["my-skill".to_string()]);
}
#[test]
fn link_agent_drops_conflict_across_name_normalization() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".agents/skills/pdf-master")).unwrap();
fs::write(
tmp.path().join(".agents/skills/pdf-master/SKILL.md"),
skill_frontmatter("pdf-master"),
)
.unwrap();
let existing = tmp.path().join(".claude/skills/PDF Master");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("SKILL.md"), "agent copy").unwrap();
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted, conflicts, ..
} => {
assert!(adopted.is_empty());
assert_eq!(conflicts, vec!["PDF Master"]);
}
other => panic!("expected Linked, got {other:?}"),
}
assert_eq!(
fs::read_to_string(tmp.path().join(".agents/skills/pdf-master/SKILL.md")).unwrap(),
skill_frontmatter("pdf-master")
);
assert!(!tmp.path().join(".agents/skills/PDF Master").exists());
let scanned: Vec<String> = scan_installed(&env)
.iter()
.map(|s| s.name.clone())
.collect();
assert_eq!(scanned, vec!["pdf-master".to_string()]);
}
#[test]
fn link_agent_keeps_only_one_of_two_sibling_names() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
for dir in [".claude/skills/PDF Master", ".claude/skills/pdf-master"] {
fs::create_dir_all(tmp.path().join(dir)).unwrap();
fs::write(
tmp.path().join(dir).join("SKILL.md"),
skill_frontmatter("pdf-master"),
)
.unwrap();
}
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted, conflicts, ..
} => {
assert_eq!(adopted.len(), 1);
assert_eq!(conflicts.len(), 1);
assert_eq!(sanitize_name(&adopted[0]), sanitize_name(&conflicts[0]));
}
other => panic!("expected Linked, got {other:?}"),
}
assert_eq!(scan_installed(&env).len(), 1);
}
#[test]
fn link_agent_drops_name_conflicts_in_favour_of_canonical() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".agents/skills/pdf")).unwrap();
fs::write(tmp.path().join(".agents/skills/pdf/SKILL.md"), "canonical").unwrap();
let existing = tmp.path().join(".claude/skills/pdf");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("SKILL.md"), "agent copy").unwrap();
fs::create_dir_all(tmp.path().join(".claude/skills/notes")).unwrap();
fs::write(tmp.path().join(".claude/skills/notes/SKILL.md"), "x").unwrap();
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted,
quarantined,
conflicts,
} => {
assert_eq!(adopted, vec!["notes"]);
assert!(quarantined.is_empty());
assert_eq!(conflicts, vec!["pdf"]);
}
other => panic!("expected Linked, got {other:?}"),
}
assert_eq!(
fs::read_to_string(tmp.path().join(".agents/skills/pdf/SKILL.md")).unwrap(),
"canonical"
);
assert!(tmp.path().join(".agents/skills/notes/SKILL.md").exists());
assert!(tmp.path().join(".claude/skills").is_symlink());
}
#[test]
fn link_agent_drops_conflicts_with_disabled_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_and_parse_skill(&src, "pdf");
install_skill(&skill, false, &env).unwrap();
move_skill("pdf", false, &env).unwrap();
let existing = tmp.path().join(".claude/skills/pdf");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("SKILL.md"), "agent copy").unwrap();
fs::create_dir_all(tmp.path().join(".claude/skills/notes")).unwrap();
fs::write(tmp.path().join(".claude/skills/notes/SKILL.md"), "x").unwrap();
let agent = get_agent("claude-code").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted, conflicts, ..
} => {
assert_eq!(adopted, vec!["notes"]);
assert_eq!(conflicts, vec!["pdf"]);
}
other => panic!("expected Linked, got {other:?}"),
}
assert!(!tmp.path().join(".agents/skills/pdf").exists());
assert!(
tmp.path()
.join(".agents/disabled-skills/pdf/SKILL.md")
.exists()
);
assert!(tmp.path().join(".agents/skills/notes/SKILL.md").exists());
}
#[test]
fn link_agent_drops_legacy_per_skill_links() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let src = tmp.path().join("pdf");
let skill = write_and_parse_skill(&src, "pdf");
install_skill(&skill, false, &env).unwrap();
fs::create_dir_all(tmp.path().join(".cursor/skills")).unwrap();
symlink_dir(
&tmp.path().join(".agents/skills/pdf"),
&tmp.path().join(".cursor/skills/pdf"),
);
let agent = get_agent("cursor").unwrap();
match link_agent(agent, &env) {
LinkOutcome::Linked {
adopted, conflicts, ..
} => {
assert!(adopted.is_empty());
assert_eq!(conflicts, vec!["pdf"]);
}
other => panic!("expected Linked, got {other:?}"),
}
let canonical_skill = tmp.path().join(".agents/skills/pdf");
assert!(canonical_skill.is_dir());
assert!(!canonical_skill.is_symlink());
assert!(canonical_skill.join("SKILL.md").exists());
assert!(tmp.path().join(".cursor/skills").is_symlink());
}
#[test]
fn unlink_agent_does_not_restore_adopted_skills() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let existing = tmp.path().join(".claude/skills/my-skill");
fs::create_dir_all(&existing).unwrap();
fs::write(existing.join("SKILL.md"), "x").unwrap();
let agent = get_agent("claude-code").unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert!(matches!(unlink_agent(agent, &env), LinkOutcome::Unlinked));
assert!(tmp.path().join(".agents/skills/my-skill/SKILL.md").exists());
let dir = tmp.path().join(".claude/skills");
assert!(dir.is_dir());
assert!(!dir.is_symlink());
assert!(fs::read_dir(&dir).unwrap().count() == 0);
}
#[test]
fn unlink_agent_removes_link_and_recreates_empty_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
let agent = get_agent("cursor").unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert!(matches!(unlink_agent(agent, &env), LinkOutcome::Unlinked));
let dir = tmp.path().join(".cursor/skills");
assert!(dir.is_dir());
assert!(!dir.is_symlink());
assert!(fs::read_dir(&dir).unwrap().count() == 0);
}
#[test]
fn unlink_agent_leaves_real_dirs_and_foreign_links_alone() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".claude/skills/my-skill")).unwrap();
let agent = get_agent("claude-code").unwrap();
assert!(matches!(unlink_agent(agent, &env), LinkOutcome::NotLinked));
assert!(tmp.path().join(".claude/skills/my-skill").exists());
let uni = get_agent("cline").unwrap();
assert!(matches!(unlink_agent(uni, &env), LinkOutcome::NotLinked));
}
#[test]
fn is_agent_linked_reflects_dir_links() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let agent = get_agent("cursor").unwrap();
assert!(!is_agent_linked(agent, &env));
fs::create_dir_all(tmp.path().join(".cursor")).unwrap();
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert!(is_agent_linked(agent, &env));
assert!(is_agent_linked(get_agent("cline").unwrap(), &env));
}
#[test]
fn private_content_classifies_skills_and_others() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
fs::create_dir_all(tmp.path().join(".claude/skills/my-skill")).unwrap();
fs::write(tmp.path().join(".claude/skills/my-skill/SKILL.md"), "x").unwrap();
fs::write(tmp.path().join(".claude/skills/README.txt"), "x").unwrap();
let agent = get_agent("claude-code").unwrap();
let (skills, others) = private_content(agent, &env);
assert_eq!(skills, vec!["my-skill"]);
assert_eq!(others, vec!["README.txt"]);
assert!(matches!(
link_agent(agent, &env),
LinkOutcome::Linked { .. }
));
assert_eq!(private_content(agent, &env), (Vec::new(), Vec::new()));
}