Skip to main content

automapper_validation/
display.rs

1//! Turning validation results into something a human or another program reads.
2//!
3//! A [`ValidationIssue`] says *what* is wrong in machine-readable form. This
4//! module decides *where* it is (an [`IssueView`]) and *how it is worded* (an
5//! [`IssueNarrator`]). Both are traits, so a consumer can render a report in a
6//! world or a language this crate never anticipated.
7
8mod format_impl;
9mod narrator;
10mod views;
11
12pub use narrator::TechnicalNarrator;
13pub use views::{Bo4eView, EdifactView};
14
15/// The `format` module: whole-report renderings.
16pub mod format {
17    pub use super::format_impl::{lines, table};
18}
19
20use crate::{SegmentPosition, Severity, ValidationCategory, ValidationIssue};
21
22/// Which world an issue is addressed in.
23pub trait IssueView {
24    /// Short name, suitable as a tab label: `"EDIFACT"`, `"BO4E"`.
25    fn name(&self) -> &str;
26    /// The issue's location in this world, or `None` if it has none here.
27    fn location(&self, issue: &ValidationIssue) -> Option<String>;
28}
29
30/// How an issue is put into words.
31///
32/// Implementations match on [`ValidationIssue::kind`], so the compiler forces
33/// every narrator to handle every kind. `location` is passed in rather than
34/// appended so the wording can place it naturally inside the sentence.
35pub trait IssueNarrator {
36    fn describe(&self, issue: &ValidationIssue, location: Option<&str>) -> String;
37}
38
39/// One issue resolved into a chosen view and wording.
40#[derive(Debug, Clone)]
41pub struct IssueDisplay<'a> {
42    pub severity: Severity,
43    pub category: ValidationCategory,
44    pub code: &'static str,
45    /// Narrated text, with the location already placed.
46    pub text: String,
47    /// The location in this view, for rendering on its own line.
48    pub location: Option<String>,
49    pub segment_position: Option<SegmentPosition>,
50    pub rule: Option<&'a str>,
51    pub actual_value: Option<&'a str>,
52    pub expected_value: Option<&'a str>,
53}
54
55impl<'a> IssueDisplay<'a> {
56    /// Resolve one issue through a view and a narrator.
57    pub fn new(
58        issue: &'a ValidationIssue,
59        view: &dyn IssueView,
60        narrator: &dyn IssueNarrator,
61    ) -> Self {
62        let location = view.location(issue);
63        Self {
64            severity: issue.severity,
65            category: issue.category(),
66            code: issue.code(),
67            text: narrator.describe(issue, location.as_deref()),
68            location,
69            segment_position: issue.segment_position,
70            rule: issue.rule.as_deref(),
71            actual_value: issue.actual_value.as_deref(),
72            expected_value: issue.expected_value.as_deref(),
73        }
74    }
75}
76
77/// A report rendered for transport: every issue keeps `message`, `code` and
78/// `category` fields, narrated in the given view, alongside its structured
79/// `kind`.
80///
81/// The HTTP API and mako's proto are display boundaries — the type has no
82/// message, code or category, but a JSON response does.
83pub fn report_to_json(
84    report: &crate::ValidationReport,
85    view: &dyn IssueView,
86    narrator: &dyn IssueNarrator,
87) -> serde_json::Value {
88    let mut value = serde_json::to_value(report).unwrap_or(serde_json::Value::Null);
89    if let Some(issues) = value.get_mut("issues").and_then(|i| i.as_array_mut()) {
90        for (json, issue) in issues.iter_mut().zip(report.issues.iter()) {
91            if let Some(obj) = json.as_object_mut() {
92                let location = view.location(issue);
93                obj.insert(
94                    "message".into(),
95                    serde_json::Value::String(narrator.describe(issue, location.as_deref())),
96                );
97                obj.insert(
98                    "code".into(),
99                    serde_json::Value::String(issue.code().into()),
100                );
101                obj.insert(
102                    "category".into(),
103                    serde_json::to_value(issue.category()).unwrap_or(serde_json::Value::Null),
104                );
105            }
106        }
107    }
108    value
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{IssueKind, ValidationLevel, ValidationReport};
115
116    /// Guards the wire contract: this is the only unit test of
117    /// `report_to_json` in the crate that owns it (the rest live two crates
118    /// away, in `automapper-api`'s integration tests). It must fail loudly if
119    /// `ValidationCategory` or `Severity` ever grow a `#[serde(rename_all)]`.
120    #[test]
121    fn report_to_json_carries_message_code_and_category_for_every_issue() {
122        let mut report = ValidationReport::new("UTILMD", ValidationLevel::Full);
123        report.add_issue(
124            ValidationIssue::new(
125                Severity::Error,
126                IssueKind::MissingRequiredField {
127                    field_name: "LOC+Z16".into(),
128                },
129            )
130            .with_field_path("SG4/SG5/LOC/C517/3225"),
131        );
132        report.add_issue(ValidationIssue::new(
133            Severity::Warning,
134            IssueKind::CodeNotAllowedForPid {
135                value: "Z99".into(),
136                allowed: vec!["Z01".into(), "Z02".into()],
137            },
138        ));
139
140        let json = report_to_json(&report, &EdifactView, &TechnicalNarrator);
141        let issues = json["issues"].as_array().expect("issues array");
142        assert_eq!(issues.len(), 2);
143
144        for issue in issues {
145            assert!(issue["message"].is_string(), "{issue}");
146            assert!(issue["code"].is_string(), "{issue}");
147            assert!(issue["category"].is_string(), "{issue}");
148            assert!(issue["kind"].is_object(), "{issue}");
149        }
150
151        let with_path = &issues[0];
152        assert_eq!(with_path["field_path"], "SG4/SG5/LOC/C517/3225");
153        assert_eq!(with_path["severity"], "Error");
154        assert_eq!(with_path["category"], "Ahb");
155        assert_eq!(with_path["code"], "AHB001");
156        assert_eq!(
157            with_path["message"],
158            "Required field 'LOC+Z16' at SG4/SG5/LOC/C517/3225 is missing"
159        );
160        assert_eq!(with_path["kind"]["type"], "missingRequiredField");
161
162        let without_path = &issues[1];
163        assert_eq!(without_path["field_path"], serde_json::Value::Null);
164        assert!(without_path["message"].is_string());
165        assert!(without_path["code"].is_string());
166        assert!(without_path["category"].is_string());
167        assert_eq!(without_path["severity"], "Warning");
168        assert_eq!(without_path["category"], "Code");
169        assert_eq!(without_path["code"], "COD002");
170        assert_eq!(without_path["kind"]["type"], "codeNotAllowedForPid");
171    }
172}