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