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