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::CodeNotAllowedForPid { value, allowed } => format!(
79 "Code '{value}' is not allowed for this PID. Allowed: [{}]",
80 allowed.join(", ")
81 ),
82 IssueKind::AnswerNotInList { answer, list, allowed } => format!(
83 "Answer '{answer}'{} is not a code of {list}. Its codes: [{}]",
84 at(location),
85 allowed.join(", ")
86 ),
87 IssueKind::ValueTooLong { field_name, length, max } => format!(
88 "Field '{field_name}'{} has {length} characters; its format allows {max}",
89 at(location)
90 ),
91 IssueKind::UntSegmentCountMismatch { declared, actual } => {
92 format!("UNT segment count mismatch: declared {declared}, actual {actual}")
93 }
94 IssueKind::UntCountNotVerifiable { unh_count } => format!(
95 "UNT validation requires per-message segments, found {unh_count} UNH segments"
96 ),
97 IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
105 use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
106 use mig_assembly::StructureDiagnosticKind;
107
108 fn missing_field_issue() -> ValidationIssue {
109 ValidationIssue::new(
110 Severity::Error,
111 IssueKind::MissingRequiredField {
112 field_name: "Merkmal, Code".into(),
113 },
114 )
115 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
116 .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
117 }
118
119 #[test]
120 fn edifact_view_narrates_with_the_edifact_path() {
121 let issue = missing_field_issue();
122 let loc = EdifactView.location(&issue);
123 assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
124 assert_eq!(
125 TechnicalNarrator.describe(&issue, loc.as_deref()),
126 "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
127 );
128 }
129
130 #[test]
131 fn bo4e_view_narrates_with_the_bo4e_path() {
132 let issue = missing_field_issue();
133 let loc = Bo4eView.location(&issue);
134 assert_eq!(
135 loc.as_deref(),
136 Some("stammdaten.Marktlokation.haushaltskunde")
137 );
138 assert_eq!(
139 TechnicalNarrator.describe(&issue, loc.as_deref()),
140 "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
141 );
142 }
143
144 #[test]
145 fn bo4e_view_has_no_location_for_envelope_issues() {
146 let issue = ValidationIssue::new(
147 Severity::Error,
148 IssueKind::UntSegmentCountMismatch {
149 declared: 30,
150 actual: 31,
151 },
152 )
153 .with_field_path("UNT/0074");
154
155 assert_eq!(Bo4eView.location(&issue), None);
156 assert_eq!(
157 TechnicalNarrator.describe(&issue, None),
158 "UNT segment count mismatch: declared 30, actual 31"
159 );
160 }
161
162 #[test]
167 fn field_condition_not_satisfied_matches_the_legacy_message() {
168 let issue = ValidationIssue::new(
169 Severity::Error,
170 IssueKind::FieldConditionNotSatisfied {
171 field_name: "Merkmal, Code".into(),
172 },
173 )
174 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
175 .with_rule("Muss [182]");
176 let loc = EdifactView.location(&issue);
177
178 assert_eq!(
181 TechnicalNarrator.describe(&issue, loc.as_deref()),
182 "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
183 );
184 }
185
186 #[test]
187 fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
188 let issue = ValidationIssue::new(
189 Severity::Info,
190 IssueKind::ConditionUnknown {
191 field_name: "Merkmal, Code".into(),
192 unresolved: UnresolvedConditions {
193 external: vec![182],
194 undetermined: vec![6],
195 missing: vec![570],
196 },
197 },
198 )
199 .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
200 let loc = EdifactView.location(&issue);
201
202 assert_eq!(
208 TechnicalNarrator.describe(&issue, loc.as_deref()),
209 "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])"
210 );
211 }
212
213 #[test]
214 fn package_cardinality_matches_the_legacy_message() {
215 let issue = ValidationIssue::new(
216 Severity::Error,
217 IssueKind::PackageCardinality {
218 package_id: "1".into(),
219 present: 2,
220 min: 0,
221 max: 1,
222 codes: vec!["A".into(), "B".into()],
223 },
224 )
225 .with_field_path("SG4/SG8/SG10");
226 let loc = EdifactView.location(&issue);
227
228 assert_eq!(
233 TechnicalNarrator.describe(&issue, loc.as_deref()),
234 "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
235 );
236 }
237
238 #[test]
239 fn code_not_allowed_for_pid_matches_the_legacy_message() {
240 let issue = ValidationIssue::new(
241 Severity::Warning,
242 IssueKind::CodeNotAllowedForPid {
243 value: "Z99".into(),
244 allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
245 },
246 );
247
248 assert_eq!(
251 TechnicalNarrator.describe(&issue, None),
252 "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
253 );
254 }
255
256 #[test]
257 fn unt_count_not_verifiable_matches_the_legacy_message() {
258 let issue = ValidationIssue::new(
259 Severity::Warning,
260 IssueKind::UntCountNotVerifiable { unh_count: 3 },
261 );
262
263 assert_eq!(
266 TechnicalNarrator.describe(&issue, None),
267 "UNT validation requires per-message segments, found 3 UNH segments"
268 );
269 }
270
271 #[test]
272 fn structure_diagnostic_passes_its_detail_through_verbatim() {
273 let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
279 the assembler advanced past it";
280 let issue = ValidationIssue::new(
281 Severity::Warning,
282 IssueKind::StructureDiagnostic {
283 kind: StructureDiagnosticKind::UnexpectedSegment,
284 segment_id: "FTX".into(),
285 position: 15,
286 detail: detail.into(),
287 },
288 );
289
290 assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
291 }
292}