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
75 .canonicalize()
76 .or_else(|_| Ok::<_, std::io::Error>(target_dir.clone())),
77 ctx.managed_root.canonicalize(),
78 ) && target_canon == root_canon
79 {
80 return Err(MarsError::Link {
81 target: target_name,
82 message: "cannot link the managed root to itself".to_string(),
83 });
84 }
85
86 let config_path = ctx.project_root.join("mars.toml");
88 if !config_path.exists() {
89 return Err(MarsError::Link {
90 target: target_name,
91 message: format!(
92 "mars.toml not found at {} — run `mars init` first",
93 ctx.project_root.display()
94 ),
95 });
96 }
97
98 if !json
100 && !super::WELL_KNOWN.contains(&target_name.as_str())
101 && !super::TOOL_DIRS.contains(&target_name.as_str())
102 {
103 output::print_warn(&format!(
104 "`{target_name}` is not a recognized tool directory — linking anyway"
105 ));
106 }
107
108 let mars_dir = ctx.project_root.join(".mars");
111 std::fs::create_dir_all(&mars_dir)?;
112 let lock_path = mars_dir.join("sync.lock");
113 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
114
115 std::fs::create_dir_all(&target_dir)?;
117
118 for subdir in ["agents", "skills"] {
120 let source = ctx.managed_root.join(subdir);
121 if !source.exists() {
122 std::fs::create_dir_all(&source)?;
123 }
124 }
125
126 let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
128 .unwrap_or_else(|| ctx.managed_root.clone());
129
130 let mut scan_results = Vec::new();
132 let mut all_conflicts: Vec<(&str, ConflictInfo)> = Vec::new();
133 let mut has_foreign = false;
134 let mut foreign_details: Vec<(&str, PathBuf)> = Vec::new();
135
136 for subdir in ["agents", "skills"] {
137 let link_path = target_dir.join(subdir);
138 let link_target = rel_root.join(subdir);
139 let managed_subdir = ctx.managed_root.join(subdir);
140
141 let result = scan_link_target(&link_path, &managed_subdir);
142 match &result {
143 ScanResult::ConflictedDir { conflicts } => {
144 for c in conflicts {
145 all_conflicts.push((subdir, c.clone()));
146 }
147 }
148 ScanResult::ForeignSymlink { target } => {
149 has_foreign = true;
150 foreign_details.push((subdir, target.clone()));
151 }
152 _ => {}
153 }
154 scan_results.push((subdir, link_path, link_target, result));
155 }
156
157 if !args.force && (!all_conflicts.is_empty() || has_foreign) {
159 if json {
160 let conflict_json: Vec<_> = all_conflicts
161 .iter()
162 .map(|(subdir, c)| {
163 serde_json::json!({
164 "path": format!("{}/{}", subdir, c.relative_path.display()),
165 "target_desc": c.target_desc,
166 "managed_desc": c.managed_desc,
167 })
168 })
169 .collect();
170 output::print_json(&serde_json::json!({
171 "ok": false,
172 "error": "conflicts found",
173 "conflicts": conflict_json,
174 }));
175 } else {
176 let total = all_conflicts.len() + foreign_details.len();
177 eprintln!("error: cannot link {target_name} — {total} conflict(s) found:\n");
178 for (subdir, info) in &all_conflicts {
179 eprintln!(" {subdir}/{}", info.relative_path.display());
180 eprintln!(
181 " {target_name}/{subdir}/{} ({})",
182 info.relative_path.display(),
183 info.target_desc
184 );
185 eprintln!(
186 " {}/{subdir}/{} ({})\n",
187 ctx.managed_root
188 .file_name()
189 .unwrap_or_default()
190 .to_string_lossy(),
191 info.relative_path.display(),
192 info.managed_desc
193 );
194 }
195 for (subdir, foreign_target) in &foreign_details {
196 eprintln!(
197 " {target_name}/{subdir} is a symlink to {} (not this mars root)\n",
198 foreign_target.display()
199 );
200 }
201 eprintln!("hint: resolve conflicts manually, then retry `mars link {target_name}`");
202 eprintln!(
203 "hint: or use `mars link {target_name} --force` to replace with symlinks (data loss)"
204 );
205 }
206 return Err(MarsError::Link {
207 target: target_name,
208 message: "conflicts found — resolve manually or use --force".to_string(),
209 });
210 }
211
212 let mut linked = 0;
214 for (subdir, link_path, link_target, result) in scan_results {
215 match result {
216 ScanResult::Empty => {
217 create_symlink(&link_path, &link_target)?;
218 linked += 1;
219 }
220 ScanResult::AlreadyLinked => {
221 if !json {
222 output::print_info(&format!("{target_name}/{subdir} already linked"));
223 }
224 }
225 ScanResult::MergeableDir { files_to_move } => {
226 let managed_subdir = ctx.managed_root.join(subdir);
227 merge_and_link(&link_path, &link_target, &managed_subdir, &files_to_move)?;
228 linked += 1;
229 if !json && !files_to_move.is_empty() {
230 output::print_info(&format!(
231 "merged {} file(s) from {target_name}/{subdir} into managed root",
232 files_to_move.len()
233 ));
234 }
235 }
236 ScanResult::ForeignSymlink { .. } | ScanResult::ConflictedDir { .. } => {
237 if link_path.symlink_metadata().is_ok() {
239 if link_path.read_link().is_ok() {
240 std::fs::remove_file(&link_path)?;
241 } else {
242 std::fs::remove_dir_all(&link_path)?;
243 }
244 }
245 create_symlink(&link_path, &link_target)?;
246 linked += 1;
247 }
248 }
249 }
250
251 let mut config = crate::config::load(&ctx.project_root)?;
253 if !config.settings.links.contains(&target_name) {
254 config.settings.links.push(target_name.clone());
255 crate::config::save(&ctx.project_root, &config)?;
256 }
257
258 if json {
260 output::print_json(&serde_json::json!({
261 "ok": true,
262 "target": target_dir.to_string_lossy(),
263 "linked": linked,
264 }));
265 } else if linked > 0 {
266 output::print_success(&format!("linked agents/ and skills/ into {target_name}"));
267 } else {
268 output::print_info(&format!("{target_name} already fully linked"));
269 }
270
271 Ok(0)
272}
273
274fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
278 if link_path.symlink_metadata().is_err() {
280 return ScanResult::Empty;
281 }
282
283 if let Ok(actual_target) = link_path.read_link() {
285 let actual_resolved = link_path
288 .parent()
289 .map(|p| p.join(&actual_target))
290 .and_then(|p| p.canonicalize().ok());
291 let expected_resolved = managed_subdir.canonicalize().ok();
292
293 match (actual_resolved, expected_resolved) {
294 (Some(a), Some(b)) if a == b => return ScanResult::AlreadyLinked,
295 _ => {
296 return ScanResult::ForeignSymlink {
297 target: actual_target,
298 };
299 }
300 }
301 }
302
303 scan_dir_recursive(link_path, managed_subdir)
305}
306
307fn scan_dir_recursive(target_subdir: &Path, managed_subdir: &Path) -> ScanResult {
309 let mut files_to_move = Vec::new();
310 let mut conflicts = Vec::new();
311
312 for entry in walkdir::WalkDir::new(target_subdir)
314 .follow_links(false)
315 .into_iter()
316 .filter_map(|e| e.ok())
317 {
318 let ft = entry.file_type();
319 if ft.is_dir() {
320 continue; }
322 if ft.is_symlink() {
323 continue;
327 }
328
329 let relative = match entry.path().strip_prefix(target_subdir) {
330 Ok(r) => r.to_path_buf(),
331 Err(_) => continue,
332 };
333
334 if !ft.is_file() {
336 conflicts.push(ConflictInfo {
337 relative_path: relative,
338 target_desc: format!("<non-regular: {:?}>", ft),
339 managed_desc: String::new(),
340 });
341 continue;
342 }
343
344 let managed_path = managed_subdir.join(&relative);
345
346 if !managed_path.exists() {
347 files_to_move.push(relative);
349 } else if managed_path.is_file() {
350 let target_hash = hash_file(entry.path());
352 let managed_hash = hash_file(&managed_path);
353 match (target_hash, managed_hash) {
354 (Some(th), Some(mh)) if th == mh => {
355 }
357 (Some(th), Some(mh)) => {
358 conflicts.push(ConflictInfo {
359 relative_path: relative,
360 target_desc: th,
361 managed_desc: mh,
362 });
363 }
364 (th, mh) => {
365 conflicts.push(ConflictInfo {
367 relative_path: relative,
368 target_desc: th.unwrap_or_else(|| "unreadable".to_string()),
369 managed_desc: mh.unwrap_or_else(|| "unreadable".to_string()),
370 });
371 }
372 }
373 } else {
374 conflicts.push(ConflictInfo {
376 relative_path: relative,
377 target_desc: "file".to_string(),
378 managed_desc: "directory".to_string(),
379 });
380 }
381 }
382
383 if !conflicts.is_empty() {
384 ScanResult::ConflictedDir { conflicts }
385 } else {
386 ScanResult::MergeableDir { files_to_move }
387 }
388}
389
390fn hash_file(path: &Path) -> Option<String> {
393 std::fs::read(path)
394 .ok()
395 .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,
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!(conflicts[0].relative_path, PathBuf::from("sub/existing.md"));
718 }
719 _ => panic!("expected ConflictedDir"),
720 }
721 }
722
723 #[test]
724 fn merge_and_link_moves_files_and_creates_symlink() {
725 let dir = TempDir::new().unwrap();
726 let managed = dir.path().join("managed");
727 std::fs::create_dir_all(&managed).unwrap();
728
729 let target_dir = dir.path().join("target");
730 let target_sub = target_dir.join("agents");
731 std::fs::create_dir_all(&target_sub).unwrap();
732 std::fs::write(target_sub.join("unique.md"), "content").unwrap();
733
734 let link_target = PathBuf::from("../managed");
735 let files = vec![PathBuf::from("unique.md")];
736
737 merge_and_link(&target_sub, &link_target, &managed, &files).unwrap();
738
739 assert!(managed.join("unique.md").exists());
741 assert!(
743 target_sub
744 .symlink_metadata()
745 .unwrap()
746 .file_type()
747 .is_symlink()
748 );
749 }
750
751 #[test]
752 fn scan_dir_recursive_skips_symlinks() {
753 let dir = TempDir::new().unwrap();
754 let target_sub = dir.path().join("target").join("agents");
755 let managed = dir.path().join("managed").join("agents");
756 std::fs::create_dir_all(&target_sub).unwrap();
757 std::fs::create_dir_all(&managed).unwrap();
758
759 std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
761
762 std::os::unix::fs::symlink(target_sub.join("real.md"), target_sub.join("linked.md"))
764 .unwrap();
765
766 let result = scan_dir_recursive(&target_sub, &managed);
767 match result {
768 ScanResult::MergeableDir { files_to_move } => {
769 assert_eq!(files_to_move.len(), 1);
771 assert_eq!(files_to_move[0], PathBuf::from("real.md"));
772 }
773 _ => panic!(
774 "expected MergeableDir, got {:?}",
775 std::mem::discriminant(&result)
776 ),
777 }
778 }
779
780 #[test]
781 fn remove_dir_contents_and_tree_cleans_up() {
782 let dir = TempDir::new().unwrap();
783 let target = dir.path().join("target");
784 std::fs::create_dir_all(target.join("sub")).unwrap();
785 std::fs::write(target.join("a.md"), "a").unwrap();
786 std::fs::write(target.join("sub").join("b.md"), "b").unwrap();
787
788 remove_dir_contents_and_tree(&target).unwrap();
789
790 assert!(!target.exists());
791 }
792}