Skip to main content

automapper_validation/display/
narrator.rs

1//! Putting an issue into words.
2
3use crate::{IssueKind, UnresolvedConditions, ValidationIssue};
4
5use super::IssueNarrator;
6
7/// The wording this repo has always used: technical, English, AHB vocabulary.
8///
9/// When paired with [`EdifactView`](super::EdifactView), this was proven
10/// byte-identical to the former `ValidationIssue::message` across the fixture
11/// corpus before `message` was removed.
12pub struct TechnicalNarrator;
13
14/// `" at X"`, or nothing when the view has no location for this issue.
15fn at(location: Option<&str>) -> String {
16    location.map_or_else(String::new, |l| format!(" at {l}"))
17}
18
19fn ids(list: &[u32]) -> String {
20    list.iter()
21        .map(|id| format!("[{id}]"))
22        .collect::<Vec<_>>()
23        .join(", ")
24}
25
26fn unresolved_detail(u: &UnresolvedConditions) -> String {
27    if u.is_empty() {
28        return String::new();
29    }
30    let mut parts = Vec::new();
31    if !u.external.is_empty() {
32        parts.push(format!(
33            "external conditions require provider: {}",
34            ids(&u.external)
35        ));
36    }
37    if !u.undetermined.is_empty() {
38        parts.push(format!(
39            "conditions could not be determined from message data: {}",
40            ids(&u.undetermined)
41        ));
42    }
43    if !u.missing.is_empty() {
44        parts.push(format!("missing conditions: {}", ids(&u.missing)));
45    }
46    format!(" ({})", parts.join("; "))
47}
48
49impl IssueNarrator for TechnicalNarrator {
50    fn describe(&self, issue: &ValidationIssue, location: Option<&str>) -> String {
51        match &issue.kind {
52            IssueKind::MissingRequiredField { field_name } => {
53                format!("Required field '{field_name}'{} is missing", at(location))
54            }
55            IssueKind::FieldConditionNotSatisfied { field_name } => format!(
56                "Field '{field_name}'{} is present but does not satisfy condition: {}",
57                at(location),
58                issue.rule.as_deref().unwrap_or_default()
59            ),
60            IssueKind::ConditionUnknown { field_name, unresolved } => format!(
61                "Condition for field '{field_name}' could not be fully evaluated{}",
62                unresolved_detail(unresolved)
63            ),
64            IssueKind::PackageCardinality { package_id, present, min, max, codes } => format!(
65                "Package [{package_id}P{min}..{max}]{}: {present} code(s) present (allowed {min}..{max}). \
66                 Codes in package: [{}]",
67                at(location),
68                codes.join(", ")
69            ),
70            IssueKind::CodeNotAllowedForPid { value, allowed } => format!(
71                "Code '{value}' is not allowed for this PID. Allowed: [{}]",
72                allowed.join(", ")
73            ),
74            IssueKind::UntSegmentCountMismatch { declared, actual } => {
75                format!("UNT segment count mismatch: declared {declared}, actual {actual}")
76            }
77            IssueKind::UntCountNotVerifiable { unh_count } => format!(
78                "UNT validation requires per-message segments, found {unh_count} UNH segments"
79            ),
80            IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
81        }
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
88    use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
89    use mig_assembly::StructureDiagnosticKind;
90
91    fn missing_field_issue() -> ValidationIssue {
92        ValidationIssue::new(
93            Severity::Error,
94            IssueKind::MissingRequiredField {
95                field_name: "Merkmal, Code".into(),
96            },
97        )
98        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
99        .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
100    }
101
102    #[test]
103    fn edifact_view_narrates_with_the_edifact_path() {
104        let issue = missing_field_issue();
105        let loc = EdifactView.location(&issue);
106        assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
107        assert_eq!(
108            TechnicalNarrator.describe(&issue, loc.as_deref()),
109            "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
110        );
111    }
112
113    #[test]
114    fn bo4e_view_narrates_with_the_bo4e_path() {
115        let issue = missing_field_issue();
116        let loc = Bo4eView.location(&issue);
117        assert_eq!(
118            loc.as_deref(),
119            Some("stammdaten.Marktlokation.haushaltskunde")
120        );
121        assert_eq!(
122            TechnicalNarrator.describe(&issue, loc.as_deref()),
123            "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
124        );
125    }
126
127    #[test]
128    fn bo4e_view_has_no_location_for_envelope_issues() {
129        let issue = ValidationIssue::new(
130            Severity::Error,
131            IssueKind::UntSegmentCountMismatch {
132                declared: 30,
133                actual: 31,
134            },
135        )
136        .with_field_path("UNT/0074");
137
138        assert_eq!(Bo4eView.location(&issue), None);
139        assert_eq!(
140            TechnicalNarrator.describe(&issue, None),
141            "UNT segment count mismatch: declared 30, actual 31"
142        );
143    }
144
145    // Each expectation below is transcribed verbatim from the `format!` call
146    // that built the equivalent legacy `message` in
147    // `git show 423f59255:crates/automapper-validation/src/validator/validate.rs`.
148
149    #[test]
150    fn field_condition_not_satisfied_matches_the_legacy_message() {
151        let issue = ValidationIssue::new(
152            Severity::Error,
153            IssueKind::FieldConditionNotSatisfied {
154                field_name: "Merkmal, Code".into(),
155            },
156        )
157        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
158        .with_rule("Muss [182]");
159        let loc = EdifactView.location(&issue);
160
161        // validate.rs:379 — format!("Field '{}' at {} is present but does not
162        // satisfy condition: {}", field.name, field.segment_path, field.ahb_status)
163        assert_eq!(
164            TechnicalNarrator.describe(&issue, loc.as_deref()),
165            "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
166        );
167    }
168
169    #[test]
170    fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
171        let issue = ValidationIssue::new(
172            Severity::Info,
173            IssueKind::ConditionUnknown {
174                field_name: "Merkmal, Code".into(),
175                unresolved: UnresolvedConditions {
176                    external: vec![182],
177                    undetermined: vec![6],
178                    missing: vec![570],
179                },
180            },
181        )
182        .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
183        let loc = EdifactView.location(&issue);
184
185        // validate.rs:444 — format!("Condition for field '{}' could not be
186        // fully evaluated{}", field.name, detail), where `detail` joins
187        // "external conditions require provider: ...", "conditions could not
188        // be determined from message data: ...", and "missing conditions:
189        // ..." with "; ", wrapped in " (...)".
190        assert_eq!(
191            TechnicalNarrator.describe(&issue, loc.as_deref()),
192            "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])"
193        );
194    }
195
196    #[test]
197    fn package_cardinality_matches_the_legacy_message() {
198        let issue = ValidationIssue::new(
199            Severity::Error,
200            IssueKind::PackageCardinality {
201                package_id: "1".into(),
202                present: 2,
203                min: 0,
204                max: 1,
205                codes: vec!["A".into(), "B".into()],
206            },
207        )
208        .with_field_path("SG4/SG8/SG10");
209        let loc = EdifactView.location(&issue);
210
211        // validate.rs:643 — format!("Package [{}P{}..{}] at {}: {} code(s)
212        // present (allowed {}..{}). Codes in package: [{}]", pkg_id,
213        // group.min, group.max, seg_path, present_count, group.min,
214        // group.max, code_list)
215        assert_eq!(
216            TechnicalNarrator.describe(&issue, loc.as_deref()),
217            "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
218        );
219    }
220
221    #[test]
222    fn code_not_allowed_for_pid_matches_the_legacy_message() {
223        let issue = ValidationIssue::new(
224            Severity::Warning,
225            IssueKind::CodeNotAllowedForPid {
226                value: "Z99".into(),
227                allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
228            },
229        );
230
231        // validate.rs:895 — format!("Code '{}' is not allowed for this PID.
232        // Allowed: [{}]", value, allowed.join(", "))
233        assert_eq!(
234            TechnicalNarrator.describe(&issue, None),
235            "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
236        );
237    }
238
239    #[test]
240    fn unt_count_not_verifiable_matches_the_legacy_message() {
241        let issue = ValidationIssue::new(
242            Severity::Warning,
243            IssueKind::UntCountNotVerifiable { unh_count: 3 },
244        );
245
246        // validate.rs:1605 — format!("UNT validation requires per-message
247        // segments, found {unh_count} UNH segments")
248        assert_eq!(
249            TechnicalNarrator.describe(&issue, None),
250            "UNT validation requires per-message segments, found 3 UNH segments"
251        );
252    }
253
254    #[test]
255    fn structure_diagnostic_passes_its_detail_through_verbatim() {
256        // Unlike the other seven kinds, `StructureDiagnostic` was never built
257        // from a `format!` in validate.rs — it carries `mig_assembly`'s own
258        // diagnostic message (see `IssueKind::StructureDiagnostic`'s doc
259        // comment), so the narrator must reproduce it byte-for-byte with no
260        // added wording.
261        let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
262                       the assembler advanced past it";
263        let issue = ValidationIssue::new(
264            Severity::Warning,
265            IssueKind::StructureDiagnostic {
266                kind: StructureDiagnosticKind::UnexpectedSegment,
267                segment_id: "FTX".into(),
268                position: 15,
269                detail: detail.into(),
270            },
271        );
272
273        assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
274    }
275}