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