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
11pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
13
14const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
16const 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 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 #[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 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 if let Some(f) = on_file {
118 f("SKIP", &relative_str);
119 }
120 result.skipped += 1;
121 continue;
122 }
123
124 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 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 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 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 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 let prefixed_files: Vec<_> = profile_files
193 .iter()
194 .map(|p| prefix_path(p, &profile.name))
195 .collect();
196
197 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 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 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 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 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 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 if file_info.status == FileStatus::Missing {
311 continue;
312 }
313
314 if !dry_run && dst.exists() {
316 fs::remove_file(&dst)?;
317 metadata.remove_file(&relative_str);
318
319 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 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 #[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 if !target.exists() {
359 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 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 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 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 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
476fn 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 let first = components[0].as_os_str().to_string_lossy();
492
493 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
495 let filename = components[1].as_os_str().to_string_lossy();
496
497 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
499 return relative_path.to_path_buf();
500 }
501
502 let mut result = PathBuf::from(components[0].as_os_str());
504 result.push(format!("{}-{}", profile_name, filename));
505
506 for comp in &components[2..] {
508 result.push(comp.as_os_str());
509 }
510 return result;
511 }
512
513 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
515 let subdir = components[1].as_os_str().to_string_lossy();
516
517 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
519 return relative_path.to_path_buf();
520 }
521
522 let mut result = PathBuf::from(components[0].as_os_str());
524 result.push(format!("{}-{}", profile_name, subdir));
525
526 for comp in &components[2..] {
528 result.push(comp.as_os_str());
529 }
530 return result;
531 }
532
533 relative_path.to_path_buf()
535}