automapper-validation 0.12.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Putting an issue into words.

use crate::{IssueKind, UnresolvedConditions, ValidationIssue};

use super::IssueNarrator;

/// The wording this repo has always used: technical, English, AHB vocabulary.
///
/// When paired with [`EdifactView`](super::EdifactView), this was proven
/// byte-identical to the former `ValidationIssue::message` across the fixture
/// corpus before `message` was removed.
pub struct TechnicalNarrator;

/// `" at X"`, or nothing when the view has no location for this issue.
fn at(location: Option<&str>) -> String {
    location.map_or_else(String::new, |l| format!(" at {l}"))
}

fn ids(list: &[u32]) -> String {
    list.iter()
        .map(|id| format!("[{id}]"))
        .collect::<Vec<_>>()
        .join(", ")
}

fn unresolved_detail(u: &UnresolvedConditions) -> String {
    if u.is_empty() {
        return String::new();
    }
    let mut parts = Vec::new();
    if !u.external.is_empty() {
        parts.push(format!(
            "external conditions require provider: {}",
            ids(&u.external)
        ));
    }
    if !u.undetermined.is_empty() {
        parts.push(format!(
            "conditions could not be determined from message data: {}",
            ids(&u.undetermined)
        ));
    }
    if !u.missing.is_empty() {
        parts.push(format!("missing conditions: {}", ids(&u.missing)));
    }
    format!(" ({})", parts.join("; "))
}

