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