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::GroupNotAllowed { group, .. } => format!(
56                "Group '{group}' is present although its condition does not hold: {}",
57                issue.rule.as_deref().unwrap_or_default()
58            ),
59            IssueKind::SegmentNotAllowed { segment, .. } => format!(
60                "Segment '{segment}' is present although its condition does not hold: {}",
61                issue.rule.as_deref().unwrap_or_default()
62            ),
63            IssueKind::FieldConditionNotSatisfied { field_name, .. } => format!(
64                "Field '{field_name}'{} is present but does not satisfy condition: {}",
65                at(location),
66                issue.rule.as_deref().unwrap_or_default()
67            ),
68            IssueKind::ConditionUnknown { field_name, unresolved } => format!(
69                "Condition for field '{field_name}' could not be fully evaluated{}",
70                unresolved_detail(unresolved)
71            ),
72            IssueKind::PackageCardinality { package_id, present, min, max, codes } => format!(
73                "Package [{package_id}P{min}..{max}]{}: {present} code(s) present (allowed {min}..{max}). \
74                 Codes in package: [{}]",
75                at(location),
76                codes.join(", ")
77            ),
78            IssueKind::RepeatedInTransaction { path, count } => format!(
79                "'{path}' is sent {count} times in one transaction; its condition allows it once: {}",
80                issue.rule.as_deref().unwrap_or_default()
81            ),
82            IssueKind::RepetitionCount { path, count } => format!(
83                "'{path}' is sent {count} times in one group; its condition allows another number: {}",
84                issue.rule.as_deref().unwrap_or_default()
85            ),
86            IssueKind::CodeNotAllowedForPid { value, allowed } => format!(
87                "Code '{value}' is not allowed for this PID. Allowed: [{}]",
88                allowed.join(", ")
89            ),
90            IssueKind::AnswerNotInList { answer, list, allowed } => format!(
91                "Answer '{answer}'{} is not a code of {list}. Its codes: [{}]",
92                at(location),
93                allowed.join(", ")
94            ),
95            IssueKind::ValueNotNumeric { field_name } => format!(
96                "Field '{field_name}'{} is not a number; its format is {}",
97                at(location),
98                issue.expected_value.as_deref().unwrap_or_default()
99            ),
100            IssueKind::DateNotInFormat { field_name, format } => format!(
101                "Field '{field_name}'{} does not fit its date format {format}",
102                at(location)
103            ),
104            IssueKind::ValueTooLong { field_name, length, max } => format!(
105                "Field '{field_name}'{} has {length} characters; its format allows {max}",
106                at(location)
107            ),
108            IssueKind::IntervalNotPositive { begin, end } => format!(
109                "Period '{begin}'..'{end}' ends at {}, not after it begins ({})",
110                issue.actual_value.as_deref().unwrap_or_default(),
111                issue
112                    .expected_value
113                    .as_deref()
114                    .unwrap_or_default()
115                    .trim_start_matches("> ")
116            ),
117            IssueKind::ReferenceNotFound { reference, value, targets } => format!(
118                "'{reference}' names '{value}', which is no {} of its transaction",
119                targets.join(" / ")
120            ),
121            IssueKind::UntSegmentCountMismatch { declared, actual } => {
122                format!("UNT segment count mismatch: declared {declared}, actual {actual}")
123            }
124            IssueKind::UntCountNotVerifiable { unh_count } => format!(
125                "UNT validation requires per-message segments, found {unh_count} UNH segments"
126            ),
127            IssueKind::Bo4eFieldDropped { path } => format!(
128                "BO4E field '{path}' is not in the message: no mapping of this PID writes it"
129            ),
130            IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
138    use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
139    use mig_assembly::StructureDiagnosticKind;
140
141    fn missing_field_issue() -> ValidationIssue {
142        ValidationIssue::new(
143            Severity::Error,
144            IssueKind::MissingRequiredField {
145                field_name: "Merkmal, Code".into(),
146            },
147        )
148        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
149        .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
150    }
151
152    #[test]
153    fn edifact_view_narrates_with_the_edifact_path() {
154        let issue = missing_field_issue();
155        let loc = EdifactView.location(&issue);
156        assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
157        assert_eq!(
158            TechnicalNarrator.describe(&issue, loc.as_deref()),
159            "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
160        );
161    }
162
163    #[test]
164    fn bo4e_view_narrates_with_the_bo4e_path() {
165        let issue = missing_field_issue();
166        let loc = Bo4eView.location(&issue);
167        assert_eq!(
168            loc.as_deref(),
169            Some("stammdaten.Marktlokation.haushaltskunde")
170        );
171        assert_eq!(
172            TechnicalNarrator.describe(&issue, loc.as_deref()),
173            "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
174        );
175    }
176
177    #[test]
178    fn bo4e_view_has_no_location_for_envelope_issues() {
179        let issue = ValidationIssue::new(
180            Severity::Error,
181            IssueKind::UntSegmentCountMismatch {
182                declared: 30,
183                actual: 31,
184            },
185        )
186        .with_field_path("UNT/0074");
187
188        assert_eq!(Bo4eView.location(&issue), None);
189        assert_eq!(
190            TechnicalNarrator.describe(&issue, None),
191            "UNT segment count mismatch: declared 30, actual 31"
192        );
193    }
194
195    // Each expectation below is transcribed verbatim from the `format!` call
196    // that built the equivalent legacy `message` in
197    // `git show 423f59255:crates/automapper-validation/src/validator/validate.rs`.
198
199    #[test]
200    fn field_condition_not_satisfied_matches_the_legacy_message() {
201        let issue = ValidationIssue::new(
202            Severity::Error,
203            IssueKind::FieldConditionNotSatisfied {
204                field_name: "Merkmal, Code".into(),
205                failed: vec![],
206            },
207        )
208        .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
209        .with_rule("Muss [182]");
210        let loc = EdifactView.location(&issue);
211
212        // validate.rs:379 — format!("Field '{}' at {} is present but does not
213        // satisfy condition: {}", field.name, field.segment_path, field.ahb_status)
214        assert_eq!(
215            TechnicalNarrator.describe(&issue, loc.as_deref()),
216            "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
217        );
218    }
219
220    #[test]
221    fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
222        let issue = ValidationIssue::new(
223            Severity::Info,
224            IssueKind::ConditionUnknown {
225                field_name: "Merkmal, Code".into(),
226                unresolved: UnresolvedConditions {
227                    external: vec![182],
228                    undetermined: vec![6],
229                    missing: vec![570],
230                },
231            },
232        )
233        .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
234        let loc = EdifactView.location(&issue);
235
236        // validate.rs:444 — format!("Condition for field '{}' could not be
237        // fully evaluated{}", field.name, detail), where `detail` joins
238        // "external conditions require provider: ...", "conditions could not
239        // be determined from message data: ...", and "missing conditions:
240        // ..." with "; ", wrapped in " (...)".
241        assert_eq!(
242            TechnicalNarrator.describe(&issue, loc.as_deref()),
243            "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])"
244        );
245    }
246
247    #[test]
248    fn package_cardinality_matches_the_legacy_message() {
249        let issue = ValidationIssue::new(
250            Severity::Error,
251            IssueKind::PackageCardinality {
252                package_id: "1".into(),
253                present: 2,
254                min: 0,
255                max: 1,
256                codes: vec!["A".into(), "B".into()],
257            },
258        )
259        .with_field_path("SG4/SG8/SG10");
260        let loc = EdifactView.location(&issue);
261
262        // validate.rs:643 — format!("Package [{}P{}..{}] at {}: {} code(s)
263        // present (allowed {}..{}). Codes in package: [{}]", pkg_id,
264        // group.min, group.max, seg_path, present_count, group.min,
265        // group.max, code_list)
266        assert_eq!(
267            TechnicalNarrator.describe(&issue, loc.as_deref()),
268            "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
269        );
270    }
271
272    #[test]
273    fn code_not_allowed_for_pid_matches_the_legacy_message() {
274        let issue = ValidationIssue::new(
275            Severity::Warning,
276            IssueKind::CodeNotAllowedForPid {
277                value: "Z99".into(),
278                allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
279            },
280        );
281
282        // validate.rs:895 — format!("Code '{}' is not allowed for this PID.
283        // Allowed: [{}]", value, allowed.join(", "))
284        assert_eq!(
285            TechnicalNarrator.describe(&issue, None),
286            "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
287        );
288    }
289
290    #[test]
291    fn unt_count_not_verifiable_matches_the_legacy_message() {
292        let issue = ValidationIssue::new(
293            Severity::Warning,
294            IssueKind::UntCountNotVerifiable { unh_count: 3 },
295        );
296
297        // validate.rs:1605 — format!("UNT validation requires per-message
298        // segments, found {unh_count} UNH segments")
299        assert_eq!(
300            TechnicalNarrator.describe(&issue, None),
301            "UNT validation requires per-message segments, found 3 UNH segments"
302        );
303    }
304
305    #[test]
306    fn structure_diagnostic_passes_its_detail_through_verbatim() {
307        // Unlike the other seven kinds, `StructureDiagnostic` was never built
308        // from a `format!` in validate.rs — it carries `mig_assembly`'s own
309        // diagnostic message (see `IssueKind::StructureDiagnostic`'s doc
310        // comment), so the narrator must reproduce it byte-for-byte with no
311        // added wording.
312        let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
313                       the assembler advanced past it";
314        let issue = ValidationIssue::new(
315            Severity::Warning,
316            IssueKind::StructureDiagnostic {
317                kind: StructureDiagnosticKind::UnexpectedSegment,
318                segment_id: "FTX".into(),
319                position: 15,
320                detail: detail.into(),
321            },
322        );
323
324        assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
325    }
326}