1use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCollector, DiagnosticLevel};
8use crate::error::MarsError;
9use crate::lock::{ItemId, ItemKind, LockFile};
10use crate::surface_ownership::CollisionAdoptHint;
11use crate::sync::apply::{ActionOutcome, ActionTaken};
12use crate::types::ItemName;
13use crate::types::managed_cmd;
14
15use super::output;
16
17#[derive(Debug, clap::Args)]
19pub struct LinkArgs {
20 pub target: String,
22 #[arg(long)]
24 pub force: bool,
25}
26
27pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
29 let parsed_target = super::target::normalize_target_name(&args.target)?;
30 let target_name = crate::config::migrations::link::normalize_link(&parsed_target).target;
31 link_target(ctx, &target_name, args.force, json)
32}
33
34fn link_target(
35 ctx: &super::MarsContext,
36 target_name: &str,
37 force: bool,
38 json: bool,
39) -> 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 `{cmd}` first",
46 ctx.project_root.display(),
47 cmd = managed_cmd("mars init"),
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 config = crate::config::load(&ctx.project_root)?;
67 let local = crate::config::load_local(&ctx.project_root)?;
68 let (effective, _) =
69 crate::config::merge_with_root(config.clone(), local.clone(), &ctx.project_root)?;
70 let mut runtime_targets = effective.settings.managed_targets();
71 let mut persisted_targets = config.settings.managed_targets();
72 ensure_target_list_contains(&mut runtime_targets, target_name);
73 ensure_target_list_contains(&mut persisted_targets, target_name);
74
75 let settings_changed = config.settings.targets.as_ref() != Some(&persisted_targets);
76
77 let lock = crate::lock::load(&ctx.project_root)?;
78 let outcomes = lock_items_as_sync_outcomes(&lock);
79 let mut diag = DiagnosticCollector::new();
80 let agent_copy_spec = crate::compiler::agent_copy::build_agent_copy_spec(
81 effective.settings.meridian_agent_copy(),
82 &runtime_targets,
83 &mut diag,
84 );
85 let agent_surface_policy = crate::compiler::agent_surface_policy(
86 effective.settings.agent_emission.as_ref(),
87 agent_copy_spec.as_ref(),
88 ctx.meridian_managed,
89 );
90
91 let cache = crate::source::GlobalCache::new()?;
92 let source_provider = crate::sync::provider::RealSourceProvider::new(&cache, &ctx.project_root);
93 let resolve_options = crate::resolve::ResolveOptions::sync();
94 let graph = crate::resolve::resolve(
95 &effective,
96 &source_provider,
97 Some(&lock),
98 &resolve_options,
99 &mut diag,
100 )?;
101
102 let filtered_outcomes;
103 let orphan_preserve_paths;
104 let (sync_outcomes, orphan_preserve) = match &agent_surface_policy {
105 crate::compiler::AgentSurfacePolicy::SuppressAll => {
106 filtered_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
107 (&filtered_outcomes, None)
108 }
109 crate::compiler::AgentSurfacePolicy::EmitSelective(spec) => {
110 orphan_preserve_paths =
111 crate::compiler::selective_native_orphan_preserve_paths(&lock, spec);
112 filtered_outcomes = crate::compiler::omit_agent_outcomes(&outcomes);
113 (&filtered_outcomes, Some(&orphan_preserve_paths))
114 }
115 crate::compiler::AgentSurfacePolicy::EmitAll => (&outcomes, None),
116 };
117 let target_sync_ctx = crate::target_sync::TargetSyncContext {
118 old_lock: &lock,
119 force,
120 collision_hint: CollisionAdoptHint::LinkForce,
121 orphan_preserve_paths: orphan_preserve,
122 };
123 let target_outcomes = crate::target_sync::sync_managed_targets(
124 &ctx.project_root,
125 &mars_dir,
126 &[target_name.to_string()],
127 sync_outcomes,
128 &target_sync_ctx,
129 &mut diag,
130 );
131 let mut diagnostics = diag.drain();
132 if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
133 diagnostics.push(diagnostic);
134 }
135
136 if !force
137 && diagnostics
138 .iter()
139 .any(|d| d.code == "target-unmanaged-collision")
140 {
141 return Err(MarsError::Link {
142 target: target_name.to_string(),
143 message: format!(
144 "unmanaged collision in `{target_name}` — hand-written files would be skipped; \
145 run `{}` to adopt",
146 managed_cmd(&format!("mars link {target_name} --force")),
147 ),
148 });
149 }
150
151 let Some(outcome) = target_outcomes.first() else {
152 return Err(MarsError::Link {
153 target: target_name.to_string(),
154 message: "target sync produced no result".to_string(),
155 });
156 };
157
158 if !outcome.errors.is_empty() {
159 return Err(MarsError::Link {
160 target: target_name.to_string(),
161 message: outcome.errors.join("; "),
162 });
163 }
164
165 let (compiled_native_outputs, removed_native_outputs) =
166 crate::compiler::materialize_native_agents_after_link(
167 &crate::compiler::NativeAgentLinkMaterializeCtx {
168 mars_ctx: ctx,
169 managed_targets: &runtime_targets,
170 config: &config,
171 local: &local,
172 effective: &effective,
173 graph: &graph,
174 old_lock: &lock,
175 target_outcomes: &target_outcomes,
176 force,
177 },
178 &mut diag,
179 );
180 diagnostics.extend(diag.drain());
181
182 if !force
183 && diagnostics
184 .iter()
185 .any(|d| d.code == "target-unmanaged-collision")
186 {
187 return Err(MarsError::Link {
188 target: target_name.to_string(),
189 message: format!(
190 "unmanaged collision in `{target_name}` — hand-written files would be skipped; \
191 run `{}` to adopt",
192 managed_cmd(&format!("mars link {target_name} --force")),
193 ),
194 });
195 }
196
197 let mut new_lock = lock;
198 crate::lock::apply_target_sync_outputs(&mut new_lock, &target_outcomes);
199 crate::lock::apply_removed_native_outputs(&mut new_lock, &removed_native_outputs);
200 crate::lock::apply_compiled_native_outputs(&mut new_lock, &compiled_native_outputs);
201 if let Some(warning) =
202 crate::compiler::persist_lock_then_native_agent_manifest(&ctx.project_root, &new_lock)?
203 {
204 diag.warn("native-agent-manifest-write", warning);
205 diagnostics.extend(diag.drain());
206 }
207
208 if settings_changed {
209 let mut config = config;
210 config.settings.targets = Some(persisted_targets);
211 crate::config::save(&ctx.project_root, &config)?;
212 }
213
214 if json {
215 output::print_json(&serde_json::json!({
216 "ok": true,
217 "target": target_name,
218 "settings_updated": settings_changed,
219 "synced": outcome.items_synced,
220 "removed": outcome.items_removed,
221 "diagnostics": diagnostics,
222 }));
223 } else {
224 output::print_success(&format!(
225 "managed target `{target_name}` (synced {}, removed {})",
226 outcome.items_synced, outcome.items_removed
227 ));
228 for diagnostic in diagnostics {
229 output::print_warn(&diagnostic.to_string());
230 }
231 }
232
233 Ok(0)
234}
235
236fn ensure_target_list_contains(targets: &mut Vec<String>, target_name: &str) {
237 if !targets.iter().any(|target| target == target_name) {
238 targets.push(target_name.to_string());
239 }
240}
241
242fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
243 (target_name == ".agents").then(|| Diagnostic {
244 level: DiagnosticLevel::Warning,
245 code: "deprecated-agents-target",
246 message: format!(
247 "`.agents` is a deprecated link target. Run `{}` to remove it. Skills are now emitted to native harness dirs automatically.",
248 managed_cmd("mars unlink .agents"),
249 ),
250 context: Some("link target".to_string()),
251 category: Some(DiagnosticCategory::Compatibility),
252 })
253}
254
255fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
256 lock.canonical_flat_items()
257 .into_iter()
258 .map(|(dest_path, item)| ActionOutcome {
259 item_id: ItemId {
260 kind: item.kind,
261 name: item_name_from_dest_path(&dest_path, item.kind),
262 },
263 action: ActionTaken::Skipped,
264 dest_path,
265 source_name: item.source,
266 source_checksum: None,
267 installed_checksum: Some(item.installed_checksum),
268 })
269 .collect()
270}
271
272fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
273 let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
274 let name = match kind {
275 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
276 ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
277 last.to_string()
278 }
279 };
280
281 ItemName::from(name)
282}