diff --git a/src/profiles/installer.rs b/src/profiles/installer.rs index 79f1a136..817c5a4f 100644 --- a/src/profiles/installer.rs +++ b/src/profiles/installer.rs @@ -5,6 +5,7 @@ use super::error::{ProfileError, ProfileResult}; use super::lockfile::ProfileLockfile; use std::path::{Path, PathBuf}; +use walkdir::WalkDir; /// Installation result: (installed files, sidecar files) /// - installed: List of relative file paths that were installed @@ -204,15 +205,19 @@ impl ProfileInstaller { true } None => { - // Unknown owner - if !force { + if path_owned_by_profile(lockfile, profile_name, file_path) { + // Directory path owned through tracked child files. + false + } else if !force { + // Unknown owner return Err(ProfileError::FileConflict { path: file_path.clone(), owner: "unknown".to_string(), }); + } else { + // Force enabled - use sidecar + true } - // Force enabled - use sidecar - true } } } else { @@ -233,15 +238,13 @@ impl ProfileInstaller { std::fs::create_dir_all(parent)?; } - // Copy file - std::fs::copy(&source_path, &final_path)?; + let copied_files = + copy_source_entry(&source_path, &final_path, Path::new(&relative_path))?; if use_sidecar { - sidecars.push((file_path.clone(), relative_path.clone())); - installed.push(relative_path); - } else { - installed.push(file_path.clone()); + sidecars.push((file_path.clone(), relative_path)); } + installed.extend(copied_files); } Ok((installed, sidecars)) @@ -253,3 +256,72 @@ impl Default for ProfileInstaller { Self::new() } } + +fn path_owned_by_profile(lockfile: &ProfileLockfile, profile_name: &str, path: &str) -> bool { + let Some(entry) = lockfile.get_profile(profile_name) else { + return false; + }; + + let prefix = format!("{}/", path.trim_end_matches('/')); + entry + .files + .iter() + .any(|tracked| tracked == path || tracked.starts_with(&prefix)) +} + +fn copy_source_entry( + source_path: &Path, + dest_path: &Path, + relative_base: &Path, +) -> ProfileResult> { + if source_path.is_dir() { + return copy_directory_contents(source_path, dest_path, relative_base); + } + + std::fs::copy(source_path, dest_path)?; + Ok(vec![normalize_path(relative_base)]) +} + +fn copy_directory_contents( + source_dir: &Path, + dest_dir: &Path, + relative_base: &Path, +) -> ProfileResult> { + let mut copied_files = Vec::new(); + std::fs::create_dir_all(dest_dir)?; + + for entry in WalkDir::new(source_dir).follow_links(true) { + let entry = entry.map_err(|e| ProfileError::IoError(std::io::Error::other(e)))?; + let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(source_dir) + .expect("walkdir entry should be under source_dir"); + + if relative.as_os_str().is_empty() { + continue; + } + + if relative.components().any(|c| c.as_os_str() == ".git") { + continue; + } + + let destination = dest_dir.join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&destination)?; + continue; + } + + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::copy(entry_path, &destination)?; + copied_files.push(normalize_path(&relative_base.join(relative))); + } + + Ok(copied_files) +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} diff --git a/tests/profiles/test_orchestrator.rs b/tests/profiles/test_orchestrator.rs index 3dba98e1..11200206 100644 --- a/tests/profiles/test_orchestrator.rs +++ b/tests/profiles/test_orchestrator.rs @@ -263,3 +263,121 @@ fn test_install_profile_conflict_creates_sidecar() { let content_b = fs::read_to_string(&sidecar_path).unwrap(); assert_eq!(content_b, "# Profile B"); } + +#[cfg(unix)] +#[test] +fn test_install_profile_supports_symlinked_directory() { + let temp = tempdir().unwrap(); + + let profiles_dir = temp.path().join("profiles"); + let profile_dir = profiles_dir.join("symlink-profile"); + fs::create_dir_all(profile_dir.join("skills")).unwrap(); + + fs::write( + profile_dir.join("profile.json"), + r#"{"name": "symlink-profile", "version": "1.0.0", "files": []}"#, + ) + .unwrap(); + + let shared_skill_dir = temp.path().join("shared-skills").join("sample-skill"); + fs::create_dir_all(&shared_skill_dir).unwrap(); + fs::write(shared_skill_dir.join("SKILL.md"), "# Sample Skill").unwrap(); + + std::os::unix::fs::symlink( + &shared_skill_dir, + profile_dir.join("skills").join("sample-skill"), + ) + .unwrap(); + + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + false, + None, + None, + None, + ) + .unwrap(); + + let installed_skill = workspace.join("skills/sample-skill/SKILL.md"); + assert!(installed_skill.exists()); + assert_eq!( + fs::read_to_string(installed_skill).unwrap(), + "# Sample Skill" + ); + + let lockfile = ProfileLockfile::load(&workspace.join(".codanna/profiles.lock.json")).unwrap(); + let entry = lockfile.get_profile("symlink-profile").unwrap(); + assert!( + entry + .files + .contains(&"skills/sample-skill/SKILL.md".to_string()) + ); +} + +#[cfg(unix)] +#[test] +fn test_force_reinstall_symlinked_directory_overwrites_owned_files() { + let temp = tempdir().unwrap(); + + let profiles_dir = temp.path().join("profiles"); + let profile_dir = profiles_dir.join("symlink-profile"); + fs::create_dir_all(profile_dir.join("skills")).unwrap(); + + fs::write( + profile_dir.join("profile.json"), + r#"{"name": "symlink-profile", "version": "1.0.0", "files": []}"#, + ) + .unwrap(); + + let shared_skill_dir = temp.path().join("shared-skills").join("sample-skill"); + fs::create_dir_all(&shared_skill_dir).unwrap(); + let skill_file = shared_skill_dir.join("SKILL.md"); + fs::write(&skill_file, "# V1").unwrap(); + + std::os::unix::fs::symlink( + &shared_skill_dir, + profile_dir.join("skills").join("sample-skill"), + ) + .unwrap(); + + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + false, + None, + None, + None, + ) + .unwrap(); + + fs::write(&skill_file, "# V2").unwrap(); + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + true, + None, + None, + None, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(workspace.join("skills/sample-skill/SKILL.md")).unwrap(), + "# V2" + ); + assert!( + !workspace + .join("skills/sample-skill.symlink-profile/SKILL.md") + .exists() + ); +}