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