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