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::metadata::{compute_file_hash, compute_hash, Metadata};
6use crate::profile::{IgnoreConfig, Profile};
7
8const CLAUDE_MD: &str = "CLAUDE.md";
9const CLAUDE_DIR: &str = ".claude";
10
11/// Callback type for file operation progress reporting
12pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
13
14// Directories where files should be prefixed with profile name
15const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
16// Directories where subdirectories should be prefixed (skills has SKILL.md inside)
17const PREFIXED_SUBDIRS: &[&str] = &["skills"];
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum FileStatus {
21    Unchanged,
22    Modified,
23    Added,
24    Missing,
25}
26
27#[derive(Debug)]
28pub struct FileInfo {
29    pub relative_path: PathBuf,
30    pub status: FileStatus,
31}
32
33#[derive(Debug, Default)]
34pub struct InstallResult {
35    pub installed: usize,
36    pub skipped: usize,
37    pub conflicts: usize,
38}
39
40#[derive(Debug, Default)]
41pub struct DiffResult {
42    pub unchanged: usize,
43    pub modified: usize,
44    pub added: usize,
45    pub missing: usize,
46    pub files: Vec<FileInfo>,
47}
48
49pub struct Installer {
50    base_dir: PathBuf,
51}
52
53impl Installer {
54    pub fn new(base_dir: PathBuf) -> Self {
55        Self { base_dir }
56    }
57
58    /// Get target directory (either project/.claude or global ~/.claude)
59    pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
60        if global {
61            let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
62            Ok(home.join(".claude"))
63        } else {
64            let base = target
65                .map(|p| p.to_path_buf())
66                .unwrap_or_else(|| std::env::current_dir().unwrap());
67
68            if !base.exists() {
69                return Err(DotAgentError::TargetNotFound { path: base });
70            }
71
72            Ok(base.join(CLAUDE_DIR))
73        }
74    }
75
76    /// Install a profile to target
77    #[allow(clippy::too_many_arguments)]
78    pub fn install(
79        &self,
80        profile: &Profile,
81        target: &Path,
82        force: bool,
83        dry_run: bool,
84        no_prefix: bool,
85        ignore_config: &IgnoreConfig,
86        on_file: FileCallback<'_>,
87    ) -> Result<InstallResult> {
88        let mut result = InstallResult::default();
89        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
90
91        // Ensure target directory exists
92        if !dry_run && !target.exists() {
93            fs::create_dir_all(target)?;
94        }
95
96        let files = profile.list_files_with_config(ignore_config)?;
97
98        for relative_path in files {
99            let src = profile.path.join(&relative_path);
100            let prefixed_path = if no_prefix {
101                relative_path.clone()
102            } else {
103                prefix_path(&relative_path, &profile.name)
104            };
105            let dst = target.join(&prefixed_path);
106            let relative_str = prefixed_path.to_string_lossy().to_string();
107
108            let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
109            let src_content = fs::read(&src)?;
110            let src_hash = compute_hash(&src_content);
111
112            if dst.exists() {
113                let dst_hash = compute_file_hash(&dst)?;
114
115                if src_hash == dst_hash {
116                    // Same content - skip
117                    if let Some(f) = on_file {
118                        f("SKIP", &relative_str);
119                    }
120                    result.skipped += 1;
121                    continue;
122                }
123
124                // CLAUDE.md is never overwritten
125                if is_claude_md {
126                    if let Some(f) = on_file {
127                        f("WARN", &relative_str);
128                    }
129                    result.skipped += 1;
130                    continue;
131                }
132
133                // Different content
134                if !force {
135                    if let Some(f) = on_file {
136                        f("CONFLICT", &relative_str);
137                    }
138                    result.conflicts += 1;
139                    continue;
140                }
141            }
142
143            // Copy file
144            if !dry_run {
145                if let Some(parent) = dst.parent() {
146                    fs::create_dir_all(parent)?;
147                }
148                fs::write(&dst, &src_content)?;
149                metadata.add_file(&relative_str, &src_hash);
150            }
151
152            if let Some(f) = on_file {
153                f("OK", &relative_str);
154            }
155            result.installed += 1;
156        }
157
158        if !dry_run && result.conflicts == 0 {
159            metadata.add_profile(&profile.name);
160            metadata.save(target)?;
161        }
162
163        Ok(result)
164    }
165
166    /// Compare profile with installed files
167    pub fn diff(
168        &self,
169        profile: &Profile,
170        target: &Path,
171        ignore_config: &IgnoreConfig,
172    ) -> Result<DiffResult> {
173        let mut result = DiffResult::default();
174
175        if !target.exists() {
176            // All files are missing
177            for relative_path in profile.list_files_with_config(ignore_config)? {
178                let prefixed_path = prefix_path(&relative_path, &profile.name);
179                result.files.push(FileInfo {
180                    relative_path: prefixed_path,
181                    status: FileStatus::Missing,
182                });
183                result.missing += 1;
184            }
185            return Ok(result);
186        }
187
188        let metadata = Metadata::load(target)?;
189        let profile_files = profile.list_files_with_config(ignore_config)?;
190
191        // Build set of prefixed paths for comparison
192        let prefixed_files: Vec<_> = profile_files
193            .iter()
194            .map(|p| prefix_path(p, &profile.name))
195            .collect();
196
197        // Check profile files against target
198        for (idx, relative_path) in profile_files.iter().enumerate() {
199            let src = profile.path.join(relative_path);
200            let prefixed_path = &prefixed_files[idx];
201            let dst = target.join(prefixed_path);
202
203            if !dst.exists() {
204                result.files.push(FileInfo {
205                    relative_path: prefixed_path.clone(),
206                    status: FileStatus::Missing,
207                });
208                result.missing += 1;
209                continue;
210            }
211
212            let src_hash = compute_file_hash(&src)?;
213            let dst_hash = compute_file_hash(&dst)?;
214
215            if src_hash == dst_hash {
216                result.files.push(FileInfo {
217                    relative_path: prefixed_path.clone(),
218                    status: FileStatus::Unchanged,
219                });
220                result.unchanged += 1;
221            } else {
222                result.files.push(FileInfo {
223                    relative_path: prefixed_path.clone(),
224                    status: FileStatus::Modified,
225                });
226                result.modified += 1;
227            }
228        }
229
230        // Check for files in metadata that aren't in profile (user added)
231        if let Some(meta) = &metadata {
232            for file_path in meta.files.keys() {
233                let path = PathBuf::from(file_path);
234                if !prefixed_files.contains(&path) {
235                    let full_path = target.join(&path);
236                    if full_path.exists() {
237                        result.files.push(FileInfo {
238                            relative_path: path,
239                            status: FileStatus::Added,
240                        });
241                        result.added += 1;
242                    }
243                }
244            }
245        }
246
247        result
248            .files
249            .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
250        Ok(result)
251    }
252
253    /// Remove installed profile files
254    pub fn remove(
255        &self,
256        profile: &Profile,
257        target: &Path,
258        force: bool,
259        dry_run: bool,
260        ignore_config: &IgnoreConfig,
261        on_file: FileCallback<'_>,
262    ) -> Result<(usize, usize)> {
263        if !target.exists() {
264            return Ok((0, 0));
265        }
266
267        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
268        let diff = self.diff(profile, target, ignore_config)?;
269
270        // Check for local modifications
271        if !force {
272            let modified: Vec<_> = diff
273                .files
274                .iter()
275                .filter(|f| f.status == FileStatus::Modified)
276                .map(|f| f.relative_path.clone())
277                .collect();
278
279            if !modified.is_empty() {
280                return Err(DotAgentError::LocalModifications { paths: modified });
281            }
282        }
283
284        let mut removed = 0;
285        let mut kept = 0;
286
287        for file_info in &diff.files {
288            let dst = target.join(&file_info.relative_path);
289            let relative_str = file_info.relative_path.to_string_lossy().to_string();
290
291            // Never remove CLAUDE.md
292            if relative_str == CLAUDE_MD {
293                if let Some(f) = on_file {
294                    f("KEEP", &relative_str);
295                }
296                kept += 1;
297                continue;
298            }
299
300            // Skip user-added files
301            if file_info.status == FileStatus::Added {
302                if let Some(f) = on_file {
303                    f("KEEP", &relative_str);
304                }
305                kept += 1;
306                continue;
307            }
308
309            // Skip missing files
310            if file_info.status == FileStatus::Missing {
311                continue;
312            }
313
314            // Remove file
315            if !dry_run && dst.exists() {
316                fs::remove_file(&dst)?;
317                metadata.remove_file(&relative_str);
318
319                // Remove empty parent directories
320                if let Some(parent) = dst.parent() {
321                    let _ = remove_empty_dirs(parent, target);
322                }
323            }
324
325            if let Some(f) = on_file {
326                f("DEL", &relative_str);
327            }
328            removed += 1;
329        }
330
331        if !dry_run {
332            metadata.remove_profile(&profile.name);
333            if metadata.installed.profiles.is_empty() && metadata.files.is_empty() {
334                // Remove metadata file if no profiles left
335                let meta_path = target.join(".dot-agent-meta.toml");
336                let _ = fs::remove_file(meta_path);
337            } else {
338                metadata.save(target)?;
339            }
340        }
341
342        Ok((removed, kept))
343    }
344
345    /// Upgrade profile files
346    #[allow(clippy::too_many_arguments)]
347    pub fn upgrade(
348        &self,
349        profile: &Profile,
350        target: &Path,
351        force: bool,
352        dry_run: bool,
353        no_prefix: bool,
354        ignore_config: &IgnoreConfig,
355        on_file: FileCallback<'_>,
356    ) -> Result<(usize, usize, usize, usize)> {
357        // updated, new, skipped, unchanged
358        if !target.exists() {
359            // Just install everything
360            let result = self.install(
361                profile,
362                target,
363                force,
364                dry_run,
365                no_prefix,
366                ignore_config,
367                on_file,
368            )?;
369            return Ok((0, result.installed, 0, 0));
370        }
371
372        let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
373        let mut updated = 0;
374        let mut new = 0;
375        let mut skipped = 0;
376        let mut unchanged = 0;
377
378        let files = profile.list_files_with_config(ignore_config)?;
379
380        for relative_path in files {
381            let src = profile.path.join(&relative_path);
382            let prefixed_path = if no_prefix {
383                relative_path.clone()
384            } else {
385                prefix_path(&relative_path, &profile.name)
386            };
387            let dst = target.join(&prefixed_path);
388            let relative_str = prefixed_path.to_string_lossy().to_string();
389            let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
390
391            let src_content = fs::read(&src)?;
392            let src_hash = compute_hash(&src_content);
393
394            if !dst.exists() {
395                // New file
396                if !dry_run {
397                    if let Some(parent) = dst.parent() {
398                        fs::create_dir_all(parent)?;
399                    }
400                    fs::write(&dst, &src_content)?;
401                    metadata.add_file(&relative_str, &src_hash);
402                }
403                if let Some(f) = on_file {
404                    f("NEW", &relative_str);
405                }
406                new += 1;
407                continue;
408            }
409
410            let dst_hash = compute_file_hash(&dst)?;
411
412            if src_hash == dst_hash {
413                if let Some(f) = on_file {
414                    f("OK", &relative_str);
415                }
416                unchanged += 1;
417                continue;
418            }
419
420            // CLAUDE.md is never overwritten
421            if is_claude_md {
422                if let Some(f) = on_file {
423                    f("WARN", &relative_str);
424                }
425                skipped += 1;
426                continue;
427            }
428
429            // Check if file was modified locally
430            let original_hash = metadata.get_file_hash(&relative_str);
431            let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
432
433            if locally_modified && !force {
434                if let Some(f) = on_file {
435                    f("SKIP", &relative_str);
436                }
437                skipped += 1;
438                continue;
439            }
440
441            // Update file
442            if !dry_run {
443                fs::write(&dst, &src_content)?;
444                metadata.add_file(&relative_str, &src_hash);
445            }
446            if let Some(f) = on_file {
447                f("UPDATE", &relative_str);
448            }
449            updated += 1;
450        }
451
452        if !dry_run {
453            metadata.add_profile(&profile.name);
454            metadata.save(target)?;
455        }
456
457        Ok((updated, new, skipped, unchanged))
458    }
459}
460
461fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
462    if dir == root {
463        return Ok(());
464    }
465
466    if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
467        fs::remove_dir(dir)?;
468        if let Some(parent) = dir.parent() {
469            remove_empty_dirs(parent, root)?;
470        }
471    }
472
473    Ok(())
474}
475
476/// Transform relative path to add profile prefix where needed
477/// Examples:
478///   agents/code-reviewer.md → agents/{profile}-code-reviewer.md
479///   skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
480///   rules/testing.md → rules/{profile}-testing.md
481///   commands/profile:cmd.md → commands/profile:cmd.md (already prefixed)
482///   CLAUDE.md → CLAUDE.md (no change)
483fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
484    let components: Vec<_> = relative_path.components().collect();
485
486    if components.is_empty() {
487        return relative_path.to_path_buf();
488    }
489
490    // Get first component (top-level directory)
491    let first = components[0].as_os_str().to_string_lossy();
492
493    // Check if this is a directory where we prefix files directly
494    if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
495        let filename = components[1].as_os_str().to_string_lossy();
496
497        // Skip if already prefixed (contains ':' or starts with profile name)
498        if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
499            return relative_path.to_path_buf();
500        }
501
502        // agents/code-reviewer.md → agents/{profile}-code-reviewer.md
503        let mut result = PathBuf::from(components[0].as_os_str());
504        result.push(format!("{}-{}", profile_name, filename));
505
506        // Add remaining components if any
507        for comp in &components[2..] {
508            result.push(comp.as_os_str());
509        }
510        return result;
511    }
512
513    // Check if this is a directory where we prefix subdirectories
514    if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
515        let subdir = components[1].as_os_str().to_string_lossy();
516
517        // Skip if already prefixed
518        if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
519            return relative_path.to_path_buf();
520        }
521
522        // skills/my-skill/SKILL.md → skills/{profile}-my-skill/SKILL.md
523        let mut result = PathBuf::from(components[0].as_os_str());
524        result.push(format!("{}-{}", profile_name, subdir));
525
526        // Add remaining components
527        for comp in &components[2..] {
528            result.push(comp.as_os_str());
529        }
530        return result;
531    }
532
533    // No transformation needed
534    relative_path.to_path_buf()
535}