Skip to main content

automapper_validation/validator/
validate.rs

1//! Main EdifactValidator implementation.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::expr::{ConditionExpr, ConditionParser};
6
7use crate::eval::{
8    ConditionEvaluator, ConditionExprEvaluator, ConditionResult, EvaluationContext,
9    ExternalConditionProvider,
10};
11use mig_types::navigator::GroupNavigator;
12use mig_types::segment::OwnedSegment;
13
14use super::tree::{AhbGroupNode, AhbNode, ValidatedTree};
15
16use super::codes::ErrorCodes;
17use super::issue::{Severity, ValidationCategory, ValidationIssue};
18use super::level::ValidationLevel;
19use super::report::ValidationReport;
20
21/// AHB field definition for validation.
22///
23/// Represents a single field in an AHB rule table with its status
24/// and allowed codes for a specific Pruefidentifikator.
25#[derive(Debug, Clone, Default)]
26pub struct AhbFieldRule {
27    /// Segment path (e.g., "SG2/NAD/C082/3039").
28    pub segment_path: String,
29
30    /// Human-readable field name (e.g., "MP-ID des MSB").
31    pub name: String,
32
33    /// AHB status (e.g., "Muss [182] ∧ [152]", "X", "Kann").
34    pub ahb_status: String,
35
36    /// Allowed code values with their AHB status.
37    pub codes: Vec<AhbCodeRule>,
38
39    /// AHB status of the innermost parent group (e.g., "Kann", "Muss", "Soll [46]").
40    ///
41    /// When the parent group is optional ("Kann") and its qualifier variant is
42    /// absent from the message, mandatory checks for child fields are skipped.
43    pub parent_group_ahb_status: Option<String>,
44
45    /// Element index within the segment (0-based). Used to locate the correct
46    /// element when checking presence and code values. `None` defaults to 0.
47    pub element_index: Option<usize>,
48
49    /// Component sub-index within a composite element (0-based). Used to locate
50    /// the correct component. `None` defaults to 0.
51    pub component_index: Option<usize>,
52
53    /// MIG `Number` attribute of the parent segment. Links this AHB field to
54    /// the corresponding `AssembledSegment::mig_number` for tree-based joining.
55    pub mig_number: Option<String>,
56}
57
58/// An allowed code value within an AHB field rule.
59#[derive(Debug, Clone, Default)]
60pub struct AhbCodeRule {
61    /// The code value (e.g., "E01", "Z33").
62    pub value: String,
63
64    /// Description of the code (e.g., "Anmeldung").
65    pub description: String,
66
67    /// AHB status for this code (e.g., "X", "Muss").
68    pub ahb_status: String,
69}
70
71/// AHB workflow definition for a specific Pruefidentifikator.
72#[derive(Debug, Clone)]
73pub struct AhbWorkflow {
74    /// The Pruefidentifikator (e.g., "11001", "55001").
75    pub pruefidentifikator: String,
76
77    /// Description of the workflow.
78    pub description: String,
79
80    /// Communication direction (e.g., "NB an LF").
81    pub communication_direction: Option<String>,
82
83    /// All field rules for this workflow.
84    pub fields: Vec<AhbFieldRule>,
85
86    /// UB (Unterbedingung) definitions parsed from the AHB XML.
87    ///
88    /// Maps UB IDs (e.g., "UB1") to their parsed condition expressions.
89    /// These are expanded inline when evaluating condition expressions
90    /// that reference UB conditions.
91    pub ub_definitions: HashMap<String, ConditionExpr>,
92}
93
94/// Validates EDIFACT messages against AHB business rules.
95///
96/// The validator is a pure validation engine: it receives pre-parsed
97/// segments, an AHB workflow, and an external condition provider.
98/// Parsing and message-type detection are the caller's responsibility.
99///
100/// The validator is generic over the `ConditionEvaluator` implementation,
101/// which is typically generated from AHB XML schemas.
102///
103/// # Example
104///
105/// ```ignore
106/// use automapper_validation::validator::EdifactValidator;
107/// use automapper_validation::eval::NoOpExternalProvider;
108///
109/// let evaluator = UtilmdConditionEvaluatorFV2510::new();
110/// let validator = EdifactValidator::new(evaluator);
111/// let external = NoOpExternalProvider;
112///
113/// let report = validator.validate(
114///     &segments,
115///     &ahb_workflow,
116///     &external,
117///     ValidationLevel::Full,
118/// );
119///
120/// if !report.is_valid() {
121///     for error in report.errors() {
122///         eprintln!("{error}");
123///     }
124/// }
125/// ```
126pub struct EdifactValidator<E: ConditionEvaluator> {
127    evaluator: E,
128}
129
130impl<E: ConditionEvaluator> EdifactValidator<E> {
131    /// Create a new validator with the given condition evaluator.
132    pub fn new(evaluator: E) -> Self {
133        Self { evaluator }
134    }
135
136    /// Validate pre-parsed EDIFACT segments against an AHB workflow.
137    ///
138    /// # Arguments
139    ///
140    /// * `segments` - Pre-parsed EDIFACT segments
141    /// * `workflow` - AHB workflow definition for the PID
142    /// * `external` - Provider for external conditions
143    /// * `level` - Validation strictness level
144    ///
145    /// # Returns
146    ///
147    /// A `ValidationReport` with all issues found.
148    pub fn validate(
149        &self,
150        segments: &[OwnedSegment],
151        workflow: &AhbWorkflow,
152        external: &dyn ExternalConditionProvider,
153        level: ValidationLevel,
154    ) -> ValidationReport {
155        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
156            .with_format_version(self.evaluator.format_version())
157            .with_pruefidentifikator(&workflow.pruefidentifikator);
158
159        let ctx = EvaluationContext::new(&workflow.pruefidentifikator, external, segments);
160
161        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
162            self.validate_conditions(workflow, &ctx, &mut report);
163        }
164
165        report
166    }
167
168    /// Validate with a group navigator for group-scoped condition queries.
169    ///
170    /// Same as [`validate`] but passes a `GroupNavigator` to the
171    /// `EvaluationContext`, enabling conditions to query segments within
172    /// specific group instances (e.g., "in derselben SG8").
173    pub fn validate_with_navigator(
174        &self,
175        segments: &[OwnedSegment],
176        workflow: &AhbWorkflow,
177        external: &dyn ExternalConditionProvider,
178        level: ValidationLevel,
179        navigator: &dyn GroupNavigator,
180    ) -> ValidationReport {
181        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
182            .with_format_version(self.evaluator.format_version())
183            .with_pruefidentifikator(&workflow.pruefidentifikator);
184
185        let ctx = EvaluationContext::with_navigator(
186            &workflow.pruefidentifikator,
187            external,
188            segments,
189            navigator,
190        );
191
192        if matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
193            self.validate_conditions(workflow, &ctx, &mut report);
194        }
195
196        report
197    }
198
199    /// Validate using a pre-built ValidatedTree where each node carries
200    /// its resolved EDIFACT value.
201    ///
202    /// This is the preferred validation path -- conditions see `ctx.resolved_value`
203    /// set from the tree node, so format conditions like [931] "ZZZ=+00" check
204    /// the correct segment instance (e.g., DTM+92, not DTM+137).
205    pub fn validate_tree(
206        &self,
207        validated_tree: &ValidatedTree,
208        segments: &[OwnedSegment],
209        external: &dyn ExternalConditionProvider,
210        level: ValidationLevel,
211        navigator: Option<&dyn GroupNavigator>,
212    ) -> ValidationReport {
213        let mut report = ValidationReport::new(self.evaluator.message_type(), level)
214            .with_format_version(self.evaluator.format_version())
215            .with_pruefidentifikator(validated_tree.pruefidentifikator);
216
217        if !matches!(level, ValidationLevel::Conditions | ValidationLevel::Full) {
218            return report;
219        }
220
221        let ctx = match navigator {
222            Some(nav) => EvaluationContext::with_navigator(
223                validated_tree.pruefidentifikator,
224                external,
225                segments,
226                nav,
227            ),
228            None => EvaluationContext::new(
229                validated_tree.pruefidentifikator,
230                external,
231                segments,
232            ),
233        };
234
235        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
236
237        // Collect all nodes depth-first from the tree.
238        let mut all_nodes: Vec<&AhbNode> = Vec::new();
239        all_nodes.extend(validated_tree.root_fields.iter());
240        for group in &validated_tree.groups {
241            collect_nodes_depth_first(group, &mut all_nodes);
242        }
243
244        // Evaluate each node.
245        for node in &all_nodes {
246            let field = node.rule;
247
248            // Create a field-scoped context with resolved value from the tree node.
249            let node_ctx = ctx.with_resolved(node.value, node.segment_elements);
250
251            // If the parent group has a conditional status, evaluate it first
252            // using the base ctx (group conditions are not field-scoped).
253            if let Some(ref group_status) = field.parent_group_ahb_status {
254                if group_status.contains('[') {
255                    let group_result = expr_eval.evaluate_status_with_ub(
256                        group_status,
257                        &ctx,
258                        validated_tree.ub_definitions,
259                    );
260                    if matches!(
261                        group_result,
262                        ConditionResult::False | ConditionResult::Unknown
263                    ) {
264                        continue;
265                    }
266                }
267            }
268
269            // Evaluate the field's AHB status condition using the node context
270            // (WITH resolved_value set).
271            let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
272                &field.ahb_status,
273                &node_ctx,
274                validated_tree.ub_definitions,
275            );
276
277            match condition_result {
278                ConditionResult::True => {
279                    // Condition is met -- field is required/applicable.
280                    // Use node.value.is_some() instead of is_field_present()
281                    // because the tree already resolved the value.
282                    if is_mandatory_status(&field.ahb_status) && node.value.is_none() {
283                        let mut issue = ValidationIssue::new(
284                            Severity::Error,
285                            ValidationCategory::Ahb,
286                            ErrorCodes::MISSING_REQUIRED_FIELD,
287                            format!(
288                                "Required field '{}' at {} is missing",
289                                field.name, field.segment_path
290                            ),
291                        )
292                        .with_field_path(&field.segment_path)
293                        .with_rule(&field.ahb_status);
294                        if let Some(first_code) = field.codes.first() {
295                            issue.expected_value = Some(first_code.value.clone());
296                        }
297                        report.add_issue(issue);
298                    }
299                }
300                ConditionResult::False => {
301                    // Condition not met. If mandatory AND value is present,
302                    // the value violates the condition constraint.
303                    if is_mandatory_status(&field.ahb_status) && node.value.is_some() {
304                        report.add_issue(
305                            ValidationIssue::new(
306                                Severity::Error,
307                                ValidationCategory::Ahb,
308                                ErrorCodes::CONDITIONAL_RULE_VIOLATION,
309                                format!(
310                                    "Field '{}' at {} is present but does not satisfy condition: {}",
311                                    field.name, field.segment_path, field.ahb_status
312                                ),
313                            )
314                            .with_field_path(&field.segment_path)
315                            .with_rule(&field.ahb_status),
316                        );
317                    }
318                }
319                ConditionResult::Unknown => {
320                    // Partition unknown IDs into external / undetermined / missing.
321                    let mut external_ids = Vec::new();
322                    let mut undetermined_ids = Vec::new();
323                    let mut missing_ids = Vec::new();
324                    for id in unknown_ids {
325                        if self.evaluator.is_external(id) {
326                            external_ids.push(id);
327                        } else if self.evaluator.is_known(id) {
328                            undetermined_ids.push(id);
329                        } else {
330                            missing_ids.push(id);
331                        }
332                    }
333
334                    let mut parts = Vec::new();
335                    if !external_ids.is_empty() {
336                        let ids: Vec<String> =
337                            external_ids.iter().map(|id| format!("[{id}]")).collect();
338                        parts.push(format!(
339                            "external conditions require provider: {}",
340                            ids.join(", ")
341                        ));
342                    }
343                    if !undetermined_ids.is_empty() {
344                        let ids: Vec<String> = undetermined_ids
345                            .iter()
346                            .map(|id| format!("[{id}]"))
347                            .collect();
348                        parts.push(format!(
349                            "conditions could not be determined from message data: {}",
350                            ids.join(", ")
351                        ));
352                    }
353                    if !missing_ids.is_empty() {
354                        let ids: Vec<String> =
355                            missing_ids.iter().map(|id| format!("[{id}]")).collect();
356                        parts.push(format!("missing conditions: {}", ids.join(", ")));
357                    }
358                    let detail = if parts.is_empty() {
359                        String::new()
360                    } else {
361                        format!(" ({})", parts.join("; "))
362                    };
363                    report.add_issue(
364                        ValidationIssue::new(
365                            Severity::Info,
366                            ValidationCategory::Ahb,
367                            ErrorCodes::CONDITION_UNKNOWN,
368                            format!(
369                                "Condition for field '{}' could not be fully evaluated{}",
370                                field.name, detail
371                            ),
372                        )
373                        .with_field_path(&field.segment_path)
374                        .with_rule(&field.ahb_status),
375                    );
376                }
377            }
378        }
379
380        // Evaluate rules whose mig_number wasn't matched to any tree node.
381        // These represent entirely-absent group variants (e.g., NAD+MS when
382        // only NAD+MR is present). Use the flat is_field_present/is_group_variant_absent
383        // logic which correctly distinguishes mandatory-but-missing variants from
384        // optional-and-absent ones.
385        for field in &validated_tree.unmatched_rules {
386            // Evaluate parent group condition (same as tree-based path).
387            if let Some(ref group_status) = field.parent_group_ahb_status {
388                if group_status.contains('[') {
389                    let group_result = expr_eval.evaluate_status_with_ub(
390                        group_status,
391                        &ctx,
392                        validated_tree.ub_definitions,
393                    );
394                    if matches!(
395                        group_result,
396                        ConditionResult::False | ConditionResult::Unknown
397                    ) {
398                        continue;
399                    }
400                }
401            }
402
403            let (condition_result, _) = expr_eval.evaluate_status_detailed_with_ub(
404                &field.ahb_status,
405                &ctx,
406                validated_tree.ub_definitions,
407            );
408
409            if matches!(condition_result, ConditionResult::True)
410                && is_mandatory_status(&field.ahb_status)
411                && !is_field_present(&ctx, field)
412                && !is_group_variant_absent(&ctx, field)
413            {
414                let mut issue = ValidationIssue::new(
415                    Severity::Error,
416                    ValidationCategory::Ahb,
417                    ErrorCodes::MISSING_REQUIRED_FIELD,
418                    format!(
419                        "Required field '{}' at {} is missing",
420                        field.name, field.segment_path
421                    ),
422                )
423                .with_field_path(&field.segment_path)
424                .with_rule(&field.ahb_status);
425                if let Some(first_code) = field.codes.first() {
426                    issue.expected_value = Some(first_code.value.clone());
427                }
428                report.add_issue(issue);
429            }
430        }
431
432        // TODO: validate_codes_cross_field() and validate_package_cardinality()
433        // are skipped in the tree path for now. They operate on flat workflow
434        // fields and don't need resolved_value. A future iteration should
435        // reconstruct an AhbWorkflow from the ValidatedTree or adapt these
436        // methods to work with tree nodes directly.
437
438        report
439    }
440
441    /// Validate AHB conditions for each field in the workflow.
442    fn validate_conditions(
443        &self,
444        workflow: &AhbWorkflow,
445        ctx: &EvaluationContext,
446        report: &mut ValidationReport,
447    ) {
448        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
449
450        for field in &workflow.fields {
451            // If the parent group has a conditional status, evaluate it first.
452            // When the parent group condition evaluates to False or Unknown,
453            // the group variant is either not required or indeterminate —
454            // skip child field validation. The group-level AhbFieldRule entry
455            // itself already produces an Info "condition unknown" warning when
456            // Unknown, so the user is informed without false mandatory errors.
457            if let Some(ref group_status) = field.parent_group_ahb_status {
458                if group_status.contains('[') {
459                    let group_result = expr_eval.evaluate_status_with_ub(
460                        group_status,
461                        ctx,
462                        &workflow.ub_definitions,
463                    );
464                    if matches!(
465                        group_result,
466                        ConditionResult::False | ConditionResult::Unknown
467                    ) {
468                        continue;
469                    }
470                }
471            }
472
473            // Evaluate the AHB status condition expression, collecting
474            // which specific condition IDs are unknown when the result is Unknown.
475            let (condition_result, unknown_ids) = expr_eval.evaluate_status_detailed_with_ub(
476                &field.ahb_status,
477                ctx,
478                &workflow.ub_definitions,
479            );
480
481            match condition_result {
482                ConditionResult::True => {
483                    // Condition is met - field is required/applicable
484                    if is_mandatory_status(&field.ahb_status)
485                        && !is_field_present(ctx, field)
486                        && !is_group_variant_absent(ctx, field)
487                    {
488                        let mut issue = ValidationIssue::new(
489                            Severity::Error,
490                            ValidationCategory::Ahb,
491                            ErrorCodes::MISSING_REQUIRED_FIELD,
492                            format!(
493                                "Required field '{}' at {} is missing",
494                                field.name, field.segment_path
495                            ),
496                        )
497                        .with_field_path(&field.segment_path)
498                        .with_rule(&field.ahb_status);
499                        // Store the first expected code for BO4E path disambiguation.
500                        // E.g., DTM+93's code "93" helps resolve SG4/DTM/C507/2005
501                        // to Prozessdaten.gueltigBis vs gueltigAb.
502                        if let Some(first_code) = field.codes.first() {
503                            issue.expected_value = Some(first_code.value.clone());
504                        }
505                        report.add_issue(issue);
506                    }
507                }
508                ConditionResult::False => {
509                    // Condition not met.
510                    // If the field has a mandatory prefix (X, Muss) AND the field is
511                    // present, the condition is a format/value constraint that the
512                    // value violates. E.g., "X [UB1]" where UB1 checks timezone
513                    // format — the field is required, but its value is wrong.
514                    if is_mandatory_status(&field.ahb_status)
515                        && is_field_present(ctx, field)
516                    {
517                        report.add_issue(
518                            ValidationIssue::new(
519                                Severity::Error,
520                                ValidationCategory::Ahb,
521                                ErrorCodes::CONDITIONAL_RULE_VIOLATION,
522                                format!(
523                                    "Field '{}' at {} is present but does not satisfy condition: {}",
524                                    field.name, field.segment_path, field.ahb_status
525                                ),
526                            )
527                            .with_field_path(&field.segment_path)
528                            .with_rule(&field.ahb_status),
529                        );
530                    }
531                }
532                ConditionResult::Unknown => {
533                    // Partition unknown IDs into three categories:
534                    // 1. External: require an external provider (business context)
535                    // 2. Undetermined: implemented but returned Unknown (data not present)
536                    // 3. Missing: not implemented in evaluator at all
537                    let mut external_ids = Vec::new();
538                    let mut undetermined_ids = Vec::new();
539                    let mut missing_ids = Vec::new();
540                    for id in unknown_ids {
541                        if self.evaluator.is_external(id) {
542                            external_ids.push(id);
543                        } else if self.evaluator.is_known(id) {
544                            undetermined_ids.push(id);
545                        } else {
546                            missing_ids.push(id);
547                        }
548                    }
549
550                    let mut parts = Vec::new();
551                    if !external_ids.is_empty() {
552                        let ids: Vec<String> =
553                            external_ids.iter().map(|id| format!("[{id}]")).collect();
554                        parts.push(format!(
555                            "external conditions require provider: {}",
556                            ids.join(", ")
557                        ));
558                    }
559                    if !undetermined_ids.is_empty() {
560                        let ids: Vec<String> = undetermined_ids
561                            .iter()
562                            .map(|id| format!("[{id}]"))
563                            .collect();
564                        parts.push(format!(
565                            "conditions could not be determined from message data: {}",
566                            ids.join(", ")
567                        ));
568                    }
569                    if !missing_ids.is_empty() {
570                        let ids: Vec<String> =
571                            missing_ids.iter().map(|id| format!("[{id}]")).collect();
572                        parts.push(format!("missing conditions: {}", ids.join(", ")));
573                    }
574                    let detail = if parts.is_empty() {
575                        String::new()
576                    } else {
577                        format!(" ({})", parts.join("; "))
578                    };
579                    report.add_issue(
580                        ValidationIssue::new(
581                            Severity::Info,
582                            ValidationCategory::Ahb,
583                            ErrorCodes::CONDITION_UNKNOWN,
584                            format!(
585                                "Condition for field '{}' could not be fully evaluated{}",
586                                field.name, detail
587                            ),
588                        )
589                        .with_field_path(&field.segment_path)
590                        .with_rule(&field.ahb_status),
591                    );
592                }
593            }
594        }
595
596        // Cross-field code validation: aggregate allowed codes across all field
597        // rules sharing the same segment path, then check each segment instance
598        // against the combined set. This avoids false positives from per-field
599        // validation (e.g., NAD/3035 with [MS] for sender and [MR] for receiver).
600        self.validate_codes_cross_field(workflow, ctx, report);
601
602        // Package cardinality post-processing: check that the count of codes
603        // present from each package group falls within [min..max] bounds.
604        self.validate_package_cardinality(workflow, ctx, report);
605    }
606
607    /// Validate package cardinality constraints across all field rules.
608    ///
609    /// Scans each code's `ahb_status` for `[NP_min..max]` package references,
610    /// groups codes by `(segment_path, package_id)`, counts how many of the
611    /// package's codes are actually present in the segment, and emits AHB006
612    /// if the count falls outside `[min..max]`.
613    fn validate_package_cardinality(
614        &self,
615        workflow: &AhbWorkflow,
616        ctx: &EvaluationContext,
617        report: &mut ValidationReport,
618    ) {
619        // Collect package groups: (segment_path, package_id) -> (min, max, Vec<code_value>)
620        // Also store (element_index, component_index) for looking up the value in the segment.
621        struct PackageGroup {
622            min: u32,
623            max: u32,
624            code_values: Vec<String>,
625            element_index: usize,
626            component_index: usize,
627        }
628
629        // Key: (segment_path, package_id)
630        let mut groups: HashMap<(String, u32), PackageGroup> = HashMap::new();
631
632        let expr_eval = ConditionExprEvaluator::new(&self.evaluator);
633
634        for field in &workflow.fields {
635            let el_idx = field.element_index.unwrap_or(0);
636            let comp_idx = field.component_index.unwrap_or(0);
637
638            // Skip package codes whose parent group condition is False or Unknown.
639            // E.g., SG10 under SG8_ZH0 with parent_group_ahb_status="Muss [42]" —
640            // if [42] can't be determined, don't enforce package constraints.
641            if let Some(ref group_status) = field.parent_group_ahb_status {
642                if group_status.contains('[') {
643                    let group_result = expr_eval.evaluate_status_with_ub(
644                        group_status,
645                        ctx,
646                        &workflow.ub_definitions,
647                    );
648                    if matches!(
649                        group_result,
650                        ConditionResult::False | ConditionResult::Unknown
651                    ) {
652                        continue;
653                    }
654                }
655            }
656
657            for code in &field.codes {
658                // Parse the code's ahb_status for Package nodes
659                if let Ok(Some(expr)) = ConditionParser::parse(&code.ahb_status) {
660                    // Walk the AST to find Package nodes
661                    let mut packages = Vec::new();
662                    collect_packages(&expr, &mut packages);
663
664                    for (pkg_id, pkg_min, pkg_max) in packages {
665                        let key = (field.segment_path.clone(), pkg_id);
666                        let group = groups.entry(key).or_insert_with(|| PackageGroup {
667                            min: pkg_min,
668                            max: pkg_max,
669                            code_values: Vec::new(),
670                            element_index: el_idx,
671                            component_index: comp_idx,
672                        });
673                        // Update min/max in case different codes specify different bounds
674                        // (should be the same, but take the intersection to be safe)
675                        group.min = group.min.max(pkg_min);
676                        group.max = group.max.min(pkg_max);
677                        group.code_values.push(code.value.clone());
678                    }
679                }
680            }
681        }
682
683        // For each package group, count present codes and check bounds
684        for ((seg_path, pkg_id), group) in &groups {
685            let segment_id = extract_segment_id(seg_path);
686            let segments = ctx.find_segments(&segment_id);
687
688            // Count how many of the package's codes appear across all segment instances
689            let mut present_count: usize = 0;
690            for seg in &segments {
691                if let Some(value) = seg
692                    .elements
693                    .get(group.element_index)
694                    .and_then(|e| e.get(group.component_index))
695                    .filter(|v| !v.is_empty())
696                {
697                    if group.code_values.contains(value) {
698                        present_count += 1;
699                    }
700                }
701            }
702
703            let min = group.min as usize;
704            let max = group.max as usize;
705
706            if present_count < min || present_count > max {
707                let code_list = group.code_values.join(", ");
708                report.add_issue(
709                    ValidationIssue::new(
710                        Severity::Error,
711                        ValidationCategory::Ahb,
712                        ErrorCodes::PACKAGE_CARDINALITY_VIOLATION,
713                        format!(
714                            "Package [{}P{}..{}] at {}: {} code(s) present (allowed {}..{}). Codes in package: [{}]",
715                            pkg_id, group.min, group.max, seg_path, present_count, group.min, group.max, code_list
716                        ),
717                    )
718                    .with_field_path(seg_path)
719                    .with_expected(format!("{}..{}", group.min, group.max))
720                    .with_actual(present_count.to_string()),
721                );
722            }
723        }
724    }
725
726    /// Validate qualifier codes by aggregating allowed values across field rules.
727    ///
728    /// When a group navigator is available, codes are grouped by segment path
729    /// (e.g., `SG2/NAD/3035` → `{MS, MR}`) and segments are looked up within
730    /// the specific group, giving precise per-group validation.
731    ///
732    /// Without a navigator, falls back to grouping by segment tag and unioning
733    /// all codes across groups to avoid cross-group false positives.
734    ///
735    /// Only validates simple qualifier paths (`[SG/]*/SEG/ELEMENT`) where the
736    /// code is in `element[0][0]`. Composite paths are skipped.
737    fn validate_codes_cross_field(
738        &self,
739        workflow: &AhbWorkflow,
740        ctx: &EvaluationContext,
741        report: &mut ValidationReport,
742    ) {
743        if ctx.navigator.is_some() {
744            self.validate_codes_group_scoped(workflow, ctx, report);
745        } else {
746            self.validate_codes_tag_scoped(workflow, ctx, report);
747        }
748    }
749
750    /// Group-scoped code validation: group codes by path, use navigator to
751    /// find segments within each group, check against that group's codes only.
752    fn validate_codes_group_scoped(
753        &self,
754        workflow: &AhbWorkflow,
755        ctx: &EvaluationContext,
756        report: &mut ValidationReport,
757    ) {
758        // Group allowed codes by (group_path_key, segment_tag, element_index, component_index).
759        // E.g., "SG2/NAD/3035" → key ("SG2", "NAD", 0, 0)
760        type CodeKey = (String, String, usize, usize);
761        let mut codes_by_group: HashMap<CodeKey, HashSet<&str>> = HashMap::new();
762
763        for field in &workflow.fields {
764            if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
765                continue;
766            }
767            let tag = extract_segment_id(&field.segment_path);
768            let group_key = extract_group_path_key(&field.segment_path);
769            let el_idx = field.element_index.unwrap_or(0);
770            let comp_idx = field.component_index.unwrap_or(0);
771            let entry = codes_by_group
772                .entry((group_key, tag, el_idx, comp_idx))
773                .or_default();
774            for code in &field.codes {
775                if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
776                    entry.insert(&code.value);
777                }
778            }
779        }
780
781        let nav = ctx.navigator.unwrap();
782
783        for ((group_key, tag, el_idx, comp_idx), allowed_codes) in &codes_by_group {
784            if allowed_codes.is_empty() {
785                continue;
786            }
787
788            let group_path: Vec<&str> = if group_key.is_empty() {
789                Vec::new()
790            } else {
791                group_key.split('/').collect()
792            };
793
794            // Iterate over all instances of this group and check segments.
795            if group_path.is_empty() {
796                // No group prefix (e.g., bare "NAD/3035") — check flat segments.
797                Self::check_segments_against_codes(
798                    ctx.find_segments(tag),
799                    allowed_codes,
800                    tag,
801                    *el_idx,
802                    *comp_idx,
803                    &format!("{tag}/qualifier"),
804                    report,
805                );
806            } else {
807                let instance_count = nav.group_instance_count(&group_path);
808                for i in 0..instance_count {
809                    let segments = nav.find_segments_in_group(tag, &group_path, i);
810                    let refs: Vec<&OwnedSegment> = segments.iter().collect();
811                    Self::check_segments_against_codes(
812                        refs,
813                        allowed_codes,
814                        tag,
815                        *el_idx,
816                        *comp_idx,
817                        &format!("{group_key}/{tag}/qualifier"),
818                        report,
819                    );
820                }
821            }
822        }
823    }
824
825    /// Fallback: group codes by segment tag (unioning across all groups) and
826    /// check all segments of that tag. Used when no navigator is available.
827    fn validate_codes_tag_scoped(
828        &self,
829        workflow: &AhbWorkflow,
830        ctx: &EvaluationContext,
831        report: &mut ValidationReport,
832    ) {
833        // Key: (tag, element_index, component_index)
834        let mut codes_by_tag: HashMap<(String, usize, usize), HashSet<&str>> = HashMap::new();
835
836        for field in &workflow.fields {
837            if field.codes.is_empty() || !is_qualifier_field(&field.segment_path) {
838                continue;
839            }
840            let tag = extract_segment_id(&field.segment_path);
841            let el_idx = field.element_index.unwrap_or(0);
842            let comp_idx = field.component_index.unwrap_or(0);
843            let entry = codes_by_tag.entry((tag, el_idx, comp_idx)).or_default();
844            for code in &field.codes {
845                if code.ahb_status == "X" || code.ahb_status.starts_with("Muss") {
846                    entry.insert(&code.value);
847                }
848            }
849        }
850
851        for ((tag, el_idx, comp_idx), allowed_codes) in &codes_by_tag {
852            if allowed_codes.is_empty() {
853                continue;
854            }
855            Self::check_segments_against_codes(
856                ctx.find_segments(tag),
857                allowed_codes,
858                tag,
859                *el_idx,
860                *comp_idx,
861                &format!("{tag}/qualifier"),
862                report,
863            );
864        }
865    }
866
867    /// Check a list of segments' qualifier at the given element/component index against allowed codes.
868    fn check_segments_against_codes(
869        segments: Vec<&OwnedSegment>,
870        allowed_codes: &HashSet<&str>,
871        _tag: &str,
872        el_idx: usize,
873        comp_idx: usize,
874        field_path: &str,
875        report: &mut ValidationReport,
876    ) {
877        for segment in segments {
878            if let Some(code_value) = segment
879                .elements
880                .get(el_idx)
881                .and_then(|e| e.get(comp_idx))
882                .filter(|v| !v.is_empty())
883            {
884                if !allowed_codes.contains(code_value.as_str()) {
885                    let mut sorted_codes: Vec<&str> = allowed_codes.iter().copied().collect();
886                    sorted_codes.sort_unstable();
887                    report.add_issue(
888                        ValidationIssue::new(
889                            Severity::Error,
890                            ValidationCategory::Code,
891                            ErrorCodes::CODE_NOT_ALLOWED_FOR_PID,
892                            format!(
893                                "Code '{}' is not allowed for this PID. Allowed: [{}]",
894                                code_value,
895                                sorted_codes.join(", ")
896                            ),
897                        )
898                        .with_field_path(field_path)
899                        .with_actual(code_value)
900                        .with_expected(sorted_codes.join(", ")),
901                    );
902                }
903            }
904        }
905    }
906}
907
908/// Check if a field's required segment instance is present.
909///
910/// For fields with qualifier codes (e.g., `SG2/NAD/3035` with `[MR]`),
911/// checks that a segment with one of those qualifier values exists.
912/// Otherwise just checks that any segment with the tag is present.
913///
914/// This prevents false negatives where `NAD+MS` is present but the
915/// validator incorrectly says NAD "exists" when `NAD+MR` is missing.
916fn is_field_present(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
917    let segment_id = extract_segment_id(&field.segment_path);
918
919    // When a field has known codes, check for a segment containing one of those
920    // specific values at the right element/component position. This applies to both
921    // simple qualifier paths (NAD/3035) and composite paths (CCI/C240/7037) —
922    // without this, a CCI segment from a different group variant (e.g., CCI+Z61)
923    // would falsely satisfy the check for CCI with C240/7037 codes Z15/Z18.
924    if !field.codes.is_empty() {
925        if let (Some(el_idx), Some(comp_idx)) = (field.element_index, field.component_index) {
926            let required_codes: Vec<&str> =
927                field.codes.iter().map(|c| c.value.as_str()).collect();
928            let matching = ctx.find_segments(&segment_id);
929            return matching.iter().any(|seg| {
930                seg.elements
931                    .get(el_idx)
932                    .and_then(|e| e.get(comp_idx))
933                    .is_some_and(|v| required_codes.contains(&v.as_str()))
934            });
935        }
936        // Fallback for fields with codes but no element/component indices:
937        // use simple qualifier check if it's a qualifier-style path.
938        if is_qualifier_field(&field.segment_path) {
939            let required_codes: Vec<&str> =
940                field.codes.iter().map(|c| c.value.as_str()).collect();
941            let el_idx = field.element_index.unwrap_or(0);
942            let comp_idx = field.component_index.unwrap_or(0);
943            let matching = ctx.find_segments(&segment_id);
944            return matching.iter().any(|seg| {
945                seg.elements
946                    .get(el_idx)
947                    .and_then(|e| e.get(comp_idx))
948                    .is_some_and(|v| required_codes.contains(&v.as_str()))
949            });
950        }
951    }
952
953    ctx.has_segment(&segment_id)
954}
955
956/// Check if the group variant for a field is absent from the message.
957///
958/// Uses tree-based logic:
959/// 1. **Group entirely absent**: If the group path has 0 instances, the
960///    whole group is absent and its children aren't required.
961/// 2. **Optional group variant absent**: If the parent group is optional
962///    ("Kann") and the specific qualifier variant isn't present, the variant
963///    is absent. E.g., SG5 "Kann" with LOC+Z17 — if no SG5 instance has
964///    LOC+Z17, all fields under that variant are skipped.
965///
966/// Returns `false` (not absent) when:
967/// - The field has no group prefix (e.g., `NAD/3035`)
968/// - No navigator is available (can't determine group presence)
969/// - The parent group is mandatory and has instances
970fn is_group_variant_absent(ctx: &EvaluationContext, field: &AhbFieldRule) -> bool {
971    let group_path: Vec<&str> = field
972        .segment_path
973        .split('/')
974        .take_while(|p| p.starts_with("SG"))
975        .collect();
976
977    if group_path.is_empty() {
978        return false;
979    }
980
981    let nav = match ctx.navigator {
982        Some(nav) => nav,
983        None => return false,
984    };
985
986    let instance_count = nav.group_instance_count(&group_path);
987
988    // Case 1: group entirely absent — only skip if the group is NOT mandatory.
989    // When a mandatory group (parent_group_ahb_status contains "Muss" or "X")
990    // has 0 instances, the group's absence is itself the error, so we should
991    // NOT skip field validation — let the field-level check report the missing field.
992    if instance_count == 0 {
993        let is_group_mandatory = field
994            .parent_group_ahb_status
995            .as_deref()
996            .is_some_and(is_mandatory_status);
997        if !is_group_mandatory {
998            return true;
999        }
1000        // Mandatory group with 0 instances → don't suppress field errors
1001        return false;
1002    }
1003
1004    // Case 2: group has instances, but the specific qualifier variant may be absent.
1005    // Only applies when the parent group is optional ("Kann") — mandatory groups
1006    // ("Muss", "X") require all their qualifier variants to be present.
1007    if let Some(ref group_status) = field.parent_group_ahb_status {
1008        if !is_mandatory_status(group_status) && !group_status.contains('[') {
1009            // Parent group is unconditionally optional (e.g., "Kann").
1010            // Check if the field's qualifier variant is present in any instance.
1011            if !field.codes.is_empty() && is_qualifier_field(&field.segment_path) {
1012                let segment_id = extract_segment_id(&field.segment_path);
1013                let required_codes: Vec<&str> =
1014                    field.codes.iter().map(|c| c.value.as_str()).collect();
1015
1016                let any_instance_has_qualifier = (0..instance_count).any(|i| {
1017                    nav.find_segments_in_group(&segment_id, &group_path, i)
1018                        .iter()
1019                        .any(|seg| {
1020                            seg.elements
1021                                .first()
1022                                .and_then(|e| e.first())
1023                                .is_some_and(|v| required_codes.contains(&v.as_str()))
1024                        })
1025                });
1026
1027                if !any_instance_has_qualifier {
1028                    return true; // optional group variant absent
1029                }
1030            }
1031        }
1032    }
1033
1034    // Case 3: non-entry segment absent from all group instances.
1035    // E.g., SG10 has QTY (entry) + optional STS segments. If no STS appears
1036    // in any SG10 instance but the entry segment (QTY) is present, fields
1037    // under STS are not required.
1038    //
1039    // We check `has_any_segment_in_group` to confirm the group instance is
1040    // genuinely populated (proving our target segment is a non-entry optional
1041    // one) vs a navigator that simply can't resolve segments.
1042    let segment_id = extract_segment_id(&field.segment_path);
1043    let segment_absent_from_all = (0..instance_count).all(|i| {
1044        nav.find_segments_in_group(&segment_id, &group_path, i)
1045            .is_empty()
1046    });
1047    if segment_absent_from_all {
1048        let group_has_other_segments =
1049            (0..instance_count).any(|i| nav.has_any_segment_in_group(&group_path, i));
1050        if group_has_other_segments {
1051            return true;
1052        }
1053    }
1054
1055    false
1056}
1057
1058/// Recursively collect all AhbNodes from a group node in depth-first order.
1059fn collect_nodes_depth_first<'a, 'b>(group: &'b AhbGroupNode<'a>, out: &mut Vec<&'b AhbNode<'a>>) {
1060    out.extend(group.fields.iter());
1061    for child in &group.children {
1062        collect_nodes_depth_first(child, out);
1063    }
1064}
1065
1066/// Recursively collect all Package nodes from a condition expression tree.
1067fn collect_packages(expr: &ConditionExpr, out: &mut Vec<(u32, u32, u32)>) {
1068    match expr {
1069        ConditionExpr::Package { id, min, max } => {
1070            out.push((*id, *min, *max));
1071        }
1072        ConditionExpr::And(exprs) | ConditionExpr::Or(exprs) => {
1073            for e in exprs {
1074                collect_packages(e, out);
1075            }
1076        }
1077        ConditionExpr::Xor(left, right) => {
1078            collect_packages(left, out);
1079            collect_packages(right, out);
1080        }
1081        ConditionExpr::Not(inner) => {
1082            collect_packages(inner, out);
1083        }
1084        ConditionExpr::Ref(_) => {}
1085    }
1086}
1087
1088/// Check if an AHB status is mandatory (Muss or X prefix).
1089fn is_mandatory_status(status: &str) -> bool {
1090    let trimmed = status.trim();
1091    trimmed.starts_with("Muss") || trimmed.starts_with('X')
1092}
1093
1094/// Check if a field path points to a simple qualifier element (element[0] of the segment).
1095///
1096/// Returns `true` for paths like `[SG/]*/SEG/ELEMENT` where the data element is
1097/// directly under the segment (no composite wrapper). These fields have their code
1098/// in `element[0][0]` and can be validated.
1099///
1100/// Returns `false` for composite paths like `SEG/COMPOSITE/ELEMENT` (e.g.,
1101/// `UNH/S009/0065`) where the element is inside a composite at an unknown index.
1102fn is_qualifier_field(path: &str) -> bool {
1103    let parts: Vec<&str> = path.split('/').filter(|p| !p.starts_with("SG")).collect();
1104    // Expected: [SEGMENT_TAG, ELEMENT_ID] — exactly 2 parts after SG stripping.
1105    // If 3+ parts, there's a composite layer (e.g., [SEG, COMPOSITE, ELEMENT]).
1106    parts.len() == 2
1107}
1108
1109/// Extract the group path prefix from a field path.
1110///
1111/// `"SG2/NAD/3035"` → `"SG2"`, `"SG4/SG12/NAD/3035"` → `"SG4/SG12"`,
1112/// `"NAD/3035"` → `""` (no group prefix).
1113fn extract_group_path_key(path: &str) -> String {
1114    let sg_parts: Vec<&str> = path
1115        .split('/')
1116        .take_while(|p| p.starts_with("SG"))
1117        .collect();
1118    sg_parts.join("/")
1119}
1120
1121/// Extract the segment ID from a field path like "SG2/NAD/C082/3039" -> "NAD".
1122fn extract_segment_id(path: &str) -> String {
1123    for part in path.split('/') {
1124        // Skip segment group identifiers and composite/element identifiers
1125        if part.starts_with("SG") || part.starts_with("C_") || part.starts_with("D_") {
1126            continue;
1127        }
1128        // Return first 3-letter uppercase segment identifier
1129        if part.len() >= 3
1130            && part
1131                .chars()
1132                .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
1133        {
1134            return part.to_string();
1135        }
1136    }
1137    // Fallback: return the last part
1138    path.split('/').next_back().unwrap_or(path).to_string()
1139}
1140
1141/// Validate that the UNT segment count matches the actual number of segments.
1142///
1143/// The UNT segment's first data element (D0074) declares the number of segments
1144/// in the message, counting from UNH to UNT inclusive. This function compares
1145/// that declared value against the actual segment count.
1146///
1147/// Returns `Some(ValidationIssue)` if there's a mismatch, `None` if correct.
1148pub fn validate_unt_segment_count(segments: &[OwnedSegment]) -> Option<ValidationIssue> {
1149    // Find the UNT segment
1150    let unt = segments.iter().rfind(|s| s.id == "UNT")?;
1151    let declared: usize = unt.get_element(0).parse().ok()?;
1152
1153    // Count segments from UNH to UNT inclusive
1154    let actual = segments
1155        .iter()
1156        .filter(|s| s.id != "UNA" && s.id != "UNB" && s.id != "UNZ")
1157        .count();
1158
1159    if declared != actual {
1160        Some(
1161            ValidationIssue::new(
1162                Severity::Error,
1163                ValidationCategory::Structure,
1164                ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH,
1165                format!(
1166                    "UNT segment count mismatch: declared {declared}, actual {actual}"
1167                ),
1168            )
1169            .with_field_path("UNT/0074")
1170            .with_expected(actual.to_string())
1171            .with_actual(declared.to_string()),
1172        )
1173    } else {
1174        None
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181    use crate::eval::{ConditionResult as CR, NoOpExternalProvider};
1182    use std::collections::HashMap;
1183
1184    /// Mock evaluator for testing the validator.
1185    struct MockEvaluator {
1186        results: HashMap<u32, CR>,
1187    }
1188
1189    impl MockEvaluator {
1190        fn new(results: Vec<(u32, CR)>) -> Self {
1191            Self {
1192                results: results.into_iter().collect(),
1193            }
1194        }
1195
1196        fn all_true(ids: &[u32]) -> Self {
1197            Self::new(ids.iter().map(|&id| (id, CR::True)).collect())
1198        }
1199    }
1200
1201    impl ConditionEvaluator for MockEvaluator {
1202        fn evaluate(&self, condition: u32, _ctx: &EvaluationContext) -> CR {
1203            self.results.get(&condition).copied().unwrap_or(CR::Unknown)
1204        }
1205        fn is_external(&self, _condition: u32) -> bool {
1206            false
1207        }
1208        fn message_type(&self) -> &str {
1209            "UTILMD"
1210        }
1211        fn format_version(&self) -> &str {
1212            "FV2510"
1213        }
1214    }
1215
1216    // === Helper function tests ===
1217
1218    #[test]
1219    fn test_is_mandatory_status() {
1220        assert!(is_mandatory_status("Muss"));
1221        assert!(is_mandatory_status("Muss [182] ∧ [152]"));
1222        assert!(is_mandatory_status("X"));
1223        assert!(is_mandatory_status("X [567]"));
1224        assert!(!is_mandatory_status("Soll [1]"));
1225        assert!(!is_mandatory_status("Kann [1]"));
1226        assert!(!is_mandatory_status(""));
1227    }
1228
1229    #[test]
1230    fn test_extract_segment_id_simple() {
1231        assert_eq!(extract_segment_id("NAD"), "NAD");
1232    }
1233
1234    #[test]
1235    fn test_extract_segment_id_with_sg_prefix() {
1236        assert_eq!(extract_segment_id("SG2/NAD/C082/3039"), "NAD");
1237    }
1238
1239    #[test]
1240    fn test_extract_segment_id_nested_sg() {
1241        assert_eq!(extract_segment_id("SG4/SG8/SEQ/C286/6350"), "SEQ");
1242    }
1243
1244    // === Validator tests with mock data ===
1245
1246    #[test]
1247    fn test_validate_missing_mandatory_field() {
1248        let evaluator = MockEvaluator::all_true(&[182, 152]);
1249        let validator = EdifactValidator::new(evaluator);
1250        let external = NoOpExternalProvider;
1251
1252        let workflow = AhbWorkflow {
1253            pruefidentifikator: "11001".to_string(),
1254            description: "Test".to_string(),
1255            communication_direction: None,
1256            fields: vec![AhbFieldRule {
1257                segment_path: "SG2/NAD/C082/3039".to_string(),
1258                name: "MP-ID des MSB".to_string(),
1259                ahb_status: "Muss [182] ∧ [152]".to_string(),
1260                codes: vec![],
1261                parent_group_ahb_status: None,
1262                ..Default::default()
1263            }],
1264            ub_definitions: HashMap::new(),
1265        };
1266
1267        // Validate with no segments
1268        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1269
1270        // Should have an error for missing mandatory field
1271        assert!(!report.is_valid());
1272        let errors: Vec<_> = report.errors().collect();
1273        assert_eq!(errors.len(), 1);
1274        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1275        assert!(errors[0].message.contains("MP-ID des MSB"));
1276    }
1277
1278    #[test]
1279    fn test_validate_condition_false_no_error() {
1280        // When condition evaluates to False, field is not required
1281        let evaluator = MockEvaluator::new(vec![(182, CR::True), (152, CR::False)]);
1282        let validator = EdifactValidator::new(evaluator);
1283        let external = NoOpExternalProvider;
1284
1285        let workflow = AhbWorkflow {
1286            pruefidentifikator: "11001".to_string(),
1287            description: "Test".to_string(),
1288            communication_direction: None,
1289            fields: vec![AhbFieldRule {
1290                segment_path: "NAD".to_string(),
1291                name: "Partnerrolle".to_string(),
1292                ahb_status: "Muss [182] ∧ [152]".to_string(),
1293                codes: vec![],
1294                parent_group_ahb_status: None,
1295                ..Default::default()
1296            }],
1297            ub_definitions: HashMap::new(),
1298        };
1299
1300        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1301
1302        // Condition is false, so field is not required - no error
1303        assert!(report.is_valid());
1304    }
1305
1306    #[test]
1307    fn test_validate_condition_unknown_adds_info() {
1308        // When condition is Unknown, add an info-level note
1309        let evaluator = MockEvaluator::new(vec![(182, CR::True)]);
1310        // 152 is not registered -> Unknown
1311        let validator = EdifactValidator::new(evaluator);
1312        let external = NoOpExternalProvider;
1313
1314        let workflow = AhbWorkflow {
1315            pruefidentifikator: "11001".to_string(),
1316            description: "Test".to_string(),
1317            communication_direction: None,
1318            fields: vec![AhbFieldRule {
1319                segment_path: "NAD".to_string(),
1320                name: "Partnerrolle".to_string(),
1321                ahb_status: "Muss [182] ∧ [152]".to_string(),
1322                codes: vec![],
1323                parent_group_ahb_status: None,
1324                ..Default::default()
1325            }],
1326            ub_definitions: HashMap::new(),
1327        };
1328
1329        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1330
1331        // Should be valid (Unknown is not an error) but have an info issue
1332        assert!(report.is_valid());
1333        let infos: Vec<_> = report.infos().collect();
1334        assert_eq!(infos.len(), 1);
1335        assert_eq!(infos[0].code, ErrorCodes::CONDITION_UNKNOWN);
1336    }
1337
1338    #[test]
1339    fn test_validate_structure_level_skips_conditions() {
1340        let evaluator = MockEvaluator::all_true(&[182, 152]);
1341        let validator = EdifactValidator::new(evaluator);
1342        let external = NoOpExternalProvider;
1343
1344        let workflow = AhbWorkflow {
1345            pruefidentifikator: "11001".to_string(),
1346            description: "Test".to_string(),
1347            communication_direction: None,
1348            fields: vec![AhbFieldRule {
1349                segment_path: "NAD".to_string(),
1350                name: "Partnerrolle".to_string(),
1351                ahb_status: "Muss [182] ∧ [152]".to_string(),
1352                codes: vec![],
1353                parent_group_ahb_status: None,
1354                ..Default::default()
1355            }],
1356            ub_definitions: HashMap::new(),
1357        };
1358
1359        // With Structure level, conditions are not checked
1360        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Structure);
1361
1362        // No AHB errors because conditions were not evaluated
1363        assert!(report.is_valid());
1364        assert_eq!(report.by_category(ValidationCategory::Ahb).count(), 0);
1365    }
1366
1367    #[test]
1368    fn test_validate_empty_workflow_no_condition_errors() {
1369        let evaluator = MockEvaluator::all_true(&[]);
1370        let validator = EdifactValidator::new(evaluator);
1371        let external = NoOpExternalProvider;
1372
1373        let empty_workflow = AhbWorkflow {
1374            pruefidentifikator: String::new(),
1375            description: String::new(),
1376            communication_direction: None,
1377            fields: vec![],
1378            ub_definitions: HashMap::new(),
1379        };
1380
1381        let report = validator.validate(&[], &empty_workflow, &external, ValidationLevel::Full);
1382
1383        assert!(report.is_valid());
1384    }
1385
1386    #[test]
1387    fn test_validate_bare_muss_always_required() {
1388        let evaluator = MockEvaluator::new(vec![]);
1389        let validator = EdifactValidator::new(evaluator);
1390        let external = NoOpExternalProvider;
1391
1392        let workflow = AhbWorkflow {
1393            pruefidentifikator: "55001".to_string(),
1394            description: "Test".to_string(),
1395            communication_direction: Some("NB an LF".to_string()),
1396            fields: vec![AhbFieldRule {
1397                segment_path: "SG2/NAD/3035".to_string(),
1398                name: "Partnerrolle".to_string(),
1399                ahb_status: "Muss".to_string(), // No conditions
1400                codes: vec![],
1401                parent_group_ahb_status: None,
1402                ..Default::default()
1403            }],
1404            ub_definitions: HashMap::new(),
1405        };
1406
1407        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1408
1409        // Bare "Muss" with no conditions -> unconditionally required -> missing = error
1410        assert!(!report.is_valid());
1411        assert_eq!(report.error_count(), 1);
1412    }
1413
1414    #[test]
1415    fn test_validate_x_status_is_mandatory() {
1416        let evaluator = MockEvaluator::new(vec![]);
1417        let validator = EdifactValidator::new(evaluator);
1418        let external = NoOpExternalProvider;
1419
1420        let workflow = AhbWorkflow {
1421            pruefidentifikator: "55001".to_string(),
1422            description: "Test".to_string(),
1423            communication_direction: None,
1424            fields: vec![AhbFieldRule {
1425                segment_path: "DTM".to_string(),
1426                name: "Datum".to_string(),
1427                ahb_status: "X".to_string(),
1428                codes: vec![],
1429                parent_group_ahb_status: None,
1430                ..Default::default()
1431            }],
1432            ub_definitions: HashMap::new(),
1433        };
1434
1435        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1436
1437        assert!(!report.is_valid());
1438        let errors: Vec<_> = report.errors().collect();
1439        assert_eq!(errors[0].code, ErrorCodes::MISSING_REQUIRED_FIELD);
1440    }
1441
1442    #[test]
1443    fn test_validate_soll_not_mandatory() {
1444        let evaluator = MockEvaluator::new(vec![]);
1445        let validator = EdifactValidator::new(evaluator);
1446        let external = NoOpExternalProvider;
1447
1448        let workflow = AhbWorkflow {
1449            pruefidentifikator: "55001".to_string(),
1450            description: "Test".to_string(),
1451            communication_direction: None,
1452            fields: vec![AhbFieldRule {
1453                segment_path: "DTM".to_string(),
1454                name: "Datum".to_string(),
1455                ahb_status: "Soll".to_string(),
1456                codes: vec![],
1457                parent_group_ahb_status: None,
1458                ..Default::default()
1459            }],
1460            ub_definitions: HashMap::new(),
1461        };
1462
1463        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
1464
1465        // Soll is not mandatory, so missing is not an error
1466        assert!(report.is_valid());
1467    }
1468
1469    #[test]
1470    fn test_report_includes_metadata() {
1471        let evaluator = MockEvaluator::new(vec![]);
1472        let validator = EdifactValidator::new(evaluator);
1473        let external = NoOpExternalProvider;
1474
1475        let workflow = AhbWorkflow {
1476            pruefidentifikator: "55001".to_string(),
1477            description: String::new(),
1478            communication_direction: None,
1479            fields: vec![],
1480            ub_definitions: HashMap::new(),
1481        };
1482
1483        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Full);
1484
1485        assert_eq!(report.format_version.as_deref(), Some("FV2510"));
1486        assert_eq!(report.level, ValidationLevel::Full);
1487        assert_eq!(report.message_type, "UTILMD");
1488        assert_eq!(report.pruefidentifikator.as_deref(), Some("55001"));
1489    }
1490
1491    #[test]
1492    fn test_validate_with_navigator_returns_report() {
1493        let evaluator = MockEvaluator::all_true(&[]);
1494        let validator = EdifactValidator::new(evaluator);
1495        let external = NoOpExternalProvider;
1496        let nav = crate::eval::NoOpGroupNavigator;
1497
1498        let workflow = AhbWorkflow {
1499            pruefidentifikator: "55001".to_string(),
1500            description: "Test".to_string(),
1501            communication_direction: None,
1502            fields: vec![],
1503            ub_definitions: HashMap::new(),
1504        };
1505
1506        let report = validator.validate_with_navigator(
1507            &[],
1508            &workflow,
1509            &external,
1510            ValidationLevel::Full,
1511            &nav,
1512        );
1513        assert!(report.is_valid());
1514    }
1515
1516    #[test]
1517    fn test_code_validation_skips_composite_paths() {
1518        // UNH/S009/0065 has codes like ["UTILMD"], but the code is in element[1]
1519        // (composite S009), not element[0] (message reference).
1520        // The validator should skip code validation for composite paths.
1521        let evaluator = MockEvaluator::new(vec![]);
1522        let validator = EdifactValidator::new(evaluator);
1523        let external = NoOpExternalProvider;
1524
1525        let unh_segment = OwnedSegment {
1526            id: "UNH".to_string(),
1527            elements: vec![
1528                vec!["ALEXANDE951842".to_string()], // element 0: message ref
1529                vec![
1530                    "UTILMD".to_string(),
1531                    "D".to_string(),
1532                    "11A".to_string(),
1533                    "UN".to_string(),
1534                    "S2.1".to_string(),
1535                ],
1536            ],
1537            segment_number: 1,
1538        };
1539
1540        let workflow = AhbWorkflow {
1541            pruefidentifikator: "55001".to_string(),
1542            description: "Test".to_string(),
1543            communication_direction: None,
1544            fields: vec![
1545                AhbFieldRule {
1546                    segment_path: "UNH/S009/0065".to_string(),
1547                    name: "Nachrichtentyp".to_string(),
1548                    ahb_status: "X".to_string(),
1549                    codes: vec![AhbCodeRule {
1550                        value: "UTILMD".to_string(),
1551                        description: "Stammdaten".to_string(),
1552                        ahb_status: "X".to_string(),
1553                    }],
1554                    parent_group_ahb_status: None,
1555                    ..Default::default()
1556                },
1557                AhbFieldRule {
1558                    segment_path: "UNH/S009/0052".to_string(),
1559                    name: "Version".to_string(),
1560                    ahb_status: "X".to_string(),
1561                    codes: vec![AhbCodeRule {
1562                        value: "D".to_string(),
1563                        description: "Draft".to_string(),
1564                        ahb_status: "X".to_string(),
1565                    }],
1566                    parent_group_ahb_status: None,
1567                    ..Default::default()
1568                },
1569            ],
1570            ub_definitions: HashMap::new(),
1571        };
1572
1573        let report = validator.validate(
1574            &[unh_segment],
1575            &workflow,
1576            &external,
1577            ValidationLevel::Conditions,
1578        );
1579
1580        // Should NOT produce COD002 false positives for composite element paths
1581        let code_errors: Vec<_> = report
1582            .by_category(ValidationCategory::Code)
1583            .filter(|i| i.severity == Severity::Error)
1584            .collect();
1585        assert!(
1586            code_errors.is_empty(),
1587            "Expected no code errors for composite paths, got: {:?}",
1588            code_errors
1589        );
1590    }
1591
1592    #[test]
1593    fn test_cross_field_code_validation_valid_qualifiers() {
1594        // NAD/3035 has separate field rules: [MS] for sender, [MR] for receiver.
1595        // Cross-field validation unions them → {MS, MR}. Both segments are valid.
1596        let evaluator = MockEvaluator::new(vec![]);
1597        let validator = EdifactValidator::new(evaluator);
1598        let external = NoOpExternalProvider;
1599
1600        let nad_ms = OwnedSegment {
1601            id: "NAD".to_string(),
1602            elements: vec![vec!["MS".to_string()]],
1603            segment_number: 4,
1604        };
1605        let nad_mr = OwnedSegment {
1606            id: "NAD".to_string(),
1607            elements: vec![vec!["MR".to_string()]],
1608            segment_number: 5,
1609        };
1610
1611        let workflow = AhbWorkflow {
1612            pruefidentifikator: "55001".to_string(),
1613            description: "Test".to_string(),
1614            communication_direction: None,
1615            fields: vec![
1616                AhbFieldRule {
1617                    segment_path: "SG2/NAD/3035".to_string(),
1618                    name: "Absender".to_string(),
1619                    ahb_status: "X".to_string(),
1620                    codes: vec![AhbCodeRule {
1621                        value: "MS".to_string(),
1622                        description: "Absender".to_string(),
1623                        ahb_status: "X".to_string(),
1624                    }],
1625                    parent_group_ahb_status: None,
1626                    ..Default::default()
1627                },
1628                AhbFieldRule {
1629                    segment_path: "SG2/NAD/3035".to_string(),
1630                    name: "Empfaenger".to_string(),
1631                    ahb_status: "X".to_string(),
1632                    codes: vec![AhbCodeRule {
1633                        value: "MR".to_string(),
1634                        description: "Empfaenger".to_string(),
1635                        ahb_status: "X".to_string(),
1636                    }],
1637                    parent_group_ahb_status: None,
1638                    ..Default::default()
1639                },
1640            ],
1641            ub_definitions: HashMap::new(),
1642        };
1643
1644        let report = validator.validate(
1645            &[nad_ms, nad_mr],
1646            &workflow,
1647            &external,
1648            ValidationLevel::Conditions,
1649        );
1650
1651        let code_errors: Vec<_> = report
1652            .by_category(ValidationCategory::Code)
1653            .filter(|i| i.severity == Severity::Error)
1654            .collect();
1655        assert!(
1656            code_errors.is_empty(),
1657            "Expected no code errors for valid qualifiers, got: {:?}",
1658            code_errors
1659        );
1660    }
1661
1662    #[test]
1663    fn test_cross_field_code_validation_catches_invalid_qualifier() {
1664        // NAD+MT is not in the allowed set {MS, MR} → should produce COD002.
1665        let evaluator = MockEvaluator::new(vec![]);
1666        let validator = EdifactValidator::new(evaluator);
1667        let external = NoOpExternalProvider;
1668
1669        let nad_ms = OwnedSegment {
1670            id: "NAD".to_string(),
1671            elements: vec![vec!["MS".to_string()]],
1672            segment_number: 4,
1673        };
1674        let nad_mt = OwnedSegment {
1675            id: "NAD".to_string(),
1676            elements: vec![vec!["MT".to_string()]], // invalid
1677            segment_number: 5,
1678        };
1679
1680        let workflow = AhbWorkflow {
1681            pruefidentifikator: "55001".to_string(),
1682            description: "Test".to_string(),
1683            communication_direction: None,
1684            fields: vec![
1685                AhbFieldRule {
1686                    segment_path: "SG2/NAD/3035".to_string(),
1687                    name: "Absender".to_string(),
1688                    ahb_status: "X".to_string(),
1689                    codes: vec![AhbCodeRule {
1690                        value: "MS".to_string(),
1691                        description: "Absender".to_string(),
1692                        ahb_status: "X".to_string(),
1693                    }],
1694                    parent_group_ahb_status: None,
1695                    ..Default::default()
1696                },
1697                AhbFieldRule {
1698                    segment_path: "SG2/NAD/3035".to_string(),
1699                    name: "Empfaenger".to_string(),
1700                    ahb_status: "X".to_string(),
1701                    codes: vec![AhbCodeRule {
1702                        value: "MR".to_string(),
1703                        description: "Empfaenger".to_string(),
1704                        ahb_status: "X".to_string(),
1705                    }],
1706                    parent_group_ahb_status: None,
1707                    ..Default::default()
1708                },
1709            ],
1710            ub_definitions: HashMap::new(),
1711        };
1712
1713        let report = validator.validate(
1714            &[nad_ms, nad_mt],
1715            &workflow,
1716            &external,
1717            ValidationLevel::Conditions,
1718        );
1719
1720        let code_errors: Vec<_> = report
1721            .by_category(ValidationCategory::Code)
1722            .filter(|i| i.severity == Severity::Error)
1723            .collect();
1724        assert_eq!(code_errors.len(), 1, "Expected one COD002 error for MT");
1725        assert!(code_errors[0].message.contains("MT"));
1726        assert!(code_errors[0].message.contains("MR"));
1727        assert!(code_errors[0].message.contains("MS"));
1728    }
1729
1730    #[test]
1731    fn test_cross_field_code_validation_unions_across_groups() {
1732        // SG2/NAD/3035 allows {MS, MR}, SG4/SG12/NAD/3035 allows {Z04, Z09}.
1733        // Since find_segments("NAD") returns all NADs, codes must be unioned
1734        // by tag: {MS, MR, Z04, Z09}. NAD+MT should be caught, all others pass.
1735        let evaluator = MockEvaluator::new(vec![]);
1736        let validator = EdifactValidator::new(evaluator);
1737        let external = NoOpExternalProvider;
1738
1739        let segments = vec![
1740            OwnedSegment {
1741                id: "NAD".to_string(),
1742                elements: vec![vec!["MS".to_string()]],
1743                segment_number: 3,
1744            },
1745            OwnedSegment {
1746                id: "NAD".to_string(),
1747                elements: vec![vec!["MR".to_string()]],
1748                segment_number: 4,
1749            },
1750            OwnedSegment {
1751                id: "NAD".to_string(),
1752                elements: vec![vec!["Z04".to_string()]],
1753                segment_number: 20,
1754            },
1755            OwnedSegment {
1756                id: "NAD".to_string(),
1757                elements: vec![vec!["Z09".to_string()]],
1758                segment_number: 21,
1759            },
1760            OwnedSegment {
1761                id: "NAD".to_string(),
1762                elements: vec![vec!["MT".to_string()]], // invalid
1763                segment_number: 22,
1764            },
1765        ];
1766
1767        let workflow = AhbWorkflow {
1768            pruefidentifikator: "55001".to_string(),
1769            description: "Test".to_string(),
1770            communication_direction: None,
1771            fields: vec![
1772                AhbFieldRule {
1773                    segment_path: "SG2/NAD/3035".to_string(),
1774                    name: "Absender".to_string(),
1775                    ahb_status: "X".to_string(),
1776                    codes: vec![AhbCodeRule {
1777                        value: "MS".to_string(),
1778                        description: "Absender".to_string(),
1779                        ahb_status: "X".to_string(),
1780                    }],
1781                    parent_group_ahb_status: None,
1782                    ..Default::default()
1783                },
1784                AhbFieldRule {
1785                    segment_path: "SG2/NAD/3035".to_string(),
1786                    name: "Empfaenger".to_string(),
1787                    ahb_status: "X".to_string(),
1788                    codes: vec![AhbCodeRule {
1789                        value: "MR".to_string(),
1790                        description: "Empfaenger".to_string(),
1791                        ahb_status: "X".to_string(),
1792                    }],
1793                    parent_group_ahb_status: None,
1794                    ..Default::default()
1795                },
1796                AhbFieldRule {
1797                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1798                    name: "Anschlussnutzer".to_string(),
1799                    ahb_status: "X".to_string(),
1800                    codes: vec![AhbCodeRule {
1801                        value: "Z04".to_string(),
1802                        description: "Anschlussnutzer".to_string(),
1803                        ahb_status: "X".to_string(),
1804                    }],
1805                    parent_group_ahb_status: None,
1806                    ..Default::default()
1807                },
1808                AhbFieldRule {
1809                    segment_path: "SG4/SG12/NAD/3035".to_string(),
1810                    name: "Korrespondenzanschrift".to_string(),
1811                    ahb_status: "X".to_string(),
1812                    codes: vec![AhbCodeRule {
1813                        value: "Z09".to_string(),
1814                        description: "Korrespondenzanschrift".to_string(),
1815                        ahb_status: "X".to_string(),
1816                    }],
1817                    parent_group_ahb_status: None,
1818                    ..Default::default()
1819                },
1820            ],
1821            ub_definitions: HashMap::new(),
1822        };
1823
1824        let report =
1825            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1826
1827        let code_errors: Vec<_> = report
1828            .by_category(ValidationCategory::Code)
1829            .filter(|i| i.severity == Severity::Error)
1830            .collect();
1831        assert_eq!(
1832            code_errors.len(),
1833            1,
1834            "Expected exactly one COD002 error for MT, got: {:?}",
1835            code_errors
1836        );
1837        assert!(code_errors[0].message.contains("MT"));
1838    }
1839
1840    #[test]
1841    fn test_is_qualifier_field_simple_paths() {
1842        assert!(is_qualifier_field("NAD/3035"));
1843        assert!(is_qualifier_field("SG2/NAD/3035"));
1844        assert!(is_qualifier_field("SG4/SG8/SEQ/6350"));
1845        assert!(is_qualifier_field("LOC/3227"));
1846    }
1847
1848    #[test]
1849    fn test_is_qualifier_field_composite_paths() {
1850        assert!(!is_qualifier_field("UNH/S009/0065"));
1851        assert!(!is_qualifier_field("NAD/C082/3039"));
1852        assert!(!is_qualifier_field("SG2/NAD/C082/3039"));
1853    }
1854
1855    #[test]
1856    fn test_is_qualifier_field_bare_segment() {
1857        assert!(!is_qualifier_field("NAD"));
1858        assert!(!is_qualifier_field("SG2/NAD"));
1859    }
1860
1861    #[test]
1862    fn test_missing_qualifier_instance_is_detected() {
1863        // NAD+MS is present but NAD+MR is missing.
1864        // The Empfaenger field requires [MR] → should produce AHB001.
1865        let evaluator = MockEvaluator::new(vec![]);
1866        let validator = EdifactValidator::new(evaluator);
1867        let external = NoOpExternalProvider;
1868
1869        let nad_ms = OwnedSegment {
1870            id: "NAD".to_string(),
1871            elements: vec![vec!["MS".to_string()]],
1872            segment_number: 3,
1873        };
1874
1875        let workflow = AhbWorkflow {
1876            pruefidentifikator: "55001".to_string(),
1877            description: "Test".to_string(),
1878            communication_direction: None,
1879            fields: vec![
1880                AhbFieldRule {
1881                    segment_path: "SG2/NAD/3035".to_string(),
1882                    name: "Absender".to_string(),
1883                    ahb_status: "X".to_string(),
1884                    codes: vec![AhbCodeRule {
1885                        value: "MS".to_string(),
1886                        description: "Absender".to_string(),
1887                        ahb_status: "X".to_string(),
1888                    }],
1889                    parent_group_ahb_status: None,
1890                    ..Default::default()
1891                },
1892                AhbFieldRule {
1893                    segment_path: "SG2/NAD/3035".to_string(),
1894                    name: "Empfaenger".to_string(),
1895                    ahb_status: "Muss".to_string(),
1896                    codes: vec![AhbCodeRule {
1897                        value: "MR".to_string(),
1898                        description: "Empfaenger".to_string(),
1899                        ahb_status: "X".to_string(),
1900                    }],
1901                    parent_group_ahb_status: None,
1902                    ..Default::default()
1903                },
1904            ],
1905            ub_definitions: HashMap::new(),
1906        };
1907
1908        let report =
1909            validator.validate(&[nad_ms], &workflow, &external, ValidationLevel::Conditions);
1910
1911        let ahb_errors: Vec<_> = report
1912            .by_category(ValidationCategory::Ahb)
1913            .filter(|i| i.severity == Severity::Error)
1914            .collect();
1915        assert_eq!(
1916            ahb_errors.len(),
1917            1,
1918            "Expected AHB001 for missing NAD+MR, got: {:?}",
1919            ahb_errors
1920        );
1921        assert!(ahb_errors[0].message.contains("Empfaenger"));
1922    }
1923
1924    #[test]
1925    fn test_present_qualifier_instance_no_error() {
1926        // Both NAD+MS and NAD+MR present → no AHB001 for either.
1927        let evaluator = MockEvaluator::new(vec![]);
1928        let validator = EdifactValidator::new(evaluator);
1929        let external = NoOpExternalProvider;
1930
1931        let segments = vec![
1932            OwnedSegment {
1933                id: "NAD".to_string(),
1934                elements: vec![vec!["MS".to_string()]],
1935                segment_number: 3,
1936            },
1937            OwnedSegment {
1938                id: "NAD".to_string(),
1939                elements: vec![vec!["MR".to_string()]],
1940                segment_number: 4,
1941            },
1942        ];
1943
1944        let workflow = AhbWorkflow {
1945            pruefidentifikator: "55001".to_string(),
1946            description: "Test".to_string(),
1947            communication_direction: None,
1948            fields: vec![
1949                AhbFieldRule {
1950                    segment_path: "SG2/NAD/3035".to_string(),
1951                    name: "Absender".to_string(),
1952                    ahb_status: "Muss".to_string(),
1953                    codes: vec![AhbCodeRule {
1954                        value: "MS".to_string(),
1955                        description: "Absender".to_string(),
1956                        ahb_status: "X".to_string(),
1957                    }],
1958                    parent_group_ahb_status: None,
1959                    ..Default::default()
1960                },
1961                AhbFieldRule {
1962                    segment_path: "SG2/NAD/3035".to_string(),
1963                    name: "Empfaenger".to_string(),
1964                    ahb_status: "Muss".to_string(),
1965                    codes: vec![AhbCodeRule {
1966                        value: "MR".to_string(),
1967                        description: "Empfaenger".to_string(),
1968                        ahb_status: "X".to_string(),
1969                    }],
1970                    parent_group_ahb_status: None,
1971                    ..Default::default()
1972                },
1973            ],
1974            ub_definitions: HashMap::new(),
1975        };
1976
1977        let report =
1978            validator.validate(&segments, &workflow, &external, ValidationLevel::Conditions);
1979
1980        let ahb_errors: Vec<_> = report
1981            .by_category(ValidationCategory::Ahb)
1982            .filter(|i| i.severity == Severity::Error)
1983            .collect();
1984        assert!(
1985            ahb_errors.is_empty(),
1986            "Expected no AHB001 errors, got: {:?}",
1987            ahb_errors
1988        );
1989    }
1990
1991    #[test]
1992    fn test_extract_group_path_key() {
1993        assert_eq!(extract_group_path_key("SG2/NAD/3035"), "SG2");
1994        assert_eq!(extract_group_path_key("SG4/SG12/NAD/3035"), "SG4/SG12");
1995        assert_eq!(extract_group_path_key("NAD/3035"), "");
1996        assert_eq!(extract_group_path_key("SG4/SG8/SEQ/6350"), "SG4/SG8");
1997    }
1998
1999    #[test]
2000    fn test_absent_optional_group_no_missing_field_error() {
2001        // SG3 is optional ("Kann"). If SG3 is absent, its children CTA/3139
2002        // and CTA/C056/3412 should NOT produce AHB001 errors.
2003        use mig_types::navigator::GroupNavigator;
2004
2005        struct NavWithoutSG3;
2006        impl GroupNavigator for NavWithoutSG3 {
2007            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2008                vec![]
2009            }
2010            fn find_segments_with_qualifier_in_group(
2011                &self,
2012                _: &str,
2013                _: usize,
2014                _: &str,
2015                _: &[&str],
2016                _: usize,
2017            ) -> Vec<OwnedSegment> {
2018                vec![]
2019            }
2020            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2021                match group_path {
2022                    ["SG2"] => 2,        // two NAD groups present
2023                    ["SG2", "SG3"] => 0, // SG3 is absent
2024                    _ => 0,
2025                }
2026            }
2027        }
2028
2029        let evaluator = MockEvaluator::new(vec![]);
2030        let validator = EdifactValidator::new(evaluator);
2031        let external = NoOpExternalProvider;
2032        let nav = NavWithoutSG3;
2033
2034        // Only NAD segments present, no CTA
2035        let segments = vec![
2036            OwnedSegment {
2037                id: "NAD".into(),
2038                elements: vec![vec!["MS".into()]],
2039                segment_number: 3,
2040            },
2041            OwnedSegment {
2042                id: "NAD".into(),
2043                elements: vec![vec!["MR".into()]],
2044                segment_number: 4,
2045            },
2046        ];
2047
2048        let workflow = AhbWorkflow {
2049            pruefidentifikator: "55001".to_string(),
2050            description: "Test".to_string(),
2051            communication_direction: None,
2052            fields: vec![
2053                AhbFieldRule {
2054                    segment_path: "SG2/SG3/CTA/3139".to_string(),
2055                    name: "Funktion des Ansprechpartners, Code".to_string(),
2056                    ahb_status: "Muss".to_string(),
2057                    codes: vec![],
2058                    parent_group_ahb_status: None,
2059                    ..Default::default()
2060                },
2061                AhbFieldRule {
2062                    segment_path: "SG2/SG3/CTA/C056/3412".to_string(),
2063                    name: "Name vom Ansprechpartner".to_string(),
2064                    ahb_status: "X".to_string(),
2065                    codes: vec![],
2066                    parent_group_ahb_status: None,
2067                    ..Default::default()
2068                },
2069            ],
2070            ub_definitions: HashMap::new(),
2071        };
2072
2073        let report = validator.validate_with_navigator(
2074            &segments,
2075            &workflow,
2076            &external,
2077            ValidationLevel::Conditions,
2078            &nav,
2079        );
2080
2081        let ahb_errors: Vec<_> = report
2082            .by_category(ValidationCategory::Ahb)
2083            .filter(|i| i.severity == Severity::Error)
2084            .collect();
2085        assert!(
2086            ahb_errors.is_empty(),
2087            "Expected no AHB001 errors when SG3 is absent, got: {:?}",
2088            ahb_errors
2089        );
2090    }
2091
2092    #[test]
2093    fn test_present_group_still_checks_mandatory_fields() {
2094        // If SG3 IS present but CTA is missing within it → AHB001 error.
2095        use mig_types::navigator::GroupNavigator;
2096
2097        struct NavWithSG3;
2098        impl GroupNavigator for NavWithSG3 {
2099            fn find_segments_in_group(&self, _: &str, _: &[&str], _: usize) -> Vec<OwnedSegment> {
2100                vec![]
2101            }
2102            fn find_segments_with_qualifier_in_group(
2103                &self,
2104                _: &str,
2105                _: usize,
2106                _: &str,
2107                _: &[&str],
2108                _: usize,
2109            ) -> Vec<OwnedSegment> {
2110                vec![]
2111            }
2112            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2113                match group_path {
2114                    ["SG2"] => 1,
2115                    ["SG2", "SG3"] => 1, // SG3 is present
2116                    _ => 0,
2117                }
2118            }
2119        }
2120
2121        let evaluator = MockEvaluator::new(vec![]);
2122        let validator = EdifactValidator::new(evaluator);
2123        let external = NoOpExternalProvider;
2124        let nav = NavWithSG3;
2125
2126        // SG3 is present (nav says 1 instance) but CTA is not in flat segments
2127        let segments = vec![OwnedSegment {
2128            id: "NAD".into(),
2129            elements: vec![vec!["MS".into()]],
2130            segment_number: 3,
2131        }];
2132
2133        let workflow = AhbWorkflow {
2134            pruefidentifikator: "55001".to_string(),
2135            description: "Test".to_string(),
2136            communication_direction: None,
2137            fields: vec![AhbFieldRule {
2138                segment_path: "SG2/SG3/CTA/3139".to_string(),
2139                name: "Funktion des Ansprechpartners, Code".to_string(),
2140                ahb_status: "Muss".to_string(),
2141                codes: vec![],
2142                parent_group_ahb_status: None,
2143                ..Default::default()
2144            }],
2145            ub_definitions: HashMap::new(),
2146        };
2147
2148        let report = validator.validate_with_navigator(
2149            &segments,
2150            &workflow,
2151            &external,
2152            ValidationLevel::Conditions,
2153            &nav,
2154        );
2155
2156        let ahb_errors: Vec<_> = report
2157            .by_category(ValidationCategory::Ahb)
2158            .filter(|i| i.severity == Severity::Error)
2159            .collect();
2160        assert_eq!(
2161            ahb_errors.len(),
2162            1,
2163            "Expected AHB001 error when SG3 is present but CTA missing"
2164        );
2165        assert!(ahb_errors[0].message.contains("CTA"));
2166    }
2167
2168    #[test]
2169    fn test_missing_qualifier_with_navigator_is_detected() {
2170        // NAD+MS is in SG2 but NAD+MR is missing. With a navigator that
2171        // reports SG2 has 1 instance, the missing MR must still be flagged.
2172        use mig_types::navigator::GroupNavigator;
2173
2174        struct NavWithSG2;
2175        impl GroupNavigator for NavWithSG2 {
2176            fn find_segments_in_group(
2177                &self,
2178                segment_id: &str,
2179                group_path: &[&str],
2180                instance_index: usize,
2181            ) -> Vec<OwnedSegment> {
2182                if segment_id == "NAD" && group_path == ["SG2"] && instance_index == 0 {
2183                    vec![OwnedSegment {
2184                        id: "NAD".into(),
2185                        elements: vec![vec!["MS".into()]],
2186                        segment_number: 3,
2187                    }]
2188                } else {
2189                    vec![]
2190                }
2191            }
2192            fn find_segments_with_qualifier_in_group(
2193                &self,
2194                _: &str,
2195                _: usize,
2196                _: &str,
2197                _: &[&str],
2198                _: usize,
2199            ) -> Vec<OwnedSegment> {
2200                vec![]
2201            }
2202            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2203                match group_path {
2204                    ["SG2"] => 1,
2205                    _ => 0,
2206                }
2207            }
2208        }
2209
2210        let evaluator = MockEvaluator::new(vec![]);
2211        let validator = EdifactValidator::new(evaluator);
2212        let external = NoOpExternalProvider;
2213        let nav = NavWithSG2;
2214
2215        let segments = vec![OwnedSegment {
2216            id: "NAD".into(),
2217            elements: vec![vec!["MS".into()]],
2218            segment_number: 3,
2219        }];
2220
2221        let workflow = AhbWorkflow {
2222            pruefidentifikator: "55001".to_string(),
2223            description: "Test".to_string(),
2224            communication_direction: None,
2225            fields: vec![
2226                AhbFieldRule {
2227                    segment_path: "SG2/NAD/3035".to_string(),
2228                    name: "Absender".to_string(),
2229                    ahb_status: "X".to_string(),
2230                    codes: vec![AhbCodeRule {
2231                        value: "MS".to_string(),
2232                        description: "Absender".to_string(),
2233                        ahb_status: "X".to_string(),
2234                    }],
2235                    parent_group_ahb_status: None,
2236                    ..Default::default()
2237                },
2238                AhbFieldRule {
2239                    segment_path: "SG2/NAD/3035".to_string(),
2240                    name: "Empfaenger".to_string(),
2241                    ahb_status: "Muss".to_string(),
2242                    codes: vec![AhbCodeRule {
2243                        value: "MR".to_string(),
2244                        description: "Empfaenger".to_string(),
2245                        ahb_status: "X".to_string(),
2246                    }],
2247                    parent_group_ahb_status: None,
2248                    ..Default::default()
2249                },
2250            ],
2251            ub_definitions: HashMap::new(),
2252        };
2253
2254        let report = validator.validate_with_navigator(
2255            &segments,
2256            &workflow,
2257            &external,
2258            ValidationLevel::Conditions,
2259            &nav,
2260        );
2261
2262        let ahb_errors: Vec<_> = report
2263            .by_category(ValidationCategory::Ahb)
2264            .filter(|i| i.severity == Severity::Error)
2265            .collect();
2266        assert_eq!(
2267            ahb_errors.len(),
2268            1,
2269            "Expected AHB001 for missing NAD+MR even with navigator, got: {:?}",
2270            ahb_errors
2271        );
2272        assert!(ahb_errors[0].message.contains("Empfaenger"));
2273    }
2274
2275    #[test]
2276    fn test_optional_group_variant_absent_no_error() {
2277        // SG5 is "Kann" (optional) with LOC+Z16 present but LOC+Z17 absent.
2278        // Field rules for LOC/3227 with Z17 and its children should NOT error
2279        // because the parent group is optional and the variant is absent.
2280        // Meanwhile, SG2 is "Muss" — missing NAD+MR MUST still error.
2281        use mig_types::navigator::GroupNavigator;
2282
2283        struct TestNav;
2284        impl GroupNavigator for TestNav {
2285            fn find_segments_in_group(
2286                &self,
2287                segment_id: &str,
2288                group_path: &[&str],
2289                instance_index: usize,
2290            ) -> Vec<OwnedSegment> {
2291                match (segment_id, group_path, instance_index) {
2292                    ("LOC", ["SG4", "SG5"], 0) => vec![OwnedSegment {
2293                        id: "LOC".into(),
2294                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2295                        segment_number: 10,
2296                    }],
2297                    ("NAD", ["SG2"], 0) => vec![OwnedSegment {
2298                        id: "NAD".into(),
2299                        elements: vec![vec!["MS".into()]],
2300                        segment_number: 3,
2301                    }],
2302                    _ => vec![],
2303                }
2304            }
2305            fn find_segments_with_qualifier_in_group(
2306                &self,
2307                _: &str,
2308                _: usize,
2309                _: &str,
2310                _: &[&str],
2311                _: usize,
2312            ) -> Vec<OwnedSegment> {
2313                vec![]
2314            }
2315            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2316                match group_path {
2317                    ["SG2"] => 1,
2318                    ["SG4"] => 1,
2319                    ["SG4", "SG5"] => 1, // only Z16 instance
2320                    _ => 0,
2321                }
2322            }
2323        }
2324
2325        let evaluator = MockEvaluator::new(vec![]);
2326        let validator = EdifactValidator::new(evaluator);
2327        let external = NoOpExternalProvider;
2328        let nav = TestNav;
2329
2330        let segments = vec![
2331            OwnedSegment {
2332                id: "NAD".into(),
2333                elements: vec![vec!["MS".into()]],
2334                segment_number: 3,
2335            },
2336            OwnedSegment {
2337                id: "LOC".into(),
2338                elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2339                segment_number: 10,
2340            },
2341        ];
2342
2343        let workflow = AhbWorkflow {
2344            pruefidentifikator: "55001".to_string(),
2345            description: "Test".to_string(),
2346            communication_direction: None,
2347            fields: vec![
2348                // SG2 "Muss" — NAD+MS present, NAD+MR missing → should error
2349                AhbFieldRule {
2350                    segment_path: "SG2/NAD/3035".to_string(),
2351                    name: "Absender".to_string(),
2352                    ahb_status: "X".to_string(),
2353                    codes: vec![AhbCodeRule {
2354                        value: "MS".to_string(),
2355                        description: "Absender".to_string(),
2356                        ahb_status: "X".to_string(),
2357                    }],
2358                    parent_group_ahb_status: Some("Muss".to_string()),
2359                    ..Default::default()
2360                },
2361                AhbFieldRule {
2362                    segment_path: "SG2/NAD/3035".to_string(),
2363                    name: "Empfaenger".to_string(),
2364                    ahb_status: "Muss".to_string(),
2365                    codes: vec![AhbCodeRule {
2366                        value: "MR".to_string(),
2367                        description: "Empfaenger".to_string(),
2368                        ahb_status: "X".to_string(),
2369                    }],
2370                    parent_group_ahb_status: Some("Muss".to_string()),
2371                    ..Default::default()
2372                },
2373                // SG5 "Kann" — LOC+Z16 present, LOC+Z17 absent → should NOT error
2374                AhbFieldRule {
2375                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2376                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2377                    ahb_status: "X".to_string(),
2378                    codes: vec![AhbCodeRule {
2379                        value: "Z16".to_string(),
2380                        description: "Marktlokation".to_string(),
2381                        ahb_status: "X".to_string(),
2382                    }],
2383                    parent_group_ahb_status: Some("Kann".to_string()),
2384                    ..Default::default()
2385                },
2386                AhbFieldRule {
2387                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2388                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2389                    ahb_status: "Muss".to_string(),
2390                    codes: vec![AhbCodeRule {
2391                        value: "Z17".to_string(),
2392                        description: "Messlokation".to_string(),
2393                        ahb_status: "X".to_string(),
2394                    }],
2395                    parent_group_ahb_status: Some("Kann".to_string()),
2396                    ..Default::default()
2397                },
2398            ],
2399            ub_definitions: HashMap::new(),
2400        };
2401
2402        let report = validator.validate_with_navigator(
2403            &segments,
2404            &workflow,
2405            &external,
2406            ValidationLevel::Conditions,
2407            &nav,
2408        );
2409
2410        let ahb_errors: Vec<_> = report
2411            .by_category(ValidationCategory::Ahb)
2412            .filter(|i| i.severity == Severity::Error)
2413            .collect();
2414
2415        // Exactly 1 error: NAD+MR missing (mandatory group)
2416        // LOC+Z17 should NOT error (optional group variant absent)
2417        assert_eq!(
2418            ahb_errors.len(),
2419            1,
2420            "Expected only AHB001 for missing NAD+MR, got: {:?}",
2421            ahb_errors
2422        );
2423        assert!(
2424            ahb_errors[0].message.contains("Empfaenger"),
2425            "Error should be for missing NAD+MR (Empfaenger)"
2426        );
2427    }
2428
2429    #[test]
2430    fn test_conditional_group_variant_absent_no_error() {
2431        // Real-world scenario: SG5 "Messlokation" has AHB_Status="Soll [165]".
2432        // Condition [165] evaluates to False → LOC+Z17 should NOT error.
2433        // SG5 "Marktlokation" has AHB_Status="Muss [2061]".
2434        // Condition [2061] evaluates to True → LOC+Z16 is present → no error.
2435        use mig_types::navigator::GroupNavigator;
2436
2437        struct TestNav;
2438        impl GroupNavigator for TestNav {
2439            fn find_segments_in_group(
2440                &self,
2441                segment_id: &str,
2442                group_path: &[&str],
2443                instance_index: usize,
2444            ) -> Vec<OwnedSegment> {
2445                if segment_id == "LOC" && group_path == ["SG4", "SG5"] && instance_index == 0 {
2446                    vec![OwnedSegment {
2447                        id: "LOC".into(),
2448                        elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2449                        segment_number: 10,
2450                    }]
2451                } else {
2452                    vec![]
2453                }
2454            }
2455            fn find_segments_with_qualifier_in_group(
2456                &self,
2457                _: &str,
2458                _: usize,
2459                _: &str,
2460                _: &[&str],
2461                _: usize,
2462            ) -> Vec<OwnedSegment> {
2463                vec![]
2464            }
2465            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2466                match group_path {
2467                    ["SG4"] => 1,
2468                    ["SG4", "SG5"] => 1, // only Z16 instance
2469                    _ => 0,
2470                }
2471            }
2472        }
2473
2474        // Condition 165 → False (Messlokation not required)
2475        // Condition 2061 → True (Marktlokation required)
2476        let evaluator = MockEvaluator::new(vec![(165, CR::False), (2061, CR::True)]);
2477        let validator = EdifactValidator::new(evaluator);
2478        let external = NoOpExternalProvider;
2479        let nav = TestNav;
2480
2481        let segments = vec![OwnedSegment {
2482            id: "LOC".into(),
2483            elements: vec![vec!["Z16".into()], vec!["DE00012345".into()]],
2484            segment_number: 10,
2485        }];
2486
2487        let workflow = AhbWorkflow {
2488            pruefidentifikator: "55001".to_string(),
2489            description: "Test".to_string(),
2490            communication_direction: None,
2491            fields: vec![
2492                // SG5 "Muss [2061]" with [2061]=True → LOC+Z16 required, present → OK
2493                AhbFieldRule {
2494                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2495                    name: "Ortsangabe, Qualifier (Z16)".to_string(),
2496                    ahb_status: "X".to_string(),
2497                    codes: vec![AhbCodeRule {
2498                        value: "Z16".to_string(),
2499                        description: "Marktlokation".to_string(),
2500                        ahb_status: "X".to_string(),
2501                    }],
2502                    parent_group_ahb_status: Some("Muss [2061]".to_string()),
2503                    ..Default::default()
2504                },
2505                // SG5 "Soll [165]" with [165]=False → LOC+Z17 NOT required → skip
2506                AhbFieldRule {
2507                    segment_path: "SG4/SG5/LOC/3227".to_string(),
2508                    name: "Ortsangabe, Qualifier (Z17)".to_string(),
2509                    ahb_status: "X".to_string(),
2510                    codes: vec![AhbCodeRule {
2511                        value: "Z17".to_string(),
2512                        description: "Messlokation".to_string(),
2513                        ahb_status: "X".to_string(),
2514                    }],
2515                    parent_group_ahb_status: Some("Soll [165]".to_string()),
2516                    ..Default::default()
2517                },
2518            ],
2519            ub_definitions: HashMap::new(),
2520        };
2521
2522        let report = validator.validate_with_navigator(
2523            &segments,
2524            &workflow,
2525            &external,
2526            ValidationLevel::Conditions,
2527            &nav,
2528        );
2529
2530        let ahb_errors: Vec<_> = report
2531            .by_category(ValidationCategory::Ahb)
2532            .filter(|i| i.severity == Severity::Error)
2533            .collect();
2534
2535        // Zero errors: Z16 is present, and Z17's group condition is False
2536        assert!(
2537            ahb_errors.is_empty(),
2538            "Expected no errors when conditional group variant [165]=False, got: {:?}",
2539            ahb_errors
2540        );
2541    }
2542
2543    #[test]
2544    fn test_conditional_group_variant_unknown_no_error() {
2545        // When a parent group condition evaluates to Unknown (unimplemented
2546        // condition), child fields should NOT produce mandatory-missing errors.
2547        // The group-level entry itself will produce an Info "condition unknown".
2548
2549        // Condition 165 is NOT in the evaluator → returns Unknown
2550        let evaluator = MockEvaluator::new(vec![]);
2551        let validator = EdifactValidator::new(evaluator);
2552        let external = NoOpExternalProvider;
2553
2554        let workflow = AhbWorkflow {
2555            pruefidentifikator: "55001".to_string(),
2556            description: "Test".to_string(),
2557            communication_direction: None,
2558            fields: vec![AhbFieldRule {
2559                segment_path: "SG4/SG5/LOC/3227".to_string(),
2560                name: "Ortsangabe, Qualifier (Z17)".to_string(),
2561                ahb_status: "X".to_string(),
2562                codes: vec![AhbCodeRule {
2563                    value: "Z17".to_string(),
2564                    description: "Messlokation".to_string(),
2565                    ahb_status: "X".to_string(),
2566                }],
2567                parent_group_ahb_status: Some("Soll [165]".to_string()),
2568                ..Default::default()
2569            }],
2570            ub_definitions: HashMap::new(),
2571        };
2572
2573        let report = validator.validate(&[], &workflow, &external, ValidationLevel::Conditions);
2574
2575        let ahb_errors: Vec<_> = report
2576            .by_category(ValidationCategory::Ahb)
2577            .filter(|i| i.severity == Severity::Error)
2578            .collect();
2579
2580        // No errors: parent group condition [165] is Unknown → skip child fields
2581        assert!(
2582            ahb_errors.is_empty(),
2583            "Expected no errors when parent group condition is Unknown, got: {:?}",
2584            ahb_errors
2585        );
2586    }
2587
2588    #[test]
2589    fn test_segment_absent_within_present_group_no_error() {
2590        // MSCONS scenario: SG10 has QTY (entry) + optional STS segments.
2591        // SG10 is present (QTY+220 exists) but STS is absent.
2592        // Fields under STS should NOT produce AHB001 errors.
2593        use mig_types::navigator::GroupNavigator;
2594
2595        struct TestNav;
2596        impl GroupNavigator for TestNav {
2597            fn find_segments_in_group(
2598                &self,
2599                segment_id: &str,
2600                group_path: &[&str],
2601                instance_index: usize,
2602            ) -> Vec<OwnedSegment> {
2603                // SG10 has QTY but no STS
2604                if segment_id == "QTY"
2605                    && group_path == ["SG5", "SG6", "SG9", "SG10"]
2606                    && instance_index == 0
2607                {
2608                    vec![OwnedSegment {
2609                        id: "QTY".into(),
2610                        elements: vec![vec!["220".into(), "0".into()]],
2611                        segment_number: 14,
2612                    }]
2613                } else {
2614                    vec![]
2615                }
2616            }
2617            fn find_segments_with_qualifier_in_group(
2618                &self,
2619                _: &str,
2620                _: usize,
2621                _: &str,
2622                _: &[&str],
2623                _: usize,
2624            ) -> Vec<OwnedSegment> {
2625                vec![]
2626            }
2627            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2628                match group_path {
2629                    ["SG5"] => 1,
2630                    ["SG5", "SG6"] => 1,
2631                    ["SG5", "SG6", "SG9"] => 1,
2632                    ["SG5", "SG6", "SG9", "SG10"] => 1,
2633                    _ => 0,
2634                }
2635            }
2636            fn has_any_segment_in_group(&self, group_path: &[&str], instance_index: usize) -> bool {
2637                // SG10 instance 0 has QTY (the entry segment)
2638                group_path == ["SG5", "SG6", "SG9", "SG10"] && instance_index == 0
2639            }
2640        }
2641
2642        let evaluator = MockEvaluator::all_true(&[]);
2643        let validator = EdifactValidator::new(evaluator);
2644        let external = NoOpExternalProvider;
2645        let nav = TestNav;
2646
2647        let segments = vec![OwnedSegment {
2648            id: "QTY".into(),
2649            elements: vec![vec!["220".into(), "0".into()]],
2650            segment_number: 14,
2651        }];
2652
2653        let workflow = AhbWorkflow {
2654            pruefidentifikator: "13017".to_string(),
2655            description: "Test".to_string(),
2656            communication_direction: None,
2657            fields: vec![
2658                // STS/C601/9015 — mandatory field under STS, but STS is absent from SG10
2659                AhbFieldRule {
2660                    segment_path: "SG5/SG6/SG9/SG10/STS/C601/9015".to_string(),
2661                    name: "Statuskategorie, Code".to_string(),
2662                    ahb_status: "X".to_string(),
2663                    codes: vec![],
2664                    parent_group_ahb_status: Some("Muss".to_string()),
2665                    ..Default::default()
2666                },
2667                // STS/C556/9013 — another field under STS
2668                AhbFieldRule {
2669                    segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2670                    name: "Statusanlaß, Code".to_string(),
2671                    ahb_status: "X [5]".to_string(),
2672                    codes: vec![],
2673                    parent_group_ahb_status: Some("Muss".to_string()),
2674                    ..Default::default()
2675                },
2676            ],
2677            ub_definitions: HashMap::new(),
2678        };
2679
2680        let report = validator.validate_with_navigator(
2681            &segments,
2682            &workflow,
2683            &external,
2684            ValidationLevel::Conditions,
2685            &nav,
2686        );
2687
2688        let ahb_errors: Vec<_> = report
2689            .by_category(ValidationCategory::Ahb)
2690            .filter(|i| i.severity == Severity::Error)
2691            .collect();
2692
2693        assert!(
2694            ahb_errors.is_empty(),
2695            "Expected no AHB001 errors when STS segment is absent from SG10, got: {:?}",
2696            ahb_errors
2697        );
2698    }
2699
2700    #[test]
2701    fn test_group_scoped_code_validation_with_navigator() {
2702        // With a navigator, SG2/NAD is checked against {MS, MR} only,
2703        // and SG4/SG12/NAD is checked against {Z04, Z09} only.
2704        // NAD+MT in SG2 → error with allowed [MR, MS] (not the full union).
2705        use mig_types::navigator::GroupNavigator;
2706
2707        struct TestNav;
2708        impl GroupNavigator for TestNav {
2709            fn find_segments_in_group(
2710                &self,
2711                segment_id: &str,
2712                group_path: &[&str],
2713                _instance_index: usize,
2714            ) -> Vec<OwnedSegment> {
2715                if segment_id != "NAD" {
2716                    return vec![];
2717                }
2718                match group_path {
2719                    ["SG2"] => vec![
2720                        OwnedSegment {
2721                            id: "NAD".into(),
2722                            elements: vec![vec!["MS".into()]],
2723                            segment_number: 3,
2724                        },
2725                        OwnedSegment {
2726                            id: "NAD".into(),
2727                            elements: vec![vec!["MT".into()]], // invalid in SG2
2728                            segment_number: 4,
2729                        },
2730                    ],
2731                    ["SG4", "SG12"] => vec![
2732                        OwnedSegment {
2733                            id: "NAD".into(),
2734                            elements: vec![vec!["Z04".into()]],
2735                            segment_number: 20,
2736                        },
2737                        OwnedSegment {
2738                            id: "NAD".into(),
2739                            elements: vec![vec!["Z09".into()]],
2740                            segment_number: 21,
2741                        },
2742                    ],
2743                    _ => vec![],
2744                }
2745            }
2746            fn find_segments_with_qualifier_in_group(
2747                &self,
2748                _: &str,
2749                _: usize,
2750                _: &str,
2751                _: &[&str],
2752                _: usize,
2753            ) -> Vec<OwnedSegment> {
2754                vec![]
2755            }
2756            fn group_instance_count(&self, group_path: &[&str]) -> usize {
2757                match group_path {
2758                    ["SG2"] | ["SG4", "SG12"] => 1,
2759                    _ => 0,
2760                }
2761            }
2762        }
2763
2764        let evaluator = MockEvaluator::new(vec![]);
2765        let validator = EdifactValidator::new(evaluator);
2766        let external = NoOpExternalProvider;
2767        let nav = TestNav;
2768
2769        let workflow = AhbWorkflow {
2770            pruefidentifikator: "55001".to_string(),
2771            description: "Test".to_string(),
2772            communication_direction: None,
2773            fields: vec![
2774                AhbFieldRule {
2775                    segment_path: "SG2/NAD/3035".to_string(),
2776                    name: "Absender".to_string(),
2777                    ahb_status: "X".to_string(),
2778                    codes: vec![AhbCodeRule {
2779                        value: "MS".to_string(),
2780                        description: "Absender".to_string(),
2781                        ahb_status: "X".to_string(),
2782                    }],
2783                    parent_group_ahb_status: None,
2784                    ..Default::default()
2785                },
2786                AhbFieldRule {
2787                    segment_path: "SG2/NAD/3035".to_string(),
2788                    name: "Empfaenger".to_string(),
2789                    ahb_status: "X".to_string(),
2790                    codes: vec![AhbCodeRule {
2791                        value: "MR".to_string(),
2792                        description: "Empfaenger".to_string(),
2793                        ahb_status: "X".to_string(),
2794                    }],
2795                    parent_group_ahb_status: None,
2796                    ..Default::default()
2797                },
2798                AhbFieldRule {
2799                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2800                    name: "Anschlussnutzer".to_string(),
2801                    ahb_status: "X".to_string(),
2802                    codes: vec![AhbCodeRule {
2803                        value: "Z04".to_string(),
2804                        description: "Anschlussnutzer".to_string(),
2805                        ahb_status: "X".to_string(),
2806                    }],
2807                    parent_group_ahb_status: None,
2808                    ..Default::default()
2809                },
2810                AhbFieldRule {
2811                    segment_path: "SG4/SG12/NAD/3035".to_string(),
2812                    name: "Korrespondenzanschrift".to_string(),
2813                    ahb_status: "X".to_string(),
2814                    codes: vec![AhbCodeRule {
2815                        value: "Z09".to_string(),
2816                        description: "Korrespondenzanschrift".to_string(),
2817                        ahb_status: "X".to_string(),
2818                    }],
2819                    parent_group_ahb_status: None,
2820                    ..Default::default()
2821                },
2822            ],
2823            ub_definitions: HashMap::new(),
2824        };
2825
2826        // All segments flat (for condition evaluation), navigator provides group scope.
2827        let all_segments = vec![
2828            OwnedSegment {
2829                id: "NAD".into(),
2830                elements: vec![vec!["MS".into()]],
2831                segment_number: 3,
2832            },
2833            OwnedSegment {
2834                id: "NAD".into(),
2835                elements: vec![vec!["MT".into()]],
2836                segment_number: 4,
2837            },
2838            OwnedSegment {
2839                id: "NAD".into(),
2840                elements: vec![vec!["Z04".into()]],
2841                segment_number: 20,
2842            },
2843            OwnedSegment {
2844                id: "NAD".into(),
2845                elements: vec![vec!["Z09".into()]],
2846                segment_number: 21,
2847            },
2848        ];
2849
2850        let report = validator.validate_with_navigator(
2851            &all_segments,
2852            &workflow,
2853            &external,
2854            ValidationLevel::Conditions,
2855            &nav,
2856        );
2857
2858        let code_errors: Vec<_> = report
2859            .by_category(ValidationCategory::Code)
2860            .filter(|i| i.severity == Severity::Error)
2861            .collect();
2862
2863        // Only one error: MT in SG2 (not allowed in {MS, MR}).
2864        // Z04 and Z09 are NOT checked against {MS, MR} — group-scoped.
2865        assert_eq!(
2866            code_errors.len(),
2867            1,
2868            "Expected exactly one COD002 error for MT in SG2, got: {:?}",
2869            code_errors
2870        );
2871        assert!(code_errors[0].message.contains("MT"));
2872        // Error should show only SG2's allowed codes, not the full union
2873        assert!(code_errors[0].message.contains("MR"));
2874        assert!(code_errors[0].message.contains("MS"));
2875        assert!(
2876            !code_errors[0].message.contains("Z04"),
2877            "SG4/SG12 codes should not leak into SG2 error"
2878        );
2879        // Field path should include the group
2880        assert!(
2881            code_errors[0]
2882                .field_path
2883                .as_deref()
2884                .unwrap_or("")
2885                .contains("SG2"),
2886            "Error field_path should reference SG2, got: {:?}",
2887            code_errors[0].field_path
2888        );
2889    }
2890
2891    // === Package cardinality tests ===
2892
2893    #[test]
2894    fn test_package_cardinality_within_bounds() {
2895        // 1 code present from package [4P0..1], max=1 -> OK
2896        let evaluator = MockEvaluator::all_true(&[]);
2897        let validator = EdifactValidator::new(evaluator);
2898        let external = NoOpExternalProvider;
2899
2900        let segments = vec![OwnedSegment {
2901            id: "STS".into(),
2902            elements: vec![
2903                vec!["Z33".into()], // element[0]
2904                vec![],             // element[1]
2905                vec!["E01".into()], // element[2], component[0]
2906            ],
2907            segment_number: 5,
2908        }];
2909
2910        let workflow = AhbWorkflow {
2911            pruefidentifikator: "13017".to_string(),
2912            description: "Test".to_string(),
2913            communication_direction: None,
2914            ub_definitions: HashMap::new(),
2915            fields: vec![AhbFieldRule {
2916                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2917                name: "Statusanlaß, Code".to_string(),
2918                ahb_status: "X".to_string(),
2919                element_index: Some(2),
2920                component_index: Some(0),
2921                codes: vec![
2922                    AhbCodeRule {
2923                        value: "E01".into(),
2924                        description: "Code 1".into(),
2925                        ahb_status: "X [4P0..1]".into(),
2926                    },
2927                    AhbCodeRule {
2928                        value: "E02".into(),
2929                        description: "Code 2".into(),
2930                        ahb_status: "X [4P0..1]".into(),
2931                    },
2932                ],
2933                parent_group_ahb_status: Some("Muss".to_string()),
2934                mig_number: None,
2935            }],
2936        };
2937
2938        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2939        let pkg_errors: Vec<_> = report
2940            .by_category(ValidationCategory::Ahb)
2941            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2942            .collect();
2943        assert!(
2944            pkg_errors.is_empty(),
2945            "1 code within [4P0..1] bounds — no error expected, got: {:?}",
2946            pkg_errors
2947        );
2948    }
2949
2950    #[test]
2951    fn test_package_cardinality_zero_present_min_zero() {
2952        // No codes from the package present, min=0 -> OK
2953        let evaluator = MockEvaluator::all_true(&[]);
2954        let validator = EdifactValidator::new(evaluator);
2955        let external = NoOpExternalProvider;
2956
2957        let segments = vec![OwnedSegment {
2958            id: "STS".into(),
2959            elements: vec![
2960                vec!["Z33".into()],
2961                vec![],
2962                vec!["X99".into()], // X99 not in package
2963            ],
2964            segment_number: 5,
2965        }];
2966
2967        let workflow = AhbWorkflow {
2968            pruefidentifikator: "13017".to_string(),
2969            description: "Test".to_string(),
2970            communication_direction: None,
2971            ub_definitions: HashMap::new(),
2972            fields: vec![AhbFieldRule {
2973                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
2974                name: "Statusanlaß, Code".to_string(),
2975                ahb_status: "X".to_string(),
2976                element_index: Some(2),
2977                component_index: Some(0),
2978                codes: vec![
2979                    AhbCodeRule {
2980                        value: "E01".into(),
2981                        description: "Code 1".into(),
2982                        ahb_status: "X [4P0..1]".into(),
2983                    },
2984                    AhbCodeRule {
2985                        value: "E02".into(),
2986                        description: "Code 2".into(),
2987                        ahb_status: "X [4P0..1]".into(),
2988                    },
2989                ],
2990                parent_group_ahb_status: Some("Muss".to_string()),
2991                mig_number: None,
2992            }],
2993        };
2994
2995        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
2996        let pkg_errors: Vec<_> = report
2997            .by_category(ValidationCategory::Ahb)
2998            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
2999            .collect();
3000        assert!(
3001            pkg_errors.is_empty(),
3002            "0 codes, min=0 — no error expected, got: {:?}",
3003            pkg_errors
3004        );
3005    }
3006
3007    #[test]
3008    fn test_package_cardinality_too_many() {
3009        // 2 codes present from package [4P0..1], max=1 -> ERROR
3010        let evaluator = MockEvaluator::all_true(&[]);
3011        let validator = EdifactValidator::new(evaluator);
3012        let external = NoOpExternalProvider;
3013
3014        // Two STS segments, each with a different package code
3015        let segments = vec![
3016            OwnedSegment {
3017                id: "STS".into(),
3018                elements: vec![vec!["Z33".into()], vec![], vec!["E01".into()]],
3019                segment_number: 5,
3020            },
3021            OwnedSegment {
3022                id: "STS".into(),
3023                elements: vec![vec!["Z33".into()], vec![], vec!["E02".into()]],
3024                segment_number: 6,
3025            },
3026        ];
3027
3028        let workflow = AhbWorkflow {
3029            pruefidentifikator: "13017".to_string(),
3030            description: "Test".to_string(),
3031            communication_direction: None,
3032            ub_definitions: HashMap::new(),
3033            fields: vec![AhbFieldRule {
3034                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3035                name: "Statusanlaß, Code".to_string(),
3036                ahb_status: "X".to_string(),
3037                element_index: Some(2),
3038                component_index: Some(0),
3039                codes: vec![
3040                    AhbCodeRule {
3041                        value: "E01".into(),
3042                        description: "Code 1".into(),
3043                        ahb_status: "X [4P0..1]".into(),
3044                    },
3045                    AhbCodeRule {
3046                        value: "E02".into(),
3047                        description: "Code 2".into(),
3048                        ahb_status: "X [4P0..1]".into(),
3049                    },
3050                ],
3051                parent_group_ahb_status: Some("Muss".to_string()),
3052                mig_number: None,
3053            }],
3054        };
3055
3056        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3057        let pkg_errors: Vec<_> = report
3058            .by_category(ValidationCategory::Ahb)
3059            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3060            .collect();
3061        assert_eq!(
3062            pkg_errors.len(),
3063            1,
3064            "2 codes present, max=1 — expected 1 error, got: {:?}",
3065            pkg_errors
3066        );
3067        assert!(pkg_errors[0].message.contains("[4P0..1]"));
3068        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("2"));
3069        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("0..1"));
3070    }
3071
3072    #[test]
3073    fn test_package_cardinality_too_few() {
3074        // 0 codes present from package [5P1..3], min=1 -> ERROR
3075        let evaluator = MockEvaluator::all_true(&[]);
3076        let validator = EdifactValidator::new(evaluator);
3077        let external = NoOpExternalProvider;
3078
3079        let segments = vec![OwnedSegment {
3080            id: "STS".into(),
3081            elements: vec![
3082                vec!["Z33".into()],
3083                vec![],
3084                vec!["X99".into()], // not in package
3085            ],
3086            segment_number: 5,
3087        }];
3088
3089        let workflow = AhbWorkflow {
3090            pruefidentifikator: "13017".to_string(),
3091            description: "Test".to_string(),
3092            communication_direction: None,
3093            ub_definitions: HashMap::new(),
3094            fields: vec![AhbFieldRule {
3095                segment_path: "SG5/SG6/SG9/SG10/STS/C556/9013".to_string(),
3096                name: "Statusanlaß, Code".to_string(),
3097                ahb_status: "X".to_string(),
3098                element_index: Some(2),
3099                component_index: Some(0),
3100                codes: vec![
3101                    AhbCodeRule {
3102                        value: "E01".into(),
3103                        description: "Code 1".into(),
3104                        ahb_status: "X [5P1..3]".into(),
3105                    },
3106                    AhbCodeRule {
3107                        value: "E02".into(),
3108                        description: "Code 2".into(),
3109                        ahb_status: "X [5P1..3]".into(),
3110                    },
3111                    AhbCodeRule {
3112                        value: "E03".into(),
3113                        description: "Code 3".into(),
3114                        ahb_status: "X [5P1..3]".into(),
3115                    },
3116                ],
3117                parent_group_ahb_status: Some("Muss".to_string()),
3118                mig_number: None,
3119            }],
3120        };
3121
3122        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3123        let pkg_errors: Vec<_> = report
3124            .by_category(ValidationCategory::Ahb)
3125            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3126            .collect();
3127        assert_eq!(
3128            pkg_errors.len(),
3129            1,
3130            "0 codes present, min=1 — expected 1 error, got: {:?}",
3131            pkg_errors
3132        );
3133        assert!(pkg_errors[0].message.contains("[5P1..3]"));
3134        assert_eq!(pkg_errors[0].actual_value.as_deref(), Some("0"));
3135        assert_eq!(pkg_errors[0].expected_value.as_deref(), Some("1..3"));
3136    }
3137
3138    #[test]
3139    fn test_package_cardinality_no_packages_in_workflow() {
3140        // Codes without package annotations -> no package errors
3141        let evaluator = MockEvaluator::all_true(&[]);
3142        let validator = EdifactValidator::new(evaluator);
3143        let external = NoOpExternalProvider;
3144
3145        let segments = vec![OwnedSegment {
3146            id: "STS".into(),
3147            elements: vec![vec!["E01".into()]],
3148            segment_number: 5,
3149        }];
3150
3151        let workflow = AhbWorkflow {
3152            pruefidentifikator: "13017".to_string(),
3153            description: "Test".to_string(),
3154            communication_direction: None,
3155            ub_definitions: HashMap::new(),
3156            fields: vec![AhbFieldRule {
3157                segment_path: "STS/9015".to_string(),
3158                name: "Status Code".to_string(),
3159                ahb_status: "X".to_string(),
3160                codes: vec![AhbCodeRule {
3161                    value: "E01".into(),
3162                    description: "Code 1".into(),
3163                    ahb_status: "X".into(),
3164                }],
3165                parent_group_ahb_status: Some("Muss".to_string()),
3166                ..Default::default()
3167            }],
3168        };
3169
3170        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3171        let pkg_errors: Vec<_> = report
3172            .by_category(ValidationCategory::Ahb)
3173            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3174            .collect();
3175        assert!(
3176            pkg_errors.is_empty(),
3177            "No packages in workflow — no errors expected"
3178        );
3179    }
3180
3181    #[test]
3182    fn test_package_cardinality_with_condition_and_package() {
3183        // Code status "X [901] [4P0..1]" has both condition and package
3184        let evaluator = MockEvaluator::all_true(&[901]);
3185        let validator = EdifactValidator::new(evaluator);
3186        let external = NoOpExternalProvider;
3187
3188        let segments = vec![OwnedSegment {
3189            id: "STS".into(),
3190            elements: vec![vec![], vec![], vec!["E01".into()]],
3191            segment_number: 5,
3192        }];
3193
3194        let workflow = AhbWorkflow {
3195            pruefidentifikator: "13017".to_string(),
3196            description: "Test".to_string(),
3197            communication_direction: None,
3198            ub_definitions: HashMap::new(),
3199            fields: vec![AhbFieldRule {
3200                segment_path: "SG10/STS/C556/9013".to_string(),
3201                name: "Code".to_string(),
3202                ahb_status: "X".to_string(),
3203                element_index: Some(2),
3204                component_index: Some(0),
3205                codes: vec![
3206                    AhbCodeRule {
3207                        value: "E01".into(),
3208                        description: "Code 1".into(),
3209                        ahb_status: "X [901] [4P0..1]".into(),
3210                    },
3211                    AhbCodeRule {
3212                        value: "E02".into(),
3213                        description: "Code 2".into(),
3214                        ahb_status: "X [901] [4P0..1]".into(),
3215                    },
3216                ],
3217                parent_group_ahb_status: Some("Muss".to_string()),
3218                mig_number: None,
3219            }],
3220        };
3221
3222        let report = validator.validate(&segments, &workflow, &external, ValidationLevel::Full);
3223        let pkg_errors: Vec<_> = report
3224            .by_category(ValidationCategory::Ahb)
3225            .filter(|i| i.code == ErrorCodes::PACKAGE_CARDINALITY_VIOLATION)
3226            .collect();
3227        assert!(
3228            pkg_errors.is_empty(),
3229            "1 code within [4P0..1] bounds — no error, got: {:?}",
3230            pkg_errors
3231        );
3232    }
3233
3234    fn make_segment(id: &str, elements: Vec<Vec<&str>>) -> OwnedSegment {
3235        OwnedSegment {
3236            id: id.to_string(),
3237            elements: elements
3238                .into_iter()
3239                .map(|e| e.into_iter().map(|s| s.to_string()).collect())
3240                .collect(),
3241            segment_number: 0,
3242        }
3243    }
3244
3245    #[test]
3246    fn test_unt_count_correct() {
3247        // UNH + BGM + DTM + UNT = 4 segments, UNT declares 4
3248        let segments = vec![
3249            make_segment("UNH", vec![vec!["001"]]),
3250            make_segment("BGM", vec![vec!["E01"]]),
3251            make_segment("DTM", vec![vec!["137", "20250401"]]),
3252            make_segment("UNT", vec![vec!["4", "001"]]),
3253        ];
3254        assert!(
3255            validate_unt_segment_count(&segments).is_none(),
3256            "Correct count should produce no issue"
3257        );
3258    }
3259
3260    #[test]
3261    fn test_unt_count_mismatch() {
3262        // UNH + BGM + UNT = 3 segments, but UNT declares 5
3263        let segments = vec![
3264            make_segment("UNH", vec![vec!["001"]]),
3265            make_segment("BGM", vec![vec!["E01"]]),
3266            make_segment("UNT", vec![vec!["5", "001"]]),
3267        ];
3268        let issue = validate_unt_segment_count(&segments)
3269            .expect("Mismatch should produce an issue");
3270        assert_eq!(issue.code, ErrorCodes::UNT_SEGMENT_COUNT_MISMATCH);
3271        assert_eq!(issue.severity, Severity::Error);
3272        assert!(issue.message.contains("declared 5"));
3273        assert!(issue.message.contains("actual 3"));
3274    }
3275
3276    #[test]
3277    fn test_unt_count_excludes_envelope() {
3278        // Envelope segments (UNA, UNB, UNZ) should not be counted
3279        let segments = vec![
3280            make_segment("UNA", vec![]),
3281            make_segment("UNB", vec![vec!["UNOC", "3"]]),
3282            make_segment("UNH", vec![vec!["001"]]),
3283            make_segment("BGM", vec![vec!["E01"]]),
3284            make_segment("UNT", vec![vec!["3", "001"]]),
3285            make_segment("UNZ", vec![vec!["1"]]),
3286        ];
3287        assert!(
3288            validate_unt_segment_count(&segments).is_none(),
3289            "Envelope segments excluded — count should be 3 (UNH+BGM+UNT)"
3290        );
3291    }
3292
3293    #[test]
3294    fn test_unt_count_no_unt_returns_none() {
3295        let segments = vec![
3296            make_segment("UNH", vec![vec!["001"]]),
3297            make_segment("BGM", vec![vec!["E01"]]),
3298        ];
3299        assert!(
3300            validate_unt_segment_count(&segments).is_none(),
3301            "No UNT segment should return None (not our problem)"
3302        );
3303    }
3304}