Skip to main content

mars_agents/cli/
link.rs

1//! `mars link <dir>` — symlink agents/ and skills/ into another directory.
2//!
3//! Creates `<dir>/agents -> <mars-root>/agents` and `<dir>/skills -> <mars-root>/skills`.
4//! Useful for tools that look in `.claude/`, `.cursor/`, etc. instead of `.agents/`.
5//!
6//! Uses a conflict-aware scan-then-act algorithm:
7//! - Phase 1 (scan): read-only analysis of the target directory
8//! - Phase 2 (act): filesystem mutations only if scan found no conflicts
9//!
10//! If any conflict exists, zero mutations occur. The user sees all problems at once.
11//!
12//! Persists the link in `mars.toml [settings] links` so `mars doctor` can verify it.
13
14use std::path::{Path, PathBuf};
15
16use crate::error::MarsError;
17use crate::hash;
18
19use super::output;
20
21/// Arguments for `mars link`.
22#[derive(Debug, clap::Args)]
23pub struct LinkArgs {
24    /// Target directory to create symlinks in (e.g. `.claude`).
25    pub target: String,
26
27    /// Remove symlinks instead of creating them.
28    #[arg(long)]
29    pub unlink: bool,
30
31    /// Replace whatever exists with symlinks. Data may be lost.
32    #[arg(long)]
33    pub force: bool,
34}
35
36/// Result of scanning a single subdir (agents/ or skills/) in the target.
37enum ScanResult {
38    /// Nothing at the link path — create symlink.
39    Empty,
40    /// Already a symlink pointing to our managed root.
41    AlreadyLinked,
42    /// Symlink pointing somewhere else.
43    ForeignSymlink { target: PathBuf },
44    /// Real directory with no conflicts against managed root.
45    MergeableDir { files_to_move: Vec<PathBuf> },
46    /// Real directory with conflicts (same filename, different content).
47    ConflictedDir { conflicts: Vec<ConflictInfo> },
48}
49
50/// Details about a single file conflict between target and managed root.
51#[derive(Clone)]
52struct ConflictInfo {
53    /// Relative path within the subdir (e.g. "reviewer.md").
54    relative_path: PathBuf,
55    /// Description of what exists in the target dir.
56    target_desc: String,
57    /// Description of what exists in the managed root.
58    managed_desc: String,
59}
60
61/// Run `mars link`.
62pub 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    // Reject self-link — linking the managed root to itself creates circular symlinks
73    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    // Verify config exists before any mutations (resolve-first principle)
87    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    // Warn if target isn't a well-known tool dir
99    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    // Acquire sync lock for the entire operation (scan + act + persist).
109    // Prevents races with concurrent mars sync or mars link.
110    let lock_path = ctx.managed_root.join(".mars").join("sync.lock");
111    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
112
113    // Create target directory if needed
114    std::fs::create_dir_all(&target_dir)?;
115
116    // Ensure managed subdirs exist
117    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    // Compute relative path from target dir back to mars root
125    let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
126        .unwrap_or_else(|| ctx.managed_root.clone());
127
128    // ── Phase 1: Scan all subdirs ──────────────────────────────────────────
129    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    // Check: any conflicts or foreign symlinks? (unless --force)
156    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    // ── Phase 2: Act ───────────────────────────────────────────────────────
211    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                // Only reachable with --force
236                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    // Persist link in config (already under sync lock from above).
250    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    // Output
257    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
272// ── Scan ────────────────────────────────────────────────────────────────────
273
274/// Scan a single link target (e.g. `.claude/agents/`) to determine its state.
275fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
276    // Check if anything exists at link_path
277    if link_path.symlink_metadata().is_err() {
278        return ScanResult::Empty;
279    }
280
281    // Check if it's a symlink
282    if let Ok(actual_target) = link_path.read_link() {
283        // Use canonicalize for comparison — textually different but semantically
284        // identical paths should match.
285        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    // It's a real directory — scan recursively
302    scan_dir_recursive(link_path, managed_subdir)
303}
304
305/// Recursively scan a target directory against the managed root.
306fn 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    // Walk the target directory recursively, without following symlinks
311    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; // Directories are structural, handled during cleanup
319        }
320        if ft.is_symlink() {
321            // Skip symlinks — don't follow, don't treat as conflicts.
322            // They survive the merge-and-link process since we only
323            // remove regular files.
324            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        // Non-regular files (fifos, sockets) → treat as conflict
333        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            // Unique file — can be moved
346            files_to_move.push(relative);
347        } else if managed_path.is_file() {
348            // Both exist as files — compare content
349            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                    // Identical — skip
354                }
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                    // Can't read one or both files — treat as conflict
364                    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            // Type mismatch (file in target, dir in managed or vice versa)
373            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
388/// Compute SHA-256 of a single file for comparison.
389/// Returns None if the file can't be read (permission denied, etc).
390fn hash_file(path: &Path) -> Option<String> {
391    std::fs::read(path)
392        .ok()
393        .map(|bytes| hash::hash_bytes(&bytes))
394}
395
396// ── Act ─────────────────────────────────────────────────────────────────────
397
398/// Move unique files into managed root, remove the target dir, create symlink.
399fn merge_and_link(
400    link_path: &Path,
401    link_target: &Path,
402    managed_subdir: &Path,
403    files_to_move: &[PathBuf],
404) -> Result<(), MarsError> {
405    // Move unique files into managed root (copy+delete for cross-fs safety)
406    for relative in files_to_move {
407        let src = link_path.join(relative);
408        let dst = managed_subdir.join(relative);
409
410        // Create parent dirs in managed root if needed
411        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 remaining files (identical copies we skipped during scan)
423    // and clean up directory tree bottom-up
424    remove_dir_contents_and_tree(link_path)?;
425
426    // Create symlink
427    create_symlink(link_path, link_target)
428}
429
430/// Remove all remaining files in a directory, then remove empty dirs bottom-up.
431/// Uses remove_dir (not remove_dir_all) so non-empty dirs fail safely.
432fn remove_dir_contents_and_tree(dir: &Path) -> Result<(), MarsError> {
433    // First, remove all regular files
434    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    // Then, remove empty directories bottom-up (deepest first)
443    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)); // Reverse order = deepest first
450
451    for d in dirs {
452        // remove_dir fails if non-empty — that's the safety net
453        let _ = std::fs::remove_dir(&d);
454    }
455
456    Ok(())
457}
458
459/// Create a symlink. Unix-only.
460fn 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
484// ── Unlink ──────────────────────────────────────────────────────────────────
485
486/// Remove symlinks created by `mars link`.
487/// Only removes symlinks that point to THIS mars root.
488fn 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            // Resolve the symlink target to absolute and compare
501            let resolved = target_dir.join(&link_target);
502            let expected = ctx.managed_root.join(subdir);
503
504            // Both must canonicalize successfully AND match.
505            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    // Remove from settings (under sync lock)
523    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
544// ── Helpers ─────────────────────────────────────────────────────────────────
545
546/// Normalize and validate a link target name.
547fn 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        // link_path doesn't exist
597        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        // File should be in managed root
738        assert!(managed.join("unique.md").exists());
739        // target_sub should be a symlink now
740        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        // Regular file — not a conflict (unique to target)
758        std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
759
760        // Symlink in target dir — should be skipped, not treated as conflict
761        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                // Only the real file should be listed for moving
768                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}