Skip to main content

automapper_validation/validator/
validate.rs

1//! Main EdifactValidator implementation.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser, StatusKind};
6
7use crate::eval::answer_lists::{answer_path, is_answer_list_id};
8use crate::eval::{
9    AnswerCodeLists, ConditionEvaluator, ConditionExprEvaluator, ConditionResult,
10    EvaluationContext, ExternalConditionProvider, GroupScope, StatusResolution,
11};
12use mig_types::navigator::GroupNavigator;
13use mig_types::segment::OwnedSegment;
14
15use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
16
17use super::issue::{Severity, ValidationIssue};
18use super::level::ValidationLevel;
19use super::report::ValidationReport;
20use crate::{IssueKind, UnresolvedConditions};
21
22// The AHB rulebook data types (`AhbFieldRule`, `AhbCodeRule`, `AhbWorkflow`) now
23// live in the `ahb-types` crate so both this validation crate and `mig-bo4e`
24// (which embeds `AhbWorkflow` in the distribution bundle) can share them as
25// serializable data — without a mapping↔validation dependency edge. Re-exported
26// here so existing `crate::validator::validate::Ahb*` paths keep resolving.
27pub use ahb_types::{AhbCodeRule, AhbFieldRule, AhbWorkflow};
28
29/// Validates EDIFACT messages against AHB business rules.
30///
31/// The validator is a pure validation engine: it receives pre-parsed
32/// segments, an AHB workflow, and an external condition provider.
33/// Parsing and message-type detection are the caller's responsibility.
34///
35/// The validator is generic over the `ConditionEvaluator` implementation,
36/// which is typically generated from AHB XML schemas.
37///
38/// # Example
39///
40/// ```ignore
41/// use automapper_validation::validator::EdifactValidator;
42/// use automapper_validation::eval::NoOpExternalProvider;
43///
44/// let evaluator = UtilmdConditionEvaluatorFV2510::new();
45/// let validator = EdifactValidator::new(evaluator);
46/// let external = NoOpExternalProvider;
47///
48/// let report = validator.validate(
49///     &segments,
50///     &ahb_workflow,
51///     &external,
52///     ValidationLevel::Full,
53/// );
54///
55/// if !report.is_valid() {
56///     for error in report.errors() {
57///         eprintln!("{error}");
58///     }
59/// }
60/// ```
61pub struct EdifactValidator<E: ConditionEvaluator> {
62    evaluator: E,
63    report_segments: bool,
64}
65
66impl<E: ConditionEvaluator> EdifactValidator<E> {
67    /// Create a new validator with the given condition evaluator.
68    pub fn new(evaluator: E) -> Self {
69        Self {
70            evaluator,
71            report_segments: false,
72        }
73    }
74
75    /// Also report segments sent although their own condition does not hold
76    /// ([`IssueKind::SegmentNotAllowed`]). Off by default: the fixture
77    /// generator uses it to repair its samples (#258); for messages in
78    /// general it still reports what some approximate condition evaluators
79    /// get wrong (`[401]` ignores its Zeitraum-ID).
80    pub fn reporting_segments(mut self) -> Self {
81        self.report_segments = true;
82        self
83    }
84
85    /// Validate pre-parsed EDIFACT segments against an AHB workflow.
86    ///
87    /// # Arguments
88    ///
89    /// * `segments` - Pre-parsed EDIFACT segments
90    /// * `workflow` - AHB workflow definition for the PID
91    /// * `external` - Provider for external conditions
92    /// * `level` - Validation strictness level
93    ///
94    /// # Returns
95    ///
96    /// A `ValidationReport` with all issues found.
97    pub fn validate(
98        &self,
99        segments: &[OwnedSegment],
100        workflow: &AhbWorkflow,
101        external: &dyn ExternalConditionProvider,
102        level: ValidationLevel,
103    ) -> ValidationReport {
104        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
105            .with_format_version(self.evaluator.format_version())
106            .with_pruefidentifikator(&workflow.pruefidentifikator);
107
108        let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
109
110        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
111            self.validate_conditions(workflow, &ctx, &mut report);
112        }
113
114        report
115    }
116
117    /// Validate with a group navigator for group-scoped condition queries.
118    ///
119    /// Same as [`validate`] but passes a `GroupNavigator` to the
120    /// `EvaluationContext`, enabling conditions to query segments within
121    /// specific group instances (e.g., "in derselben SG8").
122    pub fn validate_with_navigator(
123        &self,
124        segments: &[OwnedSegment],
125        workflow: &AhbWorkflow,
126        external: &dyn ExternalConditionProvider,
127        level: ValidationLevel,
128        navigator: &dyn GroupNavigator,
129    ) -> ValidationReport {
130        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
131            .with_format_version(self.evaluator.format_version())
132            .with_pruefidentifikator(&workflow.pruefidentifikator);
133
134        let ctx = EvaluationContext::with_navigator(
135            &workflow.pruefidentifikator,
136            external,
137            segments,
138            navigator,
139        );
140
141        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
142            self.validate_conditions(workflow, &ctx, &mut report);
143        }
144
145        report
146    }
147
148    /// Validate using a pre-built ValidatedTree where each node carries
149    /// its resolved EDIFACT value.
150    ///
151    /// This is the preferred validation path -- conditions see `ctx.resolved_value`
152    /// set from the tree node, so format conditions like [931] "ZZZ=+00" check
153    /// the correct segment instance (e.g., DTM+92, not DTM+137).
154    pub fn validate_tree(
155        &self,
156        validated_tree: &ValidatedTree,
157        segments: &[OwnedSegment],
158        external: &dyn ExternalConditionProvider,
159        level: ValidationLevel,
160        navigator: Option<&dyn GroupNavigator>,
161    ) -> ValidationReport {
162        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
163            .with_format_version(self.evaluator.format_version())
164            .with_pruefidentifikator(validated_tree.pruefidentifikator);
165
166        if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
167            return report;
168        }
169
170        let ctx = match navigator {
171            Some(nav) => EvaluationContext::with_navigator(
172                validated_tree.pruefidentifikator,
173                external,
174                segments,
175                nav,
176            ),
177            None => EvaluationContext::new(validated_tree.pruefidentifikator, external, segments),
178        };
179
180        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
181
182        // Collect all nodes depth-first from the tree (used for tag_migs
183        // precomputation and for the synthetic workflow at the bottom).
184        let mut all_nodes: Vec<&AhbNode> = Vec::new();
185        all_nodes.extend(validated_tree.root_fields.iter());
186        for group in &validated_tree.groups {
187            collect_nodes_depth_first(group, &mut all_nodes);
188        }
189
190        // For each segment tag, collect all distinct mig_numbers the workflow
191        // defines. Tags with a single mig variant (UNT/UNS/UNZ in MSCONS) let
192        // us fall back to a flat-segment check when assembly didn't claim the
193        // segment; tags with multiple variants (DTM with +92/+93 in UTILMD)
194        // must not, because a different variant's segment may satisfy
195        // `is_field_present` while the rule's specific variant is absent.
196        let mut tag_migs: HashMap<String, HashSet<&str>> = HashMap::new();
197        for node in &all_nodes {
198            if let Some(ref m) = node.rule.mig_number {
199                tag_migs
200                    .entry(extract_segment_id(&node.rule.segment_path))
201                    .or_default()
202                    .insert(m.as_str());
203            }
204        }
205        for rule in &validated_tree.unmatched_rules {
206            if let Some(ref m) = rule.mig_number {
207                tag_migs
208                    .entry(extract_segment_id(&rule.segment_path))
209                    .or_default()
210                    .insert(m.as_str());
211            }
212        }
213
214        // Evaluate root-level fields (not inside any group) with message-wide scope.
215        for node in &validated_tree.root_fields {
216            evaluate_node(
217                node,
218                &ctx,
219                &expr_eval,
220                &self.evaluator,
221                validated_tree.ub_definitions,
222                &tag_migs,
223                None,
224                &mut report,
225            );
226        }
227
228        // Walk top-level groups with per-instance scope. `validated_tree.groups`
229        // is a flat Vec<AhbGroupNode> where each entry corresponds to one
230        // group repetition; compute the instance index by counting preceding
231        // entries with the same group_id.
232        let mut instance_counter: HashMap<&str, usize> = HashMap::new();
233        for group in &validated_tree.groups {
234            let instance_index = *instance_counter
235                .entry(group.group_id)
236                .and_modify(|c| *c += 1)
237                .or_insert(0);
238
239            let path_storage = [(group.group_id, instance_index)];
240            // Build a scoped context without consuming `ctx` (used again for
241            // the synthetic-workflow checks at the end of this method).
242            let scoped_ctx = ctx.with_scope(GroupScope {
243                path: &path_storage,
244            });
245
246            walk_group_instance(
247                group,
248                &path_storage,
249                &scoped_ctx,
250                &expr_eval,
251                &self.evaluator,
252                validated_tree.ub_definitions,
253                &tag_migs,
254                instance_index,
255                self.report_segments,
256                &mut report,
257            );
258        }
259
260        // Evaluate rules whose mig_number wasn't matched to any tree node.
261        // These represent entirely-absent group variants (e.g., NAD+MS when
262        // only NAD+MR is present). Use the flat is_field_present/is_group_variant_absent
263        // logic which correctly distinguishes mandatory-but-missing variants from
264        // optional-and-absent ones.
265        for field in &validated_tree.unmatched_rules {
266            if should_skip_for_parent_group(field, &expr_eval, &ctx, validated_tree.ub_definitions)
267            {
268                continue;
269            }
270
271            let status =
272                expr_eval.resolve_status(&field.ahb_status, &ctx, validated_tree.ub_definitions);
273
274            if status.result == ConditionResult::True
275                && status.kind == Some(StatusKind::Muss)
276                && !is_field_present(&ctx, field)
277                && !is_group_variant_absent(
278                    &ctx,
279                    field,
280                    is_parent_group_required(
281                        field,
282                        &expr_eval,
283                        &ctx,
284                        validated_tree.ub_definitions,
285                    ),
286                )
287            {
288                let mut issue = ValidationIssue::new(
289                    Severity::Error,
290                    IssueKind::MissingRequiredField {
291                        field_name: field.name.clone(),
292                    },
293                )
294                .with_field_path(&field.segment_path)
295                .with_rule(&field.ahb_status);
296                if let Some(first_code) = field.codes.first() {
297                    issue.expected_value = Some(first_code.value.clone());
298                }
299                issue = attach_field_position(issue, field);
300                report.add_issue(issue);
301            }
302        }
303
304        // Run cross-field code validation and package cardinality checks.
305        // These operate on flat field rules (no resolved_value needed),
306        // so we reconstruct a lightweight workflow from the tree's rules.
307        if matches!(level, ValidationLevel::Full) {
308            let mut all_fields: Vec<AhbFieldRule> = Vec::new();
309            for node in &all_nodes {
310                all_fields.push(node.rule.clone());
311            }
312            for rule in &validated_tree.unmatched_rules {
313                all_fields.push((*rule).clone());
314            }
315            let synthetic_workflow = AhbWorkflow {
316                pruefidentifikator: validated_tree.pruefidentifikator.to_string(),
317                description: String::new(),
318                communication_direction: None,
319                fields: all_fields,
320                ub_definitions: validated_tree.ub_definitions.clone(),
321            };
322            self.validate_codes_cross_field(&synthetic_workflow, &ctx, &mut report);
323            self.validate_answer_lists(&synthetic_workflow, &ctx, &mut report);
324            self.validate_package_cardinality(&synthetic_workflow, &ctx, &mut report);
325        }
326
327        report
328    }
329
330    /// Validate AHB conditions for each field in the workflow.
331    fn validate_conditions(
332        &self,
333        workflow: &AhbWorkflow,
334        ctx: &EvaluationContext,
335        report: &mut ValidationReport,
336    ) {
337        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
338
339        for field in &workflow.fields {
340            // Skip if parent group condition is not met (False or Unknown).
341            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
342                continue;
343            }
344
345            // Evaluate the AHB status condition expression, collecting
346            // which specific condition IDs are unknown when the result is Unknown.
347            let StatusResolution {
348                result: condition_result,
349                kind,
350                unknown_ids,
351            } = expr_eval.resolve_status(&field.ahb_status, ctx, &workflow.ub_definitions);
352            let mandatory = kind == Some(StatusKind::Muss);
353
354            match condition_result {
355                ConditionResult::True => {
356                    // Condition is met - field is required/applicable
357                    if mandatory
358                        && !is_field_present(ctx, field)
359                        && !is_group_variant_absent(
360                            ctx,
361                            field,
362                            is_parent_group_required(
363                                field,
364                                &expr_eval,
365                                ctx,
366                                &workflow.ub_definitions,
367                            ),
368                        )
369                    {
370                        let mut issue = ValidationIssue::new(
371                            Severity::Error,
372                            IssueKind::MissingRequiredField {
373                                field_name: field.name.clone(),
374                            },
375                        )
376                        .with_field_path(&field.segment_path)
377                        .with_rule(&field.ahb_status);
378                        // Store the first expected code for BO4E path disambiguation.
379                        // E.g., DTM+93's code "93" helps resolve SG4/DTM/C507/2005
380                        // to Prozessdaten.gueltigBis vs gueltigAb.
381                        if let Some(first_code) = field.codes.first() {
382                            issue.expected_value = Some(first_code.value.clone());
383                        }
384                        report.add_issue(issue);
385                    }
386                }
387                ConditionResult::False => {
388                    // Condition not met.
389                    // If the field has a mandatory prefix (X, Muss) AND the field is
390                    // present, the condition is a format/value constraint that the
391                    // value violates. E.g., "X [UB1]" where UB1 checks timezone
392                    // format — the field is required, but its value is wrong.
393                    if mandatory && is_field_present(ctx, field) {
394                        report.add_issue(
395                            ValidationIssue::new(
396                                Severity::Error,
397                                IssueKind::FieldConditionNotSatisfied {
398                                    field_name: field.name.clone(),
399                                },
400                            )
401                            .with_field_path(&field.segment_path)
402                            .with_rule(&field.ahb_status),
403                        );
404                    }
405                }
406                ConditionResult::Unknown => {
407                    // Partition unknown IDs into three categories:
408                    // 1. External: require an external provider (business context)
409                    // 2. Undetermined: implemented but returned Unknown (data not present)
410                    // 3. Missing: not implemented in evaluator at all
411                    let mut external_ids = Vec::new();
412                    let mut undetermined_ids = Vec::new();
413                    let mut missing_ids = Vec::new();
414                    for id in unknown_ids {
415                        if self.evaluator.is_external(id) {
416                            external_ids.push(id);
417                        } else if self.evaluator.is_known(id) {
418                            undetermined_ids.push(id);
419                        } else {
420                            missing_ids.push(id);
421                        }
422                    }
423
424                    report.add_issue(
425                        ValidationIssue::new(
426                            Severity::Info,
427                            IssueKind::ConditionUnknown {
428                                field_name: field.name.clone(),
429                                unresolved: UnresolvedConditions {
430                                    external: external_ids,
431                                    undetermined: undetermined_ids,
432                                    missing: missing_ids,
433                                },
434                            },
435                        )
436                        .with_field_path(&field.segment_path)
437                        .with_rule(&field.ahb_status),
438                    );
439                }
440            }
441        }
442
443        // Cross-field code validation: aggregate allowed codes across all field
444        // rules sharing the same segment path, then check each segment instance
445        // against the combined set. This avoids false positives from per-field
446        // validation (e.g., NAD/3035 with [MS] for sender and [MR] for receiver).
447        self.validate_codes_cross_field(workflow, ctx, report);
448        self.validate_answer_lists(workflow, ctx, report);
449
450        // Package cardinality post-processing: check that the count of codes
451        // present from each package group falls within [min..max] bounds.
452        self.validate_package_cardinality(workflow, ctx, report);
453    }
454
455    /// Validate package cardinality constraints across all field rules.
456    ///
457    /// Scans each code's `ahb_status` for `[NP_min..max]` package references,
458    /// groups codes by `(segment_path, package_id)`, counts how many of the
459    /// package's codes are actually present in the segment, and emits AHB006
460    /// if the count falls outside `[min..max]`.
461    fn validate_package_cardinality(
462        &self,
463        workflow: &AhbWorkflow,
464        ctx: &EvaluationContext,
465        report: &mut ValidationReport,
466    ) {
467        // Collect package groups: (segment_path, package_id) -> (min, max, Vec<code_value>)
468        // Also store (element_index, component_index) for looking up the value in the segment.
469        struct PackageGroup {
470            min: u32,
471            max: u32,
472            /// The sum of the codes' own maxima, and the distinct bounds the
473            /// codes give (several: the sum is the package's maximum).
474            max_sum: u32,
475            bounds: BTreeSet<(u32, u32)>,
476            code_values: Vec<String>,
477            element_index: usize,
478            component_index: usize,
479        }
480
481        // Key: (segment_path, mig_number, package_id).
482        //
483        // `mig_number` scopes the package to a specific MIG segment variant.
484        // Example: in MSCONS SG10, STS+Z32 and STS+Z40 are separate variants
485        // that both declare `[4P0..1]` on component 9013, but they refer to
486        // disjoint packages (Z32's Statusanlaß codes vs Z40's). Without the
487        // mig_number key, both variants' codes merge into one "super-package"
488        // and any real message using both STS types fails cardinality.
489        let mut groups: HashMap<(String, Option<String>, u32, u32, u32), PackageGroup> =
490            HashMap::new();
491        // Packages a code's status offers as alternatives (`X [19P1..1] ⊻
492        // [20P1..1] ⊻ [22P1..1]`, UTILMD SG10 CAV Verwendungszweck): the
493        // instance holds to one of them, not to each.
494        let mut alternatives: HashMap<(String, Option<String>, u32), BTreeSet<u32>> =
495            HashMap::new();
496
497        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
498
499        for field in &workflow.fields {
500            let el_idx = field.element_index.unwrap_or(0);
501            let comp_idx = field.component_index.unwrap_or(0);
502
503            // Skip package codes whose parent group condition is False or Unknown.
504            if should_skip_for_parent_group(field, &expr_eval, ctx, &workflow.ub_definitions) {
505                continue;
506            }
507
508            for code in &field.codes {
509                // Parse the code's ahb_status for Package nodes
510                if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
511                    // Walk the AST to find Package nodes
512                    let mut packages = Vec::new();
513                    collect_packages(&expr, &mut packages);
514
515                    if packages.len() > 1 {
516                        let ids: BTreeSet<u32> = packages.iter().map(|(id, _, _)| *id).collect();
517                        for id in &ids {
518                            alternatives
519                                .entry((field.segment_path.clone(), field.mig_number.clone(), *id))
520                                .or_default()
521                                .extend(ids.iter().copied().filter(|other| other != id));
522                        }
523                    }
524
525                    for (pkg_id, pkg_min, pkg_max) in packages {
526                        let key = (
527                            field.segment_path.clone(),
528                            field.mig_number.clone(),
529                            pkg_id,
530                            0,
531                            0,
532                        );
533                        let group = groups.entry(key).or_insert_with(|| PackageGroup {
534                            min: pkg_min,
535                            max: pkg_max,
536                            max_sum: 0,
537                            bounds: BTreeSet::new(),
538                            code_values: Vec::new(),
539                            element_index: el_idx,
540                            component_index: comp_idx,
541                        });
542                        // Codes of one package with different bounds (UTILMD
543                        // CCI 4051 `Z01 [1P0..4]`, `Z02 [1P1..1]`; CAV
544                        // `Z85 [1P0..1]`, `ZB5 [1P1..1]`) leave the package's
545                        // own bounds open. The widest reading is taken — the
546                        // lowest minimum, and as many codes as each may occur
547                        // — so a message is not refused on a guess.
548                        group.bounds.insert((pkg_min, pkg_max));
549                        group.min = group.min.min(pkg_min);
550                        if !group.code_values.contains(&code.value) {
551                            group.max_sum = group.max_sum.saturating_add(pkg_max);
552                        }
553                        group.max = group.max.max(pkg_max);
554                        group.code_values.push(code.value.clone());
555                    }
556                }
557            }
558        }
559
560        // For each package group, count present codes and check bounds.
561        //
562        // Package cardinality like `[4P0..1]` limits the number of codes that
563        // may appear *within a single group instance*, not message-wide. When
564        // a navigator is available and the field has a group path, iterate
565        // per-instance and check each independently; otherwise fall back to
566        // the message-wide count (used by flat-segment test fixtures).
567        // Where a field's codes offer packages as alternatives at all, every
568        // package of the field is one: `ZE1 [38P1..1]` ("kein
569        // Verwendungszweck") is the other choice to `Z47 [19P1..1] ⊻ …`.
570        let mut field_packages: HashMap<(String, Option<String>), BTreeSet<u32>> = HashMap::new();
571        for (seg_path, mig_number, pkg_id, _, _) in groups.keys() {
572            field_packages
573                .entry((seg_path.clone(), mig_number.clone()))
574                .or_default()
575                .insert(*pkg_id);
576        }
577        let with_alternatives: Vec<(String, Option<String>)> = alternatives
578            .keys()
579            .map(|(p, m, _)| (p.clone(), m.clone()))
580            .collect();
581        for field in with_alternatives {
582            let Some(ids) = field_packages.get(&field) else {
583                continue;
584            };
585            for id in ids {
586                alternatives
587                    .entry((field.0.clone(), field.1.clone(), *id))
588                    .or_default()
589                    .extend(ids.iter().copied().filter(|other| other != id));
590            }
591        }
592
593        // Per package: its counts per instance, its bounds and its codes.
594        type Counted = (Vec<usize>, usize, usize, Vec<String>);
595        let mut counted: HashMap<(String, Option<String>, u32, u32, u32), Counted> = HashMap::new();
596        for ((seg_path, mig_number, pkg_id, _, _), group) in &groups {
597            let segment_id = extract_segment_id(seg_path);
598
599            // Deduplicate code values: the synthetic workflow built from a
600            // validated tree duplicates rules once per matched node, which
601            // otherwise inflates the error message's code list.
602            let mut unique_codes: Vec<&str> =
603                group.code_values.iter().map(|s| s.as_str()).collect();
604            unique_codes.sort_unstable();
605            unique_codes.dedup();
606            let code_set: HashSet<&str> = unique_codes.iter().copied().collect();
607
608            let group_path_str = extract_group_path_key(seg_path);
609            let group_path: Vec<&str> = if group_path_str.is_empty() {
610                Vec::new()
611            } else {
612                group_path_str.split('/').collect()
613            };
614
615            let min = group.min as usize;
616            let max = if group.bounds.len() > 1 {
617                group.max_sum as usize
618            } else {
619                group.max as usize
620            };
621
622            let per_instance_counts: Option<Vec<usize>> =
623                match (ctx.navigator, group_path.is_empty()) {
624                    // A group under a repeating parent (PARTIN `SG4/SG7`):
625                    // every instance at every level. The innermost index
626                    // reaches children of the first parent only, so with an
627                    // SG4 that has no SG7 first, the count fell back to the
628                    // whole message — 12 COMs against `[1P1..1]`.
629                    // A navigator that addresses only the innermost level finds
630                    // no instance this way and takes the arm below.
631                    (Some(nav), false)
632                        if group_path.len() > 1
633                            && !full_instance_paths(nav, &group_path).is_empty() =>
634                    {
635                        let paths = full_instance_paths(nav, &group_path);
636                        // A merged group's instance carries every variant's
637                        // MIG numbers; its own variant is the one whose entry
638                        // code its entry segment holds (PARTIN SG12: the
639                        // `CCI+Z40` variant's DTM package does not apply to a
640                        // `CCI+Z19` instance).
641                        // At every level: an SG10 package of the SG8
642                        // `SEQ+Z01` variant does not apply under a `SEQ+Z03`.
643                        let variant_entries: Vec<(usize, VariantEntry)> = match mig_number
644                            .as_deref()
645                        {
646                            Some(m) => (0..group_path.len())
647                                .filter_map(|level| {
648                                    let prefix = group_path[..=level].join("/");
649                                    merged_variant_entry(workflow, &prefix, m).map(|e| (level, e))
650                                })
651                                .collect(),
652                            None => Vec::new(),
653                        };
654                        if paths.is_empty() {
655                            None
656                        } else {
657                            Some(
658                                paths
659                                    .iter()
660                                    .filter(|path| match mig_number.as_deref() {
661                                        Some(m) => nav.instance_at_has_mig_number(path, m),
662                                        None => true,
663                                    })
664                                    .filter(|path| {
665                                        variant_entries.iter().all(|(level, entry)| {
666                                            nav.find_segments_in_instance(
667                                                &entry.tag,
668                                                &path[..=*level],
669                                            )
670                                            .first()
671                                            .and_then(|seg| {
672                                                seg.elements
673                                                    .get(entry.element)
674                                                    .and_then(|e| e.get(entry.component))
675                                                    .cloned()
676                                            })
677                                            .map_or(true, |v| entry.codes.contains(v.as_str()))
678                                        })
679                                    })
680                                    .map(|path| {
681                                        nav.find_segments_in_instance(&segment_id, path)
682                                            .iter()
683                                            .filter_map(|seg| {
684                                                seg.elements
685                                                    .get(group.element_index)
686                                                    .and_then(|e| e.get(group.component_index))
687                                                    .filter(|v| !v.is_empty())
688                                                    .cloned()
689                                            })
690                                            .filter(|v| code_set.contains(v.as_str()))
691                                            .count()
692                                    })
693                                    .collect(),
694                            )
695                        }
696                    }
697                    (Some(nav), false) => {
698                        let instance_count = nav.group_instance_count(&group_path);
699                        if instance_count == 0 {
700                            None
701                        } else {
702                            // Restrict the check to instances whose variant
703                            // declares the rule's mig_number. Without this, a
704                            // rule from one SG variant (e.g. PID 55218 SEQ
705                            // mig=00171 for sg8_z45_z84) is counted against
706                            // sibling variants (sg8_z01 with SEQ mig=00115)
707                            // and fires "0 codes present" on those. Rules
708                            // with no mig_number stay unscoped.
709                            Some(
710                                (0..instance_count)
711                                    .filter(|i| match mig_number.as_deref() {
712                                        Some(m) => nav.instance_has_mig_number(&group_path, *i, m),
713                                        None => true,
714                                    })
715                                    .map(|i| {
716                                        nav.find_segments_in_group(&segment_id, &group_path, i)
717                                            .iter()
718                                            .filter_map(|seg| {
719                                                seg.elements
720                                                    .get(group.element_index)
721                                                    .and_then(|e| e.get(group.component_index))
722                                                    .filter(|v| !v.is_empty())
723                                                    .cloned()
724                                            })
725                                            .filter(|v| code_set.contains(v.as_str()))
726                                            .count()
727                                    })
728                                    .collect(),
729                            )
730                        }
731                    }
732                    _ => None,
733                };
734
735            let counts: Vec<usize> = per_instance_counts.unwrap_or_else(|| {
736                let segments = ctx.find_segments(&segment_id);
737                let count = segments
738                    .iter()
739                    .filter_map(|seg| {
740                        seg.elements
741                            .get(group.element_index)
742                            .and_then(|e| e.get(group.component_index))
743                            .filter(|v| !v.is_empty())
744                            .map(|s| s.as_str())
745                    })
746                    .filter(|v| code_set.contains(v))
747                    .count();
748                vec![count]
749            });
750
751            counted.insert(
752                (
753                    seg_path.clone(),
754                    mig_number.clone(),
755                    *pkg_id,
756                    group.min,
757                    group.max,
758                ),
759                (
760                    counts,
761                    min,
762                    max,
763                    unique_codes.iter().map(|s| s.to_string()).collect(),
764                ),
765            );
766        }
767
768        // Emit one issue per out-of-bounds instance — unless an alternative
769        // package holds there. Dedup by count so a single message-wide
770        // overflow doesn't produce N copies of the same error for repeated
771        // groups.
772        let mut keys: Vec<&(String, Option<String>, u32, u32, u32)> = counted.keys().collect();
773        keys.sort();
774        for key in keys {
775            let (counts, min, max, codes) = &counted[key];
776            let (seg_path, mig_number, pkg_id, _, _) = key;
777            let alternative_holds = |i: usize| {
778                alternatives
779                    .get(&(seg_path.clone(), mig_number.clone(), *pkg_id))
780                    .is_some_and(|alts| {
781                        counted.iter().any(|((p, m, id, _, _), (c, lo, hi, _))| {
782                            p == seg_path
783                                && m == mig_number
784                                && alts.contains(id)
785                                && c.len() == counts.len()
786                                && (*lo..=*hi).contains(&c[i])
787                                && c[i] > 0
788                        })
789                    })
790            };
791            let mut reported_counts: HashSet<usize> = HashSet::new();
792            for (i, &present_count) in counts.iter().enumerate() {
793                if (present_count < *min || present_count > *max)
794                    && !alternative_holds(i)
795                    && reported_counts.insert(present_count)
796                {
797                    report.add_issue(
798                        ValidationIssue::new(
799                            Severity::Error,
800                            IssueKind::PackageCardinality {
801                                package_id: pkg_id.to_string(),
802                                present: present_count,
803                                min: *min,
804                                max: *max,
805                                codes: codes.clone(),
806                            },
807                        )
808                        .with_field_path(seg_path)
809                        .with_expected(format!("{min}..{max}"))
810                        .with_actual(present_count.to_string()),
811                    );
812                }
813            }
814        }
815    }
816
817    /// Validate qualifier codes by aggregating allowed values across field rules.
818    ///
819    /// When a group navigator is available, codes are grouped by segment path
820    /// (e.g., `SG2/NAD/3035` → `{MS, MR}`) and segments are looked up within
821    /// the specific group, giving precise per-group validation.
822    ///
823    /// Without a navigator, falls back to grouping by segment tag and unioning
824    /// all codes across groups to avoid cross-group false positives.
825    ///
826    /// Only validates simple qualifier paths (`[SG/]*/SEG/ELEMENT`) where the
827    /// code is in `element[0][0]`. Composite paths are skipped.
828    fn validate_codes_cross_field(
829        &self,
830        workflow: &AhbWorkflow,
831        ctx: &EvaluationContext,
832        report: &mut ValidationReport,
833    ) {
834        if ctx.navigator.is_some() {
835            self.validate_codes_group_scoped(workflow, ctx, report);
836        } else {
837            self.validate_codes_tag_scoped(workflow, ctx, report);
838        }
839    }
840
841    /// Group-scoped code validation: group codes by path, use navigator to
842    /// find segments within each group, check against that group's codes only.
843    fn validate_codes_group_scoped(
844        &self,
845        workflow: &AhbWorkflow,
846        ctx: &EvaluationContext,
847        report: &mut ValidationReport,
848    ) {
849        let by_loc = partition_codes_by_mig(workflow);
850        let known_qualifiers = global_qualifiers_by_tag(&by_loc);
851        let nav = ctx.navigator.unwrap();
852
853        for ((group_key, tag), migs) in &by_loc {
854            let field_path = if group_key.is_empty() {
855                format!("{tag}/qualifier")
856            } else {
857                format!("{group_key}/{tag}/qualifier")
858            };
859
860            let group_path: Vec<&str> = if group_key.is_empty() {
861                Vec::new()
862            } else {
863                group_key.split('/').collect()
864            };
865
866            let tag_qualifiers = known_qualifiers.get(tag);
867
868            if group_path.is_empty() {
869                Self::validate_segments_per_mig(
870                    &ctx.find_segments(tag),
871                    migs,
872                    tag_qualifiers,
873                    tag,
874                    &field_path,
875                    report,
876                );
877            } else {
878                let instance_count = nav.group_instance_count(&group_path);
879                for i in 0..instance_count {
880                    let owned = nav.find_segments_in_group(tag, &group_path, i);
881                    let refs: Vec<&OwnedSegment> = owned.iter().collect();
882                    Self::validate_segments_per_mig(
883                        &refs,
884                        migs,
885                        tag_qualifiers,
886                        tag,
887                        &field_path,
888                        report,
889                    );
890                }
891            }
892        }
893    }
894
895    /// Fallback: no navigator — validate segments flat by tag. Still partitions
896    /// codes by mig_number so segments are checked only against codes from their
897    /// matching mig.
898    fn validate_codes_tag_scoped(
899        &self,
900        workflow: &AhbWorkflow,
901        ctx: &EvaluationContext,
902        report: &mut ValidationReport,
903    ) {
904        let by_loc = partition_codes_by_mig(workflow);
905        let known_qualifiers = global_qualifiers_by_tag(&by_loc);
906        // Merge entries that share a tag (tag-scoped ignores group_key).
907        let mut by_tag: HashMap<String, HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
908        for ((_group_key, tag), migs) in by_loc {
909            let merged = by_tag.entry(tag).or_default();
910            for (mig_key, bucket) in migs {
911                let entry = merged.entry(mig_key).or_default();
912                if entry.qualifier_position.is_none() {
913                    entry.qualifier_position = bucket.qualifier_position;
914                }
915                if entry.qualifier_position == bucket.qualifier_position {
916                    entry.qualifier_values.extend(&bucket.qualifier_values);
917                }
918                for (pos, codes) in bucket.codes {
919                    entry.codes.entry(pos).or_default().extend(codes);
920                }
921            }
922        }
923
924        for (tag, migs) in &by_tag {
925            let field_path = format!("{tag}/qualifier");
926            Self::validate_segments_per_mig(
927                &ctx.find_segments(tag),
928                migs,
929                known_qualifiers.get(tag),
930                tag,
931                &field_path,
932                report,
933            );
934        }
935    }
936
937    /// For each segment, match it to a mig_number via its primary qualifier and
938    /// validate only that mig's code constraints.
939    ///
940    /// `tag_qualifiers` is the set of all qualifier values this tag uses anywhere
941    /// in the workflow, keyed by position. When no mig at the current location
942    /// matches a segment, but the segment's qualifier IS valid somewhere else for
943    /// this tag, we skip it (the segment belongs to another location that's
944    /// handled separately). Only truly unknown qualifier values are flagged.
945    fn validate_segments_per_mig(
946        segments: &[&OwnedSegment],
947        migs: &HashMap<Option<String>, MigCodeBucket>,
948        tag_qualifiers: Option<&HashMap<(usize, usize), HashSet<String>>>,
949        tag: &str,
950        field_path: &str,
951        report: &mut ValidationReport,
952    ) {
953        for seg in segments {
954            match match_segment_to_mig(seg, migs) {
955                Some(bucket) => {
956                    for ((el, c), allowed) in &bucket.codes {
957                        if allowed.is_empty() {
958                            continue;
959                        }
960                        Self::check_segments_against_codes(
961                            vec![*seg],
962                            allowed,
963                            tag,
964                            *el,
965                            *c,
966                            field_path,
967                            report,
968                        );
969                    }
970                }
971                None => {
972                    let Some(qualifiers) = tag_qualifiers else {
973                        continue;
974                    };
975                    // Find a qualifier position where this segment has a value.
976                    // If the value matches any known qualifier for this tag (at any
977                    // location), the segment belongs elsewhere — skip. Otherwise
978                    // report an invalid qualifier against the union at this position.
979                    for ((el, c), allowed) in qualifiers {
980                        let Some(actual) = seg
981                            .elements
982                            .get(*el)
983                            .and_then(|e| e.get(*c))
984                            .filter(|v| !v.is_empty())
985                            .map(|s| s.as_str())
986                        else {
987                            continue;
988                        };
989                        if allowed.iter().any(|v| v == actual) {
990                            // Qualifier is valid at some location — skip here.
991                            break;
992                        }
993                        // Truly unknown qualifier value — report against this
994                        // location's bucket union if it has one.
995                        let bucket_values: HashSet<&str> = migs
996                            .values()
997                            .filter(|b| b.qualifier_position == Some((*el, *c)))
998                            .flat_map(|b| b.qualifier_values.iter().copied())
999                            .collect();
1000                        if !bucket_values.is_empty() {
1001                            Self::check_segments_against_codes(
1002                                vec![*seg],
1003                                &bucket_values,
1004                                tag,
1005                                *el,
1006                                *c,
1007                                field_path,
1008                                report,
1009                            );
1010                            break;
1011                        }
1012                    }
1013                }
1014            }
1015        }
1016    }
1017
1018    /// A response's answer must be one of the codes of the EBD or Codeliste it
1019    /// names: AJT 4465 of the list in AJT 1082, STS C556/9013 of the list in
1020    /// C556/1131 ([`IssueKind::AnswerNotInList`]). A warning — the AHB, which
1021    /// the other checks enforce, does not say what a list holds.
1022    ///
1023    /// Silent when the list is not in the data, the answer is empty or one of
1024    /// the AHB's own codes for the element, or the list element holds
1025    /// something other than a list the rule allows.
1026    fn validate_answer_lists(
1027        &self,
1028        workflow: &AhbWorkflow,
1029        ctx: &EvaluationContext,
1030        report: &mut ValidationReport,
1031    ) {
1032        let lists = AnswerCodeLists::embedded();
1033        let fv = self.evaluator.format_version();
1034        let mut seen: HashSet<(*const OwnedSegment, usize, usize)> = HashSet::new();
1035        for list_rule in &workflow.fields {
1036            let Some(answer_path) = answer_path(&list_rule.segment_path) else {
1037                continue;
1038            };
1039            if list_rule.codes.is_empty()
1040                || !list_rule.codes.iter().all(|c| is_answer_list_id(&c.value))
1041            {
1042                continue;
1043            }
1044            let Some(answer_rule) = workflow
1045                .fields
1046                .iter()
1047                .find(|f| f.segment_path == answer_path && f.mig_number == list_rule.mig_number)
1048            else {
1049                continue;
1050            };
1051            let at = |rule: &AhbFieldRule| {
1052                (
1053                    rule.element_index.unwrap_or(0),
1054                    rule.component_index.unwrap_or(0),
1055                )
1056            };
1057            let (list_el, list_c) = at(list_rule);
1058            let (answer_el, answer_c) = at(answer_rule);
1059            let value = |seg: &OwnedSegment, el: usize, c: usize| {
1060                seg.elements
1061                    .get(el)
1062                    .and_then(|e| e.get(c))
1063                    .filter(|v| !v.is_empty())
1064                    .cloned()
1065            };
1066            for seg in ctx.find_segments(&extract_segment_id(&list_rule.segment_path)) {
1067                if !seen.insert((seg as *const OwnedSegment, list_el, list_c)) {
1068                    continue;
1069                }
1070                let (Some(list), Some(answer)) =
1071                    (value(seg, list_el, list_c), value(seg, answer_el, answer_c))
1072                else {
1073                    continue;
1074                };
1075                if !list_rule.codes.iter().any(|c| c.value == list) {
1076                    continue;
1077                }
1078                // An answer the AHB itself lists for the element is judged by
1079                // the AHB (IFTSTA 21025: 9013 `Z74` "kein Zugang" beside
1080                // `E_0286`, which holds only A-codes).
1081                if answer_rule.codes.iter().any(|c| c.value == answer) {
1082                    continue;
1083                }
1084                let Some(codes) = lists.codes(fv, &list) else {
1085                    continue;
1086                };
1087                if codes.contains(&answer) {
1088                    continue;
1089                }
1090                let mut allowed: Vec<String> = codes.iter().cloned().collect();
1091                allowed.sort_unstable();
1092                report.add_issue(
1093                    ValidationIssue::new(
1094                        Severity::Warning,
1095                        IssueKind::AnswerNotInList {
1096                            answer: answer.clone(),
1097                            list: list.clone(),
1098                            allowed: allowed.clone(),
1099                        },
1100                    )
1101                    .with_field_path(&answer_rule.segment_path)
1102                    .with_actual(&answer)
1103                    .with_expected(allowed.join(", ")),
1104                );
1105            }
1106        }
1107    }
1108
1109    /// Check a list of segments' qualifier at the given element/component index against allowed codes.
1110    fn check_segments_against_codes(
1111        segments: Vec<&OwnedSegment>,
1112        allowed_codes: &HashSet<&str>,
1113        _tag: &str,
1114        el_idx: usize,
1115        comp_idx: usize,
1116        field_path: &str,
1117        report: &mut ValidationReport,
1118    ) {
1119        for segment in segments {
1120            if let Some(code_value) = segment
1121                .elements
1122                .get(el_idx)
1123                .and_then(|e| e.get(comp_idx))
1124                .filter(|v| !v.is_empty())
1125            {
1126                if !allowed_codes.contains(code_value.as_str()) {
1127                    let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
1128                    sorted_codes.sort_unstable();
1129                    let kind = IssueKind::CodeNotAllowedForPid {
1130                        value: code_value.to_string(),
1131                        allowed: sorted_codes.iter().map(|s| (*s).to_string()).collect(),
1132                    };
1133                    report.add_issue(
1134                        ValidationIssue::new(Severity::Error, kind)
1135                            .with_field_path(field_path)
1136                            .with_actual(code_value)
1137                            .with_expected(sorted_codes.join(", ")),
1138                    );
1139                }
1140            }
1141        }
1142    }
1143}
1144
1145/// Every instance of the group at `group_path`, addressed at every level:
1146/// `[("SG4", 3), ("SG7", 0)]`, in message order.
1147fn full_instance_paths<'p>(
1148    nav: &dyn GroupNavigator,
1149    group_path: &[&'p str],
1150) -> Vec<Vec<(&'p str, usize)>> {
1151    let mut paths: Vec<Vec<(&'p str, usize)>> = vec![Vec::new()];
1152    for g in group_path {
1153        paths = paths
1154            .into_iter()
1155            .flat_map(|parent| {
1156                (0..nav.child_instance_count(&parent, g)).map(move |i| {
1157                    let mut path = parent.clone();
1158                    path.push((*g, i));
1159                    path
1160                })
1161            })
1162            .collect();
1163    }
1164    paths
1165}
1166
1167/// The entry rule of the variant a rule belongs to, in a group the PID filter
1168/// merged from several variants.
1169struct VariantEntry<'a> {
1170    tag: String,
1171    element: usize,
1172    component: usize,
1173    codes: HashSet<&'a str>,
1174}
1175
1176/// For the rule numbered `mig` in the group at `group_path` (`SG4/SG12`): the
1177/// entry rule of its variant, when the group is a merge of variants told
1178/// apart by their entry segment's codes. Variants occupy contiguous MIG
1179/// number ranges, so the variant is the one whose entry rule has the greatest
1180/// number not above `mig`. `None` for a group of one variant.
1181fn merged_variant_entry<'a>(
1182    workflow: &'a AhbWorkflow,
1183    group_path: &str,
1184    mig: &str,
1185) -> Option<VariantEntry<'a>> {
1186    let mig: u32 = mig.parse().ok()?;
1187    let prefix = format!("{group_path}/");
1188    // Direct rules of the group: `SG4/SG12/TAG/...`, no deeper group.
1189    let direct: Vec<(&AhbFieldRule, u32, &str)> = workflow
1190        .fields
1191        .iter()
1192        .filter_map(|r| {
1193            let rest = r.segment_path.strip_prefix(&prefix)?;
1194            let tag = rest.split('/').next()?;
1195            if tag.starts_with("SG") {
1196                return None;
1197            }
1198            Some((r, r.mig_number.as_deref()?.parse::<u32>().ok()?, tag))
1199        })
1200        .collect();
1201    // The entry tag is that of the group's first segment.
1202    let (_, _, entry_tag) = *direct.iter().min_by_key(|(_, n, _)| *n)?;
1203    let entries: Vec<&(&AhbFieldRule, u32, &str)> = direct
1204        .iter()
1205        .filter(|(r, _, tag)| *tag == entry_tag && !r.codes.is_empty())
1206        .collect();
1207    let numbers: HashSet<u32> = entries.iter().map(|(_, n, _)| *n).collect();
1208    if numbers.len() < 2 {
1209        return None;
1210    }
1211    let own = entries
1212        .iter()
1213        .filter(|(_, n, _)| *n <= mig)
1214        .map(|(_, n, _)| *n)
1215        .max()?;
1216    // The entry rule of that variant coding the first coded position.
1217    let (rule, _, tag) = entries
1218        .iter()
1219        .filter(|(_, n, _)| *n == own)
1220        .min_by_key(|(r, _, _)| (r.element_index.unwrap_or(0), r.component_index.unwrap_or(0)))?;
1221    Some(VariantEntry {
1222        tag: tag.to_string(),
1223        element: rule.element_index.unwrap_or(0),
1224        component: rule.component_index.unwrap_or(0),
1225        codes: rule.codes.iter().map(|c| c.value.as_str()).collect(),
1226    })
1227}
1228
1229/// The maximum length a MIG data format allows: `an..512` → 512, `n13` → 13,
1230/// `an3` → 3. `None` for anything else.
1231fn max_length(format: &str) -> Option<usize> {
1232    let digits = format
1233        .trim()
1234        .strip_prefix("an")
1235        .or_else(|| format.trim().strip_prefix('a'))
1236        .or_else(|| format.trim().strip_prefix('n'))?;
1237    digits.strip_prefix("..").unwrap_or(digits).parse().ok()
1238}
1239
1240/// The status that governs `field` when it carries `value`.
1241///
1242/// The PID schema gives a code element the status of its *first* code when the
1243/// AHB annotates only the codes (`D_9013` Transaktionsgrund: `E01` is
1244/// `X [192]`, `E03` is `X`; the element reads `X [192]`). Judged by that, a
1245/// message sending `E03` violates [192], which says nothing about `E03`. So a
1246/// present value that is one of the codes is judged by that code's own status —
1247/// but only where the element's status is evidently the first code's, so an
1248/// element with a condition of its own keeps it.
1249fn governing_status<'a>(field: &'a AhbFieldRule, value: Option<&str>) -> &'a str {
1250    let derived_from_codes = field
1251        .codes
1252        .first()
1253        .is_some_and(|c| c.ahb_status == field.ahb_status);
1254    if !derived_from_codes {
1255        return &field.ahb_status;
1256    }
1257    value
1258        .and_then(|v| field.codes.iter().find(|c| c.value == v))
1259        .map_or(&field.ahb_status, |c| &c.ahb_status)
1260}
1261
1262/// Check if the parent group's conditional status evaluates to False or Unknown.
1263///
1264/// Returns `true` if the field should be skipped (parent group condition not met).
1265/// Only evaluates when `parent_group_ahb_status` contains condition brackets `[`.
1266/// Simple statuses like "Kann" or "Muss" pass through (return `false`).
1267fn should_skip_for_parent_group<E: ConditionEvaluator>(
1268    field: &AhbFieldRule,
1269    expr_eval: &ConditionExprEvaluator<E>,
1270    ctx: &EvaluationContext,
1271    ub_definitions: &BTreeMap<String, ConditionExpr>,
1272) -> bool {
1273    if let Some(ref group_status) = field.parent_group_ahb_status {
1274        if group_status.contains('[') {
1275            let result = expr_eval.evaluate_status_with_ub(group_status, ctx, ub_definitions);
1276            return matches!(result, ConditionResult::False | ConditionResult::Unknown);
1277        }
1278    }
1279    false
1280}
1281
1282/// Whether the field's parent group is required here: the status that applies
1283/// to it (the strictest of its lines that holds) is `Muss`/`X`.
1284///
1285/// `should_skip_for_parent_group` has already dropped fields whose group does
1286/// not apply at all; this tells a required group from a recommended one, which
1287/// for `Soll [47] ∧ … / Muss [46] ∧ …` depends on which line holds.
1288fn is_parent_group_required<E: ConditionEvaluator>(
1289    field: &AhbFieldRule,
1290    expr_eval: &ConditionExprEvaluator<E>,
1291    ctx: &EvaluationContext,
1292    ub_definitions: &BTreeMap<String, ConditionExpr>,
1293) -> bool {
1294    field
1295        .parent_group_ahb_status
1296        .as_deref()
1297        .is_some_and(|status| {
1298            let status = expr_eval.resolve_status(status, ctx, ub_definitions);
1299            status.result == ConditionResult::True && status.kind == Some(StatusKind::Muss)
1300        })
1301}
1302
1303/// Check if a field's required segment instance is present.
1304///
1305/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
1306/// checks that a segment with one of those qualifier values exists.
1307/// Otherwise just checks that any segment with the tag is present.
1308///
1309/// This prevents false negatives where `NAD+MS` is present but the
1310/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
1311fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
1312    let segment_id = extract_segment_id(&field.segment_path);
1313
1314    // When a field has known codes, check for a segment containing one of those
1315    // specific values at the right element/component position. This applies to both
1316    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
1317    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
1318    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
1319    if !field.codes.is_empty() {
1320        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
1321            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1322            let matching = ctx.find_segments(&segment_id);
1323            return matching.iter().any(|seg| {
1324                seg.elements
1325                    .get(el_idx)
1326                    .and_then(|e| e.get(comp_idx))
1327                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1328            });
1329        }
1330        // Fallback for fields with codes but no element/component indices:
1331        // use simple qualifier check if it's a qualifier-style path.
1332        if is_qualifier_field(&field.segment_path) {
1333            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1334            let el_idx = field.element_index.unwrap_or(0);
1335            let comp_idx = field.component_index.unwrap_or(0);
1336            let matching = ctx.find_segments(&segment_id);
1337            return matching.iter().any(|seg| {
1338                seg.elements
1339                    .get(el_idx)
1340                    .and_then(|e| e.get(comp_idx))
1341                    .is_some_and(|v| required_codes.contains(&v.as_str()))
1342            });
1343        }
1344    }
1345
1346    ctx.has_segment(&segment_id)
1347}
1348
1349/// Check if the group variant for a field is absent from the message.
1350///
1351/// Uses tree-based logic:
1352/// 1. **Group entirely absent**: If the group path has 0 instances, the
1353///    whole group is absent and its children aren't required.
1354/// 2. **Optional group variant absent**: If the parent group is optional
1355///    ("Kann") and the specific qualifier variant isn't present, the variant
1356///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
1357///    LOC+Z17, all fields under that variant are skipped.
1358///
1359/// Returns `false` (not absent) when:
1360/// - The field has no group prefix (e.g., `NAD/3035`)
1361/// - No navigator is available (can't determine group presence)
1362/// - The parent group is mandatory and has instances
1363fn is_group_variant_absent(
1364    ctx: &EvaluationContext,
1365    field: &AhbFieldRule,
1366    is_group_mandatory: bool,
1367) -> bool {
1368    let group_path: Vec<&str> = field
1369        .segment_path
1370        .split('/')
1371        .take_while(|p| p.starts_with("SG"))
1372        .collect();
1373
1374    if group_path.is_empty() {
1375        return false;
1376    }
1377
1378    let nav = match ctx.navigator {
1379        Some(nav) => nav,
1380        None => return false,
1381    };
1382
1383    let instance_count = nav.group_instance_count(&group_path);
1384
1385    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
1386    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
1387    // has 0 instances, the group's absence is itself the error, so we should
1388    // NOT skip field validation — let the field-level check report the missing field.
1389    if instance_count == 0 {
1390        if !is_group_mandatory {
1391            return true;
1392        }
1393        // Mandatory group with 0 instances → don't suppress field errors
1394        return false;
1395    }
1396
1397    // Case 2: group has instances, but the specific qualifier variant may be absent.
1398    // Only applies when the parent group is not required here ("Kann", "Soll",
1399    // or "Kann [345]" whose condition holds — allowed, never demanded):
1400    // required groups ("Muss", "X") require all their qualifier variants.
1401    if field.parent_group_ahb_status.is_some() && !is_group_mandatory {
1402        // Check if the field's qualifier variant is present in any instance.
1403        if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1404            let segment_id = extract_segment_id(&field.segment_path);
1405            let required_codes: Vec<&str> = field.codes.iter().map(|c| c.value.as_str()).collect();
1406
1407            let any_instance_has_qualifier = (0..instance_count).any(|i| {
1408                nav.find_segments_in_group(&segment_id, &group_path, i)
1409                    .iter()
1410                    .any(|seg| {
1411                        seg.elements
1412                            .first()
1413                            .and_then(|e| e.first())
1414                            .is_some_and(|v| required_codes.contains(&v.as_str()))
1415                    })
1416            });
1417
1418            if !any_instance_has_qualifier {
1419                return true; // optional group variant absent
1420            }
1421        }
1422    }
1423
1424    // Case 3: non-entry segment absent from all group instances.
1425    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
1426    // in any SG10 instance but the entry segment (QTY) is present, fields
1427    // under STS are not required.
1428    //
1429    // We check `has_any_segment_in_group` to confirm the group instance is
1430    // genuinely populated (proving our target segment is a non-entry optional
1431    // one) vs a navigator that simply can't resolve segments.
1432    let segment_id = extract_segment_id(&field.segment_path);
1433    let segment_absent_from_all = (0..instance_count).all(|i| {
1434        nav.find_segments_in_group(&segment_id, &group_path, i)
1435            .is_empty()
1436    });
1437    if segment_absent_from_all {
1438        let group_has_other_segments =
1439            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1440        if group_has_other_segments {
1441            return true;
1442        }
1443    }
1444
1445    false
1446}
1447
1448/// Recursively collect all AhbNodes from a group node in depth-first order.
1449fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1450    out.extend(group.fields.iter());
1451    for child in &group.children {
1452        collect_nodes_depth_first(child, out);
1453    }
1454}
1455
1456/// Evaluate a single `AhbNode` with the given (possibly scoped) context,
1457/// emitting AHB001/AHB003/AHB005 issues as appropriate. When `instance_index`
1458/// is `Some`, errors are tagged with it via `with_instance_index`.
1459#[allow(clippy::too_many_arguments)]
1460fn evaluate_node<E: ConditionEvaluator>(
1461    node: &AhbNode,
1462    ctx: &EvaluationContext,
1463    expr_eval: &ConditionExprEvaluator<E>,
1464    evaluator: &E,
1465    ub_definitions: &BTreeMap<String, ConditionExpr>,
1466    tag_migs: &HashMap<String, HashSet<&str>>,
1467    instance_index: Option<usize>,
1468    report: &mut ValidationReport,
1469) {
1470    let field = node.rule;
1471
1472    // Create a field-scoped context with resolved value from the tree node.
1473    let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
1474
1475    // A present value no longer than its MIG format allows (`an..512`: FTX
1476    // C108/4440). Checked whatever the condition says — the format binds
1477    // wherever the field is sent,
1478    // so before the group condition decides whether the rest applies.
1479    if let (Some(value), Some(max)) = (node.value, field.format.as_deref().and_then(max_length)) {
1480        let length = value.chars().count();
1481        if length > max {
1482            let mut issue = ValidationIssue::new(
1483                Severity::Error,
1484                IssueKind::ValueTooLong {
1485                    field_name: field.name.clone(),
1486                    length,
1487                    max,
1488                },
1489            )
1490            .with_field_path(&field.segment_path)
1491            .with_actual(value)
1492            .with_expected(field.format.clone().unwrap_or_default());
1493            if let Some(idx) = instance_index {
1494                issue = issue.with_instance_index(idx);
1495            }
1496            if let Some(num) = node.matched_segment_number {
1497                issue = issue.with_position(crate::SegmentPosition {
1498                    segment_number: num,
1499                    byte_offset: 0,
1500                    message_number: 1,
1501                });
1502            }
1503            report.add_issue(attach_field_position(issue, field));
1504        }
1505    }
1506
1507    // Skip if parent group condition is not met (False or Unknown).
1508    if should_skip_for_parent_group(field, expr_eval, ctx, ub_definitions) {
1509        return;
1510    }
1511
1512    // Evaluate the field's AHB status condition using the node context
1513    // (WITH resolved_value set). A present code is judged by its own status.
1514    let status = governing_status(field, node.value);
1515    let StatusResolution {
1516        result: condition_result,
1517        kind,
1518        unknown_ids,
1519    } = expr_eval.resolve_status(status, &node_ctx, ub_definitions);
1520    let mandatory = kind == Some(StatusKind::Muss);
1521
1522    match condition_result {
1523        ConditionResult::True => {
1524            // Condition is met -- field is required/applicable.
1525            //
1526            // Normally rely on the tree's resolved value. The exception:
1527            // if assembly didn't claim the segment at all (value AND
1528            // segment_elements both absent) AND the tag has exactly one
1529            // mig variant in this PID, a flat-segment hit means the
1530            // segment IS present and assembly just failed to pick it up.
1531            // Don't double-report — structure diagnostics already warn.
1532            if mandatory && node.value.is_none() {
1533                let tag = extract_segment_id(&field.segment_path);
1534                let single_variant_present = node.segment_elements.is_none()
1535                    && tag_migs.get(&tag).is_some_and(|ms| ms.len() == 1)
1536                    && is_field_present(ctx, field);
1537                // Suppress missing-field errors when the containing segment
1538                // is optional (Kann/Soll) and absent from the instance.
1539                // "X" on a Kann segment's sub-element means "required IF
1540                // segment present"; the segment's absence is legal.
1541                let segment_optional_and_absent = node.segment_elements.is_none()
1542                    && field
1543                        .segment_ahb_status
1544                        .as_deref()
1545                        .is_some_and(is_optional_segment_status);
1546                // Likewise when the absent segment is required only under a
1547                // condition that does not hold here: a position without an
1548                // Eichgültigkeit (QUOTES 15001 `DTM+Z04`, `Muss [5] ⊻ [3] ⊻
1549                // [22]`), a position that is not the SMGW (ORDERS 17001
1550                // `FTX+Z30`, `Muss [185] ∧ [593]`).
1551                let segment_not_required_and_absent = node.segment_elements.is_none()
1552                    && field.segment_ahb_status.as_deref().is_some_and(|st| {
1553                        expr_eval
1554                            .resolve_status(st, &node_ctx, ub_definitions)
1555                            .result
1556                            != ConditionResult::True
1557                    });
1558                if !single_variant_present
1559                    && !segment_optional_and_absent
1560                    && !segment_not_required_and_absent
1561                {
1562                    let mut issue = ValidationIssue::new(
1563                        Severity::Error,
1564                        IssueKind::MissingRequiredField {
1565                            field_name: field.name.clone(),
1566                        },
1567                    )
1568                    .with_field_path(&field.segment_path)
1569                    .with_rule(&field.ahb_status);
1570                    if let Some(first_code) = field.codes.first() {
1571                        issue.expected_value = Some(first_code.value.clone());
1572                    }
1573                    if let Some(idx) = instance_index {
1574                        issue = issue.with_instance_index(idx);
1575                    }
1576                    if let Some(num) = node.matched_segment_number {
1577                        issue = issue.with_position(crate::SegmentPosition {
1578                            segment_number: num,
1579                            byte_offset: 0,
1580                            message_number: 1,
1581                        });
1582                    }
1583                    issue = attach_field_position(issue, field);
1584                    report.add_issue(issue);
1585                }
1586            }
1587        }
1588        ConditionResult::False => {
1589            // Condition not met. If mandatory AND value is present,
1590            // the value violates the condition constraint.
1591            if mandatory && node.value.is_some() {
1592                let mut issue = ValidationIssue::new(
1593                    Severity::Error,
1594                    IssueKind::FieldConditionNotSatisfied {
1595                        field_name: field.name.clone(),
1596                    },
1597                )
1598                .with_field_path(&field.segment_path)
1599                .with_rule(&field.ahb_status);
1600                if let Some(idx) = instance_index {
1601                    issue = issue.with_instance_index(idx);
1602                }
1603                if let Some(num) = node.matched_segment_number {
1604                    issue = issue.with_position(crate::SegmentPosition {
1605                        segment_number: num,
1606                        byte_offset: 0,
1607                        message_number: 1,
1608                    });
1609                }
1610                issue = attach_field_position(issue, field);
1611                report.add_issue(issue);
1612            }
1613        }
1614        ConditionResult::Unknown => {
1615            // Partition unknown IDs into external / undetermined / missing.
1616            let mut external_ids = Vec::new();
1617            let mut undetermined_ids = Vec::new();
1618            let mut missing_ids = Vec::new();
1619            for id in unknown_ids {
1620                if evaluator.is_external(id) {
1621                    external_ids.push(id);
1622                } else if evaluator.is_known(id) {
1623                    undetermined_ids.push(id);
1624                } else {
1625                    missing_ids.push(id);
1626                }
1627            }
1628
1629            let mut issue = ValidationIssue::new(
1630                Severity::Info,
1631                IssueKind::ConditionUnknown {
1632                    field_name: field.name.clone(),
1633                    unresolved: UnresolvedConditions {
1634                        external: external_ids,
1635                        undetermined: undetermined_ids,
1636                        missing: missing_ids,
1637                    },
1638                },
1639            )
1640            .with_field_path(&field.segment_path)
1641            .with_rule(&field.ahb_status);
1642            if let Some(idx) = instance_index {
1643                issue = issue.with_instance_index(idx);
1644            }
1645            if let Some(num) = node.matched_segment_number {
1646                issue = issue.with_position(crate::SegmentPosition {
1647                    segment_number: num,
1648                    byte_offset: 0,
1649                    message_number: 1,
1650                });
1651            }
1652            issue = attach_field_position(issue, field);
1653            report.add_issue(issue);
1654        }
1655    }
1656}
1657
1658/// Attach UCD `S011` position info derived from the AHB field rule's
1659/// `element_index` / `component_index`. Both are 0-based array indices
1660/// in the parsed segment; CONTRL DE0098 / DE0104 are 1-based and
1661/// DE0098 counts the segment identifier as position 1.
1662fn attach_field_position(issue: ValidationIssue, field: &AhbFieldRule) -> ValidationIssue {
1663    match field.element_index {
1664        Some(el) => {
1665            let element_pos = (el as u32) + 2;
1666            let component_pos = field.component_index.map(|c| (c as u32) + 1);
1667            issue.with_field_position(element_pos, component_pos)
1668        }
1669        None => issue,
1670    }
1671}
1672
1673/// The segments of `group` sent although their own condition does not hold
1674/// (#258): `CAV+Z56` (Wärmenutzung, `Muss [239]`) without CAV+Z65 in its SG10.
1675/// A segment counts when the qualifier rule of its slot finds its code there
1676/// — that identifies the slot among same-tag segments — and is reported once. A
1677/// segment whose status is the group's own is the group check's business.
1678fn refuse_segments<E: ConditionEvaluator>(
1679    group: &AhbGroupNode,
1680    path: &[(&str, usize)],
1681    scoped_ctx: &EvaluationContext,
1682    evaluator: &E,
1683    ub_definitions: &BTreeMap<String, ConditionExpr>,
1684    instance_index: usize,
1685    report: &mut ValidationReport,
1686) {
1687    let mut seen: Vec<u32> = Vec::new();
1688    for node in &group.fields {
1689        let rule = node.rule;
1690        let (Some(status), Some(value), Some(number)) = (
1691            rule.segment_ahb_status.as_deref(),
1692            node.value,
1693            node.matched_segment_number,
1694        ) else {
1695            continue;
1696        };
1697        // Only the qualifier (element 0, component 0) names the slot: a
1698        // format code such as DTM's `303` is shared by DTM+92 and DTM+93.
1699        let qualifier =
1700            rule.element_index.unwrap_or(0) == 0 && rule.component_index.unwrap_or(0) == 0;
1701        if !qualifier
1702            || seen.contains(&number)
1703            || Some(status) == group.ahb_status
1704            || !rule.codes.iter().any(|c| c.value == value)
1705        {
1706            continue;
1707        }
1708        seen.push(number);
1709        if !crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
1710            continue;
1711        }
1712        let mut segment: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1713        segment.extend(rule.segment_path.split('/').find(|p| !p.starts_with("SG")));
1714        report.add_issue(
1715            ValidationIssue::new(
1716                Severity::Warning,
1717                IssueKind::SegmentNotAllowed {
1718                    segment: segment.join("/"),
1719                },
1720            )
1721            .with_field_path(segment.join("/"))
1722            .with_rule(status)
1723            .with_instance_index(instance_index)
1724            .with_position(crate::SegmentPosition {
1725                segment_number: number,
1726                byte_offset: 0,
1727                message_number: 1,
1728            }),
1729        );
1730    }
1731}
1732
1733/// Recursively walk a group instance (and its children) evaluating every
1734/// `AhbNode` in the scope of the instance it belongs to.
1735///
1736/// `path` addresses `group` by its repetition at every level from the root
1737/// (`[("SG4", 1), ("SG8", 2)]`); each child instance is walked with the path
1738/// one level deeper, so "in dieser SG8" means this SG8, not every SG8 of the
1739/// first SG4. `instance_index` stays the top-level repetition, which is what
1740/// issues report.
1741#[allow(clippy::too_many_arguments)]
1742fn walk_group_instance<E: ConditionEvaluator>(
1743    group: &AhbGroupNode,
1744    path: &[(&str, usize)],
1745    scoped_ctx: &EvaluationContext,
1746    expr_eval: &ConditionExprEvaluator<E>,
1747    evaluator: &E,
1748    ub_definitions: &BTreeMap<String, ConditionExpr>,
1749    tag_migs: &HashMap<String, HashSet<&str>>,
1750    instance_index: usize,
1751    report_segments: bool,
1752    report: &mut ValidationReport,
1753) {
1754    // A group sent although its condition does not hold (a warning while the
1755    // check proves itself). Its fields are then skipped as before.
1756    let mut group_refused = false;
1757    if let Some(status) = group.ahb_status {
1758        if crate::eval::refuses_presence(status, evaluator, scoped_ctx, ub_definitions) {
1759            group_refused = true;
1760            let group_path: Vec<&str> = path.iter().map(|(g, _)| *g).collect();
1761            let mut issue = ValidationIssue::new(
1762                Severity::Warning,
1763                IssueKind::GroupNotAllowed {
1764                    group: group_path.join("/"),
1765                },
1766            )
1767            .with_field_path(group_path.join("/"))
1768            .with_rule(status)
1769            .with_instance_index(instance_index);
1770            if let Some(num) = group.fields.iter().find_map(|n| n.matched_segment_number) {
1771                issue = issue.with_position(crate::SegmentPosition {
1772                    segment_number: num,
1773                    byte_offset: 0,
1774                    message_number: 1,
1775                });
1776            }
1777            report.add_issue(issue);
1778        }
1779    }
1780    if report_segments && !group_refused {
1781        refuse_segments(
1782            group,
1783            path,
1784            scoped_ctx,
1785            evaluator,
1786            ub_definitions,
1787            instance_index,
1788            report,
1789        );
1790    }
1791    for node in &group.fields {
1792        evaluate_node(
1793            node,
1794            scoped_ctx,
1795            expr_eval,
1796            evaluator,
1797            ub_definitions,
1798            tag_migs,
1799            Some(instance_index),
1800            report,
1801        );
1802    }
1803    for child in &group.children {
1804        let mut child_path = path.to_vec();
1805        child_path.push((child.group_id, child.instance_index));
1806        let child_ctx = scoped_ctx.with_scope(GroupScope { path: &child_path });
1807        walk_group_instance(
1808            child,
1809            &child_path,
1810            &child_ctx,
1811            expr_eval,
1812            evaluator,
1813            ub_definitions,
1814            tag_migs,
1815            instance_index,
1816            report_segments,
1817            report,
1818        );
1819    }
1820}
1821
1822/// Recursively collect all Package nodes from a condition expression tree.
1823fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1824    match expr {
1825        ConditionExpr::Package { id, min, max } => {
1826            out.push((*id, *min, *max));
1827        }
1828        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1829            for e in exprs {
1830                collect_packages(e, out);
1831            }
1832        }
1833        ConditionExpr::Xor(left, right) => {
1834            collect_packages(left, out);
1835            collect_packages(right, out);
1836        }
1837        ConditionExpr::Not(inner) => {
1838            collect_packages(inner, out);
1839        }
1840        ConditionExpr::Ref(_) => {}
1841    }
1842}
1843
1844/// Check if a segment-level AHB status indicates an optional segment.
1845///
1846/// "Kann" / "Kann [x]" — always optional: absence is legal.
1847/// "Soll" / "Soll [x]" — should-be: absence is tolerated (not an error).
1848/// "Muss" (with or without conditions) — required; absence must be reported.
1849///
1850/// Used to suppress AHB001 missing-field errors on sub-elements of a segment
1851/// that is both optional at segment-level and absent from the assembled
1852/// instance: "X" on such a sub-element means "required IF segment present".
1853fn is_optional_segment_status(status: &str) -> bool {
1854    let trimmed = status.trim();
1855    trimmed.starts_with("Kann") || trimmed.starts_with("Soll")
1856}
1857
1858/// Check if a field path points to a simple qualifier element (element[0] of the segment).
1859///
1860/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
1861/// directly under the segment (no composite wrapper). These fields have their code
1862/// in `element[0][0]` and can be validated.
1863///
1864/// Also accepts composite paths like `SEG/COMPOSITE/ELEMENT` (e.g., `STS/C556/9013`).
1865/// The `element_index` and `component_index` on the field rule carry the exact
1866/// position, so composites can be checked just like simple elements.
1867fn is_qualifier_field(path: &str) -> bool {
1868    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1869    // [SEG, ELEMENT] for simple data elements; [SEG, COMPOSITE, ELEMENT] for composites.
1870    matches!(parts.len(), 2 | 3)
1871}
1872
1873/// Per-mig code constraints at one (group_key, tag) location.
1874///
1875/// A bucket represents one mig_number (or the `None` bucket when rules lack
1876/// mig numbers). Each bucket tracks its qualifier position and the set of
1877/// valid qualifier values there, so multiple single-code rules under the same
1878/// mig key (e.g. two distinct NAD qualifiers both under mig=None in a test
1879/// fixture) all contribute as valid qualifier matches.
1880#[derive(Default)]
1881struct MigCodeBucket<'a> {
1882    /// The position (element_index, component_index) treated as the discriminator
1883    /// for this bucket — the first single-code required field seen in the workflow.
1884    qualifier_position: Option<(usize, usize)>,
1885    /// Valid qualifier values at `qualifier_position`. Empty if no single-code
1886    /// required field exists for this bucket.
1887    qualifier_values: HashSet<&'a str>,
1888    /// Allowed codes for this bucket, keyed by `(element_index, component_index)`.
1889    /// Includes the qualifier position itself.
1890    codes: HashMap<(usize, usize), HashSet<&'a str>>,
1891}
1892
1893/// Partition workflow code rules by `(group_key, tag, mig_number)`. Without this
1894/// split, codes from multiple migs sharing a tag (e.g. STS+7 mig 00035 and
1895/// STS+E01 mig 00036 in PID 55018) get unioned and every segment is checked
1896/// against codes that only apply to one mig.
1897fn partition_codes_by_mig(
1898    workflow: &AhbWorkflow,
1899) -> HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>> {
1900    let mut out: HashMap<(String, String), HashMap<Option<String>, MigCodeBucket>> = HashMap::new();
1901    for field in &workflow.fields {
1902        if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
1903            continue;
1904        }
1905        let tag = extract_segment_id(&field.segment_path);
1906        let group_key = extract_group_path_key(&field.segment_path);
1907        let mig = field.mig_number.clone();
1908        let el = field.element_index.unwrap_or(0);
1909        let c = field.component_index.unwrap_or(0);
1910
1911        let bucket = out
1912            .entry((group_key, tag))
1913            .or_default()
1914            .entry(mig)
1915            .or_default();
1916
1917        // A code is "allowed for this PID" whenever its AHB status declares it
1918        // usable — whether unconditionally (`X`, `Muss`) or under a condition
1919        // (`X [35]`, `X [35] ∨ ([32] ∧ [77])`). Matching only the exact string
1920        // `"X"` drops every conditionally-allowed code, causing COD002 false
1921        // positives on fields like MSCONS QTY 6063 (220 unconditional, 67/Z18
1922        // conditional).
1923        let required: Vec<&str> = field
1924            .codes
1925            .iter()
1926            .filter(|code| code.ahb_status.starts_with('X') || code.ahb_status.starts_with("Muss"))
1927            .map(|code| code.value.as_str())
1928            .collect();
1929
1930        // The first code-bearing field locks in the qualifier position; any
1931        // further required codes at that position contribute additional valid
1932        // qualifier values. A bucket whose entry qualifier permits multiple
1933        // codes (e.g. PID 55035 SG8/RFF mig=00075 allows {Z31, Z39}) must be
1934        // matchable by any of them — without this, segments with Z39 fall
1935        // through to the union-of-other-migs path and produce a false COD002.
1936        if !required.is_empty() {
1937            if bucket.qualifier_position.is_none() {
1938                bucket.qualifier_position = Some((el, c));
1939            }
1940            if bucket.qualifier_position == Some((el, c)) {
1941                bucket.qualifier_values.extend(required.iter().copied());
1942            }
1943        }
1944
1945        for v in required {
1946            bucket.codes.entry((el, c)).or_default().insert(v);
1947        }
1948    }
1949    out
1950}
1951
1952/// For each tag, collect every qualifier value used anywhere in the workflow,
1953/// keyed by qualifier position. This lets code validation distinguish "segment
1954/// has an unknown qualifier value" (truly invalid — report) from "segment has
1955/// a valid qualifier used at a different location than the current bucket"
1956/// (belongs elsewhere — skip at this location).
1957fn global_qualifiers_by_tag(
1958    by_loc: &HashMap<(String, String), HashMap<Option<String>, MigCodeBucket<'_>>>,
1959) -> HashMap<String, HashMap<(usize, usize), HashSet<String>>> {
1960    let mut out: HashMap<String, HashMap<(usize, usize), HashSet<String>>> = HashMap::new();
1961    for ((_group, tag), migs) in by_loc {
1962        let tag_entry = out.entry(tag.clone()).or_default();
1963        for bucket in migs.values() {
1964            if let Some(pos) = bucket.qualifier_position {
1965                tag_entry
1966                    .entry(pos)
1967                    .or_default()
1968                    .extend(bucket.qualifier_values.iter().map(|s| s.to_string()));
1969            }
1970        }
1971    }
1972    out
1973}
1974
1975/// Find the bucket that best matches this segment.
1976///
1977/// A bucket is a candidate if its `qualifier_values` contain the segment's
1978/// value at `qualifier_position`. Among candidates, the one with the most
1979/// additional code positions matching the segment wins. This disambiguates
1980/// migs that share the same primary qualifier but differ on a secondary
1981/// code position (e.g. PID 55035 SG8/PIA variants all use 4347='5' but
1982/// differ on C212/7143={Z12} vs {SRW}).
1983///
1984/// Returns `None` if no bucket's qualifier matches the segment.
1985fn match_segment_to_mig<'a, 'b>(
1986    seg: &OwnedSegment,
1987    migs: &'a HashMap<Option<String>, MigCodeBucket<'b>>,
1988) -> Option<&'a MigCodeBucket<'b>> {
1989    let actual_at = |el: usize, c: usize| -> &str {
1990        seg.elements
1991            .get(el)
1992            .and_then(|e| e.get(c))
1993            .map(|s| s.as_str())
1994            .unwrap_or("")
1995    };
1996
1997    let mut best: Option<&MigCodeBucket> = None;
1998    let mut best_matches = 0usize;
1999
2000    for bucket in migs.values() {
2001        let Some((el, c)) = bucket.qualifier_position else {
2002            continue;
2003        };
2004        if !bucket.qualifier_values.contains(actual_at(el, c)) {
2005            continue;
2006        }
2007        let extra_matches = bucket
2008            .codes
2009            .iter()
2010            .filter(|(pos, _)| **pos != (el, c))
2011            .filter(|((e, k), allowed)| {
2012                let v = actual_at(*e, *k);
2013                !v.is_empty() && allowed.contains(v)
2014            })
2015            .count();
2016        if best.is_none() || extra_matches > best_matches {
2017            best = Some(bucket);
2018            best_matches = extra_matches;
2019        }
2020    }
2021    best
2022}
2023
2024/// Extract the group path prefix from a field path.
2025///
2026/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
2027/// `"NAD/3035"` → `""` (no group prefix).
2028fn extract_group_path_key(path: &str) -> String {
2029    let sg_parts: Vec<&str> = path
2030        .split('/')
2031        .take_while(|p| p.starts_with("SG"))
2032        .collect();
2033    sg_parts.join("/")
2034}
2035
2036/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
2037fn extract_segment_id(path: &str) -> String {
2038    for part in path.split('/') {
2039        // Skip segment group identifiers and composite/element identifiers
2040        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
2041            continue;
2042        }
2043        // Return first 3-letter uppercase segment identifier
2044        if part.len() >= 3
2045            && part
2046                .chars()
2047                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
2048        {
2049            return part.to_string();
2050        }
2051    }
2052    // Fallback: return the last part
2053    path.split('/').next_back().unwrap_or(path).to_string()
2054}
2055
2056/// Validate that the UNT segment count matches the actual number of segments.
2057///
2058/// The UNT segment's first data element (D0074) declares the number of segments
2059/// in the message, counting from UNH to UNT inclusive. This function compares
2060/// that declared value against the actual segment count.
2061///
2062/// Pass per-message segments (from [`MessageChunk::message_segments()`] or
2063/// [`MessageChunk::all_segments()`]), not the full interchange. For multi-message
2064/// interchanges, call once per message.
2065///
2066/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
2067pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
2068    // Reject multi-message input — counting would be incorrect.
2069    let unh_count = segments.iter().filter(|s| s.id == "UNH").count();
2070    if unh_count > 1 {
2071        return Some(ValidationIssue::new(
2072            Severity::Error,
2073            IssueKind::UntCountNotVerifiable { unh_count },
2074        ));
2075    }
2076
2077    // Find the UNT segment
2078    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
2079    let declared: usize = unt.get_element(0).parse().ok()?;
2080
2081    // Count segments from UNH to UNT inclusive
2082    let actual = segments
2083        .iter()
2084        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
2085        .count();
2086
2087    if declared != actual {
2088        Some(
2089            ValidationIssue::new(
2090                Severity::Error,
2091                IssueKind::UntSegmentCountMismatch { declared, actual },
2092            )
2093            .with_field_path("UNT/0074")
2094            .with_expected(actual.to_string())
2095            .with_actual(declared.to_string()),
2096        )
2097    } else {
2098        None
2099    }
2100}
2101
2102#[cfg(test)]
2103mod tests {
2104    use super::super::codes::ErrorCodes;
2105    use super::super::issue::ValidationCategory;
2106    use super::*;
2107    use crate::display::{EdifactView, IssueNarrator, IssueView, TechnicalNarrator};
2108    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
2109    use std::collections::HashMap;
2110
2111    /// Narrate an issue the way the EDIFACT view would, for assertions that
2112    /// used to read `.message` before Task 5 removed it.
2113    fn narrate(issue: &ValidationIssue) -> String {
2114        TechnicalNarrator.describe(issue, EdifactView.location(issue).as_deref())
2115    }
2116
2117    /// Mock evaluator for testing the validator.
2118    struct MockEvaluator {
2119        results: HashMap<u32, CR>,
2120    }
2121
2122    impl MockEvaluator {
2123        fn new(results: Vec<(u32, CR)>) -> Self {
2124            Self {
2125                results: results.into_iter().collect(),
2126            }
2127        }
2128
2129        fn all_true(ids: &[u32]) -> Self {
2130            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
2131        }
2132    }
2133
2134    impl ConditionEvaluator for MockEvaluator {
2135        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
2136            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
2137        }
2138        fn is_external(&self, _condition: u32) -> bool {
2139            false
2140        }
2141        fn message_type(&self) -> &str {
2142            "UTILMD"
2143        }
2144        fn format_version(&self) -> &str {
2145            "FV2510"
2146        }
2147    }
2148
2149    // === Helper function tests ===
2150
2151    #[test]
2152    fn a_present_code_is_judged_by_its_own_status() {
2153        // 55010 STS Transaktionsgrund: the schema gives the element E01's status.
2154        let code = |value: &str, status: &str| AhbCodeRule {
2155            value: value.to_string(),
2156            description: String::new(),
2157            ahb_status: status.to_string(),
2158        };
2159        let field = AhbFieldRule {
2160            segment_path: "SG4/STS/C556/9013".to_string(),
2161            ahb_status: "X [192]".to_string(),
2162            codes: vec![code("E01", "X [192]"), code("E03", "X")],
2163            ..Default::default()
2164        };
2165        assert_eq!(governing_status(&field, Some("E03")), "X");
2166        assert_eq!(governing_status(&field, Some("E01")), "X [192]");
2167        assert_eq!(governing_status(&field, None), "X [192]");
2168        assert_eq!(governing_status(&field, Some("ZZZ")), "X [192]");
2169
2170        // An element with a condition of its own keeps it.
2171        let own = AhbFieldRule {
2172            ahb_status: "X [931]".to_string(),
2173            codes: vec![code("303", "X")],
2174            ..Default::default()
2175        };
2176        assert_eq!(governing_status(&own, Some("303")), "X [931]");
2177    }
2178
2179    #[test]
2180    fn test_extract_segment_id_simple() {
2181        assert_eq!(extract_segment_id("NAD"), "NAD");
2182    }
2183
2184    #[test]
2185    fn test_extract_segment_id_with_sg_prefix() {
2186        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
2187    }
2188
2189    #[test]
2190    fn test_extract_segment_id_nested_sg() {
2191        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
2192    }
2193
2194    // === Validator tests with mock data ===
2195
2196    #[test]
2197    fn test_validate_missing_mandatory_field() {
2198        let evaluator = MockEvaluator::all_true(&[182, 152]);
2199        let validator = EdifactValidator::new(evaluator);
2200        let external = NoOpExternalProvider;
2201
2202        let workflow = AhbWorkflow {
2203            pruefidentifikator: "11001".to_string(),
2204            description: "Test".to_string(),
2205            communication_direction: None,
2206            fields: vec![AhbFieldRule {
2207                segment_path: "SG2/NAD/C082/3039".to_string(),
2208                name: "MP-ID des MSB".to_string(),
2209                ahb_status: "Muss [182] ∧ [152]".to_string(),
2210                codes: vec![],
2211                parent_group_ahb_status: None,
2212                segment_ahb_status: None,
2213                ..Default::default()
2214            }],
2215            ub_definitions: BTreeMap::new(),
2216        };
2217
2218        // Validate with no segments
2219        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2220
2221        // Should have an error for missing mandatory field
2222        assert!(!report.is_valid());
2223        let errors: Vec<_> = report.errors().collect();
2224        assert_eq!(errors.len(), 1);
2225        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2226        assert!(narrate(errors[0]).contains("MP-ID des MSB"));
2227    }
2228
2229    #[test]
2230    fn test_validate_condition_false_no_error() {
2231        // When condition evaluates to False, field is not required
2232        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
2233        let validator = EdifactValidator::new(evaluator);
2234        let external = NoOpExternalProvider;
2235
2236        let workflow = AhbWorkflow {
2237            pruefidentifikator: "11001".to_string(),
2238            description: "Test".to_string(),
2239            communication_direction: None,
2240            fields: vec![AhbFieldRule {
2241                segment_path: "NAD".to_string(),
2242                name: "Partnerrolle".to_string(),
2243                ahb_status: "Muss [182] ∧ [152]".to_string(),
2244                codes: vec![],
2245                parent_group_ahb_status: None,
2246                segment_ahb_status: None,
2247                ..Default::default()
2248            }],
2249            ub_definitions: BTreeMap::new(),
2250        };
2251
2252        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2253
2254        // Condition is false, so field is not required - no error
2255        assert!(report.is_valid());
2256    }
2257
2258    /// Validate an absent field with `status`, conditions set as given.
2259    fn validate_absent_field(status: &str, conditions: Vec<(u32, CR)>) -> ValidationReport {
2260        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
2261        let workflow = AhbWorkflow {
2262            pruefidentifikator: "11001".to_string(),
2263            description: "Test".to_string(),
2264            communication_direction: None,
2265            fields: vec![AhbFieldRule {
2266                segment_path: "SG2/NAD/C082/3039".to_string(),
2267                name: "MP-ID".to_string(),
2268                ahb_status: status.to_string(),
2269                ..Default::default()
2270            }],
2271            ub_definitions: BTreeMap::new(),
2272        };
2273        validator.validate(
2274            &[],
2275            &workflow,
2276            &NoOpExternalProvider,
2277            ValidationLevel::Conditions,
2278        )
2279    }
2280
2281    #[test]
2282    fn a_later_soll_line_alone_does_not_require_the_field() {
2283        // Muss [2119] / Soll [130], only [130] holds: recommended, not required.
2284        let report = validate_absent_field(
2285            "Muss [2119] \r\nSoll [130]",
2286            vec![(2119, CR::False), (130, CR::True)],
2287        );
2288        assert!(
2289            report.is_valid(),
2290            "{:?}",
2291            report.errors().collect::<Vec<_>>()
2292        );
2293    }
2294
2295    #[test]
2296    fn a_later_muss_line_requires_the_field() {
2297        // Soll [47] / Muss [46], only [46] holds: required.
2298        let report = validate_absent_field(
2299            "Soll [47]\r\nMuss [46]",
2300            vec![(47, CR::False), (46, CR::True)],
2301        );
2302        let errors: Vec<_> = report.errors().collect();
2303        assert_eq!(errors.len(), 1);
2304        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2305    }
2306
2307    #[test]
2308    fn a_weaker_unknown_line_does_not_stop_a_muss() {
2309        // [130] cannot make it any stricter than the Muss that holds.
2310        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(2119, CR::True)]);
2311        let errors: Vec<_> = report.errors().collect();
2312        assert_eq!(errors.len(), 1);
2313        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2314    }
2315
2316    #[test]
2317    fn a_stricter_unknown_line_leaves_the_status_undecided() {
2318        let report = validate_absent_field("Muss [2119]\nSoll [130]", vec![(130, CR::True)]);
2319        assert!(report.is_valid());
2320        let infos: Vec<_> = report.infos().collect();
2321        assert_eq!(infos.len(), 1, "{infos:?}");
2322        match &infos[0].kind {
2323            IssueKind::ConditionUnknown { unresolved, .. } => {
2324                assert_eq!(unresolved.missing, vec![2119]);
2325            }
2326            other => panic!("expected ConditionUnknown, got {other:?}"),
2327        }
2328    }
2329
2330    #[test]
2331    fn test_validate_condition_unknown_adds_info() {
2332        // When condition is Unknown, add an info-level note
2333        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
2334        // 152 is not registered -> Unknown
2335        let validator = EdifactValidator::new(evaluator);
2336        let external = NoOpExternalProvider;
2337
2338        let workflow = AhbWorkflow {
2339            pruefidentifikator: "11001".to_string(),
2340            description: "Test".to_string(),
2341            communication_direction: None,
2342            fields: vec![AhbFieldRule {
2343                segment_path: "NAD".to_string(),
2344                name: "Partnerrolle".to_string(),
2345                ahb_status: "Muss [182] ∧ [152]".to_string(),
2346                codes: vec![],
2347                parent_group_ahb_status: None,
2348                segment_ahb_status: None,
2349                ..Default::default()
2350            }],
2351            ub_definitions: BTreeMap::new(),
2352        };
2353
2354        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2355
2356        // Should be valid (Unknown is not an error) but have an info issue
2357        assert!(report.is_valid());
2358        let infos: Vec<_> = report.infos().collect();
2359        assert_eq!(infos.len(), 1);
2360        assert_eq!(infos[0].code(), ErrorCodes::CONDITION_UNKNOWN);
2361    }
2362
2363    #[test]
2364    fn test_validate_structure_level_skips_conditions() {
2365        let evaluator = MockEvaluator::all_true(&[182, 152]);
2366        let validator = EdifactValidator::new(evaluator);
2367        let external = NoOpExternalProvider;
2368
2369        let workflow = AhbWorkflow {
2370            pruefidentifikator: "11001".to_string(),
2371            description: "Test".to_string(),
2372            communication_direction: None,
2373            fields: vec![AhbFieldRule {
2374                segment_path: "NAD".to_string(),
2375                name: "Partnerrolle".to_string(),
2376                ahb_status: "Muss [182] ∧ [152]".to_string(),
2377                codes: vec![],
2378                parent_group_ahb_status: None,
2379                segment_ahb_status: None,
2380                ..Default::default()
2381            }],
2382            ub_definitions: BTreeMap::new(),
2383        };
2384
2385        // With Structure level, conditions are not checked
2386        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
2387
2388        // No AHB errors because conditions were not evaluated
2389        assert!(report.is_valid());
2390        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
2391    }
2392
2393    #[test]
2394    fn test_validate_empty_workflow_no_condition_errors() {
2395        let evaluator = MockEvaluator::all_true(&[]);
2396        let validator = EdifactValidator::new(evaluator);
2397        let external = NoOpExternalProvider;
2398
2399        let empty_workflow = AhbWorkflow {
2400            pruefidentifikator: String::new(),
2401            description: String::new(),
2402            communication_direction: None,
2403            fields: vec![],
2404            ub_definitions: BTreeMap::new(),
2405        };
2406
2407        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
2408
2409        assert!(report.is_valid());
2410    }
2411
2412    #[test]
2413    fn test_validate_bare_muss_always_required() {
2414        let evaluator = MockEvaluator::new(vec![]);
2415        let validator = EdifactValidator::new(evaluator);
2416        let external = NoOpExternalProvider;
2417
2418        let workflow = AhbWorkflow {
2419            pruefidentifikator: "55001".to_string(),
2420            description: "Test".to_string(),
2421            communication_direction: Some("NB an LF".to_string()),
2422            fields: vec![AhbFieldRule {
2423                segment_path: "SG2/NAD/3035".to_string(),
2424                name: "Partnerrolle".to_string(),
2425                ahb_status: "Muss".to_string(), // No conditions
2426                codes: vec![],
2427                parent_group_ahb_status: None,
2428                segment_ahb_status: None,
2429                ..Default::default()
2430            }],
2431            ub_definitions: BTreeMap::new(),
2432        };
2433
2434        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2435
2436        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
2437        assert!(!report.is_valid());
2438        assert_eq!(report.error_count(), 1);
2439    }
2440
2441    #[test]
2442    fn test_validate_x_status_is_mandatory() {
2443        let evaluator = MockEvaluator::new(vec![]);
2444        let validator = EdifactValidator::new(evaluator);
2445        let external = NoOpExternalProvider;
2446
2447        let workflow = AhbWorkflow {
2448            pruefidentifikator: "55001".to_string(),
2449            description: "Test".to_string(),
2450            communication_direction: None,
2451            fields: vec![AhbFieldRule {
2452                segment_path: "DTM".to_string(),
2453                name: "Datum".to_string(),
2454                ahb_status: "X".to_string(),
2455                codes: vec![],
2456                parent_group_ahb_status: None,
2457                segment_ahb_status: None,
2458                ..Default::default()
2459            }],
2460            ub_definitions: BTreeMap::new(),
2461        };
2462
2463        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2464
2465        assert!(!report.is_valid());
2466        let errors: Vec<_> = report.errors().collect();
2467        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
2468    }
2469
2470    #[test]
2471    fn test_validate_soll_not_mandatory() {
2472        let evaluator = MockEvaluator::new(vec![]);
2473        let validator = EdifactValidator::new(evaluator);
2474        let external = NoOpExternalProvider;
2475
2476        let workflow = AhbWorkflow {
2477            pruefidentifikator: "55001".to_string(),
2478            description: "Test".to_string(),
2479            communication_direction: None,
2480            fields: vec![AhbFieldRule {
2481                segment_path: "DTM".to_string(),
2482                name: "Datum".to_string(),
2483                ahb_status: "Soll".to_string(),
2484                codes: vec![],
2485                parent_group_ahb_status: None,
2486                segment_ahb_status: None,
2487                ..Default::default()
2488            }],
2489            ub_definitions: BTreeMap::new(),
2490        };
2491
2492        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2493
2494        // Soll is not mandatory, so missing is not an error
2495        assert!(report.is_valid());
2496    }
2497
2498    #[test]
2499    fn test_report_includes_metadata() {
2500        let evaluator = MockEvaluator::new(vec![]);
2501        let validator = EdifactValidator::new(evaluator);
2502        let external = NoOpExternalProvider;
2503
2504        let workflow = AhbWorkflow {
2505            pruefidentifikator: "55001".to_string(),
2506            description: String::new(),
2507            communication_direction: None,
2508            fields: vec![],
2509            ub_definitions: BTreeMap::new(),
2510        };
2511
2512        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
2513
2514        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
2515        assert_eq!(report.level, ValidationLevel::Full);
2516        assert_eq!(report.message_type, "UTILMD");
2517        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
2518    }
2519
2520    #[test]
2521    fn test_validate_with_navigator_returns_report() {
2522        let evaluator = MockEvaluator::all_true(&[]);
2523        let validator = EdifactValidator::new(evaluator);
2524        let external = NoOpExternalProvider;
2525        let nav = crate::eval::NoOpGroupNavigator;
2526
2527        let workflow = AhbWorkflow {
2528            pruefidentifikator: "55001".to_string(),
2529            description: "Test".to_string(),
2530            communication_direction: None,
2531            fields: vec![],
2532            ub_definitions: BTreeMap::new(),
2533        };
2534
2535        let report = validator.validate_with_navigator(
2536            &[],
2537            &workflow,
2538            &external,
2539            ValidationLevel::Full,
2540            &nav,
2541        );
2542        assert!(report.is_valid());
2543    }
2544
2545    #[test]
2546    fn test_code_validation_composite_paths_valid_codes() {
2547        // UNH/S009/0065 and UNH/S009/0052 carry codes inside composite S009 at
2548        // element_index=1, component sub_index 0 and 1. Validation should match
2549        // the actual composite positions and find no errors for matching values.
2550        let evaluator = MockEvaluator::new(vec![]);
2551        let validator = EdifactValidator::new(evaluator);
2552        let external = NoOpExternalProvider;
2553
2554        let unh_segment = OwnedSegment {
2555            id: "UNH".to_string(),
2556            elements: vec![
2557                vec!["ALEXANDE951842".to_string()],
2558                vec![
2559                    "UTILMD".to_string(),
2560                    "D".to_string(),
2561                    "11A".to_string(),
2562                    "UN".to_string(),
2563                    "S2.1".to_string(),
2564                ],
2565            ],
2566            segment_number: 1,
2567        };
2568
2569        let workflow = AhbWorkflow {
2570            pruefidentifikator: "55001".to_string(),
2571            description: "Test".to_string(),
2572            communication_direction: None,
2573            fields: vec![
2574                AhbFieldRule {
2575                    segment_path: "UNH/S009/0065".to_string(),
2576                    name: "Nachrichtentyp".to_string(),
2577                    ahb_status: "X".to_string(),
2578                    codes: vec![AhbCodeRule {
2579                        value: "UTILMD".to_string(),
2580                        description: "Stammdaten".to_string(),
2581                        ahb_status: "X".to_string(),
2582                    }],
2583                    parent_group_ahb_status: None,
2584                    segment_ahb_status: None,
2585                    element_index: Some(1),
2586                    component_index: Some(0),
2587                    ..Default::default()
2588                },
2589                AhbFieldRule {
2590                    segment_path: "UNH/S009/0052".to_string(),
2591                    name: "Version".to_string(),
2592                    ahb_status: "X".to_string(),
2593                    codes: vec![AhbCodeRule {
2594                        value: "D".to_string(),
2595                        description: "Draft".to_string(),
2596                        ahb_status: "X".to_string(),
2597                    }],
2598                    parent_group_ahb_status: None,
2599                    segment_ahb_status: None,
2600                    element_index: Some(1),
2601                    component_index: Some(1),
2602                    ..Default::default()
2603                },
2604            ],
2605            ub_definitions: BTreeMap::new(),
2606        };
2607
2608        let report = validator.validate(
2609            &[unh_segment],
2610            &workflow,
2611            &external,
2612            ValidationLevel::Conditions,
2613        );
2614
2615        let code_errors: Vec<_> = report
2616            .by_category(ValidationCategory::Code)
2617            .filter(|i| i.severity == Severity::Error)
2618            .collect();
2619        assert!(
2620            code_errors.is_empty(),
2621            "Expected no code errors when composite values match allowed codes, got: {:?}",
2622            code_errors
2623        );
2624    }
2625
2626    #[test]
2627    fn test_code_validation_partitions_by_mig_number() {
2628        // PID 55018 has two STS migs: 00035 (Statuskategorie=7) constrains
2629        // element 2 to {E03}; 00036 (Statuskategorie=E01) has no constraint there.
2630        // Unioning codes across migs would flag A99 in STS+E01 — it must not.
2631        let evaluator = MockEvaluator::new(vec![]);
2632        let validator = EdifactValidator::new(evaluator);
2633        let external = NoOpExternalProvider;
2634
2635        let sts_7 = OwnedSegment {
2636            id: "STS".to_string(),
2637            elements: vec![
2638                vec!["7".to_string()],
2639                vec![String::new()],
2640                vec!["GH02".to_string()],
2641                vec!["ZW4".to_string()],
2642            ],
2643            segment_number: 1,
2644        };
2645        let sts_e01 = OwnedSegment {
2646            id: "STS".to_string(),
2647            elements: vec![
2648                vec!["E01".to_string()],
2649                vec![String::new()],
2650                vec!["A99".to_string(), "E_0614".to_string()],
2651            ],
2652            segment_number: 2,
2653        };
2654
2655        let workflow = AhbWorkflow {
2656            pruefidentifikator: "55018".to_string(),
2657            description: "Test".to_string(),
2658            communication_direction: None,
2659            fields: vec![
2660                // Mig 00035: qualifier 7 at element 0, E03 required at element 2.
2661                AhbFieldRule {
2662                    segment_path: "SG4/STS/C601/9015".to_string(),
2663                    name: "Statuskategorie".to_string(),
2664                    ahb_status: "X".to_string(),
2665                    codes: vec![AhbCodeRule {
2666                        value: "7".to_string(),
2667                        description: "Transaktionsgrund".to_string(),
2668                        ahb_status: "X".to_string(),
2669                    }],
2670                    parent_group_ahb_status: None,
2671                    segment_ahb_status: None,
2672                    element_index: Some(0),
2673                    component_index: Some(0),
2674                    mig_number: Some("00035".to_string()),
2675                    format: None,
2676                },
2677                AhbFieldRule {
2678                    segment_path: "SG4/STS/C556/9013".to_string(),
2679                    name: "Statusanlaß".to_string(),
2680                    ahb_status: "X".to_string(),
2681                    codes: vec![AhbCodeRule {
2682                        value: "E03".to_string(),
2683                        description: "Transaktionsgrund".to_string(),
2684                        ahb_status: "X".to_string(),
2685                    }],
2686                    parent_group_ahb_status: None,
2687                    segment_ahb_status: None,
2688                    element_index: Some(2),
2689                    component_index: Some(0),
2690                    mig_number: Some("00035".to_string()),
2691                    format: None,
2692                },
2693                // Mig 00036: qualifier E01 at element 0, no codes at element 2.
2694                AhbFieldRule {
2695                    segment_path: "SG4/STS/C601/9015".to_string(),
2696                    name: "Statuskategorie".to_string(),
2697                    ahb_status: "X".to_string(),
2698                    codes: vec![AhbCodeRule {
2699                        value: "E01".to_string(),
2700                        description: "Antwort".to_string(),
2701                        ahb_status: "X".to_string(),
2702                    }],
2703                    parent_group_ahb_status: None,
2704                    segment_ahb_status: None,
2705                    element_index: Some(0),
2706                    component_index: Some(0),
2707                    mig_number: Some("00036".to_string()),
2708                    format: None,
2709                },
2710            ],
2711            ub_definitions: BTreeMap::new(),
2712        };
2713
2714        let report = validator.validate(
2715            &[sts_7, sts_e01],
2716            &workflow,
2717            &external,
2718            ValidationLevel::Conditions,
2719        );
2720
2721        let code_errors: Vec<_> = report
2722            .by_category(ValidationCategory::Code)
2723            .filter(|i| {
2724                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2725            })
2726            .collect();
2727        assert_eq!(
2728            code_errors.len(),
2729            1,
2730            "Expected one COD002 (for GH02 only), got: {:?}",
2731            code_errors
2732        );
2733        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2734    }
2735
2736    #[test]
2737    fn test_code_validation_composite_paths_detects_invalid_code() {
2738        // Mirror of the STS+7 case that motivated composite validation: a bad code
2739        // at element_index=2, component_index=0 must produce COD002.
2740        let evaluator = MockEvaluator::new(vec![]);
2741        let validator = EdifactValidator::new(evaluator);
2742        let external = NoOpExternalProvider;
2743
2744        let sts_segment = OwnedSegment {
2745            id: "STS".to_string(),
2746            elements: vec![
2747                vec!["7".to_string()],
2748                vec![String::new()],
2749                vec!["GH02".to_string()],
2750                vec!["ZW4".to_string()],
2751            ],
2752            segment_number: 1,
2753        };
2754
2755        let workflow = AhbWorkflow {
2756            pruefidentifikator: "55018".to_string(),
2757            description: "Test".to_string(),
2758            communication_direction: None,
2759            fields: vec![AhbFieldRule {
2760                segment_path: "SG4/STS/C556/9013".to_string(),
2761                name: "Statusanlaß".to_string(),
2762                ahb_status: "X".to_string(),
2763                codes: vec![AhbCodeRule {
2764                    value: "E03".to_string(),
2765                    description: "Transaktionsgrund".to_string(),
2766                    ahb_status: "X".to_string(),
2767                }],
2768                parent_group_ahb_status: None,
2769                segment_ahb_status: None,
2770                element_index: Some(2),
2771                component_index: Some(0),
2772                ..Default::default()
2773            }],
2774            ub_definitions: BTreeMap::new(),
2775        };
2776
2777        let report = validator.validate(
2778            &[sts_segment],
2779            &workflow,
2780            &external,
2781            ValidationLevel::Conditions,
2782        );
2783
2784        let code_errors: Vec<_> = report
2785            .by_category(ValidationCategory::Code)
2786            .filter(|i| {
2787                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
2788            })
2789            .collect();
2790        assert_eq!(
2791            code_errors.len(),
2792            1,
2793            "Expected COD002 for GH02, got: {:?}",
2794            code_errors
2795        );
2796        assert_eq!(code_errors[0].actual_value.as_deref(), Some("GH02"));
2797    }
2798
2799    #[test]
2800    fn test_cross_field_code_validation_valid_qualifiers() {
2801        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
2802        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
2803        let evaluator = MockEvaluator::new(vec![]);
2804        let validator = EdifactValidator::new(evaluator);
2805        let external = NoOpExternalProvider;
2806
2807        let nad_ms = OwnedSegment {
2808            id: "NAD".to_string(),
2809            elements: vec![vec!["MS".to_string()]],
2810            segment_number: 4,
2811        };
2812        let nad_mr = OwnedSegment {
2813            id: "NAD".to_string(),
2814            elements: vec![vec!["MR".to_string()]],
2815            segment_number: 5,
2816        };
2817
2818        let workflow = AhbWorkflow {
2819            pruefidentifikator: "55001".to_string(),
2820            description: "Test".to_string(),
2821            communication_direction: None,
2822            fields: vec![
2823                AhbFieldRule {
2824                    segment_path: "SG2/NAD/3035".to_string(),
2825                    name: "Absender".to_string(),
2826                    ahb_status: "X".to_string(),
2827                    codes: vec![AhbCodeRule {
2828                        value: "MS".to_string(),
2829                        description: "Absender".to_string(),
2830                        ahb_status: "X".to_string(),
2831                    }],
2832                    parent_group_ahb_status: None,
2833                    segment_ahb_status: None,
2834                    ..Default::default()
2835                },
2836                AhbFieldRule {
2837                    segment_path: "SG2/NAD/3035".to_string(),
2838                    name: "Empfaenger".to_string(),
2839                    ahb_status: "X".to_string(),
2840                    codes: vec![AhbCodeRule {
2841                        value: "MR".to_string(),
2842                        description: "Empfaenger".to_string(),
2843                        ahb_status: "X".to_string(),
2844                    }],
2845                    parent_group_ahb_status: None,
2846                    segment_ahb_status: None,
2847                    ..Default::default()
2848                },
2849            ],
2850            ub_definitions: BTreeMap::new(),
2851        };
2852
2853        let report = validator.validate(
2854            &[nad_ms, nad_mr],
2855            &workflow,
2856            &external,
2857            ValidationLevel::Conditions,
2858        );
2859
2860        let code_errors: Vec<_> = report
2861            .by_category(ValidationCategory::Code)
2862            .filter(|i| i.severity == Severity::Error)
2863            .collect();
2864        assert!(
2865            code_errors.is_empty(),
2866            "Expected no code errors for valid qualifiers, got: {:?}",
2867            code_errors
2868        );
2869    }
2870
2871    #[test]
2872    fn test_cross_field_code_validation_catches_invalid_qualifier() {
2873        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
2874        let evaluator = MockEvaluator::new(vec![]);
2875        let validator = EdifactValidator::new(evaluator);
2876        let external = NoOpExternalProvider;
2877
2878        let nad_ms = OwnedSegment {
2879            id: "NAD".to_string(),
2880            elements: vec![vec!["MS".to_string()]],
2881            segment_number: 4,
2882        };
2883        let nad_mt = OwnedSegment {
2884            id: "NAD".to_string(),
2885            elements: vec![vec!["MT".to_string()]], // invalid
2886            segment_number: 5,
2887        };
2888
2889        let workflow = AhbWorkflow {
2890            pruefidentifikator: "55001".to_string(),
2891            description: "Test".to_string(),
2892            communication_direction: None,
2893            fields: vec![
2894                AhbFieldRule {
2895                    segment_path: "SG2/NAD/3035".to_string(),
2896                    name: "Absender".to_string(),
2897                    ahb_status: "X".to_string(),
2898                    codes: vec![AhbCodeRule {
2899                        value: "MS".to_string(),
2900                        description: "Absender".to_string(),
2901                        ahb_status: "X".to_string(),
2902                    }],
2903                    parent_group_ahb_status: None,
2904                    segment_ahb_status: None,
2905                    ..Default::default()
2906                },
2907                AhbFieldRule {
2908                    segment_path: "SG2/NAD/3035".to_string(),
2909                    name: "Empfaenger".to_string(),
2910                    ahb_status: "X".to_string(),
2911                    codes: vec![AhbCodeRule {
2912                        value: "MR".to_string(),
2913                        description: "Empfaenger".to_string(),
2914                        ahb_status: "X".to_string(),
2915                    }],
2916                    parent_group_ahb_status: None,
2917                    segment_ahb_status: None,
2918                    ..Default::default()
2919                },
2920            ],
2921            ub_definitions: BTreeMap::new(),
2922        };
2923
2924        let report = validator.validate(
2925            &[nad_ms, nad_mt],
2926            &workflow,
2927            &external,
2928            ValidationLevel::Conditions,
2929        );
2930
2931        let code_errors: Vec<_> = report
2932            .by_category(ValidationCategory::Code)
2933            .filter(|i| i.severity == Severity::Error)
2934            .collect();
2935        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
2936        assert!(narrate(code_errors[0]).contains("MT"));
2937        assert!(narrate(code_errors[0]).contains("MR"));
2938        assert!(narrate(code_errors[0]).contains("MS"));
2939    }
2940
2941    #[test]
2942    fn test_cross_field_code_validation_unions_across_groups() {
2943        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
2944        // Since find_segments("NAD") returns all NADs, codes must be unioned
2945        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
2946        let evaluator = MockEvaluator::new(vec![]);
2947        let validator = EdifactValidator::new(evaluator);
2948        let external = NoOpExternalProvider;
2949
2950        let segments = vec![
2951            OwnedSegment {
2952                id: "NAD".to_string(),
2953                elements: vec![vec!["MS".to_string()]],
2954                segment_number: 3,
2955            },
2956            OwnedSegment {
2957                id: "NAD".to_string(),
2958                elements: vec![vec!["MR".to_string()]],
2959                segment_number: 4,
2960            },
2961            OwnedSegment {
2962                id: "NAD".to_string(),
2963                elements: vec![vec!["Z04".to_string()]],
2964                segment_number: 20,
2965            },
2966            OwnedSegment {
2967                id: "NAD".to_string(),
2968                elements: vec![vec!["Z09".to_string()]],
2969                segment_number: 21,
2970            },
2971            OwnedSegment {
2972                id: "NAD".to_string(),
2973                elements: vec![vec!["MT".to_string()]], // invalid
2974                segment_number: 22,
2975            },
2976        ];
2977
2978        let workflow = AhbWorkflow {
2979            pruefidentifikator: "55001".to_string(),
2980            description: "Test".to_string(),
2981            communication_direction: None,
2982            fields: vec![
2983                AhbFieldRule {
2984                    segment_path: "SG2/NAD/3035".to_string(),
2985                    name: "Absender".to_string(),
2986                    ahb_status: "X".to_string(),
2987                    codes: vec![AhbCodeRule {
2988                        value: "MS".to_string(),
2989                        description: "Absender".to_string(),
2990                        ahb_status: "X".to_string(),
2991                    }],
2992                    parent_group_ahb_status: None,
2993                    segment_ahb_status: None,
2994                    ..Default::default()
2995                },
2996                AhbFieldRule {
2997                    segment_path: "SG2/NAD/3035".to_string(),
2998                    name: "Empfaenger".to_string(),
2999                    ahb_status: "X".to_string(),
3000                    codes: vec![AhbCodeRule {
3001                        value: "MR".to_string(),
3002                        description: "Empfaenger".to_string(),
3003                        ahb_status: "X".to_string(),
3004                    }],
3005                    parent_group_ahb_status: None,
3006                    segment_ahb_status: None,
3007                    ..Default::default()
3008                },
3009                AhbFieldRule {
3010                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3011                    name: "Anschlussnutzer".to_string(),
3012                    ahb_status: "X".to_string(),
3013                    codes: vec![AhbCodeRule {
3014                        value: "Z04".to_string(),
3015                        description: "Anschlussnutzer".to_string(),
3016                        ahb_status: "X".to_string(),
3017                    }],
3018                    parent_group_ahb_status: None,
3019                    segment_ahb_status: None,
3020                    ..Default::default()
3021                },
3022                AhbFieldRule {
3023                    segment_path: "SG4/SG12/NAD/3035".to_string(),
3024                    name: "Korrespondenzanschrift".to_string(),
3025                    ahb_status: "X".to_string(),
3026                    codes: vec![AhbCodeRule {
3027                        value: "Z09".to_string(),
3028                        description: "Korrespondenzanschrift".to_string(),
3029                        ahb_status: "X".to_string(),
3030                    }],
3031                    parent_group_ahb_status: None,
3032                    segment_ahb_status: None,
3033                    ..Default::default()
3034                },
3035            ],
3036            ub_definitions: BTreeMap::new(),
3037        };
3038
3039        let report =
3040            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3041
3042        let code_errors: Vec<_> = report
3043            .by_category(ValidationCategory::Code)
3044            .filter(|i| i.severity == Severity::Error)
3045            .collect();
3046        assert_eq!(
3047            code_errors.len(),
3048            1,
3049            "Expected exactly one COD002 error for MT, got: {:?}",
3050            code_errors
3051        );
3052        assert!(narrate(code_errors[0]).contains("MT"));
3053    }
3054
3055    #[test]
3056    fn test_cross_field_code_validation_accepts_conditionally_allowed_codes() {
3057        // QTY 6063 in MSCONS PID 13025 has three AHB-allowed codes:
3058        //   220  ahb_status "X"                        (unconditional)
3059        //   67   ahb_status "X [35] ∨ ([32] ∧ [77])"   (conditional)
3060        //   Z18  ahb_status "X [35]"                    (conditional)
3061        // All three are "allowed codes for this PID" — they differ only in when
3062        // they may be used. The COD002 "code not allowed for this PID" check
3063        // must accept the conditional ones too, otherwise real-world load-profile
3064        // messages using QTY+67 (Ersatzwert) are incorrectly rejected.
3065        let evaluator = MockEvaluator::new(vec![]);
3066        let validator = EdifactValidator::new(evaluator);
3067        let external = NoOpExternalProvider;
3068
3069        let qty_67 = OwnedSegment {
3070            id: "QTY".to_string(),
3071            elements: vec![vec!["67".to_string(), "0.185".to_string()]],
3072            segment_number: 10,
3073        };
3074
3075        let workflow = AhbWorkflow {
3076            pruefidentifikator: "13025".to_string(),
3077            description: "Test".to_string(),
3078            communication_direction: None,
3079            fields: vec![AhbFieldRule {
3080                segment_path: "SG5/SG6/SG9/SG10/QTY/qualifier".to_string(),
3081                name: "Menge, Qualifier".to_string(),
3082                ahb_status: "X".to_string(),
3083                codes: vec![
3084                    AhbCodeRule {
3085                        value: "220".to_string(),
3086                        description: "Wahrer Wert".to_string(),
3087                        ahb_status: "X".to_string(),
3088                    },
3089                    AhbCodeRule {
3090                        value: "67".to_string(),
3091                        description: "Ersatzwert".to_string(),
3092                        ahb_status: "X [35] ∨ ([32] ∧ [77])".to_string(),
3093                    },
3094                    AhbCodeRule {
3095                        value: "Z18".to_string(),
3096                        description: "Vorläufiger Wert".to_string(),
3097                        ahb_status: "X [35]".to_string(),
3098                    },
3099                ],
3100                parent_group_ahb_status: None,
3101                segment_ahb_status: None,
3102                element_index: Some(0),
3103                component_index: Some(0),
3104                ..Default::default()
3105            }],
3106            ub_definitions: BTreeMap::new(),
3107        };
3108
3109        let report =
3110            validator.validate(&[qty_67], &workflow, &external, ValidationLevel::Conditions);
3111
3112        let code_errors: Vec<_> = report
3113            .by_category(ValidationCategory::Code)
3114            .filter(|i| i.severity == Severity::Error)
3115            .collect();
3116        assert!(
3117            code_errors.is_empty(),
3118            "QTY+67 should be accepted because code '67' is conditionally allowed for this PID (X [35] ∨ ([32] ∧ [77])). Got errors: {:?}",
3119            code_errors
3120        );
3121    }
3122
3123    #[test]
3124    fn test_is_qualifier_field_simple_paths() {
3125        assert!(is_qualifier_field("NAD/3035"));
3126        assert!(is_qualifier_field("SG2/NAD/3035"));
3127        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
3128        assert!(is_qualifier_field("LOC/3227"));
3129    }
3130
3131    #[test]
3132    fn test_is_qualifier_field_composite_paths() {
3133        // Composite paths (3 parts after SG stripping) are accepted so composite-level
3134        // codes like STS/C556/9013 get validated. The field rule's element_index and
3135        // component_index carry the exact position.
3136        assert!(is_qualifier_field("UNH/S009/0065"));
3137        assert!(is_qualifier_field("NAD/C082/3039"));
3138        assert!(is_qualifier_field("SG2/NAD/C082/3039"));
3139        assert!(is_qualifier_field("SG4/STS/C556/9013"));
3140    }
3141
3142    #[test]
3143    fn test_is_qualifier_field_bare_segment() {
3144        assert!(!is_qualifier_field("NAD"));
3145        assert!(!is_qualifier_field("SG2/NAD"));
3146    }
3147
3148    #[test]
3149    fn test_is_qualifier_field_rejects_deep_paths() {
3150        // 4+ parts after SG stripping are malformed — reject.
3151        assert!(!is_qualifier_field("SEG/A/B/C/D"));
3152    }
3153
3154    #[test]
3155    fn test_missing_qualifier_instance_is_detected() {
3156        // NAD+MS is present but NAD+MR is missing.
3157        // The Empfaenger field requires [MR] → should produce AHB001.
3158        let evaluator = MockEvaluator::new(vec![]);
3159        let validator = EdifactValidator::new(evaluator);
3160        let external = NoOpExternalProvider;
3161
3162        let nad_ms = OwnedSegment {
3163            id: "NAD".to_string(),
3164            elements: vec![vec!["MS".to_string()]],
3165            segment_number: 3,
3166        };
3167
3168        let workflow = AhbWorkflow {
3169            pruefidentifikator: "55001".to_string(),
3170            description: "Test".to_string(),
3171            communication_direction: None,
3172            fields: vec![
3173                AhbFieldRule {
3174                    segment_path: "SG2/NAD/3035".to_string(),
3175                    name: "Absender".to_string(),
3176                    ahb_status: "X".to_string(),
3177                    codes: vec![AhbCodeRule {
3178                        value: "MS".to_string(),
3179                        description: "Absender".to_string(),
3180                        ahb_status: "X".to_string(),
3181                    }],
3182                    parent_group_ahb_status: None,
3183                    segment_ahb_status: None,
3184                    ..Default::default()
3185                },
3186                AhbFieldRule {
3187                    segment_path: "SG2/NAD/3035".to_string(),
3188                    name: "Empfaenger".to_string(),
3189                    ahb_status: "Muss".to_string(),
3190                    codes: vec![AhbCodeRule {
3191                        value: "MR".to_string(),
3192                        description: "Empfaenger".to_string(),
3193                        ahb_status: "X".to_string(),
3194                    }],
3195                    parent_group_ahb_status: None,
3196                    segment_ahb_status: None,
3197                    ..Default::default()
3198                },
3199            ],
3200            ub_definitions: BTreeMap::new(),
3201        };
3202
3203        let report =
3204            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
3205
3206        let ahb_errors: Vec<_> = report
3207            .by_category(ValidationCategory::Ahb)
3208            .filter(|i| i.severity == Severity::Error)
3209            .collect();
3210        assert_eq!(
3211            ahb_errors.len(),
3212            1,
3213            "Expected AHB001 for missing NAD+MR, got: {:?}",
3214            ahb_errors
3215        );
3216        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3217    }
3218
3219    #[test]
3220    fn test_present_qualifier_instance_no_error() {
3221        // Both NAD+MS and NAD+MR present → no AHB001 for either.
3222        let evaluator = MockEvaluator::new(vec![]);
3223        let validator = EdifactValidator::new(evaluator);
3224        let external = NoOpExternalProvider;
3225
3226        let segments = vec![
3227            OwnedSegment {
3228                id: "NAD".to_string(),
3229                elements: vec![vec!["MS".to_string()]],
3230                segment_number: 3,
3231            },
3232            OwnedSegment {
3233                id: "NAD".to_string(),
3234                elements: vec![vec!["MR".to_string()]],
3235                segment_number: 4,
3236            },
3237        ];
3238
3239        let workflow = AhbWorkflow {
3240            pruefidentifikator: "55001".to_string(),
3241            description: "Test".to_string(),
3242            communication_direction: None,
3243            fields: vec![
3244                AhbFieldRule {
3245                    segment_path: "SG2/NAD/3035".to_string(),
3246                    name: "Absender".to_string(),
3247                    ahb_status: "Muss".to_string(),
3248                    codes: vec![AhbCodeRule {
3249                        value: "MS".to_string(),
3250                        description: "Absender".to_string(),
3251                        ahb_status: "X".to_string(),
3252                    }],
3253                    parent_group_ahb_status: None,
3254                    segment_ahb_status: None,
3255                    ..Default::default()
3256                },
3257                AhbFieldRule {
3258                    segment_path: "SG2/NAD/3035".to_string(),
3259                    name: "Empfaenger".to_string(),
3260                    ahb_status: "Muss".to_string(),
3261                    codes: vec![AhbCodeRule {
3262                        value: "MR".to_string(),
3263                        description: "Empfaenger".to_string(),
3264                        ahb_status: "X".to_string(),
3265                    }],
3266                    parent_group_ahb_status: None,
3267                    segment_ahb_status: None,
3268                    ..Default::default()
3269                },
3270            ],
3271            ub_definitions: BTreeMap::new(),
3272        };
3273
3274        let report =
3275            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
3276
3277        let ahb_errors: Vec<_> = report
3278            .by_category(ValidationCategory::Ahb)
3279            .filter(|i| i.severity == Severity::Error)
3280            .collect();
3281        assert!(
3282            ahb_errors.is_empty(),
3283            "Expected no AHB001 errors, got: {:?}",
3284            ahb_errors
3285        );
3286    }
3287
3288    #[test]
3289    fn test_extract_group_path_key() {
3290        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
3291        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
3292        assert_eq!(extract_group_path_key("NAD/3035"), "");
3293        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
3294    }
3295
3296    /// Validate a field of an absent SG9 whose group status is `group_status`.
3297    fn validate_field_of_absent_group(
3298        group_status: &str,
3299        conditions: Vec<(u32, CR)>,
3300    ) -> ValidationReport {
3301        use mig_types::navigator::GroupNavigator;
3302
3303        struct NoSg9;
3304        impl GroupNavigator for NoSg9 {
3305            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3306                vec![]
3307            }
3308            fn find_segments_with_qualifier_in_group(
3309                &self,
3310                _: &str,
3311                _: usize,
3312                _: &str,
3313                _: &[&str],
3314                _: usize,
3315            ) -> Vec<OwnedSegment> {
3316                vec![]
3317            }
3318            fn group_instance_count(&self, _: &[&str]) -> usize {
3319                0
3320            }
3321        }
3322
3323        let validator = EdifactValidator::new(MockEvaluator::new(conditions));
3324        let workflow = AhbWorkflow {
3325            pruefidentifikator: "44001".to_string(),
3326            description: "Test".to_string(),
3327            communication_direction: None,
3328            fields: vec![AhbFieldRule {
3329                segment_path: "SG9/QTY/C186/6060".to_string(),
3330                name: "Menge".to_string(),
3331                ahb_status: "X".to_string(),
3332                parent_group_ahb_status: Some(group_status.to_string()),
3333                ..Default::default()
3334            }],
3335            ub_definitions: BTreeMap::new(),
3336        };
3337        validator.validate_with_navigator(
3338            &[],
3339            &workflow,
3340            &NoOpExternalProvider,
3341            ValidationLevel::Conditions,
3342            &NoSg9,
3343        )
3344    }
3345
3346    #[test]
3347    fn an_absent_group_whose_holding_line_is_soll_is_not_missing() {
3348        // UTILMD Gas SG9 "TUM Kundenwert": Soll [47] ∧ … / Muss [46] ∧ …
3349        let report = validate_field_of_absent_group(
3350            "Soll [47]\r\nMuss [46]",
3351            vec![(47, CR::True), (46, CR::False)],
3352        );
3353        assert!(
3354            report.is_valid(),
3355            "{:?}",
3356            report.errors().collect::<Vec<_>>()
3357        );
3358    }
3359
3360    #[test]
3361    fn an_absent_group_whose_holding_line_is_muss_is_missing() {
3362        let report = validate_field_of_absent_group(
3363            "Soll [47]\r\nMuss [46]",
3364            vec![(47, CR::False), (46, CR::True)],
3365        );
3366        let errors: Vec<_> = report.errors().collect();
3367        assert_eq!(errors.len(), 1, "{errors:?}");
3368        assert_eq!(errors[0].code(), ErrorCodes::MISSING_REQUIRED_FIELD);
3369    }
3370
3371    #[test]
3372    fn test_absent_optional_group_no_missing_field_error() {
3373        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
3374        // and CTA/C056/3412 should NOT produce AHB001 errors.
3375        use mig_types::navigator::GroupNavigator;
3376
3377        struct NavWithoutSG3;
3378        impl GroupNavigator for NavWithoutSG3 {
3379            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3380                vec![]
3381            }
3382            fn find_segments_with_qualifier_in_group(
3383                &self,
3384                _: &str,
3385                _: usize,
3386                _: &str,
3387                _: &[&str],
3388                _: usize,
3389            ) -> Vec<OwnedSegment> {
3390                vec![]
3391            }
3392            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3393                match group_path {
3394                    ["SG2"] => 2,        // two NAD groups present
3395                    ["SG2", "SG3"] => 0, // SG3 is absent
3396                    _ => 0,
3397                }
3398            }
3399        }
3400
3401        let evaluator = MockEvaluator::new(vec![]);
3402        let validator = EdifactValidator::new(evaluator);
3403        let external = NoOpExternalProvider;
3404        let nav = NavWithoutSG3;
3405
3406        // Only NAD segments present, no CTA
3407        let segments = vec![
3408            OwnedSegment {
3409                id: "NAD".into(),
3410                elements: vec![vec!["MS".into()]],
3411                segment_number: 3,
3412            },
3413            OwnedSegment {
3414                id: "NAD".into(),
3415                elements: vec![vec!["MR".into()]],
3416                segment_number: 4,
3417            },
3418        ];
3419
3420        let workflow = AhbWorkflow {
3421            pruefidentifikator: "55001".to_string(),
3422            description: "Test".to_string(),
3423            communication_direction: None,
3424            fields: vec![
3425                AhbFieldRule {
3426                    segment_path: "SG2/SG3/CTA/3139".to_string(),
3427                    name: "Funktion des Ansprechpartners, Code".to_string(),
3428                    ahb_status: "Muss".to_string(),
3429                    codes: vec![],
3430                    parent_group_ahb_status: None,
3431                    segment_ahb_status: None,
3432                    ..Default::default()
3433                },
3434                AhbFieldRule {
3435                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
3436                    name: "Name vom Ansprechpartner".to_string(),
3437                    ahb_status: "X".to_string(),
3438                    codes: vec![],
3439                    parent_group_ahb_status: None,
3440                    segment_ahb_status: None,
3441                    ..Default::default()
3442                },
3443            ],
3444            ub_definitions: BTreeMap::new(),
3445        };
3446
3447        let report = validator.validate_with_navigator(
3448            &segments,
3449            &workflow,
3450            &external,
3451            ValidationLevel::Conditions,
3452            &nav,
3453        );
3454
3455        let ahb_errors: Vec<_> = report
3456            .by_category(ValidationCategory::Ahb)
3457            .filter(|i| i.severity == Severity::Error)
3458            .collect();
3459        assert!(
3460            ahb_errors.is_empty(),
3461            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
3462            ahb_errors
3463        );
3464    }
3465
3466    #[test]
3467    fn test_present_group_still_checks_mandatory_fields() {
3468        // If SG3 IS present but CTA is missing within it → AHB001 error.
3469        use mig_types::navigator::GroupNavigator;
3470
3471        struct NavWithSG3;
3472        impl GroupNavigator for NavWithSG3 {
3473            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
3474                vec![]
3475            }
3476            fn find_segments_with_qualifier_in_group(
3477                &self,
3478                _: &str,
3479                _: usize,
3480                _: &str,
3481                _: &[&str],
3482                _: usize,
3483            ) -> Vec<OwnedSegment> {
3484                vec![]
3485            }
3486            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3487                match group_path {
3488                    ["SG2"] => 1,
3489                    ["SG2", "SG3"] => 1, // SG3 is present
3490                    _ => 0,
3491                }
3492            }
3493        }
3494
3495        let evaluator = MockEvaluator::new(vec![]);
3496        let validator = EdifactValidator::new(evaluator);
3497        let external = NoOpExternalProvider;
3498        let nav = NavWithSG3;
3499
3500        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
3501        let segments = vec![OwnedSegment {
3502            id: "NAD".into(),
3503            elements: vec![vec!["MS".into()]],
3504            segment_number: 3,
3505        }];
3506
3507        let workflow = AhbWorkflow {
3508            pruefidentifikator: "55001".to_string(),
3509            description: "Test".to_string(),
3510            communication_direction: None,
3511            fields: vec![AhbFieldRule {
3512                segment_path: "SG2/SG3/CTA/3139".to_string(),
3513                name: "Funktion des Ansprechpartners, Code".to_string(),
3514                ahb_status: "Muss".to_string(),
3515                codes: vec![],
3516                parent_group_ahb_status: None,
3517                segment_ahb_status: None,
3518                ..Default::default()
3519            }],
3520            ub_definitions: BTreeMap::new(),
3521        };
3522
3523        let report = validator.validate_with_navigator(
3524            &segments,
3525            &workflow,
3526            &external,
3527            ValidationLevel::Conditions,
3528            &nav,
3529        );
3530
3531        let ahb_errors: Vec<_> = report
3532            .by_category(ValidationCategory::Ahb)
3533            .filter(|i| i.severity == Severity::Error)
3534            .collect();
3535        assert_eq!(
3536            ahb_errors.len(),
3537            1,
3538            "Expected AHB001 error when SG3 is present but CTA missing"
3539        );
3540        assert!(narrate(ahb_errors[0]).contains("CTA"));
3541    }
3542
3543    #[test]
3544    fn test_missing_qualifier_with_navigator_is_detected() {
3545        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
3546        // reports SG2 has 1 instance, the missing MR must still be flagged.
3547        use mig_types::navigator::GroupNavigator;
3548
3549        struct NavWithSG2;
3550        impl GroupNavigator for NavWithSG2 {
3551            fn find_segments_in_group(
3552                &self,
3553                segment_id: &str,
3554                group_path: &[&str],
3555                instance_index: usize,
3556            ) -> Vec<OwnedSegment> {
3557                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
3558                    vec![OwnedSegment {
3559                        id: "NAD".into(),
3560                        elements: vec![vec!["MS".into()]],
3561                        segment_number: 3,
3562                    }]
3563                } else {
3564                    vec![]
3565                }
3566            }
3567            fn find_segments_with_qualifier_in_group(
3568                &self,
3569                _: &str,
3570                _: usize,
3571                _: &str,
3572                _: &[&str],
3573                _: usize,
3574            ) -> Vec<OwnedSegment> {
3575                vec![]
3576            }
3577            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3578                match group_path {
3579                    ["SG2"] => 1,
3580                    _ => 0,
3581                }
3582            }
3583        }
3584
3585        let evaluator = MockEvaluator::new(vec![]);
3586        let validator = EdifactValidator::new(evaluator);
3587        let external = NoOpExternalProvider;
3588        let nav = NavWithSG2;
3589
3590        let segments = vec![OwnedSegment {
3591            id: "NAD".into(),
3592            elements: vec![vec!["MS".into()]],
3593            segment_number: 3,
3594        }];
3595
3596        let workflow = AhbWorkflow {
3597            pruefidentifikator: "55001".to_string(),
3598            description: "Test".to_string(),
3599            communication_direction: None,
3600            fields: vec![
3601                AhbFieldRule {
3602                    segment_path: "SG2/NAD/3035".to_string(),
3603                    name: "Absender".to_string(),
3604                    ahb_status: "X".to_string(),
3605                    codes: vec![AhbCodeRule {
3606                        value: "MS".to_string(),
3607                        description: "Absender".to_string(),
3608                        ahb_status: "X".to_string(),
3609                    }],
3610                    parent_group_ahb_status: None,
3611                    segment_ahb_status: None,
3612                    ..Default::default()
3613                },
3614                AhbFieldRule {
3615                    segment_path: "SG2/NAD/3035".to_string(),
3616                    name: "Empfaenger".to_string(),
3617                    ahb_status: "Muss".to_string(),
3618                    codes: vec![AhbCodeRule {
3619                        value: "MR".to_string(),
3620                        description: "Empfaenger".to_string(),
3621                        ahb_status: "X".to_string(),
3622                    }],
3623                    parent_group_ahb_status: None,
3624                    segment_ahb_status: None,
3625                    ..Default::default()
3626                },
3627            ],
3628            ub_definitions: BTreeMap::new(),
3629        };
3630
3631        let report = validator.validate_with_navigator(
3632            &segments,
3633            &workflow,
3634            &external,
3635            ValidationLevel::Conditions,
3636            &nav,
3637        );
3638
3639        let ahb_errors: Vec<_> = report
3640            .by_category(ValidationCategory::Ahb)
3641            .filter(|i| i.severity == Severity::Error)
3642            .collect();
3643        assert_eq!(
3644            ahb_errors.len(),
3645            1,
3646            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
3647            ahb_errors
3648        );
3649        assert!(narrate(ahb_errors[0]).contains("Empfaenger"));
3650    }
3651
3652    #[test]
3653    fn test_optional_group_variant_absent_no_error() {
3654        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
3655        // Field rules for LOC/3227 with Z17 and its children should NOT error
3656        // because the parent group is optional and the variant is absent.
3657        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
3658        use mig_types::navigator::GroupNavigator;
3659
3660        struct TestNav;
3661        impl GroupNavigator for TestNav {
3662            fn find_segments_in_group(
3663                &self,
3664                segment_id: &str,
3665                group_path: &[&str],
3666                instance_index: usize,
3667            ) -> Vec<OwnedSegment> {
3668                match (segment_id, group_path, instance_index) {
3669                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
3670                        id: "LOC".into(),
3671                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3672                        segment_number: 10,
3673                    }],
3674                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
3675                        id: "NAD".into(),
3676                        elements: vec![vec!["MS".into()]],
3677                        segment_number: 3,
3678                    }],
3679                    _ => vec![],
3680                }
3681            }
3682            fn find_segments_with_qualifier_in_group(
3683                &self,
3684                _: &str,
3685                _: usize,
3686                _: &str,
3687                _: &[&str],
3688                _: usize,
3689            ) -> Vec<OwnedSegment> {
3690                vec![]
3691            }
3692            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3693                match group_path {
3694                    ["SG2"] => 1,
3695                    ["SG4"] => 1,
3696                    ["SG4", "SG5"] => 1, // only Z16 instance
3697                    _ => 0,
3698                }
3699            }
3700        }
3701
3702        let evaluator = MockEvaluator::new(vec![]);
3703        let validator = EdifactValidator::new(evaluator);
3704        let external = NoOpExternalProvider;
3705        let nav = TestNav;
3706
3707        let segments = vec![
3708            OwnedSegment {
3709                id: "NAD".into(),
3710                elements: vec![vec!["MS".into()]],
3711                segment_number: 3,
3712            },
3713            OwnedSegment {
3714                id: "LOC".into(),
3715                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3716                segment_number: 10,
3717            },
3718        ];
3719
3720        let workflow = AhbWorkflow {
3721            pruefidentifikator: "55001".to_string(),
3722            description: "Test".to_string(),
3723            communication_direction: None,
3724            fields: vec![
3725                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
3726                AhbFieldRule {
3727                    segment_path: "SG2/NAD/3035".to_string(),
3728                    name: "Absender".to_string(),
3729                    ahb_status: "X".to_string(),
3730                    codes: vec![AhbCodeRule {
3731                        value: "MS".to_string(),
3732                        description: "Absender".to_string(),
3733                        ahb_status: "X".to_string(),
3734                    }],
3735                    parent_group_ahb_status: Some("Muss".to_string()),
3736                    segment_ahb_status: None,
3737                    ..Default::default()
3738                },
3739                AhbFieldRule {
3740                    segment_path: "SG2/NAD/3035".to_string(),
3741                    name: "Empfaenger".to_string(),
3742                    ahb_status: "Muss".to_string(),
3743                    codes: vec![AhbCodeRule {
3744                        value: "MR".to_string(),
3745                        description: "Empfaenger".to_string(),
3746                        ahb_status: "X".to_string(),
3747                    }],
3748                    parent_group_ahb_status: Some("Muss".to_string()),
3749                    segment_ahb_status: None,
3750                    ..Default::default()
3751                },
3752                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
3753                AhbFieldRule {
3754                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3755                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
3756                    ahb_status: "X".to_string(),
3757                    codes: vec![AhbCodeRule {
3758                        value: "Z16".to_string(),
3759                        description: "Marktlokation".to_string(),
3760                        ahb_status: "X".to_string(),
3761                    }],
3762                    parent_group_ahb_status: Some("Kann".to_string()),
3763                    segment_ahb_status: None,
3764                    ..Default::default()
3765                },
3766                AhbFieldRule {
3767                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3768                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
3769                    ahb_status: "Muss".to_string(),
3770                    codes: vec![AhbCodeRule {
3771                        value: "Z17".to_string(),
3772                        description: "Messlokation".to_string(),
3773                        ahb_status: "X".to_string(),
3774                    }],
3775                    parent_group_ahb_status: Some("Kann".to_string()),
3776                    segment_ahb_status: None,
3777                    ..Default::default()
3778                },
3779            ],
3780            ub_definitions: BTreeMap::new(),
3781        };
3782
3783        let report = validator.validate_with_navigator(
3784            &segments,
3785            &workflow,
3786            &external,
3787            ValidationLevel::Conditions,
3788            &nav,
3789        );
3790
3791        let ahb_errors: Vec<_> = report
3792            .by_category(ValidationCategory::Ahb)
3793            .filter(|i| i.severity == Severity::Error)
3794            .collect();
3795
3796        // Exactly 1 error: NAD+MR missing (mandatory group)
3797        // LOC+Z17 should NOT error (optional group variant absent)
3798        assert_eq!(
3799            ahb_errors.len(),
3800            1,
3801            "Expected only AHB001 for missing NAD+MR, got: {:?}",
3802            ahb_errors
3803        );
3804        assert!(
3805            narrate(ahb_errors[0]).contains("Empfaenger"),
3806            "Error should be for missing NAD+MR (Empfaenger)"
3807        );
3808    }
3809
3810    /// UTILMD Gas 44143: SG8 "Messdatenregistriergerätedaten" (SEQ+Z50) is
3811    /// `Kann [345]`. With [345] true the variant is allowed, not required, so its
3812    /// absence — beside a present SEQ+Z03 SG8 — must not report its `SEQ 1229`.
3813    #[test]
3814    fn test_optional_group_variant_with_true_condition_absent_no_error() {
3815        use mig_types::navigator::GroupNavigator;
3816
3817        struct TestNav;
3818        impl GroupNavigator for TestNav {
3819            fn find_segments_in_group(
3820                &self,
3821                segment_id: &str,
3822                group_path: &[&str],
3823                instance_index: usize,
3824            ) -> Vec<OwnedSegment> {
3825                if segment_id == "SEQ" && group_path == ["SG4", "SG8"] && instance_index == 0 {
3826                    vec![OwnedSegment {
3827                        id: "SEQ".into(),
3828                        elements: vec![vec!["Z03".into()]],
3829                        segment_number: 10,
3830                    }]
3831                } else {
3832                    vec![]
3833                }
3834            }
3835            fn find_segments_with_qualifier_in_group(
3836                &self,
3837                _: &str,
3838                _: usize,
3839                _: &str,
3840                _: &[&str],
3841                _: usize,
3842            ) -> Vec<OwnedSegment> {
3843                vec![]
3844            }
3845            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3846                match group_path {
3847                    ["SG4"] => 1,
3848                    ["SG4", "SG8"] => 1,
3849                    _ => 0,
3850                }
3851            }
3852        }
3853
3854        let evaluator = MockEvaluator::new(vec![(345, CR::True)]);
3855        let validator = EdifactValidator::new(evaluator);
3856        let segments = vec![OwnedSegment {
3857            id: "SEQ".into(),
3858            elements: vec![vec!["Z03".into()]],
3859            segment_number: 10,
3860        }];
3861        let seq = |code: &str, group: &str| AhbFieldRule {
3862            segment_path: "SG4/SG8/SEQ/1229".to_string(),
3863            name: format!("Handlung, Code ({code})"),
3864            ahb_status: "X".to_string(),
3865            codes: vec![AhbCodeRule {
3866                value: code.to_string(),
3867                description: String::new(),
3868                ahb_status: "X".to_string(),
3869            }],
3870            parent_group_ahb_status: Some(group.to_string()),
3871            segment_ahb_status: None,
3872            ..Default::default()
3873        };
3874        let workflow = AhbWorkflow {
3875            pruefidentifikator: "44143".to_string(),
3876            description: "Test".to_string(),
3877            communication_direction: None,
3878            fields: vec![seq("Z03", "Kann"), seq("Z50", "Kann [345]")],
3879            ub_definitions: BTreeMap::new(),
3880        };
3881
3882        let report = validator.validate_with_navigator(
3883            &segments,
3884            &workflow,
3885            &NoOpExternalProvider,
3886            ValidationLevel::Conditions,
3887            &TestNav,
3888        );
3889        let missing: Vec<_> = report
3890            .issues
3891            .iter()
3892            .filter(|i| matches!(i.kind, IssueKind::MissingRequiredField { .. }))
3893            .collect();
3894        assert!(missing.is_empty(), "{missing:#?}");
3895    }
3896
3897    #[test]
3898    fn test_conditional_group_variant_absent_no_error() {
3899        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
3900        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
3901        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
3902        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
3903        use mig_types::navigator::GroupNavigator;
3904
3905        struct TestNav;
3906        impl GroupNavigator for TestNav {
3907            fn find_segments_in_group(
3908                &self,
3909                segment_id: &str,
3910                group_path: &[&str],
3911                instance_index: usize,
3912            ) -> Vec<OwnedSegment> {
3913                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
3914                    vec![OwnedSegment {
3915                        id: "LOC".into(),
3916                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3917                        segment_number: 10,
3918                    }]
3919                } else {
3920                    vec![]
3921                }
3922            }
3923            fn find_segments_with_qualifier_in_group(
3924                &self,
3925                _: &str,
3926                _: usize,
3927                _: &str,
3928                _: &[&str],
3929                _: usize,
3930            ) -> Vec<OwnedSegment> {
3931                vec![]
3932            }
3933            fn group_instance_count(&self, group_path: &[&str]) -> usize {
3934                match group_path {
3935                    ["SG4"] => 1,
3936                    ["SG4", "SG5"] => 1, // only Z16 instance
3937                    _ => 0,
3938                }
3939            }
3940        }
3941
3942        // Condition 165 → False (Messlokation not required)
3943        // Condition 2061 → True (Marktlokation required)
3944        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
3945        let validator = EdifactValidator::new(evaluator);
3946        let external = NoOpExternalProvider;
3947        let nav = TestNav;
3948
3949        let segments = vec![OwnedSegment {
3950            id: "LOC".into(),
3951            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
3952            segment_number: 10,
3953        }];
3954
3955        let workflow = AhbWorkflow {
3956            pruefidentifikator: "55001".to_string(),
3957            description: "Test".to_string(),
3958            communication_direction: None,
3959            fields: vec![
3960                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
3961                AhbFieldRule {
3962                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3963                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
3964                    ahb_status: "X".to_string(),
3965                    codes: vec![AhbCodeRule {
3966                        value: "Z16".to_string(),
3967                        description: "Marktlokation".to_string(),
3968                        ahb_status: "X".to_string(),
3969                    }],
3970                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
3971                    segment_ahb_status: None,
3972                    ..Default::default()
3973                },
3974                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
3975                AhbFieldRule {
3976                    segment_path: "SG4/SG5/LOC/3227".to_string(),
3977                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
3978                    ahb_status: "X".to_string(),
3979                    codes: vec![AhbCodeRule {
3980                        value: "Z17".to_string(),
3981                        description: "Messlokation".to_string(),
3982                        ahb_status: "X".to_string(),
3983                    }],
3984                    parent_group_ahb_status: Some("Soll [165]".to_string()),
3985                    segment_ahb_status: None,
3986                    ..Default::default()
3987                },
3988            ],
3989            ub_definitions: BTreeMap::new(),
3990        };
3991
3992        let report = validator.validate_with_navigator(
3993            &segments,
3994            &workflow,
3995            &external,
3996            ValidationLevel::Conditions,
3997            &nav,
3998        );
3999
4000        let ahb_errors: Vec<_> = report
4001            .by_category(ValidationCategory::Ahb)
4002            .filter(|i| i.severity == Severity::Error)
4003            .collect();
4004
4005        // Zero errors: Z16 is present, and Z17's group condition is False
4006        assert!(
4007            ahb_errors.is_empty(),
4008            "Expected no errors when conditional group variant [165]=False, got: {:?}",
4009            ahb_errors
4010        );
4011    }
4012
4013    #[test]
4014    fn test_conditional_group_variant_unknown_no_error() {
4015        // When a parent group condition evaluates to Unknown (unimplemented
4016        // condition), child fields should NOT produce mandatory-missing errors.
4017        // The group-level entry itself will produce an Info "condition unknown".
4018
4019        // Condition 165 is NOT in the evaluator → returns Unknown
4020        let evaluator = MockEvaluator::new(vec![]);
4021        let validator = EdifactValidator::new(evaluator);
4022        let external = NoOpExternalProvider;
4023
4024        let workflow = AhbWorkflow {
4025            pruefidentifikator: "55001".to_string(),
4026            description: "Test".to_string(),
4027            communication_direction: None,
4028            fields: vec![AhbFieldRule {
4029                segment_path: "SG4/SG5/LOC/3227".to_string(),
4030                name: "Ortsangabe, Qualifier (Z17)".to_string(),
4031                ahb_status: "X".to_string(),
4032                codes: vec![AhbCodeRule {
4033                    value: "Z17".to_string(),
4034                    description: "Messlokation".to_string(),
4035                    ahb_status: "X".to_string(),
4036                }],
4037                parent_group_ahb_status: Some("Soll [165]".to_string()),
4038                segment_ahb_status: None,
4039                ..Default::default()
4040            }],
4041            ub_definitions: BTreeMap::new(),
4042        };
4043
4044        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
4045
4046        let ahb_errors: Vec<_> = report
4047            .by_category(ValidationCategory::Ahb)
4048            .filter(|i| i.severity == Severity::Error)
4049            .collect();
4050
4051        // No errors: parent group condition [165] is Unknown → skip child fields
4052        assert!(
4053            ahb_errors.is_empty(),
4054            "Expected no errors when parent group condition is Unknown, got: {:?}",
4055            ahb_errors
4056        );
4057    }
4058
4059    #[test]
4060    fn test_segment_absent_within_present_group_no_error() {
4061        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
4062        // SG10 is present (QTY+220 exists) but STS is absent.
4063        // Fields under STS should NOT produce AHB001 errors.
4064        use mig_types::navigator::GroupNavigator;
4065
4066        struct TestNav;
4067        impl GroupNavigator for TestNav {
4068            fn find_segments_in_group(
4069                &self,
4070                segment_id: &str,
4071                group_path: &[&str],
4072                instance_index: usize,
4073            ) -> Vec<OwnedSegment> {
4074                // SG10 has QTY but no STS
4075                if segment_id == "QTY"
4076                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
4077                    && instance_index == 0
4078                {
4079                    vec![OwnedSegment {
4080                        id: "QTY".into(),
4081                        elements: vec![vec!["220".into(), "0".into()]],
4082                        segment_number: 14,
4083                    }]
4084                } else {
4085                    vec![]
4086                }
4087            }
4088            fn find_segments_with_qualifier_in_group(
4089                &self,
4090                _: &str,
4091                _: usize,
4092                _: &str,
4093                _: &[&str],
4094                _: usize,
4095            ) -> Vec<OwnedSegment> {
4096                vec![]
4097            }
4098            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4099                match group_path {
4100                    ["SG5"] => 1,
4101                    ["SG5", "SG6"] => 1,
4102                    ["SG5", "SG6", "SG9"] => 1,
4103                    ["SG5", "SG6", "SG9", "SG10"] => 1,
4104                    _ => 0,
4105                }
4106            }
4107            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
4108                // SG10 instance 0 has QTY (the entry segment)
4109                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
4110            }
4111        }
4112
4113        let evaluator = MockEvaluator::all_true(&[]);
4114        let validator = EdifactValidator::new(evaluator);
4115        let external = NoOpExternalProvider;
4116        let nav = TestNav;
4117
4118        let segments = vec![OwnedSegment {
4119            id: "QTY".into(),
4120            elements: vec![vec!["220".into(), "0".into()]],
4121            segment_number: 14,
4122        }];
4123
4124        let workflow = AhbWorkflow {
4125            pruefidentifikator: "13017".to_string(),
4126            description: "Test".to_string(),
4127            communication_direction: None,
4128            fields: vec![
4129                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
4130                AhbFieldRule {
4131                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
4132                    name: "Statuskategorie, Code".to_string(),
4133                    ahb_status: "X".to_string(),
4134                    codes: vec![],
4135                    parent_group_ahb_status: Some("Muss".to_string()),
4136                    segment_ahb_status: None,
4137                    ..Default::default()
4138                },
4139                // STS/C556/9013 — another field under STS
4140                AhbFieldRule {
4141                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4142                    name: "Statusanlaß, Code".to_string(),
4143                    ahb_status: "X [5]".to_string(),
4144                    codes: vec![],
4145                    parent_group_ahb_status: Some("Muss".to_string()),
4146                    segment_ahb_status: None,
4147                    ..Default::default()
4148                },
4149            ],
4150            ub_definitions: BTreeMap::new(),
4151        };
4152
4153        let report = validator.validate_with_navigator(
4154            &segments,
4155            &workflow,
4156            &external,
4157            ValidationLevel::Conditions,
4158            &nav,
4159        );
4160
4161        let ahb_errors: Vec<_> = report
4162            .by_category(ValidationCategory::Ahb)
4163            .filter(|i| i.severity == Severity::Error)
4164            .collect();
4165
4166        assert!(
4167            ahb_errors.is_empty(),
4168            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
4169            ahb_errors
4170        );
4171    }
4172
4173    #[test]
4174    fn test_group_scoped_code_validation_with_navigator() {
4175        // With a navigator, SG2/NAD is checked against {MS, MR} only,
4176        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
4177        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
4178        use mig_types::navigator::GroupNavigator;
4179
4180        struct TestNav;
4181        impl GroupNavigator for TestNav {
4182            fn find_segments_in_group(
4183                &self,
4184                segment_id: &str,
4185                group_path: &[&str],
4186                _instance_index: usize,
4187            ) -> Vec<OwnedSegment> {
4188                if segment_id != "NAD" {
4189                    return vec![];
4190                }
4191                match group_path {
4192                    ["SG2"] => vec![
4193                        OwnedSegment {
4194                            id: "NAD".into(),
4195                            elements: vec![vec!["MS".into()]],
4196                            segment_number: 3,
4197                        },
4198                        OwnedSegment {
4199                            id: "NAD".into(),
4200                            elements: vec![vec!["MT".into()]], // invalid in SG2
4201                            segment_number: 4,
4202                        },
4203                    ],
4204                    ["SG4", "SG12"] => vec![
4205                        OwnedSegment {
4206                            id: "NAD".into(),
4207                            elements: vec![vec!["Z04".into()]],
4208                            segment_number: 20,
4209                        },
4210                        OwnedSegment {
4211                            id: "NAD".into(),
4212                            elements: vec![vec!["Z09".into()]],
4213                            segment_number: 21,
4214                        },
4215                    ],
4216                    _ => vec![],
4217                }
4218            }
4219            fn find_segments_with_qualifier_in_group(
4220                &self,
4221                _: &str,
4222                _: usize,
4223                _: &str,
4224                _: &[&str],
4225                _: usize,
4226            ) -> Vec<OwnedSegment> {
4227                vec![]
4228            }
4229            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4230                match group_path {
4231                    ["SG2"] | ["SG4", "SG12"] => 1,
4232                    _ => 0,
4233                }
4234            }
4235        }
4236
4237        let evaluator = MockEvaluator::new(vec![]);
4238        let validator = EdifactValidator::new(evaluator);
4239        let external = NoOpExternalProvider;
4240        let nav = TestNav;
4241
4242        let workflow = AhbWorkflow {
4243            pruefidentifikator: "55001".to_string(),
4244            description: "Test".to_string(),
4245            communication_direction: None,
4246            fields: vec![
4247                AhbFieldRule {
4248                    segment_path: "SG2/NAD/3035".to_string(),
4249                    name: "Absender".to_string(),
4250                    ahb_status: "X".to_string(),
4251                    codes: vec![AhbCodeRule {
4252                        value: "MS".to_string(),
4253                        description: "Absender".to_string(),
4254                        ahb_status: "X".to_string(),
4255                    }],
4256                    parent_group_ahb_status: None,
4257                    segment_ahb_status: None,
4258                    ..Default::default()
4259                },
4260                AhbFieldRule {
4261                    segment_path: "SG2/NAD/3035".to_string(),
4262                    name: "Empfaenger".to_string(),
4263                    ahb_status: "X".to_string(),
4264                    codes: vec![AhbCodeRule {
4265                        value: "MR".to_string(),
4266                        description: "Empfaenger".to_string(),
4267                        ahb_status: "X".to_string(),
4268                    }],
4269                    parent_group_ahb_status: None,
4270                    segment_ahb_status: None,
4271                    ..Default::default()
4272                },
4273                AhbFieldRule {
4274                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4275                    name: "Anschlussnutzer".to_string(),
4276                    ahb_status: "X".to_string(),
4277                    codes: vec![AhbCodeRule {
4278                        value: "Z04".to_string(),
4279                        description: "Anschlussnutzer".to_string(),
4280                        ahb_status: "X".to_string(),
4281                    }],
4282                    parent_group_ahb_status: None,
4283                    segment_ahb_status: None,
4284                    ..Default::default()
4285                },
4286                AhbFieldRule {
4287                    segment_path: "SG4/SG12/NAD/3035".to_string(),
4288                    name: "Korrespondenzanschrift".to_string(),
4289                    ahb_status: "X".to_string(),
4290                    codes: vec![AhbCodeRule {
4291                        value: "Z09".to_string(),
4292                        description: "Korrespondenzanschrift".to_string(),
4293                        ahb_status: "X".to_string(),
4294                    }],
4295                    parent_group_ahb_status: None,
4296                    segment_ahb_status: None,
4297                    ..Default::default()
4298                },
4299            ],
4300            ub_definitions: BTreeMap::new(),
4301        };
4302
4303        // All segments flat (for condition evaluation), navigator provides group scope.
4304        let all_segments = vec![
4305            OwnedSegment {
4306                id: "NAD".into(),
4307                elements: vec![vec!["MS".into()]],
4308                segment_number: 3,
4309            },
4310            OwnedSegment {
4311                id: "NAD".into(),
4312                elements: vec![vec!["MT".into()]],
4313                segment_number: 4,
4314            },
4315            OwnedSegment {
4316                id: "NAD".into(),
4317                elements: vec![vec!["Z04".into()]],
4318                segment_number: 20,
4319            },
4320            OwnedSegment {
4321                id: "NAD".into(),
4322                elements: vec![vec!["Z09".into()]],
4323                segment_number: 21,
4324            },
4325        ];
4326
4327        let report = validator.validate_with_navigator(
4328            &all_segments,
4329            &workflow,
4330            &external,
4331            ValidationLevel::Conditions,
4332            &nav,
4333        );
4334
4335        let code_errors: Vec<_> = report
4336            .by_category(ValidationCategory::Code)
4337            .filter(|i| i.severity == Severity::Error)
4338            .collect();
4339
4340        // Only one error: MT in SG2 (not allowed in {MS, MR}).
4341        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
4342        assert_eq!(
4343            code_errors.len(),
4344            1,
4345            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
4346            code_errors
4347        );
4348        assert!(narrate(code_errors[0]).contains("MT"));
4349        // Error should show only SG2's allowed codes, not the full union
4350        assert!(narrate(code_errors[0]).contains("MR"));
4351        assert!(narrate(code_errors[0]).contains("MS"));
4352        assert!(
4353            !narrate(code_errors[0]).contains("Z04"),
4354            "SG4/SG12 codes should not leak into SG2 error"
4355        );
4356        // Field path should include the group
4357        assert!(
4358            code_errors[0]
4359                .field_path
4360                .as_deref()
4361                .unwrap_or("")
4362                .contains("SG2"),
4363            "Error field_path should reference SG2, got: {:?}",
4364            code_errors[0].field_path
4365        );
4366    }
4367
4368    // === Package cardinality tests ===
4369
4370    #[test]
4371    fn test_package_cardinality_within_bounds() {
4372        // 1 code present from package [4P0..1], max=1 -> OK
4373        let evaluator = MockEvaluator::all_true(&[]);
4374        let validator = EdifactValidator::new(evaluator);
4375        let external = NoOpExternalProvider;
4376
4377        let segments = vec![OwnedSegment {
4378            id: "STS".into(),
4379            elements: vec![
4380                vec!["Z33".into()], // element[0]
4381                vec![],             // element[1]
4382                vec!["E01".into()], // element[2], component[0]
4383            ],
4384            segment_number: 5,
4385        }];
4386
4387        let workflow = AhbWorkflow {
4388            pruefidentifikator: "13017".to_string(),
4389            description: "Test".to_string(),
4390            communication_direction: None,
4391            ub_definitions: BTreeMap::new(),
4392            fields: vec![AhbFieldRule {
4393                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4394                name: "Statusanlaß, Code".to_string(),
4395                ahb_status: "X".to_string(),
4396                element_index: Some(2),
4397                component_index: Some(0),
4398                codes: vec![
4399                    AhbCodeRule {
4400                        value: "E01".into(),
4401                        description: "Code 1".into(),
4402                        ahb_status: "X [4P0..1]".into(),
4403                    },
4404                    AhbCodeRule {
4405                        value: "E02".into(),
4406                        description: "Code 2".into(),
4407                        ahb_status: "X [4P0..1]".into(),
4408                    },
4409                ],
4410                parent_group_ahb_status: Some("Muss".to_string()),
4411                segment_ahb_status: None,
4412                mig_number: None,
4413                format: None,
4414            }],
4415        };
4416
4417        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4418        let pkg_errors: Vec<_> = report
4419            .by_category(ValidationCategory::Ahb)
4420            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4421            .collect();
4422        assert!(
4423            pkg_errors.is_empty(),
4424            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
4425            pkg_errors
4426        );
4427    }
4428
4429    #[test]
4430    fn test_package_cardinality_zero_present_min_zero() {
4431        // No codes from the package present, min=0 -> OK
4432        let evaluator = MockEvaluator::all_true(&[]);
4433        let validator = EdifactValidator::new(evaluator);
4434        let external = NoOpExternalProvider;
4435
4436        let segments = vec![OwnedSegment {
4437            id: "STS".into(),
4438            elements: vec![
4439                vec!["Z33".into()],
4440                vec![],
4441                vec!["X99".into()], // X99 not in package
4442            ],
4443            segment_number: 5,
4444        }];
4445
4446        let workflow = AhbWorkflow {
4447            pruefidentifikator: "13017".to_string(),
4448            description: "Test".to_string(),
4449            communication_direction: None,
4450            ub_definitions: BTreeMap::new(),
4451            fields: vec![AhbFieldRule {
4452                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4453                name: "Statusanlaß, Code".to_string(),
4454                ahb_status: "X".to_string(),
4455                element_index: Some(2),
4456                component_index: Some(0),
4457                codes: vec![
4458                    AhbCodeRule {
4459                        value: "E01".into(),
4460                        description: "Code 1".into(),
4461                        ahb_status: "X [4P0..1]".into(),
4462                    },
4463                    AhbCodeRule {
4464                        value: "E02".into(),
4465                        description: "Code 2".into(),
4466                        ahb_status: "X [4P0..1]".into(),
4467                    },
4468                ],
4469                parent_group_ahb_status: Some("Muss".to_string()),
4470                segment_ahb_status: None,
4471                mig_number: None,
4472                format: None,
4473            }],
4474        };
4475
4476        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4477        let pkg_errors: Vec<_> = report
4478            .by_category(ValidationCategory::Ahb)
4479            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4480            .collect();
4481        assert!(
4482            pkg_errors.is_empty(),
4483            "0 codes, min=0 — no error expected, got: {:?}",
4484            pkg_errors
4485        );
4486    }
4487
4488    #[test]
4489    fn test_package_cardinality_too_many() {
4490        // 2 codes present from package [4P0..1], max=1 -> ERROR
4491        let evaluator = MockEvaluator::all_true(&[]);
4492        let validator = EdifactValidator::new(evaluator);
4493        let external = NoOpExternalProvider;
4494
4495        // Two STS segments, each with a different package code
4496        let segments = vec![
4497            OwnedSegment {
4498                id: "STS".into(),
4499                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
4500                segment_number: 5,
4501            },
4502            OwnedSegment {
4503                id: "STS".into(),
4504                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
4505                segment_number: 6,
4506            },
4507        ];
4508
4509        let workflow = AhbWorkflow {
4510            pruefidentifikator: "13017".to_string(),
4511            description: "Test".to_string(),
4512            communication_direction: None,
4513            ub_definitions: BTreeMap::new(),
4514            fields: vec![AhbFieldRule {
4515                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4516                name: "Statusanlaß, Code".to_string(),
4517                ahb_status: "X".to_string(),
4518                element_index: Some(2),
4519                component_index: Some(0),
4520                codes: vec![
4521                    AhbCodeRule {
4522                        value: "E01".into(),
4523                        description: "Code 1".into(),
4524                        ahb_status: "X [4P0..1]".into(),
4525                    },
4526                    AhbCodeRule {
4527                        value: "E02".into(),
4528                        description: "Code 2".into(),
4529                        ahb_status: "X [4P0..1]".into(),
4530                    },
4531                ],
4532                parent_group_ahb_status: Some("Muss".to_string()),
4533                segment_ahb_status: None,
4534                mig_number: None,
4535                format: None,
4536            }],
4537        };
4538
4539        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4540        let pkg_errors: Vec<_> = report
4541            .by_category(ValidationCategory::Ahb)
4542            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4543            .collect();
4544        assert_eq!(
4545            pkg_errors.len(),
4546            1,
4547            "2 codes present, max=1 — expected 1 error, got: {:?}",
4548            pkg_errors
4549        );
4550        assert!(narrate(pkg_errors[0]).contains("[4P0..1]"));
4551        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
4552        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
4553    }
4554
4555    #[test]
4556    fn test_package_cardinality_too_few() {
4557        // 0 codes present from package [5P1..3], min=1 -> ERROR
4558        let evaluator = MockEvaluator::all_true(&[]);
4559        let validator = EdifactValidator::new(evaluator);
4560        let external = NoOpExternalProvider;
4561
4562        let segments = vec![OwnedSegment {
4563            id: "STS".into(),
4564            elements: vec![
4565                vec!["Z33".into()],
4566                vec![],
4567                vec!["X99".into()], // not in package
4568            ],
4569            segment_number: 5,
4570        }];
4571
4572        let workflow = AhbWorkflow {
4573            pruefidentifikator: "13017".to_string(),
4574            description: "Test".to_string(),
4575            communication_direction: None,
4576            ub_definitions: BTreeMap::new(),
4577            fields: vec![AhbFieldRule {
4578                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4579                name: "Statusanlaß, Code".to_string(),
4580                ahb_status: "X".to_string(),
4581                element_index: Some(2),
4582                component_index: Some(0),
4583                codes: vec![
4584                    AhbCodeRule {
4585                        value: "E01".into(),
4586                        description: "Code 1".into(),
4587                        ahb_status: "X [5P1..3]".into(),
4588                    },
4589                    AhbCodeRule {
4590                        value: "E02".into(),
4591                        description: "Code 2".into(),
4592                        ahb_status: "X [5P1..3]".into(),
4593                    },
4594                    AhbCodeRule {
4595                        value: "E03".into(),
4596                        description: "Code 3".into(),
4597                        ahb_status: "X [5P1..3]".into(),
4598                    },
4599                ],
4600                parent_group_ahb_status: Some("Muss".to_string()),
4601                segment_ahb_status: None,
4602                mig_number: None,
4603                format: None,
4604            }],
4605        };
4606
4607        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4608        let pkg_errors: Vec<_> = report
4609            .by_category(ValidationCategory::Ahb)
4610            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4611            .collect();
4612        assert_eq!(
4613            pkg_errors.len(),
4614            1,
4615            "0 codes present, min=1 — expected 1 error, got: {:?}",
4616            pkg_errors
4617        );
4618        assert!(narrate(pkg_errors[0]).contains("[5P1..3]"));
4619        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
4620        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
4621    }
4622
4623    #[test]
4624    fn test_package_cardinality_no_packages_in_workflow() {
4625        // Codes without package annotations -> no package errors
4626        let evaluator = MockEvaluator::all_true(&[]);
4627        let validator = EdifactValidator::new(evaluator);
4628        let external = NoOpExternalProvider;
4629
4630        let segments = vec![OwnedSegment {
4631            id: "STS".into(),
4632            elements: vec![vec!["E01".into()]],
4633            segment_number: 5,
4634        }];
4635
4636        let workflow = AhbWorkflow {
4637            pruefidentifikator: "13017".to_string(),
4638            description: "Test".to_string(),
4639            communication_direction: None,
4640            ub_definitions: BTreeMap::new(),
4641            fields: vec![AhbFieldRule {
4642                segment_path: "STS/9015".to_string(),
4643                name: "Status Code".to_string(),
4644                ahb_status: "X".to_string(),
4645                codes: vec![AhbCodeRule {
4646                    value: "E01".into(),
4647                    description: "Code 1".into(),
4648                    ahb_status: "X".into(),
4649                }],
4650                parent_group_ahb_status: Some("Muss".to_string()),
4651                segment_ahb_status: None,
4652                ..Default::default()
4653            }],
4654        };
4655
4656        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4657        let pkg_errors: Vec<_> = report
4658            .by_category(ValidationCategory::Ahb)
4659            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4660            .collect();
4661        assert!(
4662            pkg_errors.is_empty(),
4663            "No packages in workflow — no errors expected"
4664        );
4665    }
4666
4667    #[test]
4668    fn test_package_cardinality_with_condition_and_package() {
4669        // Code status "X [901] [4P0..1]" has both condition and package
4670        let evaluator = MockEvaluator::all_true(&[901]);
4671        let validator = EdifactValidator::new(evaluator);
4672        let external = NoOpExternalProvider;
4673
4674        let segments = vec![OwnedSegment {
4675            id: "STS".into(),
4676            elements: vec![vec![], vec![], vec!["E01".into()]],
4677            segment_number: 5,
4678        }];
4679
4680        let workflow = AhbWorkflow {
4681            pruefidentifikator: "13017".to_string(),
4682            description: "Test".to_string(),
4683            communication_direction: None,
4684            ub_definitions: BTreeMap::new(),
4685            fields: vec![AhbFieldRule {
4686                segment_path: "SG10/STS/C556/9013".to_string(),
4687                name: "Code".to_string(),
4688                ahb_status: "X".to_string(),
4689                element_index: Some(2),
4690                component_index: Some(0),
4691                codes: vec![
4692                    AhbCodeRule {
4693                        value: "E01".into(),
4694                        description: "Code 1".into(),
4695                        ahb_status: "X [901] [4P0..1]".into(),
4696                    },
4697                    AhbCodeRule {
4698                        value: "E02".into(),
4699                        description: "Code 2".into(),
4700                        ahb_status: "X [901] [4P0..1]".into(),
4701                    },
4702                ],
4703                parent_group_ahb_status: Some("Muss".to_string()),
4704                segment_ahb_status: None,
4705                mig_number: None,
4706                format: None,
4707            }],
4708        };
4709
4710        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
4711        let pkg_errors: Vec<_> = report
4712            .by_category(ValidationCategory::Ahb)
4713            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4714            .collect();
4715        assert!(
4716            pkg_errors.is_empty(),
4717            "1 code within [4P0..1] bounds — no error, got: {:?}",
4718            pkg_errors
4719        );
4720    }
4721
4722    #[test]
4723    fn test_package_cardinality_scoped_per_group_instance() {
4724        // Package [4P0..1] caps the number of codes per SG10 group instance at 0..1.
4725        // MSCONS PID 13025 messages contain many SG10 reps (one per interval); each
4726        // rep has its own STS+Z32++<reason> segment using one code from the package.
4727        // The old global count summed codes across reps and flagged every
4728        // multi-interval message (e.g. 96 intervals → 96 codes "allowed 0..1").
4729        // With a navigator present, package cardinality must be evaluated per
4730        // group instance, not message-wide.
4731        use mig_types::navigator::GroupNavigator;
4732
4733        struct TwoSg10s {
4734            sts_a: OwnedSegment,
4735            sts_b: OwnedSegment,
4736        }
4737        impl GroupNavigator for TwoSg10s {
4738            fn find_segments_in_group(
4739                &self,
4740                segment_id: &str,
4741                group_path: &[&str],
4742                instance_index: usize,
4743            ) -> Vec<OwnedSegment> {
4744                if group_path == ["SG5", "SG6", "SG9", "SG10"] && segment_id == "STS" {
4745                    match instance_index {
4746                        0 => vec![self.sts_a.clone()],
4747                        1 => vec![self.sts_b.clone()],
4748                        _ => vec![],
4749                    }
4750                } else {
4751                    vec![]
4752                }
4753            }
4754            fn find_segments_with_qualifier_in_group(
4755                &self,
4756                _: &str,
4757                _: usize,
4758                _: &str,
4759                _: &[&str],
4760                _: usize,
4761            ) -> Vec<OwnedSegment> {
4762                vec![]
4763            }
4764            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4765                match group_path {
4766                    ["SG5"] | ["SG5", "SG6"] | ["SG5", "SG6", "SG9"] => 1,
4767                    ["SG5", "SG6", "SG9", "SG10"] => 2,
4768                    _ => 0,
4769                }
4770            }
4771        }
4772
4773        let sts_a = OwnedSegment {
4774            id: "STS".into(),
4775            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4776            segment_number: 10,
4777        };
4778        let sts_b = OwnedSegment {
4779            id: "STS".into(),
4780            elements: vec![vec!["Z32".into()], vec![], vec!["E01".into()]],
4781            segment_number: 15,
4782        };
4783        let nav = TwoSg10s {
4784            sts_a: sts_a.clone(),
4785            sts_b: sts_b.clone(),
4786        };
4787
4788        let evaluator = MockEvaluator::all_true(&[]);
4789        let validator = EdifactValidator::new(evaluator);
4790        let external = NoOpExternalProvider;
4791
4792        let workflow = AhbWorkflow {
4793            pruefidentifikator: "13025".to_string(),
4794            description: "Test".to_string(),
4795            communication_direction: None,
4796            ub_definitions: BTreeMap::new(),
4797            fields: vec![AhbFieldRule {
4798                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
4799                name: "Statusanlaß, Code".to_string(),
4800                ahb_status: "X".to_string(),
4801                element_index: Some(2),
4802                component_index: Some(0),
4803                codes: vec![
4804                    AhbCodeRule {
4805                        value: "E01".into(),
4806                        description: "Code 1".into(),
4807                        ahb_status: "X [4P0..1]".into(),
4808                    },
4809                    AhbCodeRule {
4810                        value: "E02".into(),
4811                        description: "Code 2".into(),
4812                        ahb_status: "X [4P0..1]".into(),
4813                    },
4814                ],
4815                parent_group_ahb_status: Some("Muss".to_string()),
4816                segment_ahb_status: None,
4817                mig_number: None,
4818                format: None,
4819            }],
4820        };
4821
4822        let report = validator.validate_with_navigator(
4823            &[sts_a, sts_b],
4824            &workflow,
4825            &external,
4826            ValidationLevel::Full,
4827            &nav,
4828        );
4829        let pkg_errors: Vec<_> = report
4830            .by_category(ValidationCategory::Ahb)
4831            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4832            .collect();
4833        assert!(
4834            pkg_errors.is_empty(),
4835            "Package cardinality is per-instance: 1 code per SG10 rep is within [4P0..1]. Got: {:?}",
4836            pkg_errors
4837        );
4838    }
4839
4840    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
4841        OwnedSegment {
4842            id: id.to_string(),
4843            elements: elements
4844                .into_iter()
4845                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
4846                .collect(),
4847            segment_number: 0,
4848        }
4849    }
4850
4851    /// Package cardinality must not fire against sibling group variants.
4852    ///
4853    /// PID 55218 has two SG8 variants: `sg8_z01` (SEQ mig=00115, no
4854    /// package) and `sg8_z45_z84` (SEQ mig=00171, with `[1P1..n]`
4855    /// package on Z45/Z84). The rule is scoped to mig=00171, but the
4856    /// old per-instance loop counted codes across every SG8 instance
4857    /// (including the Z01 sibling) and fired "0 codes present" on
4858    /// instances belonging to the other variant. With
4859    /// `instance_has_mig_number` the loop skips instances whose variant
4860    /// doesn't define the rule's mig_number.
4861    #[test]
4862    fn test_package_cardinality_scoped_to_rule_mig_variant() {
4863        use mig_types::navigator::GroupNavigator;
4864
4865        struct TwoSg8Variants {
4866            seq_z01: OwnedSegment,
4867            seq_z45: OwnedSegment,
4868        }
4869        impl GroupNavigator for TwoSg8Variants {
4870            fn find_segments_in_group(
4871                &self,
4872                segment_id: &str,
4873                group_path: &[&str],
4874                instance_index: usize,
4875            ) -> Vec<OwnedSegment> {
4876                if group_path == ["SG4", "SG8"] && segment_id == "SEQ" {
4877                    match instance_index {
4878                        0 => vec![self.seq_z01.clone()],
4879                        1 => vec![self.seq_z45.clone()],
4880                        _ => vec![],
4881                    }
4882                } else {
4883                    vec![]
4884                }
4885            }
4886            fn find_segments_with_qualifier_in_group(
4887                &self,
4888                _: &str,
4889                _: usize,
4890                _: &str,
4891                _: &[&str],
4892                _: usize,
4893            ) -> Vec<OwnedSegment> {
4894                vec![]
4895            }
4896            fn group_instance_count(&self, group_path: &[&str]) -> usize {
4897                match group_path {
4898                    ["SG4"] => 1,
4899                    ["SG4", "SG8"] => 2,
4900                    _ => 0,
4901                }
4902            }
4903            fn instance_has_mig_number(
4904                &self,
4905                group_path: &[&str],
4906                instance_index: usize,
4907                mig_number: &str,
4908            ) -> bool {
4909                if group_path != ["SG4", "SG8"] {
4910                    return true;
4911                }
4912                match (instance_index, mig_number) {
4913                    (0, "00115") => true,
4914                    (0, _) => false,
4915                    (1, "00171") => true,
4916                    (1, _) => false,
4917                    _ => false,
4918                }
4919            }
4920        }
4921
4922        let seq_z01 = OwnedSegment {
4923            id: "SEQ".into(),
4924            elements: vec![vec!["Z01".into()], vec!["1".into()]],
4925            segment_number: 10,
4926        };
4927        let seq_z45 = OwnedSegment {
4928            id: "SEQ".into(),
4929            elements: vec![vec!["Z45".into()], vec!["1".into()]],
4930            segment_number: 20,
4931        };
4932        let nav = TwoSg8Variants {
4933            seq_z01: seq_z01.clone(),
4934            seq_z45: seq_z45.clone(),
4935        };
4936
4937        let evaluator = MockEvaluator::all_true(&[]);
4938        let validator = EdifactValidator::new(evaluator);
4939        let external = NoOpExternalProvider;
4940
4941        let workflow = AhbWorkflow {
4942            pruefidentifikator: "55218".to_string(),
4943            description: "Test".to_string(),
4944            communication_direction: None,
4945            ub_definitions: BTreeMap::new(),
4946            fields: vec![AhbFieldRule {
4947                segment_path: "SG4/SG8/SEQ/1229".to_string(),
4948                name: "Handlung, Code".to_string(),
4949                ahb_status: "X".to_string(),
4950                element_index: Some(0),
4951                component_index: Some(0),
4952                codes: vec![
4953                    AhbCodeRule {
4954                        value: "Z45".into(),
4955                        description: "NNA".into(),
4956                        ahb_status: "X [1P1..4294967295]".into(),
4957                    },
4958                    AhbCodeRule {
4959                        value: "Z84".into(),
4960                        description: "Differenz-NNA".into(),
4961                        ahb_status: "X [1P0..4294967295]".into(),
4962                    },
4963                ],
4964                parent_group_ahb_status: Some("Muss".to_string()),
4965                segment_ahb_status: None,
4966                mig_number: Some("00171".to_string()),
4967                format: None,
4968            }],
4969        };
4970
4971        let report = validator.validate_with_navigator(
4972            &[seq_z01, seq_z45],
4973            &workflow,
4974            &external,
4975            ValidationLevel::Full,
4976            &nav,
4977        );
4978        let pkg_errors: Vec<_> = report
4979            .by_category(ValidationCategory::Ahb)
4980            .filter(|i| i.code() == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
4981            .collect();
4982        assert!(
4983            pkg_errors.is_empty(),
4984            "Package rule with mig=00171 must only count the Z45 instance (which has 1 code), not the sibling Z01 variant. Got: {:?}",
4985            pkg_errors
4986        );
4987    }
4988
4989    #[test]
4990    fn test_unt_count_correct() {
4991        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
4992        let segments = vec![
4993            make_segment("UNH", vec![vec!["001"]]),
4994            make_segment("BGM", vec![vec!["E01"]]),
4995            make_segment("DTM", vec![vec!["137", "20250401"]]),
4996            make_segment("UNT", vec![vec!["4", "001"]]),
4997        ];
4998        assert!(
4999            validate_unt_segment_count(&segments).is_none(),
5000            "Correct count should produce no issue"
5001        );
5002    }
5003
5004    #[test]
5005    fn test_unt_count_mismatch() {
5006        // UNH + BGM + UNT = 3 segments, but UNT declares 5
5007        let segments = vec![
5008            make_segment("UNH", vec![vec!["001"]]),
5009            make_segment("BGM", vec![vec!["E01"]]),
5010            make_segment("UNT", vec![vec!["5", "001"]]),
5011        ];
5012        let issue =
5013            validate_unt_segment_count(&segments).expect("Mismatch should produce an issue");
5014        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5015        assert_eq!(issue.severity, Severity::Error);
5016        assert!(narrate(&issue).contains("declared 5"));
5017        assert!(narrate(&issue).contains("actual 3"));
5018    }
5019
5020    #[test]
5021    fn test_unt_count_excludes_envelope() {
5022        // Envelope segments (UNA, UNB, UNZ) should not be counted
5023        let segments = vec![
5024            make_segment("UNA", vec![]),
5025            make_segment("UNB", vec![vec!["UNOC", "3"]]),
5026            make_segment("UNH", vec![vec!["001"]]),
5027            make_segment("BGM", vec![vec!["E01"]]),
5028            make_segment("UNT", vec![vec!["3", "001"]]),
5029            make_segment("UNZ", vec![vec!["1"]]),
5030        ];
5031        assert!(
5032            validate_unt_segment_count(&segments).is_none(),
5033            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
5034        );
5035    }
5036
5037    #[test]
5038    fn test_unt_count_no_unt_returns_none() {
5039        let segments = vec![
5040            make_segment("UNH", vec![vec!["001"]]),
5041            make_segment("BGM", vec![vec!["E01"]]),
5042        ];
5043        assert!(
5044            validate_unt_segment_count(&segments).is_none(),
5045            "No UNT segment should return None (not our problem)"
5046        );
5047    }
5048
5049    #[test]
5050    fn test_unt_count_rejects_multi_message_input() {
5051        // Two messages spliced together — should return an error
5052        let segments = vec![
5053            make_segment("UNH", vec![vec!["001"]]),
5054            make_segment("BGM", vec![vec!["E01"]]),
5055            make_segment("UNT", vec![vec!["3", "001"]]),
5056            make_segment("UNH", vec![vec!["002"]]),
5057            make_segment("BGM", vec![vec!["E02"]]),
5058            make_segment("UNT", vec![vec!["3", "002"]]),
5059        ];
5060        let issue = validate_unt_segment_count(&segments)
5061            .expect("Multi-message input should produce an error");
5062        assert_eq!(issue.code(), ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
5063        assert!(
5064            narrate(&issue).contains("2 UNH"),
5065            "Should mention UNH count: {}",
5066            narrate(&issue)
5067        );
5068    }
5069
5070    #[test]
5071    fn test_code_validation_accepts_multi_code_variant_qualifier() {
5072        // PID 55035 SG4/SG8 has many RFF variants — one per mig_number. The
5073        // variant with mig=00075 has TWO required codes (Z31, Z39): both are
5074        // valid qualifier values for that variant. Prior behavior only treated
5075        // buckets with exactly one required code as qualifier buckets, so
5076        // segments with Z31/Z39 fell through and were compared against the
5077        // union of *other* variants' codes — producing a false COD002.
5078        let evaluator = MockEvaluator::new(vec![]);
5079        let validator = EdifactValidator::new(evaluator);
5080        let external = NoOpExternalProvider;
5081
5082        let rff_z39 = OwnedSegment {
5083            id: "RFF".to_string(),
5084            elements: vec![
5085                vec!["RFF".to_string()],
5086                vec!["Z39".to_string(), "REF1".to_string()],
5087            ],
5088            segment_number: 1,
5089        };
5090
5091        let workflow = AhbWorkflow {
5092            pruefidentifikator: "55035".to_string(),
5093            description: "Test".to_string(),
5094            communication_direction: None,
5095            fields: vec![
5096                // Mig 00075 variant: qualifier is Z31 or Z39 (multi-code bucket)
5097                AhbFieldRule {
5098                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5099                    name: "Referenznummer Qualifier".to_string(),
5100                    ahb_status: "Muss".to_string(),
5101                    codes: vec![
5102                        AhbCodeRule {
5103                            value: "Z31".to_string(),
5104                            description: "".to_string(),
5105                            ahb_status: "X".to_string(),
5106                        },
5107                        AhbCodeRule {
5108                            value: "Z39".to_string(),
5109                            description: "".to_string(),
5110                            ahb_status: "X".to_string(),
5111                        },
5112                    ],
5113                    parent_group_ahb_status: None,
5114                    segment_ahb_status: None,
5115                    element_index: Some(1),
5116                    component_index: Some(0),
5117                    mig_number: Some("00075".to_string()),
5118                    format: None,
5119                },
5120                // Mig 00078 variant: single-code qualifier Z33
5121                AhbFieldRule {
5122                    segment_path: "SG4/SG8/RFF/C506/1153".to_string(),
5123                    name: "Referenznummer Qualifier".to_string(),
5124                    ahb_status: "Muss".to_string(),
5125                    codes: vec![AhbCodeRule {
5126                        value: "Z33".to_string(),
5127                        description: "".to_string(),
5128                        ahb_status: "X".to_string(),
5129                    }],
5130                    parent_group_ahb_status: None,
5131                    segment_ahb_status: None,
5132                    element_index: Some(1),
5133                    component_index: Some(0),
5134                    mig_number: Some("00078".to_string()),
5135                    format: None,
5136                },
5137            ],
5138            ub_definitions: BTreeMap::new(),
5139        };
5140
5141        let report = validator.validate(
5142            &[rff_z39],
5143            &workflow,
5144            &external,
5145            ValidationLevel::Conditions,
5146        );
5147
5148        let code_errors: Vec<_> = report
5149            .by_category(ValidationCategory::Code)
5150            .filter(|i| {
5151                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5152            })
5153            .collect();
5154        assert!(
5155            code_errors.is_empty(),
5156            "RFF+Z39 must be accepted (Z39 is valid for mig=00075). Got: {:?}",
5157            code_errors
5158        );
5159    }
5160
5161    #[test]
5162    fn test_code_validation_disambiguates_migs_by_full_code_profile() {
5163        // PID 55035 SG4/SG8 PIA variants all share element 0 code '5' as
5164        // qualifier. They differ only at composite C212/7143 (one variant
5165        // allows Z12, another allows SRW). Matching on the first single-code
5166        // field alone picks an arbitrary bucket and then rejects the other
5167        // variant's composite value — a false COD002.
5168        let evaluator = MockEvaluator::new(vec![]);
5169        let validator = EdifactValidator::new(evaluator);
5170        let external = NoOpExternalProvider;
5171
5172        // Include BOTH variants so the current bug is triggered deterministically:
5173        // match_segment_to_mig picks one bucket by HashMap iteration order, so
5174        // exactly one of {Z12, SRW} is rejected under the current logic.
5175        let pia_5_z12 = OwnedSegment {
5176            id: "PIA".to_string(),
5177            elements: vec![vec!["5".to_string()], vec!["Z12".to_string()]],
5178            segment_number: 1,
5179        };
5180        let pia_5_srw = OwnedSegment {
5181            id: "PIA".to_string(),
5182            elements: vec![vec!["5".to_string()], vec!["SRW".to_string()]],
5183            segment_number: 2,
5184        };
5185
5186        let make_rules = |mig: &str, composite_code: &str| {
5187            vec![
5188                AhbFieldRule {
5189                    segment_path: "SG4/SG8/PIA/4347".to_string(),
5190                    name: "Produkt-ID-Funktion".to_string(),
5191                    ahb_status: "Muss".to_string(),
5192                    codes: vec![AhbCodeRule {
5193                        value: "5".to_string(),
5194                        description: "".to_string(),
5195                        ahb_status: "X".to_string(),
5196                    }],
5197                    parent_group_ahb_status: None,
5198                    segment_ahb_status: None,
5199                    element_index: Some(0),
5200                    component_index: Some(0),
5201                    mig_number: Some(mig.to_string()),
5202                    format: None,
5203                },
5204                AhbFieldRule {
5205                    segment_path: "SG4/SG8/PIA/C212/7143".to_string(),
5206                    name: "Artikel/Dienstleistung-Identifikator".to_string(),
5207                    ahb_status: "Muss".to_string(),
5208                    codes: vec![AhbCodeRule {
5209                        value: composite_code.to_string(),
5210                        description: "".to_string(),
5211                        ahb_status: "X".to_string(),
5212                    }],
5213                    parent_group_ahb_status: None,
5214                    segment_ahb_status: None,
5215                    element_index: Some(1),
5216                    component_index: Some(0),
5217                    mig_number: Some(mig.to_string()),
5218                    format: None,
5219                },
5220            ]
5221        };
5222
5223        let mut fields = make_rules("00108", "Z12");
5224        fields.extend(make_rules("00197", "SRW"));
5225
5226        let workflow = AhbWorkflow {
5227            pruefidentifikator: "55035".to_string(),
5228            description: "Test".to_string(),
5229            communication_direction: None,
5230            fields,
5231            ub_definitions: BTreeMap::new(),
5232        };
5233
5234        let report = validator.validate(
5235            &[pia_5_z12, pia_5_srw],
5236            &workflow,
5237            &external,
5238            ValidationLevel::Conditions,
5239        );
5240
5241        let code_errors: Vec<_> = report
5242            .by_category(ValidationCategory::Code)
5243            .filter(|i| {
5244                i.severity == Severity::Error && i.code() == ErrorCodes::CODE_NOT_ALLOWED_FOR_PID
5245            })
5246            .collect();
5247        assert!(
5248            code_errors.is_empty(),
5249            "Both PIA+5+Z12 (mig=00108) and PIA+5+SRW (mig=00197) must be accepted. Got: {:?}",
5250            code_errors
5251        );
5252    }
5253}