use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{DotAgentError, Result};
use crate::json_merge::{is_mergeable_json, merge_json_file, unmerge_json_file};
use crate::metadata::{compute_file_hash, compute_hash, Metadata};
use crate::platform::Platform;
use crate::profile::{IgnoreConfig, Profile};
const CLAUDE_MD: &str = "CLAUDE.md";
const CLAUDE_DIR: &str = ".claude";
pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
const PREFIXED_SUBDIRS: &[&str] = &["skills"];
fn make_meta_key(profile_name: &str, relative_path: &str) -> String {
format!("{}:{}", profile_name, relative_path)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FileStatus {
Unchanged,
Modified,
Added,
Missing,
}
#[derive(Debug)]
pub struct FileInfo {
pub relative_path: PathBuf,
pub status: FileStatus,
}
#[derive(Debug, Default)]
pub struct InstallResult {
pub installed: usize,
pub skipped: usize,
pub conflicts: usize,
pub merged: usize,
}
#[derive(Debug, Default)]
pub struct DiffResult {
pub unchanged: usize,
pub modified: usize,
pub added: usize,
pub missing: usize,
pub files: Vec<FileInfo>,
}
#[derive(Default)]
pub struct InstallOptions<'a> {
pub force: bool,
pub dry_run: bool,
pub no_prefix: bool,
pub no_merge: bool,
pub ignore_config: IgnoreConfig,
pub on_file: FileCallback<'a>,
pub platform: Option<Platform>,
}
impl std::fmt::Debug for InstallOptions<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InstallOptions")
.field("force", &self.force)
.field("dry_run", &self.dry_run)
.field("no_prefix", &self.no_prefix)
.field("no_merge", &self.no_merge)
.field("ignore_config", &self.ignore_config)
.field("on_file", &self.on_file.is_some())
.field("platform", &self.platform)
.finish()
}
}
impl<'a> InstallOptions<'a> {
pub fn new() -> Self {
Self {
ignore_config: IgnoreConfig::with_defaults(),
..Default::default()
}
}
pub fn force(mut self, force: bool) -> Self {
self.force = force;
self
}
pub fn dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
pub fn no_prefix(mut self, no_prefix: bool) -> Self {
self.no_prefix = no_prefix;
self
}
pub fn no_merge(mut self, no_merge: bool) -> Self {
self.no_merge = no_merge;
self
}
pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
self.ignore_config = config;
self
}
pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
self.on_file = callback;
self
}
pub fn platform(mut self, platform: Platform) -> Self {
self.platform = Some(platform);
self
}
pub fn should_include_path(&self, path: &Path) -> bool {
match self.platform {
Some(platform) => platform.supports_path(path),
None => true, }
}
}
pub struct Installer {
base_dir: PathBuf,
}
impl Installer {
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
if global {
let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
Ok(home.join(".claude"))
} else {
let base = target
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap());
if !base.exists() {
return Err(DotAgentError::TargetNotFound { path: base });
}
Ok(base.join(CLAUDE_DIR))
}
}
pub fn install(
&self,
profile: &Profile,
target: &Path,
opts: &InstallOptions<'_>,
) -> Result<InstallResult> {
let mut result = InstallResult::default();
let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
if !opts.dry_run && !target.exists() {
fs::create_dir_all(target)?;
}
let files = profile.list_files_with_config(&opts.ignore_config)?;
for relative_path in files {
if !opts.should_include_path(&relative_path) {
if let Some(f) = opts.on_file {
f(
"SKIP",
&format!("{} (unsupported)", relative_path.display()),
);
}
result.skipped += 1;
continue;
}
let src = profile.path.join(&relative_path);
let prefixed_path = if opts.no_prefix {
relative_path.clone()
} else {
prefix_path(&relative_path, &profile.name)
};
let dst = target.join(&prefixed_path);
let relative_str = prefixed_path.to_string_lossy().to_string();
let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
let is_mergeable = is_mergeable_json(&relative_path);
if is_mergeable && !opts.no_merge && dst.exists() {
let merge_result = merge_json_file(&dst, &src, &profile.name)?;
if !merge_result.changed {
if let Some(f) = opts.on_file {
f("SKIP", &relative_str);
}
result.skipped += 1;
continue;
}
if !opts.dry_run {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dst, &merge_result.content)?;
metadata.add_merged(
&profile.name,
&relative_str,
merge_result.record.added_paths,
);
}
if let Some(f) = opts.on_file {
f("MERGE", &relative_str);
}
result.merged += 1;
continue;
}
let src_content = fs::read(&src)?;
let src_hash = compute_hash(&src_content);
if dst.exists() {
let dst_hash = compute_file_hash(&dst)?;
if src_hash == dst_hash {
if let Some(f) = opts.on_file {
f("SKIP", &relative_str);
}
result.skipped += 1;
continue;
}
if is_claude_md {
if let Some(f) = opts.on_file {
f("WARN", &relative_str);
}
result.skipped += 1;
continue;
}
if !opts.force {
if let Some(f) = opts.on_file {
f("CONFLICT", &relative_str);
}
result.conflicts += 1;
continue;
}
}
if !opts.dry_run {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if is_mergeable && !opts.no_merge {
let merge_result = merge_json_file(&dst, &src, &profile.name)?;
fs::write(&dst, &merge_result.content)?;
metadata.add_merged(
&profile.name,
&relative_str,
merge_result.record.added_paths,
);
} else {
fs::write(&dst, &src_content)?;
let meta_key = make_meta_key(&profile.name, &relative_str);
metadata.add_file(&meta_key, &src_hash);
}
}
if let Some(f) = opts.on_file {
f("OK", &relative_str);
}
result.installed += 1;
}
if !opts.dry_run && result.conflicts == 0 {
metadata.add_profile(&profile.name);
metadata.save(target)?;
}
Ok(result)
}
pub fn diff(
&self,
profile: &Profile,
target: &Path,
ignore_config: &IgnoreConfig,
) -> Result<DiffResult> {
let mut result = DiffResult::default();
if !target.exists() {
for relative_path in profile.list_files_with_config(ignore_config)? {
let prefixed_path = prefix_path(&relative_path, &profile.name);
result.files.push(FileInfo {
relative_path: prefixed_path,
status: FileStatus::Missing,
});
result.missing += 1;
}
return Ok(result);
}
let metadata = Metadata::load(target)?;
let profile_files = profile.list_files_with_config(ignore_config)?;
let prefixed_files: Vec<_> = profile_files
.iter()
.map(|p| prefix_path(p, &profile.name))
.collect();
for (idx, relative_path) in profile_files.iter().enumerate() {
let src = profile.path.join(relative_path);
let prefixed_path = &prefixed_files[idx];
let dst = target.join(prefixed_path);
if !dst.exists() {
result.files.push(FileInfo {
relative_path: prefixed_path.clone(),
status: FileStatus::Missing,
});
result.missing += 1;
continue;
}
let src_hash = compute_file_hash(&src)?;
let dst_hash = compute_file_hash(&dst)?;
if src_hash == dst_hash {
result.files.push(FileInfo {
relative_path: prefixed_path.clone(),
status: FileStatus::Unchanged,
});
result.unchanged += 1;
} else {
result.files.push(FileInfo {
relative_path: prefixed_path.clone(),
status: FileStatus::Modified,
});
result.modified += 1;
}
}
if let Some(meta) = &metadata {
for file_path in meta.files.keys() {
let path = PathBuf::from(file_path);
if !prefixed_files.contains(&path) {
let full_path = target.join(&path);
if full_path.exists() {
result.files.push(FileInfo {
relative_path: path,
status: FileStatus::Added,
});
result.added += 1;
}
}
}
}
result
.files
.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
Ok(result)
}
pub fn remove(
&self,
profile: &Profile,
target: &Path,
opts: &InstallOptions<'_>,
) -> Result<(usize, usize, usize)> {
if !target.exists() {
return Ok((0, 0, 0));
}
let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
let diff = self.diff(profile, target, &opts.ignore_config)?;
if !opts.force {
let modified: Vec<_> = diff
.files
.iter()
.filter(|f| {
f.status == FileStatus::Modified
&& (opts.no_merge || !is_mergeable_json(&f.relative_path))
})
.map(|f| f.relative_path.clone())
.collect();
if !modified.is_empty() {
return Err(DotAgentError::LocalModifications { paths: modified });
}
}
let mut removed = 0;
let mut kept = 0;
let mut unmerged = 0;
if !opts.no_merge {
if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
for (file_path, _json_paths) in merged_files {
let dst = target.join(&file_path);
if !dst.exists() {
continue;
}
if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
if result.changed {
if !opts.dry_run {
fs::write(&dst, &result.content)?;
}
if let Some(f) = opts.on_file {
f("UNMERGE", &file_path);
}
unmerged += 1;
}
}
}
}
}
for file_info in &diff.files {
let dst = target.join(&file_info.relative_path);
let relative_str = file_info.relative_path.to_string_lossy().to_string();
if relative_str == CLAUDE_MD {
if let Some(f) = opts.on_file {
f("KEEP", &relative_str);
}
kept += 1;
continue;
}
if file_info.status == FileStatus::Added {
if let Some(f) = opts.on_file {
f("KEEP", &relative_str);
}
kept += 1;
continue;
}
if file_info.status == FileStatus::Missing {
continue;
}
if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
if metadata.get_merged(&profile.name, &relative_str).is_some() {
continue;
}
}
if !opts.dry_run && dst.exists() {
fs::remove_file(&dst)?;
let meta_key = make_meta_key(&profile.name, &relative_str);
metadata.remove_file(&meta_key);
if let Some(parent) = dst.parent() {
let _ = remove_empty_dirs(parent, target);
}
}
if let Some(f) = opts.on_file {
f("DEL", &relative_str);
}
removed += 1;
}
if !opts.dry_run {
metadata.remove_profile(&profile.name);
metadata.remove_merged(&profile.name);
if metadata.installed.profiles.is_empty()
&& metadata.files.is_empty()
&& metadata.merged.is_empty()
{
let meta_path = target.join(".dot-agent-meta.toml");
let _ = fs::remove_file(meta_path);
} else {
metadata.save(target)?;
}
}
Ok((removed, kept, unmerged))
}
pub fn upgrade(
&self,
profile: &Profile,
target: &Path,
opts: &InstallOptions<'_>,
) -> Result<(usize, usize, usize, usize)> {
if !target.exists() {
let result = self.install(profile, target, opts)?;
return Ok((0, result.installed, 0, 0));
}
let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
let mut updated = 0;
let mut new = 0;
let mut skipped = 0;
let mut unchanged = 0;
let files = profile.list_files_with_config(&opts.ignore_config)?;
for relative_path in files {
let src = profile.path.join(&relative_path);
let prefixed_path = if opts.no_prefix {
relative_path.clone()
} else {
prefix_path(&relative_path, &profile.name)
};
let dst = target.join(&prefixed_path);
let relative_str = prefixed_path.to_string_lossy().to_string();
let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
let src_content = fs::read(&src)?;
let src_hash = compute_hash(&src_content);
let meta_key = make_meta_key(&profile.name, &relative_str);
if !dst.exists() {
if !opts.dry_run {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dst, &src_content)?;
metadata.add_file(&meta_key, &src_hash);
}
if let Some(f) = opts.on_file {
f("NEW", &relative_str);
}
new += 1;
continue;
}
let dst_hash = compute_file_hash(&dst)?;
if src_hash == dst_hash {
if let Some(f) = opts.on_file {
f("OK", &relative_str);
}
unchanged += 1;
continue;
}
if is_claude_md {
if let Some(f) = opts.on_file {
f("WARN", &relative_str);
}
skipped += 1;
continue;
}
let original_hash = metadata.get_file_hash(&meta_key);
let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
if locally_modified && !opts.force {
if let Some(f) = opts.on_file {
f("SKIP", &relative_str);
}
skipped += 1;
continue;
}
if !opts.dry_run {
fs::write(&dst, &src_content)?;
metadata.add_file(&meta_key, &src_hash);
}
if let Some(f) = opts.on_file {
f("UPDATE", &relative_str);
}
updated += 1;
}
if !opts.dry_run {
metadata.add_profile(&profile.name);
metadata.save(target)?;
}
Ok((updated, new, skipped, unchanged))
}
}
fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
if dir == root {
return Ok(());
}
if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
fs::remove_dir(dir)?;
if let Some(parent) = dir.parent() {
remove_empty_dirs(parent, root)?;
}
}
Ok(())
}
fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
let components: Vec<_> = relative_path.components().collect();
if components.is_empty() {
return relative_path.to_path_buf();
}
let first = components[0].as_os_str().to_string_lossy();
if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
let filename = components[1].as_os_str().to_string_lossy();
if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
return relative_path.to_path_buf();
}
let mut result = PathBuf::from(components[0].as_os_str());
result.push(format!("{}-{}", profile_name, filename));
for comp in &components[2..] {
result.push(comp.as_os_str());
}
return result;
}
if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
let subdir = components[1].as_os_str().to_string_lossy();
if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
return relative_path.to_path_buf();
}
let mut result = PathBuf::from(components[0].as_os_str());
result.push(format!("{}-{}", profile_name, subdir));
for comp in &components[2..] {
result.push(comp.as_os_str());
}
return result;
}
relative_path.to_path_buf()
}