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::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 #[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 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 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 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 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 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 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}