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
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Resolution {
34 KeepLocal,
36 OverwriteWithProfile,
38 Abort,
40}
41
42pub trait ConflictResolver {
44 fn resolve(
49 &self,
50 relative_path: &Path,
51 local_content: &[u8],
52 profile_content: &[u8],
53 ) -> crate::error::Result<Resolution>;
54}
55
56const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
58const PREFIXED_SUBDIRS: &[&str] = &["skills"];
60
61fn make_meta_key(profile_name: &str, relative_path: &str) -> String {
64 format!("{}:{}", profile_name, relative_path)
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub enum FileStatus {
69 Unchanged,
70 Modified,
71 Added,
72 Missing,
73}
74
75#[derive(Debug)]
76pub struct FileInfo {
77 pub relative_path: PathBuf,
78 pub status: FileStatus,
79}
80
81#[derive(Debug, Default)]
82pub struct InstallResult {
83 pub installed: usize,
84 pub skipped: usize,
85 pub conflicts: usize,
86 pub merged: usize,
87}
88
89#[derive(Debug, Default)]
90pub struct SyncBackResult {
91 pub synced: usize,
92 pub unchanged: usize,
93 pub files: Vec<PathBuf>,
94}
95
96#[derive(Debug, Default)]
97pub struct DiffResult {
98 pub unchanged: usize,
99 pub modified: usize,
100 pub added: usize,
101 pub missing: usize,
102 pub files: Vec<FileInfo>,
103}
104
105#[derive(Default)]
107pub struct InstallOptions<'a> {
108 pub force: bool,
110 pub dry_run: bool,
112 pub no_prefix: bool,
114 pub no_merge: bool,
116 pub ignore_config: IgnoreConfig,
118 pub on_file: FileCallback<'a>,
120 pub platform: Option<Platform>,
122 pub conflict_resolver: Option<&'a dyn ConflictResolver>,
124}
125
126impl std::fmt::Debug for InstallOptions<'_> {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("InstallOptions")
129 .field("force", &self.force)
130 .field("dry_run", &self.dry_run)
131 .field("no_prefix", &self.no_prefix)
132 .field("no_merge", &self.no_merge)
133 .field("ignore_config", &self.ignore_config)
134 .field("on_file", &self.on_file.is_some())
135 .field("platform", &self.platform)
136 .field("conflict_resolver", &self.conflict_resolver.is_some())
137 .finish()
138 }
139}
140
141impl<'a> InstallOptions<'a> {
142 pub fn new() -> Self {
144 Self {
145 ignore_config: IgnoreConfig::with_defaults(),
146 ..Default::default()
147 }
148 }
149
150 pub fn force(mut self, force: bool) -> Self {
152 self.force = force;
153 self
154 }
155
156 pub fn dry_run(mut self, dry_run: bool) -> Self {
158 self.dry_run = dry_run;
159 self
160 }
161
162 pub fn no_prefix(mut self, no_prefix: bool) -> Self {
164 self.no_prefix = no_prefix;
165 self
166 }
167
168 pub fn no_merge(mut self, no_merge: bool) -> Self {
170 self.no_merge = no_merge;
171 self
172 }
173
174 pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
176 self.ignore_config = config;
177 self
178 }
179
180 pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
182 self.on_file = callback;
183 self
184 }
185
186 pub fn platform(mut self, platform: Platform) -> Self {
188 self.platform = Some(platform);
189 self
190 }
191
192 pub fn conflict_resolver(mut self, resolver: &'a dyn ConflictResolver) -> Self {
194 self.conflict_resolver = Some(resolver);
195 self
196 }
197
198 pub fn should_include_path(&self, path: &Path) -> bool {
200 match self.platform {
201 Some(platform) => platform.supports_path(path),
202 None => true, }
204 }
205}
206
207pub struct Installer {
208 base_dir: PathBuf,
209}
210
211impl Installer {
212 pub fn new(base_dir: PathBuf) -> Self {
213 Self { base_dir }
214 }
215
216 pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
218 if global {
219 let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
220 Ok(home.join(".claude"))
221 } else {
222 let base = target
223 .map(|p| p.to_path_buf())
224 .unwrap_or_else(|| std::env::current_dir().unwrap());
225
226 if !base.exists() {
227 return Err(DotAgentError::TargetNotFound { path: base });
228 }
229
230 Ok(base.join(CLAUDE_DIR))
231 }
232 }
233
234 pub fn install(
236 &self,
237 profile: &Profile,
238 target: &Path,
239 opts: &InstallOptions<'_>,
240 ) -> Result<InstallResult> {
241 let mut result = InstallResult::default();
242 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
243
244 if !opts.dry_run && !target.exists() {
246 fs::create_dir_all(target)?;
247 }
248
249 let files = profile.list_files_with_config(&opts.ignore_config)?;
250
251 for relative_path in files {
252 if !opts.should_include_path(&relative_path) {
254 if let Some(f) = opts.on_file {
255 f(
256 "SKIP",
257 &format!("{} (unsupported)", relative_path.display()),
258 );
259 }
260 result.skipped += 1;
261 continue;
262 }
263
264 let src = profile.path.join(&relative_path);
265 let prefixed_path = if opts.no_prefix {
266 relative_path.clone()
267 } else {
268 prefix_path(&relative_path, &profile.name)
269 };
270 let dst = target.join(&prefixed_path);
271 let relative_str = prefixed_path.to_string_lossy().to_string();
272
273 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
274 let is_mergeable = is_mergeable_json(&relative_path);
275
276 if is_mergeable && !opts.no_merge && dst.exists() {
278 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
279
280 if !merge_result.changed {
281 if let Some(f) = opts.on_file {
282 f("SKIP", &relative_str);
283 }
284 result.skipped += 1;
285 continue;
286 }
287
288 if !opts.dry_run {
289 if let Some(parent) = dst.parent() {
290 fs::create_dir_all(parent)?;
291 }
292 fs::write(&dst, &merge_result.content)?;
293 metadata.add_merged(
294 &profile.name,
295 &relative_str,
296 merge_result.record.added_paths,
297 );
298 }
299
300 if let Some(f) = opts.on_file {
301 f("MERGE", &relative_str);
302 }
303 result.merged += 1;
304 continue;
305 }
306
307 let src_content = fs::read(&src)?;
308 let src_hash = compute_hash(&src_content);
309
310 if dst.exists() {
311 let dst_hash = compute_file_hash(&dst)?;
312
313 if src_hash == dst_hash {
314 if let Some(f) = opts.on_file {
316 f("SKIP", &relative_str);
317 }
318 result.skipped += 1;
319 continue;
320 }
321
322 if is_claude_md {
324 if let Some(f) = opts.on_file {
325 f("WARN", &relative_str);
326 }
327 result.skipped += 1;
328 continue;
329 }
330
331 if !opts.force {
333 if let Some(resolver) = opts.conflict_resolver {
334 let local_content = fs::read(&dst)?;
335 match resolver.resolve(&prefixed_path, &local_content, &src_content)? {
336 Resolution::KeepLocal => {
337 if let Some(f) = opts.on_file {
338 f("KEEP", &relative_str);
339 }
340 result.skipped += 1;
341 continue;
342 }
343 Resolution::OverwriteWithProfile => {
344 }
346 Resolution::Abort => {
347 return Err(DotAgentError::Aborted);
348 }
349 }
350 } else {
351 if let Some(f) = opts.on_file {
352 f("CONFLICT", &relative_str);
353 }
354 result.conflicts += 1;
355 continue;
356 }
357 }
358 }
359
360 if !opts.dry_run {
362 if let Some(parent) = dst.parent() {
363 fs::create_dir_all(parent)?;
364 }
365
366 if is_mergeable && !opts.no_merge {
368 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
369 fs::write(&dst, &merge_result.content)?;
370 metadata.add_merged(
371 &profile.name,
372 &relative_str,
373 merge_result.record.added_paths,
374 );
375 } else {
376 fs::write(&dst, &src_content)?;
377 let meta_key = make_meta_key(&profile.name, &relative_str);
378 metadata.add_file(&meta_key, &src_hash);
379 }
380 }
381
382 if let Some(f) = opts.on_file {
383 f("OK", &relative_str);
384 }
385 result.installed += 1;
386 }
387
388 if !opts.dry_run && result.conflicts == 0 {
389 metadata.add_profile(&profile.name);
390 metadata.save(target)?;
391 }
392
393 Ok(result)
394 }
395
396 pub fn diff(
398 &self,
399 profile: &Profile,
400 target: &Path,
401 ignore_config: &IgnoreConfig,
402 ) -> Result<DiffResult> {
403 let mut result = DiffResult::default();
404
405 if !target.exists() {
406 for relative_path in profile.list_files_with_config(ignore_config)? {
408 let prefixed_path = prefix_path(&relative_path, &profile.name);
409 result.files.push(FileInfo {
410 relative_path: prefixed_path,
411 status: FileStatus::Missing,
412 });
413 result.missing += 1;
414 }
415 return Ok(result);
416 }
417
418 let metadata = Metadata::load(target)?;
419 let profile_files = profile.list_files_with_config(ignore_config)?;
420
421 let prefixed_files: Vec<_> = profile_files
423 .iter()
424 .map(|p| prefix_path(p, &profile.name))
425 .collect();
426
427 for (idx, relative_path) in profile_files.iter().enumerate() {
429 let src = profile.path.join(relative_path);
430 let prefixed_path = &prefixed_files[idx];
431 let dst = target.join(prefixed_path);
432
433 if !dst.exists() {
434 result.files.push(FileInfo {
435 relative_path: prefixed_path.clone(),
436 status: FileStatus::Missing,
437 });
438 result.missing += 1;
439 continue;
440 }
441
442 let src_hash = compute_file_hash(&src)?;
443 let dst_hash = compute_file_hash(&dst)?;
444
445 if src_hash == dst_hash {
446 result.files.push(FileInfo {
447 relative_path: prefixed_path.clone(),
448 status: FileStatus::Unchanged,
449 });
450 result.unchanged += 1;
451 } else {
452 result.files.push(FileInfo {
453 relative_path: prefixed_path.clone(),
454 status: FileStatus::Modified,
455 });
456 result.modified += 1;
457 }
458 }
459
460 if let Some(meta) = &metadata {
462 for file_path in meta.files.keys() {
463 let path = PathBuf::from(file_path);
464 if !prefixed_files.contains(&path) {
465 let full_path = target.join(&path);
466 if full_path.exists() {
467 result.files.push(FileInfo {
468 relative_path: path,
469 status: FileStatus::Added,
470 });
471 result.added += 1;
472 }
473 }
474 }
475 }
476
477 result
478 .files
479 .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
480 Ok(result)
481 }
482
483 pub fn remove(
485 &self,
486 profile: &Profile,
487 target: &Path,
488 opts: &InstallOptions<'_>,
489 ) -> Result<(usize, usize, usize)> {
490 if !target.exists() {
491 return Ok((0, 0, 0));
492 }
493
494 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
495 let diff = self.diff(profile, target, &opts.ignore_config)?;
496
497 if !opts.force {
500 let modified: Vec<_> = diff
501 .files
502 .iter()
503 .filter(|f| {
504 f.status == FileStatus::Modified
505 && (opts.no_merge || !is_mergeable_json(&f.relative_path))
506 })
507 .map(|f| f.relative_path.clone())
508 .collect();
509
510 if !modified.is_empty() {
511 return Err(DotAgentError::LocalModifications { paths: modified });
512 }
513 }
514
515 let mut removed = 0;
516 let mut kept = 0;
517 let mut unmerged = 0;
518
519 if !opts.no_merge {
521 if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
522 for (file_path, _json_paths) in merged_files {
523 let dst = target.join(&file_path);
524 if !dst.exists() {
525 continue;
526 }
527
528 if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
529 if result.changed {
530 if !opts.dry_run {
531 fs::write(&dst, &result.content)?;
532 }
533 if let Some(f) = opts.on_file {
534 f("UNMERGE", &file_path);
535 }
536 unmerged += 1;
537 }
538 }
539 }
540 }
541 }
542
543 for file_info in &diff.files {
544 let dst = target.join(&file_info.relative_path);
545 let relative_str = file_info.relative_path.to_string_lossy().to_string();
546
547 if relative_str == CLAUDE_MD {
549 if let Some(f) = opts.on_file {
550 f("KEEP", &relative_str);
551 }
552 kept += 1;
553 continue;
554 }
555
556 if file_info.status == FileStatus::Added {
558 if let Some(f) = opts.on_file {
559 f("KEEP", &relative_str);
560 }
561 kept += 1;
562 continue;
563 }
564
565 if file_info.status == FileStatus::Missing {
567 continue;
568 }
569
570 if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
572 if metadata.get_merged(&profile.name, &relative_str).is_some() {
574 continue;
575 }
576 }
577
578 if !opts.dry_run && dst.exists() {
580 fs::remove_file(&dst)?;
581 let meta_key = make_meta_key(&profile.name, &relative_str);
582 metadata.remove_file(&meta_key);
583
584 if let Some(parent) = dst.parent() {
586 let _ = remove_empty_dirs(parent, target);
587 }
588 }
589
590 if let Some(f) = opts.on_file {
591 f("DEL", &relative_str);
592 }
593 removed += 1;
594 }
595
596 if !opts.dry_run {
597 metadata.remove_profile(&profile.name);
598 metadata.remove_merged(&profile.name);
599 if metadata.installed.profiles.is_empty()
600 && metadata.files.is_empty()
601 && metadata.merged.is_empty()
602 {
603 let meta_path = target.join(".dot-agent-meta.toml");
605 let _ = fs::remove_file(meta_path);
606 } else {
607 metadata.save(target)?;
608 }
609 }
610
611 Ok((removed, kept, unmerged))
612 }
613
614 pub fn upgrade(
616 &self,
617 profile: &Profile,
618 target: &Path,
619 opts: &InstallOptions<'_>,
620 ) -> Result<(usize, usize, usize, usize)> {
621 if !target.exists() {
623 let result = self.install(profile, target, opts)?;
625 return Ok((0, result.installed, 0, 0));
626 }
627
628 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
629 let mut updated = 0;
630 let mut new = 0;
631 let mut skipped = 0;
632 let mut unchanged = 0;
633
634 let files = profile.list_files_with_config(&opts.ignore_config)?;
635
636 for relative_path in files {
637 let src = profile.path.join(&relative_path);
638 let prefixed_path = if opts.no_prefix {
639 relative_path.clone()
640 } else {
641 prefix_path(&relative_path, &profile.name)
642 };
643 let dst = target.join(&prefixed_path);
644 let relative_str = prefixed_path.to_string_lossy().to_string();
645 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
646
647 let src_content = fs::read(&src)?;
648 let src_hash = compute_hash(&src_content);
649
650 let meta_key = make_meta_key(&profile.name, &relative_str);
651
652 if !dst.exists() {
653 if !opts.dry_run {
655 if let Some(parent) = dst.parent() {
656 fs::create_dir_all(parent)?;
657 }
658 fs::write(&dst, &src_content)?;
659 metadata.add_file(&meta_key, &src_hash);
660 }
661 if let Some(f) = opts.on_file {
662 f("NEW", &relative_str);
663 }
664 new += 1;
665 continue;
666 }
667
668 let dst_hash = compute_file_hash(&dst)?;
669
670 if src_hash == dst_hash {
671 if let Some(f) = opts.on_file {
672 f("OK", &relative_str);
673 }
674 unchanged += 1;
675 continue;
676 }
677
678 if is_claude_md {
680 if let Some(f) = opts.on_file {
681 f("WARN", &relative_str);
682 }
683 skipped += 1;
684 continue;
685 }
686
687 let original_hash = metadata.get_file_hash(&meta_key);
689 let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
690
691 if locally_modified && !opts.force {
692 if let Some(f) = opts.on_file {
693 f("SKIP", &relative_str);
694 }
695 skipped += 1;
696 continue;
697 }
698
699 if !opts.dry_run {
701 fs::write(&dst, &src_content)?;
702 metadata.add_file(&meta_key, &src_hash);
703 }
704 if let Some(f) = opts.on_file {
705 f("UPDATE", &relative_str);
706 }
707 updated += 1;
708 }
709
710 if !opts.dry_run {
711 metadata.add_profile(&profile.name);
712 metadata.save(target)?;
713 }
714
715 Ok((updated, new, skipped, unchanged))
716 }
717
718 pub fn sync_back(
723 &self,
724 profile: &Profile,
725 target: &Path,
726 ignore_config: &IgnoreConfig,
727 dry_run: bool,
728 on_file: FileCallback<'_>,
729 ) -> Result<SyncBackResult> {
730 let mut result = SyncBackResult::default();
731 let profile_files = profile.list_files_with_config(ignore_config)?;
732
733 for original_path in &profile_files {
734 let prefixed_path = prefix_path(original_path, &profile.name);
735 let src_profile = profile.path.join(original_path);
736 let dst_installed = target.join(&prefixed_path);
737
738 if !dst_installed.exists() {
739 continue;
740 }
741
742 let profile_hash = compute_file_hash(&src_profile)?;
743 let installed_hash = compute_file_hash(&dst_installed)?;
744
745 if profile_hash == installed_hash {
746 result.unchanged += 1;
747 continue;
748 }
749
750 if !dry_run {
752 if let Some(parent) = src_profile.parent() {
753 fs::create_dir_all(parent)?;
754 }
755 fs::copy(&dst_installed, &src_profile)?;
756 }
757
758 if let Some(f) = on_file {
759 f("SYNC", &original_path.to_string_lossy());
760 }
761 result.synced += 1;
762 result.files.push(original_path.clone());
763 }
764
765 Ok(result)
766 }
767}
768
769fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
770 if dir == root {
771 return Ok(());
772 }
773
774 if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
775 fs::remove_dir(dir)?;
776 if let Some(parent) = dir.parent() {
777 remove_empty_dirs(parent, root)?;
778 }
779 }
780
781 Ok(())
782}
783
784fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
792 let components: Vec<_> = relative_path.components().collect();
793
794 if components.is_empty() {
795 return relative_path.to_path_buf();
796 }
797
798 let first = components[0].as_os_str().to_string_lossy();
800
801 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
803 let filename = components[1].as_os_str().to_string_lossy();
804
805 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
807 return relative_path.to_path_buf();
808 }
809
810 let mut result = PathBuf::from(components[0].as_os_str());
812 result.push(format!("{}-{}", profile_name, filename));
813
814 for comp in &components[2..] {
816 result.push(comp.as_os_str());
817 }
818 return result;
819 }
820
821 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
823 let subdir = components[1].as_os_str().to_string_lossy();
824
825 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
827 return relative_path.to_path_buf();
828 }
829
830 let mut result = PathBuf::from(components[0].as_os_str());
832 result.push(format!("{}-{}", profile_name, subdir));
833
834 for comp in &components[2..] {
836 result.push(comp.as_os_str());
837 }
838 return result;
839 }
840
841 relative_path.to_path_buf()
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848 use crate::profile::Profile;
849 use std::fs;
850 use tempfile::TempDir;
851
852 struct MockResolver {
854 resolution: Resolution,
855 }
856
857 impl ConflictResolver for MockResolver {
858 fn resolve(&self, _: &Path, _: &[u8], _: &[u8]) -> Result<Resolution> {
859 Ok(self.resolution)
860 }
861 }
862
863 fn make_profile(name: &str, profile_dir: &Path) -> Profile {
865 Profile::new(name.to_string(), profile_dir.to_path_buf())
866 }
867
868 fn write_file(dir: &Path, filename: &str, content: &[u8]) {
870 let path = dir.join(filename);
871 if let Some(parent) = path.parent() {
872 fs::create_dir_all(parent).unwrap();
873 }
874 fs::write(path, content).unwrap();
875 }
876
877 fn make_installer(base: &Path) -> Installer {
879 Installer::new(base.to_path_buf())
880 }
881
882 #[test]
886 fn test_install_with_conflict_resolver_keep_local() {
887 let base = TempDir::new().unwrap();
888 let profile_dir = base.path().join("profile");
889 let target_dir = base.path().join("target");
890
891 write_file(&profile_dir, "rules/test-rule.md", b"profile content");
893
894 fs::create_dir_all(&target_dir).unwrap();
896 write_file(&target_dir, "rules/test-rule.md", b"local content");
897
898 let installer = make_installer(base.path());
899 let profile = make_profile("test", &profile_dir);
900 let resolver = MockResolver {
901 resolution: Resolution::KeepLocal,
902 };
903 let opts = InstallOptions::new()
904 .no_prefix(true)
905 .conflict_resolver(&resolver);
906
907 let result = installer.install(&profile, &target_dir, &opts).unwrap();
908
909 assert_eq!(result.skipped, 1);
911 assert_eq!(result.installed, 0);
912 let content = fs::read(target_dir.join("rules/test-rule.md")).unwrap();
913 assert_eq!(content, b"local content");
914 }
915
916 #[test]
920 fn test_install_with_conflict_resolver_overwrite() {
921 let base = TempDir::new().unwrap();
922 let profile_dir = base.path().join("profile");
923 let target_dir = base.path().join("target");
924
925 write_file(&profile_dir, "rules/test-rule.md", b"profile content");
926
927 fs::create_dir_all(&target_dir).unwrap();
928 write_file(&target_dir, "rules/test-rule.md", b"local content");
929
930 let installer = make_installer(base.path());
931 let profile = make_profile("test", &profile_dir);
932 let resolver = MockResolver {
933 resolution: Resolution::OverwriteWithProfile,
934 };
935 let opts = InstallOptions::new()
936 .no_prefix(true)
937 .conflict_resolver(&resolver);
938
939 let result = installer.install(&profile, &target_dir, &opts).unwrap();
940
941 assert_eq!(result.installed, 1);
943 assert_eq!(result.skipped, 0);
944 let content = fs::read(target_dir.join("rules/test-rule.md")).unwrap();
945 assert_eq!(content, b"profile content");
946 }
947
948 #[test]
952 fn test_install_with_conflict_resolver_abort() {
953 let base = TempDir::new().unwrap();
954 let profile_dir = base.path().join("profile");
955 let target_dir = base.path().join("target");
956
957 write_file(&profile_dir, "rules/test-rule.md", b"profile content");
958
959 fs::create_dir_all(&target_dir).unwrap();
960 write_file(&target_dir, "rules/test-rule.md", b"local content");
961
962 let installer = make_installer(base.path());
963 let profile = make_profile("test", &profile_dir);
964 let resolver = MockResolver {
965 resolution: Resolution::Abort,
966 };
967 let opts = InstallOptions::new()
968 .no_prefix(true)
969 .conflict_resolver(&resolver);
970
971 let err = installer.install(&profile, &target_dir, &opts).unwrap_err();
972
973 assert!(
974 matches!(err, DotAgentError::Aborted),
975 "expected DotAgentError::Aborted, got: {err:?}"
976 );
977 }
978
979 #[test]
983 fn test_install_without_resolver_preserves_existing_behavior() {
984 let base = TempDir::new().unwrap();
985 let profile_dir = base.path().join("profile");
986 let target_dir = base.path().join("target");
987
988 write_file(&profile_dir, "rules/test-rule.md", b"profile content");
989
990 fs::create_dir_all(&target_dir).unwrap();
991 write_file(&target_dir, "rules/test-rule.md", b"local content");
992
993 let installer = make_installer(base.path());
994 let profile = make_profile("test", &profile_dir);
995 let opts = InstallOptions::new().no_prefix(true);
997
998 let result = installer.install(&profile, &target_dir, &opts).unwrap();
999
1000 assert_eq!(result.conflicts, 1);
1002 assert_eq!(result.installed, 0);
1003 let content = fs::read(target_dir.join("rules/test-rule.md")).unwrap();
1005 assert_eq!(content, b"local content");
1006 }
1007
1008 #[test]
1012 fn test_sync_back_modified_files() {
1013 let base = TempDir::new().unwrap();
1014 let profile_dir = base.path().join("profile");
1015 let target_dir = base.path().join("target");
1016
1017 write_file(&profile_dir, "rules/my-rule.md", b"original");
1018
1019 let installer = make_installer(base.path());
1020 let profile = make_profile("prof", &profile_dir);
1021 let opts = InstallOptions::new();
1022 installer.install(&profile, &target_dir, &opts).unwrap();
1023
1024 write_file(&target_dir, "rules/prof-my-rule.md", b"user edited");
1026
1027 let ignore_config = IgnoreConfig::with_defaults();
1028 let result = installer
1029 .sync_back(&profile, &target_dir, &ignore_config, false, None)
1030 .unwrap();
1031
1032 assert_eq!(result.synced, 1);
1033 let content = fs::read(profile_dir.join("rules/my-rule.md")).unwrap();
1034 assert_eq!(content, b"user edited");
1035 }
1036
1037 #[test]
1038 fn test_sync_back_unchanged_files() {
1039 let base = TempDir::new().unwrap();
1040 let profile_dir = base.path().join("profile");
1041 let target_dir = base.path().join("target");
1042
1043 write_file(&profile_dir, "rules/my-rule.md", b"same content");
1044
1045 let installer = make_installer(base.path());
1046 let profile = make_profile("prof", &profile_dir);
1047 let opts = InstallOptions::new();
1048 installer.install(&profile, &target_dir, &opts).unwrap();
1049
1050 let ignore_config = IgnoreConfig::with_defaults();
1051 let result = installer
1052 .sync_back(&profile, &target_dir, &ignore_config, false, None)
1053 .unwrap();
1054
1055 assert_eq!(result.synced, 0);
1056 }
1057
1058 #[test]
1059 fn test_sync_back_dry_run() {
1060 let base = TempDir::new().unwrap();
1061 let profile_dir = base.path().join("profile");
1062 let target_dir = base.path().join("target");
1063
1064 write_file(&profile_dir, "rules/my-rule.md", b"original");
1065
1066 let installer = make_installer(base.path());
1067 let profile = make_profile("prof", &profile_dir);
1068 let opts = InstallOptions::new();
1069 installer.install(&profile, &target_dir, &opts).unwrap();
1070
1071 write_file(&target_dir, "rules/prof-my-rule.md", b"user edited");
1072
1073 let ignore_config = IgnoreConfig::with_defaults();
1074 let result = installer
1075 .sync_back(&profile, &target_dir, &ignore_config, true, None)
1076 .unwrap();
1077
1078 assert_eq!(result.synced, 1);
1079 let content = fs::read(profile_dir.join("rules/my-rule.md")).unwrap();
1081 assert_eq!(content, b"original");
1082 }
1083}