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::ValueTooLong { field_name, length, max } => format!(
101 "Field '{field_name}'{} has {length} characters; its format allows {max}",
102 at(location)
103 ),
104 IssueKind::IntervalNotPositive { begin, end } => format!(
105 "Period '{begin}'..'{end}' ends at {}, not after it begins ({})",
106 issue.actual_value.as_deref().unwrap_or_default(),
107 issue
108 .expected_value
109 .as_deref()
110 .unwrap_or_default()
111 .trim_start_matches("> ")
112 ),
113 IssueKind::ReferenceNotFound { reference, value, targets } => format!(
114 "'{reference}' names '{value}', which is no {} of its transaction",
115 targets.join(" / ")
116 ),
117 IssueKind::UntSegmentCountMismatch { declared, actual } => {
118 format!("UNT segment count mismatch: declared {declared}, actual {actual}")
119 }
120 IssueKind::UntCountNotVerifiable { unh_count } => format!(
121 "UNT validation requires per-message segments, found {unh_count} UNH segments"
122 ),
123 IssueKind::Bo4eFieldDropped { path } => format!(
124 "BO4E field '{path}' is not in the message: no mapping of this PID writes it"
125 ),
126 IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
134 use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
135 use mig_assembly::StructureDiagnosticKind;
136
137 fn missing_field_issue() -> ValidationIssue {
138 ValidationIssue::new(
139 Severity::Error,
140 IssueKind::MissingRequiredField {
141 field_name: "Merkmal, Code".into(),
142 },
143 )
144 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
145 .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
146 }
147
148 #[test]
149 fn edifact_view_narrates_with_the_edifact_path() {
150 let issue = missing_field_issue();
151 let loc = EdifactView.location(&issue);
152 assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
153 assert_eq!(
154 TechnicalNarrator.describe(&issue, loc.as_deref()),
155 "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
156 );
157 }
158
159 #[test]
160 fn bo4e_view_narrates_with_the_bo4e_path() {
161 let issue = missing_field_issue();
162 let loc = Bo4eView.location(&issue);
163 assert_eq!(
164 loc.as_deref(),
165 Some("stammdaten.Marktlokation.haushaltskunde")
166 );
167 assert_eq!(
168 TechnicalNarrator.describe(&issue, loc.as_deref()),
169 "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
170 );
171 }
172
173 #[test]
174 fn bo4e_view_has_no_location_for_envelope_issues() {
175 let issue = ValidationIssue::new(
176 Severity::Error,
177 IssueKind::UntSegmentCountMismatch {
178 declared: 30,
179 actual: 31,
180 },
181 )
182 .with_field_path("UNT/0074");
183
184 assert_eq!(Bo4eView.location(&issue), None);
185 assert_eq!(
186 TechnicalNarrator.describe(&issue, None),
187 "UNT segment count mismatch: declared 30, actual 31"
188 );
189 }
190
191 #[test]
196 fn field_condition_not_satisfied_matches_the_legacy_message() {
197 let issue = ValidationIssue::new(
198 Severity::Error,
199 IssueKind::FieldConditionNotSatisfied {
200 field_name: "Merkmal, Code".into(),
201 failed: vec![],
202 },
203 )
204 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
205 .with_rule("Muss [182]");
206 let loc = EdifactView.location(&issue);
207
208 assert_eq!(
211 TechnicalNarrator.describe(&issue, loc.as_deref()),
212 "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
213 );
214 }
215
216 #[test]
217 fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
218 let issue = ValidationIssue::new(
219 Severity::Info,
220 IssueKind::ConditionUnknown {
221 field_name: "Merkmal, Code".into(),
222 unresolved: UnresolvedConditions {
223 external: vec![182],
224 undetermined: vec![6],
225 missing: vec![570],
226 },
227 },
228 )
229 .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
230 let loc = EdifactView.location(&issue);
231
232 assert_eq!(
238 TechnicalNarrator.describe(&issue, loc.as_deref()),
239 "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])"
240 );
241 }
242
243 #[test]
244 fn package_cardinality_matches_the_legacy_message() {
245 let issue = ValidationIssue::new(
246 Severity::Error,
247 IssueKind::PackageCardinality {
248 package_id: "1".into(),
249 present: 2,
250 min: 0,
251 max: 1,
252 codes: vec!["A".into(), "B".into()],
253 },
254 )
255 .with_field_path("SG4/SG8/SG10");
256 let loc = EdifactView.location(&issue);
257
258 assert_eq!(
263 TechnicalNarrator.describe(&issue, loc.as_deref()),
264 "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
265 );
266 }
267
268 #[test]
269 fn code_not_allowed_for_pid_matches_the_legacy_message() {
270 let issue = ValidationIssue::new(
271 Severity::Warning,
272 IssueKind::CodeNotAllowedForPid {
273 value: "Z99".into(),
274 allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
275 },
276 );
277
278 assert_eq!(
281 TechnicalNarrator.describe(&issue, None),
282 "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
283 );
284 }
285
286 #[test]
287 fn unt_count_not_verifiable_matches_the_legacy_message() {
288 let issue = ValidationIssue::new(
289 Severity::Warning,
290 IssueKind::UntCountNotVerifiable { unh_count: 3 },
291 );
292
293 assert_eq!(
296 TechnicalNarrator.describe(&issue, None),
297 "UNT validation requires per-message segments, found 3 UNH segments"
298 );
299 }
300
301 #[test]
302 fn structure_diagnostic_passes_its_detail_through_verbatim() {
303 let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
309 the assembler advanced past it";
310 let issue = ValidationIssue::new(
311 Severity::Warning,
312 IssueKind::StructureDiagnostic {
313 kind: StructureDiagnosticKind::UnexpectedSegment,
314 segment_id: "FTX".into(),
315 position: 15,
316 detail: detail.into(),
317 },
318 );
319
320 assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
321 }
322}