use crate::core::{DomainReason, ErrorSnapshot, StructError};
impl<T: DomainReason> serde::Serialize for StructError<T>
where
T: serde::Serialize,
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let source_frames = self.source_frames();
let source_chain = self.source_chain();
let source_message = source_chain.first().cloned();
let want = self.target_main();
let path = self.target_path();
let mut state = serializer.serialize_struct("StructError", 9)?;
state.serialize_field("reason", self.imp().reason())?;
state.serialize_field("detail", self.imp().detail())?;
state.serialize_field("position", self.imp().position())?;
state.serialize_field("context", self.imp().context())?;
state.serialize_field("want", &want)?;
state.serialize_field("path", &path)?;
state.serialize_field("source_frames", &source_frames)?;
state.serialize_field("source_message", &source_message)?;
state.serialize_field("source_chain", &source_chain)?;
state.end()
}
}
impl serde::Serialize for ErrorSnapshot {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.stable_export().serialize(serializer)
}
}
#[cfg(test)]
mod tests {
use crate::core::context::ContextRecord;
use crate::core::{
context::OperationResult, DomainReason, ErrorCategory, ErrorIdentity, ErrorMetadata,
ErrorSnapshot, OperationContext, SnapshotContextFrame, SnapshotSourceFrame, SourceFrame,
StableErrorSnapshot, StructError, STABLE_SNAPSHOT_SCHEMA_VERSION,
};
use crate::report::RedactPolicy;
use crate::{ErrorCode, ErrorIdentityProvider, UvsReason};
#[derive(Debug, serde::Serialize)]
struct TestPolicy;
impl RedactPolicy for TestPolicy {
fn redact_key(&self, key: &str) -> bool {
matches!(key, "token" | "password" | "config.secret")
}
fn redact_value(&self, _key: Option<&str>, _value: &str) -> Option<String> {
Some("<redacted>".to_string())
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error, serde::Serialize)]
enum TestReason {
#[error("test error")]
TestError,
#[error("{0}")]
Uvs(UvsReason),
}
impl From<UvsReason> for TestReason {
fn from(value: UvsReason) -> Self {
Self::Uvs(value)
}
}
impl DomainReason for TestReason {}
impl ErrorCode for TestReason {
fn error_code(&self) -> i32 {
match self {
TestReason::TestError => 1001,
TestReason::Uvs(reason) => reason.error_code(),
}
}
}
impl ErrorIdentityProvider for TestReason {
fn stable_code(&self) -> &'static str {
match self {
TestReason::TestError => "test.test_error",
TestReason::Uvs(reason) => reason.stable_code(),
}
}
fn error_category(&self) -> ErrorCategory {
match self {
TestReason::TestError => ErrorCategory::Logic,
TestReason::Uvs(reason) => reason.error_category(),
}
}
}
#[test]
fn test_source_frame_serialization_skips_empty_metadata() {
let frame = SourceFrame {
index: 0,
message: "message".to_string(),
display: None,
debug: "debug".to_string(),
type_name: None,
error_code: None,
reason: None,
want: None,
path: None,
detail: None,
metadata: ErrorMetadata::default(),
is_root_cause: true,
};
let json_value = serde_json::to_value(&frame).unwrap();
assert!(!json_value
.as_object()
.expect("object")
.contains_key("metadata"));
}
#[test]
fn test_source_frame_serialization_includes_metadata() {
let error = StructError::from(TestReason::TestError).with_context(
OperationContext::doing("load sink defaults")
.with_meta("config.kind", "sink_defaults")
.with_meta("parse.line", 1u32),
);
let wrapped = StructError::from(TestReason::from(UvsReason::system_error()))
.with_struct_source(error);
let json_value = serde_json::to_value(&wrapped).unwrap();
assert_eq!(
json_value["source_frames"][0]["metadata"]["config.kind"],
serde_json::Value::String("sink_defaults".to_string())
);
assert_eq!(
json_value["source_frames"][0]["metadata"]["parse.line"],
serde_json::json!(1)
);
}
#[test]
fn test_context_serialization() {
let mut ctx = OperationContext::doing("serialization_test");
ctx.with_doing("inner_step");
ctx.record("key1", "value1");
ctx.record("key2", "value2");
let serialized = serde_json::to_string(&ctx).expect("序列化失败");
assert!(serialized.contains("serialization_test"));
assert!(serialized.contains("inner_step"));
assert!(serialized.contains("key1"));
assert!(serialized.contains("value1"));
let deserialized: OperationContext =
serde_json::from_str(&serialized).expect("反序列化失败");
assert_eq!(ctx, deserialized);
}
#[test]
fn test_context_serialization_skips_empty_metadata_and_reads_missing_field() {
let ctx = OperationContext::doing("serialization_test");
let serialized = serde_json::to_value(&ctx).expect("序列化失败");
assert!(!serialized
.as_object()
.expect("object")
.contains_key("metadata"));
let deserialized: OperationContext =
serde_json::from_value(serialized).expect("反序列化失败");
assert!(deserialized.metadata().is_empty());
}
#[test]
fn test_context_serialization_preserves_metadata() {
let ctx = OperationContext::doing("serialization_test")
.with_meta("config.kind", "sink_defaults")
.with_meta("parse.line", 3u32);
let serialized = serde_json::to_value(&ctx).expect("序列化失败");
assert_eq!(
serialized["metadata"]["config.kind"],
serde_json::Value::String("sink_defaults".to_string())
);
assert_eq!(serialized["metadata"]["parse.line"], serde_json::json!(3));
let deserialized: OperationContext =
serde_json::from_value(serialized).expect("反序列化失败");
assert_eq!(
deserialized.metadata().get_str("config.kind"),
Some("sink_defaults")
);
}
#[test]
fn test_identity_snapshot_serialization_includes_code_and_category() {
let identity = ErrorIdentity {
code: "sys.io_error".to_string(),
category: ErrorCategory::Sys,
reason: "system error".to_string(),
detail: Some("engine bootstrap failed".to_string()),
position: Some("src/main.rs:42".to_string()),
want: Some("start engine".to_string()),
path: Some("start engine".to_string()),
};
let value = serde_json::to_value(identity).unwrap();
assert_eq!(value["code"], serde_json::json!("sys.io_error"));
assert_eq!(value["category"], serde_json::json!("sys"));
assert_eq!(value["reason"], serde_json::json!("system error"));
}
#[test]
fn test_snapshot_default_serialization_uses_stable_export_shape() {
let source = StructError::from(TestReason::TestError)
.with_detail("inner detail")
.with_context(
OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
);
let err = StructError::from(TestReason::from(UvsReason::system_error()))
.with_detail("engine bootstrap failed")
.with_position("src/main.rs:42")
.with_context(
OperationContext::doing("start engine").with_meta("component.name", "engine"),
)
.with_struct_source(source);
let json_value = serde_json::to_value(err.snapshot()).unwrap();
assert_eq!(
json_value["schema_version"],
serde_json::json!(STABLE_SNAPSHOT_SCHEMA_VERSION)
);
assert_eq!(json_value["reason"], serde_json::json!("system error"));
assert_eq!(
json_value["detail"],
serde_json::json!("engine bootstrap failed")
);
assert_eq!(json_value["position"], serde_json::json!("src/main.rs:42"));
assert_eq!(json_value["want"], serde_json::json!("start engine"));
assert_eq!(json_value["path"], serde_json::json!("start engine"));
assert_eq!(
json_value["root_metadata"]["component.name"],
serde_json::json!("engine")
);
assert_eq!(
json_value["context"][0]["target"],
serde_json::json!("start engine")
);
assert_eq!(
json_value["context"][0]["action"],
serde_json::json!("start engine")
);
assert_eq!(json_value["context"][0]["locator"], serde_json::Value::Null);
assert_eq!(
json_value["context"][0]["path"],
serde_json::json!(["start engine"])
);
assert_eq!(
json_value["context"][0]["metadata"]["component.name"],
serde_json::json!("engine")
);
assert!(json_value["context"][0].get("fields").is_none());
assert!(json_value["context"][0].get("result").is_none());
assert_eq!(
json_value["source_frames"][0]["message"],
serde_json::json!("test error")
);
assert_eq!(
json_value["source_frames"][0]["reason"],
serde_json::json!("test error")
);
assert_eq!(
json_value["source_frames"][0]["want"],
serde_json::json!("load defaults")
);
assert_eq!(
json_value["source_frames"][0]["path"],
serde_json::json!("load defaults")
);
assert_eq!(
json_value["source_frames"][0]["detail"],
serde_json::json!("inner detail")
);
assert_eq!(
json_value["source_frames"][0]["metadata"]["config.kind"],
serde_json::json!("sink_defaults")
);
assert_eq!(
json_value["source_frames"][0]["is_root_cause"],
serde_json::json!(true)
);
assert!(json_value["source_frames"][0].get("debug").is_none());
assert!(json_value["source_frames"][0].get("display").is_none());
assert!(json_value["source_frames"][0].get("type_name").is_none());
assert!(json_value.get("source_message").is_none());
assert!(json_value.get("source_chain").is_none());
}
#[test]
fn test_snapshot_stable_export_serialization_omits_compat_projection_fields() {
let snapshot = ErrorSnapshot {
reason: "system error".to_string(),
detail: Some("outer detail".to_string()),
position: Some("src/main.rs:42".to_string()),
want: Some("start engine".to_string()),
path: Some("start engine".to_string()),
context: vec![SnapshotContextFrame {
target: Some("start engine".to_string()),
action: Some("start engine".to_string()),
locator: Some("engine.toml".to_string()),
path: vec!["start engine".to_string()],
metadata: {
let mut metadata = ErrorMetadata::new();
metadata.insert("component.name", "engine");
metadata
},
fields: vec![("tenant".to_string(), "alpha".to_string())],
result: OperationResult::Fail,
}],
root_metadata: {
let mut metadata = ErrorMetadata::new();
metadata.insert("component.name", "engine");
metadata
},
source_frames: vec![SnapshotSourceFrame {
index: 0,
message: "db unavailable".to_string(),
display: Some("db unavailable".to_string()),
type_name: Some("std::io::Error".to_string()),
error_code: None,
reason: None,
want: Some("load config".to_string()),
path: Some("load config / read".to_string()),
detail: Some("inner detail".to_string()),
metadata: {
let mut metadata = ErrorMetadata::new();
metadata.insert("config.kind", "sink_defaults");
metadata
},
is_root_cause: true,
}],
category: ErrorCategory::Sys,
code: "sys.test_error".to_string(),
};
let stable = snapshot.stable_export();
let json_value = serde_json::to_value(&stable).unwrap();
assert_eq!(StableErrorSnapshot::clone(&stable), stable);
assert_eq!(
json_value["schema_version"],
serde_json::json!(STABLE_SNAPSHOT_SCHEMA_VERSION)
);
assert_eq!(json_value["reason"], serde_json::json!("system error"));
assert_eq!(
json_value["context"][0]["target"],
serde_json::json!("start engine")
);
assert_eq!(
json_value["context"][0]["action"],
serde_json::json!("start engine")
);
assert_eq!(
json_value["context"][0]["locator"],
serde_json::json!("engine.toml")
);
assert!(json_value["context"][0].get("fields").is_none());
assert!(json_value["context"][0].get("result").is_none());
assert_eq!(
json_value["source_frames"][0]["index"],
serde_json::json!(0)
);
assert!(json_value["source_frames"][0].get("debug").is_none());
assert!(json_value["source_frames"][0].get("display").is_none());
assert!(json_value["source_frames"][0].get("type_name").is_none());
}
#[test]
fn test_report_serialization_supports_structured_export() {
let source = StructError::from(TestReason::TestError).with_context(
OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
);
let err = StructError::from(TestReason::from(UvsReason::system_error()))
.with_context(
OperationContext::doing("start engine").with_meta("component.name", "engine"),
)
.with_struct_source(source);
let json_value = serde_json::to_value(err.report()).expect("serialize report");
assert_eq!(json_value["reason"], serde_json::json!("system error"));
assert_eq!(
json_value["root_metadata"]["component.name"],
serde_json::json!("engine")
);
assert_eq!(
json_value["source_frames"][0]["metadata"]["config.kind"],
serde_json::json!("sink_defaults")
);
}
#[test]
fn test_report_redacted_supports_structured_export() {
let err = StructError::from(TestReason::TestError)
.with_detail("token=abc")
.with_std_source(std::io::Error::other("token=abc"))
.with_context(OperationContext::doing("load").with_meta("config.secret", "abc"));
let json_value =
serde_json::to_value(err.report_redacted(&TestPolicy)).expect("serialize redacted");
let encoded = serde_json::to_string(&json_value).expect("json string");
assert!(encoded.contains("<redacted>"));
assert!(!encoded.contains("token=abc"));
assert!(!encoded.contains("\"abc\""));
}
}