1use crate::diagnostic::{Diagnostic, DiagnosticCategory, DiagnosticCollector, DiagnosticLevel};
8use crate::error::MarsError;
9use crate::lock::{ItemId, ItemKind, LockFile};
10use crate::sync::apply::{ActionOutcome, ActionTaken};
11use crate::types::ItemName;
12use crate::types::managed_cmd;
13use std::collections::HashSet;
14
15use super::output;
16
17#[derive(Debug, clap::Args)]
19pub struct LinkArgs {
20 pub target: String,
22}
23
24pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
26 let parsed_target = super::target::normalize_target_name(&args.target)?;
27 let target_name = crate::config::migrations::link::normalize_link(&parsed_target).target;
28 link_target(ctx, &target_name, json)
29}
30
31fn link_target(ctx: &super::MarsContext, target_name: &str, json: bool) -> Result<i32, MarsError> {
32 let config_path = ctx.project_root.join("mars.toml");
33 if !config_path.exists() {
34 return Err(MarsError::Link {
35 target: target_name.to_string(),
36 message: format!(
37 "mars.toml not found at {} — run `{cmd}` first",
38 ctx.project_root.display(),
39 cmd = managed_cmd("mars init"),
40 ),
41 });
42 }
43
44 if !json
45 && !super::WELL_KNOWN.contains(&target_name)
46 && !super::TOOL_DIRS.contains(&target_name)
47 {
48 output::print_warn(&format!(
49 "`{target_name}` is not a recognized tool directory — managing anyway"
50 ));
51 }
52
53 let mars_dir = ctx.project_root.join(".mars");
54 std::fs::create_dir_all(&mars_dir)?;
55 let lock_path = mars_dir.join("sync.lock");
56 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
57
58 let mut config = crate::config::load(&ctx.project_root)?;
59 let mut targets = config.settings.managed_targets();
60 if !targets.iter().any(|target| target == target_name) {
61 targets.push(target_name.to_string());
62 }
63
64 let settings_changed = config.settings.targets.as_ref() != Some(&targets);
65
66 let lock = crate::lock::load(&ctx.project_root)?;
67 let outcomes = lock_items_as_sync_outcomes(&lock);
68 let agent_surface_policy = crate::compiler::agent_surface_policy(
69 config.settings.agent_emission.as_ref(),
70 ctx.meridian_managed,
71 );
72 let suppressed_outcomes;
73 let sync_outcomes = if matches!(
74 agent_surface_policy,
75 crate::compiler::AgentSurfacePolicy::SuppressAll
76 ) {
77 suppressed_outcomes = crate::compiler::suppress_agent_outcomes(&outcomes);
78 &suppressed_outcomes
79 } else {
80 &outcomes
81 };
82 let previous_managed_paths = lock
83 .all_output_dest_paths()
84 .map(|dest_path| dest_path.to_string())
85 .collect::<HashSet<String>>();
86
87 let mut diag = DiagnosticCollector::new();
88 let target_outcomes = crate::target_sync::sync_managed_targets(
89 &ctx.project_root,
90 &mars_dir,
91 &[target_name.to_string()],
92 sync_outcomes,
93 &previous_managed_paths,
94 true,
95 &mut diag,
96 );
97 let mut diagnostics = diag.drain();
98 if let Some(diagnostic) = deprecated_agents_target_diagnostic(target_name) {
99 diagnostics.push(diagnostic);
100 }
101
102 let Some(outcome) = target_outcomes.first() else {
103 return Err(MarsError::Link {
104 target: target_name.to_string(),
105 message: "target sync produced no result".to_string(),
106 });
107 };
108
109 if !outcome.errors.is_empty() {
110 return Err(MarsError::Link {
111 target: target_name.to_string(),
112 message: outcome.errors.join("; "),
113 });
114 }
115
116 if settings_changed {
117 config.settings.targets = Some(targets);
118 crate::config::save(&ctx.project_root, &config)?;
119 }
120
121 if json {
122 output::print_json(&serde_json::json!({
123 "ok": true,
124 "target": target_name,
125 "settings_updated": settings_changed,
126 "synced": outcome.items_synced,
127 "removed": outcome.items_removed,
128 "diagnostics": diagnostics,
129 }));
130 } else {
131 output::print_success(&format!(
132 "managed target `{target_name}` (synced {}, removed {})",
133 outcome.items_synced, outcome.items_removed
134 ));
135 for diagnostic in diagnostics {
136 output::print_warn(&diagnostic.to_string());
137 }
138 }
139
140 Ok(0)
141}
142
143fn deprecated_agents_target_diagnostic(target_name: &str) -> Option<Diagnostic> {
144 (target_name == ".agents").then(|| Diagnostic {
145 level: DiagnosticLevel::Warning,
146 code: "deprecated-agents-target",
147 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(),
148 context: Some("link target".to_string()),
149 category: Some(DiagnosticCategory::Compatibility),
150 })
151}
152
153fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
154 lock.flat_items()
155 .into_iter()
156 .map(|(dest_path, item)| ActionOutcome {
157 item_id: ItemId {
158 kind: item.kind,
159 name: item_name_from_dest_path(&dest_path, item.kind),
160 },
161 action: ActionTaken::Skipped,
162 dest_path,
163 source_name: item.source,
164 source_checksum: None,
165 installed_checksum: Some(item.installed_checksum),
166 })
167 .collect()
168}
169
170fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
171 let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
172 let name = match kind {
173 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
174 ItemKind::Skill | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
175 last.to_string()
176 }
177 };
178
179 ItemName::from(name)
180}