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.managed_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.managed_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 lock_path = ctx.managed_root.join(".mars").join("sync.lock");
111 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
112
113 std::fs::create_dir_all(&target_dir)?;
115
116 for subdir in ["agents", "skills"] {
118 let source = ctx.managed_root.join(subdir);
119 if !source.exists() {
120 std::fs::create_dir_all(&source)?;
121 }
122 }
123
124 let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
126 .unwrap_or_else(|| ctx.managed_root.clone());
127
128 let mut scan_results = Vec::new();
130 let mut all_conflicts: Vec<(&str, ConflictInfo)> = Vec::new();
131 let mut has_foreign = false;
132 let mut foreign_details: Vec<(&str, PathBuf)> = Vec::new();
133
134 for subdir in ["agents", "skills"] {
135 let link_path = target_dir.join(subdir);
136 let link_target = rel_root.join(subdir);
137 let managed_subdir = ctx.managed_root.join(subdir);
138
139 let result = scan_link_target(&link_path, &managed_subdir);
140 match &result {
141 ScanResult::ConflictedDir { conflicts } => {
142 for c in conflicts {
143 all_conflicts.push((subdir, c.clone()));
144 }
145 }
146 ScanResult::ForeignSymlink { target } => {
147 has_foreign = true;
148 foreign_details.push((subdir, target.clone()));
149 }
150 _ => {}
151 }
152 scan_results.push((subdir, link_path, link_target, result));
153 }
154
155 if !args.force && (!all_conflicts.is_empty() || has_foreign) {
157 if json {
158 let conflict_json: Vec<_> = all_conflicts
159 .iter()
160 .map(|(subdir, c)| {
161 serde_json::json!({
162 "path": format!("{}/{}", subdir, c.relative_path.display()),
163 "target_desc": c.target_desc,
164 "managed_desc": c.managed_desc,
165 })
166 })
167 .collect();
168 output::print_json(&serde_json::json!({
169 "ok": false,
170 "error": "conflicts found",
171 "conflicts": conflict_json,
172 }));
173 } else {
174 let total = all_conflicts.len() + foreign_details.len();
175 eprintln!("error: cannot link {target_name} — {total} conflict(s) found:\n");
176 for (subdir, info) in &all_conflicts {
177 eprintln!(" {subdir}/{}", info.relative_path.display());
178 eprintln!(
179 " {target_name}/{subdir}/{} ({})",
180 info.relative_path.display(),
181 info.target_desc
182 );
183 eprintln!(
184 " {}/{subdir}/{} ({})\n",
185 ctx.managed_root
186 .file_name()
187 .unwrap_or_default()
188 .to_string_lossy(),
189 info.relative_path.display(),
190 info.managed_desc
191 );
192 }
193 for (subdir, foreign_target) in &foreign_details {
194 eprintln!(
195 " {target_name}/{subdir} is a symlink to {} (not this mars root)\n",
196 foreign_target.display()
197 );
198 }
199 eprintln!("hint: resolve conflicts manually, then retry `mars link {target_name}`");
200 eprintln!(
201 "hint: or use `mars link {target_name} --force` to replace with symlinks (data loss)"
202 );
203 }
204 return Err(MarsError::Link {
205 target: target_name,
206 message: "conflicts found — resolve manually or use --force".to_string(),
207 });
208 }
209
210 let mut linked = 0;
212 for (subdir, link_path, link_target, result) in scan_results {
213 match result {
214 ScanResult::Empty => {
215 create_symlink(&link_path, &link_target)?;
216 linked += 1;
217 }
218 ScanResult::AlreadyLinked => {
219 if !json {
220 output::print_info(&format!("{target_name}/{subdir} already linked"));
221 }
222 }
223 ScanResult::MergeableDir { files_to_move } => {
224 let managed_subdir = ctx.managed_root.join(subdir);
225 merge_and_link(&link_path, &link_target, &managed_subdir, &files_to_move)?;
226 linked += 1;
227 if !json && !files_to_move.is_empty() {
228 output::print_info(&format!(
229 "merged {} file(s) from {target_name}/{subdir} into managed root",
230 files_to_move.len()
231 ));
232 }
233 }
234 ScanResult::ForeignSymlink { .. } | ScanResult::ConflictedDir { .. } => {
235 if link_path.symlink_metadata().is_ok() {
237 if link_path.read_link().is_ok() {
238 std::fs::remove_file(&link_path)?;
239 } else {
240 std::fs::remove_dir_all(&link_path)?;
241 }
242 }
243 create_symlink(&link_path, &link_target)?;
244 linked += 1;
245 }
246 }
247 }
248
249 let mut config = crate::config::load(&ctx.managed_root)?;
251 if !config.settings.links.contains(&target_name) {
252 config.settings.links.push(target_name.clone());
253 crate::config::save(&ctx.managed_root, &config)?;
254 }
255
256 if json {
258 output::print_json(&serde_json::json!({
259 "ok": true,
260 "target": target_dir.to_string_lossy(),
261 "linked": linked,
262 }));
263 } else if linked > 0 {
264 output::print_success(&format!("linked agents/ and skills/ into {target_name}"));
265 } else {
266 output::print_info(&format!("{target_name} already fully linked"));
267 }
268
269 Ok(0)
270}
271
272fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
276 if link_path.symlink_metadata().is_err() {
278 return ScanResult::Empty;
279 }
280
281 if let Ok(actual_target) = link_path.read_link() {
283 let actual_resolved = link_path
286 .parent()
287 .map(|p| p.join(&actual_target))
288 .and_then(|p| p.canonicalize().ok());
289 let expected_resolved = managed_subdir.canonicalize().ok();
290
291 match (actual_resolved, expected_resolved) {
292 (Some(a), Some(b)) if a == b => return ScanResult::AlreadyLinked,
293 _ => {
294 return ScanResult::ForeignSymlink {
295 target: actual_target,
296 };
297 }
298 }
299 }
300
301 scan_dir_recursive(link_path, managed_subdir)
303}
304
305fn scan_dir_recursive(target_subdir: &Path, managed_subdir: &Path) -> ScanResult {
307 let mut files_to_move = Vec::new();
308 let mut conflicts = Vec::new();
309
310 for entry in walkdir::WalkDir::new(target_subdir)
312 .follow_links(false)
313 .into_iter()
314 .filter_map(|e| e.ok())
315 {
316 let ft = entry.file_type();
317 if ft.is_dir() {
318 continue; }
320 if ft.is_symlink() {
321 continue;
325 }
326
327 let relative = match entry.path().strip_prefix(target_subdir) {
328 Ok(r) => r.to_path_buf(),
329 Err(_) => continue,
330 };
331
332 if !ft.is_file() {
334 conflicts.push(ConflictInfo {
335 relative_path: relative,
336 target_desc: format!("<non-regular: {:?}>", ft),
337 managed_desc: String::new(),
338 });
339 continue;
340 }
341
342 let managed_path = managed_subdir.join(&relative);
343
344 if !managed_path.exists() {
345 files_to_move.push(relative);
347 } else if managed_path.is_file() {
348 let target_hash = hash_file(entry.path());
350 let managed_hash = hash_file(&managed_path);
351 match (target_hash, managed_hash) {
352 (Some(th), Some(mh)) if th == mh => {
353 }
355 (Some(th), Some(mh)) => {
356 conflicts.push(ConflictInfo {
357 relative_path: relative,
358 target_desc: th,
359 managed_desc: mh,
360 });
361 }
362 (th, mh) => {
363 conflicts.push(ConflictInfo {
365 relative_path: relative,
366 target_desc: th.unwrap_or_else(|| "unreadable".to_string()),
367 managed_desc: mh.unwrap_or_else(|| "unreadable".to_string()),
368 });
369 }
370 }
371 } else {
372 conflicts.push(ConflictInfo {
374 relative_path: relative,
375 target_desc: "file".to_string(),
376 managed_desc: "directory".to_string(),
377 });
378 }
379 }
380
381 if !conflicts.is_empty() {
382 ScanResult::ConflictedDir { conflicts }
383 } else {
384 ScanResult::MergeableDir { files_to_move }
385 }
386}
387
388fn hash_file(path: &Path) -> Option<String> {
391 std::fs::read(path)
392 .ok()
393 .map(|bytes| hash::hash_bytes(&bytes))
394}
395
396fn merge_and_link(
400 link_path: &Path,
401 link_target: &Path,
402 managed_subdir: &Path,
403 files_to_move: &[PathBuf],
404) -> Result<(), MarsError> {
405 for relative in files_to_move {
407 let src = link_path.join(relative);
408 let dst = managed_subdir.join(relative);
409
410 if let Some(parent) = dst.parent() {
412 std::fs::create_dir_all(parent)?;
413 }
414
415 std::fs::copy(&src, &dst).map_err(|e| MarsError::Link {
416 target: link_path.display().to_string(),
417 message: format!("failed to copy {}: {e}", relative.display()),
418 })?;
419 std::fs::remove_file(&src)?;
420 }
421
422 remove_dir_contents_and_tree(link_path)?;
425
426 create_symlink(link_path, link_target)
428}
429
430fn remove_dir_contents_and_tree(dir: &Path) -> Result<(), MarsError> {
433 for entry in walkdir::WalkDir::new(dir)
435 .into_iter()
436 .filter_map(|e| e.ok())
437 .filter(|e| e.file_type().is_file())
438 {
439 std::fs::remove_file(entry.path())?;
440 }
441
442 let mut dirs: Vec<_> = walkdir::WalkDir::new(dir)
444 .into_iter()
445 .filter_map(|e| e.ok())
446 .filter(|e| e.file_type().is_dir())
447 .map(|e| e.into_path())
448 .collect();
449 dirs.sort_by(|a, b| b.cmp(a)); for d in dirs {
452 let _ = std::fs::remove_dir(&d);
454 }
455
456 Ok(())
457}
458
459fn create_symlink(link_path: &Path, link_target: &Path) -> Result<(), MarsError> {
461 #[cfg(unix)]
462 {
463 std::os::unix::fs::symlink(link_target, link_path).map_err(|e| MarsError::Link {
464 target: link_path.display().to_string(),
465 message: format!(
466 "failed to create symlink {} -> {}: {e}",
467 link_path.display(),
468 link_target.display()
469 ),
470 })?;
471 Ok(())
472 }
473
474 #[cfg(not(unix))]
475 {
476 let _ = (link_path, link_target);
477 Err(MarsError::Link {
478 target: String::new(),
479 message: "symlinks are only supported on Unix".to_string(),
480 })
481 }
482}
483
484fn unlink(
489 ctx: &super::MarsContext,
490 target_name: &str,
491 target_dir: &Path,
492 json: bool,
493) -> Result<i32, MarsError> {
494 let mut removed = 0;
495
496 for subdir in ["agents", "skills"] {
497 let link_path = target_dir.join(subdir);
498
499 if let Ok(link_target) = link_path.read_link() {
500 let resolved = target_dir.join(&link_target);
502 let expected = ctx.managed_root.join(subdir);
503
504 let matches = match (resolved.canonicalize(), expected.canonicalize()) {
506 (Ok(a), Ok(b)) => a == b,
507 _ => false,
508 };
509
510 if matches {
511 std::fs::remove_file(&link_path)?;
512 removed += 1;
513 } else if !json {
514 output::print_warn(&format!(
515 "{target_name}/{subdir} is a symlink to {} (not this mars root) — skipping",
516 link_target.display()
517 ));
518 }
519 }
520 }
521
522 crate::sync::mutate_link_config(
524 &ctx.managed_root,
525 &crate::sync::LinkMutation::Clear {
526 target: target_name.to_string(),
527 },
528 )?;
529
530 if json {
531 output::print_json(&serde_json::json!({
532 "ok": true,
533 "removed": removed,
534 }));
535 } else if removed > 0 {
536 output::print_success(&format!("removed {removed} symlink(s) from {target_name}"));
537 } else {
538 output::print_info("no symlinks to remove");
539 }
540
541 Ok(0)
542}
543
544fn normalize_link_target(target: &str) -> Result<String, MarsError> {
548 let normalized = target.trim_end_matches('/').trim_end_matches('\\');
549 if normalized.contains('/') || normalized.contains('\\') {
550 return Err(MarsError::Link {
551 target: target.to_string(),
552 message: "link target must be a directory name, not a path".to_string(),
553 });
554 }
555 if normalized.is_empty() || normalized == "." || normalized == ".." {
556 return Err(MarsError::Link {
557 target: target.to_string(),
558 message: "invalid link target name".to_string(),
559 });
560 }
561 Ok(normalized.to_string())
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use tempfile::TempDir;
568
569 #[test]
570 fn normalize_strips_trailing_slash() {
571 assert_eq!(normalize_link_target(".claude/").unwrap(), ".claude");
572 }
573
574 #[test]
575 fn normalize_rejects_path() {
576 assert!(normalize_link_target("foo/bar").is_err());
577 }
578
579 #[test]
580 fn normalize_rejects_empty() {
581 assert!(normalize_link_target("").is_err());
582 }
583
584 #[test]
585 fn normalize_rejects_dots() {
586 assert!(normalize_link_target(".").is_err());
587 assert!(normalize_link_target("..").is_err());
588 }
589
590 #[test]
591 fn scan_empty_returns_empty() {
592 let dir = TempDir::new().unwrap();
593 let link_path = dir.path().join("agents");
594 let managed = dir.path().join("managed");
595 std::fs::create_dir_all(&managed).unwrap();
596 let result = scan_link_target(&link_path, &managed);
598 assert!(matches!(result, ScanResult::Empty));
599 }
600
601 #[test]
602 fn scan_symlink_to_correct_target_returns_already_linked() {
603 let dir = TempDir::new().unwrap();
604 let managed = dir.path().join("managed");
605 std::fs::create_dir_all(&managed).unwrap();
606
607 let target_dir = dir.path().join("target");
608 std::fs::create_dir_all(&target_dir).unwrap();
609
610 let link_path = target_dir.join("agents");
611 #[cfg(unix)]
612 std::os::unix::fs::symlink(&managed, &link_path).unwrap();
613
614 let result = scan_link_target(&link_path, &managed);
615 assert!(matches!(result, ScanResult::AlreadyLinked));
616 }
617
618 #[test]
619 fn scan_symlink_to_wrong_target_returns_foreign() {
620 let dir = TempDir::new().unwrap();
621 let managed = dir.path().join("managed");
622 std::fs::create_dir_all(&managed).unwrap();
623
624 let other = dir.path().join("other");
625 std::fs::create_dir_all(&other).unwrap();
626
627 let target_dir = dir.path().join("target");
628 std::fs::create_dir_all(&target_dir).unwrap();
629
630 let link_path = target_dir.join("agents");
631 #[cfg(unix)]
632 std::os::unix::fs::symlink(&other, &link_path).unwrap();
633
634 let result = scan_link_target(&link_path, &managed);
635 assert!(matches!(result, ScanResult::ForeignSymlink { .. }));
636 }
637
638 #[test]
639 fn scan_dir_with_unique_files_returns_mergeable() {
640 let dir = TempDir::new().unwrap();
641 let managed = dir.path().join("managed");
642 std::fs::create_dir_all(&managed).unwrap();
643
644 let target_sub = dir.path().join("target_sub");
645 std::fs::create_dir_all(&target_sub).unwrap();
646 std::fs::write(target_sub.join("unique.md"), "unique content").unwrap();
647
648 let result = scan_dir_recursive(&target_sub, &managed);
649 match result {
650 ScanResult::MergeableDir { files_to_move } => {
651 assert_eq!(files_to_move.len(), 1);
652 assert_eq!(files_to_move[0], PathBuf::from("unique.md"));
653 }
654 _ => panic!("expected MergeableDir"),
655 }
656 }
657
658 #[test]
659 fn scan_dir_with_identical_files_returns_mergeable_empty() {
660 let dir = TempDir::new().unwrap();
661 let managed = dir.path().join("managed");
662 std::fs::create_dir_all(&managed).unwrap();
663 std::fs::write(managed.join("same.md"), "content").unwrap();
664
665 let target_sub = dir.path().join("target_sub");
666 std::fs::create_dir_all(&target_sub).unwrap();
667 std::fs::write(target_sub.join("same.md"), "content").unwrap();
668
669 let result = scan_dir_recursive(&target_sub, &managed);
670 match result {
671 ScanResult::MergeableDir { files_to_move } => {
672 assert!(files_to_move.is_empty());
673 }
674 _ => panic!("expected MergeableDir with empty files_to_move"),
675 }
676 }
677
678 #[test]
679 fn scan_dir_with_conflicting_files_returns_conflicted() {
680 let dir = TempDir::new().unwrap();
681 let managed = dir.path().join("managed");
682 std::fs::create_dir_all(&managed).unwrap();
683 std::fs::write(managed.join("conflict.md"), "managed version").unwrap();
684
685 let target_sub = dir.path().join("target_sub");
686 std::fs::create_dir_all(&target_sub).unwrap();
687 std::fs::write(target_sub.join("conflict.md"), "target version").unwrap();
688
689 let result = scan_dir_recursive(&target_sub, &managed);
690 match result {
691 ScanResult::ConflictedDir { conflicts } => {
692 assert_eq!(conflicts.len(), 1);
693 assert_eq!(conflicts[0].relative_path, PathBuf::from("conflict.md"));
694 }
695 _ => panic!("expected ConflictedDir"),
696 }
697 }
698
699 #[test]
700 fn scan_dir_recursive_handles_nested() {
701 let dir = TempDir::new().unwrap();
702 let managed = dir.path().join("managed");
703 std::fs::create_dir_all(managed.join("sub")).unwrap();
704 std::fs::write(managed.join("sub").join("existing.md"), "managed").unwrap();
705
706 let target_sub = dir.path().join("target_sub");
707 std::fs::create_dir_all(target_sub.join("sub")).unwrap();
708 std::fs::write(target_sub.join("sub").join("existing.md"), "different").unwrap();
709 std::fs::write(target_sub.join("sub").join("unique.md"), "unique").unwrap();
710
711 let result = scan_dir_recursive(&target_sub, &managed);
712 match result {
713 ScanResult::ConflictedDir { conflicts } => {
714 assert_eq!(conflicts.len(), 1);
715 assert_eq!(conflicts[0].relative_path, PathBuf::from("sub/existing.md"));
716 }
717 _ => panic!("expected ConflictedDir"),
718 }
719 }
720
721 #[test]
722 fn merge_and_link_moves_files_and_creates_symlink() {
723 let dir = TempDir::new().unwrap();
724 let managed = dir.path().join("managed");
725 std::fs::create_dir_all(&managed).unwrap();
726
727 let target_dir = dir.path().join("target");
728 let target_sub = target_dir.join("agents");
729 std::fs::create_dir_all(&target_sub).unwrap();
730 std::fs::write(target_sub.join("unique.md"), "content").unwrap();
731
732 let link_target = PathBuf::from("../managed");
733 let files = vec![PathBuf::from("unique.md")];
734
735 merge_and_link(&target_sub, &link_target, &managed, &files).unwrap();
736
737 assert!(managed.join("unique.md").exists());
739 assert!(
741 target_sub
742 .symlink_metadata()
743 .unwrap()
744 .file_type()
745 .is_symlink()
746 );
747 }
748
749 #[test]
750 fn scan_dir_recursive_skips_symlinks() {
751 let dir = TempDir::new().unwrap();
752 let target_sub = dir.path().join("target").join("agents");
753 let managed = dir.path().join("managed").join("agents");
754 std::fs::create_dir_all(&target_sub).unwrap();
755 std::fs::create_dir_all(&managed).unwrap();
756
757 std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
759
760 std::os::unix::fs::symlink(target_sub.join("real.md"), target_sub.join("linked.md"))
762 .unwrap();
763
764 let result = scan_dir_recursive(&target_sub, &managed);
765 match result {
766 ScanResult::MergeableDir { files_to_move } => {
767 assert_eq!(files_to_move.len(), 1);
769 assert_eq!(files_to_move[0], PathBuf::from("real.md"));
770 }
771 _ => panic!(
772 "expected MergeableDir, got {:?}",
773 std::mem::discriminant(&result)
774 ),
775 }
776 }
777
778 #[test]
779 fn remove_dir_contents_and_tree_cleans_up() {
780 let dir = TempDir::new().unwrap();
781 let target = dir.path().join("target");
782 std::fs::create_dir_all(target.join("sub")).unwrap();
783 std::fs::write(target.join("a.md"), "a").unwrap();
784 std::fs::write(target.join("sub").join("b.md"), "b").unwrap();
785
786 remove_dir_contents_and_tree(&target).unwrap();
787
788 assert!(!target.exists());
789 }
790}