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.canonicalize().or_else(|_| Ok::<_, std::io::Error>(target_dir.clone())),
75        ctx.managed_root.canonicalize(),
76    ) {
77        if target_canon == root_canon {
78            return Err(MarsError::Link {
79                target: target_name,
80                message: "cannot link the managed root to itself".to_string(),
81            });
82        }
83    }
84
85    // Verify config exists before any mutations (resolve-first principle)
86    let config_path = ctx.managed_root.join("mars.toml");
87    if !config_path.exists() {
88        return Err(MarsError::Link {
89            target: target_name,
90            message: format!(
91                "mars.toml not found at {} — run `mars init` first",
92                ctx.managed_root.display()
93            ),
94        });
95    }
96
97    // Warn if target isn't a well-known tool dir
98    if !json
99        && !super::WELL_KNOWN.contains(&target_name.as_str())
100        && !super::TOOL_DIRS.contains(&target_name.as_str())
101    {
102        output::print_warn(&format!(
103            "`{target_name}` is not a recognized tool directory — linking anyway"
104        ));
105    }
106
107    // Acquire sync lock for the entire operation (scan + act + persist).
108    // Prevents races with concurrent mars sync or mars link.
109    let lock_path = ctx.managed_root.join(".mars").join("sync.lock");
110    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
111
112    // Create target directory if needed
113    std::fs::create_dir_all(&target_dir)?;
114
115    // Ensure managed subdirs exist
116    for subdir in ["agents", "skills"] {
117        let source = ctx.managed_root.join(subdir);
118        if !source.exists() {
119            std::fs::create_dir_all(&source)?;
120        }
121    }
122
123    // Compute relative path from target dir back to mars root
124    let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
125        .unwrap_or_else(|| ctx.managed_root.clone());
126
127    // ── Phase 1: Scan all subdirs ──────────────────────────────────────────
128    let mut scan_results = Vec::new();
129    let mut all_conflicts: Vec<(&str, ConflictInfo)> = Vec::new();
130    let mut has_foreign = false;
131    let mut foreign_details: Vec<(&str, PathBuf)> = Vec::new();
132
133    for subdir in ["agents", "skills"] {
134        let link_path = target_dir.join(subdir);
135        let link_target = rel_root.join(subdir);
136        let managed_subdir = ctx.managed_root.join(subdir);
137
138        let result = scan_link_target(&link_path, &managed_subdir);
139        match &result {
140            ScanResult::ConflictedDir { conflicts } => {
141                for c in conflicts {
142                    all_conflicts.push((subdir, c.clone()));
143                }
144            }
145            ScanResult::ForeignSymlink { target } => {
146                has_foreign = true;
147                foreign_details.push((subdir, target.clone()));
148            }
149            _ => {}
150        }
151        scan_results.push((subdir, link_path, link_target, result));
152    }
153
154    // Check: any conflicts or foreign symlinks? (unless --force)
155    if !args.force && (!all_conflicts.is_empty() || has_foreign) {
156        if json {
157            let conflict_json: Vec<_> = all_conflicts
158                .iter()
159                .map(|(subdir, c)| {
160                    serde_json::json!({
161                        "path": format!("{}/{}", subdir, c.relative_path.display()),
162                        "target_desc": c.target_desc,
163                        "managed_desc": c.managed_desc,
164                    })
165                })
166                .collect();
167            output::print_json(&serde_json::json!({
168                "ok": false,
169                "error": "conflicts found",
170                "conflicts": conflict_json,
171            }));
172        } else {
173            let total = all_conflicts.len() + foreign_details.len();
174            eprintln!(
175                "error: cannot link {target_name} — {total} conflict(s) found:\n"
176            );
177            for (subdir, info) in &all_conflicts {
178                eprintln!("  {subdir}/{}", info.relative_path.display());
179                eprintln!(
180                    "    {target_name}/{subdir}/{} ({})",
181                    info.relative_path.display(),
182                    info.target_desc
183                );
184                eprintln!(
185                    "    {}/{subdir}/{} ({})\n",
186                    ctx.managed_root
187                        .file_name()
188                        .unwrap_or_default()
189                        .to_string_lossy(),
190                    info.relative_path.display(),
191                    info.managed_desc
192                );
193            }
194            for (subdir, foreign_target) in &foreign_details {
195                eprintln!(
196                    "  {target_name}/{subdir} is a symlink to {} (not this mars root)\n",
197                    foreign_target.display()
198                );
199            }
200            eprintln!(
201                "hint: resolve conflicts manually, then retry `mars link {target_name}`"
202            );
203            eprintln!(
204                "hint: or use `mars link {target_name} --force` to replace with symlinks (data loss)"
205            );
206        }
207        return Err(MarsError::Link {
208            target: target_name,
209            message: "conflicts found — resolve manually or use --force".to_string(),
210        });
211    }
212
213    // ── Phase 2: Act ───────────────────────────────────────────────────────
214    let mut linked = 0;
215    for (subdir, link_path, link_target, result) in scan_results {
216        match result {
217            ScanResult::Empty => {
218                create_symlink(&link_path, &link_target)?;
219                linked += 1;
220            }
221            ScanResult::AlreadyLinked => {
222                if !json {
223                    output::print_info(&format!("{target_name}/{subdir} already linked"));
224                }
225            }
226            ScanResult::MergeableDir { files_to_move } => {
227                let managed_subdir = ctx.managed_root.join(subdir);
228                merge_and_link(
229                    &link_path,
230                    &link_target,
231                    &managed_subdir,
232                    &files_to_move,
233                )?;
234                linked += 1;
235                if !json && !files_to_move.is_empty() {
236                    output::print_info(&format!(
237                        "merged {} file(s) from {target_name}/{subdir} into managed root",
238                        files_to_move.len()
239                    ));
240                }
241            }
242            ScanResult::ForeignSymlink { .. } | ScanResult::ConflictedDir { .. } => {
243                // Only reachable with --force
244                if link_path.symlink_metadata().is_ok() {
245                    if link_path.read_link().is_ok() {
246                        std::fs::remove_file(&link_path)?;
247                    } else {
248                        std::fs::remove_dir_all(&link_path)?;
249                    }
250                }
251                create_symlink(&link_path, &link_target)?;
252                linked += 1;
253            }
254        }
255    }
256
257    // Persist link in config (already under sync lock from above).
258    let mut config = crate::config::load(&ctx.managed_root)?;
259    if !config.settings.links.contains(&target_name) {
260        config.settings.links.push(target_name.clone());
261        crate::config::save(&ctx.managed_root, &config)?;
262    }
263
264    // Output
265    if json {
266        output::print_json(&serde_json::json!({
267            "ok": true,
268            "target": target_dir.to_string_lossy(),
269            "linked": linked,
270        }));
271    } else if linked > 0 {
272        output::print_success(&format!("linked agents/ and skills/ into {target_name}"));
273    } else {
274        output::print_info(&format!("{target_name} already fully linked"));
275    }
276
277    Ok(0)
278}
279
280// ── Scan ────────────────────────────────────────────────────────────────────
281
282/// Scan a single link target (e.g. `.claude/agents/`) to determine its state.
283fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
284    // Check if anything exists at link_path
285    if link_path.symlink_metadata().is_err() {
286        return ScanResult::Empty;
287    }
288
289    // Check if it's a symlink
290    if let Ok(actual_target) = link_path.read_link() {
291        // Use canonicalize for comparison — textually different but semantically
292        // identical paths should match.
293        let actual_resolved = link_path
294            .parent()
295            .map(|p| p.join(&actual_target))
296            .and_then(|p| p.canonicalize().ok());
297        let expected_resolved = managed_subdir.canonicalize().ok();
298
299        match (actual_resolved, expected_resolved) {
300            (Some(a), Some(b)) if a == b => return ScanResult::AlreadyLinked,
301            _ => return ScanResult::ForeignSymlink { target: actual_target },
302        }
303    }
304
305    // It's a real directory — scan recursively
306    scan_dir_recursive(link_path, managed_subdir)
307}
308
309/// Recursively scan a target directory against the managed root.
310fn scan_dir_recursive(target_subdir: &Path, managed_subdir: &Path) -> ScanResult {
311    let mut files_to_move = Vec::new();
312    let mut conflicts = Vec::new();
313
314    // Walk the target directory recursively, without following symlinks
315    for entry in walkdir::WalkDir::new(target_subdir)
316        .follow_links(false)
317        .into_iter()
318        .filter_map(|e| e.ok())
319    {
320        let ft = entry.file_type();
321        if ft.is_dir() {
322            continue; // Directories are structural, handled during cleanup
323        }
324        if ft.is_symlink() {
325            // Skip symlinks — don't follow, don't treat as conflicts.
326            // They survive the merge-and-link process since we only
327            // remove regular files.
328            continue;
329        }
330
331        let relative = match entry.path().strip_prefix(target_subdir) {
332            Ok(r) => r.to_path_buf(),
333            Err(_) => continue,
334        };
335
336        // Non-regular files (fifos, sockets) → treat as conflict
337        if !ft.is_file() {
338            conflicts.push(ConflictInfo {
339                relative_path: relative,
340                target_desc: format!("<non-regular: {:?}>", ft),
341                managed_desc: String::new(),
342            });
343            continue;
344        }
345
346        let managed_path = managed_subdir.join(&relative);
347
348        if !managed_path.exists() {
349            // Unique file — can be moved
350            files_to_move.push(relative);
351        } else if managed_path.is_file() {
352            // Both exist as files — compare content
353            let target_hash = hash_file(entry.path());
354            let managed_hash = hash_file(&managed_path);
355            match (target_hash, managed_hash) {
356                (Some(th), Some(mh)) if th == mh => {
357                    // Identical — skip
358                }
359                (Some(th), Some(mh)) => {
360                    conflicts.push(ConflictInfo {
361                        relative_path: relative,
362                        target_desc: th,
363                        managed_desc: mh,
364                    });
365                }
366                (th, mh) => {
367                    // Can't read one or both files — treat as conflict
368                    conflicts.push(ConflictInfo {
369                        relative_path: relative,
370                        target_desc: th.unwrap_or_else(|| "unreadable".to_string()),
371                        managed_desc: mh.unwrap_or_else(|| "unreadable".to_string()),
372                    });
373                }
374            }
375        } else {
376            // Type mismatch (file in target, dir in managed or vice versa)
377            conflicts.push(ConflictInfo {
378                relative_path: relative,
379                target_desc: "file".to_string(),
380                managed_desc: "directory".to_string(),
381            });
382        }
383    }
384
385    if !conflicts.is_empty() {
386        ScanResult::ConflictedDir { conflicts }
387    } else {
388        ScanResult::MergeableDir { files_to_move }
389    }
390}
391
392/// Compute SHA-256 of a single file for comparison.
393/// Returns None if the file can't be read (permission denied, etc).
394fn hash_file(path: &Path) -> Option<String> {
395    std::fs::read(path).ok().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.managed_root,
527        &crate::sync::LinkMutation::Clear {
528            target: target_name.to_string(),
529        },
530    )?;
531
532    if json {
533        output::print_json(&serde_json::json!({
534            "ok": true,
535            "removed": removed,
536        }));
537    } else if removed > 0 {
538        output::print_success(&format!("removed {removed} symlink(s) from {target_name}"));
539    } else {
540        output::print_info("no symlinks to remove");
541    }
542
543    Ok(0)
544}
545
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!(
718                    conflicts[0].relative_path,
719                    PathBuf::from("sub/existing.md")
720                );
721            }
722            _ => panic!("expected ConflictedDir"),
723        }
724    }
725
726    #[test]
727    fn merge_and_link_moves_files_and_creates_symlink() {
728        let dir = TempDir::new().unwrap();
729        let managed = dir.path().join("managed");
730        std::fs::create_dir_all(&managed).unwrap();
731
732        let target_dir = dir.path().join("target");
733        let target_sub = target_dir.join("agents");
734        std::fs::create_dir_all(&target_sub).unwrap();
735        std::fs::write(target_sub.join("unique.md"), "content").unwrap();
736
737        let link_target = PathBuf::from("../managed");
738        let files = vec![PathBuf::from("unique.md")];
739
740        merge_and_link(&target_sub, &link_target, &managed, &files).unwrap();
741
742        // File should be in managed root
743        assert!(managed.join("unique.md").exists());
744        // target_sub should be a symlink now
745        assert!(target_sub.symlink_metadata().unwrap().file_type().is_symlink());
746    }
747
748    #[test]
749    fn scan_dir_recursive_skips_symlinks() {
750        let dir = TempDir::new().unwrap();
751        let target_sub = dir.path().join("target").join("agents");
752        let managed = dir.path().join("managed").join("agents");
753        std::fs::create_dir_all(&target_sub).unwrap();
754        std::fs::create_dir_all(&managed).unwrap();
755
756        // Regular file — not a conflict (unique to target)
757        std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
758
759        // Symlink in target dir — should be skipped, not treated as conflict
760        std::os::unix::fs::symlink(
761            target_sub.join("real.md"),
762            target_sub.join("linked.md"),
763        ).unwrap();
764
765        let result = scan_dir_recursive(&target_sub, &managed);
766        match result {
767            ScanResult::MergeableDir { files_to_move } => {
768                // Only the real file should be listed for moving
769                assert_eq!(files_to_move.len(), 1);
770                assert_eq!(files_to_move[0], PathBuf::from("real.md"));
771            }
772            _ => panic!("expected MergeableDir, got {:?}", std::mem::discriminant(&result)),
773        }
774    }
775
776    #[test]
777    fn remove_dir_contents_and_tree_cleans_up() {
778        let dir = TempDir::new().unwrap();
779        let target = dir.path().join("target");
780        std::fs::create_dir_all(target.join("sub")).unwrap();
781        std::fs::write(target.join("a.md"), "a").unwrap();
782        std::fs::write(target.join("sub").join("b.md"), "b").unwrap();
783
784        remove_dir_contents_and_tree(&target).unwrap();
785
786        assert!(!target.exists());
787    }
788}