Skip to main content

mars_agents/cli/
link.rs

1//! `mars link <dir>` — manage target directories materialized from `.mars/`.
2//!
3//! `mars link <target>` adds the target to `settings.targets` and copies
4//! content from `.mars/` into that target.
5//! `mars link --unlink <target>` removes the target from `settings.targets`
6//! and removes the target directory.
7
8use crate::diagnostic::DiagnosticCollector;
9use crate::error::MarsError;
10use crate::lock::{ItemId, ItemKind, LockFile};
11use crate::sync::apply::{ActionOutcome, ActionTaken};
12use crate::types::ItemName;
13use std::collections::HashSet;
14use std::path::PathBuf;
15
16use super::output;
17
18/// Arguments for `mars link`.
19#[derive(Debug, clap::Args)]
20pub struct LinkArgs {
21    /// Target directory to materialize (e.g. `.claude`).
22    pub target: String,
23
24    /// Remove target management instead of adding it.
25    #[arg(long)]
26    pub unlink: bool,
27}
28
29/// Run `mars link`.
30pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
31    let target_name = normalize_target_name(&args.target)?;
32
33    if args.unlink {
34        return unlink_target(ctx, &target_name, json);
35    }
36
37    link_target(ctx, &target_name, json)
38}
39
40fn link_target(ctx: &super::MarsContext, target_name: &str, json: bool) -> Result<i32, MarsError> {
41    let config_path = ctx.project_root.join("mars.toml");
42    if !config_path.exists() {
43        return Err(MarsError::Link {
44            target: target_name.to_string(),
45            message: format!(
46                "mars.toml not found at {} — run `mars init` first",
47                ctx.project_root.display()
48            ),
49        });
50    }
51
52    if !json
53        && !super::WELL_KNOWN.contains(&target_name)
54        && !super::TOOL_DIRS.contains(&target_name)
55    {
56        output::print_warn(&format!(
57            "`{target_name}` is not a recognized tool directory — managing anyway"
58        ));
59    }
60
61    let mars_dir = ctx.project_root.join(".mars");
62    std::fs::create_dir_all(&mars_dir)?;
63    let lock_path = mars_dir.join("sync.lock");
64    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
65
66    let mut config = crate::config::load(&ctx.project_root)?;
67    let mut targets = config
68        .settings
69        .targets
70        .clone()
71        .unwrap_or_else(|| config.settings.managed_targets());
72    if !targets.iter().any(|target| target == target_name) {
73        targets.push(target_name.to_string());
74    }
75
76    let settings_changed = config.settings.targets.as_ref() != Some(&targets);
77
78    let lock = crate::lock::load(&ctx.project_root)?;
79    let outcomes = lock_items_as_sync_outcomes(&lock);
80    let previous_managed_paths = lock
81        .items
82        .keys()
83        .map(|dest_path| dest_path.as_path().to_path_buf())
84        .collect::<HashSet<PathBuf>>();
85
86    let mut diag = DiagnosticCollector::new();
87    let target_outcomes = crate::target_sync::sync_managed_targets(
88        &ctx.project_root,
89        &mars_dir,
90        &[target_name.to_string()],
91        &outcomes,
92        &previous_managed_paths,
93        true,
94        &mut diag,
95    );
96    let diagnostics = diag.drain();
97
98    let Some(outcome) = target_outcomes.first() else {
99        return Err(MarsError::Link {
100            target: target_name.to_string(),
101            message: "target sync produced no result".to_string(),
102        });
103    };
104
105    if !outcome.errors.is_empty() {
106        return Err(MarsError::Link {
107            target: target_name.to_string(),
108            message: outcome.errors.join("; "),
109        });
110    }
111
112    if settings_changed {
113        config.settings.targets = Some(targets);
114        crate::config::save(&ctx.project_root, &config)?;
115    }
116
117    if json {
118        output::print_json(&serde_json::json!({
119            "ok": true,
120            "target": target_name,
121            "settings_updated": settings_changed,
122            "synced": outcome.items_synced,
123            "removed": outcome.items_removed,
124            "diagnostics": diagnostics,
125        }));
126    } else {
127        output::print_success(&format!(
128            "managed target `{target_name}` (synced {}, removed {})",
129            outcome.items_synced, outcome.items_removed
130        ));
131        for diagnostic in diagnostics {
132            output::print_warn(&diagnostic.to_string());
133        }
134    }
135
136    Ok(0)
137}
138
139fn unlink_target(
140    ctx: &super::MarsContext,
141    target_name: &str,
142    json: bool,
143) -> Result<i32, MarsError> {
144    let mars_dir = ctx.project_root.join(".mars");
145    std::fs::create_dir_all(&mars_dir)?;
146    let lock_path = mars_dir.join("sync.lock");
147    let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
148
149    let mut config = crate::config::load(&ctx.project_root)?;
150    let mut settings_updated = false;
151    let mut target_was_managed = false;
152
153    if let Some(targets) = config.settings.targets.as_mut() {
154        let old_len = targets.len();
155        targets.retain(|target| target != target_name);
156        if targets.len() != old_len {
157            settings_updated = true;
158            target_was_managed = true;
159        }
160        if targets.is_empty() {
161            config.settings.targets = None;
162        }
163    }
164
165    if settings_updated {
166        crate::config::save(&ctx.project_root, &config)?;
167    }
168
169    let target_dir = ctx.project_root.join(target_name);
170    let removed_dir = if target_was_managed && target_dir.exists() {
171        std::fs::remove_dir_all(&target_dir)?;
172        true
173    } else {
174        false
175    };
176
177    if json {
178        output::print_json(&serde_json::json!({
179            "ok": true,
180            "target": target_name,
181            "settings_updated": settings_updated,
182            "removed_dir": removed_dir,
183        }));
184    } else if removed_dir {
185        output::print_success(&format!("removed managed target `{target_name}`"));
186    } else {
187        output::print_info(&format!("removed `{target_name}` from settings.targets"));
188    }
189
190    Ok(0)
191}
192
193fn normalize_target_name(target: &str) -> Result<String, MarsError> {
194    let normalized = target.trim_end_matches('/').trim_end_matches('\\');
195    if normalized.contains('/') || normalized.contains('\\') {
196        return Err(MarsError::Link {
197            target: target.to_string(),
198            message: "link target must be a directory name, not a path".to_string(),
199        });
200    }
201    if normalized.is_empty() || normalized == "." || normalized == ".." {
202        return Err(MarsError::Link {
203            target: target.to_string(),
204            message: "invalid link target name".to_string(),
205        });
206    }
207    Ok(normalized.to_string())
208}
209
210fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
211    lock.items
212        .values()
213        .map(|item| ActionOutcome {
214            item_id: ItemId {
215                kind: item.kind,
216                name: item_name_from_dest_path(&item.dest_path, item.kind),
217            },
218            action: ActionTaken::Skipped,
219            dest_path: item.dest_path.clone(),
220            source_name: item.source.clone(),
221            source_checksum: None,
222            installed_checksum: Some(item.installed_checksum.clone()),
223        })
224        .collect()
225}
226
227fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
228    let path = dest_path.as_path();
229
230    let name = match kind {
231        ItemKind::Agent => path
232            .file_stem()
233            .map(|stem| stem.to_string_lossy().to_string())
234            .unwrap_or_else(|| path.to_string_lossy().to_string()),
235        ItemKind::Skill => path
236            .file_name()
237            .map(|name| name.to_string_lossy().to_string())
238            .unwrap_or_else(|| path.to_string_lossy().to_string()),
239    };
240
241    ItemName::from(name)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::normalize_target_name;
247
248    #[test]
249    fn normalize_strips_trailing_slash() {
250        assert_eq!(normalize_target_name(".claude/").unwrap(), ".claude");
251    }
252
253    #[test]
254    fn normalize_rejects_path() {
255        assert!(normalize_target_name("foo/bar").is_err());
256    }
257
258    #[test]
259    fn normalize_rejects_empty() {
260        assert!(normalize_target_name("").is_err());
261    }
262
263    #[test]
264    fn normalize_rejects_dots() {
265        assert!(normalize_target_name(".").is_err());
266        assert!(normalize_target_name("..").is_err());
267    }
268}