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 force: false,
125 dry_run: true,
126 frozen: false,
127 refresh_models: false,
128 no_refresh_models: false,
129 },
130 };
131
132 let config = crate::config::load(&ctx.project_root).unwrap_or_default();
134
135 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 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 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 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
236fn 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 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}