use serde::{Deserialize, Serialize};
use super::kind::IssueKind;
use crate::display::IssueNarrator;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
Info,
Warning,
Error,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Info => write!(f, "INFO"),
Severity::Warning => write!(f, "WARN"),
Severity::Error => write!(f, "ERROR"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ValidationCategory {
Structure,
Format,
Code,
Ahb,
}
impl std::fmt::Display for ValidationCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationCategory::Structure => write!(f, "Structure"),
ValidationCategory::Format => write!(f, "Format"),
ValidationCategory::Code => write!(f, "Code"),
ValidationCategory::Ahb => write!(f, "AHB"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SegmentPosition {
pub segment_number: u32,
pub byte_offset: usize,
pub message_number: u32,
}
impl From<edifact_primitives::SegmentPosition> for SegmentPosition {
fn from(pos: edifact_primitives::SegmentPosition) -> Self {
Self {
segment_number: pos.segment_number,
byte_offset: pos.byte_offset,
message_number: pos.message_number,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationIssue {
pub severity: Severity,
pub kind: IssueKind,
pub segment_position: Option<SegmentPosition>,
pub field_path: Option<String>,
pub rule: Option<String>,
pub actual_value: Option<String>,
pub expected_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bo4e_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field_element_position: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub field_component_position: Option<u32>,
}
impl ValidationIssue {
pub fn new(severity: Severity, kind: IssueKind) -> Self {
Self {
severity,
kind,
segment_position: None,
field_path: None,
rule: None,
actual_value: None,
expected_value: None,
bo4e_path: None,
instance_index: None,
field_element_position: None,
field_component_position: None,
}
}
pub fn code(&self) -> &'static str {
self.kind.code()
}
pub fn category(&self) -> ValidationCategory {
self.kind.category()
}
pub fn with_field_position(mut self, element_pos: u32, component_pos: Option<u32>) -> Self {
self.field_element_position = Some(element_pos);
self.field_component_position = component_pos;
self
}
pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
self.segment_position = Some(position.into());
self
}
pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
self.field_path = Some(path.into());
self
}
pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
self.rule = Some(rule.into());
self
}
pub fn with_actual(mut self, value: impl Into<String>) -> Self {
self.actual_value = Some(value.into());
self
}
pub fn with_expected(mut self, value: impl Into<String>) -> Self {
self.expected_value = Some(value.into());
self
}
pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
self.bo4e_path = Some(path.into());
self
}
pub fn with_instance_index(mut self, index: usize) -> Self {
self.instance_index = Some(index);
self
}
pub fn is_error(&self) -> bool {
self.severity == Severity::Error
}
pub fn is_warning(&self) -> bool {
self.severity == Severity::Warning
}
}
impl std::fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let location = self.field_path.as_deref();
write!(
f,
"[{}] {}: {}",
self.severity,
self.code(),
crate::display::TechnicalNarrator.describe(self, location)
)?;
if let Some(ref pos) = self.segment_position {
write!(
f,
" (segment #{}, byte {})",
pos.segment_number, pos.byte_offset
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_ordering() {
assert!(Severity::Info < Severity::Warning);
assert!(Severity::Warning < Severity::Error);
}
fn missing_field_issue() -> ValidationIssue {
ValidationIssue::new(
Severity::Error,
IssueKind::MissingRequiredField {
field_name: "Merkmal, Code".into(),
},
)
}
#[test]
fn test_issue_builder() {
let issue = missing_field_issue()
.with_field_path("SG2/NAD/C082/3039")
.with_rule("Muss [182] ∧ [152]")
.with_position(SegmentPosition {
segment_number: 5,
byte_offset: 234,
message_number: 1,
});
assert!(issue.is_error());
assert!(!issue.is_warning());
assert_eq!(issue.code(), "AHB001");
assert_eq!(issue.field_path.as_deref(), Some("SG2/NAD/C082/3039"));
assert_eq!(issue.rule.as_deref(), Some("Muss [182] ∧ [152]"));
assert_eq!(issue.segment_position.unwrap().segment_number, 5);
}
#[test]
fn test_issue_display() {
let issue = missing_field_issue().with_field_path("NAD");
let display = format!("{issue}");
assert!(display.contains("[ERROR]"));
assert!(display.contains("AHB001"));
assert!(display.contains("Merkmal, Code"));
assert!(display.contains("at NAD"));
}
#[test]
fn test_issue_serialization() {
let issue = ValidationIssue::new(
Severity::Warning,
IssueKind::CodeNotAllowedForPid {
value: "X".into(),
allowed: vec!["A".into()],
},
);
let json = serde_json::to_string_pretty(&issue).unwrap();
assert!(!json.contains("bo4e_path"));
let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.code(), "COD002");
assert_eq!(deserialized.severity, Severity::Warning);
assert!(deserialized.bo4e_path.is_none());
}
#[test]
fn test_bo4e_path_builder_and_serialization() {
let issue = missing_field_issue()
.with_field_path("SG4/SG5/LOC/C517/3225")
.with_bo4e_path("stammdaten.Marktlokation.marktlokationsId");
assert_eq!(
issue.bo4e_path.as_deref(),
Some("stammdaten.Marktlokation.marktlokationsId")
);
let json = serde_json::to_string_pretty(&issue).unwrap();
assert!(json.contains("bo4e_path"));
assert!(json.contains("stammdaten.Marktlokation.marktlokationsId"));
let deserialized: ValidationIssue = serde_json::from_str(&json).unwrap();
assert_eq!(
deserialized.bo4e_path.as_deref(),
Some("stammdaten.Marktlokation.marktlokationsId")
);
}
#[test]
fn test_category_display() {
assert_eq!(format!("{}", ValidationCategory::Structure), "Structure");
assert_eq!(format!("{}", ValidationCategory::Ahb), "AHB");
}
#[test]
fn test_position_from_edifact_primitives() {
let edifact_pos = edifact_primitives::SegmentPosition::new(3, 100, 1);
let pos: SegmentPosition = edifact_pos.into();
assert_eq!(pos.segment_number, 3);
assert_eq!(pos.byte_offset, 100);
assert_eq!(pos.message_number, 1);
}
#[test]
fn issue_instance_index_round_trip() {
let issue = missing_field_issue().with_instance_index(3);
assert_eq!(issue.instance_index, Some(3));
}
#[test]
fn issue_instance_index_defaults_to_none() {
let issue = missing_field_issue();
assert_eq!(issue.instance_index, None);
}
#[test]
fn code_and_category_are_derived_and_display_has_no_duplicate_path() {
let issue = ValidationIssue::new(
Severity::Error,
IssueKind::MissingRequiredField {
field_name: "Merkmal, Code".into(),
},
)
.with_field_path("SG4/SG8/SG10/CCI/C240/7037");
assert_eq!(issue.code(), "AHB001");
assert_eq!(issue.category(), ValidationCategory::Ahb);
let shown = issue.to_string();
assert_eq!(
shown.matches("SG4/SG8/SG10/CCI/C240/7037").count(),
1,
"{shown}"
);
}
}