use super::error::{ProfileError, ProfileResult};
use super::lockfile::ProfileLockfile;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub type InstallResult = (Vec<String>, Vec<(String, String)>);
pub fn generate_sidecar_path(original: &Path, provider: &str) -> PathBuf {
let file_name = original
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
let parent = original.parent();
let sidecar_name =
if file_name.starts_with('.') && file_name.chars().filter(|&c| c == '.').count() == 1 {
format!("{file_name}.{provider}")
} else if let Some(dot_pos) = file_name.find('.') {
let (stem, ext) = file_name.split_at(dot_pos);
format!("{stem}.{provider}{ext}")
} else {
format!("{file_name}.{provider}")
};
if let Some(p) = parent {
p.join(sidecar_name)
} else {
PathBuf::from(sidecar_name)
}
}
pub fn check_all_conflicts(
workspace: &Path,
files: &[String],
profile_name: &str,
lockfile: &ProfileLockfile,
force: bool,
) -> ProfileResult<()> {
let mut conflicts = Vec::new();
for file_path in files {
let dest_path = workspace.join(file_path);
if dest_path.exists() {
match lockfile.find_file_owner(file_path) {
Some(owner) if owner == profile_name => {
continue;
}
Some(owner) => {
if !force {
conflicts.push((file_path.clone(), owner.to_string()));
}
}
None => {
if path_owned_by_profile(lockfile, profile_name, file_path) {
continue;
}
if !force {
conflicts.push((file_path.clone(), "unknown".to_string()));
}
}
}
}
}
if !conflicts.is_empty() {
return Err(ProfileError::MultipleFileConflicts { conflicts });
}
Ok(())
}
pub fn check_profile_conflicts(
workspace: &Path,
profile_name: &str,
files: &[String],
force: bool,
) -> ProfileResult<()> {
let lockfile_path = workspace.join(".codanna/profiles.lock.json");
let lockfile = ProfileLockfile::load(&lockfile_path)?;
for file_path in files {
let dest = workspace.join(file_path);
if dest.exists() {
match lockfile.find_file_owner(file_path) {
Some(owner) if owner != profile_name && !force => {
return Err(ProfileError::FileConflict {
path: file_path.to_string(),
owner: owner.to_string(),
});
}
None if !force => {
return Err(ProfileError::FileConflict {
path: file_path.to_string(),
owner: "unknown".to_string(),
});
}
_ => {
}
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ProfileInstaller;
impl ProfileInstaller {
pub fn new() -> Self {
Self
}
pub fn install_files(
&self,
source_dir: &Path,
dest_dir: &Path,
files: &[String],
profile_name: &str,
provider_name: &str,
lockfile: &ProfileLockfile,
force: bool,
) -> ProfileResult<InstallResult> {
let mut installed = Vec::new();
let mut sidecars = Vec::new();
for file_path in files {
let source_path = source_dir.join(file_path);
if !source_path.exists() {
continue;
}
let dest_path = dest_dir.join(file_path);
let use_sidecar = if dest_path.exists() {
match lockfile.find_file_owner(file_path) {
Some(owner) if owner == profile_name => {
false
}
Some(owner) => {
if !force {
return Err(ProfileError::FileConflict {
path: file_path.clone(),
owner: owner.to_string(),
});
}
true
}
None => {
if path_owned_by_profile(lockfile, profile_name, file_path) {
false
} else if !force {
return Err(ProfileError::FileConflict {
path: file_path.clone(),
owner: "unknown".to_string(),
});
} else {
true
}
}
}
} else {
false };
let (final_path, relative_path) = if use_sidecar {
let sidecar_path = generate_sidecar_path(&dest_path, provider_name);
let sidecar_relative = generate_sidecar_path(Path::new(file_path), provider_name);
(sidecar_path, sidecar_relative.to_string_lossy().to_string())
} else {
(dest_path.clone(), file_path.clone())
};
if let Some(parent) = final_path.parent() {
std::fs::create_dir_all(parent)?;
}
let copied_files =
copy_source_entry(&source_path, &final_path, Path::new(&relative_path))?;
if use_sidecar {
sidecars.push((file_path.clone(), relative_path));
}
installed.extend(copied_files);
}
Ok((installed, sidecars))
}
}
impl Default for ProfileInstaller {
fn default() -> Self {
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<Vec<String>> {
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<Vec<String>> {
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).map_err(|_| {
ProfileError::IoError(std::io::Error::other(format!(
"walkdir entry '{}' is outside source '{}'",
entry_path.display(),
source_dir.display()
)))
})?;
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('\\', "/")
}