1use std::path::{Path, PathBuf};
4
5use serde::Serialize;
6
7use crate::config::Config;
8use crate::error::MarsError;
9use crate::local_source;
10use crate::lock::ItemKind;
11use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
12use crate::types::{DestPath, MarsContext};
13
14use super::output;
15
16#[derive(Debug, clap::Args)]
17pub struct AdoptArgs {
18 pub path: PathBuf,
20
21 #[arg(long)]
23 pub dry_run: bool,
24}
25
26#[derive(Debug)]
27struct AdoptPlan {
28 kind: ItemKind,
29 name: String,
30 source_abs: PathBuf,
31 source_display: String,
32 dest_abs: PathBuf,
33 dest_display: String,
34}
35
36#[derive(Debug, Serialize)]
37struct AdoptJson<'a> {
38 ok: bool,
39 kind: &'a str,
40 name: &'a str,
41 source_path: &'a str,
42 dest_path: &'a str,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 sync: Option<serde_json::Value>,
45}
46
47pub fn run(args: &AdoptArgs, ctx: &MarsContext, json: bool) -> Result<i32, MarsError> {
48 let config = crate::config::load(&ctx.project_root)?;
49
50 let lock = crate::lock::load(&ctx.project_root)?;
51 let source_abs = resolve_cli_path(&args.path)?;
52 let source_display = relative_display(&ctx.project_root, &source_abs);
53
54 if source_abs.symlink_metadata().is_err() {
55 return Err(MarsError::InvalidRequest {
56 message: format!("path not found: {source_display}"),
57 });
58 }
59
60 let (target_name, target_rel) = source_target_membership(ctx, &config, &source_abs)?;
61 let target_dest = DestPath::new(target_rel.to_string_lossy().as_ref()).map_err(|e| {
62 MarsError::InvalidRequest {
63 message: format!(
64 "{} resolves to invalid managed target item `{}`: {e}",
65 source_display,
66 target_rel.display()
67 ),
68 }
69 })?;
70 if lock.contains_output(&target_name, target_dest.as_str()) {
71 return Err(MarsError::InvalidRequest {
72 message: format!(
73 "{source_display} is already managed by Mars (target `{target_name}` item `{}`)",
74 target_rel.display()
75 ),
76 });
77 }
78
79 let plan = build_plan(ctx, &source_abs, &source_display)?;
80
81 if args.dry_run {
82 return print_dry_run(&plan, json);
83 }
84
85 let preflight = SyncRequest {
90 resolution: ResolutionMode::Normal,
91 mutation: None,
92 options: SyncOptions {
93 dry_run: true,
94 ..SyncOptions::default()
95 },
96 recovery: Default::default(),
97 lossiness_mode: crate::diagnostic::LossinessMode::Hidden,
98 };
99 crate::sync::execute(ctx, &preflight)?;
100
101 move_item(&plan.source_abs, &plan.dest_abs)?;
102
103 let request = SyncRequest {
104 resolution: ResolutionMode::Normal,
105 mutation: None,
106 options: SyncOptions::default(),
107 recovery: Default::default(),
108 lossiness_mode: crate::diagnostic::LossinessMode::Hidden,
109 };
110 let report = match crate::sync::execute(ctx, &request) {
111 Ok(report) => report,
112 Err(sync_error) => {
113 return match restore_item(&plan.dest_abs, &plan.source_abs) {
114 Ok(()) => Err(sync_error),
115 Err(restore_error) => Err(MarsError::InvalidRequest {
116 message: format!(
117 "adoption sync failed ({sync_error}); restoring `{}` also failed \
118 ({restore_error}); your content remains at `{}`",
119 plan.source_abs.display(),
120 plan.dest_abs.display()
121 ),
122 }),
123 };
124 }
125 };
126
127 if json {
128 output::print_json(&AdoptJson {
129 ok: true,
130 kind: kind_name(plan.kind),
131 name: &plan.name,
132 source_path: &plan.source_display,
133 dest_path: &plan.dest_display,
134 sync: Some(output::sync_report_json(&report)),
135 });
136 } else {
137 output::print_success(&format!(
138 "adopted {} `{}`: {} -> {}",
139 kind_name(plan.kind),
140 plan.name,
141 plan.source_display,
142 plan.dest_display
143 ));
144 output::print_sync_report(&report, false, true);
145 }
146
147 Ok(0)
148}
149
150fn resolve_cli_path(path: &Path) -> Result<PathBuf, MarsError> {
151 let absolute = if path.is_absolute() {
152 path.to_path_buf()
153 } else {
154 std::env::current_dir()?.join(path)
155 };
156 Ok(absolute)
157}
158
159fn source_target_membership(
160 ctx: &MarsContext,
161 config: &Config,
162 source_abs: &Path,
163) -> Result<(String, PathBuf), MarsError> {
164 let source_canon = dunce::canonicalize(source_abs)?;
165 for target_name in config.settings.managed_targets() {
166 let target_root = ctx.project_root.join(&target_name);
167 let Ok(target_canon) = dunce::canonicalize(&target_root) else {
168 continue;
169 };
170 if let Ok(relative) = source_canon.strip_prefix(&target_canon) {
171 return Ok((target_name, relative.to_path_buf()));
172 }
173 }
174
175 Err(MarsError::InvalidRequest {
176 message: format!(
177 "{} is not inside a managed target directory",
178 relative_display(&ctx.project_root, source_abs)
179 ),
180 })
181}
182
183fn build_plan(
184 ctx: &MarsContext,
185 source_abs: &Path,
186 source_display: &str,
187) -> Result<AdoptPlan, MarsError> {
188 let metadata = source_abs.symlink_metadata()?;
189 let preferred_root = local_source::preferred_local_source_root(&ctx.project_root);
190
191 let (kind, name, dest_abs) = if metadata.is_dir() {
192 if !source_abs.join("SKILL.md").is_file() {
193 return Err(MarsError::InvalidRequest {
194 message: format!(
195 "{source_display} is not a valid skill directory (expected a directory containing SKILL.md)"
196 ),
197 });
198 }
199 let name = source_abs
200 .file_name()
201 .and_then(|name| name.to_str())
202 .ok_or_else(|| MarsError::InvalidRequest {
203 message: format!("could not derive skill name from {source_display}"),
204 })?
205 .to_string();
206 (
207 ItemKind::Skill,
208 name.clone(),
209 preferred_root.join("skills").join(&name),
210 )
211 } else if metadata.is_file() {
212 let is_agent = source_abs.extension().and_then(|ext| ext.to_str()) == Some("md")
213 && source_abs
214 .parent()
215 .and_then(|path| path.file_name())
216 .and_then(|name| name.to_str())
217 == Some("agents");
218 if !is_agent {
219 return Err(MarsError::InvalidRequest {
220 message: format!(
221 "{source_display} is not a valid agent file (expected a .md file inside agents/)"
222 ),
223 });
224 }
225 let name = source_abs
226 .file_stem()
227 .and_then(|name| name.to_str())
228 .ok_or_else(|| MarsError::InvalidRequest {
229 message: format!("could not derive agent name from {source_display}"),
230 })?
231 .to_string();
232 (
233 ItemKind::Agent,
234 name.clone(),
235 preferred_root.join("agents").join(format!("{name}.md")),
236 )
237 } else {
238 return Err(MarsError::InvalidRequest {
239 message: format!(
240 "{source_display} is not a valid item (expected a skill directory or agent markdown file)"
241 ),
242 });
243 };
244
245 if dest_abs.symlink_metadata().is_ok() {
246 return Err(MarsError::InvalidRequest {
247 message: format!(
248 "{} already exists; refusing to overwrite local source content",
249 relative_display(&ctx.project_root, &dest_abs)
250 ),
251 });
252 }
253
254 Ok(AdoptPlan {
255 kind,
256 name,
257 source_abs: source_abs.to_path_buf(),
258 source_display: source_display.to_string(),
259 dest_display: relative_display(&ctx.project_root, &dest_abs),
260 dest_abs,
261 })
262}
263
264fn print_dry_run(plan: &AdoptPlan, json: bool) -> Result<i32, MarsError> {
265 if json {
266 output::print_json(&serde_json::json!({
267 "ok": true,
268 "dry_run": true,
269 "kind": kind_name(plan.kind),
270 "name": plan.name,
271 "source_path": plan.source_display,
272 "dest_path": plan.dest_display,
273 "sync": serde_json::Value::Null,
274 }));
275 } else {
276 output::print_info(&format!(
277 "would adopt {} `{}`: {} -> {}",
278 kind_name(plan.kind),
279 plan.name,
280 plan.source_display,
281 plan.dest_display
282 ));
283 }
284 Ok(0)
285}
286
287fn move_item(source: &Path, dest: &Path) -> Result<(), MarsError> {
288 if let Some(parent) = dest.parent() {
289 std::fs::create_dir_all(parent)?;
290 }
291
292 match std::fs::rename(source, dest) {
293 Ok(()) => Ok(()),
294 Err(err) if is_cross_device_rename(&err) => Err(MarsError::InvalidRequest {
295 message: format!(
296 "cannot adopt {} across filesystems in MVP; move it onto the same filesystem as the repo first",
297 source.display()
298 ),
299 }),
300 Err(err) => Err(err.into()),
301 }
302}
303
304fn restore_item(source: &Path, dest: &Path) -> Result<(), MarsError> {
305 if dest.symlink_metadata().is_ok() {
306 return Err(MarsError::InvalidRequest {
307 message: format!(
308 "refusing to overwrite content created at original path `{}`",
309 dest.display()
310 ),
311 });
312 }
313 if let Some(parent) = dest.parent() {
314 std::fs::create_dir_all(parent)?;
315 }
316 std::fs::rename(source, dest)?;
317 Ok(())
318}
319
320fn kind_name(kind: ItemKind) -> &'static str {
321 match kind {
322 ItemKind::Agent => "agent",
323 ItemKind::Skill => "skill",
324 ItemKind::Hook => "hook",
325 ItemKind::McpServer => "mcp-server",
326 ItemKind::BootstrapDoc => "bootstrap-doc",
327 }
328}
329
330fn relative_display(project_root: &Path, path: &Path) -> String {
331 path.strip_prefix(project_root)
332 .unwrap_or(path)
333 .display()
334 .to_string()
335}
336
337#[cfg(unix)]
338fn is_cross_device_rename(err: &std::io::Error) -> bool {
339 err.raw_os_error() == Some(libc::EXDEV)
340}
341
342#[cfg(windows)]
343fn is_cross_device_rename(err: &std::io::Error) -> bool {
344 const ERROR_NOT_SAME_DEVICE: i32 = 17;
345 err.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
346}
347
348#[cfg(not(any(unix, windows)))]
349fn is_cross_device_rename(_err: &std::io::Error) -> bool {
350 false
351}