1use crate::diagnostic::DiagnosticCollector;
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 previous_managed_paths = lock
80 .items
81 .keys()
82 .map(|dest_path| dest_path.to_string())
83 .collect::<HashSet<String>>();
84
85 let mut diag = DiagnosticCollector::new();
86 let target_outcomes = crate::target_sync::sync_managed_targets(
87 &ctx.project_root,
88 &mars_dir,
89 &[target_name.to_string()],
90 &outcomes,
91 &previous_managed_paths,
92 true,
93 &mut diag,
94 );
95 let diagnostics = diag.drain();
96
97 let Some(outcome) = target_outcomes.first() else {
98 return Err(MarsError::Link {
99 target: target_name.to_string(),
100 message: "target sync produced no result".to_string(),
101 });
102 };
103
104 if !outcome.errors.is_empty() {
105 return Err(MarsError::Link {
106 target: target_name.to_string(),
107 message: outcome.errors.join("; "),
108 });
109 }
110
111 if settings_changed {
112 config.settings.targets = Some(targets);
113 crate::config::save(&ctx.project_root, &config)?;
114 }
115
116 if json {
117 output::print_json(&serde_json::json!({
118 "ok": true,
119 "target": target_name,
120 "settings_updated": settings_changed,
121 "synced": outcome.items_synced,
122 "removed": outcome.items_removed,
123 "diagnostics": diagnostics,
124 }));
125 } else {
126 output::print_success(&format!(
127 "managed target `{target_name}` (synced {}, removed {})",
128 outcome.items_synced, outcome.items_removed
129 ));
130 for diagnostic in diagnostics {
131 output::print_warn(&diagnostic.to_string());
132 }
133 }
134
135 Ok(0)
136}
137
138fn unlink_target(
139 ctx: &super::MarsContext,
140 target_name: &str,
141 json: bool,
142) -> Result<i32, MarsError> {
143 let mars_dir = ctx.project_root.join(".mars");
144 std::fs::create_dir_all(&mars_dir)?;
145 let lock_path = mars_dir.join("sync.lock");
146 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
147
148 let mut config = crate::config::load(&ctx.project_root)?;
149 let mut settings_updated = false;
150 let mut target_was_managed = false;
151
152 if let Some(targets) = config.settings.targets.as_mut() {
153 let old_len = targets.len();
154 targets.retain(|target| target != target_name);
155 if targets.len() != old_len {
156 settings_updated = true;
157 target_was_managed = true;
158 }
159 if targets.is_empty() {
160 config.settings.targets = None;
161 }
162 }
163
164 if settings_updated {
165 crate::config::save(&ctx.project_root, &config)?;
166 }
167
168 let target_dir = ctx.project_root.join(target_name);
169 let removed_dir = if target_was_managed && target_dir.exists() {
170 std::fs::remove_dir_all(&target_dir)?;
171 true
172 } else {
173 false
174 };
175
176 if json {
177 output::print_json(&serde_json::json!({
178 "ok": true,
179 "target": target_name,
180 "settings_updated": settings_updated,
181 "removed_dir": removed_dir,
182 }));
183 } else if removed_dir {
184 output::print_success(&format!("removed managed target `{target_name}`"));
185 } else {
186 output::print_info(&format!("removed `{target_name}` from settings.targets"));
187 }
188
189 Ok(0)
190}
191
192fn normalize_target_name(target: &str) -> Result<String, MarsError> {
193 let normalized = target.trim_end_matches('/').trim_end_matches('\\');
194 if normalized.contains('/') || normalized.contains('\\') {
195 return Err(MarsError::Link {
196 target: target.to_string(),
197 message: "link target must be a directory name, not a path".to_string(),
198 });
199 }
200 if normalized.is_empty() || normalized == "." || normalized == ".." {
201 return Err(MarsError::Link {
202 target: target.to_string(),
203 message: "invalid link target name".to_string(),
204 });
205 }
206 Ok(normalized.to_string())
207}
208
209fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
210 lock.items
211 .values()
212 .map(|item| ActionOutcome {
213 item_id: ItemId {
214 kind: item.kind,
215 name: item_name_from_dest_path(&item.dest_path, item.kind),
216 },
217 action: ActionTaken::Skipped,
218 dest_path: item.dest_path.clone(),
219 source_name: item.source.clone(),
220 source_checksum: None,
221 installed_checksum: Some(item.installed_checksum.clone()),
222 })
223 .collect()
224}
225
226fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
227 let last = dest_path.as_str().rsplit('/').next().unwrap_or("");
228 let name = match kind {
229 ItemKind::Agent => last.strip_suffix(".md").unwrap_or(last).to_string(),
230 ItemKind::Skill => last.to_string(),
231 };
232
233 ItemName::from(name)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::normalize_target_name;
239
240 #[test]
241 fn normalize_strips_trailing_slash() {
242 assert_eq!(normalize_target_name(".claude/").unwrap(), ".claude");
243 }
244
245 #[test]
246 fn normalize_rejects_path() {
247 assert!(normalize_target_name("foo/bar").is_err());
248 }
249
250 #[test]
251 fn normalize_rejects_empty() {
252 assert!(normalize_target_name("").is_err());
253 }
254
255 #[test]
256 fn normalize_rejects_dots() {
257 assert!(normalize_target_name(".").is_err());
258 assert!(normalize_target_name("..").is_err());
259 }
260}