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