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 mut config = crate::config::load(&ctx.project_root)?;
67 let mut targets = config.settings.managed_targets();
68 if !targets.iter().any(|target| target == target_name) {
69 targets.push(target_name.to_string());
70 }
71
72 let settings_changed = config.settings.targets.as_ref() != Some(&targets);
73
74 let lock = crate::lock::load(&ctx.project_root)?;
75 let outcomes = lock_items_as_sync_outcomes(&lock);
76 let agent_surface_policy = crate::compiler::agent_surface_policy(
77 config.settings.agent_emission.as_ref(),
78 ctx.meridian_managed,
79 );
80 let suppressed_outcomes;
81 let sync_outcomes = if matches!(
82 agent_surface_policy,
83 crate::compiler::AgentSurfacePolicy::SuppressAll
84 ) {
85 suppressed_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
86 &suppressed_outcomes
87 } else {
88 &outcomes
89 };
90
91 let mut diag = DiagnosticCollector::new();
92 let target_sync_ctx = crate::target_sync::TargetSyncContext {
93 old_lock: &lock,
94 force,
95 collision_hint: CollisionAdoptHint::LinkForce,
96 };
97 let target_outcomes = crate::target_sync::sync_managed_targets(
98 &ctx.project_root,
99 &mars_dir,
100 &[target_name.to_string()],
101 sync_outcomes,
102 &target_sync_ctx,
103 &mut diag,
104 );
105 let mut diagnostics = diag.drain();
106 if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
107 diagnostics.push(diagnostic);
108 }
109
110 if !force
111 && diagnostics
112 .iter()
113 .any(|d| d.code == "target-unmanaged-collision")
114 {
115 return Err(MarsError::Link {
116 target: target_name.to_string(),
117 message: format!(
118 "unmanaged collision in `{target_name}` — hand-written files would be skipped; \
119 run `{}` to adopt",
120 managed_cmd(&format!("mars link {target_name} --force")),
121 ),
122 });
123 }
124
125 let Some(outcome) = target_outcomes.first() else {
126 return Err(MarsError::Link {
127 target: target_name.to_string(),
128 message: "target sync produced no result".to_string(),
129 });
130 };
131
132 if !outcome.errors.is_empty() {
133 return Err(MarsError::Link {
134 target: target_name.to_string(),
135 message: outcome.errors.join("; "),
136 });
137 }
138
139 let mut new_lock = lock;
140 crate::lock::apply_target_sync_outputs(&mut new_lock, &target_outcomes);
141 crate::lock::write(&ctx.project_root, &new_lock)?;
142
143 if settings_changed {
144 config.settings.targets = Some(targets);
145 crate::config::save(&ctx.project_root, &config)?;
146 }
147
148 if json {
149 output::print_json(&serde_json::json!({
150 "ok": true,
151 "target": target_name,
152 "settings_updated": settings_changed,
153 "synced": outcome.items_synced,
154 "removed": outcome.items_removed,
155 "diagnostics": diagnostics,
156 }));
157 } else {
158 output::print_success(&format!(
159 "managed target `{target_name}` (synced {}, removed {})",
160 outcome.items_synced, outcome.items_removed
161 ));
162 for diagnostic in diagnostics {
163 output::print_warn(&diagnostic.to_string());
164 }
165 }
166
167 Ok(0)
168}
169
170fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
171 (target_name == ".agents").then(|| Diagnostic {
172 level: DiagnosticLevel::Warning,
173 code: "deprecated-agents-target",
174 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(),
175 context: Some("link target".to_string()),
176 category: Some(DiagnosticCategory::Compatibility),
177 })
178}
179
180fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
181 lock.canonical_flat_items()
182 .into_iter()
183 .map(|(dest_path, item)| ActionOutcome {
184 item_id: ItemId {
185 kind: item.kind,
186 name: item_name_from_dest_path(&dest_path, item.kind),
187 },
188 action: ActionTaken::Skipped,
189 dest_path,
190 source_name: item.source,
191 source_checksum: None,
192 installed_checksum: Some(item.installed_checksum),
193 })
194 .collect()
195}
196
197fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
198 let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
199 let name = match kind {
200 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
201 ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
202 last.to_string()
203 }
204 };
205
206 ItemName::from(name)
207}