use std::path::{Path, PathBuf};
use crate::error::MarsError;
use crate::hash;
use super::output;
#[derive(Debug, clap::Args)]
pub struct LinkArgs {
pub target: String,
#[arg(long)]
pub unlink: bool,
#[arg(long)]
pub force: bool,
}
enum ScanResult {
Empty,
AlreadyLinked,
ForeignSymlink { target: PathBuf },
MergeableDir { files_to_move: Vec<PathBuf> },
ConflictedDir { conflicts: Vec<ConflictInfo> },
}
#[derive(Clone)]
struct ConflictInfo {
relative_path: PathBuf,
target_desc: String,
managed_desc: String,
}
pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
if args.unlink {
let target_name = normalize_link_target(&args.target)?;
let target_dir = ctx.project_root.join(&target_name);
return unlink(ctx, &target_name, &target_dir, json);
}
let target_name = normalize_link_target(&args.target)?;
let target_dir = ctx.project_root.join(&target_name);
if let (Ok(target_canon), Ok(root_canon)) = (
target_dir
.canonicalize()
.or_else(|_| Ok::<_, std::io::Error>(target_dir.clone())),
ctx.managed_root.canonicalize(),
) && target_canon == root_canon
{
return Err(MarsError::Link {
target: target_name,
message: "cannot link the managed root to itself".to_string(),
});
}
let config_path = ctx.project_root.join("mars.toml");
if !config_path.exists() {
return Err(MarsError::Link {
target: target_name,
message: format!(
"mars.toml not found at {} — run `mars init` first",
ctx.project_root.display()
),
});
}
if !json
&& !super::WELL_KNOWN.contains(&target_name.as_str())
&& !super::TOOL_DIRS.contains(&target_name.as_str())
{
output::print_warn(&format!(
"`{target_name}` is not a recognized tool directory — linking anyway"
));
}
let mars_dir = ctx.project_root.join(".mars");
std::fs::create_dir_all(&mars_dir)?;
let lock_path = mars_dir.join("sync.lock");
let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
std::fs::create_dir_all(&target_dir)?;
for subdir in ["agents", "skills"] {
let source = ctx.managed_root.join(subdir);
if !source.exists() {
std::fs::create_dir_all(&source)?;
}
}
let rel_root = pathdiff::diff_paths(&ctx.managed_root, &target_dir)
.unwrap_or_else(|| ctx.managed_root.clone());
let mut scan_results = Vec::new();
let mut all_conflicts: Vec<(&str, ConflictInfo)> = Vec::new();
let mut has_foreign = false;
let mut foreign_details: Vec<(&str, PathBuf)> = Vec::new();
for subdir in ["agents", "skills"] {
let link_path = target_dir.join(subdir);
let link_target = rel_root.join(subdir);
let managed_subdir = ctx.managed_root.join(subdir);
let result = scan_link_target(&link_path, &managed_subdir);
match &result {
ScanResult::ConflictedDir { conflicts } => {
for c in conflicts {
all_conflicts.push((subdir, c.clone()));
}
}
ScanResult::ForeignSymlink { target } => {
has_foreign = true;
foreign_details.push((subdir, target.clone()));
}
_ => {}
}
scan_results.push((subdir, link_path, link_target, result));
}
if !args.force && (!all_conflicts.is_empty() || has_foreign) {
if json {
let conflict_json: Vec<_> = all_conflicts
.iter()
.map(|(subdir, c)| {
serde_json::json!({
"path": format!("{}/{}", subdir, c.relative_path.display()),
"target_desc": c.target_desc,
"managed_desc": c.managed_desc,
})
})
.collect();
output::print_json(&serde_json::json!({
"ok": false,
"error": "conflicts found",
"conflicts": conflict_json,
}));
} else {
let total = all_conflicts.len() + foreign_details.len();
eprintln!("error: cannot link {target_name} — {total} conflict(s) found:\n");
for (subdir, info) in &all_conflicts {
eprintln!(" {subdir}/{}", info.relative_path.display());
eprintln!(
" {target_name}/{subdir}/{} ({})",
info.relative_path.display(),
info.target_desc
);
eprintln!(
" {}/{subdir}/{} ({})\n",
ctx.managed_root
.file_name()
.unwrap_or_default()
.to_string_lossy(),
info.relative_path.display(),
info.managed_desc
);
}
for (subdir, foreign_target) in &foreign_details {
eprintln!(
" {target_name}/{subdir} is a symlink to {} (not this mars root)\n",
foreign_target.display()
);
}
eprintln!("hint: resolve conflicts manually, then retry `mars link {target_name}`");
eprintln!(
"hint: or use `mars link {target_name} --force` to replace with symlinks (data loss)"
);
}
return Err(MarsError::Link {
target: target_name,
message: "conflicts found — resolve manually or use --force".to_string(),
});
}
let mut linked = 0;
for (subdir, link_path, link_target, result) in scan_results {
match result {
ScanResult::Empty => {
create_symlink(&link_path, &link_target)?;
linked += 1;
}
ScanResult::AlreadyLinked => {
if !json {
output::print_info(&format!("{target_name}/{subdir} already linked"));
}
}
ScanResult::MergeableDir { files_to_move } => {
let managed_subdir = ctx.managed_root.join(subdir);
merge_and_link(&link_path, &link_target, &managed_subdir, &files_to_move)?;
linked += 1;
if !json && !files_to_move.is_empty() {
output::print_info(&format!(
"merged {} file(s) from {target_name}/{subdir} into managed root",
files_to_move.len()
));
}
}
ScanResult::ForeignSymlink { .. } | ScanResult::ConflictedDir { .. } => {
if link_path.symlink_metadata().is_ok() {
if link_path.read_link().is_ok() {
std::fs::remove_file(&link_path)?;
} else {
std::fs::remove_dir_all(&link_path)?;
}
}
create_symlink(&link_path, &link_target)?;
linked += 1;
}
}
}
let mut config = crate::config::load(&ctx.project_root)?;
if !config.settings.links.contains(&target_name) {
config.settings.links.push(target_name.clone());
crate::config::save(&ctx.project_root, &config)?;
}
if json {
output::print_json(&serde_json::json!({
"ok": true,
"target": target_dir.to_string_lossy(),
"linked": linked,
}));
} else if linked > 0 {
output::print_success(&format!("linked agents/ and skills/ into {target_name}"));
} else {
output::print_info(&format!("{target_name} already fully linked"));
}
Ok(0)
}
fn scan_link_target(link_path: &Path, managed_subdir: &Path) -> ScanResult {
if link_path.symlink_metadata().is_err() {
return ScanResult::Empty;
}
if let Ok(actual_target) = link_path.read_link() {
let actual_resolved = link_path
.parent()
.map(|p| p.join(&actual_target))
.and_then(|p| p.canonicalize().ok());
let expected_resolved = managed_subdir.canonicalize().ok();
match (actual_resolved, expected_resolved) {
(Some(a), Some(b)) if a == b => return ScanResult::AlreadyLinked,
_ => {
return ScanResult::ForeignSymlink {
target: actual_target,
};
}
}
}
scan_dir_recursive(link_path, managed_subdir)
}
fn scan_dir_recursive(target_subdir: &Path, managed_subdir: &Path) -> ScanResult {
let mut files_to_move = Vec::new();
let mut conflicts = Vec::new();
for entry in walkdir::WalkDir::new(target_subdir)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let ft = entry.file_type();
if ft.is_dir() {
continue; }
if ft.is_symlink() {
continue;
}
let relative = match entry.path().strip_prefix(target_subdir) {
Ok(r) => r.to_path_buf(),
Err(_) => continue,
};
if !ft.is_file() {
conflicts.push(ConflictInfo {
relative_path: relative,
target_desc: format!("<non-regular: {:?}>", ft),
managed_desc: String::new(),
});
continue;
}
let managed_path = managed_subdir.join(&relative);
if !managed_path.exists() {
files_to_move.push(relative);
} else if managed_path.is_file() {
let target_hash = hash_file(entry.path());
let managed_hash = hash_file(&managed_path);
match (target_hash, managed_hash) {
(Some(th), Some(mh)) if th == mh => {
}
(Some(th), Some(mh)) => {
conflicts.push(ConflictInfo {
relative_path: relative,
target_desc: th,
managed_desc: mh,
});
}
(th, mh) => {
conflicts.push(ConflictInfo {
relative_path: relative,
target_desc: th.unwrap_or_else(|| "unreadable".to_string()),
managed_desc: mh.unwrap_or_else(|| "unreadable".to_string()),
});
}
}
} else {
conflicts.push(ConflictInfo {
relative_path: relative,
target_desc: "file".to_string(),
managed_desc: "directory".to_string(),
});
}
}
if !conflicts.is_empty() {
ScanResult::ConflictedDir { conflicts }
} else {
ScanResult::MergeableDir { files_to_move }
}
}
fn hash_file(path: &Path) -> Option<String> {
std::fs::read(path)
.ok()
.map(|bytes| hash::hash_bytes(&bytes))
}
fn merge_and_link(
link_path: &Path,
link_target: &Path,
managed_subdir: &Path,
files_to_move: &[PathBuf],
) -> Result<(), MarsError> {
for relative in files_to_move {
let src = link_path.join(relative);
let dst = managed_subdir.join(relative);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src, &dst).map_err(|e| MarsError::Link {
target: link_path.display().to_string(),
message: format!("failed to copy {}: {e}", relative.display()),
})?;
std::fs::remove_file(&src)?;
}
remove_dir_contents_and_tree(link_path)?;
create_symlink(link_path, link_target)
}
fn remove_dir_contents_and_tree(dir: &Path) -> Result<(), MarsError> {
for entry in walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
std::fs::remove_file(entry.path())?;
}
let mut dirs: Vec<_> = walkdir::WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_dir())
.map(|e| e.into_path())
.collect();
dirs.sort_by(|a, b| b.cmp(a));
for d in dirs {
let _ = std::fs::remove_dir(&d);
}
Ok(())
}
fn create_symlink(link_path: &Path, link_target: &Path) -> Result<(), MarsError> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(link_target, link_path).map_err(|e| MarsError::Link {
target: link_path.display().to_string(),
message: format!(
"failed to create symlink {} -> {}: {e}",
link_path.display(),
link_target.display()
),
})?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = (link_path, link_target);
Err(MarsError::Link {
target: String::new(),
message: "symlinks are only supported on Unix".to_string(),
})
}
}
fn unlink(
ctx: &super::MarsContext,
target_name: &str,
target_dir: &Path,
json: bool,
) -> Result<i32, MarsError> {
let mut removed = 0;
for subdir in ["agents", "skills"] {
let link_path = target_dir.join(subdir);
if let Ok(link_target) = link_path.read_link() {
let resolved = target_dir.join(&link_target);
let expected = ctx.managed_root.join(subdir);
let matches = match (resolved.canonicalize(), expected.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
if matches {
std::fs::remove_file(&link_path)?;
removed += 1;
} else if !json {
output::print_warn(&format!(
"{target_name}/{subdir} is a symlink to {} (not this mars root) — skipping",
link_target.display()
));
}
}
}
crate::sync::mutate_link_config(
ctx,
&crate::sync::LinkMutation::Clear {
target: target_name.to_string(),
},
)?;
if json {
output::print_json(&serde_json::json!({
"ok": true,
"removed": removed,
}));
} else if removed > 0 {
output::print_success(&format!("removed {removed} symlink(s) from {target_name}"));
} else {
output::print_info("no symlinks to remove");
}
Ok(0)
}
fn normalize_link_target(target: &str) -> Result<String, MarsError> {
let normalized = target.trim_end_matches('/').trim_end_matches('\\');
if normalized.contains('/') || normalized.contains('\\') {
return Err(MarsError::Link {
target: target.to_string(),
message: "link target must be a directory name, not a path".to_string(),
});
}
if normalized.is_empty() || normalized == "." || normalized == ".." {
return Err(MarsError::Link {
target: target.to_string(),
message: "invalid link target name".to_string(),
});
}
Ok(normalized.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn normalize_strips_trailing_slash() {
assert_eq!(normalize_link_target(".claude/").unwrap(), ".claude");
}
#[test]
fn normalize_rejects_path() {
assert!(normalize_link_target("foo/bar").is_err());
}
#[test]
fn normalize_rejects_empty() {
assert!(normalize_link_target("").is_err());
}
#[test]
fn normalize_rejects_dots() {
assert!(normalize_link_target(".").is_err());
assert!(normalize_link_target("..").is_err());
}
#[test]
fn scan_empty_returns_empty() {
let dir = TempDir::new().unwrap();
let link_path = dir.path().join("agents");
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
let result = scan_link_target(&link_path, &managed);
assert!(matches!(result, ScanResult::Empty));
}
#[test]
fn scan_symlink_to_correct_target_returns_already_linked() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
let target_dir = dir.path().join("target");
std::fs::create_dir_all(&target_dir).unwrap();
let link_path = target_dir.join("agents");
#[cfg(unix)]
std::os::unix::fs::symlink(&managed, &link_path).unwrap();
let result = scan_link_target(&link_path, &managed);
assert!(matches!(result, ScanResult::AlreadyLinked));
}
#[test]
fn scan_symlink_to_wrong_target_returns_foreign() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
let other = dir.path().join("other");
std::fs::create_dir_all(&other).unwrap();
let target_dir = dir.path().join("target");
std::fs::create_dir_all(&target_dir).unwrap();
let link_path = target_dir.join("agents");
#[cfg(unix)]
std::os::unix::fs::symlink(&other, &link_path).unwrap();
let result = scan_link_target(&link_path, &managed);
assert!(matches!(result, ScanResult::ForeignSymlink { .. }));
}
#[test]
fn scan_dir_with_unique_files_returns_mergeable() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
let target_sub = dir.path().join("target_sub");
std::fs::create_dir_all(&target_sub).unwrap();
std::fs::write(target_sub.join("unique.md"), "unique content").unwrap();
let result = scan_dir_recursive(&target_sub, &managed);
match result {
ScanResult::MergeableDir { files_to_move } => {
assert_eq!(files_to_move.len(), 1);
assert_eq!(files_to_move[0], PathBuf::from("unique.md"));
}
_ => panic!("expected MergeableDir"),
}
}
#[test]
fn scan_dir_with_identical_files_returns_mergeable_empty() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
std::fs::write(managed.join("same.md"), "content").unwrap();
let target_sub = dir.path().join("target_sub");
std::fs::create_dir_all(&target_sub).unwrap();
std::fs::write(target_sub.join("same.md"), "content").unwrap();
let result = scan_dir_recursive(&target_sub, &managed);
match result {
ScanResult::MergeableDir { files_to_move } => {
assert!(files_to_move.is_empty());
}
_ => panic!("expected MergeableDir with empty files_to_move"),
}
}
#[test]
fn scan_dir_with_conflicting_files_returns_conflicted() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
std::fs::write(managed.join("conflict.md"), "managed version").unwrap();
let target_sub = dir.path().join("target_sub");
std::fs::create_dir_all(&target_sub).unwrap();
std::fs::write(target_sub.join("conflict.md"), "target version").unwrap();
let result = scan_dir_recursive(&target_sub, &managed);
match result {
ScanResult::ConflictedDir { conflicts } => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].relative_path, PathBuf::from("conflict.md"));
}
_ => panic!("expected ConflictedDir"),
}
}
#[test]
fn scan_dir_recursive_handles_nested() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(managed.join("sub")).unwrap();
std::fs::write(managed.join("sub").join("existing.md"), "managed").unwrap();
let target_sub = dir.path().join("target_sub");
std::fs::create_dir_all(target_sub.join("sub")).unwrap();
std::fs::write(target_sub.join("sub").join("existing.md"), "different").unwrap();
std::fs::write(target_sub.join("sub").join("unique.md"), "unique").unwrap();
let result = scan_dir_recursive(&target_sub, &managed);
match result {
ScanResult::ConflictedDir { conflicts } => {
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].relative_path, PathBuf::from("sub/existing.md"));
}
_ => panic!("expected ConflictedDir"),
}
}
#[test]
fn merge_and_link_moves_files_and_creates_symlink() {
let dir = TempDir::new().unwrap();
let managed = dir.path().join("managed");
std::fs::create_dir_all(&managed).unwrap();
let target_dir = dir.path().join("target");
let target_sub = target_dir.join("agents");
std::fs::create_dir_all(&target_sub).unwrap();
std::fs::write(target_sub.join("unique.md"), "content").unwrap();
let link_target = PathBuf::from("../managed");
let files = vec![PathBuf::from("unique.md")];
merge_and_link(&target_sub, &link_target, &managed, &files).unwrap();
assert!(managed.join("unique.md").exists());
assert!(
target_sub
.symlink_metadata()
.unwrap()
.file_type()
.is_symlink()
);
}
#[test]
fn scan_dir_recursive_skips_symlinks() {
let dir = TempDir::new().unwrap();
let target_sub = dir.path().join("target").join("agents");
let managed = dir.path().join("managed").join("agents");
std::fs::create_dir_all(&target_sub).unwrap();
std::fs::create_dir_all(&managed).unwrap();
std::fs::write(target_sub.join("real.md"), "real agent").unwrap();
std::os::unix::fs::symlink(target_sub.join("real.md"), target_sub.join("linked.md"))
.unwrap();
let result = scan_dir_recursive(&target_sub, &managed);
match result {
ScanResult::MergeableDir { files_to_move } => {
assert_eq!(files_to_move.len(), 1);
assert_eq!(files_to_move[0], PathBuf::from("real.md"));
}
_ => panic!(
"expected MergeableDir, got {:?}",
std::mem::discriminant(&result)
),
}
}
#[test]
fn remove_dir_contents_and_tree_cleans_up() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target");
std::fs::create_dir_all(target.join("sub")).unwrap();
std::fs::write(target.join("a.md"), "a").unwrap();
std::fs::write(target.join("sub").join("b.md"), "b").unwrap();
remove_dir_contents_and_tree(&target).unwrap();
assert!(!target.exists());
}
}