automapper-validation 0.8.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! The machine-readable description of what a validation issue *is*.
//!
//! Wording lives in [`crate::display`]. A kind carries every fact the wording
//! needs, so a consumer can say something other than the sentence we ship.

use mig_assembly::StructureDiagnosticKind;
use serde::{Deserialize, Serialize};

use super::issue::ValidationCategory;

/// Why a condition could not be evaluated.
///
/// Any combination of these may be non-empty; each is a list of AHB condition ids.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnresolvedConditions {
    /// Need an external provider to answer.
    pub external: Vec<u32>,
    /// Not determinable from the message data.
    pub undetermined: Vec<u32>,
    /// Not present in the rulebook at all.
    pub missing: Vec<u32>,
}

impl UnresolvedConditions {
    /// True when nothing at all could be attributed — the issue has no detail.
    pub fn is_empty(&self) -> bool {
        self.external.is_empty() && self.undetermined.is_empty() && self.missing.is_empty()
    }
}

/// What kind of problem a [`ValidationIssue`](super::issue::ValidationIssue) reports.
///
/// One variant per problem the validator actually emits. `ErrorCodes` lists 22
/// code strings, but only these are ever constructed; adding a new check means
/// adding a variant, which the compiler then forces every narrator to handle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum IssueKind {
    /// AHB001 — a field the AHB marks mandatory for this PID is absent.
    MissingRequiredField { field_name: String },
    /// AHB003 — the field is present but its AHB condition is not satisfied.
    FieldConditionNotSatisfied { field_name: String },
    /// AHB005 — the condition expression could not be fully evaluated.
    ConditionUnknown {
        field_name: String,
        unresolved: UnresolvedConditions,
    },
    /// AHB006 — number of codes present in a package is outside `min..max`.
    PackageCardinality {
        package_id: String,
        present: usize,
        min: usize,
        max: usize,
        codes: Vec<String>,
    },
    /// COD002 — the value is a valid code but not allowed for this PID.
    CodeNotAllowedForPid { value: String, allowed: Vec<String> },
    /// STR007 — UNT's declared segment count disagrees with the actual count.
    UntSegmentCountMismatch { declared: usize, actual: usize },
    /// STR007 — the input holds several messages, so the count is not checkable.
    UntCountNotVerifiable { unh_count: usize },
    /// STR003 / STR008 — a structure diagnostic raised during assembly.
    ///
    /// `detail` carries the diagnostic's own message verbatim: some
    /// diagnostics (e.g. `mig_assembly::Assembler::assemble_with_diagnostics`'s
    /// `"Assembly failed: {e}"` case) say something not derivable from
    /// `(kind, segment_id, position)` alone.
    StructureDiagnostic {
        kind: StructureDiagnosticKind,
        segment_id: String,
        position: usize,
        detail: String,
    },
}

impl IssueKind {
    /// The stable machine identifier, unchanged from the former `code` field.
    pub fn code(&self) -> &'static str {
        match self {
            Self::MissingRequiredField { .. } => "AHB001",
            Self::FieldConditionNotSatisfied { .. } => "AHB003",
            Self::ConditionUnknown { .. } => "AHB005",
            Self::PackageCardinality { .. } => "AHB006",
            Self::CodeNotAllowedForPid { .. } => "COD002",
            Self::UntSegmentCountMismatch { .. } | Self::UntCountNotVerifiable { .. } => "STR007",
            // Mirrors the mapping pipeline.rs applied before kinds existed:
            // skipped segments get their own code, every other diagnostic is
            // reported as an unexpected segment. An orphaned group segment is
            // a skipped segment too (its content is equally absent); the kind
            // and message tell the missing entry segment apart.
            Self::StructureDiagnostic { kind, .. } => match kind {
                StructureDiagnosticKind::SkippedUnknownSegment
                | StructureDiagnosticKind::OrphanedGroupSegment => "STR008",
                _ => "STR003",
            },
        }
    }

    /// The issue's category. Derived, so it can never disagree with the kind.
    pub fn category(&self) -> ValidationCategory {
        match self {
            Self::MissingRequiredField { .. }
            | Self::FieldConditionNotSatisfied { .. }
            | Self::ConditionUnknown { .. }
            | Self::PackageCardinality { .. } => ValidationCategory::Ahb,
            Self::CodeNotAllowedForPid { .. } => ValidationCategory::Code,
            Self::UntSegmentCountMismatch { .. }
            | Self::UntCountNotVerifiable { .. }
            | Self::StructureDiagnostic { .. } => ValidationCategory::Structure,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use mig_assembly::StructureDiagnosticKind;

    #[test]
    fn code_matches_the_legacy_error_code_for_every_kind() {
        let cases: Vec<(IssueKind, &str)> = vec![
            (
                IssueKind::MissingRequiredField {
                    field_name: "f".into(),
                },
                "AHB001",
            ),
            (
                IssueKind::FieldConditionNotSatisfied {
                    field_name: "f".into(),
                },
                "AHB003",
            ),
            (
                IssueKind::ConditionUnknown {
                    field_name: "f".into(),
                    unresolved: UnresolvedConditions::default(),
                },
                "AHB005",
            ),
            (
                IssueKind::PackageCardinality {
                    package_id: "1P0..1".into(),
                    present: 2,
                    min: 0,
                    max: 1,
                    codes: vec![],
                },
                "AHB006",
            ),
            (
                IssueKind::CodeNotAllowedForPid {
                    value: "X".into(),
                    allowed: vec![],
                },
                "COD002",
            ),
            (
                IssueKind::UntSegmentCountMismatch {
                    declared: 30,
                    actual: 31,
                },
                "STR007",
            ),
            (IssueKind::UntCountNotVerifiable { unh_count: 2 }, "STR007"),
            (
                IssueKind::StructureDiagnostic {
                    kind: StructureDiagnosticKind::SkippedUnknownSegment,
                    segment_id: "CCI".into(),
                    position: 15,
                    detail: "irrelevant to the code".into(),
                },
                "STR008",
            ),
            (
                IssueKind::StructureDiagnostic {
                    kind: StructureDiagnosticKind::UnexpectedSegment,
                    segment_id: "CCI".into(),
                    position: 15,
                    detail: "irrelevant to the code".into(),
                },
                "STR003",
            ),
        ];
        for (kind, expected) in cases {
            assert_eq!(kind.code(), expected, "wrong code for {kind:?}");
        }
    }

    #[test]
    fn category_is_derived_from_the_kind() {
        assert_eq!(
            IssueKind::MissingRequiredField {
                field_name: "f".into()
            }
            .category(),
            ValidationCategory::Ahb
        );
        assert_eq!(
            IssueKind::CodeNotAllowedForPid {
                value: "X".into(),
                allowed: vec![]
            }
            .category(),
            ValidationCategory::Code
        );
        assert_eq!(
            IssueKind::UntSegmentCountMismatch {
                declared: 1,
                actual: 2
            }
            .category(),
            ValidationCategory::Structure
        );
    }
}