1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::error::{DotAgentError, Result};
5use crate::json_merge::{is_mergeable_json, merge_json_file, unmerge_json_file};
6use crate::metadata::{compute_file_hash, compute_hash, Metadata};
7use crate::profile::{IgnoreConfig, Profile};
8
9const CLAUDE_MD: &str = "CLAUDE.md";
10const CLAUDE_DIR: &str = ".claude";
11
12pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
14
15const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
17const PREFIXED_SUBDIRS: &[&str] = &["skills"];
19
20fn make_meta_key(profile_name: &str, relative_path: &str) -> String {
23 format!("{}:{}", profile_name, relative_path)
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum FileStatus {
28 Unchanged,
29 Modified,
30 Added,
31 Missing,
32}
33
34#[derive(Debug)]
35pub struct FileInfo {
36 pub relative_path: PathBuf,
37 pub status: FileStatus,
38}
39
40#[derive(Debug, Default)]
41pub struct InstallResult {
42 pub installed: usize,
43 pub skipped: usize,
44 pub conflicts: usize,
45 pub merged: usize,
46}
47
48#[derive(Debug, Default)]
49pub struct DiffResult {
50 pub unchanged: usize,
51 pub modified: usize,
52 pub added: usize,
53 pub missing: usize,
54 pub files: Vec<FileInfo>,
55}
56
57pub struct Installer {
58 base_dir: PathBuf,
59}
60
61impl Installer {
62 pub fn new(base_dir: PathBuf) -> Self {
63 Self { base_dir }
64 }
65
66 pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
68 if global {
69 let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
70 Ok(home.join(".claude"))
71 } else {
72 let base = target
73 .map(|p| p.to_path_buf())
74 .unwrap_or_else(|| std::env::current_dir().unwrap());
75
76 if !base.exists() {
77 return Err(DotAgentError::TargetNotFound { path: base });
78 }
79
80 Ok(base.join(CLAUDE_DIR))
81 }
82 }
83
84 #[allow(clippy::too_many_arguments)]
86 pub fn install(
87 &self,
88 profile: &Profile,
89 target: &Path,
90 force: bool,
91 dry_run: bool,
92 no_prefix: bool,
93 no_merge: bool,
94 ignore_config: &IgnoreConfig,
95 on_file: FileCallback<'_>,
96 ) -> Result<InstallResult> {
97 let mut result = InstallResult::default();
98 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
99
100 if !dry_run && !target.exists() {
102 fs::create_dir_all(target)?;
103 }
104
105 let files = profile.list_files_with_config(ignore_config)?;
106
107 for relative_path in files {
108 let src = profile.path.join(&relative_path);
109 let prefixed_path = if no_prefix {
110 relative_path.clone()
111 } else {
112 prefix_path(&relative_path, &profile.name)
113 };
114 let dst = target.join(&prefixed_path);
115 let relative_str = prefixed_path.to_string_lossy().to_string();
116
117 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
118 let is_mergeable = is_mergeable_json(&relative_path);
119
120 if is_mergeable && !no_merge && dst.exists() {
122 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
123
124 if !merge_result.changed {
125 if let Some(f) = on_file {
126 f("SKIP", &relative_str);
127 }
128 result.skipped += 1;
129 continue;
130 }
131
132 if !dry_run {
133 if let Some(parent) = dst.parent() {
134 fs::create_dir_all(parent)?;
135 }
136 fs::write(&dst, &merge_result.content)?;
137 metadata.add_merged(
138 &profile.name,
139 &relative_str,
140 merge_result.record.added_paths,
141 );
142 }
143
144 if let Some(f) = on_file {
145 f("MERGE", &relative_str);
146 }
147 result.merged += 1;
148 continue;
149 }
150
151 let src_content = fs::read(&src)?;
152 let src_hash = compute_hash(&src_content);
153
154 if dst.exists() {
155 let dst_hash = compute_file_hash(&dst)?;
156
157 if src_hash == dst_hash {
158 if let Some(f) = on_file {
160 f("SKIP", &relative_str);
161 }
162 result.skipped += 1;
163 continue;
164 }
165
166 if is_claude_md {
168 if let Some(f) = on_file {
169 f("WARN", &relative_str);
170 }
171 result.skipped += 1;
172 continue;
173 }
174
175 if !force {
177 if let Some(f) = on_file {
178 f("CONFLICT", &relative_str);
179 }
180 result.conflicts += 1;
181 continue;
182 }
183 }
184
185 if !dry_run {
187 if let Some(parent) = dst.parent() {
188 fs::create_dir_all(parent)?;
189 }
190
191 if is_mergeable && !no_merge {
193 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
194 fs::write(&dst, &merge_result.content)?;
195 metadata.add_merged(
196 &profile.name,
197 &relative_str,
198 merge_result.record.added_paths,
199 );
200 } else {
201 fs::write(&dst, &src_content)?;
202 let meta_key = make_meta_key(&profile.name, &relative_str);
203 metadata.add_file(&meta_key, &src_hash);
204 }
205 }
206
207 if let Some(f) = on_file {
208 f("OK", &relative_str);
209 }
210 result.installed += 1;
211 }
212
213 if !dry_run && result.conflicts == 0 {
214 metadata.add_profile(&profile.name);
215 metadata.save(target)?;
216 }
217
218 Ok(result)
219 }
220
221 pub fn diff(
223 &self,
224 profile: &Profile,
225 target: &Path,
226 ignore_config: &IgnoreConfig,
227 ) -> Result<DiffResult> {
228 let mut result = DiffResult::default();
229
230 if !target.exists() {
231 for relative_path in profile.list_files_with_config(ignore_config)? {
233 let prefixed_path = prefix_path(&relative_path, &profile.name);
234 result.files.push(FileInfo {
235 relative_path: prefixed_path,
236 status: FileStatus::Missing,
237 });
238 result.missing += 1;
239 }
240 return Ok(result);
241 }
242
243 let metadata = Metadata::load(target)?;
244 let profile_files = profile.list_files_with_config(ignore_config)?;
245
246 let prefixed_files: Vec<_> = profile_files
248 .iter()
249 .map(|p| prefix_path(p, &profile.name))
250 .collect();
251
252 for (idx, relative_path) in profile_files.iter().enumerate() {
254 let src = profile.path.join(relative_path);
255 let prefixed_path = &prefixed_files[idx];
256 let dst = target.join(prefixed_path);
257
258 if !dst.exists() {
259 result.files.push(FileInfo {
260 relative_path: prefixed_path.clone(),
261 status: FileStatus::Missing,
262 });
263 result.missing += 1;
264 continue;
265 }
266
267 let src_hash = compute_file_hash(&src)?;
268 let dst_hash = compute_file_hash(&dst)?;
269
270 if src_hash == dst_hash {
271 result.files.push(FileInfo {
272 relative_path: prefixed_path.clone(),
273 status: FileStatus::Unchanged,
274 });
275 result.unchanged += 1;
276 } else {
277 result.files.push(FileInfo {
278 relative_path: prefixed_path.clone(),
279 status: FileStatus::Modified,
280 });
281 result.modified += 1;
282 }
283 }
284
285 if let Some(meta) = &metadata {
287 for file_path in meta.files.keys() {
288 let path = PathBuf::from(file_path);
289 if !prefixed_files.contains(&path) {
290 let full_path = target.join(&path);
291 if full_path.exists() {
292 result.files.push(FileInfo {
293 relative_path: path,
294 status: FileStatus::Added,
295 });
296 result.added += 1;
297 }
298 }
299 }
300 }
301
302 result
303 .files
304 .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
305 Ok(result)
306 }
307
308 #[allow(clippy::too_many_arguments)]
310 pub fn remove(
311 &self,
312 profile: &Profile,
313 target: &Path,
314 force: bool,
315 dry_run: bool,
316 no_merge: bool,
317 ignore_config: &IgnoreConfig,
318 on_file: FileCallback<'_>,
319 ) -> Result<(usize, usize, usize)> {
320 if !target.exists() {
321 return Ok((0, 0, 0));
322 }
323
324 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
325 let diff = self.diff(profile, target, ignore_config)?;
326
327 if !force {
330 let modified: Vec<_> = diff
331 .files
332 .iter()
333 .filter(|f| {
334 f.status == FileStatus::Modified
335 && (no_merge || !is_mergeable_json(&f.relative_path))
336 })
337 .map(|f| f.relative_path.clone())
338 .collect();
339
340 if !modified.is_empty() {
341 return Err(DotAgentError::LocalModifications { paths: modified });
342 }
343 }
344
345 let mut removed = 0;
346 let mut kept = 0;
347 let mut unmerged = 0;
348
349 if !no_merge {
351 if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
352 for (file_path, _json_paths) in merged_files {
353 let dst = target.join(&file_path);
354 if !dst.exists() {
355 continue;
356 }
357
358 if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
359 if result.changed {
360 if !dry_run {
361 fs::write(&dst, &result.content)?;
362 }
363 if let Some(f) = on_file {
364 f("UNMERGE", &file_path);
365 }
366 unmerged += 1;
367 }
368 }
369 }
370 }
371 }
372
373 for file_info in &diff.files {
374 let dst = target.join(&file_info.relative_path);
375 let relative_str = file_info.relative_path.to_string_lossy().to_string();
376
377 if relative_str == CLAUDE_MD {
379 if let Some(f) = on_file {
380 f("KEEP", &relative_str);
381 }
382 kept += 1;
383 continue;
384 }
385
386 if file_info.status == FileStatus::Added {
388 if let Some(f) = on_file {
389 f("KEEP", &relative_str);
390 }
391 kept += 1;
392 continue;
393 }
394
395 if file_info.status == FileStatus::Missing {
397 continue;
398 }
399
400 if !no_merge && is_mergeable_json(&file_info.relative_path) {
402 if metadata.get_merged(&profile.name, &relative_str).is_some() {
404 continue;
405 }
406 }
407
408 if !dry_run && dst.exists() {
410 fs::remove_file(&dst)?;
411 let meta_key = make_meta_key(&profile.name, &relative_str);
412 metadata.remove_file(&meta_key);
413
414 if let Some(parent) = dst.parent() {
416 let _ = remove_empty_dirs(parent, target);
417 }
418 }
419
420 if let Some(f) = on_file {
421 f("DEL", &relative_str);
422 }
423 removed += 1;
424 }
425
426 if !dry_run {
427 metadata.remove_profile(&profile.name);
428 metadata.remove_merged(&profile.name);
429 if metadata.installed.profiles.is_empty()
430 && metadata.files.is_empty()
431 && metadata.merged.is_empty()
432 {
433 let meta_path = target.join(".dot-agent-meta.toml");
435 let _ = fs::remove_file(meta_path);
436 } else {
437 metadata.save(target)?;
438 }
439 }
440
441 Ok((removed, kept, unmerged))
442 }
443
444 #[allow(clippy::too_many_arguments)]
446 pub fn upgrade(
447 &self,
448 profile: &Profile,
449 target: &Path,
450 force: bool,
451 dry_run: bool,
452 no_prefix: bool,
453 no_merge: bool,
454 ignore_config: &IgnoreConfig,
455 on_file: FileCallback<'_>,
456 ) -> Result<(usize, usize, usize, usize)> {
457 if !target.exists() {
459 let result = self.install(
461 profile,
462 target,
463 force,
464 dry_run,
465 no_prefix,
466 no_merge,
467 ignore_config,
468 on_file,
469 )?;
470 return Ok((0, result.installed, 0, 0));
471 }
472
473 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
474 let mut updated = 0;
475 let mut new = 0;
476 let mut skipped = 0;
477 let mut unchanged = 0;
478
479 let files = profile.list_files_with_config(ignore_config)?;
480
481 for relative_path in files {
482 let src = profile.path.join(&relative_path);
483 let prefixed_path = if no_prefix {
484 relative_path.clone()
485 } else {
486 prefix_path(&relative_path, &profile.name)
487 };
488 let dst = target.join(&prefixed_path);
489 let relative_str = prefixed_path.to_string_lossy().to_string();
490 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
491
492 let src_content = fs::read(&src)?;
493 let src_hash = compute_hash(&src_content);
494
495 let meta_key = make_meta_key(&profile.name, &relative_str);
496
497 if !dst.exists() {
498 if !dry_run {
500 if let Some(parent) = dst.parent() {
501 fs::create_dir_all(parent)?;
502 }
503 fs::write(&dst, &src_content)?;
504 metadata.add_file(&meta_key, &src_hash);
505 }
506 if let Some(f) = on_file {
507 f("NEW", &relative_str);
508 }
509 new += 1;
510 continue;
511 }
512
513 let dst_hash = compute_file_hash(&dst)?;
514
515 if src_hash == dst_hash {
516 if let Some(f) = on_file {
517 f("OK", &relative_str);
518 }
519 unchanged += 1;
520 continue;
521 }
522
523 if is_claude_md {
525 if let Some(f) = on_file {
526 f("WARN", &relative_str);
527 }
528 skipped += 1;
529 continue;
530 }
531
532 let original_hash = metadata.get_file_hash(&meta_key);
534 let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
535
536 if locally_modified && !force {
537 if let Some(f) = on_file {
538 f("SKIP", &relative_str);
539 }
540 skipped += 1;
541 continue;
542 }
543
544 if !dry_run {
546 fs::write(&dst, &src_content)?;
547 metadata.add_file(&meta_key, &src_hash);
548 }
549 if let Some(f) = on_file {
550 f("UPDATE", &relative_str);
551 }
552 updated += 1;
553 }
554
555 if !dry_run {
556 metadata.add_profile(&profile.name);
557 metadata.save(target)?;
558 }
559
560 Ok((updated, new, skipped, unchanged))
561 }
562}
563
564fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
565 if dir == root {
566 return Ok(());
567 }
568
569 if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
570 fs::remove_dir(dir)?;
571 if let Some(parent) = dir.parent() {
572 remove_empty_dirs(parent, root)?;
573 }
574 }
575
576 Ok(())
577}
578
579fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
587 let components: Vec<_> = relative_path.components().collect();
588
589 if components.is_empty() {
590 return relative_path.to_path_buf();
591 }
592
593 let first = components[0].as_os_str().to_string_lossy();
595
596 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
598 let filename = components[1].as_os_str().to_string_lossy();
599
600 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
602 return relative_path.to_path_buf();
603 }
604
605 let mut result = PathBuf::from(components[0].as_os_str());
607 result.push(format!("{}-{}", profile_name, filename));
608
609 for comp in &components[2..] {
611 result.push(comp.as_os_str());
612 }
613 return result;
614 }
615
616 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
618 let subdir = components[1].as_os_str().to_string_lossy();
619
620 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
622 return relative_path.to_path_buf();
623 }
624
625 let mut result = PathBuf::from(components[0].as_os_str());
627 result.push(format!("{}-{}", profile_name, subdir));
628
629 for comp in &components[2..] {
631 result.push(comp.as_os_str());
632 }
633 return result;
634 }
635
636 relative_path.to_path_buf()
638}