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