use serde::{Deserialize, Serialize};
use crate::{DiagnosticCode, DiagnosticInfo, DiagnosticStage};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum DiagnosticSeverity {
Debug,
Info,
Warning,
Error,
Fatal,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SourceRef {
pub source_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub byte_offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub record: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_token: Option<String>,
}
impl SourceRef {
pub fn new(source_id: impl Into<String>) -> Self {
Self {
source_id: source_id.into(),
line: None,
column: None,
byte_offset: None,
record: None,
field: None,
raw_token: None,
}
}
#[must_use]
pub fn with_field(mut self, field: impl Into<String>) -> Self {
self.field = Some(field.into());
self
}
#[must_use]
pub fn with_record(mut self, record: impl Into<String>) -> Self {
self.record = Some(record.into());
self
}
#[must_use]
pub fn with_line(mut self, line: u32) -> Self {
self.line = Some(line);
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "SerializedDiagnostic", into = "SerializedDiagnostic")]
pub struct StructuredDiagnostic {
pub code: DiagnosticCode,
pub severity: DiagnosticSeverity,
pub message: String,
pub element_path: Option<String>,
pub source_ref: Option<SourceRef>,
pub details: serde_json::Map<String, serde_json::Value>,
pub suggested_action: Option<String>,
pub safe_to_ignore: Vec<String>,
}
impl StructuredDiagnostic {
pub fn new(
code: impl Into<DiagnosticCode>,
severity: DiagnosticSeverity,
message: impl Into<String>,
) -> Self {
Self {
code: code.into(),
severity,
message: message.into(),
element_path: None,
source_ref: None,
details: serde_json::Map::new(),
suggested_action: None,
safe_to_ignore: Vec::new(),
}
}
pub fn of(info: &DiagnosticInfo, message: impl Into<String>) -> Self {
Self::new(info.code, info.severity, message)
}
#[must_use]
pub fn stage(&self) -> Option<DiagnosticStage> {
self.code.stage()
}
#[must_use]
pub fn with_severity(mut self, severity: DiagnosticSeverity) -> Self {
self.severity = severity;
self
}
#[must_use]
pub fn with_element_path(mut self, path: impl Into<String>) -> Self {
self.element_path = Some(path.into());
self
}
#[must_use]
pub fn with_source_ref(mut self, source_ref: SourceRef) -> Self {
self.source_ref = Some(source_ref);
self
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Map<String, serde_json::Value>) -> Self {
self.details = details;
self
}
#[must_use]
pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
self.suggested_action = Some(action.into());
self
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
struct SerializedDiagnostic {
code: DiagnosticCode,
severity: DiagnosticSeverity,
#[serde(default, skip_serializing_if = "Option::is_none")]
stage: Option<DiagnosticStage>,
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
element_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_ref: Option<SourceRef>,
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
details: serde_json::Map<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
suggested_action: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
safe_to_ignore: Vec<String>,
}
impl From<StructuredDiagnostic> for SerializedDiagnostic {
fn from(d: StructuredDiagnostic) -> Self {
let stage = d.stage();
Self {
code: d.code,
severity: d.severity,
stage,
message: d.message,
element_path: d.element_path,
source_ref: d.source_ref,
details: d.details,
suggested_action: d.suggested_action,
safe_to_ignore: d.safe_to_ignore,
}
}
}
impl From<SerializedDiagnostic> for StructuredDiagnostic {
fn from(s: SerializedDiagnostic) -> Self {
Self {
code: s.code,
severity: s.severity,
message: s.message,
element_path: s.element_path,
source_ref: s.source_ref,
details: s.details,
suggested_action: s.suggested_action,
safe_to_ignore: s.safe_to_ignore,
}
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for StructuredDiagnostic {
fn schema_name() -> std::borrow::Cow<'static, str> {
"StructuredDiagnostic".into()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
"powerio_diag::StructuredDiagnostic".into()
}
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
<SerializedDiagnostic as schemars::JsonSchema>::json_schema(generator)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> StructuredDiagnostic {
StructuredDiagnostic::new(
"EMIT.PSSE.FIELD_DROPPED",
DiagnosticSeverity::Warning,
"generator cost curves have no PSS/E record and are dropped",
)
}
#[test]
fn the_serialized_stage_comes_from_the_code() {
let value = serde_json::to_value(sample()).unwrap();
assert_eq!(value["stage"], serde_json::json!("emit"));
assert_eq!(sample().stage(), Some(DiagnosticStage::Emit));
}
#[test]
fn a_namespace_outside_the_ten_omits_the_serialized_stage() {
let d = StructuredDiagnostic::new(
"W.FEEDER.VOLTAGE_LOW",
DiagnosticSeverity::Warning,
"a downstream verifier's own code",
);
let value = serde_json::to_value(&d).unwrap();
assert!(value.get("stage").is_none());
assert_eq!(d.stage(), None);
}
#[test]
fn a_document_stage_that_contradicts_its_code_reads_back_from_the_code() {
let json = r#"{
"code": "READ.DSS.INCLUDE_REFUSED",
"severity": "error",
"stage": "parse",
"message": "redirect ../shared.dss: refused"
}"#;
let d: StructuredDiagnostic = serde_json::from_str(json).unwrap();
assert_eq!(d.stage(), Some(DiagnosticStage::Read));
let value = serde_json::to_value(&d).unwrap();
assert_eq!(value["stage"], serde_json::json!("read"));
}
#[test]
fn a_document_without_a_stage_loads() {
let json = r#"{
"code": "EMIT.PSSE.FIELD_DROPPED",
"severity": "warning",
"message": "dropped"
}"#;
let d: StructuredDiagnostic = serde_json::from_str(json).unwrap();
assert_eq!(d.stage(), Some(DiagnosticStage::Emit));
}
#[test]
fn the_empty_optional_fields_are_not_serialized() {
let value = serde_json::to_value(sample()).unwrap();
let object = value.as_object().unwrap();
for absent in [
"element_path",
"source_ref",
"details",
"suggested_action",
"safe_to_ignore",
] {
assert!(!object.contains_key(absent), "{absent}");
}
assert_eq!(object.len(), 4);
assert!(serde_json::to_string(&sample()).unwrap().starts_with(
r#"{"code":"EMIT.PSSE.FIELD_DROPPED","severity":"warning","stage":"emit","message":"#
));
}
#[test]
fn severity_orders_worst_last() {
let mut severities = [
DiagnosticSeverity::Error,
DiagnosticSeverity::Debug,
DiagnosticSeverity::Fatal,
DiagnosticSeverity::Info,
DiagnosticSeverity::Warning,
];
severities.sort_unstable();
assert_eq!(severities.last(), Some(&DiagnosticSeverity::Fatal));
}
#[test]
fn a_round_trip_keeps_every_field() {
let d = sample()
.with_element_path("/gen/1")
.with_source_ref(SourceRef::new("case").with_line(12).with_field("gencost"))
.with_details(
serde_json::json!({"element": "gencost"})
.as_object()
.unwrap()
.clone(),
)
.with_suggested_action("write the costs to a sibling document")
.with_severity(DiagnosticSeverity::Info);
let json = serde_json::to_string(&d).unwrap();
assert_eq!(
serde_json::from_str::<StructuredDiagnostic>(&json).unwrap(),
d
);
}
}