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