Skip to main content

dot_agent_core/install/
mod.rs

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
12// Internal imports
13use metadata::{compute_file_hash, compute_hash};
14
15// Re-exports
16pub 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
28/// Callback type for file operation progress reporting
29pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
30
31/// Resolution for a file conflict during install/switch
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Resolution {
34    /// Keep the local version, skip profile file
35    KeepLocal,
36    /// Overwrite with profile version
37    OverwriteWithProfile,
38    /// Abort the entire operation
39    Abort,
40}
41
42/// Strategy for resolving file conflicts during install/switch operations
43pub trait ConflictResolver {
44    /// Called when a local file differs from the profile version.
45    /// `relative_path`: path relative to target dir (e.g., "rules/my-rule.md")
46    /// `local_content`: current content on disk
47    /// `profile_content`: content from the profile being installed
48    fn resolve(
49        &self,
50        relative_path: &Path,
51        local_content: &[u8],
52        profile_content: &[u8],
53    ) -> crate::error::Result<Resolution>;
54}
55
56// Directories where files should be prefixed with profile name
57const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
58// Directories where subdirectories should be prefixed (skills has SKILL.md inside)
59const PREFIXED_SUBDIRS: &[&str] = &["skills"];
60
61/// Generate metadata key with profile prefix.
62/// Format: "{profile}:{relative_path}"
63fn 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/// Options for install/upgrade/remove operations
106#[derive(Default)]
107pub struct InstallOptions<'a> {
108    /// Force overwrite of existing files
109    pub force: bool,
110    /// Don't actually make changes, just show what would happen
111    pub dry_run: bool,
112    /// Don't add profile prefix to file names (install/upgrade only)
113    pub no_prefix: bool,
114    /// Don't merge JSON files (hooks.json, settings.json, etc.)
115    pub no_merge: bool,
116    /// File ignore configuration
117    pub ignore_config: IgnoreConfig,
118    /// Callback for file operation progress
119    pub on_file: FileCallback<'a>,
120    /// Target platform (for filtering unsupported files)
121    pub platform: Option<Platform>,
122    /// Strategy for resolving file conflicts (None = skip with CONFLICT report)
123    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    /// Create new options with defaults
143    pub fn new() -> Self {
144        Self {
145            ignore_config: IgnoreConfig::with_defaults(),
146            ..Default::default()
147        }
148    }
149
150    /// Set force flag
151    pub fn force(mut self, force: bool) -> Self {
152        self.force = force;
153        self
154    }
155
156    /// Set dry_run flag
157    pub fn dry_run(mut self, dry_run: bool) -> Self {
158        self.dry_run = dry_run;
159        self
160    }
161
162    /// Set no_prefix flag
163    pub fn no_prefix(mut self, no_prefix: bool) -> Self {
164        self.no_prefix = no_prefix;
165        self
166    }
167
168    /// Set no_merge flag
169    pub fn no_merge(mut self, no_merge: bool) -> Self {
170        self.no_merge = no_merge;
171        self
172    }
173
174    /// Set ignore configuration
175    pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
176        self.ignore_config = config;
177        self
178    }
179
180    /// Set file callback
181    pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
182        self.on_file = callback;
183        self
184    }
185
186    /// Set target platform for filtering
187    pub fn platform(mut self, platform: Platform) -> Self {
188        self.platform = Some(platform);
189        self
190    }
191
192    /// Set conflict resolver strategy
193    pub fn conflict_resolver(mut self, resolver: &'a dyn ConflictResolver) -> Self {
194        self.conflict_resolver = Some(resolver);
195        self
196    }
197
198    /// Check if a path should be included for the target platform
199    pub fn should_include_path(&self, path: &Path) -> bool {
200        match self.platform {
201            Some(platform) => platform.supports_path(path),
202            None => true, // No platform filter, include everything
203        }
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    /// Get target directory (either project/.claude or global ~/.claude)
217    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    /// Install a profile to target
235    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        // Ensure target directory exists
245        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            // Platform filtering: skip files not supported by target platform
253            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            // Handle mergeable JSON files
277            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                    // Same content - skip
315                    if let Some(f) = opts.on_file {
316                        f("SKIP", &relative_str);
317                    }
318                    result.skipped += 1;
319                    continue;
320                }
321
322                // CLAUDE.md is never overwritten
323                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                // Different content - for JSON files with no_merge, this is a conflict
332                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                                // Fall through to write logic below
345                            }
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            // Copy file (or write merged JSON for new files)
361            if !opts.dry_run {
362                if let Some(parent) = dst.parent() {
363                    fs::create_dir_all(parent)?;
364                }
365
366                // For new mergeable JSON files, add profile marker
367                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    /// Compare profile with installed files
397    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            // All files are missing
407            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        // Build set of prefixed paths for comparison
422        let prefixed_files: Vec<_> = profile_files
423            .iter()
424            .map(|p| prefix_path(p, &profile.name))
425            .collect();
426
427        // Check profile files against target
428        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        // Check for files in metadata that aren't in profile (user added)
461        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    /// Remove installed profile files
484    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        // Check for local modifications
498        // Skip mergeable JSON files - they are expected to differ due to profile markers
499        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        // First, handle unmerging from JSON files
520        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            // Never remove CLAUDE.md
548            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            // Skip user-added files
557            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            // Skip missing files
566            if file_info.status == FileStatus::Missing {
567                continue;
568            }
569
570            // Skip merged JSON files (already handled above)
571            if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
572                // Only delete if we own this file entirely (not merged)
573                if metadata.get_merged(&profile.name, &relative_str).is_some() {
574                    continue;
575                }
576            }
577
578            // Remove file
579            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                // Remove empty parent directories
585                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                // Remove metadata file if no profiles left
604                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    /// Upgrade profile files
615    pub fn upgrade(
616        &self,
617        profile: &Profile,
618        target: &Path,
619        opts: &InstallOptions<'_>,
620    ) -> Result<(usize, usize, usize, usize)> {
621        // updated, new, skipped, unchanged
622        if !target.exists() {
623            // Just install everything
624            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                // New file
654                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            // CLAUDE.md is never overwritten
679            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            // Check if file was modified locally
688            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            // Update file
700            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    /// Sync modified installed files back to the profile directory.
719    ///
720    /// Detects files that were modified locally (compared to the profile),
721    /// and copies them back into the profile, removing the profile-name prefix.
722    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            // File was modified locally — copy back to profile
751            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
784/// Transform relative path to add profile prefix where needed
785/// Examples:
786///   agents/code-reviewer.md → agents/{profile}-code-reviewer.md
787///   skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
788///   rules/testing.md → rules/{profile}-testing.md
789///   commands/profile:cmd.md → commands/profile:cmd.md (already prefixed)
790///   CLAUDE.md → CLAUDE.md (no change)
791fn 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    // Get first component (top-level directory)
799    let first = components[0].as_os_str().to_string_lossy();
800
801    // Check if this is a directory where we prefix files directly
802    if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
803        let filename = components[1].as_os_str().to_string_lossy();
804
805        // Skip if already prefixed (contains ':' or starts with profile name)
806        if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
807            return relative_path.to_path_buf();
808        }
809
810        // agents/code-reviewer.md → agents/{profile}-code-reviewer.md
811        let mut result = PathBuf::from(components[0].as_os_str());
812        result.push(format!("{}-{}", profile_name, filename));
813
814        // Add remaining components if any
815        for comp in &components[2..] {
816            result.push(comp.as_os_str());
817        }
818        return result;
819    }
820
821    // Check if this is a directory where we prefix subdirectories
822    if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
823        let subdir = components[1].as_os_str().to_string_lossy();
824
825        // Skip if already prefixed
826        if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
827            return relative_path.to_path_buf();
828        }
829
830        // skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
831        let mut result = PathBuf::from(components[0].as_os_str());
832        result.push(format!("{}-{}", profile_name, subdir));
833
834        // Add remaining components
835        for comp in &components[2..] {
836            result.push(comp.as_os_str());
837        }
838        return result;
839    }
840
841    // No transformation needed
842    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    /// A mock ConflictResolver that always returns a fixed Resolution.
853    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    /// Build a minimal Profile pointing at `profile_dir` with `name`.
864    fn make_profile(name: &str, profile_dir: &Path) -> Profile {
865        Profile::new(name.to_string(), profile_dir.to_path_buf())
866    }
867
868    /// Write `content` to `dir/filename`, creating parent dirs as needed.
869    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    /// Create an Installer whose base_dir is `base`.
878    fn make_installer(base: &Path) -> Installer {
879        Installer::new(base.to_path_buf())
880    }
881
882    // -----------------------------------------------------------------------
883    // Test: ConflictResolver::KeepLocal — existing file must be unchanged
884    // -----------------------------------------------------------------------
885    #[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        // Profile has "rules/test-rule.md" with profile content
892        write_file(&profile_dir, "rules/test-rule.md", b"profile content");
893
894        // Target already has the file with different (local) content
895        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        // File was kept (skipped), not overwritten
910        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    // -----------------------------------------------------------------------
917    // Test: ConflictResolver::OverwriteWithProfile — file must be replaced
918    // -----------------------------------------------------------------------
919    #[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        // File was overwritten with profile content
942        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    // -----------------------------------------------------------------------
949    // Test: ConflictResolver::Abort — operation must return DotAgentError::Aborted
950    // -----------------------------------------------------------------------
951    #[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    // -----------------------------------------------------------------------
980    // Test: No resolver — conflict is reported and file is skipped (existing behaviour)
981    // -----------------------------------------------------------------------
982    #[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        // No conflict_resolver set
996        let opts = InstallOptions::new().no_prefix(true);
997
998        let result = installer.install(&profile, &target_dir, &opts).unwrap();
999
1000        // Existing behaviour: conflicting file is counted as conflict and skipped
1001        assert_eq!(result.conflicts, 1);
1002        assert_eq!(result.installed, 0);
1003        // Local file must remain unchanged
1004        let content = fs::read(target_dir.join("rules/test-rule.md")).unwrap();
1005        assert_eq!(content, b"local content");
1006    }
1007
1008    // -----------------------------------------------------------------------
1009    // Tests: sync_back
1010    // -----------------------------------------------------------------------
1011    #[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        // User edits the installed (prefixed) file
1025        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        // Profile file should remain original (dry run)
1080        let content = fs::read(profile_dir.join("rules/my-rule.md")).unwrap();
1081        assert_eq!(content, b"original");
1082    }
1083}