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;
14use std::path::PathBuf;
15
16use super::output;
17
18#[derive(Debug, clap::Args)]
20pub struct LinkArgs {
21 pub target: String,
23
24 #[arg(long)]
26 pub unlink: bool,
27}
28
29pub fn run(args: &LinkArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
31 let target_name = normalize_target_name(&args.target)?;
32
33 if args.unlink {
34 return unlink_target(ctx, &target_name, json);
35 }
36
37 link_target(ctx, &target_name, json)
38}
39
40fn link_target(ctx: &super::MarsContext, target_name: &str, json: bool) -> Result<i32, MarsError> {
41 let config_path = ctx.project_root.join("mars.toml");
42 if !config_path.exists() {
43 return Err(MarsError::Link {
44 target: target_name.to_string(),
45 message: format!(
46 "mars.toml not found at {} — run `mars init` first",
47 ctx.project_root.display()
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
68 .settings
69 .targets
70 .clone()
71 .unwrap_or_else(|| config.settings.managed_targets());
72 if !targets.iter().any(|target| target == target_name) {
73 targets.push(target_name.to_string());
74 }
75
76 let settings_changed = config.settings.targets.as_ref() != Some(&targets);
77
78 let lock = crate::lock::load(&ctx.project_root)?;
79 let outcomes = lock_items_as_sync_outcomes(&lock);
80 let previous_managed_paths = lock
81 .items
82 .keys()
83 .map(|dest_path| dest_path.as_path().to_path_buf())
84 .collect::<HashSet<PathBuf>>();
85
86 let mut diag = DiagnosticCollector::new();
87 let target_outcomes = crate::target_sync::sync_managed_targets(
88 &ctx.project_root,
89 &mars_dir,
90 &[target_name.to_string()],
91 &outcomes,
92 &previous_managed_paths,
93 true,
94 &mut diag,
95 );
96 let diagnostics = diag.drain();
97
98 let Some(outcome) = target_outcomes.first() else {
99 return Err(MarsError::Link {
100 target: target_name.to_string(),
101 message: "target sync produced no result".to_string(),
102 });
103 };
104
105 if !outcome.errors.is_empty() {
106 return Err(MarsError::Link {
107 target: target_name.to_string(),
108 message: outcome.errors.join("; "),
109 });
110 }
111
112 if settings_changed {
113 config.settings.targets = Some(targets);
114 crate::config::save(&ctx.project_root, &config)?;
115 }
116
117 if json {
118 output::print_json(&serde_json::json!({
119 "ok": true,
120 "target": target_name,
121 "settings_updated": settings_changed,
122 "synced": outcome.items_synced,
123 "removed": outcome.items_removed,
124 "diagnostics": diagnostics,
125 }));
126 } else {
127 output::print_success(&format!(
128 "managed target `{target_name}` (synced {}, removed {})",
129 outcome.items_synced, outcome.items_removed
130 ));
131 for diagnostic in diagnostics {
132 output::print_warn(&diagnostic.to_string());
133 }
134 }
135
136 Ok(0)
137}
138
139fn unlink_target(
140 ctx: &super::MarsContext,
141 target_name: &str,
142 json: bool,
143) -> Result<i32, MarsError> {
144 let mars_dir = ctx.project_root.join(".mars");
145 std::fs::create_dir_all(&mars_dir)?;
146 let lock_path = mars_dir.join("sync.lock");
147 let _sync_lock = crate::fs::FileLock::acquire(&lock_path)?;
148
149 let mut config = crate::config::load(&ctx.project_root)?;
150 let mut settings_updated = false;
151 let mut target_was_managed = false;
152
153 if let Some(targets) = config.settings.targets.as_mut() {
154 let old_len = targets.len();
155 targets.retain(|target| target != target_name);
156 if targets.len() != old_len {
157 settings_updated = true;
158 target_was_managed = true;
159 }
160 if targets.is_empty() {
161 config.settings.targets = None;
162 }
163 }
164
165 if settings_updated {
166 crate::config::save(&ctx.project_root, &config)?;
167 }
168
169 let target_dir = ctx.project_root.join(target_name);
170 let removed_dir = if target_was_managed && target_dir.exists() {
171 std::fs::remove_dir_all(&target_dir)?;
172 true
173 } else {
174 false
175 };
176
177 if json {
178 output::print_json(&serde_json::json!({
179 "ok": true,
180 "target": target_name,
181 "settings_updated": settings_updated,
182 "removed_dir": removed_dir,
183 }));
184 } else if removed_dir {
185 output::print_success(&format!("removed managed target `{target_name}`"));
186 } else {
187 output::print_info(&format!("removed `{target_name}` from settings.targets"));
188 }
189
190 Ok(0)
191}
192
193fn normalize_target_name(target: &str) -> Result<String, MarsError> {
194 let normalized = target.trim_end_matches('/').trim_end_matches('\\');
195 if normalized.contains('/') || normalized.contains('\\') {
196 return Err(MarsError::Link {
197 target: target.to_string(),
198 message: "link target must be a directory name, not a path".to_string(),
199 });
200 }
201 if normalized.is_empty() || normalized == "." || normalized == ".." {
202 return Err(MarsError::Link {
203 target: target.to_string(),
204 message: "invalid link target name".to_string(),
205 });
206 }
207 Ok(normalized.to_string())
208}
209
210fn lock_items_as_sync_outcomes(lock: &LockFile) -> Vec<ActionOutcome> {
211 lock.items
212 .values()
213 .map(|item| ActionOutcome {
214 item_id: ItemId {
215 kind: item.kind,
216 name: item_name_from_dest_path(&item.dest_path, item.kind),
217 },
218 action: ActionTaken::Skipped,
219 dest_path: item.dest_path.clone(),
220 source_name: item.source.clone(),
221 source_checksum: None,
222 installed_checksum: None,
223 })
224 .collect()
225}
226
227fn item_name_from_dest_path(dest_path: &crate::types::DestPath, kind: ItemKind) -> ItemName {
228 let path = dest_path.as_path();
229
230 let name = match kind {
231 ItemKind::Agent => path
232 .file_stem()
233 .map(|stem| stem.to_string_lossy().to_string())
234 .unwrap_or_else(|| path.to_string_lossy().to_string()),
235 ItemKind::Skill => path
236 .file_name()
237 .map(|name| name.to_string_lossy().to_string())
238 .unwrap_or_else(|| path.to_string_lossy().to_string()),
239 };
240
241 ItemName::from(name)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::normalize_target_name;
247
248 #[test]
249 fn normalize_strips_trailing_slash() {
250 assert_eq!(normalize_target_name(".claude/").unwrap(), ".claude");
251 }
252
253 #[test]
254 fn normalize_rejects_path() {
255 assert!(normalize_target_name("foo/bar").is_err());
256 }
257
258 #[test]
259 fn normalize_rejects_empty() {
260 assert!(normalize_target_name("").is_err());
261 }
262
263 #[test]
264 fn normalize_rejects_dots() {
265 assert!(normalize_target_name(".").is_err());
266 assert!(normalize_target_name("..").is_err());
267 }
268}