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
12pub type FileCallback<'a> = Option<&'a dyn Fn(&str, &str)>;
14
15const PREFIXED_DIRS: &[&str] = &["agents", "commands", "rules"];
17const PREFIXED_SUBDIRS: &[&str] = &["skills"];
19
20fn 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
57#[derive(Default)]
59pub struct InstallOptions<'a> {
60 pub force: bool,
62 pub dry_run: bool,
64 pub no_prefix: bool,
66 pub no_merge: bool,
68 pub ignore_config: IgnoreConfig,
70 pub on_file: FileCallback<'a>,
72}
73
74impl std::fmt::Debug for InstallOptions<'_> {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("InstallOptions")
77 .field("force", &self.force)
78 .field("dry_run", &self.dry_run)
79 .field("no_prefix", &self.no_prefix)
80 .field("no_merge", &self.no_merge)
81 .field("ignore_config", &self.ignore_config)
82 .field("on_file", &self.on_file.is_some())
83 .finish()
84 }
85}
86
87impl<'a> InstallOptions<'a> {
88 pub fn new() -> Self {
90 Self {
91 ignore_config: IgnoreConfig::with_defaults(),
92 ..Default::default()
93 }
94 }
95
96 pub fn force(mut self, force: bool) -> Self {
98 self.force = force;
99 self
100 }
101
102 pub fn dry_run(mut self, dry_run: bool) -> Self {
104 self.dry_run = dry_run;
105 self
106 }
107
108 pub fn no_prefix(mut self, no_prefix: bool) -> Self {
110 self.no_prefix = no_prefix;
111 self
112 }
113
114 pub fn no_merge(mut self, no_merge: bool) -> Self {
116 self.no_merge = no_merge;
117 self
118 }
119
120 pub fn ignore_config(mut self, config: IgnoreConfig) -> Self {
122 self.ignore_config = config;
123 self
124 }
125
126 pub fn on_file(mut self, callback: FileCallback<'a>) -> Self {
128 self.on_file = callback;
129 self
130 }
131}
132
133pub struct Installer {
134 base_dir: PathBuf,
135}
136
137impl Installer {
138 pub fn new(base_dir: PathBuf) -> Self {
139 Self { base_dir }
140 }
141
142 pub fn resolve_target(&self, target: Option<&Path>, global: bool) -> Result<PathBuf> {
144 if global {
145 let home = dirs::home_dir().ok_or(DotAgentError::HomeNotFound)?;
146 Ok(home.join(".claude"))
147 } else {
148 let base = target
149 .map(|p| p.to_path_buf())
150 .unwrap_or_else(|| std::env::current_dir().unwrap());
151
152 if !base.exists() {
153 return Err(DotAgentError::TargetNotFound { path: base });
154 }
155
156 Ok(base.join(CLAUDE_DIR))
157 }
158 }
159
160 pub fn install(
162 &self,
163 profile: &Profile,
164 target: &Path,
165 opts: &InstallOptions<'_>,
166 ) -> Result<InstallResult> {
167 let mut result = InstallResult::default();
168 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
169
170 if !opts.dry_run && !target.exists() {
172 fs::create_dir_all(target)?;
173 }
174
175 let files = profile.list_files_with_config(&opts.ignore_config)?;
176
177 for relative_path in files {
178 let src = profile.path.join(&relative_path);
179 let prefixed_path = if opts.no_prefix {
180 relative_path.clone()
181 } else {
182 prefix_path(&relative_path, &profile.name)
183 };
184 let dst = target.join(&prefixed_path);
185 let relative_str = prefixed_path.to_string_lossy().to_string();
186
187 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
188 let is_mergeable = is_mergeable_json(&relative_path);
189
190 if is_mergeable && !opts.no_merge && dst.exists() {
192 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
193
194 if !merge_result.changed {
195 if let Some(f) = opts.on_file {
196 f("SKIP", &relative_str);
197 }
198 result.skipped += 1;
199 continue;
200 }
201
202 if !opts.dry_run {
203 if let Some(parent) = dst.parent() {
204 fs::create_dir_all(parent)?;
205 }
206 fs::write(&dst, &merge_result.content)?;
207 metadata.add_merged(
208 &profile.name,
209 &relative_str,
210 merge_result.record.added_paths,
211 );
212 }
213
214 if let Some(f) = opts.on_file {
215 f("MERGE", &relative_str);
216 }
217 result.merged += 1;
218 continue;
219 }
220
221 let src_content = fs::read(&src)?;
222 let src_hash = compute_hash(&src_content);
223
224 if dst.exists() {
225 let dst_hash = compute_file_hash(&dst)?;
226
227 if src_hash == dst_hash {
228 if let Some(f) = opts.on_file {
230 f("SKIP", &relative_str);
231 }
232 result.skipped += 1;
233 continue;
234 }
235
236 if is_claude_md {
238 if let Some(f) = opts.on_file {
239 f("WARN", &relative_str);
240 }
241 result.skipped += 1;
242 continue;
243 }
244
245 if !opts.force {
247 if let Some(f) = opts.on_file {
248 f("CONFLICT", &relative_str);
249 }
250 result.conflicts += 1;
251 continue;
252 }
253 }
254
255 if !opts.dry_run {
257 if let Some(parent) = dst.parent() {
258 fs::create_dir_all(parent)?;
259 }
260
261 if is_mergeable && !opts.no_merge {
263 let merge_result = merge_json_file(&dst, &src, &profile.name)?;
264 fs::write(&dst, &merge_result.content)?;
265 metadata.add_merged(
266 &profile.name,
267 &relative_str,
268 merge_result.record.added_paths,
269 );
270 } else {
271 fs::write(&dst, &src_content)?;
272 let meta_key = make_meta_key(&profile.name, &relative_str);
273 metadata.add_file(&meta_key, &src_hash);
274 }
275 }
276
277 if let Some(f) = opts.on_file {
278 f("OK", &relative_str);
279 }
280 result.installed += 1;
281 }
282
283 if !opts.dry_run && result.conflicts == 0 {
284 metadata.add_profile(&profile.name);
285 metadata.save(target)?;
286 }
287
288 Ok(result)
289 }
290
291 pub fn diff(
293 &self,
294 profile: &Profile,
295 target: &Path,
296 ignore_config: &IgnoreConfig,
297 ) -> Result<DiffResult> {
298 let mut result = DiffResult::default();
299
300 if !target.exists() {
301 for relative_path in profile.list_files_with_config(ignore_config)? {
303 let prefixed_path = prefix_path(&relative_path, &profile.name);
304 result.files.push(FileInfo {
305 relative_path: prefixed_path,
306 status: FileStatus::Missing,
307 });
308 result.missing += 1;
309 }
310 return Ok(result);
311 }
312
313 let metadata = Metadata::load(target)?;
314 let profile_files = profile.list_files_with_config(ignore_config)?;
315
316 let prefixed_files: Vec<_> = profile_files
318 .iter()
319 .map(|p| prefix_path(p, &profile.name))
320 .collect();
321
322 for (idx, relative_path) in profile_files.iter().enumerate() {
324 let src = profile.path.join(relative_path);
325 let prefixed_path = &prefixed_files[idx];
326 let dst = target.join(prefixed_path);
327
328 if !dst.exists() {
329 result.files.push(FileInfo {
330 relative_path: prefixed_path.clone(),
331 status: FileStatus::Missing,
332 });
333 result.missing += 1;
334 continue;
335 }
336
337 let src_hash = compute_file_hash(&src)?;
338 let dst_hash = compute_file_hash(&dst)?;
339
340 if src_hash == dst_hash {
341 result.files.push(FileInfo {
342 relative_path: prefixed_path.clone(),
343 status: FileStatus::Unchanged,
344 });
345 result.unchanged += 1;
346 } else {
347 result.files.push(FileInfo {
348 relative_path: prefixed_path.clone(),
349 status: FileStatus::Modified,
350 });
351 result.modified += 1;
352 }
353 }
354
355 if let Some(meta) = &metadata {
357 for file_path in meta.files.keys() {
358 let path = PathBuf::from(file_path);
359 if !prefixed_files.contains(&path) {
360 let full_path = target.join(&path);
361 if full_path.exists() {
362 result.files.push(FileInfo {
363 relative_path: path,
364 status: FileStatus::Added,
365 });
366 result.added += 1;
367 }
368 }
369 }
370 }
371
372 result
373 .files
374 .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
375 Ok(result)
376 }
377
378 pub fn remove(
380 &self,
381 profile: &Profile,
382 target: &Path,
383 opts: &InstallOptions<'_>,
384 ) -> Result<(usize, usize, usize)> {
385 if !target.exists() {
386 return Ok((0, 0, 0));
387 }
388
389 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
390 let diff = self.diff(profile, target, &opts.ignore_config)?;
391
392 if !opts.force {
395 let modified: Vec<_> = diff
396 .files
397 .iter()
398 .filter(|f| {
399 f.status == FileStatus::Modified
400 && (opts.no_merge || !is_mergeable_json(&f.relative_path))
401 })
402 .map(|f| f.relative_path.clone())
403 .collect();
404
405 if !modified.is_empty() {
406 return Err(DotAgentError::LocalModifications { paths: modified });
407 }
408 }
409
410 let mut removed = 0;
411 let mut kept = 0;
412 let mut unmerged = 0;
413
414 if !opts.no_merge {
416 if let Some(merged_files) = metadata.get_merged_files(&profile.name).cloned() {
417 for (file_path, _json_paths) in merged_files {
418 let dst = target.join(&file_path);
419 if !dst.exists() {
420 continue;
421 }
422
423 if let Some(result) = unmerge_json_file(&dst, &profile.name)? {
424 if result.changed {
425 if !opts.dry_run {
426 fs::write(&dst, &result.content)?;
427 }
428 if let Some(f) = opts.on_file {
429 f("UNMERGE", &file_path);
430 }
431 unmerged += 1;
432 }
433 }
434 }
435 }
436 }
437
438 for file_info in &diff.files {
439 let dst = target.join(&file_info.relative_path);
440 let relative_str = file_info.relative_path.to_string_lossy().to_string();
441
442 if relative_str == CLAUDE_MD {
444 if let Some(f) = opts.on_file {
445 f("KEEP", &relative_str);
446 }
447 kept += 1;
448 continue;
449 }
450
451 if file_info.status == FileStatus::Added {
453 if let Some(f) = opts.on_file {
454 f("KEEP", &relative_str);
455 }
456 kept += 1;
457 continue;
458 }
459
460 if file_info.status == FileStatus::Missing {
462 continue;
463 }
464
465 if !opts.no_merge && is_mergeable_json(&file_info.relative_path) {
467 if metadata.get_merged(&profile.name, &relative_str).is_some() {
469 continue;
470 }
471 }
472
473 if !opts.dry_run && dst.exists() {
475 fs::remove_file(&dst)?;
476 let meta_key = make_meta_key(&profile.name, &relative_str);
477 metadata.remove_file(&meta_key);
478
479 if let Some(parent) = dst.parent() {
481 let _ = remove_empty_dirs(parent, target);
482 }
483 }
484
485 if let Some(f) = opts.on_file {
486 f("DEL", &relative_str);
487 }
488 removed += 1;
489 }
490
491 if !opts.dry_run {
492 metadata.remove_profile(&profile.name);
493 metadata.remove_merged(&profile.name);
494 if metadata.installed.profiles.is_empty()
495 && metadata.files.is_empty()
496 && metadata.merged.is_empty()
497 {
498 let meta_path = target.join(".dot-agent-meta.toml");
500 let _ = fs::remove_file(meta_path);
501 } else {
502 metadata.save(target)?;
503 }
504 }
505
506 Ok((removed, kept, unmerged))
507 }
508
509 pub fn upgrade(
511 &self,
512 profile: &Profile,
513 target: &Path,
514 opts: &InstallOptions<'_>,
515 ) -> Result<(usize, usize, usize, usize)> {
516 if !target.exists() {
518 let result = self.install(profile, target, opts)?;
520 return Ok((0, result.installed, 0, 0));
521 }
522
523 let mut metadata = Metadata::load(target)?.unwrap_or_else(|| Metadata::new(&self.base_dir));
524 let mut updated = 0;
525 let mut new = 0;
526 let mut skipped = 0;
527 let mut unchanged = 0;
528
529 let files = profile.list_files_with_config(&opts.ignore_config)?;
530
531 for relative_path in files {
532 let src = profile.path.join(&relative_path);
533 let prefixed_path = if opts.no_prefix {
534 relative_path.clone()
535 } else {
536 prefix_path(&relative_path, &profile.name)
537 };
538 let dst = target.join(&prefixed_path);
539 let relative_str = prefixed_path.to_string_lossy().to_string();
540 let is_claude_md = relative_path.to_string_lossy() == CLAUDE_MD;
541
542 let src_content = fs::read(&src)?;
543 let src_hash = compute_hash(&src_content);
544
545 let meta_key = make_meta_key(&profile.name, &relative_str);
546
547 if !dst.exists() {
548 if !opts.dry_run {
550 if let Some(parent) = dst.parent() {
551 fs::create_dir_all(parent)?;
552 }
553 fs::write(&dst, &src_content)?;
554 metadata.add_file(&meta_key, &src_hash);
555 }
556 if let Some(f) = opts.on_file {
557 f("NEW", &relative_str);
558 }
559 new += 1;
560 continue;
561 }
562
563 let dst_hash = compute_file_hash(&dst)?;
564
565 if src_hash == dst_hash {
566 if let Some(f) = opts.on_file {
567 f("OK", &relative_str);
568 }
569 unchanged += 1;
570 continue;
571 }
572
573 if is_claude_md {
575 if let Some(f) = opts.on_file {
576 f("WARN", &relative_str);
577 }
578 skipped += 1;
579 continue;
580 }
581
582 let original_hash = metadata.get_file_hash(&meta_key);
584 let locally_modified = original_hash.map(|h| h != &dst_hash).unwrap_or(false);
585
586 if locally_modified && !opts.force {
587 if let Some(f) = opts.on_file {
588 f("SKIP", &relative_str);
589 }
590 skipped += 1;
591 continue;
592 }
593
594 if !opts.dry_run {
596 fs::write(&dst, &src_content)?;
597 metadata.add_file(&meta_key, &src_hash);
598 }
599 if let Some(f) = opts.on_file {
600 f("UPDATE", &relative_str);
601 }
602 updated += 1;
603 }
604
605 if !opts.dry_run {
606 metadata.add_profile(&profile.name);
607 metadata.save(target)?;
608 }
609
610 Ok((updated, new, skipped, unchanged))
611 }
612}
613
614fn remove_empty_dirs(dir: &Path, root: &Path) -> std::io::Result<()> {
615 if dir == root {
616 return Ok(());
617 }
618
619 if dir.is_dir() && fs::read_dir(dir)?.next().is_none() {
620 fs::remove_dir(dir)?;
621 if let Some(parent) = dir.parent() {
622 remove_empty_dirs(parent, root)?;
623 }
624 }
625
626 Ok(())
627}
628
629fn prefix_path(relative_path: &Path, profile_name: &str) -> PathBuf {
637 let components: Vec<_> = relative_path.components().collect();
638
639 if components.is_empty() {
640 return relative_path.to_path_buf();
641 }
642
643 let first = components[0].as_os_str().to_string_lossy();
645
646 if PREFIXED_DIRS.contains(&first.as_ref()) && components.len() >= 2 {
648 let filename = components[1].as_os_str().to_string_lossy();
649
650 if filename.contains(':') || filename.starts_with(&format!("{}-", profile_name)) {
652 return relative_path.to_path_buf();
653 }
654
655 let mut result = PathBuf::from(components[0].as_os_str());
657 result.push(format!("{}-{}", profile_name, filename));
658
659 for comp in &components[2..] {
661 result.push(comp.as_os_str());
662 }
663 return result;
664 }
665
666 if PREFIXED_SUBDIRS.contains(&first.as_ref()) && components.len() >= 2 {
668 let subdir = components[1].as_os_str().to_string_lossy();
669
670 if subdir.contains(':') || subdir.starts_with(&format!("{}-", profile_name)) {
672 return relative_path.to_path_buf();
673 }
674
675 let mut result = PathBuf::from(components[0].as_os_str());
677 result.push(format!("{}-{}", profile_name, subdir));
678
679 for comp in &components[2..] {
681 result.push(comp.as_os_str());
682 }
683 return result;
684 }
685
686 relative_path.to_path_buf()
688}