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::FieldConditionNotSatisfied { field_name } => format!(
56 "Field '{field_name}'{} is present but does not satisfy condition: {}",
57 at(location),
58 issue.rule.as_deref().unwrap_or_default()
59 ),
60 IssueKind::ConditionUnknown { field_name, unresolved } => format!(
61 "Condition for field '{field_name}' could not be fully evaluated{}",
62 unresolved_detail(unresolved)
63 ),
64 IssueKind::PackageCardinality { package_id, present, min, max, codes } => format!(
65 "Package [{package_id}P{min}..{max}]{}: {present} code(s) present (allowed {min}..{max}). \
66 Codes in package: [{}]",
67 at(location),
68 codes.join(", ")
69 ),
70 IssueKind::CodeNotAllowedForPid { value, allowed } => format!(
71 "Code '{value}' is not allowed for this PID. Allowed: [{}]",
72 allowed.join(", ")
73 ),
74 IssueKind::UntSegmentCountMismatch { declared, actual } => {
75 format!("UNT segment count mismatch: declared {declared}, actual {actual}")
76 }
77 IssueKind::UntCountNotVerifiable { unh_count } => format!(
78 "UNT validation requires per-message segments, found {unh_count} UNH segments"
79 ),
80 IssueKind::StructureDiagnostic { detail, .. } => detail.clone(),
81 }
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use crate::display::{Bo4eView, EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
88 use crate::{IssueKind, Severity, UnresolvedConditions, ValidationIssue};
89 use mig_assembly::StructureDiagnosticKind;
90
91 fn missing_field_issue() -> ValidationIssue {
92 ValidationIssue::new(
93 Severity::Error,
94 IssueKind::MissingRequiredField {
95 field_name: "Merkmal, Code".into(),
96 },
97 )
98 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
99 .with_bo4e_path("stammdaten.Marktlokation.haushaltskunde")
100 }
101
102 #[test]
103 fn edifact_view_narrates_with_the_edifact_path() {
104 let issue = missing_field_issue();
105 let loc = EdifactView.location(&issue);
106 assert_eq!(loc.as_deref(), Some("SG4/SG8/SG10/CCI/C240/7037"));
107 assert_eq!(
108 TechnicalNarrator.describe(&issue, loc.as_deref()),
109 "Required field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is missing"
110 );
111 }
112
113 #[test]
114 fn bo4e_view_narrates_with_the_bo4e_path() {
115 let issue = missing_field_issue();
116 let loc = Bo4eView.location(&issue);
117 assert_eq!(
118 loc.as_deref(),
119 Some("stammdaten.Marktlokation.haushaltskunde")
120 );
121 assert_eq!(
122 TechnicalNarrator.describe(&issue, loc.as_deref()),
123 "Required field 'Merkmal, Code' at stammdaten.Marktlokation.haushaltskunde is missing"
124 );
125 }
126
127 #[test]
128 fn bo4e_view_has_no_location_for_envelope_issues() {
129 let issue = ValidationIssue::new(
130 Severity::Error,
131 IssueKind::UntSegmentCountMismatch {
132 declared: 30,
133 actual: 31,
134 },
135 )
136 .with_field_path("UNT/0074");
137
138 assert_eq!(Bo4eView.location(&issue), None);
139 assert_eq!(
140 TechnicalNarrator.describe(&issue, None),
141 "UNT segment count mismatch: declared 30, actual 31"
142 );
143 }
144
145 #[test]
150 fn field_condition_not_satisfied_matches_the_legacy_message() {
151 let issue = ValidationIssue::new(
152 Severity::Error,
153 IssueKind::FieldConditionNotSatisfied {
154 field_name: "Merkmal, Code".into(),
155 },
156 )
157 .with_field_path("SG4/SG8/SG10/CCI/C240/7037")
158 .with_rule("Muss [182]");
159 let loc = EdifactView.location(&issue);
160
161 assert_eq!(
164 TechnicalNarrator.describe(&issue, loc.as_deref()),
165 "Field 'Merkmal, Code' at SG4/SG8/SG10/CCI/C240/7037 is present but does not satisfy condition: Muss [182]"
166 );
167 }
168
169 #[test]
170 fn condition_unknown_joins_multiple_id_groups_with_semicolons() {
171 let issue = ValidationIssue::new(
172 Severity::Info,
173 IssueKind::ConditionUnknown {
174 field_name: "Merkmal, Code".into(),
175 unresolved: UnresolvedConditions {
176 external: vec![182],
177 undetermined: vec![6],
178 missing: vec![570],
179 },
180 },
181 )
182 .with_field_path("SG4/SG8/SG10/CCI/C240/7037");
183 let loc = EdifactView.location(&issue);
184
185 assert_eq!(
191 TechnicalNarrator.describe(&issue, loc.as_deref()),
192 "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])"
193 );
194 }
195
196 #[test]
197 fn package_cardinality_matches_the_legacy_message() {
198 let issue = ValidationIssue::new(
199 Severity::Error,
200 IssueKind::PackageCardinality {
201 package_id: "1".into(),
202 present: 2,
203 min: 0,
204 max: 1,
205 codes: vec!["A".into(), "B".into()],
206 },
207 )
208 .with_field_path("SG4/SG8/SG10");
209 let loc = EdifactView.location(&issue);
210
211 assert_eq!(
216 TechnicalNarrator.describe(&issue, loc.as_deref()),
217 "Package [1P0..1] at SG4/SG8/SG10: 2 code(s) present (allowed 0..1). Codes in package: [A, B]"
218 );
219 }
220
221 #[test]
222 fn code_not_allowed_for_pid_matches_the_legacy_message() {
223 let issue = ValidationIssue::new(
224 Severity::Warning,
225 IssueKind::CodeNotAllowedForPid {
226 value: "Z99".into(),
227 allowed: vec!["Z01".into(), "Z02".into(), "Z98".into()],
228 },
229 );
230
231 assert_eq!(
234 TechnicalNarrator.describe(&issue, None),
235 "Code 'Z99' is not allowed for this PID. Allowed: [Z01, Z02, Z98]"
236 );
237 }
238
239 #[test]
240 fn unt_count_not_verifiable_matches_the_legacy_message() {
241 let issue = ValidationIssue::new(
242 Severity::Warning,
243 IssueKind::UntCountNotVerifiable { unh_count: 3 },
244 );
245
246 assert_eq!(
249 TechnicalNarrator.describe(&issue, None),
250 "UNT validation requires per-message segments, found 3 UNH segments"
251 );
252 }
253
254 #[test]
255 fn structure_diagnostic_passes_its_detail_through_verbatim() {
256 let detail = "Segment 'FTX' at position 15 is not defined in the PID-filtered MIG; \
262 the assembler advanced past it";
263 let issue = ValidationIssue::new(
264 Severity::Warning,
265 IssueKind::StructureDiagnostic {
266 kind: StructureDiagnosticKind::UnexpectedSegment,
267 segment_id: "FTX".into(),
268 position: 15,
269 detail: detail.into(),
270 },
271 );
272
273 assert_eq!(TechnicalNarrator.describe(&issue, None), detail);
274 }
275}