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            dry_run: true,
125            ..SyncOptions::default()
126        },
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 has_conflicts = report.has_conflicts();
148            let status = if has_conflicts {
149                ExportStatus::Partial
150            } else {
151                ExportStatus::Complete
152            };
153
154            let mut items: Vec<ExportItem> = Vec::new();
155            let mut outputs: Vec<ExportOutput> = Vec::new();
156
157            for outcome in &report.applied.outcomes {
158                let action = action_label(&outcome.action);
159                let name = outcome.item_id.name.to_string();
160                let kind = kind_label(&outcome.item_id.kind);
161                let source = outcome.source_name.to_string();
162                let dest_path = outcome.dest_path.to_string();
163
164                items.push(ExportItem {
165                    name: name.clone(),
166                    kind: kind.clone(),
167                    source: source.clone(),
168                    action: action.to_string(),
169                });
170                outputs.push(ExportOutput {
171                    item_name: name,
172                    kind,
173                    dest_path,
174                    source,
175                });
176            }
177
178            // Include pruned (remove) actions.
179            for outcome in &report.pruned {
180                let name = outcome.item_id.name.to_string();
181                let kind = kind_label(&outcome.item_id.kind);
182                let source = outcome.source_name.to_string();
183                let dest_path = outcome.dest_path.to_string();
184
185                items.push(ExportItem {
186                    name: name.clone(),
187                    kind: kind.clone(),
188                    source: source.clone(),
189                    action: "remove".to_string(),
190                });
191                outputs.push(ExportOutput {
192                    item_name: name,
193                    kind,
194                    dest_path,
195                    source,
196                });
197            }
198
199            let diagnostics = report
200                .diagnostics
201                .iter()
202                .map(export_diagnostic)
203                .collect::<Vec<_>>();
204
205            (status, items, outputs, diagnostics)
206        }
207        Err(err) => {
208            // Compile failed entirely — report as failed with the error as a diagnostic.
209            let diagnostics = vec![ExportDiagnostic {
210                level: "error",
211                code: "pipeline-failed",
212                message: err.to_string(),
213                context: None,
214                category: Some("config"),
215            }];
216            (ExportStatus::Failed, vec![], vec![], diagnostics)
217        }
218    };
219
220    let envelope = ExportEnvelope {
221        schema_version: SCHEMA_VERSION,
222        status,
223        dependencies,
224        items,
225        outputs,
226        diagnostics,
227    };
228
229    super::output::print_json(&envelope);
230    Ok(0)
231}
232
233// ── Helpers ───────────────────────────────────────────────────────────────────
234
235fn infer_origin(dep: &crate::config::InstallDep) -> String {
236    if dep.url.is_some() {
237        "git".to_string()
238    } else if dep.path.is_some() {
239        "path".to_string()
240    } else {
241        "registry".to_string()
242    }
243}
244
245fn action_label(action: &crate::sync::apply::ActionTaken) -> &'static str {
246    use crate::sync::apply::ActionTaken;
247    match action {
248        ActionTaken::Installed => "install",
249        ActionTaken::Updated => "overwrite",
250        ActionTaken::Merged => "merge",
251        ActionTaken::Conflicted => "conflict",
252        ActionTaken::Removed => "remove",
253        ActionTaken::Skipped => "skip",
254        ActionTaken::Kept => "skip",
255    }
256}
257
258fn kind_label(kind: &crate::lock::ItemKind) -> String {
259    use crate::lock::ItemKind;
260    match kind {
261        ItemKind::Agent => "agent".to_string(),
262        ItemKind::Skill => "skill".to_string(),
263        ItemKind::Hook => "hook".to_string(),
264        ItemKind::McpServer => "mcp-server".to_string(),
265        ItemKind::BootstrapDoc => "bootstrap-doc".to_string(),
266    }
267}
268
269fn export_diagnostic(d: &crate::diagnostic::Diagnostic) -> ExportDiagnostic {
270    use crate::diagnostic::{DiagnosticCategory, DiagnosticLevel};
271    ExportDiagnostic {
272        level: match d.level {
273            DiagnosticLevel::Error => "error",
274            DiagnosticLevel::Warning => "warning",
275            DiagnosticLevel::Info => "info",
276        },
277        code: d.code,
278        message: d.message.clone(),
279        context: d.context.clone(),
280        category: d.category.map(|c| match c {
281            DiagnosticCategory::Compatibility => "compatibility",
282            DiagnosticCategory::Lossiness => "lossiness",
283            DiagnosticCategory::Validation => "validation",
284            DiagnosticCategory::Config => "config",
285        }),
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn schema_version_is_nonzero() {
295        const { assert!(SCHEMA_VERSION >= 1) };
296    }
297
298    #[test]
299    fn export_status_serializes_lowercase() {
300        let complete = serde_json::to_string(&ExportStatus::Complete).unwrap();
301        let partial = serde_json::to_string(&ExportStatus::Partial).unwrap();
302        let failed = serde_json::to_string(&ExportStatus::Failed).unwrap();
303        assert_eq!(complete, r#""complete""#);
304        assert_eq!(partial, r#""partial""#);
305        assert_eq!(failed, r#""failed""#);
306    }
307
308    #[test]
309    fn envelope_includes_schema_version() {
310        let env = ExportEnvelope {
311            schema_version: 1,
312            status: ExportStatus::Complete,
313            dependencies: vec![],
314            items: vec![],
315            outputs: vec![],
316            diagnostics: vec![],
317        };
318        let json = serde_json::to_string(&env).unwrap();
319        assert!(
320            json.contains("\"schema_version\":1"),
321            "missing schema_version: {json}"
322        );
323    }
324
325    #[test]
326    fn envelope_no_file_bodies() {
327        // ExportEnvelope must not have any field that could hold file content.
328        // Verified structurally: ExportItem, ExportOutput, ExportDependency
329        // have no "content", "body", or "source_content" fields.
330        let item = ExportItem {
331            name: "coder".to_string(),
332            kind: "agent".to_string(),
333            source: "meridian-base".to_string(),
334            action: "install".to_string(),
335        };
336        let json = serde_json::to_string(&item).unwrap();
337        assert!(
338            !json.contains("content"),
339            "item should not have content field"
340        );
341        assert!(!json.contains("body"), "item should not have body field");
342    }
343
344    #[test]
345    fn export_dependency_origin_git() {
346        use crate::config::InstallDep;
347        use crate::types::SourceUrl;
348        let dep = InstallDep {
349            url: Some(SourceUrl::from("https://github.com/org/repo")),
350            path: None,
351            subpath: None,
352            version: None,
353            filter: Default::default(),
354        };
355        assert_eq!(infer_origin(&dep), "git");
356    }
357
358    #[test]
359    fn export_dependency_origin_path() {
360        use crate::config::InstallDep;
361        let dep = InstallDep {
362            url: None,
363            path: Some(std::path::PathBuf::from("../local-pkg")),
364            subpath: None,
365            version: None,
366            filter: Default::default(),
367        };
368        assert_eq!(infer_origin(&dep), "path");
369    }
370
371    #[test]
372    fn export_diagnostic_maps_levels() {
373        use crate::diagnostic::{Diagnostic, DiagnosticLevel};
374        let d = Diagnostic {
375            level: DiagnosticLevel::Error,
376            code: "test",
377            message: "msg".to_string(),
378            context: None,
379            category: None,
380        };
381        let ed = export_diagnostic(&d);
382        assert_eq!(ed.level, "error");
383        assert_eq!(ed.code, "test");
384        assert_eq!(ed.category, None);
385    }
386}