use serde::Serialize;
use crate::cli::MarsContext;
use crate::error::MarsError;
use crate::sync::{ResolutionMode, SyncOptions, SyncRequest};
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, clap::Args)]
pub struct ExportArgs {
}
#[derive(Debug, Serialize)]
pub struct ExportEnvelope {
pub schema_version: u32,
pub status: ExportStatus,
pub dependencies: Vec<ExportDependency>,
pub items: Vec<ExportItem>,
pub outputs: Vec<ExportOutput>,
pub diagnostics: Vec<ExportDiagnostic>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ExportStatus {
Complete,
Partial,
Failed,
}
#[derive(Debug, Serialize)]
pub struct ExportDependency {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub origin: String,
}
#[derive(Debug, Serialize)]
pub struct ExportItem {
pub name: String,
pub kind: String,
pub source: String,
pub action: String,
}
#[derive(Debug, Serialize)]
pub struct ExportOutput {
pub item_name: String,
pub kind: String,
pub dest_path: String,
pub source: String,
}
#[derive(Debug, Serialize)]
pub struct ExportDiagnostic {
pub level: &'static str,
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<&'static str>,
}
pub fn run(_args: &ExportArgs, ctx: &MarsContext, _json: bool) -> Result<i32, MarsError> {
let request = SyncRequest {
resolution: ResolutionMode::Normal,
mutation: None,
options: SyncOptions {
force: false,
dry_run: true,
frozen: false,
refresh_models: false,
no_refresh_models: false,
},
};
let config = crate::config::load(&ctx.project_root).unwrap_or_default();
let dependencies: Vec<ExportDependency> = config
.dependencies
.iter()
.chain(config.local_dependencies.iter())
.map(|(name, dep)| ExportDependency {
name: name.to_string(),
version: dep.version.clone(),
origin: infer_origin(dep),
})
.collect();
let (status, items, outputs, diagnostics) = match crate::sync::execute(ctx, &request) {
Ok(report) => {
let has_conflicts = report.has_conflicts();
let status = if has_conflicts {
ExportStatus::Partial
} else {
ExportStatus::Complete
};
let mut items: Vec<ExportItem> = Vec::new();
let mut outputs: Vec<ExportOutput> = Vec::new();
for outcome in &report.applied.outcomes {
let action = action_label(&outcome.action);
let name = outcome.item_id.name.to_string();
let kind = kind_label(&outcome.item_id.kind);
let source = outcome.source_name.to_string();
let dest_path = outcome.dest_path.to_string();
items.push(ExportItem {
name: name.clone(),
kind: kind.clone(),
source: source.clone(),
action: action.to_string(),
});
outputs.push(ExportOutput {
item_name: name,
kind,
dest_path,
source,
});
}
for outcome in &report.pruned {
let name = outcome.item_id.name.to_string();
let kind = kind_label(&outcome.item_id.kind);
let source = outcome.source_name.to_string();
let dest_path = outcome.dest_path.to_string();
items.push(ExportItem {
name: name.clone(),
kind: kind.clone(),
source: source.clone(),
action: "remove".to_string(),
});
outputs.push(ExportOutput {
item_name: name,
kind,
dest_path,
source,
});
}
let diagnostics = report
.diagnostics
.iter()
.map(export_diagnostic)
.collect::<Vec<_>>();
(status, items, outputs, diagnostics)
}
Err(err) => {
let diagnostics = vec![ExportDiagnostic {
level: "error",
code: "pipeline-failed",
message: err.to_string(),
context: None,
category: Some("config"),
}];
(ExportStatus::Failed, vec![], vec![], diagnostics)
}
};
let envelope = ExportEnvelope {
schema_version: SCHEMA_VERSION,
status,
dependencies,
items,
outputs,
diagnostics,
};
super::output::print_json(&envelope);
Ok(0)
}
fn infer_origin(dep: &crate::config::InstallDep) -> String {
if dep.url.is_some() {
"git".to_string()
} else if dep.path.is_some() {
"path".to_string()
} else {
"registry".to_string()
}
}
fn action_label(action: &crate::sync::apply::ActionTaken) -> &'static str {
use crate::sync::apply::ActionTaken;
match action {
ActionTaken::Installed => "install",
ActionTaken::Updated => "overwrite",
ActionTaken::Merged => "merge",
ActionTaken::Conflicted => "conflict",
ActionTaken::Removed => "remove",
ActionTaken::Skipped => "skip",
ActionTaken::Kept => "skip",
}
}
fn kind_label(kind: &crate::lock::ItemKind) -> String {
use crate::lock::ItemKind;
match kind {
ItemKind::Agent => "agent".to_string(),
ItemKind::Skill => "skill".to_string(),
ItemKind::Hook => "hook".to_string(),
ItemKind::McpServer => "mcp-server".to_string(),
ItemKind::BootstrapDoc => "bootstrap-doc".to_string(),
}
}
fn export_diagnostic(d: &crate::diagnostic::Diagnostic) -> ExportDiagnostic {
use crate::diagnostic::{DiagnosticCategory, DiagnosticLevel};
ExportDiagnostic {
level: match d.level {
DiagnosticLevel::Error => "error",
DiagnosticLevel::Warning => "warning",
DiagnosticLevel::Info => "info",
},
code: d.code,
message: d.message.clone(),
context: d.context.clone(),
category: d.category.map(|c| match c {
DiagnosticCategory::Compatibility => "compatibility",
DiagnosticCategory::Lossiness => "lossiness",
DiagnosticCategory::Validation => "validation",
DiagnosticCategory::Config => "config",
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_is_nonzero() {
const { assert!(SCHEMA_VERSION >= 1) };
}
#[test]
fn export_status_serializes_lowercase() {
let complete = serde_json::to_string(&ExportStatus::Complete).unwrap();
let partial = serde_json::to_string(&ExportStatus::Partial).unwrap();
let failed = serde_json::to_string(&ExportStatus::Failed).unwrap();
assert_eq!(complete, r#""complete""#);
assert_eq!(partial, r#""partial""#);
assert_eq!(failed, r#""failed""#);
}
#[test]
fn envelope_includes_schema_version() {
let env = ExportEnvelope {
schema_version: 1,
status: ExportStatus::Complete,
dependencies: vec![],
items: vec![],
outputs: vec![],
diagnostics: vec![],
};
let json = serde_json::to_string(&env).unwrap();
assert!(
json.contains("\"schema_version\":1"),
"missing schema_version: {json}"
);
}
#[test]
fn envelope_no_file_bodies() {
let item = ExportItem {
name: "coder".to_string(),
kind: "agent".to_string(),
source: "meridian-base".to_string(),
action: "install".to_string(),
};
let json = serde_json::to_string(&item).unwrap();
assert!(
!json.contains("content"),
"item should not have content field"
);
assert!(!json.contains("body"), "item should not have body field");
}
#[test]
fn export_dependency_origin_git() {
use crate::config::InstallDep;
use crate::types::SourceUrl;
let dep = InstallDep {
url: Some(SourceUrl::from("https://github.com/org/repo")),
path: None,
subpath: None,
version: None,
filter: Default::default(),
};
assert_eq!(infer_origin(&dep), "git");
}
#[test]
fn export_dependency_origin_path() {
use crate::config::InstallDep;
let dep = InstallDep {
url: None,
path: Some(std::path::PathBuf::from("../local-pkg")),
subpath: None,
version: None,
filter: Default::default(),
};
assert_eq!(infer_origin(&dep), "path");
}
#[test]
fn export_diagnostic_maps_levels() {
use crate::diagnostic::{Diagnostic, DiagnosticLevel};
let d = Diagnostic {
level: DiagnosticLevel::Error,
code: "test",
message: "msg".to_string(),
context: None,
category: None,
};
let ed = export_diagnostic(&d);
assert_eq!(ed.level, "error");
assert_eq!(ed.code, "test");
assert_eq!(ed.category, None);
}
}