automapper-validation 0.14.1

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Validation issue types.

use serde::{Deserialize, Serialize};

use super::kind::IssueKind;
use crate::display::IssueNarrator;

/// Severity level of a validation issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Severity {
    /// Informational message, not a problem.
    Info,
    /// Warning that may indicate a problem but does not fail validation.
    Warning,
    /// Error that causes validation to fail.
    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"),
        }
    }
}

/// Category of validation issue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ValidationCategory {
    /// Structural issues: missing segments, wrong order, MaxRep exceeded.
    Structure,
    /// Format issues: invalid data format (an..35, n13, dates).
    Format,
    /// Code issues: invalid code value not in allowed list.
    Code,
    /// AHB issues: PID-specific condition rule violations.
    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"),
        }
    }
}

/// Serializable segment position for validation reports.
///
/// Mirrors `edifact_primitives::SegmentPosition` but with serde support,
/// since the edifact-primitives crate is intentionally zero-dependency.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SegmentPosition {
    /// 1-based segment number within the interchange.
    pub segment_number: u32,
    /// Byte offset from the start of the input.
    pub byte_offset: usize,
    /// 1-based message number within the interchange.
    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,
        }
    }
}

/// A single validation issue found in an EDIFACT message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationIssue {
    /// Severity level of this issue.
    pub severity: Severity,

    /// Machine-readable description of the problem. `code()` and `category()`
    /// are derived from it, so they can never disagree with the kind.
    pub kind: IssueKind,

    /// Position in the EDIFACT message where the issue was found.
    pub segment_position: Option<SegmentPosition>,

    /// Field path within the segment (e.g., "SG2/NAD/C082/3039").
    pub field_path: Option<String>,

    /// The AHB rule that triggered this issue (e.g., "Muss [182] ∧ [152]").
    pub rule: Option<String>,

    /// The actual value found (if applicable).
    pub actual_value: Option<String>,

    /// The expected value (if applicable).
    pub expected_value: Option<String>,

    /// BO4E field path (e.g., "stammdaten.Marktlokation.marktlokationsId").
    /// Set when validation is triggered from BO4E input and errors can be
    /// traced back to the source BO4E structure.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bo4e_path: Option<String>,

    /// Index of the group instance this issue applies to (0-based) when
    /// the issue originated inside a group repetition. `None` for issues
    /// that aren't scoped to a specific instance (e.g., top-level root
    /// fields, unmatched rules, flat-segment checks).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub instance_index: Option<usize>,

    /// 1-based EDIFACT position of the offending data element (or
    /// composite) within the segment. Counts the segment identifier as
    /// position 1, so the first element after the tag is position 2.
    /// Maps to CONTRL UCD `0098`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub field_element_position: Option<u32>,

    /// 1-based position of the offending component within its composite
    /// data element. Maps to CONTRL UCD `0104`. `None` when the issue is
    /// at the simple-element level.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub field_component_position: Option<u32>,
}

impl ValidationIssue {
    /// Create a validation issue. Category and code are derived from `kind`.
    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,
        }
    }

    /// The stable machine identifier, e.g. `"AHB001"`.
    pub fn code(&self) -> &'static str {
        self.kind.code()
    }

    /// The issue's category, derived from its kind.
    pub fn category(&self) -> ValidationCategory {
        self.kind.category()
    }

    /// Builder: set the 1-based field positions used by CONTRL UCD `S011`.
    ///
    /// `element_pos` counts the segment identifier as position 1 (so the
    /// first element after the tag is position 2). `component_pos` is the
    /// 1-based position within a composite, or `None` for simple elements.
    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
    }

    /// Builder: set the segment position.
    pub fn with_position(mut self, position: impl Into<SegmentPosition>) -> Self {
        self.segment_position = Some(position.into());
        self
    }

    /// Builder: set the field path.
    pub fn with_field_path(mut self, path: impl Into<String>) -> Self {
        self.field_path = Some(path.into());
        self
    }

    /// Builder: set the AHB rule.
    pub fn with_rule(mut self, rule: impl Into<String>) -> Self {
        self.rule = Some(rule.into());
        self
    }

    /// Builder: set the actual value.
    pub fn with_actual(mut self, value: impl Into<String>) -> Self {
        self.actual_value = Some(value.into());
        self
    }

    /// Builder: set the expected value.
    pub fn with_expected(mut self, value: impl Into<String>) -> Self {
        self.expected_value = Some(value.into());
        self
    }

    /// Builder: set the BO4E field path.
    pub fn with_bo4e_path(mut self, path: impl Into<String>) -> Self {
        self.bo4e_path = Some(path.into());
        self
    }

    /// Builder: set the group instance index (0-based) this issue applies to.
    pub fn with_instance_index(mut self, index: usize) -> Self {
        self.instance_index = Some(index);
        self
    }

    /// Returns true if this is an error-level issue.
    pub fn is_error(&self) -> bool {
        self.severity == Severity::Error
    }

    /// Returns true if this is a warning-level issue.
    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();
        // bo4e_path should be absent from JSON when None (skip_serializing_if)
        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);
        // The old Display appended " at {path}" to a message that already contained
        // the path, printing it twice. It must now appear exactly once.
        let shown = issue.to_string();
        assert_eq!(
            shown.matches("SG4/SG8/SG10/CCI/C240/7037").count(),
            1,
            "{shown}"
        );
    }
}