Skip to main content

mars_agents/cli/
export.rs

1//! `mars export` — produce a JSON representation of the compile plan.
2//!
3//! Runs the same dry-run pipeline as `mars validate` but outputs structured
4//! JSON describing the full compile plan: dependencies, items, outputs,
5//! and diagnostics. Designed for tooling that needs to inspect what mars
6//! would do without executing it.
7//!
8//! Constraints:
9//! - Read-only: no write-path side effects.
10//! - No rendered file bodies in output — only metadata.
11//! - No host-absolute paths in output except documented opaque command strings
12//!   (hook `command` fields, which are absolute by necessity).
13
14use serde::Serialize;
15
16use crate::cli::MarsContext;
17use crate::error::MarsError;
18use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
19
20/// JSON schema version for the export envelope.
21const SCHEMA_VERSION: u32 = 1;
22
23/// Arguments for `mars export`.
24#[derive(Debug, clap::Args)]
25pub struct ExportArgs {
26    // No extra flags for now — the command always outputs JSON.
27    // Future: --target <filter> to restrict to specific target roots.
28}
29
30// ── Output types ──────────────────────────────────────────────────────────────
31
32/// Top-level export envelope.
33///
34/// Schema versioned for forward compatibility. The `status` field indicates
35/// whether the compile plan completed or failed.
36#[derive(Debug, Serialize)]
37pub struct ExportEnvelope {
38    /// Format version — increment when the JSON shape changes incompatibly.
39    pub schema_version: u32,
40    /// Overall compile plan status.
41    pub status: ExportStatus,
42    /// Dependency metadata layer: what the project declares as dependencies.
43    pub dependencies: Vec<ExportDependency>,
44    /// Item layer: all items in the compile plan.
45    pub items: Vec<ExportItem>,
46    /// Output layer: per-item output records (dest paths, target roots).
47    pub outputs: Vec<ExportOutput>,
48    /// Diagnostic layer: all diagnostics from the pipeline.
49    pub diagnostics: Vec<ExportDiagnostic>,
50}
51
52/// Overall status of the compile plan.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "lowercase")]
55pub enum ExportStatus {
56    /// All items compiled successfully — no conflicts or errors.
57    Complete,
58    /// The compile pipeline failed entirely (resolver error, I/O error, etc.).
59    Failed,
60}
61
62/// One declared dependency from mars.toml.
63#[derive(Debug, Serialize)]
64pub struct ExportDependency {
65    /// Logical name of the dependency (key in [dependencies]).
66    pub name: String,
67    /// Resolved version tag or commit, if known.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub version: Option<String>,
70    /// Source origin kind: "git", "path", or "registry".
71    pub origin: String,
72}
73
74/// One item in the compile plan (agent, skill, hook, mcp, etc.).
75#[derive(Debug, Serialize)]
76pub struct ExportItem {
77    /// Item name.
78    pub name: String,
79    /// Item kind: "agent", "skill", "hook", "mcp-server", "bootstrap-doc".
80    pub kind: String,
81    /// Source dependency that provides this item.
82    pub source: String,
83    /// Planned action: "install", "overwrite", "skip", or "remove".
84    pub action: String,
85}
86
87/// One output record — where an item lands in the target.
88#[derive(Debug, Serialize)]
89pub struct ExportOutput {
90    /// Item name this output belongs to.
91    pub item_name: String,
92    /// Item kind.
93    pub kind: String,
94    /// Destination path within the managed directory (relative, no absolute prefix).
95    pub dest_path: String,
96    /// Source dependency name.
97    pub source: String,
98}
99
100/// One diagnostic in export output.
101#[derive(Debug, Serialize)]
102pub struct ExportDiagnostic {
103    pub level: &'static str,
104    pub code: &'static str,
105    pub message: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub context: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub category: Option<&'static str>,
110}
111
112// ── Command implementation ────────────────────────────────────────────────────
113
114/// Run `mars export`.
115///
116/// Always outputs JSON (the `--json` global flag is accepted but redundant here).
117pub fn run(_args: &ExportArgs, ctx: &MarsContext, _json: bool) -> Result<i32, MarsError> {
118    let request = SyncRequest {
119        resolution: ResolutionMode::Normal,
120        mutation: None,
121        options: SyncOptions {
122            dry_run: true,
123            ..SyncOptions::default()
124        },
125        recovery: Default::default(),
126        lossiness_mode: crate::diagnostic::LossinessMode::Hidden,
127    };
128
129    // Load config for dependency metadata (non-fatal: if missing, no dep metadata).
130    let config = crate::config::load(&ctx.project_root).unwrap_or_default();
131
132    // Build the dependency layer from declared dependencies in config.
133    let dependencies: Vec<ExportDependency> = config
134        .dependencies
135        .iter()
136        .chain(config.local_dependencies.iter())
137        .map(|(name, dep)| ExportDependency {
138            name: name.to_string(),
139            version: dep.version.clone(),
140            origin: infer_origin(dep),
141        })
142        .collect();
143
144    // Run the pipeline in dry-run mode to get the compile plan.
145    let (status, items, outputs, diagnostics) = match crate::sync::execute(ctx, &request) {
146        Ok(report) => {
147            let status = ExportStatus::Complete;
148
149            let mut items: Vec<ExportItem> = Vec::new();
150            let mut outputs: Vec<ExportOutput> = Vec::new();
151
152            for outcome in &report.applied.outcomes {
153                let action = action_label(&outcome.action);
154                let name = outcome.item_id.name.to_string();
155                let kind = kind_label(&outcome.item_id.kind);
156                let source = outcome.source_name.to_string();
157                let dest_path = outcome.dest_path.to_string();
158
159                items.push(ExportItem {
160                    name: name.clone(),
161                    kind: kind.clone(),
162                    source: source.clone(),
163                    action: action.to_string(),
164                });
165                outputs.push(ExportOutput {
166                    item_name: name,
167                    kind,
168                    dest_path,
169                    source,
170                });
171            }
172
173            let diagnostics = report
174                .diagnostics
175                .iter()
176                .map(export_diagnostic)
177                .collect::<Vec<_>>();
178
179            (status, items, outputs, diagnostics)
180        }
181        Err(err) => {
182            // Compile failed entirely — report as failed with the error as a diagnostic.
183            let diagnostics = vec![ExportDiagnostic {
184                level: "error",
185                code: "pipeline-failed",
186                message: err.to_string(),
187                context: None,
188                category: Some("config"),
189            }];
190            (ExportStatus::Failed, vec![], vec![], diagnostics)
191        }
192    };
193
194    let envelope = ExportEnvelope {
195        schema_version: SCHEMA_VERSION,
196        status,
197        dependencies,
198        items,
199        outputs,
200        diagnostics,
201    };
202
203    super::output::print_json(&envelope);
204    Ok(0)
205}
206
207// ── Helpers ───────────────────────────────────────────────────────────────────
208
209fn infer_origin(dep: &crate::config::InstallDep) -> String {
210    if dep.url.is_some() {
211        "git".to_string()
212    } else if dep.path.is_some() {
213        "path".to_string()
214    } else {
215        "registry".to_string()
216    }
217}
218
219fn action_label(action: &crate::sync::apply::ActionTaken) -> &'static str {
220    use crate::sync::apply::ActionTaken;
221    match action {
222        ActionTaken::Installed => "install",
223        ActionTaken::Updated => "overwrite",
224        ActionTaken::Removed => "remove",
225        ActionTaken::Skipped => "skip",
226        ActionTaken::Kept => "skip",
227    }
228}
229
230fn kind_label(kind: &crate::lock::ItemKind) -> String {
231    use crate::lock::ItemKind;
232    match kind {
233        ItemKind::Agent => "agent".to_string(),
234        ItemKind::Skill => "skill".to_string(),
235        ItemKind::Hook => "hook".to_string(),
236        ItemKind::McpServer => "mcp-server".to_string(),
237        ItemKind::BootstrapDoc => "bootstrap-doc".to_string(),
238    }
239}
240
241fn export_diagnostic(d: &crate::diagnostic::Diagnostic) -> ExportDiagnostic {
242    use crate::diagnostic::{DiagnosticCategory, DiagnosticLevel};
243    ExportDiagnostic {
244        level: match d.level {
245            DiagnosticLevel::Error => "error",
246            DiagnosticLevel::Warning => "warning",
247            DiagnosticLevel::Info => "info",
248        },
249        code: d.code,
250        message: d.message.clone(),
251        context: d.context.clone(),
252        category: d.category.map(|c| match c {
253            DiagnosticCategory::Compatibility => "compatibility",
254            DiagnosticCategory::Lossiness => "lossiness",
255            DiagnosticCategory::Validation => "validation",
256        }),
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn schema_version_is_nonzero() {
266        const { assert!(SCHEMA_VERSION >= 1) };
267    }
268
269    #[test]
270    fn export_status_serializes_lowercase() {
271        let complete = serde_json::to_string(&ExportStatus::Complete).unwrap();
272        let failed = serde_json::to_string(&ExportStatus::Failed).unwrap();
273        assert_eq!(complete, r#""complete""#);
274        assert_eq!(failed, r#""failed""#);
275    }
276
277    #[test]
278    fn envelope_includes_schema_version() {
279        let env = ExportEnvelope {
280            schema_version: 1,
281            status: ExportStatus::Complete,
282            dependencies: vec![],
283            items: vec![],
284            outputs: vec![],
285            diagnostics: vec![],
286        };
287        let json = serde_json::to_string(&env).unwrap();
288        assert!(
289            json.contains("\"schema_version\":1"),
290            "missing schema_version: {json}"
291        );
292    }
293
294    #[test]
295    fn envelope_no_file_bodies() {
296        // ExportEnvelope must not have any field that could hold file content.
297        // Verified structurally: ExportItem, ExportOutput, ExportDependency
298        // have no "content", "body", or "source_content" fields.
299        let item = ExportItem {
300            name: "coder".to_string(),
301            kind: "agent".to_string(),
302            source: "meridian-base".to_string(),
303            action: "install".to_string(),
304        };
305        let json = serde_json::to_string(&item).unwrap();
306        assert!(
307            !json.contains("content"),
308            "item should not have content field"
309        );
310        assert!(!json.contains("body"), "item should not have body field");
311    }
312
313    #[test]
314    fn export_dependency_origin_git() {
315        use crate::config::InstallDep;
316        use crate::types::SourceUrl;
317        let dep = InstallDep {
318            url: Some(SourceUrl::from("https://github.com/org/repo")),
319            path: None,
320            subpath: None,
321            version: None,
322            dialect: None,
323            filter: Default::default(),
324        };
325        assert_eq!(infer_origin(&dep), "git");
326    }
327
328    #[test]
329    fn export_dependency_origin_path() {
330        use crate::config::InstallDep;
331        let dep = InstallDep {
332            url: None,
333            path: Some(std::path::PathBuf::from("../local-pkg")),
334            subpath: None,
335            version: None,
336            dialect: None,
337            filter: Default::default(),
338        };
339        assert_eq!(infer_origin(&dep), "path");
340    }
341
342    #[test]
343    fn export_diagnostic_maps_levels() {
344        use crate::diagnostic::{Diagnostic, DiagnosticLevel};
345        let d = Diagnostic {
346            level: DiagnosticLevel::Error,
347            code: "test",
348            message: "msg".to_string(),
349            context: None,
350            category: None,
351        };
352        let ed = export_diagnostic(&d);
353        assert_eq!(ed.level, "error");
354        assert_eq!(ed.code, "test");
355        assert_eq!(ed.category, None);
356    }
357}