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.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    // 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 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    // Create target directory if needed
116    std::fs::create_dir_all(&target_dir)?;
117
118    // Ensure managed subdirs exist
119    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    // Compute relative path from target dir back to mars root
127    let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
128        .unwrap_or_else(|| ctx.managed_root.clone());
129
130    // ── Phase 1: Scan all subdirs ──────────────────────────────────────────
131    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    // Check: any conflicts or foreign symlinks? (unless --force)
158    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    // ── Phase 2: Act ───────────────────────────────────────────────────────
213    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                // Only reachable with --force
238                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    // Persist link in config (already under sync lock from above).
252    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    // Output
259    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
274// ── Scan ────────────────────────────────────────────────────────────────────
275
276/// Scan a single link target (e.g. `.claude/agents/`) to determine its state.
277fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
278    // Check if anything exists at link_path
279    if link_path.symlink_metadata().is_err() {
280        return ScanResult::Empty;
281    }
282
283    // Check if it's a symlink
284    if let Ok(actual_target) = link_path.read_link() {
285        // Use canonicalize for comparison — textually different but semantically
286        // identical paths should match.
287        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    // It's a real directory — scan recursively
304    scan_dir_recursive(link_path, managed_subdir)
305}
306
307/// Recursively scan a target directory against the managed root.
308fn 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    // Walk the target directory recursively, without following symlinks
313    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; // Directories are structural, handled during cleanup
321        }
322        if ft.is_symlink() {
323            // Skip symlinks — don't follow, don't treat as conflicts.
324            // They survive the merge-and-link process since we only
325            // remove regular files.
326            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        // Non-regular files (fifos, sockets) → treat as conflict
335        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            // Unique file — can be moved
348            files_to_move.push(relative);
349        } else if managed_path.is_file() {
350            // Both exist as files — compare content
351            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                    // Identical — skip
356                }
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                    // Can't read one or both files — treat as conflict
366                    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            // Type mismatch (file in target, dir in managed or vice versa)
375            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
390/// Compute SHA-256 of a single file for comparison.
391/// Returns None if the file can't be read (permission denied, etc).
392fn hash_file(path: &Path) -> Option<String> {
393    std::fs::read(path)
394        .ok()
395        .map(|bytes| hash::hash_bytes(&bytes))
396}
397
398// ── Act ─────────────────────────────────────────────────────────────────────
399
400/// Move unique files into managed root, remove the target dir, create symlink.
401fn merge_and_link(
402    link_path: &Path,
403    link_target: &Path,
404    managed_subdir: &Path,
405    files_to_move: &[PathBuf],
406) -> Result<(), MarsError> {
407    // Move unique files into managed root (copy+delete for cross-fs safety)
408    for relative in files_to_move {
409        let src = link_path.join(relative);
410        let dst = managed_subdir.join(relative);
411
412        // Create parent dirs in managed root if needed
413        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 remaining files (identical copies we skipped during scan)
425    // and clean up directory tree bottom-up
426    remove_dir_contents_and_tree(link_path)?;
427
428    // Create symlink
429    create_symlink(link_path, link_target)
430}
431
432/// Remove all remaining files in a directory, then remove empty dirs bottom-up.
433/// Uses remove_dir (not remove_dir_all) so non-empty dirs fail safely.
434fn remove_dir_contents_and_tree(dir: &Path) -> Result<(), MarsError> {
435    // First, remove all regular files
436    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    // Then, remove empty directories bottom-up (deepest first)
445    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)); // Reverse order = deepest first
452
453    for d in dirs {
454        // remove_dir fails if non-empty — that's the safety net
455        let _ = std::fs::remove_dir(&d);
456    }
457
458    Ok(())
459}
460
461/// Create a symlink. Unix-only.
462fn 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
486// ── Unlink ──────────────────────────────────────────────────────────────────
487
488/// Remove symlinks created by `mars link`.
489/// Only removes symlinks that point to THIS mars root.
490fn 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            // Resolve the symlink target to absolute and compare
503            let resolved = target_dir.join(&link_target);
504            let expected = ctx.managed_root.join(subdir);
505
506            // Both must canonicalize successfully AND match.
507            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    // Remove from settings (under sync lock)
525    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
546// ── Helpers ─────────────────────────────────────────────────────────────────
547
548/// Normalize and validate a link target name.
549fn 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        // link_path doesn't exist
599        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        // File should be in managed root
740        assert!(managed.join("unique.md").exists());
741        // target_sub should be a symlink now
742        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        // Regular file — not a conflict (unique to target)
760        std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
761
762        // Symlink in target dir — should be skipped, not treated as conflict
763        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                // Only the real file should be listed for moving
770                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}