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