Skip to main content

mars_agents/cli/
adopt.rs

1//! `mars adopt <path>` — validate, move target content into `.mars-src/`, then sync.
2
3use 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    /// Path to an unmanaged item under a managed target directory.
19    pub path: PathBuf,
20
21    /// Show what would happen without moving content or syncing.
22    #[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    // Resolve and validate the existing project before moving user content.
86    // The adopted item is not discoverable from `.mars-src` until after the
87    // move, so the real sync still follows; rollback protects every error from
88    // that second execution.
89    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}