use crate::path_utils::relative_path_from_dir;
use crate::skills::catalog::types::{
ConfigEntry, SkillCandidate, SkillsConfig, TargetConflictStrategy, TargetMode,
};
use anyhow::{Context, Result, anyhow};
use inquire::Select;
use inquire::error::InquireError;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{ErrorKind, Read};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[error("cancelled")]
pub(crate) struct SkillSyncCancelled;
const VENDORED_METADATA_FILE: &str = ".llman-vendored.json";
pub fn apply_target_links(
skill: &SkillCandidate,
config: &SkillsConfig,
desired_by_target: &HashMap<String, bool>,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<()> {
for target in &config.targets {
let enabled = desired_by_target
.get(&target.id)
.copied()
.unwrap_or(target.enabled);
apply_target_link(skill, target, enabled, interactive, target_conflict)?;
}
Ok(())
}
pub fn apply_target_diff(
skills: &[SkillCandidate],
target: &ConfigEntry,
desired: &HashSet<String>,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<()> {
apply_target_diff_with_conflict_resolver(
skills,
target,
desired,
interactive,
target_conflict,
|skill_id, target| resolve_target_conflict(skill_id, target, true, None),
)
}
fn apply_target_diff_with_conflict_resolver<F>(
skills: &[SkillCandidate],
target: &ConfigEntry,
desired: &HashSet<String>,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
resolve_conflict: F,
) -> Result<()>
where
F: FnMut(&str, &ConfigEntry) -> Result<TargetConflictStrategy>,
{
match target.mode {
TargetMode::Skip => Ok(()),
TargetMode::Link => apply_target_diff_link_with_conflict_resolver(
skills,
target,
desired,
interactive,
target_conflict,
resolve_conflict,
),
TargetMode::Copy => apply_target_diff_copy_with_conflict_resolver(
skills,
target,
desired,
interactive,
target_conflict,
resolve_conflict,
),
}
}
fn apply_target_diff_link_with_conflict_resolver<F>(
skills: &[SkillCandidate],
target: &ConfigEntry,
desired: &HashSet<String>,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
mut resolve_conflict: F,
) -> Result<()>
where
F: FnMut(&str, &ConfigEntry) -> Result<TargetConflictStrategy>,
{
#[derive(Debug, Clone, Copy)]
struct PlannedOp {
skill_index: usize,
enabled: bool,
conflict_strategy: Option<TargetConflictStrategy>,
}
let mut planned = Vec::new();
for (index, skill) in skills.iter().enumerate() {
let current = is_skill_linked(skill, target);
let wanted = desired.contains(&skill.skill_id);
let needs_compaction = wanted
&& current
&& should_compact_target_links(target)
&& link_target_is_absolute(target, &skill.skill_id);
if current == wanted && !needs_compaction {
continue;
}
let mut conflict_strategy = None;
if wanted && !current {
let link_path = target.path.join(&skill.skill_id);
let entry_exists = fs::symlink_metadata(&link_path).is_ok();
if entry_exists {
conflict_strategy = match target_conflict {
Some(strategy) => Some(strategy),
None => {
if !interactive {
return Err(anyhow!(t!(
"skills.target_conflict.requires_flag",
skill = skill.skill_id,
target = target.id
)));
}
Some(resolve_conflict(&skill.skill_id, target)?)
}
};
}
}
planned.push(PlannedOp {
skill_index: index,
enabled: wanted,
conflict_strategy,
});
}
for op in planned {
let skill = skills
.get(op.skill_index)
.ok_or_else(|| anyhow!("invalid skill index"))?;
apply_target_link(skill, target, op.enabled, interactive, op.conflict_strategy)?;
}
Ok(())
}
fn apply_target_diff_copy_with_conflict_resolver<F>(
skills: &[SkillCandidate],
target: &ConfigEntry,
desired: &HashSet<String>,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
mut resolve_conflict: F,
) -> Result<()>
where
F: FnMut(&str, &ConfigEntry) -> Result<TargetConflictStrategy>,
{
#[derive(Debug, Clone, Copy)]
struct PlannedOp {
skill_index: usize,
enabled: bool,
conflict_strategy: Option<TargetConflictStrategy>,
}
let mut planned = Vec::new();
for (index, skill) in skills.iter().enumerate() {
let wanted = desired.contains(&skill.skill_id);
let state = copy_state(skill, target)?;
if wanted {
if state.up_to_date {
continue;
}
let mut conflict_strategy = None;
if state.enable_conflict {
conflict_strategy = match target_conflict {
Some(strategy) => Some(strategy),
None => {
if !interactive {
return Err(anyhow!(t!(
"skills.target_conflict.requires_flag",
skill = skill.skill_id,
target = target.id
)));
}
Some(resolve_conflict(&skill.skill_id, target)?)
}
};
}
planned.push(PlannedOp {
skill_index: index,
enabled: true,
conflict_strategy,
});
} else if state.present {
planned.push(PlannedOp {
skill_index: index,
enabled: false,
conflict_strategy: None,
});
}
}
cleanup_vendored_metadata_files(target, skills)?;
for op in planned {
let skill = skills
.get(op.skill_index)
.ok_or_else(|| anyhow!("invalid skill index"))?;
apply_target_link(skill, target, op.enabled, interactive, op.conflict_strategy)?;
}
Ok(())
}
fn should_compact_target_links(target: &ConfigEntry) -> bool {
matches!(target.scope.as_str(), "project" | "repo")
}
fn link_target_is_absolute(target: &ConfigEntry, skill_id: &str) -> bool {
let link_path = target.path.join(skill_id);
let Ok(meta) = fs::symlink_metadata(&link_path) else {
return false;
};
if !meta.file_type().is_symlink() {
return false;
}
let Ok(dest) = fs::read_link(&link_path) else {
return false;
};
dest.is_absolute()
}
pub fn is_skill_linked(skill: &SkillCandidate, target: &ConfigEntry) -> bool {
let link_path = target.path.join(&skill.skill_id);
let meta = match fs::symlink_metadata(&link_path) {
Ok(meta) => meta,
Err(_) => return false,
};
if !meta.file_type().is_symlink() {
return false;
}
let existing = match fs::read_link(&link_path) {
Ok(path) => path,
Err(_) => return false,
};
if existing == skill.skill_dir {
return true;
}
let existing_abs = if existing.is_absolute() {
existing
} else {
link_path.parent().unwrap_or(&target.path).join(existing)
};
let Ok(existing_canon) = fs::canonicalize(existing_abs) else {
return false;
};
let Ok(desired_canon) = fs::canonicalize(&skill.skill_dir) else {
return false;
};
existing_canon == desired_canon
}
pub fn is_skill_present(skill: &SkillCandidate, target: &ConfigEntry) -> bool {
match target.mode {
TargetMode::Skip => false,
TargetMode::Link => is_skill_linked(skill, target),
TargetMode::Copy => is_skill_vendored(skill, target).unwrap_or(false),
}
}
#[derive(Debug, Clone, Copy)]
struct CopyState {
present: bool,
up_to_date: bool,
enable_conflict: bool,
}
fn copy_state(skill: &SkillCandidate, target: &ConfigEntry) -> Result<CopyState> {
let entry_path = target.path.join(&skill.skill_id);
let meta = match fs::symlink_metadata(&entry_path) {
Ok(meta) => meta,
Err(_) => {
return Ok(CopyState {
present: false,
up_to_date: false,
enable_conflict: false,
});
}
};
if meta.file_type().is_symlink() {
let linked = is_skill_linked(skill, target);
return Ok(CopyState {
present: linked,
up_to_date: false,
enable_conflict: !linked,
});
}
if meta.is_file() {
return Ok(CopyState {
present: false,
up_to_date: false,
enable_conflict: true,
});
}
if !meta.is_dir() {
return Ok(CopyState {
present: false,
up_to_date: false,
enable_conflict: true,
});
}
if is_dir_empty(&entry_path).unwrap_or(false) {
return Ok(CopyState {
present: false,
up_to_date: false,
enable_conflict: false,
});
}
let desired_digest = compute_dir_digest(&skill.skill_dir)?;
let existing_digest = compute_dir_digest(&entry_path)?;
let up_to_date = existing_digest == desired_digest;
Ok(CopyState {
present: up_to_date,
up_to_date,
enable_conflict: !up_to_date,
})
}
fn is_skill_vendored(skill: &SkillCandidate, target: &ConfigEntry) -> Result<bool> {
let entry_path = target.path.join(&skill.skill_id);
let meta = match fs::symlink_metadata(&entry_path) {
Ok(meta) => meta,
Err(_) => return Ok(false),
};
if meta.file_type().is_symlink() {
return Ok(is_skill_linked(skill, target));
}
Ok(meta.is_dir())
}
fn is_dir_empty(path: &Path) -> Result<bool> {
let mut entries = fs::read_dir(path)?;
Ok(entries.next().is_none())
}
fn remove_file_if_exists(path: &Path) -> Result<()> {
match fs::symlink_metadata(path) {
Ok(meta) if meta.is_file() || meta.file_type().is_symlink() => {
fs::remove_file(path)?;
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
Ok(())
}
fn cleanup_vendored_metadata_files(target: &ConfigEntry, skills: &[SkillCandidate]) -> Result<()> {
if !target.path.is_dir() {
return Ok(());
}
remove_file_if_exists(&target.path.join(VENDORED_METADATA_FILE))?;
for skill in skills {
remove_file_if_exists(
&target
.path
.join(&skill.skill_id)
.join(VENDORED_METADATA_FILE),
)?;
}
Ok(())
}
fn compute_dir_digest(root: &Path) -> Result<String> {
let mut hasher = Sha256::new();
let mut stack = Vec::new();
hash_dir_recursive(root, root, &mut hasher, &mut stack)?;
let digest = hasher.finalize();
Ok(hex_lower(&digest))
}
fn hash_dir_recursive(
root: &Path,
dir: &Path,
hasher: &mut Sha256,
stack: &mut Vec<PathBuf>,
) -> Result<()> {
let canon = fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf());
if stack.contains(&canon) {
return Ok(());
}
stack.push(canon);
let result = (|| {
let mut entries = fs::read_dir(dir)?.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.cmp(&b.file_name().to_string_lossy())
});
for entry in entries {
let name = entry.file_name();
if name.to_string_lossy() == VENDORED_METADATA_FILE {
continue;
}
let path = entry.path();
let rel = match path.strip_prefix(root) {
Ok(rel) => rel,
Err(_) => {
return Err(anyhow!(
"{} {}",
t!("skills.hash.strip_prefix_failed"),
path.display()
));
}
};
let link_meta = match fs::symlink_metadata(&path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
let follow_meta = if link_meta.file_type().is_symlink() {
match fs::metadata(&path) {
Ok(meta) => meta,
Err(_) => continue,
}
} else {
link_meta
};
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update([0u8]);
if follow_meta.is_dir() {
hasher.update(b"dir");
hasher.update([0u8]);
hash_dir_recursive(root, &path, hasher, stack)?;
continue;
}
if follow_meta.is_file() {
hasher.update(b"file");
hasher.update([0u8]);
let mut file = fs::File::open(&path)?;
let mut buf = [0u8; 8192];
loop {
let read = file.read(&mut buf)?;
if read == 0 {
break;
}
hasher.update(&buf[..read]);
}
}
}
Ok(())
})();
stack.pop();
result
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
pub fn apply_target_link(
skill: &SkillCandidate,
target: &ConfigEntry,
enabled: bool,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<()> {
match target.mode {
TargetMode::Skip => Ok(()),
TargetMode::Link => {
if enabled {
ensure_target_link(skill, target, interactive, target_conflict)?;
} else {
remove_target_link(skill, target)?;
}
Ok(())
}
TargetMode::Copy => {
if enabled {
ensure_target_copy(skill, target, interactive, target_conflict)?;
} else {
remove_target_copy(skill, target)?;
}
Ok(())
}
}
}
fn ensure_target_link(
skill: &SkillCandidate,
target: &ConfigEntry,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<()> {
if !target.path.exists() {
fs::create_dir_all(&target.path)?;
} else if !target.path.is_dir() {
eprintln!(
"{}",
t!("skills.target.not_directory", path = target.path.display())
);
return Ok(());
}
let link_path = target.path.join(&skill.skill_id);
let desired = &skill.skill_dir;
let compact = should_compact_target_links(target);
let preferred_target = if compact {
relative_path_from_dir(&target.path, desired).unwrap_or_else(|| desired.to_path_buf())
} else {
desired.to_path_buf()
};
if fs::symlink_metadata(&link_path).is_ok() {
if fs::symlink_metadata(&link_path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
{
if is_skill_linked(skill, target) {
if compact
&& fs::read_link(&link_path)
.ok()
.is_some_and(|dest| dest.is_absolute())
&& !preferred_target.is_absolute()
{
remove_path(&link_path)?;
create_symlink_exclusive(&preferred_target, &link_path, skill, target)?;
}
return Ok(());
}
}
let decision =
resolve_target_conflict(&skill.skill_id, target, interactive, target_conflict)?;
if decision == TargetConflictStrategy::Skip {
return Ok(());
}
remove_path(&link_path)?;
}
create_symlink_exclusive(&preferred_target, &link_path, skill, target)?;
Ok(())
}
fn create_symlink_exclusive(
preferred_target: &Path,
link_path: &Path,
skill: &SkillCandidate,
target: &ConfigEntry,
) -> Result<()> {
match create_symlink(preferred_target, link_path) {
Ok(()) => Ok(()),
Err(e) if is_already_exists_err(&e) => {
if is_skill_linked(skill, target) {
Ok(())
} else {
Err(e).with_context(|| {
format!(
"skill link race at {}: path exists but is not the expected link",
link_path.display()
)
})
}
}
Err(e) => Err(e),
}
}
fn is_already_exists_err(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.map(|e| e.kind() == ErrorKind::AlreadyExists)
.unwrap_or(false)
})
}
fn remove_target_link(skill: &SkillCandidate, target: &ConfigEntry) -> Result<()> {
let link_path = target.path.join(&skill.skill_id);
let meta = match fs::symlink_metadata(&link_path) {
Ok(meta) => meta,
Err(_) => return Ok(()),
};
if meta.file_type().is_symlink() {
remove_path(&link_path)?;
return Ok(());
}
eprintln!(
"{}",
t!("skills.target.not_symlink", path = link_path.display())
);
Ok(())
}
fn ensure_target_copy(
skill: &SkillCandidate,
target: &ConfigEntry,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<()> {
if !target.path.exists() {
fs::create_dir_all(&target.path)?;
} else if !target.path.is_dir() {
eprintln!(
"{}",
t!("skills.target.not_directory", path = target.path.display())
);
return Ok(());
}
let entry_path = target.path.join(&skill.skill_id);
if let Ok(meta) = fs::symlink_metadata(&entry_path) {
if meta.file_type().is_symlink() {
if is_skill_linked(skill, target) {
remove_existing_entry_for_copy(&entry_path)?;
} else {
let decision =
resolve_target_conflict(&skill.skill_id, target, interactive, target_conflict)?;
if decision == TargetConflictStrategy::Skip {
return Ok(());
}
remove_existing_entry_for_copy(&entry_path)?;
}
} else if meta.is_dir() {
if is_dir_empty(&entry_path).unwrap_or(false) {
fs::remove_dir(&entry_path)?;
} else {
let desired_digest = compute_dir_digest(&skill.skill_dir)?;
let existing_digest = compute_dir_digest(&entry_path)?;
if existing_digest == desired_digest {
remove_file_if_exists(&entry_path.join(VENDORED_METADATA_FILE))?;
return Ok(());
}
let decision =
resolve_target_conflict(&skill.skill_id, target, interactive, target_conflict)?;
if decision == TargetConflictStrategy::Skip {
return Ok(());
}
fs::remove_dir_all(&entry_path)?;
}
} else {
let decision =
resolve_target_conflict(&skill.skill_id, target, interactive, target_conflict)?;
if decision == TargetConflictStrategy::Skip {
return Ok(());
}
remove_existing_entry_for_copy(&entry_path)?;
}
}
copy_dir_all_follow_links(&skill.skill_dir, &entry_path)?;
remove_file_if_exists(&entry_path.join(VENDORED_METADATA_FILE))?;
Ok(())
}
fn remove_target_copy(skill: &SkillCandidate, target: &ConfigEntry) -> Result<()> {
let entry_path = target.path.join(&skill.skill_id);
let meta = match fs::symlink_metadata(&entry_path) {
Ok(meta) => meta,
Err(_) => return Ok(()),
};
if meta.file_type().is_symlink() {
if is_skill_linked(skill, target) {
remove_path(&entry_path)?;
} else {
eprintln!(
"{}",
t!("skills.target.not_managed", path = entry_path.display())
);
}
return Ok(());
}
if meta.is_dir() {
if is_dir_empty(&entry_path).unwrap_or(false) {
fs::remove_dir(&entry_path)?;
return Ok(());
}
let desired_digest = compute_dir_digest(&skill.skill_dir)?;
let existing_digest = compute_dir_digest(&entry_path)?;
if existing_digest != desired_digest {
eprintln!(
"{}",
t!("skills.target.not_managed", path = entry_path.display())
);
return Ok(());
}
fs::remove_dir_all(&entry_path)?;
return Ok(());
}
eprintln!(
"{}",
t!("skills.target.not_managed", path = entry_path.display())
);
Ok(())
}
fn remove_existing_entry_for_copy(path: &Path) -> Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() || meta.is_file() {
fs::remove_file(path)?;
return Ok(());
}
if meta.is_dir() {
fs::remove_dir_all(path)?;
}
Ok(())
}
fn copy_dir_all_follow_links(from: &Path, to: &Path) -> Result<()> {
let mut stack = Vec::new();
copy_dir_all_follow_links_inner(from, to, &mut stack)
}
fn copy_dir_all_follow_links_inner(from: &Path, to: &Path, stack: &mut Vec<PathBuf>) -> Result<()> {
fs::create_dir_all(to).with_context(|| format!("create directory {}", to.display()))?;
let canon = fs::canonicalize(from).unwrap_or_else(|_| from.to_path_buf());
if stack.contains(&canon) {
return Ok(());
}
stack.push(canon);
let result = (|| {
let mut entries = fs::read_dir(from)
.with_context(|| format!("read directory {}", from.display()))?
.collect::<std::result::Result<Vec<_>, _>>()?;
entries.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.cmp(&b.file_name().to_string_lossy())
});
for entry in entries {
let name = entry.file_name();
if name.to_string_lossy() == VENDORED_METADATA_FILE {
continue;
}
let src = entry.path();
let dst = to.join(&name);
let link_meta = match fs::symlink_metadata(&src) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e.into()),
};
if link_meta.file_type().is_symlink() {
let follow_meta = match fs::metadata(&src) {
Ok(meta) => meta,
Err(_) => continue,
};
if follow_meta.is_dir() {
copy_dir_all_follow_links_inner(&src, &dst, stack)?;
} else if follow_meta.is_file() {
fs::copy(&src, &dst).with_context(|| {
format!("copy file {} -> {}", src.display(), dst.display())
})?;
}
continue;
}
if link_meta.is_dir() {
copy_dir_all_follow_links_inner(&src, &dst, stack)?;
} else if link_meta.is_file() {
fs::copy(&src, &dst)
.with_context(|| format!("copy file {} -> {}", src.display(), dst.display()))?;
}
}
Ok(())
})();
stack.pop();
result
}
fn resolve_target_conflict(
skill_id: &str,
target: &ConfigEntry,
interactive: bool,
target_conflict: Option<TargetConflictStrategy>,
) -> Result<TargetConflictStrategy> {
if let Some(conflict) = target_conflict {
return Ok(conflict);
}
if !interactive {
return Err(anyhow!(t!(
"skills.target_conflict.requires_flag",
skill = skill_id,
target = target.id
)));
}
let prompt = t!(
"skills.target_conflict.prompt",
skill = skill_id,
target = target.id
);
let overwrite_label = t!("skills.target_conflict.option_overwrite").to_string();
let skip_label = t!("skills.target_conflict.option_skip").to_string();
let selection =
match Select::new(&prompt, vec![overwrite_label.clone(), skip_label.clone()]).prompt() {
Ok(selection) => selection,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Err(SkillSyncCancelled.into());
}
Err(e) => {
return Err(anyhow!(t!(
"skills.target_conflict.prompt_failed",
error = e
)));
}
};
if selection == overwrite_label {
Ok(TargetConflictStrategy::Overwrite)
} else {
Ok(TargetConflictStrategy::Skip)
}
}
fn remove_path(path: &Path) -> Result<()> {
let meta = fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() || meta.is_file() {
fs::remove_file(path)?;
return Ok(());
}
if meta.is_dir() {
let mut entries = fs::read_dir(path)?;
if entries.next().is_some() {
return Err(anyhow!(
"Refusing to delete non-empty directory: {}",
path.display()
));
}
fs::remove_dir(path)?;
}
Ok(())
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link)?;
Ok(())
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> Result<()> {
std::os::windows::fs::symlink_dir(target, link)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_non_interactive_conflict_requires_flag() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let conflict_path = target_root.join("skill");
fs::create_dir_all(&conflict_path).expect("create conflict");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root,
enabled: true,
mode: TargetMode::Link,
};
let config = SkillsConfig {
targets: vec![target],
};
let desired_by_target = HashMap::new();
let err = apply_target_links(&skill, &config, &desired_by_target, false, None)
.expect_err("should require target-conflict");
assert!(err.to_string().contains("--target-conflict"));
}
#[cfg(unix)]
#[test]
fn test_overwrite_conflict_replaces_entry() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let conflict_path = target_root.join("skill");
fs::create_dir_all(&conflict_path).expect("create conflict");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Link,
};
let config = SkillsConfig {
targets: vec![target],
};
let desired_by_target = HashMap::new();
apply_target_links(
&skill,
&config,
&desired_by_target,
false,
Some(TargetConflictStrategy::Overwrite),
)
.expect("overwrite conflict");
let link_path = target_root.join("skill");
let meta = fs::symlink_metadata(&link_path).expect("symlink metadata");
assert!(meta.file_type().is_symlink());
let target = fs::read_link(&link_path).expect("read link");
assert_eq!(target, skill_dir);
}
#[cfg(unix)]
#[test]
fn test_overwrite_conflict_refuses_non_empty_directory() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let conflict_path = target_root.join("skill");
fs::create_dir_all(&conflict_path).expect("create conflict");
fs::write(conflict_path.join("keep.txt"), "keep").expect("write keep");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Link,
};
let config = SkillsConfig {
targets: vec![target],
};
let desired_by_target = HashMap::new();
let err = apply_target_links(
&skill,
&config,
&desired_by_target,
false,
Some(TargetConflictStrategy::Overwrite),
)
.expect_err("should refuse deleting non-empty directory");
assert!(
err.to_string()
.contains("Refusing to delete non-empty directory")
);
assert!(conflict_path.join("keep.txt").exists());
}
#[cfg(unix)]
#[test]
fn test_is_skill_linked_with_relative_symlink() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
let skills_root = root.join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = root.join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let link_path = target_root.join("skill");
unix_fs::symlink("../skills/skill", &link_path).expect("create relative symlink");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root,
enabled: true,
mode: TargetMode::Link,
};
assert!(is_skill_linked(&skill, &target));
}
#[cfg(unix)]
#[test]
fn test_apply_target_diff_respects_current_state() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
let skills_root = root.join("skills");
let skill_dir = skills_root.join("skill");
let other_dir = skills_root.join("other");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::create_dir_all(&other_dir).expect("create other dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = root.join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let link_path = target_root.join("skill");
unix_fs::symlink(&other_dir, &link_path).expect("create conflicting symlink");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Link,
};
let mut desired = HashSet::new();
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, true, None)
.expect("apply diff");
assert!(link_path.exists());
desired.insert("skill".to_string());
apply_target_diff(
&[skill],
&target,
&desired,
true,
Some(TargetConflictStrategy::Overwrite),
)
.expect("apply diff");
let target_path = fs::read_link(&link_path).expect("read link");
assert_eq!(target_path, skill_dir);
}
#[cfg(unix)]
#[test]
fn test_project_scope_compacts_existing_absolute_link_to_relative() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let root = temp.path();
let skills_root = root.join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = root.join("repo").join(".claude").join("skills");
fs::create_dir_all(&target_root).expect("create target root");
let link_path = target_root.join("skill");
unix_fs::symlink(&skill_dir, &link_path).expect("create absolute symlink");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_project".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Link,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff");
let dest = fs::read_link(&link_path).expect("read link");
assert!(!dest.is_absolute(), "expected compact relative symlink");
let expected = relative_path_from_dir(&target_root, &skill_dir).expect("relative path");
assert_eq!(dest, expected);
assert!(is_skill_linked(&skill, &target));
}
#[cfg(unix)]
#[test]
fn test_dangling_symlink_is_treated_as_existing_entry() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let link_path = target_root.join("skill");
unix_fs::symlink(temp.path().join("missing"), &link_path).expect("dangling symlink");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Link,
};
apply_target_link(
&skill,
&target,
true,
false,
Some(TargetConflictStrategy::Overwrite),
)
.expect("overwrite dangling");
let meta = fs::symlink_metadata(&link_path).expect("metadata");
assert!(meta.file_type().is_symlink());
let dest = fs::read_link(&link_path).expect("read link");
assert_eq!(dest, skill_dir);
apply_target_link(&skill, &target, false, false, None).expect("remove");
assert!(fs::symlink_metadata(&link_path).is_err());
assert!(
skill_dir.join("SKILL.md").exists(),
"removing the symlink must not delete the target skill directory"
);
}
#[cfg(unix)]
#[test]
fn test_conflict_cancel_aborts_without_side_effects() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let link_path = target_root.join("skill");
fs::create_dir_all(&link_path).expect("create conflict dir");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_user".to_string(),
agent: "claude".to_string(),
scope: "user".to_string(),
path: target_root,
enabled: true,
mode: TargetMode::Link,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
let err = apply_target_diff_with_conflict_resolver(
&[skill],
&target,
&desired,
true,
None,
|_skill_id, _target| Err(SkillSyncCancelled.into()),
)
.expect_err("cancel");
assert!(err.is::<SkillSyncCancelled>());
let meta = fs::symlink_metadata(&link_path).expect("meta");
assert!(meta.is_dir());
assert!(!meta.file_type().is_symlink());
}
#[test]
fn test_copy_mode_creates_vendored_directory() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "repo".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Copy,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff");
let entry_path = target_root.join("skill");
let meta = fs::symlink_metadata(&entry_path).expect("metadata");
assert!(meta.is_dir());
assert!(!meta.file_type().is_symlink());
assert!(entry_path.join("SKILL.md").exists());
assert!(!entry_path.join(VENDORED_METADATA_FILE).exists());
assert!(
!target_root.join(VENDORED_METADATA_FILE).exists(),
"should not create vendored metadata file"
);
}
#[cfg(unix)]
#[test]
fn test_copy_mode_ignores_dangling_symlink_in_source() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
unix_fs::symlink("missing.txt", skill_dir.join("dangling.txt")).expect("create symlink");
let target_root = temp.path().join("targets");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "repo".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Copy,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff");
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff again (digest)");
let entry_path = target_root.join("skill");
assert!(entry_path.join("SKILL.md").exists());
assert!(
!entry_path.join("dangling.txt").exists(),
"dangling symlink should be ignored in vendored copy"
);
}
#[cfg(unix)]
#[test]
fn test_copy_mode_handles_symlink_directory_cycles() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let sub = skill_dir.join("sub");
fs::create_dir_all(&sub).expect("create sub dir");
fs::write(sub.join("a.txt"), "a").expect("write file");
unix_fs::symlink("..", sub.join("back")).expect("create back symlink");
let target_root = temp.path().join("targets");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "repo".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Copy,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff");
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff again (digest)");
let entry_path = target_root.join("skill");
assert!(entry_path.join("sub").join("a.txt").exists());
let back_path = entry_path.join("sub").join("back");
let meta = fs::symlink_metadata(&back_path).expect("meta");
assert!(meta.is_dir());
assert!(!meta.file_type().is_symlink());
}
#[test]
fn test_copy_mode_recopies_when_source_changes() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "v1").expect("write skill v1");
let target_root = temp.path().join("targets");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "claude_project".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Copy,
};
let mut desired = HashSet::new();
desired.insert("skill".to_string());
apply_target_diff(std::slice::from_ref(&skill), &target, &desired, false, None)
.expect("apply diff v1");
let entry_path = target_root.join("skill");
assert_eq!(
fs::read_to_string(entry_path.join("SKILL.md")).expect("read vendored skill"),
"v1"
);
fs::write(skill_dir.join("SKILL.md"), "v2").expect("write skill v2");
apply_target_diff(
std::slice::from_ref(&skill),
&target,
&desired,
false,
Some(TargetConflictStrategy::Overwrite),
)
.expect("apply diff v2");
assert_eq!(
fs::read_to_string(entry_path.join("SKILL.md")).expect("read vendored skill"),
"v2"
);
assert!(
!target_root.join(VENDORED_METADATA_FILE).exists(),
"should not create vendored metadata file"
);
}
#[test]
fn test_copy_mode_non_interactive_conflict_requires_flag() {
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(target_root.join("skill")).expect("create conflicting dir");
fs::write(target_root.join("skill").join("keep.txt"), "keep").expect("write keep");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "repo".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root,
enabled: true,
mode: TargetMode::Copy,
};
let config = SkillsConfig {
targets: vec![target],
};
let desired_by_target = HashMap::new();
let err = apply_target_links(&skill, &config, &desired_by_target, false, None)
.expect_err("should require target-conflict");
assert!(err.to_string().contains("--target-conflict"));
}
#[cfg(unix)]
#[test]
fn test_copy_mode_migrates_symlink_to_directory() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().expect("temp dir");
let skills_root = temp.path().join("skills");
let skill_dir = skills_root.join("skill");
fs::create_dir_all(&skill_dir).expect("create skill dir");
fs::write(skill_dir.join("SKILL.md"), "# skill").expect("write skill");
let target_root = temp.path().join("targets");
fs::create_dir_all(&target_root).expect("create target root");
let entry_path = target_root.join("skill");
unix_fs::symlink(&skill_dir, &entry_path).expect("create symlink");
let skill = SkillCandidate {
skill_id: "skill".to_string(),
skill_dir: skill_dir.clone(),
..Default::default()
};
let target = ConfigEntry {
id: "repo".to_string(),
agent: "claude".to_string(),
scope: "project".to_string(),
path: target_root.clone(),
enabled: true,
mode: TargetMode::Copy,
};
apply_target_link(&skill, &target, true, false, None).expect("migrate");
let meta = fs::symlink_metadata(&entry_path).expect("metadata");
assert!(meta.is_dir());
assert!(!meta.file_type().is_symlink());
assert!(entry_path.join("SKILL.md").exists());
assert!(!entry_path.join(VENDORED_METADATA_FILE).exists());
assert!(
!target_root.join(VENDORED_METADATA_FILE).exists(),
"should not create vendored metadata file"
);
}
}