automapper_validation/display/
narrator.rs1use crate::{IssueKind, UnresolvedConditions, ValidationIssue};
4
5use super::IssueNarrator;
6
7pub struct TechnicalNarrator;
13
14fn 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 #[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 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 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 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 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 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 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}