use serde::de::{self, Deserializer};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Warning,
Error,
}
#[allow(missing_docs)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticCode {
UnsupportedSpecVersion,
InvalidJson,
InvalidBundleStructure,
SchemaValidationFailed,
DanglingReference,
DuplicateEntityId,
DuplicateUniqueRef,
CycleDetected,
ChronologyConflict,
EntityNotFound,
EntityAlreadyExists,
UnknownEntityKind,
DeleteBlockedByReference,
ManualReviewRequired,
ZipReadError,
ZipWriteError,
GedcomParseError,
GedcomUnrecognizedTag,
Internal,
}
impl DiagnosticCode {
pub fn as_str(self) -> &'static str {
match self {
DiagnosticCode::UnsupportedSpecVersion => "UNSUPPORTED_SPEC_VERSION",
DiagnosticCode::InvalidJson => "INVALID_JSON",
DiagnosticCode::InvalidBundleStructure => "INVALID_BUNDLE_STRUCTURE",
DiagnosticCode::SchemaValidationFailed => "SCHEMA_VALIDATION_FAILED",
DiagnosticCode::DanglingReference => "DANGLING_REFERENCE",
DiagnosticCode::DuplicateEntityId => "DUPLICATE_ENTITY_ID",
DiagnosticCode::DuplicateUniqueRef => "DUPLICATE_UNIQUE_REF",
DiagnosticCode::CycleDetected => "CYCLE_DETECTED",
DiagnosticCode::ChronologyConflict => "CHRONOLOGY_CONFLICT",
DiagnosticCode::EntityNotFound => "ENTITY_NOT_FOUND",
DiagnosticCode::EntityAlreadyExists => "ENTITY_ALREADY_EXISTS",
DiagnosticCode::UnknownEntityKind => "UNKNOWN_ENTITY_KIND",
DiagnosticCode::DeleteBlockedByReference => "DELETE_BLOCKED_BY_REFERENCE",
DiagnosticCode::ManualReviewRequired => "MANUAL_REVIEW_REQUIRED",
DiagnosticCode::ZipReadError => "ZIP_READ_ERROR",
DiagnosticCode::ZipWriteError => "ZIP_WRITE_ERROR",
DiagnosticCode::GedcomParseError => "GEDCOM_PARSE_ERROR",
DiagnosticCode::GedcomUnrecognizedTag => "GEDCOM_UNRECOGNIZED_TAG",
DiagnosticCode::Internal => "INTERNAL",
}
}
pub fn from_wire(s: &str) -> Option<Self> {
use DiagnosticCode::*;
Some(match s {
"UNSUPPORTED_SPEC_VERSION" => UnsupportedSpecVersion,
"INVALID_JSON" => InvalidJson,
"INVALID_BUNDLE_STRUCTURE" => InvalidBundleStructure,
"SCHEMA_VALIDATION_FAILED" => SchemaValidationFailed,
"DANGLING_REFERENCE" => DanglingReference,
"DUPLICATE_ENTITY_ID" => DuplicateEntityId,
"DUPLICATE_UNIQUE_REF" => DuplicateUniqueRef,
"CYCLE_DETECTED" => CycleDetected,
"CHRONOLOGY_CONFLICT" => ChronologyConflict,
"ENTITY_NOT_FOUND" => EntityNotFound,
"ENTITY_ALREADY_EXISTS" => EntityAlreadyExists,
"UNKNOWN_ENTITY_KIND" => UnknownEntityKind,
"DELETE_BLOCKED_BY_REFERENCE" => DeleteBlockedByReference,
"MANUAL_REVIEW_REQUIRED" => ManualReviewRequired,
"ZIP_READ_ERROR" => ZipReadError,
"ZIP_WRITE_ERROR" => ZipWriteError,
"GEDCOM_PARSE_ERROR" => GedcomParseError,
"GEDCOM_UNRECOGNIZED_TAG" => GedcomUnrecognizedTag,
"INTERNAL" => Internal,
_ => return None,
})
}
}
impl Serialize for DiagnosticCode {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for DiagnosticCode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
DiagnosticCode::from_wire(&raw)
.ok_or_else(|| de::Error::custom(format!("unknown diagnostic code: {raw}")))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diagnostic {
pub code: DiagnosticCode,
pub severity: Severity,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity_ref: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope {
pub status: Status,
#[serde(default)]
pub data: serde_json::Value,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
impl Envelope {
pub fn ok(data: serde_json::Value) -> Self {
Self {
status: Status::Ok,
data,
diagnostics: Vec::new(),
}
}
pub fn ok_with(data: serde_json::Value, diagnostics: Vec<Diagnostic>) -> Self {
Self {
status: Status::Ok,
data,
diagnostics,
}
}
pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
Self {
status: Status::Error,
data: serde_json::Value::Null,
diagnostics: vec![Diagnostic {
code,
severity: Severity::Error,
message: message.into(),
entity_ref: None,
}],
}
}
pub fn error_many(diagnostics: Vec<Diagnostic>) -> Self {
Self {
status: Status::Error,
data: serde_json::Value::Null,
diagnostics,
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|e| {
format!(
"{{\"status\":\"error\",\"data\":null,\"diagnostics\":[{{\"code\":\"INTERNAL\",\"severity\":\"error\",\"message\":\"envelope serialization failed: {}\"}}]}}",
e.to_string().replace('"', "'")
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn ok_envelope_has_ok_status_and_no_diagnostics() {
let env = Envelope::ok(json!({"hello": "world"}));
assert_eq!(env.status, Status::Ok);
assert!(env.diagnostics.is_empty());
assert_eq!(env.data, json!({"hello": "world"}));
}
#[test]
fn error_envelope_has_null_data_and_one_diagnostic() {
let env = Envelope::error(DiagnosticCode::InvalidJson, "not JSON");
assert_eq!(env.status, Status::Error);
assert!(env.data.is_null());
assert_eq!(env.diagnostics.len(), 1);
assert_eq!(env.diagnostics[0].code, DiagnosticCode::InvalidJson);
assert_eq!(env.diagnostics[0].severity, Severity::Error);
}
#[test]
fn diagnostic_code_wire_form_is_stable() {
let cases = [
(
DiagnosticCode::UnsupportedSpecVersion,
"UNSUPPORTED_SPEC_VERSION",
),
(DiagnosticCode::DanglingReference, "DANGLING_REFERENCE"),
(
DiagnosticCode::DeleteBlockedByReference,
"DELETE_BLOCKED_BY_REFERENCE",
),
(
DiagnosticCode::ManualReviewRequired,
"MANUAL_REVIEW_REQUIRED",
),
(DiagnosticCode::ChronologyConflict, "CHRONOLOGY_CONFLICT"),
];
for (code, wire) in cases {
assert_eq!(code.as_str(), wire);
assert_eq!(DiagnosticCode::from_wire(wire), Some(code));
}
}
#[test]
fn envelope_json_round_trip_preserves_all_fields() {
let original = Envelope::ok_with(
json!({"nested": [1, 2, {"a": "b"}]}),
vec![
Diagnostic {
code: DiagnosticCode::DanglingReference,
severity: Severity::Warning,
message: "person X not found".into(),
entity_ref: Some("families/abc".into()),
},
Diagnostic {
code: DiagnosticCode::ManualReviewRequired,
severity: Severity::Info,
message: "ambiguous merge".into(),
entity_ref: None,
},
],
);
let wire = original.to_json();
let parsed: Envelope = serde_json::from_str(&wire).expect("re-parse");
assert_eq!(parsed.status, original.status);
assert_eq!(parsed.data, original.data);
assert_eq!(parsed.diagnostics.len(), 2);
assert_eq!(
parsed.diagnostics[0].code,
DiagnosticCode::DanglingReference
);
assert_eq!(parsed.diagnostics[0].severity, Severity::Warning);
assert_eq!(parsed.diagnostics[0].message, "person X not found");
assert_eq!(
parsed.diagnostics[0].entity_ref.as_deref(),
Some("families/abc")
);
assert!(parsed.diagnostics[1].entity_ref.is_none());
}
#[test]
fn omitted_entity_ref_is_absent_from_wire_form() {
let env = Envelope::error(DiagnosticCode::InvalidJson, "boom");
let wire = env.to_json();
assert!(
!wire.contains("entity_ref"),
"wire form had entity_ref: {wire}"
);
assert!(wire.contains("\"status\":\"error\""));
assert!(wire.contains("\"code\":\"INVALID_JSON\""));
}
#[test]
fn unknown_wire_code_deserializes_to_error() {
let wire = r#"{"status":"error","data":null,"diagnostics":[
{"code":"MADE_UP_CODE","severity":"error","message":"x"}]}"#;
let err = serde_json::from_str::<Envelope>(wire).unwrap_err();
assert!(err.to_string().contains("unknown diagnostic code"));
}
}