Skip to main content

automapper_validation/eval/
context.rs

1//! Evaluation context for condition evaluation.
2
3use super::codelist::ArtikelIdCodelist;
4use super::ebd_cluster::EbdClusterLookup;
5use super::evaluator::{ConditionResult, ExternalConditionProvider};
6use mig_types::navigator::GroupNavigator;
7use mig_types::segment::OwnedSegment;
8use std::collections::HashMap;
9use std::sync::{Arc, OnceLock};
10
11/// Context passed to condition evaluators during evaluation.
12///
13/// Carries references to the transaction data and external condition
14/// provider needed to evaluate AHB conditions.
15pub struct EvaluationContext<'a> {
16    /// The Pruefidentifikator (e.g., "11001", "55001") that identifies
17    /// the specific AHB workflow being validated against.
18    pub pruefidentifikator: &'a str,
19
20    /// Provider for external conditions that depend on business context
21    /// outside the EDIFACT message.
22    pub external: &'a dyn ExternalConditionProvider,
23
24    /// Parsed EDIFACT segments for direct segment inspection by condition
25    /// evaluators. Conditions often need to check specific segment values.
26    pub segments: &'a [OwnedSegment],
27
28    /// Optional group navigator for group-scoped condition queries.
29    /// When None, group-scoped methods return empty / false / 0.
30    pub navigator: Option<&'a dyn GroupNavigator>,
31
32    /// The resolved value of the data element currently being validated.
33    ///
34    /// Set per-field during tree-based validation from `AhbNode.value`.
35    /// Format/value conditions (e.g., [931] "ZZZ=+00", [932] "HHMM=2200")
36    /// check this first instead of searching message-wide.
37    /// When None, conditions fall back to message-wide segment searches.
38    pub resolved_value: Option<&'a str>,
39
40    /// The full segment elements for the segment being validated.
41    ///
42    /// Allows cross-element access within the same segment (e.g., check
43    /// qualifier in element 0 while validating value in element 1).
44    /// When None, conditions fall back to message-wide segment searches.
45    pub resolved_segment: Option<&'a [Vec<String>]>,
46
47    /// BDEW Artikel-ID / Artikelnummer codelist, used by conditions like
48    /// [73] and [284] that reference "Codeliste der Gruppenartikel- und
49    /// Artikel-ID". Defaults to [`ArtikelIdCodelist::embedded`].
50    pub codelist: &'a ArtikelIdCodelist,
51
52    /// Per-EBD answer-code cluster lookup, used by conditions like
53    /// `[359]` / `[360]` / `[366]` / `[368]` that restrict STS response
54    /// codes to the Zustimmung or Ablehnung cluster of a specific EBD.
55    /// Defaults to [`EbdClusterLookup::embedded`].
56    pub ebd_clusters: &'a EbdClusterLookup,
57
58    /// Optional group scope — when Some, scope-aware helpers like
59    /// `scoped_find_segments` narrow lookups to that group instance.
60    /// When None, scoped helpers fall back to message-wide semantics.
61    pub scope: Option<GroupScope<'a>>,
62
63    /// The index of `segments` by tag and qualifier, built on the first
64    /// message-wide lookup and shared by the contexts derived for the same
65    /// segments (`with_scope`, `with_resolved`). A context over other
66    /// segments (`with_view`) gets its own.
67    segment_index: Arc<OnceLock<SegmentIndex>>,
68}
69
70/// A no-op group navigator that returns empty results for all queries.
71pub struct NoOpGroupNavigator;
72
73/// A scope identifying one group instance, by its repetition at every level
74/// from the root: `[("SG4", 1), ("SG8", 2)]` is the third SG8 of the second SG4.
75///
76/// When attached to an [`EvaluationContext`] via [`EvaluationContext::with_scope`],
77/// scope-aware helpers narrow segment lookups to that instance instead of searching
78/// message-wide.
79#[derive(Debug, Clone, Copy)]
80pub struct GroupScope<'a> {
81    pub path: &'a [(&'a str, usize)],
82}
83
84impl GroupNavigator for NoOpGroupNavigator {
85    fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
86        Vec::new()
87    }
88    fn find_segments_with_qualifier_in_group(
89        &self,
90        _: &str,
91        _: usize,
92        _: &str,
93        _: &[&str],
94        _: usize,
95    ) -> Vec<OwnedSegment> {
96        Vec::new()
97    }
98    fn group_instance_count(&self, _: &[&str]) -> usize {
99        0
100    }
101}
102
103impl<'a> EvaluationContext<'a> {
104    /// Create a new evaluation context (without group navigator).
105    pub fn new(
106        pruefidentifikator: &'a str,
107        external: &'a dyn ExternalConditionProvider,
108        segments: &'a [OwnedSegment],
109    ) -> Self {
110        Self {
111            pruefidentifikator,
112            external,
113            segments,
114            navigator: None,
115            resolved_value: None,
116            resolved_segment: None,
117            codelist: ArtikelIdCodelist::embedded(),
118            ebd_clusters: EbdClusterLookup::embedded(),
119            scope: None,
120            segment_index: Arc::default(),
121        }
122    }
123
124    /// Create a new evaluation context with a group navigator.
125    pub fn with_navigator(
126        pruefidentifikator: &'a str,
127        external: &'a dyn ExternalConditionProvider,
128        segments: &'a [OwnedSegment],
129        navigator: &'a dyn GroupNavigator,
130    ) -> Self {
131        Self {
132            pruefidentifikator,
133            external,
134            segments,
135            navigator: Some(navigator),
136            resolved_value: None,
137            resolved_segment: None,
138            codelist: ArtikelIdCodelist::embedded(),
139            ebd_clusters: EbdClusterLookup::embedded(),
140            scope: None,
141            segment_index: Arc::default(),
142        }
143    }
144
145    /// Override the codelist (tests only — defaults to embedded).
146    pub fn with_codelist(mut self, codelist: &'a ArtikelIdCodelist) -> Self {
147        self.codelist = codelist;
148        self
149    }
150
151    /// Override the EBD cluster lookup (tests only — defaults to embedded).
152    pub fn with_ebd_clusters(mut self, ebd_clusters: &'a EbdClusterLookup) -> Self {
153        self.ebd_clusters = ebd_clusters;
154        self
155    }
156
157    /// Attach a group scope; returns a fresh context without consuming self.
158    /// Mirrors `with_resolved`'s `&self` shape so `validate_tree` can build
159    /// a scoped context inside a loop without moving the outer one.
160    pub fn with_scope(&self, scope: GroupScope<'a>) -> Self {
161        Self {
162            scope: Some(scope),
163            segment_index: Arc::clone(&self.segment_index),
164            ..*self
165        }
166    }
167
168    /// This context over another view of the message — one transaction of
169    /// it ([`mig_assembly::transaction_view`]): its `segments` and
170    /// `navigator`, no scope yet.
171    pub fn with_view<'b>(
172        &self,
173        segments: &'b [OwnedSegment],
174        navigator: &'b dyn GroupNavigator,
175    ) -> EvaluationContext<'b>
176    where
177        'a: 'b,
178    {
179        EvaluationContext {
180            pruefidentifikator: self.pruefidentifikator,
181            external: self.external,
182            segments,
183            navigator: Some(navigator),
184            resolved_value: self.resolved_value,
185            resolved_segment: self.resolved_segment,
186            codelist: self.codelist,
187            ebd_clusters: self.ebd_clusters,
188            scope: None,
189            segment_index: Arc::default(),
190        }
191    }
192
193    /// Create a context with a resolved field value for tree-based validation.
194    ///
195    /// The resolved value comes from the `ValidatedTree` — the specific data
196    /// element value at the current tree position. Conditions check this first.
197    pub fn with_resolved(
198        &self,
199        value: Option<&'a str>,
200        segment: Option<&'a [Vec<String>]>,
201    ) -> Self {
202        Self {
203            resolved_value: value,
204            resolved_segment: segment,
205            segment_index: Arc::clone(&self.segment_index),
206            ..*self
207        }
208    }
209
210    /// Get the group navigator, if one is set.
211    pub fn navigator(&self) -> Option<&'a dyn GroupNavigator> {
212        self.navigator
213    }
214
215    /// The index of `segments`, built on first use. `None` when `segments`
216    /// is no longer the slice the index was built for (the field is public
217    /// and may be reassigned): lookups then scan.
218    fn index(&self) -> Option<&SegmentIndex> {
219        let index = self
220            .segment_index
221            .get_or_init(|| SegmentIndex::build(self.segments));
222        index.describes(self.segments).then_some(index)
223    }
224
225    /// The segments `segment_id` — those with `qualifier` as the first
226    /// component of element `.0`, given one — in message order. Reads the
227    /// index; scans where it cannot be read.
228    fn lookup<'s>(
229        &'s self,
230        segment_id: &'s str,
231        qualifier: Option<(usize, &'s str)>,
232    ) -> SegmentLookup<'a, 's> {
233        let positions = match self.index() {
234            Some(index) => Positions::Listed(index.positions(segment_id, qualifier).iter()),
235            None => Positions::All(0..self.segments.len()),
236        };
237        SegmentLookup {
238            segments: self.segments,
239            positions,
240            segment_id,
241            qualifier,
242        }
243    }
244
245    /// Find the first segment with the given ID.
246    pub fn find_segment(&self, segment_id: &str) -> Option<&'a OwnedSegment> {
247        self.lookup(segment_id, None).next()
248    }
249
250    /// Find all segments with the given ID.
251    pub fn find_segments(&self, segment_id: &str) -> Vec<&'a OwnedSegment> {
252        self.lookup(segment_id, None).collect()
253    }
254
255    /// Find segments with a specific qualifier value on a given element.
256    pub fn find_segments_with_qualifier(
257        &self,
258        segment_id: &str,
259        element_index: usize,
260        qualifier: &str,
261    ) -> Vec<&'a OwnedSegment> {
262        self.lookup(segment_id, Some((element_index, qualifier)))
263            .collect()
264    }
265
266    /// The first segment with a specific qualifier value on a given element:
267    /// `find_segments_with_qualifier(..).first()`, without scanning the rest
268    /// of the message. A condition judged once per repetition (MSCONS `[495]`
269    /// on each SG10 DTM, #437) looks the header up in linear time this way.
270    pub fn find_segment_with_qualifier(
271        &self,
272        segment_id: &str,
273        element_index: usize,
274        qualifier: &str,
275    ) -> Option<&'a OwnedSegment> {
276        self.lookup(segment_id, Some((element_index, qualifier)))
277            .next()
278    }
279
280    /// Find segments `tag` within the current scope — the scoped group instance
281    /// and every group nested in it — or message-wide if no scope is set.
282    /// Scope + navigator required for per-instance narrowing; falls back to
283    /// `find_segments` when either is absent.
284    ///
285    /// Returns owned `OwnedSegment` values because the navigator may synthesise
286    /// segments from an assembled tree rather than borrowing from
287    /// `ctx.segments`. Callers that only need a `&OwnedSegment` should use
288    /// `find_segments` directly when they know message-wide is correct.
289    pub fn scoped_find_segments(&self, tag: &str) -> Vec<OwnedSegment> {
290        match (self.scope, self.navigator) {
291            (Some(scope), Some(nav)) => nav.find_segments_in_subtree(tag, scope.path),
292            _ => self.find_segments(tag).into_iter().cloned().collect(),
293        }
294    }
295
296    /// Find segments `tag` in the nearest enclosing instance of `group_id` —
297    /// "in dieser SG8" asked by a field that sits in an SG10 of that SG8 —
298    /// including every group nested in it.
299    ///
300    /// Message-wide when no scope or navigator is set, or when the scope has no
301    /// enclosing `group_id` (a transaction-level field): the same answer an
302    /// unscoped lookup gives.
303    pub fn scoped_find_segments_in(&self, group_id: &str, tag: &str) -> Vec<OwnedSegment> {
304        if let (Some(scope), Some(nav)) = (self.scope, self.navigator) {
305            if let Some(level) = scope.path.iter().rposition(|(id, _)| *id == group_id) {
306                return nav.find_segments_in_subtree(tag, &scope.path[..=level]);
307            }
308        }
309        self.find_segments(tag).into_iter().cloned().collect()
310    }
311
312    /// The other instances of the enclosing `group_id` under the same parent —
313    /// "in einem weiteren SG8" — as scope paths, in message order.
314    ///
315    /// `None` without a scope, a navigator, or an enclosing `group_id`.
316    pub fn sibling_scopes(&self, group_id: &str) -> Option<Vec<Vec<(&'a str, usize)>>> {
317        let (scope, nav) = (self.scope?, self.navigator?);
318        let level = scope.path.iter().rposition(|(id, _)| *id == group_id)?;
319        let (parent, own) = (&scope.path[..level], scope.path[level]);
320        let count = nav.child_instance_count(parent, own.0);
321        Some(
322            (0..count)
323                .filter(|&i| i != own.1)
324                .map(|i| {
325                    let mut path = parent.to_vec();
326                    path.push((own.0, i));
327                    path
328                })
329                .collect(),
330        )
331    }
332
333    /// The segments `tag` of the group instance at `path` and the groups nested
334    /// in it; empty without a navigator.
335    pub fn find_segments_at(&self, path: &[(&str, usize)], tag: &str) -> Vec<OwnedSegment> {
336        self.navigator
337            .map(|nav| nav.find_segments_in_subtree(tag, path))
338            .unwrap_or_default()
339    }
340
341    /// [`scoped_find_segments_in`](Self::scoped_find_segments_in), keeping the
342    /// segments whose `element_index` starts with `qualifier`.
343    pub fn scoped_find_segments_in_with_qualifier(
344        &self,
345        group_id: &str,
346        tag: &str,
347        element_index: usize,
348        qualifier: &str,
349    ) -> Vec<OwnedSegment> {
350        self.scoped_find_segments_in(group_id, tag)
351            .into_iter()
352            .filter(|s| {
353                s.elements
354                    .get(element_index)
355                    .and_then(|e| e.first())
356                    .is_some_and(|v| v == qualifier)
357            })
358            .collect()
359    }
360
361    /// Find segments `tag` with a qualifier value on element `element_index`,
362    /// narrowed to the current scope or message-wide.
363    pub fn scoped_find_segments_with_qualifier(
364        &self,
365        tag: &str,
366        element_index: usize,
367        qualifier: &str,
368    ) -> Vec<OwnedSegment> {
369        self.scoped_find_segments(tag)
370            .into_iter()
371            .filter(|s| {
372                s.elements
373                    .get(element_index)
374                    .and_then(|e| e.first())
375                    .is_some_and(|v| v == qualifier)
376            })
377            .collect()
378    }
379
380    /// Validate a format condition on the current field value.
381    ///
382    /// Prefers `resolved_value` (exact field from tree-based validation).
383    /// Falls back to searching segments by tag and extracting `elements[elem][comp]`,
384    /// but only when exactly one segment of that tag exists — otherwise the
385    /// fallback would pick an arbitrary occurrence and validate the wrong field.
386    /// Returns `ConditionResult::Unknown` when no unambiguous value is available.
387    ///
388    /// Usage: `ctx.format_check("DTM", 0, 1, validate_timezone_utc)`
389    pub fn format_check(
390        &self,
391        tag: &str,
392        elem: usize,
393        comp: usize,
394        validate: impl FnOnce(&str) -> ConditionResult,
395    ) -> ConditionResult {
396        // Tree-based validation: the exact field value is authoritative.
397        if let Some(val) = self.resolved_value {
398            return validate(val);
399        }
400        // Non-tree fallback: use the segment only when unambiguous. Picking
401        // `.first()` from multiple same-tag segments (e.g. several DTMs in the
402        // message) would validate an arbitrary one, producing both false
403        // positives and false negatives. Unknown is the honest answer.
404        let segs = self.find_segments(tag);
405        if segs.len() != 1 {
406            return ConditionResult::Unknown;
407        }
408        match segs[0]
409            .elements
410            .get(elem)
411            .and_then(|e| e.get(comp))
412            .map(|s| s.as_str())
413        {
414            Some(val) => validate(val),
415            None => ConditionResult::Unknown,
416        }
417    }
418
419    /// Validate a format condition with qualifier-filtered segment fallback.
420    ///
421    /// Like `format_check`, but the fallback searches for segments matching
422    /// `tag` where `elements[qual_elem][0] == qualifier`, then extracts
423    /// `elements[val_elem][val_comp]`. Returns `Unknown` when the qualifier
424    /// filter doesn't narrow down to exactly one segment.
425    ///
426    /// Usage: `ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| validate_hhmm_equals(v, "2200"))`
427    pub fn format_check_qualified(
428        &self,
429        tag: &str,
430        qual_elem: usize,
431        qualifier: &str,
432        val_elem: usize,
433        val_comp: usize,
434        validate: impl FnOnce(&str) -> ConditionResult,
435    ) -> ConditionResult {
436        if let Some(val) = self.resolved_value {
437            return validate(val);
438        }
439        let segs = self.find_segments_with_qualifier(tag, qual_elem, qualifier);
440        if segs.len() != 1 {
441            return ConditionResult::Unknown;
442        }
443        match segs[0]
444            .elements
445            .get(val_elem)
446            .and_then(|e| e.get(val_comp))
447            .map(|s| s.as_str())
448        {
449            Some(val) => validate(val),
450            None => ConditionResult::Unknown,
451        }
452    }
453
454    /// Judge the value of the field the condition sits on, or `Unknown`
455    /// when the validator resolved none.
456    ///
457    /// For format conditions placed on several segments: ORDERS `[950]`
458    /// (Marktlokations-ID) sits on LOC+172 and on RFF+Z18/Z20/Z59, never on
459    /// the LOC+Z16 a qualified lookup would search (#373).
460    pub fn format_check_resolved(
461        &self,
462        validate: impl FnOnce(&str) -> ConditionResult,
463    ) -> ConditionResult {
464        match self.resolved_value {
465            Some(val) => validate(val),
466            None => ConditionResult::Unknown,
467        }
468    }
469
470    /// Read a value at `elements[elem][comp]` of the segment currently being
471    /// validated ("im selben Segment" conditions).
472    ///
473    /// Prefers `resolved_segment` (the exact segment the field lives in,
474    /// bound per-field during tree-based validation). Falls back to the
475    /// first message-wide segment matching `tag` when no tree context is
476    /// available — matches the prior `find_segments(tag).first()` behavior.
477    ///
478    /// Conditions phrased "Wenn im selben Segment im DE2379 der Code 303
479    /// vorhanden ist" should use this instead of iterating every `tag`
480    /// segment in the message, which finds spurious matches elsewhere.
481    pub fn self_segment_value(&self, tag: &str, elem: usize, comp: usize) -> Option<&str> {
482        if let Some(elements) = self.resolved_segment {
483            return elements
484                .get(elem)
485                .and_then(|e| e.get(comp))
486                .map(|s| s.as_str());
487        }
488        self.find_segment(tag)
489            .and_then(|s| s.elements.get(elem))
490            .and_then(|e| e.get(comp))
491            .map(|s| s.as_str())
492    }
493
494    /// Convenience wrapper — returns `true` when `self_segment_value` equals
495    /// `expected`. The common shape for "im selben Segment" code-equality
496    /// conditions.
497    pub fn self_segment_value_equals(
498        &self,
499        tag: &str,
500        elem: usize,
501        comp: usize,
502        expected: &str,
503    ) -> bool {
504        self.self_segment_value(tag, elem, comp)
505            .is_some_and(|v| v == expected)
506    }
507
508    /// Check if a segment with the given ID exists.
509    pub fn has_segment(&self, segment_id: &str) -> bool {
510        self.find_segment(segment_id).is_some()
511    }
512
513    /// Count SG8 instances whose PIA+5 OBIS code satisfies `obis_pred`,
514    /// restricted to SG8 instances with:
515    /// - SEQ qualifier (DE1229) == current SEQ's qualifier, AND
516    /// - SEQ DE1050 (Zeitraum-ID in C286) == current SEQ's DE1050.
517    ///
518    /// "Current" is taken from `resolved_segment` (tree-based validation),
519    /// which must point at a SEQ segment with a qualifier in
520    /// `{Z02, ZA1, ZA2}` (OBIS-Daten der Marktlokation). Returns 0 when the
521    /// context isn't anchored to such a SEQ.
522    ///
523    /// Used by UTILMD_Strom conditions [413]/[414]/[415]/[416] which ask
524    /// "does another SG8 with matching Zeitraum-ID + qualifier have a
525    /// Wirkarbeit kumuliert / 1/4 Stunde OBIS?"
526    ///
527    /// Implementation: linear scan of `self.segments`. Each encountered SEQ
528    /// defines the "current group's" matching state; subsequent PIA+5
529    /// segments (before the next SEQ) belong to that SG8 instance and are
530    /// tested against `obis_pred` when the SEQ matches current's qualifier +
531    /// Zeitraum-ID. This relies on the standard EDIFACT group layout where
532    /// SEQ is SG8's entry segment and child segments (SG9 PIA …) appear
533    /// consecutively after it.
534    pub fn count_sg8_obis_matches<F>(&self, obis_pred: F) -> usize
535    where
536        F: Fn(&str) -> bool,
537    {
538        let resolved = match self.resolved_segment {
539            Some(s) => s,
540            None => return 0,
541        };
542        let cur_qual = match resolved.first().and_then(|e| e.first()) {
543            Some(v) => v.as_str(),
544            None => return 0,
545        };
546        if !matches!(cur_qual, "Z02" | "ZA1" | "ZA2") {
547            return 0;
548        }
549        let cur_zid = match resolved.get(1).and_then(|e| e.first()) {
550            Some(v) => v.as_str(),
551            None => return 0,
552        };
553
554        let mut count = 0usize;
555        let mut in_matching_seq = false;
556        for seg in self.segments {
557            if seg.id == "SEQ" {
558                let qual = seg
559                    .elements
560                    .first()
561                    .and_then(|e| e.first())
562                    .map(String::as_str);
563                let zid = seg
564                    .elements
565                    .get(1)
566                    .and_then(|e| e.first())
567                    .map(String::as_str);
568                in_matching_seq = qual == Some(cur_qual) && zid == Some(cur_zid);
569            } else if seg.id == "PIA" && in_matching_seq {
570                let p_qual = seg
571                    .elements
572                    .first()
573                    .and_then(|e| e.first())
574                    .map(String::as_str);
575                if p_qual == Some("5") {
576                    if let Some(obis) = seg.elements.get(1).and_then(|e| e.first()) {
577                        if obis_pred(obis) {
578                            count += 1;
579                        }
580                    }
581                }
582            }
583        }
584        count
585    }
586
587    /// Find all segments with the given tag within a specific group instance.
588    /// Returns empty if no navigator is set.
589    pub fn find_segments_in_group(
590        &self,
591        segment_id: &str,
592        group_path: &[&str],
593        instance_index: usize,
594    ) -> Vec<OwnedSegment> {
595        match self.navigator {
596            Some(nav) => nav.find_segments_in_group(segment_id, group_path, instance_index),
597            None => Vec::new(),
598        }
599    }
600
601    /// Find segments matching a tag + qualifier within a group instance.
602    /// Returns empty if no navigator is set.
603    pub fn find_segments_with_qualifier_in_group(
604        &self,
605        segment_id: &str,
606        element_index: usize,
607        qualifier: &str,
608        group_path: &[&str],
609        instance_index: usize,
610    ) -> Vec<OwnedSegment> {
611        match self.navigator {
612            Some(nav) => nav.find_segments_with_qualifier_in_group(
613                segment_id,
614                element_index,
615                qualifier,
616                group_path,
617                instance_index,
618            ),
619            None => Vec::new(),
620        }
621    }
622
623    /// Check if a segment exists in a specific group instance.
624    /// Returns false if no navigator is set.
625    pub fn has_segment_in_group(
626        &self,
627        segment_id: &str,
628        group_path: &[&str],
629        instance_index: usize,
630    ) -> bool {
631        !self
632            .find_segments_in_group(segment_id, group_path, instance_index)
633            .is_empty()
634    }
635
636    /// Count repetitions of a group at the given path.
637    /// Returns 0 if no navigator is set.
638    pub fn group_instance_count(&self, group_path: &[&str]) -> usize {
639        match self.navigator {
640            Some(nav) => nav.group_instance_count(group_path),
641            None => 0,
642        }
643    }
644
645    /// Count child group repetitions within a specific parent group instance.
646    /// Returns 0 if no navigator is set.
647    pub fn child_group_instance_count(
648        &self,
649        parent_path: &[&str],
650        parent_instance: usize,
651        child_group_id: &str,
652    ) -> usize {
653        match self.navigator {
654            Some(nav) => {
655                nav.child_group_instance_count(parent_path, parent_instance, child_group_id)
656            }
657            None => 0,
658        }
659    }
660
661    /// Find segments in a child group within a specific parent group instance.
662    /// Returns empty if no navigator is set.
663    pub fn find_segments_in_child_group(
664        &self,
665        segment_id: &str,
666        parent_path: &[&str],
667        parent_instance: usize,
668        child_group_id: &str,
669        child_instance: usize,
670    ) -> Vec<OwnedSegment> {
671        match self.navigator {
672            Some(nav) => nav.find_segments_in_child_group(
673                segment_id,
674                parent_path,
675                parent_instance,
676                child_group_id,
677                child_instance,
678            ),
679            None => Vec::new(),
680        }
681    }
682
683    /// Extract a single value from the first matching segment in a group instance.
684    /// Returns None if no navigator is set or value not found.
685    pub fn extract_value_in_group(
686        &self,
687        segment_id: &str,
688        element_index: usize,
689        component_index: usize,
690        group_path: &[&str],
691        instance_index: usize,
692    ) -> Option<String> {
693        self.navigator?.extract_value_in_group(
694            segment_id,
695            element_index,
696            component_index,
697            group_path,
698            instance_index,
699        )
700    }
701
702    // --- High-level condition helpers ---
703    // These reduce generated condition evaluator boilerplate by ~50%.
704
705    /// Check if any segment with the given tag + qualifier exists (message-wide).
706    /// Returns `True` if found, `False` if not.
707    pub fn has_qualifier(
708        &self,
709        tag: &str,
710        element_index: usize,
711        qualifier: &str,
712    ) -> ConditionResult {
713        ConditionResult::from(
714            self.find_segment_with_qualifier(tag, element_index, qualifier)
715                .is_some(),
716        )
717    }
718
719    /// Check if a segment with given tag + qualifier does NOT exist (message-wide).
720    /// Returns `True` if absent, `False` if present.
721    pub fn lacks_qualifier(
722        &self,
723        tag: &str,
724        element_index: usize,
725        qualifier: &str,
726    ) -> ConditionResult {
727        ConditionResult::from(
728            self.find_segment_with_qualifier(tag, element_index, qualifier)
729                .is_none(),
730        )
731    }
732
733    /// Check if any segment with the given tag + qualifier has a specific sub-element value.
734    ///
735    /// Finds segments matching `tag` with `elements[qual_elem][0] == qualifier`,
736    /// then checks if `elements[value_elem][value_comp]` matches any of `values`.
737    pub fn has_qualified_value(
738        &self,
739        tag: &str,
740        qual_elem: usize,
741        qualifier: &str,
742        value_elem: usize,
743        value_comp: usize,
744        values: &[&str],
745    ) -> ConditionResult {
746        let mut found = false;
747        for seg in self.lookup(tag, Some((qual_elem, qualifier))) {
748            found = true;
749            if let Some(v) = seg
750                .elements
751                .get(value_elem)
752                .and_then(|e| e.get(value_comp))
753                .map(|s| s.as_str())
754            {
755                if values.contains(&v) {
756                    return ConditionResult::True;
757                }
758            }
759        }
760        if found {
761            ConditionResult::False
762        } else {
763            ConditionResult::Unknown
764        }
765    }
766
767    /// Group-scoped qualifier existence check with message-wide fallback.
768    ///
769    /// Checks if any group instance at `group_path` contains a segment matching
770    /// `tag` with `elements[element_index][0] == qualifier`. Falls back to
771    /// message-wide search if no group navigator is available.
772    pub fn any_group_has_qualifier(
773        &self,
774        tag: &str,
775        element_index: usize,
776        qualifier: &str,
777        group_path: &[&str],
778    ) -> ConditionResult {
779        let instance_count = self.group_instance_count(group_path);
780        if instance_count > 0 {
781            for i in 0..instance_count {
782                if !self
783                    .find_segments_with_qualifier_in_group(
784                        tag,
785                        element_index,
786                        qualifier,
787                        group_path,
788                        i,
789                    )
790                    .is_empty()
791                {
792                    return ConditionResult::True;
793                }
794            }
795            return ConditionResult::False;
796        }
797        // Fallback: message-wide search
798        self.has_qualifier(tag, element_index, qualifier)
799    }
800
801    /// Group-scoped segment existence check (any tag match, no qualifier).
802    ///
803    /// Checks if any group instance at `group_path` contains a segment with
804    /// `elements[element_index][0]` matching any of `qualifiers`. Falls back
805    /// to message-wide search if no group navigator is available.
806    pub fn any_group_has_any_qualifier(
807        &self,
808        tag: &str,
809        element_index: usize,
810        qualifiers: &[&str],
811        group_path: &[&str],
812    ) -> ConditionResult {
813        let instance_count = self.group_instance_count(group_path);
814        if instance_count > 0 {
815            for i in 0..instance_count {
816                let segs = self.find_segments_in_group(tag, group_path, i);
817                if segs.iter().any(|seg| {
818                    seg.elements
819                        .get(element_index)
820                        .and_then(|e| e.first())
821                        .map(|s| s.as_str())
822                        .is_some_and(|v| qualifiers.contains(&v))
823                }) {
824                    return ConditionResult::True;
825                }
826            }
827            return ConditionResult::False;
828        }
829        // Fallback: message-wide search
830        let found = self.find_segments(tag).iter().any(|seg| {
831            seg.elements
832                .get(element_index)
833                .and_then(|e| e.first())
834                .map(|s| s.as_str())
835                .is_some_and(|v| qualifiers.contains(&v))
836        });
837        ConditionResult::from(found)
838    }
839
840    /// Group-scoped check for sub-element value within qualified segments.
841    ///
842    /// For each group instance at `group_path`, checks if a segment matching
843    /// `tag` with `elements[qual_elem][0] == qualifier` has
844    /// `elements[value_elem][value_comp]` in `values`. Falls back to message-wide.
845    pub fn any_group_has_qualified_value(
846        &self,
847        tag: &str,
848        qual_elem: usize,
849        qualifier: &str,
850        value_elem: usize,
851        value_comp: usize,
852        values: &[&str],
853        group_path: &[&str],
854    ) -> ConditionResult {
855        let instance_count = self.group_instance_count(group_path);
856        if instance_count > 0 {
857            for i in 0..instance_count {
858                let segs = self.find_segments_with_qualifier_in_group(
859                    tag, qual_elem, qualifier, group_path, i,
860                );
861                for seg in &segs {
862                    if seg
863                        .elements
864                        .get(value_elem)
865                        .and_then(|e| e.get(value_comp))
866                        .map(|s| s.as_str())
867                        .is_some_and(|v| values.contains(&v))
868                    {
869                        return ConditionResult::True;
870                    }
871                }
872            }
873            return ConditionResult::False;
874        }
875        // Fallback: message-wide search
876        self.has_qualified_value(tag, qual_elem, qualifier, value_elem, value_comp, values)
877    }
878
879    // --- Parent-child group navigation helpers ---
880
881    /// Pattern A: Check if parent group instances matching a qualifier have a child
882    /// group containing a specific qualifier.
883    ///
884    /// Example: "In the SG8 with SEQ+Z98, does its SG10 child have CCI+Z23?"
885    ///
886    /// Falls back to message-wide search if no navigator is available.
887    #[allow(clippy::too_many_arguments)]
888    pub fn filtered_parent_child_has_qualifier(
889        &self,
890        parent_path: &[&str],
891        parent_tag: &str,
892        parent_elem: usize,
893        parent_qual: &str,
894        child_group_id: &str,
895        child_tag: &str,
896        child_elem: usize,
897        child_qual: &str,
898    ) -> ConditionResult {
899        let parent_count = self.group_instance_count(parent_path);
900        if parent_count > 0 {
901            for pi in 0..parent_count {
902                // Check if this parent instance has the required qualifier
903                let parent_segs = self.find_segments_with_qualifier_in_group(
904                    parent_tag,
905                    parent_elem,
906                    parent_qual,
907                    parent_path,
908                    pi,
909                );
910                if parent_segs.is_empty() {
911                    continue;
912                }
913                // Check child group instances for the child qualifier
914                let child_count = self.child_group_instance_count(parent_path, pi, child_group_id);
915                for ci in 0..child_count {
916                    let child_segs = self.find_segments_in_child_group(
917                        child_tag,
918                        parent_path,
919                        pi,
920                        child_group_id,
921                        ci,
922                    );
923                    if child_segs.iter().any(|s| {
924                        s.elements
925                            .get(child_elem)
926                            .and_then(|e| e.first())
927                            .is_some_and(|v| v == child_qual)
928                    }) {
929                        return ConditionResult::True;
930                    }
931                }
932            }
933            return ConditionResult::False;
934        }
935        // Fallback: message-wide — check both qualifiers exist independently
936        let has_parent = self
937            .find_segment_with_qualifier(parent_tag, parent_elem, parent_qual)
938            .is_some();
939        let has_child = self
940            .find_segment_with_qualifier(child_tag, child_elem, child_qual)
941            .is_some();
942        ConditionResult::from(has_parent && has_child)
943    }
944
945    /// Pattern B: Check if any group instance has one qualifier present but another absent.
946    ///
947    /// Example: "In any SG8, SEQ+Z59 is present but CCI+11 is absent"
948    ///
949    /// Falls back to message-wide search if no navigator is available.
950    #[allow(clippy::too_many_arguments)]
951    pub fn any_group_has_qualifier_without(
952        &self,
953        present_tag: &str,
954        present_elem: usize,
955        present_qual: &str,
956        absent_tag: &str,
957        absent_elem: usize,
958        absent_qual: &str,
959        group_path: &[&str],
960    ) -> ConditionResult {
961        let instance_count = self.group_instance_count(group_path);
962        if instance_count > 0 {
963            for i in 0..instance_count {
964                let has_present = !self
965                    .find_segments_with_qualifier_in_group(
966                        present_tag,
967                        present_elem,
968                        present_qual,
969                        group_path,
970                        i,
971                    )
972                    .is_empty();
973                let has_absent = !self
974                    .find_segments_with_qualifier_in_group(
975                        absent_tag,
976                        absent_elem,
977                        absent_qual,
978                        group_path,
979                        i,
980                    )
981                    .is_empty();
982                if has_present && !has_absent {
983                    return ConditionResult::True;
984                }
985            }
986            return ConditionResult::False;
987        }
988        // Fallback: message-wide
989        let has_present = self
990            .find_segment_with_qualifier(present_tag, present_elem, present_qual)
991            .is_some();
992        let has_absent = self
993            .find_segment_with_qualifier(absent_tag, absent_elem, absent_qual)
994            .is_some();
995        ConditionResult::from(has_present && !has_absent)
996    }
997
998    /// The scope path down to the enclosing instance of `group_path`'s last
999    /// group, when the scope reaches it.
1000    fn enclosing_path(&self, group_path: &[&str]) -> Option<&'a [(&'a str, usize)]> {
1001        let group = group_path.last()?;
1002        let scope = self.scope?;
1003        self.navigator?;
1004        let level = scope.path.iter().rposition(|(id, _)| id == group)?;
1005        Some(&scope.path[..=level])
1006    }
1007
1008    /// The scope path down to the nearest enclosing instance of `group_id`
1009    /// ("dieser SG8" for a field in one of its SG10s), when the scope reaches
1010    /// it and a navigator can read it.
1011    pub fn enclosing_scope(&self, group_id: &str) -> Option<&'a [(&'a str, usize)]> {
1012        self.enclosing_path(&[group_id])
1013    }
1014
1015    /// The `child_id` groups of the enclosing `group_id` instance, each with
1016    /// its `tags` segments (and those of groups nested in it): "SG10
1017    /// CCI+++E13 CAV+EHZ in dieser SG8" pairs a CCI and a CAV of one SG10.
1018    /// `None` when the scope does not reach a `group_id` instance.
1019    pub fn enclosing_child_segments(
1020        &self,
1021        group_id: &str,
1022        child_id: &str,
1023        tags: &[&str],
1024    ) -> Option<Vec<Vec<OwnedSegment>>> {
1025        let path = self.enclosing_scope(group_id)?;
1026        Some(self.child_segments_at(path, child_id, tags))
1027    }
1028
1029    /// The `child_id` groups of the instance at `path`, each with its `tags`
1030    /// segments (and those of groups nested in it); empty without a navigator.
1031    pub fn child_segments_at(
1032        &self,
1033        path: &[(&str, usize)],
1034        child_id: &str,
1035        tags: &[&str],
1036    ) -> Vec<Vec<OwnedSegment>> {
1037        let Some(nav) = self.navigator else {
1038            return Vec::new();
1039        };
1040        (0..nav.child_instance_count(path, child_id))
1041            .map(|j| {
1042                let mut child = path.to_vec();
1043                child.push((child_id, j));
1044                tags.iter()
1045                    .flat_map(|tag| nav.find_segments_in_subtree(tag, &child))
1046                    .collect()
1047            })
1048            .collect()
1049    }
1050
1051    /// Every `group_id` instance directly under the enclosing `parent_id`
1052    /// instance, the own one included, as scope paths in message order — "eine
1053    /// SG8 SEQ+Z01" asked from one SG8 or SG6 of an SG4. `None` when the scope
1054    /// does not reach a `parent_id` instance.
1055    pub fn scopes_under(
1056        &self,
1057        parent_id: &str,
1058        group_id: &'a str,
1059    ) -> Option<Vec<Vec<(&'a str, usize)>>> {
1060        let parent = self.enclosing_scope(parent_id)?;
1061        let nav = self.navigator?;
1062        Some(
1063            (0..nav.child_instance_count(parent, group_id))
1064                .map(|i| {
1065                    let mut path = parent.to_vec();
1066                    path.push((group_id, i));
1067                    path
1068                })
1069                .collect(),
1070        )
1071    }
1072
1073    /// [`any_group_has_qualifier_without`](Self::any_group_has_qualifier_without)
1074    /// for "in derselben SGn … nicht vorhanden": the enclosing instance only,
1075    /// when the scope reaches it.
1076    #[allow(clippy::too_many_arguments)]
1077    pub fn this_group_has_qualifier_without(
1078        &self,
1079        present_tag: &str,
1080        present_elem: usize,
1081        present_qual: &str,
1082        absent_tag: &str,
1083        absent_elem: usize,
1084        absent_qual: &str,
1085        group_path: &[&str],
1086    ) -> ConditionResult {
1087        let Some(path) = self.enclosing_path(group_path) else {
1088            return self.any_group_has_qualifier_without(
1089                present_tag,
1090                present_elem,
1091                present_qual,
1092                absent_tag,
1093                absent_elem,
1094                absent_qual,
1095                group_path,
1096            );
1097        };
1098        let has = |tag: &str, elem: usize, qual: &str| {
1099            self.find_segments_at(path, tag)
1100                .iter()
1101                .any(|s| first_component(s, elem) == Some(qual))
1102        };
1103        ConditionResult::from(
1104            has(present_tag, present_elem, present_qual)
1105                && !has(absent_tag, absent_elem, absent_qual),
1106        )
1107    }
1108
1109    /// "in dieser/derselben SGn das TAG+QUAL nicht vorhanden": the enclosing
1110    /// instance of the group `group_path` names holds no `tag` with
1111    /// `qualifier`. Unknown without a scope reaching it: which instance is
1112    /// meant cannot be told, and "some instance lacks it" holds of any other
1113    /// variant of the group (55095: every SG8 but the ZF5 lacks RFF+Z14).
1114    ///
1115    /// Unlike [`this_group_has_qualifier_without`](Self::this_group_has_qualifier_without)
1116    /// it asks nothing of the group's entry: UTILMD [316] holds in any SG8
1117    /// without RFF+Z14, a SEQ+ZA3 Zähleinrichtung (55648) as well as a Z03.
1118    pub fn this_group_lacks_qualifier(
1119        &self,
1120        tag: &str,
1121        element_index: usize,
1122        qualifier: &str,
1123        group_path: &[&str],
1124    ) -> ConditionResult {
1125        match self.enclosing_path(group_path) {
1126            Some(path) => ConditionResult::from(
1127                !self
1128                    .find_segments_at(path, tag)
1129                    .iter()
1130                    .any(|s| first_component(s, element_index) == Some(qualifier)),
1131            ),
1132            None => ConditionResult::Unknown,
1133        }
1134    }
1135
1136    /// [`filtered_parent_child_has_qualifier`](Self::filtered_parent_child_has_qualifier)
1137    /// for "in dieser SG8 SEQ+… SG10 CCI…": the enclosing parent instance and
1138    /// its child groups only, when the scope reaches it.
1139    #[allow(clippy::too_many_arguments)]
1140    pub fn this_group_parent_child_has_qualifier(
1141        &self,
1142        parent_path: &[&str],
1143        parent_tag: &str,
1144        parent_elem: usize,
1145        parent_qual: &str,
1146        child_group_id: &str,
1147        child_tag: &str,
1148        child_elem: usize,
1149        child_qual: &str,
1150    ) -> ConditionResult {
1151        let (Some(path), Some(nav)) = (self.enclosing_path(parent_path), self.navigator) else {
1152            return self.filtered_parent_child_has_qualifier(
1153                parent_path,
1154                parent_tag,
1155                parent_elem,
1156                parent_qual,
1157                child_group_id,
1158                child_tag,
1159                child_elem,
1160                child_qual,
1161            );
1162        };
1163        let parent_matches = nav
1164            .find_segments_in_instance(parent_tag, path)
1165            .iter()
1166            .any(|s| first_component(s, parent_elem) == Some(parent_qual));
1167        if !parent_matches {
1168            return ConditionResult::False;
1169        }
1170        let children = nav.child_instance_count(path, child_group_id);
1171        let found = (0..children).any(|j| {
1172            let mut child = path.to_vec();
1173            child.push((child_group_id, j));
1174            nav.find_segments_in_subtree(child_tag, &child)
1175                .iter()
1176                .any(|s| first_component(s, child_elem) == Some(child_qual))
1177        });
1178        ConditionResult::from(found)
1179    }
1180
1181    /// [`any_group_has_qualifier`](Self::any_group_has_qualifier) for "in
1182    /// dieser SGn": only the enclosing instance of the group `group_path`
1183    /// names (and the groups nested in it), when the scope reaches it.
1184    pub fn this_group_has_qualifier(
1185        &self,
1186        tag: &str,
1187        element_index: usize,
1188        qualifier: &str,
1189        group_path: &[&str],
1190    ) -> ConditionResult {
1191        match self.enclosing_path(group_path) {
1192            Some(path) => ConditionResult::from(
1193                self.find_segments_at(path, tag)
1194                    .iter()
1195                    .any(|s| first_component(s, element_index) == Some(qualifier)),
1196            ),
1197            None => self.any_group_has_qualifier(tag, element_index, qualifier, group_path),
1198        }
1199    }
1200
1201    /// [`any_group_has_any_qualifier`](Self::any_group_has_any_qualifier) for
1202    /// "in dieser SGn", as [`this_group_has_qualifier`](Self::this_group_has_qualifier).
1203    pub fn this_group_has_any_qualifier(
1204        &self,
1205        tag: &str,
1206        element_index: usize,
1207        qualifiers: &[&str],
1208        group_path: &[&str],
1209    ) -> ConditionResult {
1210        match self.enclosing_path(group_path) {
1211            Some(path) => ConditionResult::from(self.find_segments_at(path, tag).iter().any(|s| {
1212                first_component(s, element_index).is_some_and(|v| qualifiers.contains(&v))
1213            })),
1214            None => self.any_group_has_any_qualifier(tag, element_index, qualifiers, group_path),
1215        }
1216    }
1217
1218    /// [`any_group_has_qualified_value`](Self::any_group_has_qualified_value)
1219    /// for "in dieser SGn", as [`this_group_has_qualifier`](Self::this_group_has_qualifier).
1220    #[allow(clippy::too_many_arguments)]
1221    pub fn this_group_has_qualified_value(
1222        &self,
1223        tag: &str,
1224        qual_elem: usize,
1225        qualifier: &str,
1226        value_elem: usize,
1227        value_comp: usize,
1228        values: &[&str],
1229        group_path: &[&str],
1230    ) -> ConditionResult {
1231        match self.enclosing_path(group_path) {
1232            Some(path) => ConditionResult::from(self.find_segments_at(path, tag).iter().any(|s| {
1233                first_component(s, qual_elem) == Some(qualifier)
1234                    && s.elements
1235                        .get(value_elem)
1236                        .and_then(|e| e.get(value_comp))
1237                        .is_some_and(|v| values.contains(&v.as_str()))
1238            })),
1239            None => self.any_group_has_qualified_value(
1240                tag, qual_elem, qualifier, value_elem, value_comp, values, group_path,
1241            ),
1242        }
1243    }
1244
1245    /// [`collect_group_values`](Self::collect_group_values) for "in dieser
1246    /// SGn": the values of the enclosing instance (index 0), when the scope
1247    /// reaches it.
1248    pub fn this_group_values(
1249        &self,
1250        tag: &str,
1251        elem: usize,
1252        comp: usize,
1253        group_path: &[&str],
1254    ) -> Vec<(usize, String)> {
1255        match self.enclosing_path(group_path) {
1256            Some(path) => self
1257                .find_segments_at(path, tag)
1258                .iter()
1259                .filter_map(|s| s.elements.get(elem).and_then(|e| e.get(comp)).cloned())
1260                .filter(|v| !v.is_empty())
1261                .map(|v| (0, v))
1262                .collect(),
1263            None => self.collect_group_values(tag, elem, comp, group_path),
1264        }
1265    }
1266
1267    /// Pattern C helper: Collect all values at a specific element+component across group instances.
1268    ///
1269    /// Returns `(instance_index, value)` pairs for non-empty values.
1270    pub fn collect_group_values(
1271        &self,
1272        tag: &str,
1273        elem: usize,
1274        comp: usize,
1275        group_path: &[&str],
1276    ) -> Vec<(usize, String)> {
1277        let instance_count = self.group_instance_count(group_path);
1278        let mut results = Vec::new();
1279        for i in 0..instance_count {
1280            if let Some(val) = self
1281                .navigator
1282                .and_then(|nav| nav.extract_value_in_group(tag, elem, comp, group_path, i))
1283            {
1284                if !val.is_empty() {
1285                    results.push((i, val));
1286                }
1287            }
1288        }
1289        results
1290    }
1291
1292    /// Pattern C: Check if a value from one group path matches a value in another group path.
1293    ///
1294    /// Example: "Zeitraum-ID in SG6 RFF+Z49 matches reference in SG8 SEQ.c286"
1295    ///
1296    /// Finds qualified segments in source_path, extracts their value, then checks if
1297    /// any instance in target_path has the same value at the target position.
1298    ///
1299    /// Falls back to message-wide search if no navigator is available.
1300    #[allow(clippy::too_many_arguments)]
1301    pub fn groups_share_qualified_value(
1302        &self,
1303        source_tag: &str,
1304        source_qual_elem: usize,
1305        source_qual: &str,
1306        source_value_elem: usize,
1307        source_value_comp: usize,
1308        source_path: &[&str],
1309        target_tag: &str,
1310        target_elem: usize,
1311        target_comp: usize,
1312        target_path: &[&str],
1313    ) -> ConditionResult {
1314        let source_count = self.group_instance_count(source_path);
1315        let target_count = self.group_instance_count(target_path);
1316        if source_count > 0 && target_count > 0 {
1317            // Collect source values from qualified segments
1318            let mut source_values = Vec::new();
1319            for si in 0..source_count {
1320                let segs = self.find_segments_with_qualifier_in_group(
1321                    source_tag,
1322                    source_qual_elem,
1323                    source_qual,
1324                    source_path,
1325                    si,
1326                );
1327                for seg in &segs {
1328                    if let Some(val) = seg
1329                        .elements
1330                        .get(source_value_elem)
1331                        .and_then(|e| e.get(source_value_comp))
1332                    {
1333                        if !val.is_empty() {
1334                            source_values.push(val.clone());
1335                        }
1336                    }
1337                }
1338            }
1339            if source_values.is_empty() {
1340                return ConditionResult::Unknown;
1341            }
1342            // Check if any target instance has a matching value
1343            let target_values =
1344                self.collect_group_values(target_tag, target_elem, target_comp, target_path);
1345            for (_, tv) in &target_values {
1346                if source_values.iter().any(|sv| sv == tv) {
1347                    return ConditionResult::True;
1348                }
1349            }
1350            return ConditionResult::False;
1351        }
1352        // Fallback: message-wide search
1353        let source_segs =
1354            self.find_segments_with_qualifier(source_tag, source_qual_elem, source_qual);
1355        let source_vals: Vec<&str> = source_segs
1356            .iter()
1357            .filter_map(|s| {
1358                s.elements
1359                    .get(source_value_elem)
1360                    .and_then(|e| e.get(source_value_comp))
1361                    .map(|v| v.as_str())
1362            })
1363            .filter(|v| !v.is_empty())
1364            .collect();
1365        if source_vals.is_empty() {
1366            return ConditionResult::Unknown;
1367        }
1368        let target_segs = self.find_segments(target_tag);
1369        let has_match = target_segs.iter().any(|s| {
1370            s.elements
1371                .get(target_elem)
1372                .and_then(|e| e.get(target_comp))
1373                .map(|v| v.as_str())
1374                .is_some_and(|v| source_vals.contains(&v))
1375        });
1376        ConditionResult::from(has_match)
1377    }
1378
1379    /// Group-scoped co-occurrence check: two segment conditions must both be true
1380    /// in the same group instance.
1381    ///
1382    /// For each group instance, checks that:
1383    /// 1. A segment with `tag_a` has `elements[elem_a][0]` in `quals_a`
1384    /// 2. A segment with `tag_b` has `elements[elem_b][comp_b]` in `vals_b`
1385    ///
1386    /// Falls back to message-wide search.
1387    #[allow(clippy::too_many_arguments)]
1388    pub fn any_group_has_co_occurrence(
1389        &self,
1390        tag_a: &str,
1391        elem_a: usize,
1392        quals_a: &[&str],
1393        tag_b: &str,
1394        elem_b: usize,
1395        comp_b: usize,
1396        vals_b: &[&str],
1397        group_path: &[&str],
1398    ) -> ConditionResult {
1399        let instance_count = self.group_instance_count(group_path);
1400        if instance_count > 0 {
1401            for i in 0..instance_count {
1402                let a_present = self
1403                    .find_segments_in_group(tag_a, group_path, i)
1404                    .iter()
1405                    .any(|seg| {
1406                        seg.elements
1407                            .get(elem_a)
1408                            .and_then(|e| e.first())
1409                            .map(|s| s.as_str())
1410                            .is_some_and(|v| quals_a.contains(&v))
1411                    });
1412                let b_present = self
1413                    .find_segments_in_group(tag_b, group_path, i)
1414                    .iter()
1415                    .any(|seg| {
1416                        seg.elements
1417                            .get(elem_b)
1418                            .and_then(|e| e.get(comp_b))
1419                            .map(|s| s.as_str())
1420                            .is_some_and(|v| vals_b.contains(&v))
1421                    });
1422                if a_present && b_present {
1423                    return ConditionResult::True;
1424                }
1425            }
1426            return ConditionResult::False;
1427        }
1428        // Fallback: message-wide
1429        let a_found = self.find_segments(tag_a).iter().any(|seg| {
1430            seg.elements
1431                .get(elem_a)
1432                .and_then(|e| e.first())
1433                .map(|s| s.as_str())
1434                .is_some_and(|v| quals_a.contains(&v))
1435        });
1436        if !a_found {
1437            return ConditionResult::False;
1438        }
1439        let b_found = self.find_segments(tag_b).iter().any(|seg| {
1440            seg.elements
1441                .get(elem_b)
1442                .and_then(|e| e.get(comp_b))
1443                .map(|s| s.as_str())
1444                .is_some_and(|v| vals_b.contains(&v))
1445        });
1446        ConditionResult::from(b_found)
1447    }
1448
1449    // --- Multi-element matching helpers ---
1450
1451    /// Check if any segment with the given tag has ALL specified element/component values.
1452    ///
1453    /// Each check is `(element_index, component_index, expected_value)`.
1454    /// Returns `True` if a matching segment exists, `False` if segments exist but none match,
1455    /// `Unknown` if no segments with the tag exist.
1456    ///
1457    /// Example: `ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32"), (2, 0, "A99")])`
1458    pub fn has_segment_matching(
1459        &self,
1460        tag: &str,
1461        checks: &[(usize, usize, &str)],
1462    ) -> ConditionResult {
1463        let segments = self.find_segments(tag);
1464        if segments.is_empty() {
1465            return ConditionResult::Unknown;
1466        }
1467        let found = segments.iter().any(|seg| {
1468            checks.iter().all(|(elem, comp, val)| {
1469                seg.elements
1470                    .get(*elem)
1471                    .and_then(|e| e.get(*comp))
1472                    .is_some_and(|v| v == val)
1473            })
1474        });
1475        ConditionResult::from(found)
1476    }
1477
1478    /// Group-scoped multi-element match with message-wide fallback.
1479    ///
1480    /// Checks if any group instance at `group_path` contains a segment with `tag`
1481    /// where ALL element/component checks match.
1482    pub fn has_segment_matching_in_group(
1483        &self,
1484        tag: &str,
1485        checks: &[(usize, usize, &str)],
1486        group_path: &[&str],
1487    ) -> ConditionResult {
1488        let instance_count = self.group_instance_count(group_path);
1489        if instance_count > 0 {
1490            for i in 0..instance_count {
1491                let segs = self.find_segments_in_group(tag, group_path, i);
1492                if segs.iter().any(|seg| {
1493                    checks.iter().all(|(elem, comp, val)| {
1494                        seg.elements
1495                            .get(*elem)
1496                            .and_then(|e| e.get(*comp))
1497                            .is_some_and(|v| v == val)
1498                    })
1499                }) {
1500                    return ConditionResult::True;
1501                }
1502            }
1503            return ConditionResult::False;
1504        }
1505        // Fallback: message-wide
1506        self.has_segment_matching(tag, checks)
1507    }
1508
1509    // --- DTM date comparison helpers ---
1510
1511    /// Check if a DTM segment with the given qualifier has a value >= threshold.
1512    ///
1513    /// Both the DTM value and threshold should be in EDIFACT format 303 (CCYYMMDDHHMM).
1514    /// Returns `Unknown` if no DTM with the qualifier exists.
1515    pub fn dtm_ge(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1516        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1517        match segs.first() {
1518            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1519                Some(val) => ConditionResult::from(val.as_str() >= threshold),
1520                None => ConditionResult::Unknown,
1521            },
1522            None => ConditionResult::Unknown,
1523        }
1524    }
1525
1526    /// Check if a DTM segment with the given qualifier has a value < threshold.
1527    pub fn dtm_lt(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1528        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1529        match segs.first() {
1530            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1531                Some(val) => ConditionResult::from((val.as_str()) < threshold),
1532                None => ConditionResult::Unknown,
1533            },
1534            None => ConditionResult::Unknown,
1535        }
1536    }
1537
1538    /// Check if a DTM segment with the given qualifier has a value <= threshold.
1539    pub fn dtm_le(&self, qualifier: &str, threshold: &str) -> ConditionResult {
1540        let segs = self.find_segments_with_qualifier("DTM", 0, qualifier);
1541        match segs.first() {
1542            Some(dtm) => match dtm.elements.first().and_then(|e| e.get(1)) {
1543                Some(val) => ConditionResult::from(val.as_str() <= threshold),
1544                None => ConditionResult::Unknown,
1545            },
1546            None => ConditionResult::Unknown,
1547        }
1548    }
1549
1550    // --- Group-scoped cardinality helpers ---
1551
1552    /// Count segments matching tag + qualifier within a group path (across all instances).
1553    ///
1554    /// Returns the total count across all group instances.
1555    /// Falls back to message-wide count if no navigator.
1556    pub fn count_qualified_in_group(
1557        &self,
1558        tag: &str,
1559        element_index: usize,
1560        qualifier: &str,
1561        group_path: &[&str],
1562    ) -> usize {
1563        let instance_count = self.group_instance_count(group_path);
1564        if instance_count > 0 {
1565            let mut total = 0;
1566            for i in 0..instance_count {
1567                total += self
1568                    .find_segments_with_qualifier_in_group(
1569                        tag,
1570                        element_index,
1571                        qualifier,
1572                        group_path,
1573                        i,
1574                    )
1575                    .len();
1576            }
1577            return total;
1578        }
1579        // Fallback: message-wide
1580        self.find_segments_with_qualifier(tag, element_index, qualifier)
1581            .len()
1582    }
1583
1584    /// Count segments matching tag (any qualifier) within a group path.
1585    pub fn count_in_group(&self, tag: &str, group_path: &[&str]) -> usize {
1586        let instance_count = self.group_instance_count(group_path);
1587        if instance_count > 0 {
1588            let mut total = 0;
1589            for i in 0..instance_count {
1590                total += self.find_segments_in_group(tag, group_path, i).len();
1591            }
1592            return total;
1593        }
1594        // Fallback: message-wide
1595        self.find_segments(tag).len()
1596    }
1597}
1598
1599/// `elements[element][0]` of `segment`.
1600fn first_component(segment: &OwnedSegment, element: usize) -> Option<&str> {
1601    segment
1602        .elements
1603        .get(element)
1604        .and_then(|e| e.first())
1605        .map(String::as_str)
1606}
1607
1608/// The positions of one slice's segments by tag, and by tag and qualifier
1609/// (the first component of the first element), each in message order.
1610#[derive(Debug)]
1611struct SegmentIndex {
1612    /// Address and length of the slice indexed: an index is read only for
1613    /// that slice (see [`EvaluationContext::index`]).
1614    slice: (usize, usize),
1615    by_tag: HashMap<String, TagPositions>,
1616}
1617
1618#[derive(Debug, Default)]
1619struct TagPositions {
1620    all: Vec<usize>,
1621    by_qualifier: HashMap<String, Vec<usize>>,
1622}
1623
1624impl SegmentIndex {
1625    fn build(segments: &[OwnedSegment]) -> Self {
1626        let mut by_tag: HashMap<String, TagPositions> = HashMap::new();
1627        for (position, segment) in segments.iter().enumerate() {
1628            let tag = by_tag.entry(segment.id.clone()).or_default();
1629            tag.all.push(position);
1630            if let Some(qualifier) = segment.elements.first().and_then(|e| e.first()) {
1631                tag.by_qualifier
1632                    .entry(qualifier.clone())
1633                    .or_default()
1634                    .push(position);
1635            }
1636        }
1637        Self {
1638            slice: Self::address(segments),
1639            by_tag,
1640        }
1641    }
1642
1643    fn address(segments: &[OwnedSegment]) -> (usize, usize) {
1644        (segments.as_ptr() as usize, segments.len())
1645    }
1646
1647    /// Whether this is the index of `segments`. The context borrows both
1648    /// slices, immutably, for as long as it lives, so a slice at the same
1649    /// address with the same length is the same slice.
1650    fn describes(&self, segments: &[OwnedSegment]) -> bool {
1651        self.slice == Self::address(segments)
1652    }
1653
1654    /// The positions to look at for `tag` (with `qualifier`): exactly the
1655    /// matching ones for a qualifier on element 0, all of the tag's otherwise.
1656    fn positions(&self, tag: &str, qualifier: Option<(usize, &str)>) -> &[usize] {
1657        let Some(tag) = self.by_tag.get(tag) else {
1658            return &[];
1659        };
1660        match qualifier {
1661            Some((0, qualifier)) => tag.by_qualifier.get(qualifier).map_or(&[], Vec::as_slice),
1662            _ => &tag.all,
1663        }
1664    }
1665}
1666
1667/// The segments of a message-wide lookup, in message order: the candidates
1668/// the index lists (or every segment, where it cannot be read), each checked
1669/// against tag and qualifier.
1670struct SegmentLookup<'a, 's> {
1671    segments: &'a [OwnedSegment],
1672    positions: Positions<'s>,
1673    segment_id: &'s str,
1674    qualifier: Option<(usize, &'s str)>,
1675}
1676
1677enum Positions<'s> {
1678    Listed(std::slice::Iter<'s, usize>),
1679    All(std::ops::Range<usize>),
1680}
1681
1682impl<'a> Iterator for SegmentLookup<'a, '_> {
1683    type Item = &'a OwnedSegment;
1684
1685    fn next(&mut self) -> Option<&'a OwnedSegment> {
1686        loop {
1687            let position = match &mut self.positions {
1688                Positions::Listed(positions) => *positions.next()?,
1689                Positions::All(positions) => positions.next()?,
1690            };
1691            let segment = &self.segments[position];
1692            let matches = segment.id == self.segment_id
1693                && match self.qualifier {
1694                    None => true,
1695                    Some((element_index, qualifier)) => segment
1696                        .elements
1697                        .get(element_index)
1698                        .and_then(|e| e.first())
1699                        .is_some_and(|v| v == qualifier),
1700                };
1701            if matches {
1702                return Some(segment);
1703            }
1704        }
1705    }
1706}
1707
1708#[cfg(test)]
1709mod tests {
1710    use super::super::evaluator::NoOpExternalProvider;
1711    use super::*;
1712    use mig_types::navigator::GroupNavigator;
1713
1714    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
1715        OwnedSegment {
1716            id: id.to_string(),
1717            elements: elements
1718                .into_iter()
1719                .map(|e| e.into_iter().map(|c| c.to_string()).collect())
1720                .collect(),
1721            segment_number: 0,
1722        }
1723    }
1724
1725    // --- Mock navigator for testing ---
1726    struct MockGroupNavigator {
1727        groups: Vec<(Vec<String>, usize, Vec<OwnedSegment>)>,
1728        /// Children: (parent_path, parent_instance, child_group_id, child_instance, segments)
1729        children: Vec<(Vec<String>, usize, String, usize, Vec<OwnedSegment>)>,
1730    }
1731
1732    impl MockGroupNavigator {
1733        fn new() -> Self {
1734            Self {
1735                groups: vec![],
1736                children: vec![],
1737            }
1738        }
1739        fn with_group(mut self, path: &[&str], instance: usize, segs: Vec<OwnedSegment>) -> Self {
1740            self.groups
1741                .push((path.iter().map(|s| s.to_string()).collect(), instance, segs));
1742            self
1743        }
1744        fn with_child_group(
1745            mut self,
1746            parent_path: &[&str],
1747            parent_instance: usize,
1748            child_id: &str,
1749            child_instance: usize,
1750            segs: Vec<OwnedSegment>,
1751        ) -> Self {
1752            self.children.push((
1753                parent_path.iter().map(|s| s.to_string()).collect(),
1754                parent_instance,
1755                child_id.to_string(),
1756                child_instance,
1757                segs,
1758            ));
1759            self
1760        }
1761        fn find_instance(&self, group_path: &[&str], idx: usize) -> Option<&[OwnedSegment]> {
1762            self.groups
1763                .iter()
1764                .find(|(p, i, _)| {
1765                    let ps: Vec<&str> = p.iter().map(|s| s.as_str()).collect();
1766                    ps.as_slice() == group_path && *i == idx
1767                })
1768                .map(|(_, _, segs)| segs.as_slice())
1769        }
1770    }
1771
1772    impl GroupNavigator for MockGroupNavigator {
1773        fn find_segments_in_group(
1774            &self,
1775            segment_id: &str,
1776            group_path: &[&str],
1777            instance_index: usize,
1778        ) -> Vec<OwnedSegment> {
1779            self.find_instance(group_path, instance_index)
1780                .map(|segs| {
1781                    segs.iter()
1782                        .filter(|s| s.id == segment_id)
1783                        .cloned()
1784                        .collect()
1785                })
1786                .unwrap_or_default()
1787        }
1788        fn find_segments_with_qualifier_in_group(
1789            &self,
1790            segment_id: &str,
1791            element_index: usize,
1792            qualifier: &str,
1793            group_path: &[&str],
1794            instance_index: usize,
1795        ) -> Vec<OwnedSegment> {
1796            self.find_segments_in_group(segment_id, group_path, instance_index)
1797                .into_iter()
1798                .filter(|s| {
1799                    s.elements
1800                        .get(element_index)
1801                        .and_then(|e| e.first())
1802                        .is_some_and(|v| v == qualifier)
1803                })
1804                .collect()
1805        }
1806        fn group_instance_count(&self, group_path: &[&str]) -> usize {
1807            self.groups
1808                .iter()
1809                .filter(|(p, _, _)| {
1810                    let ps: Vec<&str> = p.iter().map(|s| s.as_str()).collect();
1811                    ps.as_slice() == group_path
1812                })
1813                .count()
1814        }
1815        fn child_group_instance_count(
1816            &self,
1817            parent_path: &[&str],
1818            parent_instance: usize,
1819            child_group_id: &str,
1820        ) -> usize {
1821            self.children
1822                .iter()
1823                .filter(|(pp, pi, cid, _, _)| {
1824                    let ps: Vec<&str> = pp.iter().map(|s| s.as_str()).collect();
1825                    ps.as_slice() == parent_path && *pi == parent_instance && cid == child_group_id
1826                })
1827                .count()
1828        }
1829        fn find_segments_in_child_group(
1830            &self,
1831            segment_id: &str,
1832            parent_path: &[&str],
1833            parent_instance: usize,
1834            child_group_id: &str,
1835            child_instance: usize,
1836        ) -> Vec<OwnedSegment> {
1837            self.children
1838                .iter()
1839                .find(|(pp, pi, cid, ci, _)| {
1840                    let ps: Vec<&str> = pp.iter().map(|s| s.as_str()).collect();
1841                    ps.as_slice() == parent_path
1842                        && *pi == parent_instance
1843                        && cid == child_group_id
1844                        && *ci == child_instance
1845                })
1846                .map(|(_, _, _, _, segs)| {
1847                    segs.iter()
1848                        .filter(|s| s.id == segment_id)
1849                        .cloned()
1850                        .collect()
1851                })
1852                .unwrap_or_default()
1853        }
1854        fn extract_value_in_group(
1855            &self,
1856            segment_id: &str,
1857            element_index: usize,
1858            component_index: usize,
1859            group_path: &[&str],
1860            instance_index: usize,
1861        ) -> Option<String> {
1862            let segs = self.find_instance(group_path, instance_index)?;
1863            let seg = segs.iter().find(|s| s.id == segment_id)?;
1864            seg.elements
1865                .get(element_index)?
1866                .get(component_index)
1867                .cloned()
1868        }
1869    }
1870
1871    #[test]
1872    fn test_find_segment() {
1873        let segments = vec![
1874            make_segment("UNH", vec![vec!["test"]]),
1875            make_segment("NAD", vec![vec!["MS"], vec!["123456789", "", "293"]]),
1876        ];
1877        let external = NoOpExternalProvider;
1878        let ctx = EvaluationContext::new("11001", &external, &segments);
1879
1880        assert!(ctx.find_segment("NAD").is_some());
1881        assert!(ctx.find_segment("DTM").is_none());
1882    }
1883
1884    #[test]
1885    fn test_find_segments_with_qualifier() {
1886        let segments = vec![
1887            make_segment("NAD", vec![vec!["MS"], vec!["111"]]),
1888            make_segment("NAD", vec![vec!["MR"], vec!["222"]]),
1889            make_segment("NAD", vec![vec!["MS"], vec!["333"]]),
1890        ];
1891        let external = NoOpExternalProvider;
1892        let ctx = EvaluationContext::new("11001", &external, &segments);
1893
1894        let ms_nads = ctx.find_segments_with_qualifier("NAD", 0, "MS");
1895        assert_eq!(ms_nads.len(), 2);
1896    }
1897
1898    #[test]
1899    fn test_has_segment() {
1900        let segments = vec![make_segment("UNH", vec![vec!["test"]])];
1901        let external = NoOpExternalProvider;
1902        let ctx = EvaluationContext::new("11001", &external, &segments);
1903
1904        assert!(ctx.has_segment("UNH"));
1905        assert!(!ctx.has_segment("NAD"));
1906    }
1907
1908    // --- Group navigator tests ---
1909
1910    #[test]
1911    fn test_no_navigator_group_find_returns_empty() {
1912        let segments = vec![make_segment("SEQ", vec![vec!["Z98"]])];
1913        let external = NoOpExternalProvider;
1914        let ctx = EvaluationContext::new("55001", &external, &segments);
1915        assert!(ctx
1916            .find_segments_in_group("SEQ", &["SG4", "SG8"], 0)
1917            .is_empty());
1918    }
1919
1920    #[test]
1921    fn test_no_navigator_group_instance_count_zero() {
1922        let external = NoOpExternalProvider;
1923        let ctx = EvaluationContext::new("55001", &external, &[]);
1924        assert_eq!(ctx.group_instance_count(&["SG4"]), 0);
1925    }
1926
1927    #[test]
1928    fn test_with_navigator_finds_segments_in_group() {
1929        let external = NoOpExternalProvider;
1930        let nav = MockGroupNavigator::new().with_group(
1931            &["SG4", "SG8"],
1932            0,
1933            vec![
1934                make_segment("SEQ", vec![vec!["Z98"]]),
1935                make_segment("CCI", vec![vec!["Z30"], vec![], vec!["Z07"]]),
1936            ],
1937        );
1938        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1939        let result = ctx.find_segments_in_group("SEQ", &["SG4", "SG8"], 0);
1940        assert_eq!(result.len(), 1);
1941        assert_eq!(result[0].id, "SEQ");
1942    }
1943
1944    #[test]
1945    fn test_with_navigator_qualifier_in_group() {
1946        let external = NoOpExternalProvider;
1947        let nav = MockGroupNavigator::new().with_group(
1948            &["SG4", "SG8"],
1949            0,
1950            vec![
1951                make_segment("SEQ", vec![vec!["Z98"]]),
1952                make_segment("SEQ", vec![vec!["Z01"]]),
1953            ],
1954        );
1955        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1956        let result = ctx.find_segments_with_qualifier_in_group("SEQ", 0, "Z98", &["SG4", "SG8"], 0);
1957        assert_eq!(result.len(), 1);
1958    }
1959
1960    #[test]
1961    fn test_group_instance_count_with_navigator() {
1962        let external = NoOpExternalProvider;
1963        let nav = MockGroupNavigator::new()
1964            .with_group(
1965                &["SG4", "SG8"],
1966                0,
1967                vec![make_segment("SEQ", vec![vec!["Z98"]])],
1968            )
1969            .with_group(
1970                &["SG4", "SG8"],
1971                1,
1972                vec![make_segment("SEQ", vec![vec!["Z01"]])],
1973            );
1974        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1975        assert_eq!(ctx.group_instance_count(&["SG4", "SG8"]), 2);
1976    }
1977
1978    #[test]
1979    fn test_has_segment_in_group() {
1980        let external = NoOpExternalProvider;
1981        let nav = MockGroupNavigator::new().with_group(
1982            &["SG4", "SG8"],
1983            0,
1984            vec![make_segment("SEQ", vec![vec!["Z98"]])],
1985        );
1986        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
1987        assert!(ctx.has_segment_in_group("SEQ", &["SG4", "SG8"], 0));
1988        assert!(!ctx.has_segment_in_group("CCI", &["SG4", "SG8"], 0));
1989        assert!(!ctx.has_segment_in_group("SEQ", &["SG4", "SG5"], 0));
1990    }
1991
1992    // --- High-level helper tests ---
1993
1994    #[test]
1995    fn test_has_qualifier() {
1996        let segments = vec![
1997            make_segment("NAD", vec![vec!["MS"], vec!["111"]]),
1998            make_segment("NAD", vec![vec!["MR"], vec!["222"]]),
1999        ];
2000        let external = NoOpExternalProvider;
2001        let ctx = EvaluationContext::new("11001", &external, &segments);
2002
2003        assert_eq!(ctx.has_qualifier("NAD", 0, "MS"), ConditionResult::True);
2004        assert_eq!(ctx.has_qualifier("NAD", 0, "DP"), ConditionResult::False);
2005    }
2006
2007    #[test]
2008    fn test_lacks_qualifier() {
2009        let segments = vec![make_segment("DTM", vec![vec!["92", "2025"]])];
2010        let external = NoOpExternalProvider;
2011        let ctx = EvaluationContext::new("11001", &external, &segments);
2012
2013        assert_eq!(ctx.lacks_qualifier("DTM", 0, "93"), ConditionResult::True);
2014        assert_eq!(ctx.lacks_qualifier("DTM", 0, "92"), ConditionResult::False);
2015    }
2016
2017    #[test]
2018    fn test_has_qualified_value() {
2019        let segments = vec![make_segment("STS", vec![vec!["7"], vec![], vec!["ZG9"]])];
2020        let external = NoOpExternalProvider;
2021        let ctx = EvaluationContext::new("55001", &external, &segments);
2022
2023        assert_eq!(
2024            ctx.has_qualified_value("STS", 0, "7", 2, 0, &["ZG9", "ZH1", "ZH2"]),
2025            ConditionResult::True,
2026        );
2027        assert_eq!(
2028            ctx.has_qualified_value("STS", 0, "7", 2, 0, &["E01"]),
2029            ConditionResult::False,
2030        );
2031        // No STS+E01 → Unknown
2032        assert_eq!(
2033            ctx.has_qualified_value("STS", 0, "E01", 2, 0, &["Z01"]),
2034            ConditionResult::Unknown,
2035        );
2036    }
2037
2038    #[test]
2039    fn test_any_group_has_qualifier() {
2040        let external = NoOpExternalProvider;
2041        let nav = MockGroupNavigator::new()
2042            .with_group(
2043                &["SG4", "SG8"],
2044                0,
2045                vec![make_segment("SEQ", vec![vec!["Z01"]])],
2046            )
2047            .with_group(
2048                &["SG4", "SG8"],
2049                1,
2050                vec![make_segment("SEQ", vec![vec!["Z98"]])],
2051            );
2052        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2053
2054        assert_eq!(
2055            ctx.any_group_has_qualifier("SEQ", 0, "Z98", &["SG4", "SG8"]),
2056            ConditionResult::True,
2057        );
2058        assert_eq!(
2059            ctx.any_group_has_qualifier("SEQ", 0, "Z99", &["SG4", "SG8"]),
2060            ConditionResult::False,
2061        );
2062    }
2063
2064    #[test]
2065    fn test_any_group_has_qualifier_fallback() {
2066        // No navigator — falls back to message-wide search
2067        let segments = vec![make_segment("SEQ", vec![vec!["Z98"]])];
2068        let external = NoOpExternalProvider;
2069        let ctx = EvaluationContext::new("55001", &external, &segments);
2070
2071        assert_eq!(
2072            ctx.any_group_has_qualifier("SEQ", 0, "Z98", &["SG4", "SG8"]),
2073            ConditionResult::True,
2074        );
2075    }
2076
2077    #[test]
2078    fn test_any_group_has_any_qualifier() {
2079        let external = NoOpExternalProvider;
2080        let nav = MockGroupNavigator::new().with_group(
2081            &["SG4", "SG8"],
2082            0,
2083            vec![make_segment("SEQ", vec![vec!["Z80"]])],
2084        );
2085        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2086
2087        assert_eq!(
2088            ctx.any_group_has_any_qualifier("SEQ", 0, &["Z01", "Z80", "Z81"], &["SG4", "SG8"]),
2089            ConditionResult::True,
2090        );
2091        assert_eq!(
2092            ctx.any_group_has_any_qualifier("SEQ", 0, &["Z98"], &["SG4", "SG8"]),
2093            ConditionResult::False,
2094        );
2095    }
2096
2097    #[test]
2098    fn test_any_group_has_co_occurrence() {
2099        let external = NoOpExternalProvider;
2100        let nav = MockGroupNavigator::new().with_group(
2101            &["SG4", "SG8"],
2102            0,
2103            vec![
2104                make_segment("SEQ", vec![vec!["Z01"]]),
2105                make_segment("CCI", vec![vec!["Z30"], vec![], vec!["Z07"]]),
2106            ],
2107        );
2108        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2109
2110        assert_eq!(
2111            ctx.any_group_has_co_occurrence(
2112                "SEQ",
2113                0,
2114                &["Z01"],
2115                "CCI",
2116                2,
2117                0,
2118                &["Z07"],
2119                &["SG4", "SG8"],
2120            ),
2121            ConditionResult::True,
2122        );
2123        // Wrong CCI value
2124        assert_eq!(
2125            ctx.any_group_has_co_occurrence(
2126                "SEQ",
2127                0,
2128                &["Z01"],
2129                "CCI",
2130                2,
2131                0,
2132                &["ZC0"],
2133                &["SG4", "SG8"],
2134            ),
2135            ConditionResult::False,
2136        );
2137    }
2138
2139    // --- Parent-child navigation tests ---
2140
2141    #[test]
2142    fn test_filtered_parent_child_has_qualifier() {
2143        let external = NoOpExternalProvider;
2144        // SG8[0] has SEQ+Z98, with SG10 child having CCI+Z23
2145        // SG8[1] has SEQ+Z01, no SG10 children
2146        let nav = MockGroupNavigator::new()
2147            .with_group(
2148                &["SG4", "SG8"],
2149                0,
2150                vec![make_segment("SEQ", vec![vec!["Z98"]])],
2151            )
2152            .with_group(
2153                &["SG4", "SG8"],
2154                1,
2155                vec![make_segment("SEQ", vec![vec!["Z01"]])],
2156            )
2157            .with_child_group(
2158                &["SG4", "SG8"],
2159                0,
2160                "SG10",
2161                0,
2162                vec![make_segment("CCI", vec![vec!["Z23"]])],
2163            );
2164        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2165
2166        // SG8 with SEQ+Z98 has SG10 child with CCI+Z23 → True
2167        assert_eq!(
2168            ctx.filtered_parent_child_has_qualifier(
2169                &["SG4", "SG8"],
2170                "SEQ",
2171                0,
2172                "Z98",
2173                "SG10",
2174                "CCI",
2175                0,
2176                "Z23",
2177            ),
2178            ConditionResult::True,
2179        );
2180        // SG8 with SEQ+Z01 has no SG10 children → False
2181        assert_eq!(
2182            ctx.filtered_parent_child_has_qualifier(
2183                &["SG4", "SG8"],
2184                "SEQ",
2185                0,
2186                "Z01",
2187                "SG10",
2188                "CCI",
2189                0,
2190                "Z23",
2191            ),
2192            ConditionResult::False,
2193        );
2194        // Wrong child qualifier → False
2195        assert_eq!(
2196            ctx.filtered_parent_child_has_qualifier(
2197                &["SG4", "SG8"],
2198                "SEQ",
2199                0,
2200                "Z98",
2201                "SG10",
2202                "CCI",
2203                0,
2204                "Z99",
2205            ),
2206            ConditionResult::False,
2207        );
2208    }
2209
2210    #[test]
2211    fn test_filtered_parent_child_fallback() {
2212        // No navigator — falls back to message-wide
2213        let segments = vec![
2214            make_segment("SEQ", vec![vec!["Z98"]]),
2215            make_segment("CCI", vec![vec!["Z23"]]),
2216        ];
2217        let external = NoOpExternalProvider;
2218        let ctx = EvaluationContext::new("55001", &external, &segments);
2219
2220        assert_eq!(
2221            ctx.filtered_parent_child_has_qualifier(
2222                &["SG4", "SG8"],
2223                "SEQ",
2224                0,
2225                "Z98",
2226                "SG10",
2227                "CCI",
2228                0,
2229                "Z23",
2230            ),
2231            ConditionResult::True,
2232        );
2233        // Missing child qualifier in message-wide → False
2234        assert_eq!(
2235            ctx.filtered_parent_child_has_qualifier(
2236                &["SG4", "SG8"],
2237                "SEQ",
2238                0,
2239                "Z98",
2240                "SG10",
2241                "CCI",
2242                0,
2243                "Z99",
2244            ),
2245            ConditionResult::False,
2246        );
2247    }
2248
2249    #[test]
2250    fn test_any_group_has_qualifier_without() {
2251        let external = NoOpExternalProvider;
2252        // SG8[0]: SEQ+Z59 present, CCI+11 absent
2253        // SG8[1]: SEQ+Z01 present, CCI+11 present
2254        let nav = MockGroupNavigator::new()
2255            .with_group(
2256                &["SG4", "SG8"],
2257                0,
2258                vec![make_segment("SEQ", vec![vec!["Z59"]])],
2259            )
2260            .with_group(
2261                &["SG4", "SG8"],
2262                1,
2263                vec![
2264                    make_segment("SEQ", vec![vec!["Z01"]]),
2265                    make_segment("CCI", vec![vec!["11"]]),
2266                ],
2267            );
2268        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2269
2270        // SG8[0] has SEQ+Z59 without CCI+11 → True
2271        assert_eq!(
2272            ctx.any_group_has_qualifier_without("SEQ", 0, "Z59", "CCI", 0, "11", &["SG4", "SG8"]),
2273            ConditionResult::True,
2274        );
2275        // Looking for SEQ+Z01 without CCI+11 → False (SG8[1] has both)
2276        assert_eq!(
2277            ctx.any_group_has_qualifier_without("SEQ", 0, "Z01", "CCI", 0, "11", &["SG4", "SG8"]),
2278            ConditionResult::False,
2279        );
2280        // Looking for SEQ+Z99 (doesn't exist) → False
2281        assert_eq!(
2282            ctx.any_group_has_qualifier_without("SEQ", 0, "Z99", "CCI", 0, "11", &["SG4", "SG8"]),
2283            ConditionResult::False,
2284        );
2285    }
2286
2287    #[test]
2288    fn test_any_group_has_qualifier_without_fallback() {
2289        let segments = vec![make_segment("SEQ", vec![vec!["Z59"]])];
2290        let external = NoOpExternalProvider;
2291        let ctx = EvaluationContext::new("55001", &external, &segments);
2292
2293        // Message-wide: SEQ+Z59 present, CCI+11 absent → True
2294        assert_eq!(
2295            ctx.any_group_has_qualifier_without("SEQ", 0, "Z59", "CCI", 0, "11", &["SG4", "SG8"]),
2296            ConditionResult::True,
2297        );
2298    }
2299
2300    #[test]
2301    fn test_collect_group_values() {
2302        let external = NoOpExternalProvider;
2303        let nav = MockGroupNavigator::new()
2304            .with_group(
2305                &["SG4", "SG6"],
2306                0,
2307                vec![make_segment("RFF", vec![vec!["Z49", "REF001"]])],
2308            )
2309            .with_group(
2310                &["SG4", "SG6"],
2311                1,
2312                vec![make_segment("RFF", vec![vec!["Z49", "REF002"]])],
2313            );
2314        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2315
2316        let values = ctx.collect_group_values("RFF", 0, 1, &["SG4", "SG6"]);
2317        assert_eq!(values.len(), 2);
2318        assert_eq!(values[0], (0, "REF001".to_string()));
2319        assert_eq!(values[1], (1, "REF002".to_string()));
2320    }
2321
2322    #[test]
2323    fn test_groups_share_qualified_value() {
2324        let external = NoOpExternalProvider;
2325        // SG6[0]: RFF+Z49 with value "TS001"
2326        // SG8[0]: SEQ with c286 value "TS001" (matches)
2327        // SG8[1]: SEQ with c286 value "TS999" (no match)
2328        let nav = MockGroupNavigator::new()
2329            .with_group(
2330                &["SG4", "SG6"],
2331                0,
2332                vec![make_segment("RFF", vec![vec!["Z49", "TS001"]])],
2333            )
2334            .with_group(
2335                &["SG4", "SG8"],
2336                0,
2337                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]])],
2338            )
2339            .with_group(
2340                &["SG4", "SG8"],
2341                1,
2342                vec![make_segment("SEQ", vec![vec!["Z01"], vec!["TS999"]])],
2343            );
2344        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2345
2346        // RFF+Z49 value "TS001" matches SEQ value at [1][0] → True
2347        assert_eq!(
2348            ctx.groups_share_qualified_value(
2349                "RFF",
2350                0,
2351                "Z49",
2352                0,
2353                1,
2354                &["SG4", "SG6"],
2355                "SEQ",
2356                1,
2357                0,
2358                &["SG4", "SG8"],
2359            ),
2360            ConditionResult::True,
2361        );
2362    }
2363
2364    #[test]
2365    fn test_groups_share_qualified_value_no_match() {
2366        let external = NoOpExternalProvider;
2367        let nav = MockGroupNavigator::new()
2368            .with_group(
2369                &["SG4", "SG6"],
2370                0,
2371                vec![make_segment("RFF", vec![vec!["Z49", "TS001"]])],
2372            )
2373            .with_group(
2374                &["SG4", "SG8"],
2375                0,
2376                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS999"]])],
2377            );
2378        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2379
2380        // No matching value → False
2381        assert_eq!(
2382            ctx.groups_share_qualified_value(
2383                "RFF",
2384                0,
2385                "Z49",
2386                0,
2387                1,
2388                &["SG4", "SG6"],
2389                "SEQ",
2390                1,
2391                0,
2392                &["SG4", "SG8"],
2393            ),
2394            ConditionResult::False,
2395        );
2396    }
2397
2398    #[test]
2399    fn test_groups_share_qualified_value_no_source() {
2400        let external = NoOpExternalProvider;
2401        // No RFF+Z49 at all
2402        let nav = MockGroupNavigator::new()
2403            .with_group(
2404                &["SG4", "SG6"],
2405                0,
2406                vec![make_segment("RFF", vec![vec!["Z13", "55001"]])],
2407            )
2408            .with_group(
2409                &["SG4", "SG8"],
2410                0,
2411                vec![make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]])],
2412            );
2413        let ctx = EvaluationContext::with_navigator("55001", &external, &[], &nav);
2414
2415        // No source qualifier match → Unknown
2416        assert_eq!(
2417            ctx.groups_share_qualified_value(
2418                "RFF",
2419                0,
2420                "Z49",
2421                0,
2422                1,
2423                &["SG4", "SG6"],
2424                "SEQ",
2425                1,
2426                0,
2427                &["SG4", "SG8"],
2428            ),
2429            ConditionResult::Unknown,
2430        );
2431    }
2432
2433    #[test]
2434    fn test_groups_share_qualified_value_fallback() {
2435        // No navigator — falls back to message-wide
2436        let segments = vec![
2437            make_segment("RFF", vec![vec!["Z49", "TS001"]]),
2438            make_segment("SEQ", vec![vec!["Z98"], vec!["TS001"]]),
2439        ];
2440        let external = NoOpExternalProvider;
2441        let ctx = EvaluationContext::new("55001", &external, &segments);
2442
2443        assert_eq!(
2444            ctx.groups_share_qualified_value(
2445                "RFF",
2446                0,
2447                "Z49",
2448                0,
2449                1,
2450                &["SG4", "SG6"],
2451                "SEQ",
2452                1,
2453                0,
2454                &["SG4", "SG8"],
2455            ),
2456            ConditionResult::True,
2457        );
2458    }
2459
2460    #[test]
2461    fn test_child_group_pass_throughs_no_navigator() {
2462        let external = NoOpExternalProvider;
2463        let ctx = EvaluationContext::new("55001", &external, &[]);
2464
2465        assert_eq!(
2466            ctx.child_group_instance_count(&["SG4", "SG8"], 0, "SG10"),
2467            0
2468        );
2469        assert!(ctx
2470            .find_segments_in_child_group("CCI", &["SG4", "SG8"], 0, "SG10", 0)
2471            .is_empty());
2472        assert_eq!(
2473            ctx.extract_value_in_group("SEQ", 0, 0, &["SG4", "SG8"], 0),
2474            None
2475        );
2476    }
2477
2478    // --- has_segment_matching tests ---
2479
2480    #[test]
2481    fn test_has_segment_matching_found() {
2482        let segments = vec![
2483            make_segment("STS", vec![vec!["7"], vec!["E01"], vec!["ZW4"]]),
2484            make_segment("STS", vec![vec!["Z20"], vec!["Z32"], vec!["A99"]]),
2485        ];
2486        let external = NoOpExternalProvider;
2487        let ctx = EvaluationContext::new("55001", &external, &segments);
2488
2489        assert_eq!(
2490            ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32"), (2, 0, "A99")]),
2491            ConditionResult::True,
2492        );
2493    }
2494
2495    #[test]
2496    fn test_has_segment_matching_not_found() {
2497        let segments = vec![make_segment(
2498            "STS",
2499            vec![vec!["7"], vec!["E01"], vec!["ZW4"]],
2500        )];
2501        let external = NoOpExternalProvider;
2502        let ctx = EvaluationContext::new("55001", &external, &segments);
2503
2504        assert_eq!(
2505            ctx.has_segment_matching("STS", &[(0, 0, "Z20"), (1, 0, "Z32")]),
2506            ConditionResult::False,
2507        );
2508    }
2509
2510    #[test]
2511    fn test_has_segment_matching_no_segments() {
2512        let external = NoOpExternalProvider;
2513        let ctx = EvaluationContext::new("55001", &external, &[]);
2514
2515        assert_eq!(
2516            ctx.has_segment_matching("STS", &[(0, 0, "Z20")]),
2517            ConditionResult::Unknown,
2518        );
2519    }
2520
2521    #[test]
2522    fn test_has_segment_matching_in_group() {
2523        let nav = MockGroupNavigator::new()
2524            .with_group(
2525                &["SG4"],
2526                0,
2527                vec![make_segment("STS", vec![vec!["7"], vec!["E01"]])],
2528            )
2529            .with_group(
2530                &["SG4"],
2531                1,
2532                vec![make_segment("STS", vec![vec!["Z20"], vec!["Z32"]])],
2533            );
2534        let segments = vec![
2535            make_segment("STS", vec![vec!["7"], vec!["E01"]]),
2536            make_segment("STS", vec![vec!["Z20"], vec!["Z32"]]),
2537        ];
2538        let external = NoOpExternalProvider;
2539        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2540
2541        assert_eq!(
2542            ctx.has_segment_matching_in_group("STS", &[(0, 0, "Z20"), (1, 0, "Z32")], &["SG4"]),
2543            ConditionResult::True,
2544        );
2545    }
2546
2547    // --- DTM comparison tests ---
2548
2549    #[test]
2550    fn test_dtm_ge() {
2551        let segments = vec![make_segment(
2552            "DTM",
2553            vec![vec!["137", "202601010000", "303"]],
2554        )];
2555        let external = NoOpExternalProvider;
2556        let ctx = EvaluationContext::new("55001", &external, &segments);
2557
2558        assert_eq!(ctx.dtm_ge("137", "202601010000"), ConditionResult::True);
2559        assert_eq!(ctx.dtm_ge("137", "202501010000"), ConditionResult::True);
2560        assert_eq!(ctx.dtm_ge("137", "202701010000"), ConditionResult::False);
2561        assert_eq!(ctx.dtm_ge("999", "202601010000"), ConditionResult::Unknown);
2562    }
2563
2564    #[test]
2565    fn test_dtm_lt() {
2566        let segments = vec![make_segment(
2567            "DTM",
2568            vec![vec!["137", "202601010000", "303"]],
2569        )];
2570        let external = NoOpExternalProvider;
2571        let ctx = EvaluationContext::new("55001", &external, &segments);
2572
2573        assert_eq!(ctx.dtm_lt("137", "202701010000"), ConditionResult::True);
2574        assert_eq!(ctx.dtm_lt("137", "202601010000"), ConditionResult::False);
2575        assert_eq!(ctx.dtm_lt("137", "202501010000"), ConditionResult::False);
2576    }
2577
2578    #[test]
2579    fn test_dtm_le() {
2580        let segments = vec![make_segment(
2581            "DTM",
2582            vec![vec!["137", "202601010000", "303"]],
2583        )];
2584        let external = NoOpExternalProvider;
2585        let ctx = EvaluationContext::new("55001", &external, &segments);
2586
2587        assert_eq!(ctx.dtm_le("137", "202601010000"), ConditionResult::True);
2588        assert_eq!(ctx.dtm_le("137", "202701010000"), ConditionResult::True);
2589        assert_eq!(ctx.dtm_le("137", "202501010000"), ConditionResult::False);
2590    }
2591
2592    // --- Count helpers tests ---
2593
2594    #[test]
2595    fn test_count_qualified_in_group() {
2596        let nav = MockGroupNavigator::new()
2597            .with_group(
2598                &["SG4", "SG8"],
2599                0,
2600                vec![
2601                    make_segment("CCI", vec![vec!["Z23"]]),
2602                    make_segment("CCI", vec![vec!["Z30"]]),
2603                ],
2604            )
2605            .with_group(
2606                &["SG4", "SG8"],
2607                1,
2608                vec![make_segment("CCI", vec![vec!["Z23"]])],
2609            );
2610        let segments = vec![
2611            make_segment("CCI", vec![vec!["Z23"]]),
2612            make_segment("CCI", vec![vec!["Z30"]]),
2613            make_segment("CCI", vec![vec!["Z23"]]),
2614        ];
2615        let external = NoOpExternalProvider;
2616        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2617
2618        assert_eq!(
2619            ctx.count_qualified_in_group("CCI", 0, "Z23", &["SG4", "SG8"]),
2620            2
2621        );
2622        assert_eq!(
2623            ctx.count_qualified_in_group("CCI", 0, "Z30", &["SG4", "SG8"]),
2624            1
2625        );
2626        assert_eq!(
2627            ctx.count_qualified_in_group("CCI", 0, "Z99", &["SG4", "SG8"]),
2628            0
2629        );
2630    }
2631
2632    #[test]
2633    fn test_count_in_group() {
2634        let nav = MockGroupNavigator::new()
2635            .with_group(
2636                &["SG4", "SG8"],
2637                0,
2638                vec![
2639                    make_segment("SEQ", vec![vec!["Z98"]]),
2640                    make_segment("CCI", vec![vec!["Z23"]]),
2641                ],
2642            )
2643            .with_group(
2644                &["SG4", "SG8"],
2645                1,
2646                vec![make_segment("SEQ", vec![vec!["Z01"]])],
2647            );
2648        let segments = vec![
2649            make_segment("SEQ", vec![vec!["Z98"]]),
2650            make_segment("CCI", vec![vec!["Z23"]]),
2651            make_segment("SEQ", vec![vec!["Z01"]]),
2652        ];
2653        let external = NoOpExternalProvider;
2654        let ctx = EvaluationContext::with_navigator("55001", &external, &segments, &nav);
2655
2656        assert_eq!(ctx.count_in_group("SEQ", &["SG4", "SG8"]), 2);
2657        assert_eq!(ctx.count_in_group("CCI", &["SG4", "SG8"]), 1);
2658        assert_eq!(ctx.count_in_group("DTM", &["SG4", "SG8"]), 0);
2659    }
2660
2661    #[test]
2662    fn test_count_fallback_no_navigator() {
2663        let segments = vec![
2664            make_segment("CCI", vec![vec!["Z23"]]),
2665            make_segment("CCI", vec![vec!["Z30"]]),
2666            make_segment("CCI", vec![vec!["Z23"]]),
2667        ];
2668        let external = NoOpExternalProvider;
2669        let ctx = EvaluationContext::new("55001", &external, &segments);
2670
2671        // Falls back to message-wide count
2672        assert_eq!(
2673            ctx.count_qualified_in_group("CCI", 0, "Z23", &["SG4", "SG8"]),
2674            2
2675        );
2676        assert_eq!(ctx.count_in_group("CCI", &["SG4", "SG8"]), 3);
2677    }
2678
2679    // --- format_check / format_check_qualified tests ---
2680
2681    #[test]
2682    fn format_check_prefers_resolved_value() {
2683        // Segments have "WRONG" but resolved_value is "CORRECT"
2684        let segments = vec![make_segment("DTM", vec![vec!["92", "WRONG"]])];
2685        let external = NoOpExternalProvider;
2686        let ctx = EvaluationContext::new("55001", &external, &segments)
2687            .with_resolved(Some("CORRECT"), None);
2688
2689        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "CORRECT"));
2690        assert_eq!(result, ConditionResult::True);
2691    }
2692
2693    #[test]
2694    fn format_check_falls_back_to_segment_search() {
2695        // No resolved_value, segments have the value
2696        let segments = vec![make_segment("DTM", vec![vec!["92", "202501011200"]])];
2697        let external = NoOpExternalProvider;
2698        let ctx = EvaluationContext::new("55001", &external, &segments);
2699
2700        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "202501011200"));
2701        assert_eq!(result, ConditionResult::True);
2702    }
2703
2704    #[test]
2705    fn format_check_returns_unknown_when_segment_absent() {
2706        // No resolved_value, no matching segments — we can't decide.
2707        let segments: Vec<OwnedSegment> = vec![];
2708        let external = NoOpExternalProvider;
2709        let ctx = EvaluationContext::new("55001", &external, &segments);
2710
2711        let result = ctx.format_check("DTM", 0, 1, |_| ConditionResult::True);
2712        assert_eq!(result, ConditionResult::Unknown);
2713    }
2714
2715    #[test]
2716    fn format_check_returns_unknown_when_multiple_segments() {
2717        // Two DTMs, no resolved_value. Picking .first() would silently pick
2718        // one; Unknown is the honest answer when we can't tell which is being
2719        // validated.
2720        let segments = vec![
2721            make_segment("DTM", vec![vec!["92", "OK"]]),
2722            make_segment("DTM", vec![vec!["163", "BAD"]]),
2723        ];
2724        let external = NoOpExternalProvider;
2725        let ctx = EvaluationContext::new("55001", &external, &segments);
2726
2727        let result = ctx.format_check("DTM", 0, 1, |v| ConditionResult::from(v == "OK"));
2728        assert_eq!(result, ConditionResult::Unknown);
2729    }
2730
2731    #[test]
2732    fn format_check_qualified_prefers_resolved_value() {
2733        // Two DTM segments but resolved_value overrides
2734        let segments = vec![
2735            make_segment("DTM", vec![vec!["92", "WRONG"]]),
2736            make_segment("DTM", vec![vec!["163", "ALSO_WRONG"]]),
2737        ];
2738        let external = NoOpExternalProvider;
2739        let ctx = EvaluationContext::new("55001", &external, &segments)
2740            .with_resolved(Some("CORRECT"), None);
2741
2742        let result = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2743            ConditionResult::from(v == "CORRECT")
2744        });
2745        assert_eq!(result, ConditionResult::True);
2746    }
2747
2748    #[test]
2749    fn format_check_resolved_judges_only_the_fields_own_value() {
2750        // A LOC+Z16 elsewhere in the message is not the value the condition
2751        // sits on (#373).
2752        let segments = vec![make_segment("LOC", vec![vec!["Z16"], vec!["WRONG"]])];
2753        let external = NoOpExternalProvider;
2754        let ctx = EvaluationContext::new("17101", &external, &segments);
2755        let check = |v: &str| ConditionResult::from(v == "CORRECT");
2756
2757        assert_eq!(ctx.format_check_resolved(check), ConditionResult::Unknown);
2758        let field = ctx.with_resolved(Some("CORRECT"), None);
2759        assert_eq!(field.format_check_resolved(check), ConditionResult::True);
2760    }
2761
2762    #[test]
2763    fn format_check_qualified_falls_back_to_qualified_search() {
2764        // Two DTM segments with different qualifiers, no resolved_value
2765        let segments = vec![
2766            make_segment("DTM", vec![vec!["92", "2200"]]),
2767            make_segment("DTM", vec![vec!["163", "0800"]]),
2768        ];
2769        let external = NoOpExternalProvider;
2770        let ctx = EvaluationContext::new("55001", &external, &segments);
2771
2772        // Should find DTM+163 and extract "0800", not DTM+92's "2200"
2773        let result = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2774            ConditionResult::from(v == "0800")
2775        });
2776        assert_eq!(result, ConditionResult::True);
2777
2778        // Verify it does NOT pick DTM+92's value
2779        let result2 = ctx.format_check_qualified("DTM", 0, "163", 0, 1, |v| {
2780            ConditionResult::from(v == "2200")
2781        });
2782        assert_eq!(result2, ConditionResult::False);
2783    }
2784
2785    // --- self_segment_value / self_segment_value_equals tests ---
2786
2787    #[test]
2788    fn self_segment_value_prefers_resolved_segment() {
2789        // Two DTM segments in the message: the first has "OTHER", the
2790        // resolved_segment (the one being validated) has "303". Per-field
2791        // condition must read "303", not "OTHER".
2792        let segments = vec![
2793            make_segment("DTM", vec![vec!["472", "", "OTHER"]]),
2794            make_segment("DTM", vec![vec!["Z25", "", "303"]]),
2795        ];
2796        let external = NoOpExternalProvider;
2797        let resolved_elements = vec![vec!["Z25".to_string(), String::new(), "303".to_string()]];
2798        let ctx = EvaluationContext::new("55001", &external, &segments)
2799            .with_resolved(None, Some(&resolved_elements));
2800
2801        assert_eq!(ctx.self_segment_value("DTM", 0, 2), Some("303"));
2802        assert!(ctx.self_segment_value_equals("DTM", 0, 2, "303"));
2803        assert!(!ctx.self_segment_value_equals("DTM", 0, 2, "OTHER"));
2804    }
2805
2806    #[test]
2807    fn self_segment_value_falls_back_when_no_resolved_segment() {
2808        // Outside tree context — fall back to the first tag match.
2809        let segments = vec![
2810            make_segment("DTM", vec![vec!["Z25", "", "303"]]),
2811            make_segment("DTM", vec![vec!["472", "", "OTHER"]]),
2812        ];
2813        let external = NoOpExternalProvider;
2814        let ctx = EvaluationContext::new("55001", &external, &segments);
2815
2816        assert_eq!(ctx.self_segment_value("DTM", 0, 2), Some("303"));
2817    }
2818
2819    #[test]
2820    fn self_segment_value_missing_returns_none() {
2821        let segments: Vec<OwnedSegment> = vec![];
2822        let external = NoOpExternalProvider;
2823        let ctx = EvaluationContext::new("55001", &external, &segments);
2824
2825        assert_eq!(ctx.self_segment_value("DTM", 0, 2), None);
2826        assert!(!ctx.self_segment_value_equals("DTM", 0, 2, "303"));
2827    }
2828
2829    /// Build a UTILMD-like segment sequence where each SG8 is:
2830    ///   SEQ+Q[:zid] then PIA+5+<obis>
2831    /// The input `sg8s` is a list of `(qualifier, zeitraum_id, obis)`.
2832    fn make_sg8_sequence(sg8s: &[(&str, &str, &str)]) -> Vec<OwnedSegment> {
2833        let mut segments = Vec::new();
2834        for (i, (qual, zid, obis)) in sg8s.iter().enumerate() {
2835            segments.push(OwnedSegment {
2836                id: "SEQ".to_string(),
2837                elements: vec![vec![qual.to_string()], vec![zid.to_string()]],
2838                segment_number: (2 * i + 1) as u32,
2839            });
2840            segments.push(OwnedSegment {
2841                id: "PIA".to_string(),
2842                elements: vec![vec!["5".to_string()], vec![obis.to_string()]],
2843                segment_number: (2 * i + 2) as u32,
2844            });
2845        }
2846        segments
2847    }
2848
2849    #[test]
2850    fn count_sg8_obis_matches_counts_matching_kumuliert() {
2851        // Two SG8s with same qualifier Z02 + zid "ZR1", two kumuliert OBIS.
2852        let segments = make_sg8_sequence(&[
2853            ("Z02", "ZR1", "1-1:1.8.0"), // kumuliert
2854            ("Z02", "ZR1", "1-1:2.8.0"), // kumuliert
2855            ("Z02", "ZR2", "1-1:1.8.0"), // different zid — shouldn't count
2856            ("ZA1", "ZR1", "1-1:1.8.0"), // different qualifier — shouldn't count
2857        ]);
2858        let external = NoOpExternalProvider;
2859        // Anchor on first SEQ (Z02, ZR1).
2860        let seq = &segments[0].elements;
2861        let ctx =
2862            EvaluationContext::new("55001", &external, &segments).with_resolved(None, Some(seq));
2863
2864        let n = ctx
2865            .count_sg8_obis_matches(super::super::format_validators::is_obis_wirkarbeit_kumuliert);
2866        assert_eq!(n, 2);
2867    }
2868
2869    #[test]
2870    fn count_sg8_obis_matches_returns_zero_without_resolved_segment() {
2871        let segments = make_sg8_sequence(&[("Z02", "ZR1", "1-1:1.8.0")]);
2872        let external = NoOpExternalProvider;
2873        let ctx = EvaluationContext::new("55001", &external, &segments);
2874        let n = ctx
2875            .count_sg8_obis_matches(super::super::format_validators::is_obis_wirkarbeit_kumuliert);
2876        assert_eq!(n, 0);
2877    }
2878
2879    #[test]
2880    fn count_sg8_obis_matches_quarter_hour_and_kumuliert_are_disjoint() {
2881        let segments = make_sg8_sequence(&[
2882            ("Z02", "ZR1", "1-1:1.8.0"),  // kumuliert
2883            ("Z02", "ZR1", "1-1:1.29.0"), // 1/4 h
2884        ]);
2885        let external = NoOpExternalProvider;
2886        let seq = &segments[0].elements;
2887        let ctx =
2888            EvaluationContext::new("55001", &external, &segments).with_resolved(None, Some(seq));
2889
2890        assert_eq!(
2891            ctx.count_sg8_obis_matches(
2892                super::super::format_validators::is_obis_wirkarbeit_kumuliert
2893            ),
2894            1
2895        );
2896        assert_eq!(
2897            ctx.count_sg8_obis_matches(
2898                super::super::format_validators::is_obis_wirkarbeit_quarter_hour
2899            ),
2900            1
2901        );
2902    }
2903
2904    // --- Segment index: the lookups give what scanning the slice gives ---
2905
2906    /// The message-wide lookup as it was before the index: every segment
2907    /// `tag` whose `elements[element_index][0]` is `qualifier`, in order.
2908    fn scan<'s>(
2909        segments: &'s [OwnedSegment],
2910        tag: &str,
2911        qualifier: Option<(usize, &str)>,
2912    ) -> Vec<&'s OwnedSegment> {
2913        segments
2914            .iter()
2915            .filter(|s| {
2916                s.id == tag
2917                    && qualifier.is_none_or(|(element_index, qualifier)| {
2918                        s.elements
2919                            .get(element_index)
2920                            .and_then(|e| e.first())
2921                            .is_some_and(|v| v == qualifier)
2922                    })
2923            })
2924            .collect()
2925    }
2926
2927    /// The same segments, by identity (not just equal contents).
2928    fn same(found: &[&OwnedSegment], expected: &[&OwnedSegment]) -> bool {
2929        found.len() == expected.len()
2930            && found
2931                .iter()
2932                .zip(expected)
2933                .all(|(f, e)| std::ptr::eq(*f, *e))
2934    }
2935
2936    fn same_one(found: Option<&OwnedSegment>, expected: Option<&&OwnedSegment>) -> bool {
2937        match (found, expected) {
2938            (Some(f), Some(e)) => std::ptr::eq(f, *e),
2939            (None, None) => true,
2940            _ => false,
2941        }
2942    }
2943
2944    /// Segments the index has to tell apart: equal qualifiers on several
2945    /// tags, an empty qualifier, an element without components, a segment
2946    /// without elements, qualifiers in other elements, interleaved tags.
2947    fn index_sample() -> Vec<OwnedSegment> {
2948        vec![
2949            make_segment("UNH", vec![vec!["1"], vec!["INVOIC", "D"]]),
2950            make_segment("DTM", vec![vec!["137", "202501010000", "303"]]),
2951            make_segment("RFF", vec![vec!["Z13", "31002"]]),
2952            make_segment("DTM", vec![vec!["9", "202501020000", "303"]]),
2953            make_segment("LIN", vec![vec!["1"], vec![], vec!["ABC", "Z01"]]),
2954            make_segment("QTY", vec![vec!["47", "1", "KWH"]]),
2955            make_segment("ALC", vec![vec!["A"], vec!["", "Z01"]]),
2956            make_segment("ALC", vec![vec!["C"], vec!["", "Z03"]]),
2957            make_segment("LIN", vec![vec!["2"], vec![], vec!["DEF", "Z02"]]),
2958            make_segment("ALC", vec![vec!["A"], vec!["", "Z04"]]),
2959            make_segment("FTX", vec![vec![""], vec![""], vec!["RCH"]]),
2960            make_segment("FTX", vec![vec![], vec!["ACB"]]),
2961            make_segment("UNS", vec![]),
2962            make_segment("MOA", vec![vec!["9", "100"]]),
2963            make_segment("DTM", vec![vec!["137", "202501030000", "303"]]),
2964            make_segment("RFF", vec![vec!["137"]]),
2965        ]
2966    }
2967
2968    /// Every tag, qualifier and element index the sample has, and some it
2969    /// has not.
2970    fn index_queries() -> Vec<(&'static str, Option<(usize, &'static str)>)> {
2971        let mut queries = vec![];
2972        for tag in [
2973            "UNH", "DTM", "RFF", "LIN", "QTY", "ALC", "FTX", "UNS", "MOA", "NAD",
2974        ] {
2975            queries.push((tag, None));
2976            for element_index in 0..4 {
2977                for qualifier in [
2978                    "", "1", "2", "9", "47", "137", "Z13", "A", "C", "ACB", "RCH", "INVOIC", "ABC",
2979                    "Z01", "XYZ",
2980                ] {
2981                    queries.push((tag, Some((element_index, qualifier))));
2982                }
2983            }
2984        }
2985        queries
2986    }
2987
2988    /// Checks every lookup of `ctx` against scanning `expected_over`.
2989    fn assert_lookups_scan(ctx: &EvaluationContext, expected_over: &[OwnedSegment]) {
2990        for (tag, qualifier) in index_queries() {
2991            let expected = scan(expected_over, tag, qualifier);
2992            let what = format!("{tag} {qualifier:?}");
2993            let Some((element_index, q)) = qualifier else {
2994                assert!(same(&ctx.find_segments(tag), &expected), "{what}");
2995                assert!(same_one(ctx.find_segment(tag), expected.first()), "{what}");
2996                assert_eq!(ctx.has_segment(tag), !expected.is_empty(), "{what}");
2997                continue;
2998            };
2999            let found = ctx.find_segments_with_qualifier(tag, element_index, q);
3000            assert!(same(&found, &expected), "{what}");
3001            assert!(
3002                same_one(
3003                    ctx.find_segment_with_qualifier(tag, element_index, q),
3004                    expected.first()
3005                ),
3006                "{what}"
3007            );
3008            assert_eq!(
3009                ctx.has_qualifier(tag, element_index, q),
3010                ConditionResult::from(!expected.is_empty()),
3011                "{what}"
3012            );
3013            assert_eq!(
3014                ctx.lacks_qualifier(tag, element_index, q),
3015                ConditionResult::from(expected.is_empty()),
3016                "{what}"
3017            );
3018            for (value_elem, value_comp, values) in [
3019                (1, 1, &["Z01", "Z04"][..]),
3020                (0, 1, &["202501030000"][..]),
3021                (2, 0, &["RCH"][..]),
3022                (5, 0, &["X"][..]),
3023            ] {
3024                let expected_value = if expected.is_empty() {
3025                    ConditionResult::Unknown
3026                } else {
3027                    ConditionResult::from(expected.iter().any(|s| {
3028                        s.elements
3029                            .get(value_elem)
3030                            .and_then(|e| e.get(value_comp))
3031                            .is_some_and(|v| values.contains(&v.as_str()))
3032                    }))
3033                };
3034                assert_eq!(
3035                    ctx.has_qualified_value(tag, element_index, q, value_elem, value_comp, values),
3036                    expected_value,
3037                    "{what} {value_elem}/{value_comp}"
3038                );
3039            }
3040        }
3041    }
3042
3043    #[test]
3044    fn indexed_lookups_find_what_scanning_finds() {
3045        let segments = index_sample();
3046        let external = NoOpExternalProvider;
3047        let ctx = EvaluationContext::new("31002", &external, &segments);
3048        assert_lookups_scan(&ctx, &segments);
3049        // Scoped and resolved contexts read the same slice through the same
3050        // index; it is built once.
3051        let path = [("SG26", 0)];
3052        let scoped = ctx
3053            .with_scope(GroupScope { path: &path })
3054            .with_resolved(Some("x"), None);
3055        assert!(Arc::ptr_eq(&ctx.segment_index, &scoped.segment_index));
3056        assert_lookups_scan(&scoped, &segments);
3057    }
3058
3059    #[test]
3060    fn indexed_lookups_on_an_empty_message() {
3061        let external = NoOpExternalProvider;
3062        let ctx = EvaluationContext::new("31002", &external, &[]);
3063        assert_lookups_scan(&ctx, &[]);
3064    }
3065
3066    /// A view (one transaction of the message) has segments of its own: its
3067    /// lookups read them, never the index of the context it came from — even
3068    /// where views reuse one buffer, as `validate_tree_in` may.
3069    #[test]
3070    fn a_view_indexes_its_own_segments() {
3071        let segments = index_sample();
3072        let external = NoOpExternalProvider;
3073        let nav = NoOpGroupNavigator;
3074        let ctx = EvaluationContext::with_navigator("31002", &external, &segments, &nav);
3075        assert_lookups_scan(&ctx, &segments);
3076
3077        let mut reversed = segments.clone();
3078        reversed.reverse();
3079        let view = ctx.with_view(&reversed, &nav);
3080        assert!(!Arc::ptr_eq(&ctx.segment_index, &view.segment_index));
3081        assert_lookups_scan(&view, &reversed);
3082        // The parent still reads its own.
3083        assert_lookups_scan(&ctx, &segments);
3084
3085        let mut buffer: Vec<OwnedSegment> = Vec::with_capacity(segments.len());
3086        for drop_every in [2, 3] {
3087            buffer.clear();
3088            buffer.extend(
3089                segments
3090                    .iter()
3091                    .enumerate()
3092                    .filter(|(i, _)| i % drop_every != 0)
3093                    .map(|(_, s)| s.clone()),
3094            );
3095            let view = ctx.with_view(&buffer, &nav);
3096            assert_lookups_scan(&view, &buffer);
3097        }
3098    }
3099
3100    /// `segments` is a public field: a context pointed at other segments
3101    /// after its index was built scans them rather than reading the index.
3102    #[test]
3103    fn a_context_given_other_segments_does_not_read_the_old_index() {
3104        let segments = index_sample();
3105        let other: Vec<OwnedSegment> = segments[4..10].to_vec();
3106        let external = NoOpExternalProvider;
3107        let mut ctx = EvaluationContext::new("31002", &external, &segments);
3108        assert_lookups_scan(&ctx, &segments);
3109        ctx.segments = &other;
3110        assert_lookups_scan(&ctx, &other);
3111        // A prefix of the same slice: same address, another length.
3112        ctx.segments = &segments[..5];
3113        assert_lookups_scan(&ctx, &segments[..5]);
3114    }
3115}