impl IssueNarrator for TechnicalNarrator {
    fn describe(&self, issue: &ValidationIssue, location: Option<&str>) -> String {
        match &issue.kind {
            IssueKind::MissingRequiredField { field_name } => {
                format!("Required field '{field_name}'{} is missing", at(location))
            }
            IssueKind::FieldConditionNotSatisfied { field_name } => format!(
                "Field '{field_name}'{} is present but does not satisfy condition: {}",
                at(location),
                issue.rule.as_deref().unwrap_or_default()
            ),
            IssueKind::ConditionUnknown { field_name, unresolved } => format!(
                "Condition for field '{field_name}' could not be fully evaluated{}",
                unresolved_detail(unresolved)
            ),
            IssueKind::PackageCardinality { package_id, present, min, max, codes } => format!(
                "Package [{package_id}P{min}..{max}]{}: {present} code(s) present (allowed {min}..{max}). \
                 Codes in package: [{}]",
                at(location),
                codes.join(", ")
            ),
            IssueKind::CodeNotAllowedForPid { value, allowed } => format!(
                "Code '{value}' is not allowed for this PID. Allowed: [{}]",
                allowed.join(", ")
            ),
            IssueKind::UntSegmentCountMismatch { declared, actual } => {
                format!("UNT segment count mismatch: declared {declared}, actual {actual}")
            }
            IssueKind::UntCountNotVerifiable { unh_count } => format!(
                "UNT validation requires per-message segments, found {unh_count} UNH segments"
            ),
            IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
    use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
    use mig_assembly::StructureDiagnosticKind;

    fn missing_field_issue() -> ValidationIssue {
        ValidationIssue::new(
            Severity::Error,
            IssueKind::MissingRequiredField {
                field_name: "Merkmal, Code".into(),
            },
        )
        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
        .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
    }

    #[test]
    fn edifact_view_narrates_with_the_edifact_path() {
        let issue = missing_field_issue();
        let loc = EdifactView.location(&issue);
        assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
        assert_eq!(
            TechnicalNarrator.describe(&issue, loc.as_deref()),
            "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
        );
    }

    #[test]
    fn bo4e_view_narrates_with_the_bo4e_path() {
        let issue = missing_field_issue();
        let loc = Bo4eView.location(&issue);
        assert_eq!(
            loc.as_deref(),
            Some("stammdaten.Marktlokation.haushaltskunde")
        );
        assert_eq!(
            TechnicalNarrator.describe(&issue, loc.as_deref()),
            "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
        );
    }

    #[test]
    fn bo4e_view_has_no_location_for_envelope_issues() {
        let issue = ValidationIssue::new(
            Severity::Error,
            IssueKind::UntSegmentCountMismatch {
                declared: 30,
                actual: 31,
            },
        )
        .with_field_path("UNT/0074");

        assert_eq!(Bo4eView.location(&issue), None);
        assert_eq!(
            TechnicalNarrator.describe(&issue, None),
            "UNT segment count mismatch: declared 30, actual 31"
        );
    }

    // Each expectation below is transcribed verbatim from the `format!` call
    // that built the equivalent legacy `message` in
    // `git show 423f59255:crates/automapper-validation/src/validator/validate.rs`.

    #[test]
    fn field_condition_not_satisfied_matches_the_legacy_message() {
        let issue = ValidationIssue::new(
            Severity::Error,
            IssueKind::FieldConditionNotSatisfied {
                field_name: "Merkmal, Code".into(),
            },
        )
        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
        .with_rule("Muss [182]");
        let loc = EdifactView.location(&issue);

        // validate.rs:379 — format!("Field '{}' at {} is present but does not
        // satisfy condition: {}", field.name, field.segment_path, field.ahb_status)
        assert_eq!(
            TechnicalNarrator.describe(&issue, loc.as_deref()),
            "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
        );
    }

    #[test]
    fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
        let issue = ValidationIssue::new(
            Severity::Info,
            IssueKind::ConditionUnknown {
                field_name: "Merkmal, Code".into(),
                unresolved: UnresolvedConditions {
                    external: vec![182],
                    undetermined: vec![6],
                    missing: vec![570],
                },
            },
        )
        .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
        let loc = EdifactView.location(&issue);

        // validate.rs:444 — format!("Condition for field '{}' could not be
        // fully evaluated{}", field.name, detail), where `detail` joins
        // "external conditions require provider: ...", "conditions could not
        // be determined from message data: ...", and "missing conditions:
        // ..." with "; ", wrapped in " (...)".
        assert_eq!(
            TechnicalNarrator.describe(&issue, loc.as_deref()),
            "Condition for field 'Merkmal, Code' could not be fully evaluated (external conditions require provider: [182]; conditions could not be determined from message data: [6]; missing conditions: [570])"
        );
    }

    #[test]
    fn package_cardinality_matches_the_legacy_message() {
        let issue = ValidationIssue::new(
            Severity::Error,
            IssueKind::PackageCardinality {
                package_id: "1".into(),
                present: 2,
                min: 0,
                max: 1,
                codes: vec!["A".into(), "B".into()],
            },
        )
        .with_field_path("SG4/SG8/SG10");
        let loc = EdifactView.location(&issue);

        // validate.rs:643 — format!("Package [{}P{}..{}] at {}: {} code(s)
        // present (allowed {}..{}). Codes in package: [{}]", pkg_id,
        // group.min, group.max, seg_path, present_count, group.min,
        // group.max, code_list)
        assert_eq!(
            TechnicalNarrator.describe(&issue, loc.as_deref()),
            "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
        );
    }

    #[test]
    fn code_not_allowed_for_pid_matches_the_legacy_message() {
        let issue = ValidationIssue::new(
            Severity::Warning,
            IssueKind::CodeNotAllowedForPid {
                value: "Z99".into(),
                allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
            },
        );

        // validate.rs:895 — format!("Code '{}' is not allowed for this PID.
        // Allowed: [{}]", value, allowed.join(", "))
        assert_eq!(
            TechnicalNarrator.describe(&issue, None),
            "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
        );
    }

    #[test]
    fn unt_count_not_verifiable_matches_the_legacy_message() {
        let issue = ValidationIssue::new(
            Severity::Warning,
            IssueKind::UntCountNotVerifiable { unh_count: 3 },
        );

        // validate.rs:1605 — format!("UNT validation requires per-message
        // segments, found {unh_count} UNH segments")
        assert_eq!(
            TechnicalNarrator.describe(&issue, None),
            "UNT validation requires per-message segments, found 3 UNH segments"
        );
    }

    #[test]
    fn structure_diagnostic_passes_its_detail_through_verbatim() {
        // Unlike the other seven kinds, `StructureDiagnostic` was never built
        // from a `format!` in validate.rs — it carries `mig_assembly`'s own
        // diagnostic message (see `IssueKind::StructureDiagnostic`'s doc
        // comment), so the narrator must reproduce it byte-for-byte with no
        // added wording.
        let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
                       the assembler advanced past it";
        let issue = ValidationIssue::new(
            Severity::Warning,
            IssueKind::StructureDiagnostic {
                kind: StructureDiagnosticKind::UnexpectedSegment,
                segment_id: "FTX".into(),
                position: 15,
                detail: detail.into(),
            },
        );

        assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
    }
}