automapper_validation/eval/
ebd_cluster.rs1use super::context::EvaluationContext;
12use super::evaluator::ConditionResult;
13use serde::Deserialize;
14use std::collections::HashMap;
15use std::sync::OnceLock;
16
17const EMBEDDED_JSON: &str = include_str!("../../data/ebd_cluster_map.json");
18
19#[derive(Debug, Deserialize)]
20struct RawFile {
21 ebds: HashMap<String, HashMap<String, String>>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Cluster {
36 Zustimmung,
37 Ablehnung,
38 AblehnungKopfebene,
39 AblehnungPositionsebene,
40 AblehnungSummenebene,
41 Other(String),
45}
46
47impl Cluster {
48 pub fn from_token(token: &str) -> Self {
49 match token {
50 "Zustimmung" => Cluster::Zustimmung,
51 "Ablehnung" => Cluster::Ablehnung,
52 "Ablehnung auf Kopfebene" => Cluster::AblehnungKopfebene,
53 "Ablehnung auf Positionsebene" => Cluster::AblehnungPositionsebene,
54 "Ablehnung auf Summenebene" => Cluster::AblehnungSummenebene,
55 _ => Cluster::Other(token.to_owned()),
56 }
57 }
58
59 pub fn is_zustimmung(&self) -> bool {
60 matches!(self, Cluster::Zustimmung)
61 }
62
63 pub fn is_ablehnung(&self) -> bool {
65 matches!(
66 self,
67 Cluster::Ablehnung
68 | Cluster::AblehnungKopfebene
69 | Cluster::AblehnungPositionsebene
70 | Cluster::AblehnungSummenebene
71 )
72 }
73
74 pub fn is_ablehnung_kopfebene(&self) -> bool {
75 matches!(self, Cluster::AblehnungKopfebene)
76 }
77
78 pub fn is_ablehnung_positionsebene(&self) -> bool {
79 matches!(self, Cluster::AblehnungPositionsebene)
80 }
81
82 pub fn is_ablehnung_summenebene(&self) -> bool {
83 matches!(self, Cluster::AblehnungSummenebene)
84 }
85}
86
87#[derive(Debug)]
88pub struct EbdClusterLookup {
89 clusters: HashMap<(String, String), Cluster>,
90}
91
92impl EbdClusterLookup {
93 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
94 let raw: RawFile = serde_json::from_str(json)?;
95 let clusters = raw
96 .ebds
97 .into_iter()
98 .flat_map(|(ebd, codes)| {
99 codes
100 .into_iter()
101 .map(move |(code, token)| ((ebd.clone(), code), Cluster::from_token(&token)))
102 })
103 .collect();
104 Ok(Self { clusters })
105 }
106
107 pub fn embedded() -> &'static Self {
108 static CELL: OnceLock<EbdClusterLookup> = OnceLock::new();
109 CELL.get_or_init(|| {
110 EbdClusterLookup::from_json(EMBEDDED_JSON)
111 .expect("embedded ebd_cluster_map.json is malformed")
112 })
113 }
114
115 pub fn cluster(&self, ebd: &str, code: &str) -> Option<&Cluster> {
119 self.clusters.get(&(ebd.to_owned(), code.to_owned()))
120 }
121}
122
123fn sts_e01_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
134 let c556 = seg.elements.get(2)?;
135 let code = c556.first().filter(|v| !v.is_empty())?.as_str();
136 let ebd = c556.get(1).filter(|v| !v.is_empty())?.as_str();
137 Some((code, ebd))
138}
139
140fn ajt_code_ebd(seg: &mig_types::segment::OwnedSegment) -> Option<(&str, &str)> {
144 let code = seg
145 .elements
146 .first()?
147 .first()
148 .filter(|v| !v.is_empty())?
149 .as_str();
150 let ebd = seg
151 .elements
152 .get(1)?
153 .first()
154 .filter(|v| !v.is_empty())?
155 .as_str();
156 Some((code, ebd))
157}
158
159pub fn all_sts_e01_in_cluster(
165 ctx: &EvaluationContext,
166 predicate: impl Fn(&Cluster) -> bool,
167) -> ConditionResult {
168 let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
169 if sts_segs.is_empty() {
170 return ConditionResult::Unknown;
171 }
172 let all = sts_segs.iter().all(|s| {
173 let Some((code, ebd)) = sts_e01_code_ebd(s) else {
174 return false;
175 };
176 ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
177 });
178 ConditionResult::from(all)
179}
180
181pub fn ebd_ablehnung_except(
184 ctx: &EvaluationContext,
185 required_ebd: &str,
186 excluded_codes: &[&str],
187) -> ConditionResult {
188 let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
189 if sts_segs.is_empty() {
190 return ConditionResult::Unknown;
191 }
192 let all = sts_segs.iter().all(|s| {
193 let Some((code, ebd)) = sts_e01_code_ebd(s) else {
194 return false;
195 };
196 ebd == required_ebd
197 && !excluded_codes.contains(&code)
198 && ctx
199 .ebd_clusters
200 .cluster(ebd, code)
201 .is_some_and(Cluster::is_ablehnung)
202 });
203 ConditionResult::from(all)
204}
205
206pub fn any_sts_e01_in_cluster(
212 ctx: &EvaluationContext,
213 predicate: impl Fn(&Cluster) -> bool,
214) -> ConditionResult {
215 let sts_segs = ctx.find_segments_with_qualifier("STS", 0, "E01");
216 let found = sts_segs.iter().any(|s| {
217 sts_e01_code_ebd(s)
218 .is_some_and(|(code, ebd)| ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate))
219 });
220 ConditionResult::from(found)
221}
222
223pub fn resolved_sts_code_in_cluster(
232 ctx: &EvaluationContext,
233 predicate: impl Fn(&Cluster) -> bool,
234) -> ConditionResult {
235 let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
236 return ConditionResult::Unknown;
237 };
238 if code.is_empty() {
239 return ConditionResult::Unknown;
240 }
241 let ebd = segment
242 .get(2)
243 .and_then(|e| e.get(1))
244 .map(|s| s.as_str())
245 .filter(|s| !s.is_empty());
246 let Some(ebd) = ebd else {
247 return ConditionResult::Unknown;
248 };
249 ctx.ebd_clusters
250 .cluster(ebd, code)
251 .map(|c| ConditionResult::from(predicate(c)))
252 .unwrap_or(ConditionResult::Unknown)
253}
254
255pub fn resolved_ajt_code_in_cluster(
258 ctx: &EvaluationContext,
259 predicate: impl Fn(&Cluster) -> bool,
260) -> ConditionResult {
261 let (Some(code), Some(segment)) = (ctx.resolved_value, ctx.resolved_segment) else {
262 return ConditionResult::Unknown;
263 };
264 if code.is_empty() {
265 return ConditionResult::Unknown;
266 }
267 let ebd = segment
268 .get(1)
269 .and_then(|e| e.first())
270 .map(|s| s.as_str())
271 .filter(|s| !s.is_empty());
272 let Some(ebd) = ebd else {
273 return ConditionResult::Unknown;
274 };
275 ctx.ebd_clusters
276 .cluster(ebd, code)
277 .map(|c| ConditionResult::from(predicate(c)))
278 .unwrap_or(ConditionResult::Unknown)
279}
280
281pub fn any_scoped_ajt_in_cluster(
286 ctx: &EvaluationContext,
287 excluded_codes: &[&str],
288 predicate: impl Fn(&Cluster) -> bool,
289) -> ConditionResult {
290 let ajt_segs = ctx.scoped_find_segments("AJT");
291 let found = ajt_segs.iter().any(|s| {
292 ajt_code_ebd(s).is_some_and(|(code, ebd)| {
293 !excluded_codes.contains(&code)
294 && ctx.ebd_clusters.cluster(ebd, code).is_some_and(&predicate)
295 })
296 });
297 ConditionResult::from(found)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn a36_in_e0624_is_zustimmung() {
306 let lookup = EbdClusterLookup::embedded();
307 assert_eq!(lookup.cluster("E_0624", "A36"), Some(&Cluster::Zustimmung));
308 }
309
310 #[test]
311 fn a30_in_e0624_is_ablehnung() {
312 let lookup = EbdClusterLookup::embedded();
313 assert_eq!(lookup.cluster("E_0624", "A30"), Some(&Cluster::Ablehnung));
314 }
315
316 #[test]
317 fn unknown_code_returns_none() {
318 let lookup = EbdClusterLookup::embedded();
319 assert_eq!(lookup.cluster("E_0624", "Z99"), None);
320 }
321
322 #[test]
323 fn unknown_ebd_returns_none() {
324 let lookup = EbdClusterLookup::embedded();
325 assert_eq!(lookup.cluster("E_9999", "A01"), None);
326 }
327
328 #[test]
329 fn same_code_can_differ_across_ebds() {
330 let lookup = EbdClusterLookup::embedded();
333 assert!(lookup.cluster("E_0014", "A01").is_some());
334 assert!(lookup.cluster("E_0049", "A01").is_some());
335 }
336
337 #[test]
338 fn korrekturliste_wegen_ablehnung_is_preserved_as_other() {
339 let lookup = EbdClusterLookup::embedded();
340 match lookup.cluster("E_0014", "A04") {
344 Some(Cluster::Other(s)) => assert_eq!(s, "Korrekturliste wegen Ablehnung"),
345 other => {
346 panic!("expected Cluster::Other(\"Korrekturliste wegen Ablehnung\"), got {other:?}")
347 }
348 }
349 }
350
351 #[test]
352 fn sub_level_ablehnung_variants_still_count_as_ablehnung() {
353 assert!(Cluster::Ablehnung.is_ablehnung());
354 assert!(Cluster::AblehnungKopfebene.is_ablehnung());
355 assert!(Cluster::AblehnungPositionsebene.is_ablehnung());
356 assert!(Cluster::AblehnungSummenebene.is_ablehnung());
357 assert!(!Cluster::Zustimmung.is_ablehnung());
358 assert!(!Cluster::Other("Abweisung".into()).is_ablehnung());
359 }
360}