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_dest_path(&target_dest) {
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 move_item(&plan.source_abs, &plan.dest_abs)?;
86
87 let request = SyncRequest {
88 resolution: ResolutionMode::Normal,
89 mutation: None,
90 options: SyncOptions {
91 force: false,
92 dry_run: false,
93 frozen: false,
94 no_refresh_models: false,
95 },
96 };
97 let report = crate::sync::execute(ctx, &request)?;
98
99 if json {
100 output::print_json(&AdoptJson {
101 ok: true,
102 kind: kind_name(plan.kind),
103 name: &plan.name,
104 source_path: &plan.source_display,
105 dest_path: &plan.dest_display,
106 sync: Some(output::sync_report_json(&report)),
107 });
108 } else {
109 output::print_success(&format!(
110 "adopted {} `{}`: {} -> {}",
111 kind_name(plan.kind),
112 plan.name,
113 plan.source_display,
114 plan.dest_display
115 ));
116 output::print_sync_report(&report, false, true);
117 }
118
119 Ok(if report.has_conflicts() { 1 } else { 0 })
120}
121
122fn resolve_cli_path(path: &Path) -> Result<PathBuf, MarsError> {
123 let absolute = if path.is_absolute() {
124 path.to_path_buf()
125 } else {
126 std::env::current_dir()?.join(path)
127 };
128 Ok(absolute)
129}
130
131fn source_target_membership(
132 ctx: &MarsContext,
133 config: &Config,
134 source_abs: &Path,
135) -> Result<(String, PathBuf), MarsError> {
136 let source_canon = dunce::canonicalize(source_abs)?;
137 for target_name in config.settings.managed_targets() {
138 let target_root = ctx.project_root.join(&target_name);
139 let Ok(target_canon) = dunce::canonicalize(&target_root) else {
140 continue;
141 };
142 if let Ok(relative) = source_canon.strip_prefix(&target_canon) {
143 return Ok((target_name, relative.to_path_buf()));
144 }
145 }
146
147 Err(MarsError::InvalidRequest {
148 message: format!(
149 "{} is not inside a managed target directory",
150 relative_display(&ctx.project_root, source_abs)
151 ),
152 })
153}
154
155fn build_plan(
156 ctx: &MarsContext,
157 source_abs: &Path,
158 source_display: &str,
159) -> Result<AdoptPlan, MarsError> {
160 let metadata = source_abs.symlink_metadata()?;
161 let preferred_root = local_source::preferred_local_source_root(&ctx.project_root);
162
163 let (kind, name, dest_abs) = if metadata.is_dir() {
164 if !source_abs.join("SKILL.md").is_file() {
165 return Err(MarsError::InvalidRequest {
166 message: format!(
167 "{source_display} is not a valid skill directory (expected a directory containing SKILL.md)"
168 ),
169 });
170 }
171 let name = source_abs
172 .file_name()
173 .and_then(|name| name.to_str())
174 .ok_or_else(|| MarsError::InvalidRequest {
175 message: format!("could not derive skill name from {source_display}"),
176 })?
177 .to_string();
178 (
179 ItemKind::Skill,
180 name.clone(),
181 preferred_root.join("skills").join(&name),
182 )
183 } else if metadata.is_file() {
184 let is_agent = source_abs.extension().and_then(|ext| ext.to_str()) == Some("md")
185 && source_abs
186 .parent()
187 .and_then(|path| path.file_name())
188 .and_then(|name| name.to_str())
189 == Some("agents");
190 if !is_agent {
191 return Err(MarsError::InvalidRequest {
192 message: format!(
193 "{source_display} is not a valid agent file (expected a .md file inside agents/)"
194 ),
195 });
196 }
197 let name = source_abs
198 .file_stem()
199 .and_then(|name| name.to_str())
200 .ok_or_else(|| MarsError::InvalidRequest {
201 message: format!("could not derive agent name from {source_display}"),
202 })?
203 .to_string();
204 (
205 ItemKind::Agent,
206 name.clone(),
207 preferred_root.join("agents").join(format!("{name}.md")),
208 )
209 } else {
210 return Err(MarsError::InvalidRequest {
211 message: format!(
212 "{source_display} is not a valid item (expected a skill directory or agent markdown file)"
213 ),
214 });
215 };
216
217 if dest_abs.symlink_metadata().is_ok() {
218 return Err(MarsError::InvalidRequest {
219 message: format!(
220 "{} already exists; refusing to overwrite local source content",
221 relative_display(&ctx.project_root, &dest_abs)
222 ),
223 });
224 }
225
226 Ok(AdoptPlan {
227 kind,
228 name,
229 source_abs: source_abs.to_path_buf(),
230 source_display: source_display.to_string(),
231 dest_display: relative_display(&ctx.project_root, &dest_abs),
232 dest_abs,
233 })
234}
235
236fn print_dry_run(plan: &AdoptPlan, json: bool) -> Result<i32, MarsError> {
237 if json {
238 output::print_json(&serde_json::json!({
239 "ok": true,
240 "dry_run": true,
241 "kind": kind_name(plan.kind),
242 "name": plan.name,
243 "source_path": plan.source_display,
244 "dest_path": plan.dest_display,
245 "sync": serde_json::Value::Null,
246 }));
247 } else {
248 output::print_info(&format!(
249 "would adopt {} `{}`: {} -> {}",
250 kind_name(plan.kind),
251 plan.name,
252 plan.source_display,
253 plan.dest_display
254 ));
255 }
256 Ok(0)
257}
258
259fn move_item(source: &Path, dest: &Path) -> Result<(), MarsError> {
260 if let Some(parent) = dest.parent() {
261 std::fs::create_dir_all(parent)?;
262 }
263
264 match std::fs::rename(source, dest) {
265 Ok(()) => Ok(()),
266 Err(err) if is_cross_device_rename(&err) => Err(MarsError::InvalidRequest {
267 message: format!(
268 "cannot adopt {} across filesystems in MVP; move it onto the same filesystem as the repo first",
269 source.display()
270 ),
271 }),
272 Err(err) => Err(err.into()),
273 }
274}
275
276fn kind_name(kind: ItemKind) -> &'static str {
277 match kind {
278 ItemKind::Agent => "agent",
279 ItemKind::Skill => "skill",
280 ItemKind::Hook => "hook",
281 ItemKind::McpServer => "mcp-server",
282 ItemKind::BootstrapDoc => "bootstrap-doc",
283 }
284}
285
286fn relative_display(project_root: &Path, path: &Path) -> String {
287 path.strip_prefix(project_root)
288 .unwrap_or(path)
289 .display()
290 .to_string()
291}
292
293#[cfg(unix)]
294fn is_cross_device_rename(err: &std::io::Error) -> bool {
295 err.raw_os_error() == Some(libc::EXDEV)
296}
297
298#[cfg(windows)]
299fn is_cross_device_rename(err: &std::io::Error) -> bool {
300 const ERROR_NOT_SAME_DEVICE: i32 = 17;
301 err.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
302}
303
304#[cfg(not(any(unix, windows)))]
305fn is_cross_device_rename(_err: &std::io::Error) -> bool {
306 false
307}