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