use serde::Serialize;
use std::path::PathBuf;
#[derive(Debug, Serialize)]
pub(crate) struct YankOutput {
pub name: String,
pub version: String,
pub published_at: String,
pub outcome: YankOutcomeWire,
pub index_path: PathBuf,
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum YankOutcomeWire {
Yanked,
Unyanked,
AlreadyYanked,
AlreadyUnyanked,
}
#[derive(Debug, Serialize)]
pub(crate) struct PackageOutput {
pub artifact_path: PathBuf,
pub index_path: PathBuf,
pub hash: String,
pub new_entry_name: String,
pub new_entry_version: String,
pub new_entry_published_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct IndexSearchOutput {
pub hits: Vec<IndexSearchHitOutput>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct IndexSearchHitOutput {
pub name: String,
pub version: String,
pub published_at: String,
pub description: String,
pub triggers: Vec<String>,
pub visibility: IndexVisibilityOutput,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub(crate) enum IndexVisibilityOutput {
Visible,
Hidden {
reasons: Vec<IndexVisibilityReasonOutput>,
},
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum IndexVisibilityReasonOutput {
Yanked,
IncompatibleDatabaseVersion { required: String, actual: String },
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub(crate) enum IndexInfoOutput {
Found {
plugin: Box<IndexInfoPluginOutput>,
},
NotFound {
name: String,
version: Option<String>,
},
FilteredOut {
name: String,
version: Option<String>,
reasons: Vec<IndexVisibilityReasonOutput>,
},
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct IndexInfoPluginOutput {
pub name: String,
pub version: String,
pub published_at: String,
pub description: String,
pub triggers: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub documentation: Option<String>,
pub artifact_url: String,
pub dependencies: IndexDependenciesOutput,
pub hash: String,
pub visibility: IndexVisibilityOutput,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct IndexDependenciesOutput {
pub database_version: String,
pub python: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ListOutput {
pub templates: Vec<ListTemplate>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ListTemplate {
pub name: &'static str,
pub short_name: &'static str,
}
#[derive(Debug, Serialize)]
pub(crate) struct NewOutput {
pub kind: &'static str,
pub template: &'static str,
pub target_dir: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub files_written: Vec<PathBuf>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "lowercase")]
pub(crate) enum Envelope<R: Serialize> {
Ok { result: R },
Error { error: JsonError },
}
#[derive(Debug, Clone, Serialize)]
pub struct JsonError {
pub code: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<JsonError>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cause: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ValidateResult {}
pub(crate) fn write_envelope_ok<W: std::io::Write, R: Serialize>(
writer: &mut W,
result: R,
) -> std::io::Result<()> {
let env = Envelope::Ok { result };
serde_json::to_writer(&mut *writer, &env).map_err(std::io::Error::other)?;
writer.write_all(b"\n")
}
pub fn write_envelope_error<W: std::io::Write>(
writer: &mut W,
error: &JsonError,
) -> std::io::Result<()> {
let env: Envelope<()> = Envelope::Error {
error: error.clone(),
};
serde_json::to_writer(&mut *writer, &env).map_err(std::io::Error::other)?;
writer.write_all(b"\n")
}
#[cfg(test)]
mod envelope_tests {
use super::*;
use serde::Serialize;
const ALL_WIRE_CODES: &[&str] = &[
"validate::failed",
"validate::schema_reported",
"validate::missing_required_file",
"validate::python_parse",
"validate::trigger_not_implemented",
"validate::async_trigger_fn",
"validate::name_version_conflict",
"validate::index_read_failed",
"validate::schema_error",
"validate::io_failed",
"validate::invalid_exclude_pattern",
"validate::sdk_error",
"package::canonical_collision",
"package::already_published",
"package::path_too_long",
"package::archive_failed",
"package::hash_failed",
"package::schema_error",
"package::path_overlap",
"package::index_parse_failed",
"package::io_failed",
"package::invalid_exclude_pattern",
"package::sdk_error",
"index::index_read_failed",
"index::index_parse_failed",
"yank::entry_not_found",
"yank::index_parse_failed",
"yank::schema_error",
"yank::io_failed",
"yank::sdk_error",
"new::scaffold_failed",
"new::derived_name_invalid",
"new::derived_name_unavailable",
"new::path_resolution_failed",
"new::sdk_error",
"io::read_failed",
"io::write_failed",
"io::canonicalize_failed",
"path::resolution_failed",
"usage::missing_required_argument",
"usage::invalid_value",
"usage::value_validation",
"usage::unknown_argument",
"usage::invalid_subcommand",
"usage::missing_subcommand",
"usage::too_many_values",
"usage::too_few_values",
"usage::parse_error",
"usage::invalid_name",
"usage::invalid_artifacts_url",
"usage::invalid_database_version",
"usage::invalid_target",
"usage::input_output_overlap",
"usage::sibling_canonical_collision",
"cli::unknown",
];
#[derive(Serialize)]
struct Demo {
a: u32,
}
#[test]
fn json_envelope_ok_serializes_shape() {
let env = Envelope::Ok {
result: Demo { a: 7 },
};
let s = serde_json::to_string(&env).unwrap();
assert_eq!(s, r#"{"status":"ok","result":{"a":7}}"#);
}
#[test]
fn json_envelope_error_serializes_shape() {
let env: Envelope<()> = Envelope::Error {
error: JsonError {
code: "x::y".into(),
message: "msg".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
},
};
let s = serde_json::to_string(&env).unwrap();
assert_eq!(
s,
r#"{"status":"error","error":{"code":"x::y","message":"msg"}}"#
);
}
#[test]
fn json_error_omits_empty_optional_fields() {
let e = JsonError {
code: "c".into(),
message: "m".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
};
let s = serde_json::to_string(&e).unwrap();
assert_eq!(s, r#"{"code":"c","message":"m"}"#);
}
#[test]
fn json_error_keeps_non_empty_fields() {
let e = JsonError {
code: "c".into(),
message: "m".into(),
field: Some("f".into()),
details: Some(serde_json::json!({"k": "v"})),
diagnostics: vec![JsonError {
code: "sub::a".into(),
message: "sm".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
}],
cause: vec!["root cause".into()],
};
let s = serde_json::to_string(&e).unwrap();
assert!(s.contains(r#""field":"f""#));
assert!(s.contains(r#""details":{"k":"v"}"#));
assert!(s.contains(r#""diagnostics":[{"code":"sub::a","message":"sm"}]"#));
assert!(s.contains(r#""cause":["root cause"]"#));
}
#[test]
fn validate_success_result_is_empty_object() {
let env = Envelope::Ok {
result: ValidateResult {},
};
let s = serde_json::to_string(&env).unwrap();
assert_eq!(s, r#"{"status":"ok","result":{}}"#);
}
#[test]
fn write_envelope_ok_writes_compact_with_trailing_newline() {
let mut buf = Vec::new();
write_envelope_ok(&mut buf, Demo { a: 1 }).unwrap();
assert_eq!(buf, b"{\"status\":\"ok\",\"result\":{\"a\":1}}\n");
}
#[test]
fn write_envelope_error_writes_compact_with_trailing_newline() {
let err = JsonError {
code: "c".into(),
message: "m".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
};
let mut buf = Vec::new();
write_envelope_error(&mut buf, &err).unwrap();
assert_eq!(
buf,
b"{\"status\":\"error\",\"error\":{\"code\":\"c\",\"message\":\"m\"}}\n"
);
}
#[test]
fn json_output_is_compact_single_newline() {
let mut buf = Vec::new();
write_envelope_ok(&mut buf, Demo { a: 0 }).unwrap();
let s = std::str::from_utf8(&buf).unwrap();
assert!(!s.contains('\n') || s.ends_with('\n'));
assert_eq!(s.matches('\n').count(), 1);
assert!(!s.contains(" "));
}
#[test]
fn yank_output_outcome_serializes_four_case_enum() {
let payload = YankOutput {
name: "p".into(),
version: "1.0.0".into(),
published_at: "2026-04-29T18:45:12Z".into(),
outcome: YankOutcomeWire::Yanked,
index_path: std::path::PathBuf::from("/abs/idx.json"),
};
let s = serde_json::to_string(&payload).unwrap();
assert!(s.contains(r#""outcome":"yanked""#));
assert!(!s.contains("target_state"));
}
#[test]
fn yank_outcome_values_stable() {
let cases = [
YankOutcomeWire::Yanked,
YankOutcomeWire::Unyanked,
YankOutcomeWire::AlreadyYanked,
YankOutcomeWire::AlreadyUnyanked,
];
let strings: Vec<String> = cases
.iter()
.map(|c| serde_json::to_string(c).unwrap())
.collect();
assert_eq!(
strings,
vec![
r#""yanked""#,
r#""unyanked""#,
r#""already_yanked""#,
r#""already_unyanked""#,
],
);
}
#[test]
fn code_allocations_stable() {
insta::assert_yaml_snapshot!(ALL_WIRE_CODES);
}
#[test]
fn envelope_field_names_stable() {
let ok = serde_json::to_value(Envelope::Ok {
result: Demo { a: 0 },
})
.unwrap();
let err: serde_json::Value = serde_json::to_value(Envelope::<()>::Error {
error: JsonError {
code: "c".into(),
message: "m".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
},
})
.unwrap();
let ok_keys: Vec<_> = ok.as_object().unwrap().keys().cloned().collect();
let err_keys: Vec<_> = err.as_object().unwrap().keys().cloned().collect();
insta::assert_yaml_snapshot!("envelope_ok_keys", ok_keys);
insta::assert_yaml_snapshot!("envelope_error_keys", err_keys);
}
#[test]
fn json_error_field_names_stable() {
let e = JsonError {
code: "c".into(),
message: "m".into(),
field: Some("f".into()),
details: Some(serde_json::json!({})),
diagnostics: vec![JsonError {
code: "s".into(),
message: "sm".into(),
field: None,
details: None,
diagnostics: vec![],
cause: vec![],
}],
cause: vec!["c1".into()],
};
let v = serde_json::to_value(&e).unwrap();
let keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect();
insta::assert_yaml_snapshot!("json_error_keys", keys);
}
}