1use serde::Serialize;
15
16use crate::cli::MarsContext;
17use crate::error::MarsError;
18use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
19
20const SCHEMA_VERSION: u32 = 1;
22
23#[derive(Debug, clap::Args)]
25pub struct ExportArgs {
26 }
29
30#[derive(Debug, Serialize)]
37pub struct ExportEnvelope {
38 pub schema_version: u32,
40 pub status: ExportStatus,
42 pub dependencies: Vec<ExportDependency>,
44 pub items: Vec<ExportItem>,
46 pub outputs: Vec<ExportOutput>,
48 pub diagnostics: Vec<ExportDiagnostic>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "lowercase")]
55pub enum ExportStatus {
56 Complete,
58 Partial,
60 Failed,
62}
63
64#[derive(Debug, Serialize)]
66pub struct ExportDependency {
67 pub name: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub version: Option<String>,
72 pub origin: String,
74}
75
76#[derive(Debug, Serialize)]
78pub struct ExportItem {
79 pub name: String,
81 pub kind: String,
83 pub source: String,
85 pub action: String,
87}
88
89#[derive(Debug, Serialize)]
91pub struct ExportOutput {
92 pub item_name: String,
94 pub kind: String,
96 pub dest_path: String,
98 pub source: String,
100}
101
102#[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
114pub 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 let config = crate::config::load(&ctx.project_root).unwrap_or_default();
131
132 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 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 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 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
233fn 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 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}