Skip to main content

dot_agent_core/
installer.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::error::{DotAgentError, Result};
5use crate::json_merge::{is_mergeable_json, merge_json_file, unmerge_json_file};
6use crate::metadata::{compute_file_hash, compute_hash, Metadata};
7use crate::profile::{IgnoreConfig, Profile};
8
9const CLAUDE_MD: &str = "CLAUDE.md";
10const CLAUDE_DIR: &str = ".claude";
11
12/// Callback type for file operation progress reporting
13pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
14
15// Directories where files should be prefixed with profile name
16const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
17// Directories where subdirectories should be prefixed (skills has SKILL.md inside)
18const PREFIXED_SUBDIRS: &[&str] = &["skills"];
19
20/// Generate metadata key with profile prefix.
21/// Format: "{profile}:{relative_path}"
22fn make_meta_key(profile_name: &str, relative_path: &str) -> String {
23    format!("{}:{}", profile_name, relative_path)
24}
25
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum FileStatus {
28    Unchanged,
29    Modified,
30    Added,
31    Missing,
32}
33
34#[derive(Debug)]
35pub struct FileInfo {
36    pub relative_path: PathBuf,
37    pub status: FileStatus,
38}
39
40#[derive(Debug, Default)]
41pub struct InstallResult {
42    pub installed: usize,
43    pub skipped: usize,
44    pub conflicts: usize,
45    pub merged: usize,
46}
47
48#[derive(Debug, Default)]
49pub struct DiffResult {
50    pub unchanged: usize,
51    pub modified: usize,
52    pub added: usize,
53    pub missing: usize,
54    pub files: Vec<FileInfo>,
55}
56
57/// Options for install/upgrade/remove operations
58#[derive(Default)]
59pub struct InstallOptions<'a> {
60    /// Force overwrite of existing files
61    pub force: bool,
62    /// Don't actually make changes, just show what would happen
63    pub dry_run: bool,
64    /// Don't add profile prefix to file names (install/upgrade only)
65    pub no_prefix: bool,
66    /// Don't merge JSON files (hooks.json, settings.json, etc.)
67    pub no_merge: bool,
68    /// File ignore configuration
69    pub ignore_config: IgnoreConfig,
70    /// Callback for file operation progress
71    pub on_file: FileCallback<'a>,
72}
73
74impl std::fmt::Debug for InstallOptions<'_> {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.debug_struct("InstallOptions")
77            .field("force", &self.force)
78            .field("dry_run", &self.dry_run)
79            .field("no_prefix", &self.no_prefix)
80            .field("no_merge", &self.no_merge)
81            .field("ignore_config", &self.ignore_config)
82            .field("on_file", &self.on_file.is_some())
83            .finish()
84    }
85}
86
87impl<'a> InstallOptions<'a> {
88    /// Create new options with defaults
89    pub fn new() -> Self {
90        Self {
91            ignore_config: IgnoreConfig::with_defaults(),
92            ..Default::default()
93        }
94    }
95
96    /// Set force flag
97    pub fn force(mut self, force: bool) -> Self {
98        self.force = force;
99        self
100    }
101
102    /// Set dry_run flag
103    pub fn dry_run(mut self, dry_run: bool) -> Self {
104        self.dry_run = dry_run;
105        self
106    }
107
108    /// Set no_prefix flag
109    pub fn no_prefix(mut self, no_prefix: bool) -> Self {
110        self.no_prefix = no_prefix;
111        self
112    }
113
114    /// Set no_merge flag
115    pub fn no_merge(mut self, no_merge: bool) -> Self {
116        self.no_merge = no_merge;
117        self
118    }
119
120    /// Set ignore configuration
121    pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
122        self.ignore_config = config;
123        self
124    }
125
126    /// Set file callback
127    pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
128        self.on_file = callback;
129        self
130    }
131}
132
133pub struct Installer {
134    base_dir: PathBuf,
135}
136
137impl Installer {
138    pub fn new(base_dir: PathBuf) -> Self {
139        Self { base_dir }
140    }
141
142    /// Get target directory (either project/.claude or global ~/.claude)
143    pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
144        if global {
145            let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
146            Ok(home.join(".claude"))
147        } else {
148            let base = target
149                .map(|p| p.to_path_buf())
150                .unwrap_or_else(|| std::env::current_dir().unwrap());
151
152            if !base.exists() {
153                return Err(DotAgentError::TargetNotFound { path: base });
154            }
155
156            Ok(base.join(CLAUDE_DIR))
157        }
158    }
159
160    /// Install a profile to target
161    pub fn install(
162        &self,
163        profile: &Profile,
164        target: &Path,
165        opts: &InstallOptions<'_>,
166    ) -> Result<InstallResult> {
167        let mut result = InstallResult::default();
168        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
169
170        // Ensure target directory exists
171        if !opts.dry_run && !target.exists() {
172            fs::create_dir_all(target)?;
173        }
174
175        let files = profile.list_files_with_config(&opts.ignore_config)?;
176
177        for relative_path in files {
178            let src = profile.path.join(&relative_path);
179            let prefixed_path = if opts.no_prefix {
180                relative_path.clone()
181            } else {
182                prefix_path(&relative_path, &profile.name)
183            };
184            let dst = target.join(&prefixed_path);
185            let relative_str = prefixed_path.to_string_lossy().to_string();
186
187            let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
188            let is_mergeable = is_mergeable_json(&relative_path);
189
190            // Handle mergeable JSON files
191            if is_mergeable && !opts.no_merge && dst.exists() {
192                let merge_result = merge_json_file(&dst, &src, &profile.name)?;
193
194                if !merge_result.changed {
195                    if let Some(f) = opts.on_file {
196                        f("SKIP", &relative_str);
197                    }
198                    result.skipped += 1;
199                    continue;
200                }
201
202                if !opts.dry_run {
203                    if let Some(parent) = dst.parent() {
204                        fs::create_dir_all(parent)?;
205                    }
206                    fs::write(&dst, &merge_result.content)?;
207                    metadata.add_merged(
208                        &profile.name,
209                        &relative_str,
210                        merge_result.record.added_paths,
211                    );
212                }
213
214                if let Some(f) = opts.on_file {
215                    f("MERGE", &relative_str);
216                }
217                result.merged += 1;
218                continue;
219            }
220
221            let src_content = fs::read(&src)?;
222            let src_hash = compute_hash(&src_content);
223
224            if dst.exists() {
225                let dst_hash = compute_file_hash(&dst)?;
226
227                if src_hash == dst_hash {
228                    // Same content - skip
229                    if let Some(f) = opts.on_file {
230                        f("SKIP", &relative_str);
231                    }
232                    result.skipped += 1;
233                    continue;
234                }
235
236                // CLAUDE.md is never overwritten
237                if is_claude_md {
238                    if let Some(f) = opts.on_file {
239                        f("WARN", &relative_str);
240                    }
241                    result.skipped += 1;
242                    continue;
243                }
244
245                // Different content - for JSON files with no_merge, this is a conflict
246                if !opts.force {
247                    if let Some(f) = opts.on_file {
248                        f("CONFLICT", &relative_str);
249                    }
250                    result.conflicts += 1;
251                    continue;
252                }
253            }
254
255            // Copy file (or write merged JSON for new files)
256            if !opts.dry_run {
257                if let Some(parent) = dst.parent() {
258                    fs::create_dir_all(parent)?;
259                }
260
261                // For new mergeable JSON files, add profile marker
262                if is_mergeable && !opts.no_merge {
263                    let merge_result = merge_json_file(&dst, &src, &profile.name)?;
264                    fs::write(&dst, &merge_result.content)?;
265                    metadata.add_merged(
266                        &profile.name,
267                        &relative_str,
268                        merge_result.record.added_paths,
269                    );
270                } else {
271                    fs::write(&dst, &src_content)?;
272                    let meta_key = make_meta_key(&profile.name, &relative_str);
273                    metadata.add_file(&meta_key, &src_hash);
274                }
275            }
276
277            if let Some(f) = opts.on_file {
278                f("OK", &relative_str);
279            }
280            result.installed += 1;
281        }
282
283        if !opts.dry_run && result.conflicts == 0 {
284            metadata.add_profile(&profile.name);
285            metadata.save(target)?;
286        }
287
288        Ok(result)
289    }
290
291    /// Compare profile with installed files
292    pub fn diff(
293        &self,
294        profile: &Profile,
295        target: &Path,
296        ignore_config: &IgnoreConfig,
297    ) -> Result<DiffResult> {
298        let mut result = DiffResult::default();
299
300        if !target.exists() {
301            // All files are missing
302            for relative_path in profile.list_files_with_config(ignore_config)? {
303                let prefixed_path = prefix_path(&relative_path, &profile.name);
304                result.files.push(FileInfo {
305                    relative_path: prefixed_path,
306                    status: FileStatus::Missing,
307                });
308                result.missing += 1;
309            }
310            return Ok(result);
311        }
312
313        let metadata = Metadata::load(target)?;
314        let profile_files = profile.list_files_with_config(ignore_config)?;
315
316        // Build set of prefixed paths for comparison
317        let prefixed_files: Vec<_> = profile_files
318            .iter()
319            .map(|p| prefix_path(p, &profile.name))
320            .collect();
321
322        // Check profile files against target
323        for (idx, relative_path) in profile_files.iter().enumerate() {
324            let src = profile.path.join(relative_path);
325            let prefixed_path = &prefixed_files[idx];
326            let dst = target.join(prefixed_path);
327
328            if !dst.exists() {
329                result.files.push(FileInfo {
330                    relative_path: prefixed_path.clone(),
331                    status: FileStatus::Missing,
332                });
333                result.missing += 1;
334                continue;
335            }
336
337            let src_hash = compute_file_hash(&src)?;
338            let dst_hash = compute_file_hash(&dst)?;
339
340            if src_hash == dst_hash {
341                result.files.push(FileInfo {
342                    relative_path: prefixed_path.clone(),
343                    status: FileStatus::Unchanged,
344                });
345                result.unchanged += 1;
346            } else {
347                result.files.push(FileInfo {
348                    relative_path: prefixed_path.clone(),
349                    status: FileStatus::Modified,
350                });
351                result.modified += 1;
352            }
353        }
354
355        // Check for files in metadata that aren't in profile (user added)
356        if let Some(meta) = &metadata {
357            for file_path in meta.files.keys() {
358                let path = PathBuf::from(file_path);
359                if !prefixed_files.contains(&path) {
360                    let full_path = target.join(&path);
361                    if full_path.exists() {
362                        result.files.push(FileInfo {
363                            relative_path: path,
364                            status: FileStatus::Added,
365                        });
366                        result.added += 1;
367                    }
368                }
369            }
370        }
371
372        result
373            .files
374            .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
375        Ok(result)
376    }
377
378    /// Remove installed profile files
379    pub fn remove(
380        &self,
381        profile: &Profile,
382        target: &Path,
383        opts: &InstallOptions<'_>,
384    ) -> Result<(usize, usize, usize)> {
385        if !target.exists() {
386            return Ok((0, 0, 0));
387        }
388
389        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
390        let diff = self.diff(profile, target, &opts.ignore_config)?;
391
392        // Check for local modifications
393        // Skip mergeable JSON files - they are expected to differ due to profile markers
394        if !opts.force {
395            let modified: Vec<_> = diff
396                .files
397                .iter()
398                .filter(|f| {
399                    f.status == FileStatus::Modified
400                        && (opts.no_merge || !is_mergeable_json(&f.relative_path))
401                })
402                .map(|f| f.relative_path.clone())
403                .collect();
404
405            if !modified.is_empty() {
406                return Err(DotAgentError::LocalModifications { paths: modified });
407            }
408        }
409
410        let mut removed = 0;
411        let mut kept = 0;
412        let mut unmerged = 0;
413
414        // First, handle unmerging from JSON files
415        if !opts.no_merge {
416            if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
417                for (file_path, _json_paths) in merged_files {
418                    let dst = target.join(&file_path);
419                    if !dst.exists() {
420                        continue;
421                    }
422
423                    if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
424                        if result.changed {
425                            if !opts.dry_run {
426                                fs::write(&dst, &result.content)?;
427                            }
428                            if let Some(f) = opts.on_file {
429                                f("UNMERGE", &file_path);
430                            }
431                            unmerged += 1;
432                        }
433                    }
434                }
435            }
436        }
437
438        for file_info in &diff.files {
439            let dst = target.join(&file_info.relative_path);
440            let relative_str = file_info.relative_path.to_string_lossy().to_string();
441
442            // Never remove CLAUDE.md
443            if relative_str == CLAUDE_MD {
444                if let Some(f) = opts.on_file {
445                    f("KEEP", &relative_str);
446                }
447                kept += 1;
448                continue;
449            }
450
451            // Skip user-added files
452            if file_info.status == FileStatus::Added {
453                if let Some(f) = opts.on_file {
454                    f("KEEP", &relative_str);
455                }
456                kept += 1;
457                continue;
458            }
459
460            // Skip missing files
461            if file_info.status == FileStatus::Missing {
462                continue;
463            }
464
465            // Skip merged JSON files (already handled above)
466            if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
467                // Only delete if we own this file entirely (not merged)
468                if metadata.get_merged(&profile.name, &relative_str).is_some() {
469                    continue;
470                }
471            }
472
473            // Remove file
474            if !opts.dry_run && dst.exists() {
475                fs::remove_file(&dst)?;
476                let meta_key = make_meta_key(&profile.name, &relative_str);
477                metadata.remove_file(&meta_key);
478
479                // Remove empty parent directories
480                if let Some(parent) = dst.parent() {
481                    let _ = remove_empty_dirs(parent, target);
482                }
483            }
484
485            if let Some(f) = opts.on_file {
486                f("DEL", &relative_str);
487            }
488            removed += 1;
489        }
490
491        if !opts.dry_run {
492            metadata.remove_profile(&profile.name);
493            metadata.remove_merged(&profile.name);
494            if metadata.installed.profiles.is_empty()
495                && metadata.files.is_empty()
496                && metadata.merged.is_empty()
497            {
498                // Remove metadata file if no profiles left
499                let meta_path = target.join(".dot-agent-meta.toml");
500                let _ = fs::remove_file(meta_path);
501            } else {
502                metadata.save(target)?;
503            }
504        }
505
506        Ok((removed, kept, unmerged))
507    }
508
509    /// Upgrade profile files
510    pub fn upgrade(
511        &self,
512        profile: &Profile,
513        target: &Path,
514        opts: &InstallOptions<'_>,
515    ) -> Result<(usize, usize, usize, usize)> {
516        // updated, new, skipped, unchanged
517        if !target.exists() {
518            // Just install everything
519            let result = self.install(profile, target, opts)?;
520            return Ok((0, result.installed, 0, 0));
521        }
522
523        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
524        let mut updated = 0;
525        let mut new = 0;
526        let mut skipped = 0;
527        let mut unchanged = 0;
528
529        let files = profile.list_files_with_config(&opts.ignore_config)?;
530
531        for relative_path in files {
532            let src = profile.path.join(&relative_path);
533            let prefixed_path = if opts.no_prefix {
534                relative_path.clone()
535            } else {
536                prefix_path(&relative_path, &profile.name)
537            };
538            let dst = target.join(&prefixed_path);
539            let relative_str = prefixed_path.to_string_lossy().to_string();
540            let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
541
542            let src_content = fs::read(&src)?;
543            let src_hash = compute_hash(&src_content);
544
545            let meta_key = make_meta_key(&profile.name, &relative_str);
546
547            if !dst.exists() {
548                // New file
549                if !opts.dry_run {
550                    if let Some(parent) = dst.parent() {
551                        fs::create_dir_all(parent)?;
552                    }
553                    fs::write(&dst, &src_content)?;
554                    metadata.add_file(&meta_key, &src_hash);
555                }
556                if let Some(f) = opts.on_file {
557                    f("NEW", &relative_str);
558                }
559                new += 1;
560                continue;
561            }
562
563            let dst_hash = compute_file_hash(&dst)?;
564
565            if src_hash == dst_hash {
566                if let Some(f) = opts.on_file {
567                    f("OK", &relative_str);
568                }
569                unchanged += 1;
570                continue;
571            }
572
573            // CLAUDE.md is never overwritten
574            if is_claude_md {
575                if let Some(f) = opts.on_file {
576                    f("WARN", &relative_str);
577                }
578                skipped += 1;
579                continue;
580            }
581
582            // Check if file was modified locally
583            let original_hash = metadata.get_file_hash(&meta_key);
584            let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
585
586            if locally_modified && !opts.force {
587                if let Some(f) = opts.on_file {
588                    f("SKIP", &relative_str);
589                }
590                skipped += 1;
591                continue;
592            }
593
594            // Update file
595            if !opts.dry_run {
596                fs::write(&dst, &src_content)?;
597                metadata.add_file(&meta_key, &src_hash);
598            }
599            if let Some(f) = opts.on_file {
600                f("UPDATE", &relative_str);
601            }
602            updated += 1;
603        }
604
605        if !opts.dry_run {
606            metadata.add_profile(&profile.name);
607            metadata.save(target)?;
608        }
609
610        Ok((updated, new, skipped, unchanged))
611    }
612}
613
614fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
615    if dir == root {
616        return Ok(());
617    }
618
619    if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
620        fs::remove_dir(dir)?;
621        if let Some(parent) = dir.parent() {
622            remove_empty_dirs(parent, root)?;
623        }
624    }
625
626    Ok(())
627}
628
629/// Transform relative path to add profile prefix where needed
630/// Examples:
631///   agents/code-reviewer.md → agents/{profile}-code-reviewer.md
632///   skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
633///   rules/testing.md → rules/{profile}-testing.md
634///   commands/profile:cmd.md → commands/profile:cmd.md (already prefixed)
635///   CLAUDE.md → CLAUDE.md (no change)
636fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
637    let components: Vec<_> = relative_path.components().collect();
638
639    if components.is_empty() {
640        return relative_path.to_path_buf();
641    }
642
643    // Get first component (top-level directory)
644    let first = components[0].as_os_str().to_string_lossy();
645
646    // Check if this is a directory where we prefix files directly
647    if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
648        let filename = components[1].as_os_str().to_string_lossy();
649
650        // Skip if already prefixed (contains ':' or starts with profile name)
651        if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
652            return relative_path.to_path_buf();
653        }
654
655        // agents/code-reviewer.md → agents/{profile}-code-reviewer.md
656        let mut result = PathBuf::from(components[0].as_os_str());
657        result.push(format!("{}-{}", profile_name, filename));
658
659        // Add remaining components if any
660        for comp in &components[2..] {
661            result.push(comp.as_os_str());
662        }
663        return result;
664    }
665
666    // Check if this is a directory where we prefix subdirectories
667    if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
668        let subdir = components[1].as_os_str().to_string_lossy();
669
670        // Skip if already prefixed
671        if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
672            return relative_path.to_path_buf();
673        }
674
675        // skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
676        let mut result = PathBuf::from(components[0].as_os_str());
677        result.push(format!("{}-{}", profile_name, subdir));
678
679        // Add remaining components
680        for comp in &components[2..] {
681            result.push(comp.as_os_str());
682        }
683        return result;
684    }
685
686    // No transformation needed
687    relative_path.to_path_buf()
688}