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::platform::Platform;
8use crate::profile::{IgnoreConfig, Profile};
9
10const CLAUDE_MD: &str = "CLAUDE.md";
11const CLAUDE_DIR: &str = ".claude";
12
13pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
15
16const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
18const PREFIXED_SUBDIRS: &[&str] = &["skills"];
20
21fn make_meta_key(profile_name: &str, relative_path: &str) -> String {
24 format!("{}:{}", profile_name, relative_path)
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub enum FileStatus {
29 Unchanged,
30 Modified,
31 Added,
32 Missing,
33}
34
35#[derive(Debug)]
36pub struct FileInfo {
37 pub relative_path: PathBuf,
38 pub status: FileStatus,
39}
40
41#[derive(Debug, Default)]
42pub struct InstallResult {
43 pub installed: usize,
44 pub skipped: usize,
45 pub conflicts: usize,
46 pub merged: usize,
47}
48
49#[derive(Debug, Default)]
50pub struct DiffResult {
51 pub unchanged: usize,
52 pub modified: usize,
53 pub added: usize,
54 pub missing: usize,
55 pub files: Vec<FileInfo>,
56}
57
58#[derive(Default)]
60pub struct InstallOptions<'a> {
61 pub force: bool,
63 pub dry_run: bool,
65 pub no_prefix: bool,
67 pub no_merge: bool,
69 pub ignore_config: IgnoreConfig,
71 pub on_file: FileCallback<'a>,
73 pub platform: Option<Platform>,
75}
76
77impl std::fmt::Debug for InstallOptions<'_> {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.debug_struct("InstallOptions")
80 .field("force", &self.force)
81 .field("dry_run", &self.dry_run)
82 .field("no_prefix", &self.no_prefix)
83 .field("no_merge", &self.no_merge)
84 .field("ignore_config", &self.ignore_config)
85 .field("on_file", &self.on_file.is_some())
86 .field("platform", &self.platform)
87 .finish()
88 }
89}
90
91impl<'a> InstallOptions<'a> {
92 pub fn new() -> Self {
94 Self {
95 ignore_config: IgnoreConfig::with_defaults(),
96 ..Default::default()
97 }
98 }
99
100 pub fn force(mut self, force: bool) -> Self {
102 self.force = force;
103 self
104 }
105
106 pub fn dry_run(mut self, dry_run: bool) -> Self {
108 self.dry_run = dry_run;
109 self
110 }
111
112 pub fn no_prefix(mut self, no_prefix: bool) -> Self {
114 self.no_prefix = no_prefix;
115 self
116 }
117
118 pub fn no_merge(mut self, no_merge: bool) -> Self {
120 self.no_merge = no_merge;
121 self
122 }
123
124 pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
126 self.ignore_config = config;
127 self
128 }
129
130 pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
132 self.on_file = callback;
133 self
134 }
135
136 pub fn platform(mut self, platform: Platform) -> Self {
138 self.platform = Some(platform);
139 self
140 }
141
142 pub fn should_include_path(&self, path: &Path) -> bool {
144 match self.platform {
145 Some(platform) => platform.supports_path(path),
146 None => true, }
148 }
149}
150
151pub struct Installer {
152 base_dir: PathBuf,
153}
154
155impl Installer {
156 pub fn new(base_dir: PathBuf) -> Self {
157 Self { base_dir }
158 }
159
160 pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
162 if global {
163 let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
164 Ok(home.join(".claude"))
165 } else {
166 let base = target
167 .map(|p| p.to_path_buf())
168 .unwrap_or_else(|| std::env::current_dir().unwrap());
169
170 if !base.exists() {
171 return Err(DotAgentError::TargetNotFound { path: base });
172 }
173
174 Ok(base.join(CLAUDE_DIR))
175 }
176 }
177
178 pub fn install(
180 &self,
181 profile: &Profile,
182 target: &Path,
183 opts: &InstallOptions<'_>,
184 ) -> Result<InstallResult> {
185 let mut result = InstallResult::default();
186 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
187
188 if !opts.dry_run && !target.exists() {
190 fs::create_dir_all(target)?;
191 }
192
193 let files = profile.list_files_with_config(&opts.ignore_config)?;
194
195 for relative_path in files {
196 if !opts.should_include_path(&relative_path) {
198 if let Some(f) = opts.on_file {
199 f(
200 "SKIP",
201 &format!("{} (unsupported)", relative_path.display()),
202 );
203 }
204 result.skipped += 1;
205 continue;
206 }
207
208 let src = profile.path.join(&relative_path);
209 let prefixed_path = if opts.no_prefix {
210 relative_path.clone()
211 } else {
212 prefix_path(&relative_path, &profile.name)
213 };
214 let dst = target.join(&prefixed_path);
215 let relative_str = prefixed_path.to_string_lossy().to_string();
216
217 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
218 let is_mergeable = is_mergeable_json(&relative_path);
219
220 if is_mergeable && !opts.no_merge && dst.exists() {
222 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
223
224 if !merge_result.changed {
225 if let Some(f) = opts.on_file {
226 f("SKIP", &relative_str);
227 }
228 result.skipped += 1;
229 continue;
230 }
231
232 if !opts.dry_run {
233 if let Some(parent) = dst.parent() {
234 fs::create_dir_all(parent)?;
235 }
236 fs::write(&dst, &merge_result.content)?;
237 metadata.add_merged(
238 &profile.name,
239 &relative_str,
240 merge_result.record.added_paths,
241 );
242 }
243
244 if let Some(f) = opts.on_file {
245 f("MERGE", &relative_str);
246 }
247 result.merged += 1;
248 continue;
249 }
250
251 let src_content = fs::read(&src)?;
252 let src_hash = compute_hash(&src_content);
253
254 if dst.exists() {
255 let dst_hash = compute_file_hash(&dst)?;
256
257 if src_hash == dst_hash {
258 if let Some(f) = opts.on_file {
260 f("SKIP", &relative_str);
261 }
262 result.skipped += 1;
263 continue;
264 }
265
266 if is_claude_md {
268 if let Some(f) = opts.on_file {
269 f("WARN", &relative_str);
270 }
271 result.skipped += 1;
272 continue;
273 }
274
275 if !opts.force {
277 if let Some(f) = opts.on_file {
278 f("CONFLICT", &relative_str);
279 }
280 result.conflicts += 1;
281 continue;
282 }
283 }
284
285 if !opts.dry_run {
287 if let Some(parent) = dst.parent() {
288 fs::create_dir_all(parent)?;
289 }
290
291 if is_mergeable && !opts.no_merge {
293 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
294 fs::write(&dst, &merge_result.content)?;
295 metadata.add_merged(
296 &profile.name,
297 &relative_str,
298 merge_result.record.added_paths,
299 );
300 } else {
301 fs::write(&dst, &src_content)?;
302 let meta_key = make_meta_key(&profile.name, &relative_str);
303 metadata.add_file(&meta_key, &src_hash);
304 }
305 }
306
307 if let Some(f) = opts.on_file {
308 f("OK", &relative_str);
309 }
310 result.installed += 1;
311 }
312
313 if !opts.dry_run && result.conflicts == 0 {
314 metadata.add_profile(&profile.name);
315 metadata.save(target)?;
316 }
317
318 Ok(result)
319 }
320
321 pub fn diff(
323 &self,
324 profile: &Profile,
325 target: &Path,
326 ignore_config: &IgnoreConfig,
327 ) -> Result<DiffResult> {
328 let mut result = DiffResult::default();
329
330 if !target.exists() {
331 for relative_path in profile.list_files_with_config(ignore_config)? {
333 let prefixed_path = prefix_path(&relative_path, &profile.name);
334 result.files.push(FileInfo {
335 relative_path: prefixed_path,
336 status: FileStatus::Missing,
337 });
338 result.missing += 1;
339 }
340 return Ok(result);
341 }
342
343 let metadata = Metadata::load(target)?;
344 let profile_files = profile.list_files_with_config(ignore_config)?;
345
346 let prefixed_files: Vec<_> = profile_files
348 .iter()
349 .map(|p| prefix_path(p, &profile.name))
350 .collect();
351
352 for (idx, relative_path) in profile_files.iter().enumerate() {
354 let src = profile.path.join(relative_path);
355 let prefixed_path = &prefixed_files[idx];
356 let dst = target.join(prefixed_path);
357
358 if !dst.exists() {
359 result.files.push(FileInfo {
360 relative_path: prefixed_path.clone(),
361 status: FileStatus::Missing,
362 });
363 result.missing += 1;
364 continue;
365 }
366
367 let src_hash = compute_file_hash(&src)?;
368 let dst_hash = compute_file_hash(&dst)?;
369
370 if src_hash == dst_hash {
371 result.files.push(FileInfo {
372 relative_path: prefixed_path.clone(),
373 status: FileStatus::Unchanged,
374 });
375 result.unchanged += 1;
376 } else {
377 result.files.push(FileInfo {
378 relative_path: prefixed_path.clone(),
379 status: FileStatus::Modified,
380 });
381 result.modified += 1;
382 }
383 }
384
385 if let Some(meta) = &metadata {
387 for file_path in meta.files.keys() {
388 let path = PathBuf::from(file_path);
389 if !prefixed_files.contains(&path) {
390 let full_path = target.join(&path);
391 if full_path.exists() {
392 result.files.push(FileInfo {
393 relative_path: path,
394 status: FileStatus::Added,
395 });
396 result.added += 1;
397 }
398 }
399 }
400 }
401
402 result
403 .files
404 .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
405 Ok(result)
406 }
407
408 pub fn remove(
410 &self,
411 profile: &Profile,
412 target: &Path,
413 opts: &InstallOptions<'_>,
414 ) -> Result<(usize, usize, usize)> {
415 if !target.exists() {
416 return Ok((0, 0, 0));
417 }
418
419 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
420 let diff = self.diff(profile, target, &opts.ignore_config)?;
421
422 if !opts.force {
425 let modified: Vec<_> = diff
426 .files
427 .iter()
428 .filter(|f| {
429 f.status == FileStatus::Modified
430 && (opts.no_merge || !is_mergeable_json(&f.relative_path))
431 })
432 .map(|f| f.relative_path.clone())
433 .collect();
434
435 if !modified.is_empty() {
436 return Err(DotAgentError::LocalModifications { paths: modified });
437 }
438 }
439
440 let mut removed = 0;
441 let mut kept = 0;
442 let mut unmerged = 0;
443
444 if !opts.no_merge {
446 if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
447 for (file_path, _json_paths) in merged_files {
448 let dst = target.join(&file_path);
449 if !dst.exists() {
450 continue;
451 }
452
453 if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
454 if result.changed {
455 if !opts.dry_run {
456 fs::write(&dst, &result.content)?;
457 }
458 if let Some(f) = opts.on_file {
459 f("UNMERGE", &file_path);
460 }
461 unmerged += 1;
462 }
463 }
464 }
465 }
466 }
467
468 for file_info in &diff.files {
469 let dst = target.join(&file_info.relative_path);
470 let relative_str = file_info.relative_path.to_string_lossy().to_string();
471
472 if relative_str == CLAUDE_MD {
474 if let Some(f) = opts.on_file {
475 f("KEEP", &relative_str);
476 }
477 kept += 1;
478 continue;
479 }
480
481 if file_info.status == FileStatus::Added {
483 if let Some(f) = opts.on_file {
484 f("KEEP", &relative_str);
485 }
486 kept += 1;
487 continue;
488 }
489
490 if file_info.status == FileStatus::Missing {
492 continue;
493 }
494
495 if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
497 if metadata.get_merged(&profile.name, &relative_str).is_some() {
499 continue;
500 }
501 }
502
503 if !opts.dry_run && dst.exists() {
505 fs::remove_file(&dst)?;
506 let meta_key = make_meta_key(&profile.name, &relative_str);
507 metadata.remove_file(&meta_key);
508
509 if let Some(parent) = dst.parent() {
511 let _ = remove_empty_dirs(parent, target);
512 }
513 }
514
515 if let Some(f) = opts.on_file {
516 f("DEL", &relative_str);
517 }
518 removed += 1;
519 }
520
521 if !opts.dry_run {
522 metadata.remove_profile(&profile.name);
523 metadata.remove_merged(&profile.name);
524 if metadata.installed.profiles.is_empty()
525 && metadata.files.is_empty()
526 && metadata.merged.is_empty()
527 {
528 let meta_path = target.join(".dot-agent-meta.toml");
530 let _ = fs::remove_file(meta_path);
531 } else {
532 metadata.save(target)?;
533 }
534 }
535
536 Ok((removed, kept, unmerged))
537 }
538
539 pub fn upgrade(
541 &self,
542 profile: &Profile,
543 target: &Path,
544 opts: &InstallOptions<'_>,
545 ) -> Result<(usize, usize, usize, usize)> {
546 if !target.exists() {
548 let result = self.install(profile, target, opts)?;
550 return Ok((0, result.installed, 0, 0));
551 }
552
553 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
554 let mut updated = 0;
555 let mut new = 0;
556 let mut skipped = 0;
557 let mut unchanged = 0;
558
559 let files = profile.list_files_with_config(&opts.ignore_config)?;
560
561 for relative_path in files {
562 let src = profile.path.join(&relative_path);
563 let prefixed_path = if opts.no_prefix {
564 relative_path.clone()
565 } else {
566 prefix_path(&relative_path, &profile.name)
567 };
568 let dst = target.join(&prefixed_path);
569 let relative_str = prefixed_path.to_string_lossy().to_string();
570 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
571
572 let src_content = fs::read(&src)?;
573 let src_hash = compute_hash(&src_content);
574
575 let meta_key = make_meta_key(&profile.name, &relative_str);
576
577 if !dst.exists() {
578 if !opts.dry_run {
580 if let Some(parent) = dst.parent() {
581 fs::create_dir_all(parent)?;
582 }
583 fs::write(&dst, &src_content)?;
584 metadata.add_file(&meta_key, &src_hash);
585 }
586 if let Some(f) = opts.on_file {
587 f("NEW", &relative_str);
588 }
589 new += 1;
590 continue;
591 }
592
593 let dst_hash = compute_file_hash(&dst)?;
594
595 if src_hash == dst_hash {
596 if let Some(f) = opts.on_file {
597 f("OK", &relative_str);
598 }
599 unchanged += 1;
600 continue;
601 }
602
603 if is_claude_md {
605 if let Some(f) = opts.on_file {
606 f("WARN", &relative_str);
607 }
608 skipped += 1;
609 continue;
610 }
611
612 let original_hash = metadata.get_file_hash(&meta_key);
614 let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
615
616 if locally_modified && !opts.force {
617 if let Some(f) = opts.on_file {
618 f("SKIP", &relative_str);
619 }
620 skipped += 1;
621 continue;
622 }
623
624 if !opts.dry_run {
626 fs::write(&dst, &src_content)?;
627 metadata.add_file(&meta_key, &src_hash);
628 }
629 if let Some(f) = opts.on_file {
630 f("UPDATE", &relative_str);
631 }
632 updated += 1;
633 }
634
635 if !opts.dry_run {
636 metadata.add_profile(&profile.name);
637 metadata.save(target)?;
638 }
639
640 Ok((updated, new, skipped, unchanged))
641 }
642}
643
644fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
645 if dir == root {
646 return Ok(());
647 }
648
649 if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
650 fs::remove_dir(dir)?;
651 if let Some(parent) = dir.parent() {
652 remove_empty_dirs(parent, root)?;
653 }
654 }
655
656 Ok(())
657}
658
659fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
667 let components: Vec<_> = relative_path.components().collect();
668
669 if components.is_empty() {
670 return relative_path.to_path_buf();
671 }
672
673 let first = components[0].as_os_str().to_string_lossy();
675
676 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
678 let filename = components[1].as_os_str().to_string_lossy();
679
680 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
682 return relative_path.to_path_buf();
683 }
684
685 let mut result = PathBuf::from(components[0].as_os_str());
687 result.push(format!("{}-{}", profile_name, filename));
688
689 for comp in &components[2..] {
691 result.push(comp.as_os_str());
692 }
693 return result;
694 }
695
696 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
698 let subdir = components[1].as_os_str().to_string_lossy();
699
700 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
702 return relative_path.to_path_buf();
703 }
704
705 let mut result = PathBuf::from(components[0].as_os_str());
707 result.push(format!("{}-{}", profile_name, subdir));
708
709 for comp in &components[2..] {
711 result.push(comp.as_os_str());
712 }
713 return result;
714 }
715
716 relative_path.to_path_buf()
718}