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