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
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 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 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 if let Some(f) = on_file {
116 f("SKIP", &relative_str);
117 }
118 result.skipped += 1;
119 continue;
120 }
121
122 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 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 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 pub fn diff(&self, profile: &Profile, target: &Path) -> Result<DiffResult> {
166 let mut result = DiffResult::default();
167
168 if !target.exists() {
169 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 let prefixed_files: Vec<_> = profile_files
186 .iter()
187 .map(|p| prefix_path(p, &profile.name))
188 .collect();
189
190 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 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 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 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 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 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 if file_info.status == FileStatus::Missing {
303 continue;
304 }
305
306 if !dry_run && dst.exists() {
308 fs::remove_file(&dst)?;
309 metadata.remove_file(&relative_str);
310
311 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 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 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 if !target.exists() {
349 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 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 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 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 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
458fn 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 let first = components[0].as_os_str().to_string_lossy();
474
475 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
477 let filename = components[1].as_os_str().to_string_lossy();
478
479 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
481 return relative_path.to_path_buf();
482 }
483
484 let mut result = PathBuf::from(components[0].as_os_str());
486 result.push(format!("{}-{}", profile_name, filename));
487
488 for comp in &components[2..] {
490 result.push(comp.as_os_str());
491 }
492 return result;
493 }
494
495 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
497 let subdir = components[1].as_os_str().to_string_lossy();
498
499 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
501 return relative_path.to_path_buf();
502 }
503
504 let mut result = PathBuf::from(components[0].as_os_str());
506 result.push(format!("{}-{}", profile_name, subdir));
507
508 for comp in &components[2..] {
510 result.push(comp.as_os_str());
511 }
512 return result;
513 }
514
515 relative_path.to_path_buf()
517}