1use std::path::{Path, PathBuf};
15
16use crate::error::MarsError;
17use crate::hash;
18
19use super::output;
20
21#[derive(Debug, clap::Args)]
23pub struct LinkArgs {
24 pub target: String,
26
27 #[arg(long)]
29 pub unlink: bool,
30
31 #[arg(long)]
33 pub force: bool,
34}
35
36enum ScanResult {
38 Empty,
40 AlreadyLinked,
42 ForeignSymlink { target: PathBuf },
44 MergeableDir { files_to_move: Vec<PathBuf> },
46 ConflictedDir { conflicts: Vec<ConflictInfo> },
48}
49
50#[derive(Clone)]
52struct ConflictInfo {
53 relative_path: PathBuf,
55 target_desc: String,
57 managed_desc: String,
59}
60
61pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
63 if args.unlink {
64 let target_name = normalize_link_target(&args.target)?;
65 let target_dir = ctx.project_root.join(&target_name);
66 return unlink(ctx, &target_name, &target_dir, json);
67 }
68
69 let target_name = normalize_link_target(&args.target)?;
70 let target_dir = ctx.project_root.join(&target_name);
71
72 if let (Ok(target_canon), Ok(root_canon)) = (
74 target_dir.canonicalize().or_else(|_| Ok::<_, std::io::Error>(target_dir.clone())),
75 ctx.managed_root.canonicalize(),
76 ) {
77 if target_canon == root_canon {
78 return Err(MarsError::Link {
79 target: target_name,
80 message: "cannot link the managed root to itself".to_string(),
81 });
82 }
83 }
84
85 let config_path = ctx.managed_root.join("mars.toml");
87 if !config_path.exists() {
88 return Err(MarsError::Link {
89 target: target_name,
90 message: format!(
91 "mars.toml not found at {} — run `mars init` first",
92 ctx.managed_root.display()
93 ),
94 });
95 }
96
97 if !json
99 && !super::WELL_KNOWN.contains(&target_name.as_str())
100 && !super::TOOL_DIRS.contains(&target_name.as_str())
101 {
102 output::print_warn(&format!(
103 "`{target_name}` is not a recognized tool directory — linking anyway"
104 ));
105 }
106
107 let lock_path = ctx.managed_root.join(".mars").join("sync.lock");
110 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
111
112 std::fs::create_dir_all(&target_dir)?;
114
115 for subdir in ["agents", "skills"] {
117 let source = ctx.managed_root.join(subdir);
118 if !source.exists() {
119 std::fs::create_dir_all(&source)?;
120 }
121 }
122
123 let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
125 .unwrap_or_else(|| ctx.managed_root.clone());
126
127 let mut scan_results = Vec::new();
129 let mut all_conflicts: Vec<(&str, ConflictInfo)> = Vec::new();
130 let mut has_foreign = false;
131 let mut foreign_details: Vec<(&str, PathBuf)> = Vec::new();
132
133 for subdir in ["agents", "skills"] {
134 let link_path = target_dir.join(subdir);
135 let link_target = rel_root.join(subdir);
136 let managed_subdir = ctx.managed_root.join(subdir);
137
138 let result = scan_link_target(&link_path, &managed_subdir);
139 match &result {
140 ScanResult::ConflictedDir { conflicts } => {
141 for c in conflicts {
142 all_conflicts.push((subdir, c.clone()));
143 }
144 }
145 ScanResult::ForeignSymlink { target } => {
146 has_foreign = true;
147 foreign_details.push((subdir, target.clone()));
148 }
149 _ => {}
150 }
151 scan_results.push((subdir, link_path, link_target, result));
152 }
153
154 if !args.force && (!all_conflicts.is_empty() || has_foreign) {
156 if json {
157 let conflict_json: Vec<_> = all_conflicts
158 .iter()
159 .map(|(subdir, c)| {
160 serde_json::json!({
161 "path": format!("{}/{}", subdir, c.relative_path.display()),
162 "target_desc": c.target_desc,
163 "managed_desc": c.managed_desc,
164 })
165 })
166 .collect();
167 output::print_json(&serde_json::json!({
168 "ok": false,
169 "error": "conflicts found",
170 "conflicts": conflict_json,
171 }));
172 } else {
173 let total = all_conflicts.len() + foreign_details.len();
174 eprintln!(
175 "error: cannot link {target_name} — {total} conflict(s) found:\n"
176 );
177 for (subdir, info) in &all_conflicts {
178 eprintln!(" {subdir}/{}", info.relative_path.display());
179 eprintln!(
180 " {target_name}/{subdir}/{} ({})",
181 info.relative_path.display(),
182 info.target_desc
183 );
184 eprintln!(
185 " {}/{subdir}/{} ({})\n",
186 ctx.managed_root
187 .file_name()
188 .unwrap_or_default()
189 .to_string_lossy(),
190 info.relative_path.display(),
191 info.managed_desc
192 );
193 }
194 for (subdir, foreign_target) in &foreign_details {
195 eprintln!(
196 " {target_name}/{subdir} is a symlink to {} (not this mars root)\n",
197 foreign_target.display()
198 );
199 }
200 eprintln!(
201 "hint: resolve conflicts manually, then retry `mars link {target_name}`"
202 );
203 eprintln!(
204 "hint: or use `mars link {target_name} --force` to replace with symlinks (data loss)"
205 );
206 }
207 return Err(MarsError::Link {
208 target: target_name,
209 message: "conflicts found — resolve manually or use --force".to_string(),
210 });
211 }
212
213 let mut linked = 0;
215 for (subdir, link_path, link_target, result) in scan_results {
216 match result {
217 ScanResult::Empty => {
218 create_symlink(&link_path, &link_target)?;
219 linked += 1;
220 }
221 ScanResult::AlreadyLinked => {
222 if !json {
223 output::print_info(&format!("{target_name}/{subdir} already linked"));
224 }
225 }
226 ScanResult::MergeableDir { files_to_move } => {
227 let managed_subdir = ctx.managed_root.join(subdir);
228 merge_and_link(
229 &link_path,
230 &link_target,
231 &managed_subdir,
232 &files_to_move,
233 )?;
234 linked += 1;
235 if !json && !files_to_move.is_empty() {
236 output::print_info(&format!(
237 "merged {} file(s) from {target_name}/{subdir} into managed root",
238 files_to_move.len()
239 ));
240 }
241 }
242 ScanResult::ForeignSymlink { .. } | ScanResult::ConflictedDir { .. } => {
243 if link_path.symlink_metadata().is_ok() {
245 if link_path.read_link().is_ok() {
246 std::fs::remove_file(&link_path)?;
247 } else {
248 std::fs::remove_dir_all(&link_path)?;
249 }
250 }
251 create_symlink(&link_path, &link_target)?;
252 linked += 1;
253 }
254 }
255 }
256
257 let mut config = crate::config::load(&ctx.managed_root)?;
259 if !config.settings.links.contains(&target_name) {
260 config.settings.links.push(target_name.clone());
261 crate::config::save(&ctx.managed_root, &config)?;
262 }
263
264 if json {
266 output::print_json(&serde_json::json!({
267 "ok": true,
268 "target": target_dir.to_string_lossy(),
269 "linked": linked,
270 }));
271 } else if linked > 0 {
272 output::print_success(&format!("linked agents/ and skills/ into {target_name}"));
273 } else {
274 output::print_info(&format!("{target_name} already fully linked"));
275 }
276
277 Ok(0)
278}
279
280fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
284 if link_path.symlink_metadata().is_err() {
286 return ScanResult::Empty;
287 }
288
289 if let Ok(actual_target) = link_path.read_link() {
291 let actual_resolved = link_path
294 .parent()
295 .map(|p| p.join(&actual_target))
296 .and_then(|p| p.canonicalize().ok());
297 let expected_resolved = managed_subdir.canonicalize().ok();
298
299 match (actual_resolved, expected_resolved) {
300 (Some(a), Some(b)) if a == b => return ScanResult::AlreadyLinked,
301 _ => return ScanResult::ForeignSymlink { target: actual_target },
302 }
303 }
304
305 scan_dir_recursive(link_path, managed_subdir)
307}
308
309fn scan_dir_recursive(target_subdir: &Path, managed_subdir: &Path) -> ScanResult {
311 let mut files_to_move = Vec::new();
312 let mut conflicts = Vec::new();
313
314 for entry in walkdir::WalkDir::new(target_subdir)
316 .follow_links(false)
317 .into_iter()
318 .filter_map(|e| e.ok())
319 {
320 let ft = entry.file_type();
321 if ft.is_dir() {
322 continue; }
324 if ft.is_symlink() {
325 continue;
329 }
330
331 let relative = match entry.path().strip_prefix(target_subdir) {
332 Ok(r) => r.to_path_buf(),
333 Err(_) => continue,
334 };
335
336 if !ft.is_file() {
338 conflicts.push(ConflictInfo {
339 relative_path: relative,
340 target_desc: format!("<non-regular: {:?}>", ft),
341 managed_desc: String::new(),
342 });
343 continue;
344 }
345
346 let managed_path = managed_subdir.join(&relative);
347
348 if !managed_path.exists() {
349 files_to_move.push(relative);
351 } else if managed_path.is_file() {
352 let target_hash = hash_file(entry.path());
354 let managed_hash = hash_file(&managed_path);
355 match (target_hash, managed_hash) {
356 (Some(th), Some(mh)) if th == mh => {
357 }
359 (Some(th), Some(mh)) => {
360 conflicts.push(ConflictInfo {
361 relative_path: relative,
362 target_desc: th,
363 managed_desc: mh,
364 });
365 }
366 (th, mh) => {
367 conflicts.push(ConflictInfo {
369 relative_path: relative,
370 target_desc: th.unwrap_or_else(|| "unreadable".to_string()),
371 managed_desc: mh.unwrap_or_else(|| "unreadable".to_string()),
372 });
373 }
374 }
375 } else {
376 conflicts.push(ConflictInfo {
378 relative_path: relative,
379 target_desc: "file".to_string(),
380 managed_desc: "directory".to_string(),
381 });
382 }
383 }
384
385 if !conflicts.is_empty() {
386 ScanResult::ConflictedDir { conflicts }
387 } else {
388 ScanResult::MergeableDir { files_to_move }
389 }
390}
391
392fn hash_file(path: &Path) -> Option<String> {
395 std::fs::read(path).ok().map(|bytes| hash::hash_bytes(&bytes))
396}
397
398fn merge_and_link(
402 link_path: &Path,
403 link_target: &Path,
404 managed_subdir: &Path,
405 files_to_move: &[PathBuf],
406) -> Result<(), MarsError> {
407 for relative in files_to_move {
409 let src = link_path.join(relative);
410 let dst = managed_subdir.join(relative);
411
412 if let Some(parent) = dst.parent() {
414 std::fs::create_dir_all(parent)?;
415 }
416
417 std::fs::copy(&src, &dst).map_err(|e| MarsError::Link {
418 target: link_path.display().to_string(),
419 message: format!("failed to copy {}: {e}", relative.display()),
420 })?;
421 std::fs::remove_file(&src)?;
422 }
423
424 remove_dir_contents_and_tree(link_path)?;
427
428 create_symlink(link_path, link_target)
430}
431
432fn remove_dir_contents_and_tree(dir: &Path) -> Result<(), MarsError> {
435 for entry in walkdir::WalkDir::new(dir)
437 .into_iter()
438 .filter_map(|e| e.ok())
439 .filter(|e| e.file_type().is_file())
440 {
441 std::fs::remove_file(entry.path())?;
442 }
443
444 let mut dirs: Vec<_> = walkdir::WalkDir::new(dir)
446 .into_iter()
447 .filter_map(|e| e.ok())
448 .filter(|e| e.file_type().is_dir())
449 .map(|e| e.into_path())
450 .collect();
451 dirs.sort_by(|a, b| b.cmp(a)); for d in dirs {
454 let _ = std::fs::remove_dir(&d);
456 }
457
458 Ok(())
459}
460
461fn create_symlink(link_path: &Path, link_target: &Path) -> Result<(), MarsError> {
463 #[cfg(unix)]
464 {
465 std::os::unix::fs::symlink(link_target, link_path).map_err(|e| MarsError::Link {
466 target: link_path.display().to_string(),
467 message: format!(
468 "failed to create symlink {} -> {}: {e}",
469 link_path.display(),
470 link_target.display()
471 ),
472 })?;
473 Ok(())
474 }
475
476 #[cfg(not(unix))]
477 {
478 let _ = (link_path, link_target);
479 Err(MarsError::Link {
480 target: String::new(),
481 message: "symlinks are only supported on Unix".to_string(),
482 })
483 }
484}
485
486fn unlink(
491 ctx: &super::MarsContext,
492 target_name: &str,
493 target_dir: &Path,
494 json: bool,
495) -> Result<i32, MarsError> {
496 let mut removed = 0;
497
498 for subdir in ["agents", "skills"] {
499 let link_path = target_dir.join(subdir);
500
501 if let Ok(link_target) = link_path.read_link() {
502 let resolved = target_dir.join(&link_target);
504 let expected = ctx.managed_root.join(subdir);
505
506 let matches = match (resolved.canonicalize(), expected.canonicalize()) {
508 (Ok(a), Ok(b)) => a == b,
509 _ => false,
510 };
511
512 if matches {
513 std::fs::remove_file(&link_path)?;
514 removed += 1;
515 } else if !json {
516 output::print_warn(&format!(
517 "{target_name}/{subdir} is a symlink to {} (not this mars root) — skipping",
518 link_target.display()
519 ));
520 }
521 }
522 }
523
524 crate::sync::mutate_link_config(
526 &ctx.managed_root,
527 &crate::sync::LinkMutation::Clear {
528 target: target_name.to_string(),
529 },
530 )?;
531
532 if json {
533 output::print_json(&serde_json::json!({
534 "ok": true,
535 "removed": removed,
536 }));
537 } else if removed > 0 {
538 output::print_success(&format!("removed {removed} symlink(s) from {target_name}"));
539 } else {
540 output::print_info("no symlinks to remove");
541 }
542
543 Ok(0)
544}
545
546fn normalize_link_target(target: &str) -> Result<String, MarsError> {
550 let normalized = target.trim_end_matches('/').trim_end_matches('\\');
551 if normalized.contains('/') || normalized.contains('\\') {
552 return Err(MarsError::Link {
553 target: target.to_string(),
554 message: "link target must be a directory name, not a path".to_string(),
555 });
556 }
557 if normalized.is_empty() || normalized == "." || normalized == ".." {
558 return Err(MarsError::Link {
559 target: target.to_string(),
560 message: "invalid link target name".to_string(),
561 });
562 }
563 Ok(normalized.to_string())
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use tempfile::TempDir;
570
571 #[test]
572 fn normalize_strips_trailing_slash() {
573 assert_eq!(normalize_link_target(".claude/").unwrap(), ".claude");
574 }
575
576 #[test]
577 fn normalize_rejects_path() {
578 assert!(normalize_link_target("foo/bar").is_err());
579 }
580
581 #[test]
582 fn normalize_rejects_empty() {
583 assert!(normalize_link_target("").is_err());
584 }
585
586 #[test]
587 fn normalize_rejects_dots() {
588 assert!(normalize_link_target(".").is_err());
589 assert!(normalize_link_target("..").is_err());
590 }
591
592 #[test]
593 fn scan_empty_returns_empty() {
594 let dir = TempDir::new().unwrap();
595 let link_path = dir.path().join("agents");
596 let managed = dir.path().join("managed");
597 std::fs::create_dir_all(&managed).unwrap();
598 let result = scan_link_target(&link_path, &managed);
600 assert!(matches!(result, ScanResult::Empty));
601 }
602
603 #[test]
604 fn scan_symlink_to_correct_target_returns_already_linked() {
605 let dir = TempDir::new().unwrap();
606 let managed = dir.path().join("managed");
607 std::fs::create_dir_all(&managed).unwrap();
608
609 let target_dir = dir.path().join("target");
610 std::fs::create_dir_all(&target_dir).unwrap();
611
612 let link_path = target_dir.join("agents");
613 #[cfg(unix)]
614 std::os::unix::fs::symlink(&managed, &link_path).unwrap();
615
616 let result = scan_link_target(&link_path, &managed);
617 assert!(matches!(result, ScanResult::AlreadyLinked));
618 }
619
620 #[test]
621 fn scan_symlink_to_wrong_target_returns_foreign() {
622 let dir = TempDir::new().unwrap();
623 let managed = dir.path().join("managed");
624 std::fs::create_dir_all(&managed).unwrap();
625
626 let other = dir.path().join("other");
627 std::fs::create_dir_all(&other).unwrap();
628
629 let target_dir = dir.path().join("target");
630 std::fs::create_dir_all(&target_dir).unwrap();
631
632 let link_path = target_dir.join("agents");
633 #[cfg(unix)]
634 std::os::unix::fs::symlink(&other, &link_path).unwrap();
635
636 let result = scan_link_target(&link_path, &managed);
637 assert!(matches!(result, ScanResult::ForeignSymlink { .. }));
638 }
639
640 #[test]
641 fn scan_dir_with_unique_files_returns_mergeable() {
642 let dir = TempDir::new().unwrap();
643 let managed = dir.path().join("managed");
644 std::fs::create_dir_all(&managed).unwrap();
645
646 let target_sub = dir.path().join("target_sub");
647 std::fs::create_dir_all(&target_sub).unwrap();
648 std::fs::write(target_sub.join("unique.md"), "unique content").unwrap();
649
650 let result = scan_dir_recursive(&target_sub, &managed);
651 match result {
652 ScanResult::MergeableDir { files_to_move } => {
653 assert_eq!(files_to_move.len(), 1);
654 assert_eq!(files_to_move[0], PathBuf::from("unique.md"));
655 }
656 _ => panic!("expected MergeableDir"),
657 }
658 }
659
660 #[test]
661 fn scan_dir_with_identical_files_returns_mergeable_empty() {
662 let dir = TempDir::new().unwrap();
663 let managed = dir.path().join("managed");
664 std::fs::create_dir_all(&managed).unwrap();
665 std::fs::write(managed.join("same.md"), "content").unwrap();
666
667 let target_sub = dir.path().join("target_sub");
668 std::fs::create_dir_all(&target_sub).unwrap();
669 std::fs::write(target_sub.join("same.md"), "content").unwrap();
670
671 let result = scan_dir_recursive(&target_sub, &managed);
672 match result {
673 ScanResult::MergeableDir { files_to_move } => {
674 assert!(files_to_move.is_empty());
675 }
676 _ => panic!("expected MergeableDir with empty files_to_move"),
677 }
678 }
679
680 #[test]
681 fn scan_dir_with_conflicting_files_returns_conflicted() {
682 let dir = TempDir::new().unwrap();
683 let managed = dir.path().join("managed");
684 std::fs::create_dir_all(&managed).unwrap();
685 std::fs::write(managed.join("conflict.md"), "managed version").unwrap();
686
687 let target_sub = dir.path().join("target_sub");
688 std::fs::create_dir_all(&target_sub).unwrap();
689 std::fs::write(target_sub.join("conflict.md"), "target version").unwrap();
690
691 let result = scan_dir_recursive(&target_sub, &managed);
692 match result {
693 ScanResult::ConflictedDir { conflicts } => {
694 assert_eq!(conflicts.len(), 1);
695 assert_eq!(conflicts[0].relative_path, PathBuf::from("conflict.md"));
696 }
697 _ => panic!("expected ConflictedDir"),
698 }
699 }
700
701 #[test]
702 fn scan_dir_recursive_handles_nested() {
703 let dir = TempDir::new().unwrap();
704 let managed = dir.path().join("managed");
705 std::fs::create_dir_all(managed.join("sub")).unwrap();
706 std::fs::write(managed.join("sub").join("existing.md"), "managed").unwrap();
707
708 let target_sub = dir.path().join("target_sub");
709 std::fs::create_dir_all(target_sub.join("sub")).unwrap();
710 std::fs::write(target_sub.join("sub").join("existing.md"), "different").unwrap();
711 std::fs::write(target_sub.join("sub").join("unique.md"), "unique").unwrap();
712
713 let result = scan_dir_recursive(&target_sub, &managed);
714 match result {
715 ScanResult::ConflictedDir { conflicts } => {
716 assert_eq!(conflicts.len(), 1);
717 assert_eq!(
718 conflicts[0].relative_path,
719 PathBuf::from("sub/existing.md")
720 );
721 }
722 _ => panic!("expected ConflictedDir"),
723 }
724 }
725
726 #[test]
727 fn merge_and_link_moves_files_and_creates_symlink() {
728 let dir = TempDir::new().unwrap();
729 let managed = dir.path().join("managed");
730 std::fs::create_dir_all(&managed).unwrap();
731
732 let target_dir = dir.path().join("target");
733 let target_sub = target_dir.join("agents");
734 std::fs::create_dir_all(&target_sub).unwrap();
735 std::fs::write(target_sub.join("unique.md"), "content").unwrap();
736
737 let link_target = PathBuf::from("../managed");
738 let files = vec![PathBuf::from("unique.md")];
739
740 merge_and_link(&target_sub, &link_target, &managed, &files).unwrap();
741
742 assert!(managed.join("unique.md").exists());
744 assert!(target_sub.symlink_metadata().unwrap().file_type().is_symlink());
746 }
747
748 #[test]
749 fn scan_dir_recursive_skips_symlinks() {
750 let dir = TempDir::new().unwrap();
751 let target_sub = dir.path().join("target").join("agents");
752 let managed = dir.path().join("managed").join("agents");
753 std::fs::create_dir_all(&target_sub).unwrap();
754 std::fs::create_dir_all(&managed).unwrap();
755
756 std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
758
759 std::os::unix::fs::symlink(
761 target_sub.join("real.md"),
762 target_sub.join("linked.md"),
763 ).unwrap();
764
765 let result = scan_dir_recursive(&target_sub, &managed);
766 match result {
767 ScanResult::MergeableDir { files_to_move } => {
768 assert_eq!(files_to_move.len(), 1);
770 assert_eq!(files_to_move[0], PathBuf::from("real.md"));
771 }
772 _ => panic!("expected MergeableDir, got {:?}", std::mem::discriminant(&result)),
773 }
774 }
775
776 #[test]
777 fn remove_dir_contents_and_tree_cleans_up() {
778 let dir = TempDir::new().unwrap();
779 let target = dir.path().join("target");
780 std::fs::create_dir_all(target.join("sub")).unwrap();
781 std::fs::write(target.join("a.md"), "a").unwrap();
782 std::fs::write(target.join("sub").join("b.md"), "b").unwrap();
783
784 remove_dir_contents_and_tree(&target).unwrap();
785
786 assert!(!target.exists());
787 }
788}