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// Directories where files should be prefixed with profile name
32const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
33// Directories where subdirectories should be prefixed (skills has SKILL.md inside)
34const PREFIXED_SUBDIRS: &[&str] = &["skills"];
35
36/// Generate metadata key with profile prefix.
37/// Format: "{profile}:{relative_path}"
38fn 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/// Options for install/upgrade/remove operations
74#[derive(Default)]
75pub struct InstallOptions<'a> {
76    /// Force overwrite of existing files
77    pub force: bool,
78    /// Don't actually make changes, just show what would happen
79    pub dry_run: bool,
80    /// Don't add profile prefix to file names (install/upgrade only)
81    pub no_prefix: bool,
82    /// Don't merge JSON files (hooks.json, settings.json, etc.)
83    pub no_merge: bool,
84    /// File ignore configuration
85    pub ignore_config: IgnoreConfig,
86    /// Callback for file operation progress
87    pub on_file: FileCallback<'a>,
88    /// Target platform (for filtering unsupported files)
89    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    /// Create new options with defaults
108    pub fn new() -> Self {
109        Self {
110            ignore_config: IgnoreConfig::with_defaults(),
111            ..Default::default()
112        }
113    }
114
115    /// Set force flag
116    pub fn force(mut self, force: bool) -> Self {
117        self.force = force;
118        self
119    }
120
121    /// Set dry_run flag
122    pub fn dry_run(mut self, dry_run: bool) -> Self {
123        self.dry_run = dry_run;
124        self
125    }
126
127    /// Set no_prefix flag
128    pub fn no_prefix(mut self, no_prefix: bool) -> Self {
129        self.no_prefix = no_prefix;
130        self
131    }
132
133    /// Set no_merge flag
134    pub fn no_merge(mut self, no_merge: bool) -> Self {
135        self.no_merge = no_merge;
136        self
137    }
138
139    /// Set ignore configuration
140    pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
141        self.ignore_config = config;
142        self
143    }
144
145    /// Set file callback
146    pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
147        self.on_file = callback;
148        self
149    }
150
151    /// Set target platform for filtering
152    pub fn platform(mut self, platform: Platform) -> Self {
153        self.platform = Some(platform);
154        self
155    }
156
157    /// Check if a path should be included for the target platform
158    pub fn should_include_path(&self, path: &Path) -> bool {
159        match self.platform {
160            Some(platform) => platform.supports_path(path),
161            None => true, // No platform filter, include everything
162        }
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    /// Get target directory (either project/.claude or global ~/.claude)
176    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    /// Install a profile to target
194    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        // Ensure target directory exists
204        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            // Platform filtering: skip files not supported by target platform
212            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            // Handle mergeable JSON files
236            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                    // Same content - skip
274                    if let Some(f) = opts.on_file {
275                        f("SKIP", &relative_str);
276                    }
277                    result.skipped += 1;
278                    continue;
279                }
280
281                // CLAUDE.md is never overwritten
282                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                // Different content - for JSON files with no_merge, this is a conflict
291                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            // Copy file (or write merged JSON for new files)
301            if !opts.dry_run {
302                if let Some(parent) = dst.parent() {
303                    fs::create_dir_all(parent)?;
304                }
305
306                // For new mergeable JSON files, add profile marker
307                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    /// Compare profile with installed files
337    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            // All files are missing
347            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        // Build set of prefixed paths for comparison
362        let prefixed_files: Vec<_> = profile_files
363            .iter()
364            .map(|p| prefix_path(p, &profile.name))
365            .collect();
366
367        // Check profile files against target
368        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        // Check for files in metadata that aren't in profile (user added)
401        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    /// Remove installed profile files
424    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        // Check for local modifications
438        // Skip mergeable JSON files - they are expected to differ due to profile markers
439        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        // First, handle unmerging from JSON files
460        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            // Never remove CLAUDE.md
488            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            // Skip user-added files
497            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            // Skip missing files
506            if file_info.status == FileStatus::Missing {
507                continue;
508            }
509
510            // Skip merged JSON files (already handled above)
511            if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
512                // Only delete if we own this file entirely (not merged)
513                if metadata.get_merged(&profile.name, &relative_str).is_some() {
514                    continue;
515                }
516            }
517
518            // Remove file
519            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                // Remove empty parent directories
525                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                // Remove metadata file if no profiles left
544                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    /// Upgrade profile files
555    pub fn upgrade(
556        &self,
557        profile: &Profile,
558        target: &Path,
559        opts: &InstallOptions<'_>,
560    ) -> Result<(usize, usize, usize, usize)> {
561        // updated, new, skipped, unchanged
562        if !target.exists() {
563            // Just install everything
564            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                // New file
594                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            // CLAUDE.md is never overwritten
619            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            // Check if file was modified locally
628            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            // Update file
640            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
674/// Transform relative path to add profile prefix where needed
675/// Examples:
676///   agents/code-reviewer.md → agents/{profile}-code-reviewer.md
677///   skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
678///   rules/testing.md → rules/{profile}-testing.md
679///   commands/profile:cmd.md → commands/profile:cmd.md (already prefixed)
680///   CLAUDE.md → CLAUDE.md (no change)
681fn 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    // Get first component (top-level directory)
689    let first = components[0].as_os_str().to_string_lossy();
690
691    // Check if this is a directory where we prefix files directly
692    if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
693        let filename = components[1].as_os_str().to_string_lossy();
694
695        // Skip if already prefixed (contains ':' or starts with profile name)
696        if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
697            return relative_path.to_path_buf();
698        }
699
700        // agents/code-reviewer.md → agents/{profile}-code-reviewer.md
701        let mut result = PathBuf::from(components[0].as_os_str());
702        result.push(format!("{}-{}", profile_name, filename));
703
704        // Add remaining components if any
705        for comp in &components[2..] {
706            result.push(comp.as_os_str());
707        }
708        return result;
709    }
710
711    // Check if this is a directory where we prefix subdirectories
712    if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
713        let subdir = components[1].as_os_str().to_string_lossy();
714
715        // Skip if already prefixed
716        if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
717            return relative_path.to_path_buf();
718        }
719
720        // skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
721        let mut result = PathBuf::from(components[0].as_os_str());
722        result.push(format!("{}-{}", profile_name, subdir));
723
724        // Add remaining components
725        for comp in &components[2..] {
726            result.push(comp.as_os_str());
727        }
728        return result;
729    }
730
731    // No transformation needed
732    relative_path.to_path_buf()
733}