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