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 Failed,
60}
61
62#[derive(Debug, Serialize)]
64pub struct ExportDependency {
65 pub name: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub version: Option<String>,
70 pub origin: String,
72}
73
74#[derive(Debug, Serialize)]
76pub struct ExportItem {
77 pub name: String,
79 pub kind: String,
81 pub source: String,
83 pub action: String,
85}
86
87#[derive(Debug, Serialize)]
89pub struct ExportOutput {
90 pub item_name: String,
92 pub kind: String,
94 pub dest_path: String,
96 pub source: String,
98}
99
100#[derive(Debug, Serialize)]
102pub struct ExportDiagnostic {
103 pub level: &'static str,
104 pub code: &'static str,
105 pub message: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub context: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub category: Option<&'static str>,
110}
111
112pub fn run(_args: &ExportArgs, ctx: &MarsContext, _json: bool) -> Result<i32, MarsError> {
118 let request = SyncRequest {
119 resolution: ResolutionMode::Normal,
120 mutation: None,
121 options: SyncOptions {
122 dry_run: true,
123 ..SyncOptions::default()
124 },
125 recovery: Default::default(),
126 lossiness_mode: crate::diagnostic::LossinessMode::Hidden,
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 status = ExportStatus::Complete;
148
149 let mut items: Vec<ExportItem> = Vec::new();
150 let mut outputs: Vec<ExportOutput> = Vec::new();
151
152 for outcome in &report.applied.outcomes {
153 let action = action_label(&outcome.action);
154 let name = outcome.item_id.name.to_string();
155 let kind = kind_label(&outcome.item_id.kind);
156 let source = outcome.source_name.to_string();
157 let dest_path = outcome.dest_path.to_string();
158
159 items.push(ExportItem {
160 name: name.clone(),
161 kind: kind.clone(),
162 source: source.clone(),
163 action: action.to_string(),
164 });
165 outputs.push(ExportOutput {
166 item_name: name,
167 kind,
168 dest_path,
169 source,
170 });
171 }
172
173 let diagnostics = report
174 .diagnostics
175 .iter()
176 .map(export_diagnostic)
177 .collect::<Vec<_>>();
178
179 (status, items, outputs, diagnostics)
180 }
181 Err(err) => {
182 let diagnostics = vec![ExportDiagnostic {
184 level: "error",
185 code: "pipeline-failed",
186 message: err.to_string(),
187 context: None,
188 category: Some("config"),
189 }];
190 (ExportStatus::Failed, vec![], vec![], diagnostics)
191 }
192 };
193
194 let envelope = ExportEnvelope {
195 schema_version: SCHEMA_VERSION,
196 status,
197 dependencies,
198 items,
199 outputs,
200 diagnostics,
201 };
202
203 super::output::print_json(&envelope);
204 Ok(0)
205}
206
207fn infer_origin(dep: &crate::config::InstallDep) -> String {
210 if dep.url.is_some() {
211 "git".to_string()
212 } else if dep.path.is_some() {
213 "path".to_string()
214 } else {
215 "registry".to_string()
216 }
217}
218
219fn action_label(action: &crate::sync::apply::ActionTaken) -> &'static str {
220 use crate::sync::apply::ActionTaken;
221 match action {
222 ActionTaken::Installed => "install",
223 ActionTaken::Updated => "overwrite",
224 ActionTaken::Removed => "remove",
225 ActionTaken::Skipped => "skip",
226 ActionTaken::Kept => "skip",
227 }
228}
229
230fn kind_label(kind: &crate::lock::ItemKind) -> String {
231 use crate::lock::ItemKind;
232 match kind {
233 ItemKind::Agent => "agent".to_string(),
234 ItemKind::Skill => "skill".to_string(),
235 ItemKind::Hook => "hook".to_string(),
236 ItemKind::McpServer => "mcp-server".to_string(),
237 ItemKind::BootstrapDoc => "bootstrap-doc".to_string(),
238 }
239}
240
241fn export_diagnostic(d: &crate::diagnostic::Diagnostic) -> ExportDiagnostic {
242 use crate::diagnostic::{DiagnosticCategory, DiagnosticLevel};
243 ExportDiagnostic {
244 level: match d.level {
245 DiagnosticLevel::Error => "error",
246 DiagnosticLevel::Warning => "warning",
247 DiagnosticLevel::Info => "info",
248 },
249 code: d.code,
250 message: d.message.clone(),
251 context: d.context.clone(),
252 category: d.category.map(|c| match c {
253 DiagnosticCategory::Compatibility => "compatibility",
254 DiagnosticCategory::Lossiness => "lossiness",
255 DiagnosticCategory::Validation => "validation",
256 }),
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn schema_version_is_nonzero() {
266 const { assert!(SCHEMA_VERSION >= 1) };
267 }
268
269 #[test]
270 fn export_status_serializes_lowercase() {
271 let complete = serde_json::to_string(&ExportStatus::Complete).unwrap();
272 let failed = serde_json::to_string(&ExportStatus::Failed).unwrap();
273 assert_eq!(complete, r#""complete""#);
274 assert_eq!(failed, r#""failed""#);
275 }
276
277 #[test]
278 fn envelope_includes_schema_version() {
279 let env = ExportEnvelope {
280 schema_version: 1,
281 status: ExportStatus::Complete,
282 dependencies: vec![],
283 items: vec![],
284 outputs: vec![],
285 diagnostics: vec![],
286 };
287 let json = serde_json::to_string(&env).unwrap();
288 assert!(
289 json.contains("\"schema_version\":1"),
290 "missing schema_version: {json}"
291 );
292 }
293
294 #[test]
295 fn envelope_no_file_bodies() {
296 let item = ExportItem {
300 name: "coder".to_string(),
301 kind: "agent".to_string(),
302 source: "meridian-base".to_string(),
303 action: "install".to_string(),
304 };
305 let json = serde_json::to_string(&item).unwrap();
306 assert!(
307 !json.contains("content"),
308 "item should not have content field"
309 );
310 assert!(!json.contains("body"), "item should not have body field");
311 }
312
313 #[test]
314 fn export_dependency_origin_git() {
315 use crate::config::InstallDep;
316 use crate::types::SourceUrl;
317 let dep = InstallDep {
318 url: Some(SourceUrl::from("https://github.com/org/repo")),
319 path: None,
320 subpath: None,
321 version: None,
322 dialect: None,
323 filter: Default::default(),
324 };
325 assert_eq!(infer_origin(&dep), "git");
326 }
327
328 #[test]
329 fn export_dependency_origin_path() {
330 use crate::config::InstallDep;
331 let dep = InstallDep {
332 url: None,
333 path: Some(std::path::PathBuf::from("../local-pkg")),
334 subpath: None,
335 version: None,
336 dialect: None,
337 filter: Default::default(),
338 };
339 assert_eq!(infer_origin(&dep), "path");
340 }
341
342 #[test]
343 fn export_diagnostic_maps_levels() {
344 use crate::diagnostic::{Diagnostic, DiagnosticLevel};
345 let d = Diagnostic {
346 level: DiagnosticLevel::Error,
347 code: "test",
348 message: "msg".to_string(),
349 context: None,
350 category: None,
351 };
352 let ed = export_diagnostic(&d);
353 assert_eq!(ed.level, "error");
354 assert_eq!(ed.code, "test");
355 assert_eq!(ed.category, None);
356 }
357}