Skip to main content

mars_agents/cli/
adopt.rs

1//! `mars adopt <path>` — move unmanaged 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    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}