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 is complete, partial, 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    /// Some items have conflicts or warnings that prevent clean install.
59    Partial,
60    /// The compile pipeline failed entirely (resolver error, I/O error, etc.).
61    Failed,
62}
63
64/// One declared dependency from mars.toml.
65#[derive(Debug, Serialize)]
66pub struct ExportDependency {
67    /// Logical name of the dependency (key in [dependencies]).
68    pub name: String,
69    /// Resolved version tag or commit, if known.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub version: Option<String>,
72    /// Source origin kind: "git", "path", or "registry".
73    pub origin: String,
74}
75
76/// One item in the compile plan (agent, skill, hook, mcp, etc.).
77#[derive(Debug, Serialize)]
78pub struct ExportItem {
79    /// Item name.
80    pub name: String,
81    /// Item kind: "agent", "skill", "hook", "mcp-server", "bootstrap-doc".
82    pub kind: String,
83    /// Source dependency that provides this item.
84    pub source: String,
85    /// Planned action: "install", "overwrite", "skip", "conflict", "remove".
86    pub action: String,
87}
88
89/// One output record — where an item lands in the target.
90#[derive(Debug, Serialize)]
91pub struct ExportOutput {
92    /// Item name this output belongs to.
93    pub item_name: String,
94    /// Item kind.
95    pub kind: String,
96    /// Destination path within the managed directory (relative, no absolute prefix).
97    pub dest_path: String,
98    /// Source dependency name.
99    pub source: String,
100}
101
102/// One diagnostic in export output.
103#[derive(Debug, Serialize)]
104pub struct ExportDiagnostic {
105    pub level: &'static str,
106    pub code: &'static str,
107    pub message: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub context: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub category: Option<&'static str>,
112}
113
114// ── Command implementation ────────────────────────────────────────────────────
115
116/// Run `mars export`.
117///
118/// Always outputs JSON (the `--json` global flag is accepted but redundant here).
119pub fn run(_args: &ExportArgs, ctx: &MarsContext, _json: bool) -> Result<i32, MarsError> {
120    let request = SyncRequest {
121        resolution: ResolutionMode::Normal,
122        mutation: None,
123        options: SyncOptions {
124            force: false,
125            dry_run: true,
126            frozen: false,
127            no_refresh_models: false,
128        },
129    };
130
131    // Load config for dependency metadata (non-fatal: if missing, no dep metadata).
132    let config = crate::config::load(&ctx.project_root).unwrap_or_default();
133
134    // Build the dependency layer from declared dependencies in config.
135    let dependencies: Vec<ExportDependency> = config
136        .dependencies
137        .iter()
138        .chain(config.local_dependencies.iter())
139        .map(|(name, dep)| ExportDependency {
140            name: name.to_string(),
141            version: dep.version.clone(),
142            origin: infer_origin(dep),
143        })
144        .collect();
145
146    // Run the pipeline in dry-run mode to get the compile plan.
147    let (status, items, outputs, diagnostics) = match crate::sync::execute(ctx, &request) {
148        Ok(report) => {
149            let has_conflicts = report.has_conflicts();
150            let status = if has_conflicts {
151                ExportStatus::Partial
152            } else {
153                ExportStatus::Complete
154            };
155
156            let mut items: Vec<ExportItem> = Vec::new();
157            let mut outputs: Vec<ExportOutput> = Vec::new();
158
159            for outcome in &report.applied.outcomes {
160                let action = action_label(&outcome.action);
161                let name = outcome.item_id.name.to_string();
162                let kind = kind_label(&outcome.item_id.kind);
163                let source = outcome.source_name.to_string();
164                let dest_path = outcome.dest_path.to_string();
165
166                items.push(ExportItem {
167                    name: name.clone(),
168                    kind: kind.clone(),
169                    source: source.clone(),
170                    action: action.to_string(),
171                });
172                outputs.push(ExportOutput {
173                    item_name: name,
174                    kind,
175                    dest_path,
176                    source,
177                });
178            }
179
180            // Include pruned (remove) actions.
181            for outcome in &report.pruned {
182                let name = outcome.item_id.name.to_string();
183                let kind = kind_label(&outcome.item_id.kind);
184                let source = outcome.source_name.to_string();
185                let dest_path = outcome.dest_path.to_string();
186
187                items.push(ExportItem {
188                    name: name.clone(),
189                    kind: kind.clone(),
190                    source: source.clone(),
191                    action: "remove".to_string(),
192                });
193                outputs.push(ExportOutput {
194                    item_name: name,
195                    kind,
196                    dest_path,
197                    source,
198                });
199            }
200
201            let diagnostics = report
202                .diagnostics
203                .iter()
204                .map(export_diagnostic)
205                .collect::<Vec<_>>();
206
207            (status, items, outputs, diagnostics)
208        }
209        Err(err) => {
210            // Compile failed entirely — report as failed with the error as a diagnostic.
211            let diagnostics = vec![ExportDiagnostic {
212                level: "error",
213                code: "pipeline-failed",
214                message: err.to_string(),
215                context: None,
216                category: Some("config"),
217            }];
218            (ExportStatus::Failed, vec![], vec![], diagnostics)
219        }
220    };
221
222    let envelope = ExportEnvelope {
223        schema_version: SCHEMA_VERSION,
224        status,
225        dependencies,
226        items,
227        outputs,
228        diagnostics,
229    };
230
231    super::output::print_json(&envelope);
232    Ok(0)
233}
234
235// ── Helpers ───────────────────────────────────────────────────────────────────
236
237fn infer_origin(dep: &crate::config::InstallDep) -> String {
238    if dep.url.is_some() {
239        "git".to_string()
240    } else if dep.path.is_some() {
241        "path".to_string()
242    } else {
243        "registry".to_string()
244    }
245}
246
247fn action_label(action: &crate::sync::apply::ActionTaken) -> &'static str {
248    use crate::sync::apply::ActionTaken;
249    match action {
250        ActionTaken::Installed => "install",
251        ActionTaken::Updated => "overwrite",
252        ActionTaken::Merged => "merge",
253        ActionTaken::Conflicted => "conflict",
254        ActionTaken::Removed => "remove",
255        ActionTaken::Skipped => "skip",
256        ActionTaken::Kept => "skip",
257    }
258}
259
260fn kind_label(kind: &crate::lock::ItemKind) -> String {
261    use crate::lock::ItemKind;
262    match kind {
263        ItemKind::Agent => "agent".to_string(),
264        ItemKind::Skill => "skill".to_string(),
265        ItemKind::Hook => "hook".to_string(),
266        ItemKind::McpServer => "mcp-server".to_string(),
267        ItemKind::BootstrapDoc => "bootstrap-doc".to_string(),
268    }
269}
270
271fn export_diagnostic(d: &crate::diagnostic::Diagnostic) -> ExportDiagnostic {
272    use crate::diagnostic::{DiagnosticCategory, DiagnosticLevel};
273    ExportDiagnostic {
274        level: match d.level {
275            DiagnosticLevel::Error => "error",
276            DiagnosticLevel::Warning => "warning",
277            DiagnosticLevel::Info => "info",
278        },
279        code: d.code,
280        message: d.message.clone(),
281        context: d.context.clone(),
282        category: d.category.map(|c| match c {
283            DiagnosticCategory::Compatibility => "compatibility",
284            DiagnosticCategory::Lossiness => "lossiness",
285            DiagnosticCategory::Validation => "validation",
286            DiagnosticCategory::Config => "config",
287        }),
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn schema_version_is_nonzero() {
297        const { assert!(SCHEMA_VERSION >= 1) };
298    }
299
300    #[test]
301    fn export_status_serializes_lowercase() {
302        let complete = serde_json::to_string(&ExportStatus::Complete).unwrap();
303        let partial = serde_json::to_string(&ExportStatus::Partial).unwrap();
304        let failed = serde_json::to_string(&ExportStatus::Failed).unwrap();
305        assert_eq!(complete, r#""complete""#);
306        assert_eq!(partial, r#""partial""#);
307        assert_eq!(failed, r#""failed""#);
308    }
309
310    #[test]
311    fn envelope_includes_schema_version() {
312        let env = ExportEnvelope {
313            schema_version: 1,
314            status: ExportStatus::Complete,
315            dependencies: vec![],
316            items: vec![],
317            outputs: vec![],
318            diagnostics: vec![],
319        };
320        let json = serde_json::to_string(&env).unwrap();
321        assert!(
322            json.contains("\"schema_version\":1"),
323            "missing schema_version: {json}"
324        );
325    }
326
327    #[test]
328    fn envelope_no_file_bodies() {
329        // ExportEnvelope must not have any field that could hold file content.
330        // Verified structurally: ExportItem, ExportOutput, ExportDependency
331        // have no "content", "body", or "source_content" fields.
332        let item = ExportItem {
333            name: "coder".to_string(),
334            kind: "agent".to_string(),
335            source: "meridian-base".to_string(),
336            action: "install".to_string(),
337        };
338        let json = serde_json::to_string(&item).unwrap();
339        assert!(
340            !json.contains("content"),
341            "item should not have content field"
342        );
343        assert!(!json.contains("body"), "item should not have body field");
344    }
345
346    #[test]
347    fn export_dependency_origin_git() {
348        use crate::config::InstallDep;
349        use crate::types::SourceUrl;
350        let dep = InstallDep {
351            url: Some(SourceUrl::from("https://github.com/org/repo")),
352            path: None,
353            subpath: None,
354            version: None,
355            filter: Default::default(),
356        };
357        assert_eq!(infer_origin(&dep), "git");
358    }
359
360    #[test]
361    fn export_dependency_origin_path() {
362        use crate::config::InstallDep;
363        let dep = InstallDep {
364            url: None,
365            path: Some(std::path::PathBuf::from("../local-pkg")),
366            subpath: None,
367            version: None,
368            filter: Default::default(),
369        };
370        assert_eq!(infer_origin(&dep), "path");
371    }
372
373    #[test]
374    fn export_diagnostic_maps_levels() {
375        use crate::diagnostic::{Diagnostic, DiagnosticLevel};
376        let d = Diagnostic {
377            level: DiagnosticLevel::Error,
378            code: "test",
379            message: "msg".to_string(),
380            context: None,
381            category: None,
382        };
383        let ed = export_diagnostic(&d);
384        assert_eq!(ed.level, "error");
385        assert_eq!(ed.code, "test");
386        assert_eq!(ed.category, None);
387    }
388}