automapper-validation 0.12.0

AHB condition expression parsing, evaluation, and EDIFACT validation
Documentation
//! Turning validation results into something a human or another program reads.
//!
//! A [`ValidationIssue`] says *what* is wrong in machine-readable form. This
//! module decides *where* it is (an [`IssueView`]) and *how it is worded* (an
//! [`IssueNarrator`]). Both are traits, so a consumer can render a report in a
//! world or a language this crate never anticipated.

mod format_impl;
mod narrator;
mod views;

pub use narrator::TechnicalNarrator;
pub use views::{Bo4eView, EdifactView};

/// The `format` module: whole-report renderings.
pub mod format {
    pub use super::format_impl::{lines, table};
}

use crate::{SegmentPosition, Severity, ValidationCategory, ValidationIssue};

/// Which world an issue is addressed in.
pub trait IssueView {
    /// Short name, suitable as a tab label: `"EDIFACT"`, `"BO4E"`.
    fn name(&self) -> &str;
    /// The issue's location in this world, or `None` if it has none here.
    fn location(&self, issue: &ValidationIssue) -> Option<String>;
}

/// How an issue is put into words.
///
/// Implementations match on [`ValidationIssue::kind`], so the compiler forces
/// every narrator to handle every kind. `location` is passed in rather than
/// appended so the wording can place it naturally inside the sentence.
pub trait IssueNarrator {
    fn describe(&self, issue: &ValidationIssue, location: Option<&str>) -> String;
}

/// One issue resolved into a chosen view and wording.
#[derive(Debug, Clone)]
pub struct IssueDisplay<'a> {
    pub severity: Severity,
    pub category: ValidationCategory,
    pub code: &'static str,
    /// Narrated text, with the location already placed.
    pub text: String,
    /// The location in this view, for rendering on its own line.
    pub location: Option<String>,
    pub segment_position: Option<SegmentPosition>,
    pub rule: Option<&'a str>,
    pub actual_value: Option<&'a str>,
    pub expected_value: Option<&'a str>,
}

impl<'a> IssueDisplay<'a> {
    /// Resolve one issue through a view and a narrator.
    pub fn new(
        issue: &'a ValidationIssue,
        view: &dyn IssueView,
        narrator: &dyn IssueNarrator,
    ) -> Self {
        let location = view.location(issue);
        Self {
            severity: issue.severity,
            category: issue.category(),
            code: issue.code(),
            text: narrator.describe(issue, location.as_deref()),
            location,
            segment_position: issue.segment_position,
            rule: issue.rule.as_deref(),
            actual_value: issue.actual_value.as_deref(),
            expected_value: issue.expected_value.as_deref(),
        }
    }
}

/// A report rendered for transport: every issue keeps `message`, `code` and
/// `category` fields, narrated in the given view, alongside its structured
/// `kind`.
///
/// The HTTP API and mako's proto are display boundaries — the type has no
/// message, code or category, but a JSON response does.
pub fn report_to_json(
    report: &crate::ValidationReport,
    view: &dyn IssueView,
    narrator: &dyn IssueNarrator,
) -> serde_json::Value {
    let mut value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
    if let Some(issues) = value.get_mut("issues").and_then(|i| i.as_array_mut()) {
        for (json, issue) in issues.iter_mut().zip(report.issues.iter()) {
            if let Some(obj) = json.as_object_mut() {
                let location = view.location(issue);
                obj.insert(
                    "message".into(),
                    serde_json::Value::String(narrator.describe(issue, location.as_deref())),
                );
                obj.insert(
                    "code".into(),
                    serde_json::Value::String(issue.code().into()),
                );
                obj.insert(
                    "category".into(),
                    serde_json::to_value(issue.category()).unwrap_or(serde_json::Value::Null),
                );
            }
        }
    }
    value
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{IssueKind, ValidationLevel, ValidationReport};

    /// Guards the wire contract: this is the only unit test of
    /// `report_to_json` in the crate that owns it (the rest live two crates
    /// away, in `automapper-api`'s integration tests). It must fail loudly if
    /// `ValidationCategory` or `Severity` ever grow a `#[serde(rename_all)]`.
    #[test]
    fn report_to_json_carries_message_code_and_category_for_every_issue() {
        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
        report.add_issue(
            ValidationIssue::new(
                Severity::Error,
                IssueKind::MissingRequiredField {
                    field_name: "LOC+Z16".into(),
                },
            )
            .with_field_path("SG4/SG5/LOC/C517/3225"),
        );
        report.add_issue(ValidationIssue::new(
            Severity::Warning,
            IssueKind::CodeNotAllowedForPid {
                value: "Z99".into(),
                allowed: vec!["Z01".into(), "Z02".into()],
            },
        ));

        let json = report_to_json(&report, &EdifactView, &TechnicalNarrator);
        let issues = json["issues"].as_array().expect("issues array");
        assert_eq!(issues.len(), 2);

        for issue in issues {
            assert!(issue["message"].is_string(), "{issue}");
            assert!(issue["code"].is_string(), "{issue}");
            assert!(issue["category"].is_string(), "{issue}");
            assert!(issue["kind"].is_object(), "{issue}");
        }

        let with_path = &issues[0];
        assert_eq!(with_path["field_path"], "SG4/SG5/LOC/C517/3225");
        assert_eq!(with_path["severity"], "Error");
        assert_eq!(with_path["category"], "Ahb");
        assert_eq!(with_path["code"], "AHB001");
        assert_eq!(
            with_path["message"],
            "Required field 'LOC+Z16' at SG4/SG5/LOC/C517/3225 is missing"
        );
        assert_eq!(with_path["kind"]["type"], "missingRequiredField");

        let without_path = &issues[1];
        assert_eq!(without_path["field_path"], serde_json::Value::Null);
        assert!(without_path["message"].is_string());
        assert!(without_path["code"].is_string());
        assert!(without_path["category"].is_string());
        assert_eq!(without_path["severity"], "Warning");
        assert_eq!(without_path["category"], "Code");
        assert_eq!(without_path["code"], "COD002");
        assert_eq!(without_path["kind"]["type"], "codeNotAllowedForPid");
    }
}