Skip to main content

edifact_rs/
directory_validator.rs

1//! Shared UN/EDIFACT directory validation engine used by D.11A, D.01B and D.96A.
2
3use crate::validator::{ValidationRuleContext, Validator, report_error};
4use crate::{EdifactError, Segment, ValidationIssue, ValidationReport, ValidationSeverity};
5use std::sync::Arc;
6
7/// Mandatory/Conditional status of a data element within a segment.
8///
9/// Marked `#[non_exhaustive]` because UN/EDIFACT also defines Required, Advised,
10/// Dependent, and Not-used statuses; adding one must not break downstream `match`
11/// arms.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum Status {
15    /// Element must be present.
16    Mandatory,
17    /// Element is optional unless additional rules require it.
18    Conditional,
19}
20
21/// Reference to a data element within a segment definition.
22///
23/// Fields are private to enforce the one-based position invariant through the
24/// [`ElementRef::new`] constructor.  Use [`ElementRef::new`] for compile-time
25/// literals (panics at compile time when `position == 0`).
26///
27/// Use [`OwnedElementRef`] for runtime-constructed element refs.
28#[derive(Debug, Clone, Copy)]
29pub struct ElementRef {
30    /// One-based element position in the segment definition.
31    position: u8,
32    /// UN/EDIFACT data element identifier.
33    data_element: &'static str,
34    /// Requirement status of the element.
35    status: Status,
36    /// Maximum repetition count for this element.
37    max_repeat: u8,
38}
39
40impl ElementRef {
41    /// Construct an `ElementRef` with compile-time position validation.
42    ///
43    /// `position` must be ≥ 1 (one-based).  When called in a `const` context
44    /// (e.g. inside a `static` array initialiser), a zero `position` causes a
45    /// **compile-time error**.  At runtime it panics.
46    ///
47    /// # Panics
48    ///
49    /// Panics if `position == 0`.
50    ///
51    /// # Example
52    ///
53    /// ```rust
54    /// use edifact_rs::{ElementRef, Status};
55    ///
56    /// const BGM_1001: ElementRef = ElementRef::new(1, "1001", Status::Mandatory, 1);
57    /// ```
58    #[must_use]
59    pub const fn new(
60        position: u8,
61        data_element: &'static str,
62        status: Status,
63        max_repeat: u8,
64    ) -> Self {
65        assert!(
66            position != 0,
67            "ElementRef position must be >= 1 (one-based)"
68        );
69        Self {
70            position,
71            data_element,
72            status,
73            max_repeat,
74        }
75    }
76
77    /// One-based element position in the segment definition.
78    #[must_use]
79    #[inline]
80    pub const fn position(&self) -> u8 {
81        self.position
82    }
83
84    /// UN/EDIFACT data element identifier.
85    #[must_use]
86    #[inline]
87    pub const fn data_element(&self) -> &'static str {
88        self.data_element
89    }
90
91    /// Requirement status of the element.
92    #[must_use]
93    #[inline]
94    pub const fn status(&self) -> Status {
95        self.status
96    }
97
98    /// Maximum repetition count for this element.
99    #[must_use]
100    #[inline]
101    pub const fn max_repeat(&self) -> u8 {
102        self.max_repeat
103    }
104}
105
106/// Definition of an EDIFACT segment (tag + element structure).
107///
108/// Construct with [`SegmentDefinition::new`] rather than a struct literal, so
109/// that future fields (max repeat, description, …) are not a breaking change.
110#[derive(Debug)]
111#[non_exhaustive]
112pub struct SegmentDefinition {
113    /// Segment tag.
114    pub tag: &'static str,
115    /// Human-readable segment name.
116    pub name: &'static str,
117    /// Ordered element definitions.
118    pub elements: &'static [ElementRef],
119}
120
121impl SegmentDefinition {
122    /// Create a segment definition.
123    ///
124    /// `const` so directory tables can still be built at compile time despite
125    /// the `#[non_exhaustive]` attribute blocking external struct literals.
126    #[must_use]
127    pub const fn new(
128        tag: &'static str,
129        name: &'static str,
130        elements: &'static [ElementRef],
131    ) -> Self {
132        Self {
133            tag,
134            name,
135            elements,
136        }
137    }
138}
139
140/// Owned runtime equivalent of [`ElementRef`].
141///
142/// Used by [`DirectoryValidatorBuilder`] and [`DirectoryValidator::from_owned_definitions`]
143/// to construct validators from data that is not available at compile time (e.g. loaded
144/// from JSON or a database at startup).
145///
146/// Use [`OwnedElementRef::new_unchecked`] for compile-time-known positions (panics on invalid
147/// input, no error handling noise) or [`OwnedElementRef::try_new`] when the position
148/// comes from an external source and you need a `Result`. Fields are private to prevent
149/// bypassing the position invariant through struct-literal syntax.
150#[derive(Debug, Clone)]
151pub struct OwnedElementRef {
152    /// One-based element position.
153    position: u8,
154    /// UN/EDIFACT data element identifier.
155    data_element: String,
156    /// Requirement status.
157    status: Status,
158    /// Maximum repetition count.
159    max_repeat: u8,
160}
161
162/// Owned runtime equivalent of [`SegmentDefinition`].
163///
164/// Used by [`DirectoryValidatorBuilder`] and [`DirectoryValidator::from_owned_definitions`].
165///
166/// Use [`OwnedSegmentDef::new_unchecked`] for compile-time-known tags (panics on invalid input,
167/// no error handling noise) or [`OwnedSegmentDef::try_new`] when the tag comes from
168/// an external source and you need a `Result`. Fields are private to prevent bypassing
169/// the tag invariant through struct-literal syntax.
170#[derive(Debug, Clone)]
171pub struct OwnedSegmentDef {
172    /// Segment tag (e.g. `"BGM"`).
173    tag: String,
174    /// Human-readable segment name.
175    name: String,
176    /// Ordered element definitions.
177    elements: Vec<OwnedElementRef>,
178}
179
180impl OwnedSegmentDef {
181    /// Construct an owned segment definition.
182    ///
183    /// This is the ergonomic constructor for compile-time-known tags (e.g.
184    /// `"BGM"`, `"UNH"`).  It panics immediately on invalid input so that
185    /// call sites with literal tag strings require no `.unwrap()` / `.expect()`
186    /// boilerplate.
187    ///
188    /// Use [`try_new`][Self::try_new] instead when the tag originates from an
189    /// external source (user input, config file, database) and you need a
190    /// `Result` to propagate errors gracefully.
191    ///
192    /// # Panics
193    ///
194    /// Panics if `tag` is not exactly three ASCII uppercase letters.
195    pub fn new_unchecked(tag: String, name: String, elements: Vec<OwnedElementRef>) -> Self {
196        assert!(
197            tag.len() == 3 && tag.bytes().all(|b| b.is_ascii_uppercase()),
198            "OwnedSegmentDef::new_unchecked: tag must be exactly three ASCII uppercase letters, got {tag:?}"
199        );
200        Self {
201            tag,
202            name,
203            elements,
204        }
205    }
206
207    /// Construct an owned segment definition, returning an error for invalid tags.
208    ///
209    /// Prefer this over [`new_unchecked`][Self::new_unchecked] when the tag comes from an external
210    /// source (user input, config file, database) and you want to handle the
211    /// error without panicking.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`EdifactError::InvalidSegmentTag`] if `tag` is not exactly three
216    /// ASCII uppercase letters.
217    pub fn try_new(
218        tag: String,
219        name: String,
220        elements: Vec<OwnedElementRef>,
221    ) -> Result<Self, EdifactError> {
222        if tag.len() != 3 || !tag.bytes().all(|b| b.is_ascii_uppercase()) {
223            return Err(EdifactError::InvalidSegmentTag(tag));
224        }
225        Ok(Self {
226            tag,
227            name,
228            elements,
229        })
230    }
231
232    /// Segment tag (e.g. `"BGM"`).
233    #[inline]
234    pub fn tag(&self) -> &str {
235        &self.tag
236    }
237
238    /// Human-readable segment name.
239    #[inline]
240    pub fn name(&self) -> &str {
241        &self.name
242    }
243
244    /// Element definitions for this segment.
245    #[inline]
246    pub fn elements(&self) -> &[OwnedElementRef] {
247        &self.elements
248    }
249}
250
251impl OwnedElementRef {
252    /// Construct an owned element reference.
253    ///
254    /// This is the ergonomic constructor for compile-time-known positions.
255    /// It panics immediately on invalid input so that call sites with literal
256    /// position numbers require no `.unwrap()` / `.expect()` boilerplate.
257    ///
258    /// Use [`try_new`][Self::try_new] instead when the position originates from
259    /// an external source (user input, config file, database) and you need a
260    /// `Result` to propagate errors gracefully.
261    ///
262    /// # Panics
263    ///
264    /// Panics if `position` is `0` (positions are one-based).
265    pub fn new_unchecked(
266        position: u8,
267        data_element: String,
268        status: Status,
269        max_repeat: u8,
270    ) -> Self {
271        assert!(
272            position != 0,
273            "OwnedElementRef::new_unchecked: position must be >= 1 (one-based), got 0"
274        );
275        Self {
276            position,
277            data_element,
278            status,
279            max_repeat,
280        }
281    }
282
283    /// Construct an owned element reference, returning an error for position `0`.
284    ///
285    /// Prefer this over [`new_unchecked`][Self::new_unchecked] when the position comes from an
286    /// external source (user input, config file, database) and you want to
287    /// handle the error without panicking.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`EdifactError::InvalidElementPosition`] if `position` is `0`.
292    pub fn try_new(
293        position: u8,
294        data_element: String,
295        status: Status,
296        max_repeat: u8,
297    ) -> Result<Self, EdifactError> {
298        if position == 0 {
299            return Err(EdifactError::InvalidElementPosition);
300        }
301        Ok(Self {
302            position,
303            data_element,
304            status,
305            max_repeat,
306        })
307    }
308
309    /// One-based element position (always >= 1).
310    #[inline]
311    pub fn position(&self) -> u8 {
312        self.position
313    }
314
315    /// UN/EDIFACT data element identifier.
316    #[inline]
317    pub fn data_element(&self) -> &str {
318        &self.data_element
319    }
320
321    /// Requirement status of this element.
322    #[inline]
323    pub fn status(&self) -> Status {
324        self.status
325    }
326
327    /// Maximum repetition count for this element.
328    #[inline]
329    pub fn max_repeat(&self) -> u8 {
330        self.max_repeat
331    }
332}
333
334type SegmentLookupFn = Arc<dyn Fn(&str) -> Option<&'static SegmentDefinition> + Send + Sync>;
335type IsCodeValidFn = Arc<dyn Fn(&str, &str) -> bool + Send + Sync>;
336type SuggestCodeFn = Arc<dyn Fn(&str, &str) -> Option<&'static str> + Send + Sync>;
337type ExpectedComponentsFn = Arc<dyn Fn(&str, usize) -> Option<u8> + Send + Sync>;
338type AdditionalStructureRuleRefFn = fn(&Segment<'_>) -> Result<(), EdifactError>;
339type AdditionalStructureRuleFn =
340    Arc<dyn Fn(&Segment<'_>) -> Result<(), EdifactError> + Send + Sync>;
341/// Returns the `(element_index, component_index, data_element_id)` tuples to
342/// validate against a code list for the given segment tag.
343type CodeListRulesFn = Arc<dyn Fn(&str) -> &'static [(usize, usize, &'static str)] + Send + Sync>;
344/// Returns the mandatory segment tags for a given EDIFACT message type.
345///
346/// The slice should contain every tag that must appear at least once in a
347/// conformant message of the given type.  The tags are also used to check
348/// canonical ordering — their relative order in the returned slice is taken
349/// as the expected order in the message.
350type RequiredSegmentsFn = Arc<dyn Fn(&str) -> &'static [&'static str] + Send + Sync>;
351
352/// Internal enum that unifies lookup results from static and owned segment definitions.
353///
354/// Allows `validate_segment` to handle both code-generated (`&'static`) and
355/// runtime-constructed ([`OwnedSegmentDef`]) definitions without duplication.
356enum SegmentDefRef<'a> {
357    Static(&'static SegmentDefinition),
358    Owned(&'a OwnedSegmentDef),
359}
360
361impl SegmentDefRef<'_> {
362    /// Returns the highest defined element position (one-based → used directly as
363    /// the maximum zero-based slot count for element-count validation).
364    ///
365    /// For owned definitions the highest `position` value may exceed the number
366    /// of entries in the `elements` vec when positions are non-consecutive.
367    fn max_element_position(&self) -> usize {
368        match self {
369            Self::Static(d) => d
370                .elements
371                .iter()
372                .map(|e| e.position as usize)
373                .max()
374                .unwrap_or(0),
375            Self::Owned(d) => d
376                .elements
377                .iter()
378                .map(|e| e.position as usize)
379                .max()
380                .unwrap_or(0),
381        }
382    }
383
384    /// Returns the highest position number among mandatory elements (one-based).
385    ///
386    /// This equals the minimum number of elements that must be present in a
387    /// segment: if the highest-positioned mandatory element is at position 5,
388    /// the segment must supply at least 5 elements.
389    fn last_mandatory_position(&self) -> usize {
390        match self {
391            Self::Static(d) => d
392                .elements
393                .iter()
394                .filter(|e| e.status == Status::Mandatory)
395                .map(|e| e.position as usize)
396                .max()
397                .unwrap_or(0),
398            Self::Owned(d) => d
399                .elements
400                .iter()
401                .filter(|e| e.status == Status::Mandatory)
402                .map(|e| e.position as usize)
403                .max()
404                .unwrap_or(0),
405        }
406    }
407
408    /// Iterate over mandatory element positions without heap allocation.
409    ///
410    /// Calls `f(zero_based_index, data_element_id)` for each element whose
411    /// status is [`Status::Mandatory`].  Returns `Err` immediately if `f`
412    /// returns `Err`, short-circuiting the remaining elements.
413    fn for_each_mandatory_position<E, F>(&self, mut f: F) -> Result<(), E>
414    where
415        F: FnMut(usize, &str) -> Result<(), E>,
416    {
417        match self {
418            Self::Static(d) => {
419                for e in d.elements.iter().filter(|e| e.status == Status::Mandatory) {
420                    f((e.position as usize).saturating_sub(1), e.data_element)?;
421                }
422            }
423            Self::Owned(d) => {
424                for e in d.elements.iter().filter(|e| e.status == Status::Mandatory) {
425                    f(
426                        (e.position as usize).saturating_sub(1),
427                        e.data_element.as_str(),
428                    )?;
429                }
430            }
431        }
432        Ok(())
433    }
434}
435
436/// Default required-segments mapping used when no custom function is provided.
437///
438/// Returns the universal minimum: every EDIFACT message must begin with `UNH`
439/// and end with `UNT`.  Message-type-specific mandatory segments (such as
440/// `BGM` for ORDERS/INVOIC) must be enforced by a
441/// [`ProfileRulePack`][crate::ProfileRulePack] or a custom
442/// [`DirectoryValidatorBuilder::with_required_segments`] function to avoid
443/// false positives for message types that do not require `BGM`.
444fn default_required_segments(_message_type: &str) -> &'static [&'static str] {
445    &["UNH", "UNT"]
446}
447
448/// Code-list validation rules common to all UN/EDIFACT directory releases.
449///
450/// Each entry is `(element_index, component_index, data_element_id)`.
451/// `element_index` and `component_index` are zero-based.
452///
453/// Covers the most frequently validated qualifier/code elements across ORDERS,
454/// INVOIC, UTILMD, and similar message types.
455pub(crate) fn base_code_list_rules(tag: &str) -> &'static [(usize, usize, &'static str)] {
456    match tag {
457        "BGM" => &[(0, 0, "1001")],
458        "DTM" => &[(0, 0, "2005")],
459        "NAD" => &[(0, 0, "3035")],
460        "QTY" => &[(0, 0, "6063")],
461        "RFF" => &[(0, 0, "1153")],
462        "MOA" => &[(0, 0, "5025")],
463        "PRI" => &[(0, 0, "5125")],
464        "LOC" => &[(0, 0, "3227")],
465        _ => &[],
466    }
467}
468
469/// Shared validator implementation that is configured per UN/EDIFACT directory release.
470///
471/// # Scope and limitations
472///
473/// `DirectoryValidator` validates individual segment *content* (element counts,
474/// component counts, code-list values, and conditional rules) and checks that
475/// every *mandatory* segment type is present at least once.  It does **not**
476/// validate segment *sequence* or *repetition cardinality* — i.e., it cannot
477/// tell you that a `BGM` segment appears more than once, or that a `RFF` group
478/// appears in the wrong position.  Full sequence validation requires a
479/// state-machine per message type (UN/EDIFACT Segment Tables) which is outside
480/// the scope of this implementation.
481#[derive(Clone)]
482pub struct DirectoryValidator {
483    directory_id: String,
484    segment_lookup: SegmentLookupFn,
485    /// Runtime-owned segment definitions (from builder / JSON / DB).
486    ///
487    /// When `Some`, takes precedence over `segment_lookup` for tag resolution.
488    owned_defs: Option<Arc<Vec<OwnedSegmentDef>>>,
489    /// Tag -> index into `owned_defs`.  Without this, `resolve_def` was a linear
490    /// scan per segment, making validation O(n_segments x n_definitions).
491    owned_index: Option<Arc<std::collections::HashMap<String, usize>>>,
492    is_code_valid: IsCodeValidFn,
493    suggest_code: SuggestCodeFn,
494    expected_components: ExpectedComponentsFn,
495    code_list_rules: CodeListRulesFn,
496    additional_structure_rule: Option<AdditionalStructureRuleFn>,
497    /// Configurable mapping from message type to required segment tags.
498    required_segments: RequiredSegmentsFn,
499    message_type: Option<String>,
500    enforce_known_tags: bool,
501    structure_checks: bool,
502    code_list_checks: bool,
503}
504
505impl std::fmt::Debug for DirectoryValidator {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("DirectoryValidator")
508            .field("directory_id", &self.directory_id)
509            .field("message_type", &self.message_type)
510            .field("enforce_known_tags", &self.enforce_known_tags)
511            .field("structure_checks", &self.structure_checks)
512            .field("code_list_checks", &self.code_list_checks)
513            .finish_non_exhaustive()
514    }
515}
516
517impl DirectoryValidator {
518    /// Create a validator for a specific directory release with injected lookup/check hooks.
519    pub fn new(
520        directory_id: &'static str,
521        segment_lookup: fn(&str) -> Option<&'static SegmentDefinition>,
522        is_code_valid: fn(&str, &str) -> bool,
523        suggest_code: fn(&str, &str) -> Option<&'static str>,
524        expected_components: fn(&str, usize) -> Option<u8>,
525        additional_structure_rule: Option<AdditionalStructureRuleRefFn>,
526    ) -> Self {
527        Self {
528            directory_id: directory_id.to_owned(),
529            segment_lookup: Arc::new(segment_lookup),
530            owned_defs: None,
531            owned_index: None,
532            is_code_valid: Arc::new(is_code_valid),
533            suggest_code: Arc::new(suggest_code),
534            expected_components: Arc::new(expected_components),
535            code_list_rules: Arc::new(base_code_list_rules),
536            additional_structure_rule: additional_structure_rule
537                .map(|f| Arc::new(f) as AdditionalStructureRuleFn),
538            required_segments: Arc::new(default_required_segments),
539            message_type: None,
540            enforce_known_tags: true,
541            structure_checks: true,
542            code_list_checks: true,
543        }
544    }
545
546    /// Create a validator from a static slice of [`SegmentDefinition`]s.
547    ///
548    /// This is the preferred constructor when code-generating directory data as
549    /// a `static` array: no manual fn-pointer boilerplate is required.
550    ///
551    /// Code-list checks are **disabled** by default (the built-in `is_code_valid`
552    /// always returns `true`).  Call [`with_code_list_rules`][Self::with_code_list_rules]
553    /// to register directory-specific rules that actually validate code values.
554    ///
555    /// # Example
556    ///
557    /// ```rust,ignore
558    /// static MY_SEGMENTS: &[SegmentDefinition] = &[ /* … */ ];
559    ///
560    /// let validator = DirectoryValidator::from_definitions(MY_SEGMENTS)
561    ///     .with_code_list_rules(my_code_list_rules);
562    /// ```
563    pub fn from_definitions(definitions: &'static [SegmentDefinition]) -> Self {
564        let lookup_map: std::collections::HashMap<&'static str, &'static SegmentDefinition> =
565            definitions.iter().map(|d| (d.tag, d)).collect();
566        let lookup_map = Arc::new(lookup_map);
567        Self {
568            directory_id: "custom".to_owned(),
569            segment_lookup: Arc::new(move |tag: &str| lookup_map.get(tag).copied()),
570            owned_defs: None,
571            owned_index: None,
572            is_code_valid: Arc::new(|_de: &str, _code: &str| true),
573            suggest_code: Arc::new(|_de: &str, _code: &str| None),
574            expected_components: Arc::new(|_tag: &str, _idx: usize| None),
575            code_list_rules: Arc::new(base_code_list_rules),
576            additional_structure_rule: None,
577            required_segments: Arc::new(default_required_segments),
578            message_type: None,
579            enforce_known_tags: true,
580            structure_checks: true,
581            code_list_checks: false,
582        }
583    }
584
585    /// Create a validator from a runtime-owned collection of segment definitions.
586    ///
587    /// Use this (or [`DirectoryValidatorBuilder`]) when segment definitions are
588    /// loaded from an external source at startup (JSON, database, YAML, …) rather
589    /// than being known at compile time.
590    ///
591    /// Code-list checks are **disabled** by default; enable them by chaining
592    /// [`with_code_list_rules`][Self::with_code_list_rules] and setting
593    /// `is_code_valid` via a custom [`new`][Self::new] call or by subclassing
594    /// the builder.
595    ///
596    /// # Example
597    ///
598    /// ```rust,ignore
599    /// let defs = vec![
600    ///     OwnedSegmentDef::new_unchecked(
601    ///         "BGM".to_owned(),
602    ///         "Beginning of message".to_owned(),
603    ///         vec![OwnedElementRef::new_unchecked(1, "C002".to_owned(), Status::Mandatory, 1)],
604    ///     ),
605    /// ];
606    /// let validator = DirectoryValidator::from_owned_definitions(defs)
607    ///     .with_directory_id("runtime-profile");
608    /// ```
609    pub fn from_owned_definitions(definitions: Vec<OwnedSegmentDef>) -> Self {
610        Self {
611            directory_id: "custom".to_owned(),
612            // The static lookup is never consulted when `owned_defs` is `Some`.
613            segment_lookup: Arc::new(|_| None),
614            owned_index: Some(Arc::new(
615                definitions
616                    .iter()
617                    .enumerate()
618                    .map(|(i, d)| (d.tag.clone(), i))
619                    .collect(),
620            )),
621            owned_defs: Some(Arc::new(definitions)),
622            is_code_valid: Arc::new(|_de: &str, _code: &str| true),
623            suggest_code: Arc::new(|_de: &str, _code: &str| None),
624            expected_components: Arc::new(|_tag: &str, _idx: usize| None),
625            code_list_rules: Arc::new(base_code_list_rules),
626            additional_structure_rule: None,
627            required_segments: Arc::new(default_required_segments),
628            message_type: None,
629            enforce_known_tags: true,
630            structure_checks: true,
631            code_list_checks: false,
632        }
633    }
634
635    /// Set the directory identifier string (used in error messages).
636    pub fn with_directory_id(mut self, id: impl Into<String>) -> Self {
637        self.directory_id = id.into();
638        self
639    }
640
641    /// Override the code-list rules function.
642    ///
643    /// Directories can supply a directory-specific implementation that extends or
644    /// replaces the base rules from `base_code_list_rules`.
645    pub fn with_code_list_rules(
646        mut self,
647        f: impl Fn(&str) -> &'static [(usize, usize, &'static str)] + Send + Sync + 'static,
648    ) -> Self {
649        self.code_list_rules = Arc::new(f);
650        self
651    }
652
653    /// Enable only structure checks and disable code-list checks.
654    pub fn structure_only(mut self) -> Self {
655        self.structure_checks = true;
656        self.code_list_checks = false;
657        self
658    }
659
660    /// Enable only code-list checks and disable structure checks.
661    pub fn code_list_only(mut self) -> Self {
662        self.structure_checks = false;
663        self.code_list_checks = true;
664        self
665    }
666
667    /// Configure whether unknown segment tags should be rejected.
668    pub fn enforce_known_tags(mut self, enforce: bool) -> Self {
669        self.enforce_known_tags = enforce;
670        self
671    }
672
673    /// Override the required-segments mapping used for structural validation.
674    ///
675    /// The supplied function receives an EDIFACT message type string (e.g. `"ORDERS"`)
676    /// and must return a `'static` slice of segment tags that are mandatory for that
677    /// type.  The tags are checked both for *presence* and for *canonical ordering*
678    /// within the message.
679    ///
680    /// # Example
681    ///
682    /// ```rust,ignore
683    /// fn my_required_segments(msg_type: &str) -> &'static [&'static str] {
684    ///     match msg_type {
685    ///         "DESADV" => &["UNH", "BGM", "SHP", "UNT"],
686    ///         "INVOIC" => &["UNH", "BGM", "MOA", "UNT"],
687    ///         _ => &["UNH", "UNT"],
688    ///     }
689    /// }
690    ///
691    /// let validator = DirectoryValidator::from_definitions(DEFS)
692    ///     .with_required_segments(my_required_segments);
693    /// ```
694    pub fn with_required_segments(
695        mut self,
696        f: impl Fn(&str) -> &'static [&'static str] + Send + Sync + 'static,
697    ) -> Self {
698        self.required_segments = Arc::new(f);
699        self
700    }
701
702    fn detect_message_type(&self, segments: &[Segment<'_>]) -> Option<String> {
703        if let Some(explicit) = self.message_type.as_deref() {
704            return Some(explicit.to_owned());
705        }
706
707        segments
708            .iter()
709            .find(|s| s.tag == "UNH")
710            .and_then(|s| s.get_element(1))
711            .and_then(|e| e.get_component(0))
712            .map(str::to_owned)
713    }
714
715    /// Count the non-trailing-empty components in element `element_idx` of `seg`.
716    ///
717    /// Per ISO 9735-1 §3.3 ("Trailing empty component data elements may be omitted"),
718    /// a sender is not required to transmit trailing empty components; this function
719    /// therefore strips them before checking against the expected count so that
720    /// conformant messages with omitted trailing components are still accepted.
721    ///
722    /// # Examples
723    ///
724    /// - `DTM+137:20200101:` has three declared components but only 2 non-empty → effective=2
725    /// - `NAD+MS++::293` has a composite with 3 components, last two empty → effective=1
726    fn effective_component_count(seg: &Segment<'_>, element_idx: usize) -> Option<u8> {
727        let elem = seg.elements.get(element_idx)?;
728        let mut count = elem.components.len();
729        while count > 0 && elem.components[count - 1].0.as_ref().is_empty() {
730            count -= 1;
731        }
732        u8::try_from(count).ok()
733    }
734
735    fn validate_component_counts(&self, seg: &Segment<'_>) -> Result<(), EdifactError> {
736        for idx in 0..seg.elements.len() {
737            if let Some(expected) = (self.expected_components)(seg.tag, idx) {
738                let actual = Self::effective_component_count(seg, idx).unwrap_or(0);
739                if actual != expected {
740                    return Err(EdifactError::InvalidComponentCount {
741                        tag: seg.tag.to_owned(),
742                        element_index: idx,
743                        expected,
744                        actual,
745                        offset: seg.span.start,
746                    });
747                }
748            }
749        }
750        Ok(())
751    }
752
753    fn validate_code_lists(&self, seg: &Segment<'_>) -> Result<(), EdifactError> {
754        let rules = (self.code_list_rules)(seg.tag);
755
756        for (elem_idx, comp_idx, de) in rules {
757            let value = seg
758                .get_element(*elem_idx)
759                .and_then(|e| e.get_component(*comp_idx))
760                .unwrap_or("");
761            if !value.is_empty() && !(self.is_code_valid)(de, value) {
762                let suggestion = (self.suggest_code)(de, value);
763                return Err(EdifactError::InvalidCodeValue {
764                    tag: seg.tag.to_owned(),
765                    element_index: *elem_idx,
766                    value: value.to_owned(),
767                    code_list: (*de).to_owned(),
768                    offset: seg.span.start,
769                    suggestion,
770                });
771            }
772        }
773
774        Ok(())
775    }
776}
777
778impl DirectoryValidator {
779    fn resolve_def<'a>(&'a self, tag: &str) -> Option<SegmentDefRef<'a>> {
780        if let Some(owned) = &self.owned_defs {
781            let index = self.owned_index.as_ref()?;
782            owned.get(*index.get(tag)?).map(SegmentDefRef::Owned)
783        } else {
784            (self.segment_lookup)(tag).map(SegmentDefRef::Static)
785        }
786    }
787
788    fn validate_segment(&self, seg: &Segment<'_>) -> Result<(), EdifactError> {
789        if !self.structure_checks && !self.code_list_checks {
790            return Ok(());
791        }
792
793        let Some(def) = self.resolve_def(seg.tag) else {
794            if self.structure_checks && self.enforce_known_tags {
795                return Err(EdifactError::InvalidSegmentForMessage {
796                    tag: seg.tag.to_owned(),
797                    message_type: self
798                        .message_type
799                        .clone()
800                        .unwrap_or_else(|| self.directory_id.clone()),
801                    offset: seg.tag_span.start,
802                });
803            }
804            return Ok(());
805        };
806
807        let max_elements = def.max_element_position();
808        let min_elements = def.last_mandatory_position();
809        let actual = seg.elements.len();
810
811        if self.structure_checks && (actual < min_elements || actual > max_elements) {
812            return Err(EdifactError::InvalidElementCount {
813                tag: seg.tag.to_owned(),
814                min: min_elements,
815                max: max_elements,
816                actual,
817                offset: seg.span.start,
818            });
819        }
820
821        if self.structure_checks {
822            def.for_each_mandatory_position(|idx, _de| {
823                let is_present = seg.elements.get(idx).is_some_and(|elem| {
824                    elem.components.iter().any(|(c, _)| !c.as_ref().is_empty())
825                });
826                if !is_present {
827                    return Err(EdifactError::MissingRequiredElement {
828                        tag: seg.tag.to_owned(),
829                        element_index: idx,
830                    });
831                }
832                Ok(())
833            })?;
834            self.validate_component_counts(seg)?;
835
836            if let Some(rule) = &self.additional_structure_rule {
837                rule(seg)?;
838            }
839        }
840
841        if self.code_list_checks {
842            self.validate_code_lists(seg)?;
843        }
844
845        Ok(())
846    }
847}
848
849impl Validator for DirectoryValidator {
850    fn set_message_type(&mut self, message_type: Option<&str>) {
851        self.message_type = message_type.map(str::to_owned);
852    }
853
854    fn validate_batch(
855        &self,
856        segments: &[Segment<'_>],
857        report: &mut ValidationReport,
858        _context: &ValidationRuleContext<'_>,
859    ) {
860        for seg in segments {
861            if let Err(err) = self.validate_segment(seg) {
862                report_error(report, err);
863            }
864        }
865
866        if self.structure_checks {
867            if let Some(message_type) = self.detect_message_type(segments) {
868                // One pass recording each tag's first index answers both the
869                // presence and the ordering question.  The previous shape ran two
870                // full scans *per required tag* and invoked `required_segments`
871                // twice, which is O(|required| x n) on every batch.
872                let mut first_index: std::collections::HashMap<&str, usize> =
873                    std::collections::HashMap::with_capacity(segments.len());
874                for (i, seg) in segments.iter().enumerate() {
875                    first_index.entry(seg.tag).or_insert(i);
876                }
877
878                let required = (self.required_segments)(&message_type);
879                for required_tag in required {
880                    if !first_index.contains_key(*required_tag) {
881                        report.add_error(
882                            ValidationIssue::new(
883                                ValidationSeverity::Error,
884                                format!(
885                                    "required segment {} missing for message type {}",
886                                    required_tag, message_type
887                                ),
888                            )
889                            .with_segment(*required_tag)
890                            .with_suggestion("Add the mandatory segment at the correct position"),
891                        );
892                    }
893                }
894
895                let mut last_idx = None;
896                for tag in required {
897                    if let Some(&idx) = first_index.get(*tag) {
898                        if let Some(prev) = last_idx {
899                            if idx < prev {
900                                report.add_error(
901                                    ValidationIssue::new(
902                                        ValidationSeverity::Error,
903                                        format!(
904                                            "segment sequence violation for message type {}: '{}' appears out of order",
905                                            message_type, tag
906                                        ),
907                                    )
908                                    .with_segment(*tag)
909                                    .with_suggestion(
910                                        "Ensure required segments follow UN/EDIFACT canonical order",
911                                    ),
912                                );
913                            }
914                        }
915                        last_idx = Some(idx);
916                    }
917                }
918            }
919        }
920    }
921}
922
923// ── DirectoryValidatorBuilder ─────────────────────────────────────────────────
924
925/// Builder for [`DirectoryValidator`] using runtime-owned segment definitions.
926///
927/// Use this when segment definitions are loaded from an external source at
928/// startup (JSON, database, YAML, …) rather than being available as `static`
929/// arrays at compile time.
930///
931/// # Example
932///
933/// ```rust,ignore
934/// let validator = DirectoryValidatorBuilder::new("my-profile")
935///     .add_segment(
936///         OwnedSegmentDef::new_unchecked(
937///             "BGM".to_owned(),
938///             "Beginning of message".to_owned(),
939///             vec![OwnedElementRef::new_unchecked(1, "C002".to_owned(), Status::Mandatory, 1)],
940///         ),
941///     )
942///     .build();
943/// ```
944#[derive(Debug, Default)]
945pub struct DirectoryValidatorBuilder {
946    directory_id: Option<String>,
947    segments: Vec<OwnedSegmentDef>,
948}
949
950impl DirectoryValidatorBuilder {
951    /// Create a new builder with the given directory identifier.
952    ///
953    /// The identifier is used in error messages; set a human-readable value
954    /// such as `"UTILMD-5.5.3a"` or `"custom-profile"`.
955    pub fn new(directory_id: impl Into<String>) -> Self {
956        Self {
957            directory_id: Some(directory_id.into()),
958            segments: Vec::new(),
959        }
960    }
961
962    /// Add a segment definition to the builder.
963    ///
964    /// Definitions can be added in any order; the resulting validator looks
965    /// them up by tag at validation time.
966    pub fn add_segment(mut self, def: OwnedSegmentDef) -> Self {
967        self.segments.push(def);
968        self
969    }
970
971    /// Extend the builder with multiple segment definitions at once.
972    pub fn add_segments(mut self, defs: impl IntoIterator<Item = OwnedSegmentDef>) -> Self {
973        self.segments.extend(defs);
974        self
975    }
976
977    /// Build the [`DirectoryValidator`].
978    ///
979    /// Returns a validator backed by the accumulated [`OwnedSegmentDef`]s.
980    /// Code-list checks are disabled by default; chain
981    /// [`DirectoryValidator::with_code_list_rules`] on the returned value to
982    /// enable them.
983    pub fn build(self) -> DirectoryValidator {
984        let mut validator = DirectoryValidator::from_owned_definitions(self.segments);
985        if let Some(id) = self.directory_id {
986            validator.directory_id = id;
987        }
988        validator
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    static TEST_ELEMENTS: &[ElementRef] = &[ElementRef::new(1, "C507", Status::Mandatory, 1)];
997
998    static TEST_SEGMENT: SegmentDefinition =
999        SegmentDefinition::new("TST", "Test segment", TEST_ELEMENTS);
1000
1001    fn segment_lookup(tag: &str) -> Option<&'static SegmentDefinition> {
1002        match tag {
1003            "TST" => Some(&TEST_SEGMENT),
1004            _ => None,
1005        }
1006    }
1007
1008    fn code_valid(_de: &str, _code: &str) -> bool {
1009        true
1010    }
1011
1012    fn suggest_code(_de: &str, _code: &str) -> Option<&'static str> {
1013        None
1014    }
1015
1016    fn expected_components(_tag: &str, _idx: usize) -> Option<u8> {
1017        None
1018    }
1019
1020    #[test]
1021    fn mandatory_composite_present_when_any_component_non_empty() {
1022        let input = b"TST+:ABC'";
1023        let segments: Vec<_> = crate::from_bytes(input)
1024            .collect::<Result<Vec<_>, _>>()
1025            .expect("parse should succeed");
1026
1027        let validator = DirectoryValidator::new(
1028            "TEST",
1029            segment_lookup,
1030            code_valid,
1031            suggest_code,
1032            expected_components,
1033            None,
1034        );
1035
1036        let mut report = ValidationReport::default();
1037        validator.validate_batch(
1038            &segments,
1039            &mut report,
1040            &crate::validator::ValidationRuleContext::empty(),
1041        );
1042        assert!(!report.has_errors());
1043    }
1044
1045    // ── effective_component_count (ISO 9735-1 §3.3 trailing-empty-component trim) ──
1046
1047    fn parse_single(input: &[u8]) -> crate::OwnedSegment {
1048        crate::from_reader_collect(std::io::Cursor::new(input))
1049            .expect("parse should succeed")
1050            .into_iter()
1051            .next()
1052            .expect("at least one segment")
1053    }
1054
1055    #[test]
1056    fn trailing_empty_component_stripped_from_dtm() {
1057        // DTM+137:20200101: has three components in element 0; the third is empty.
1058        // ISO 9735-1 §3.3 says trailing empty components may be omitted,
1059        // so effective count should be 2.
1060        let owned = parse_single(b"DTM+137:20200101:'");
1061        let seg = owned.as_borrowed();
1062        let count = DirectoryValidator::effective_component_count(&seg, 0);
1063        assert_eq!(
1064            count,
1065            Some(2),
1066            "trailing empty component should be stripped"
1067        );
1068    }
1069
1070    #[test]
1071    fn all_empty_components_result_in_zero() {
1072        // NAD+MS++: → element 2 is ":" with two empty components → effective=0
1073        let owned = parse_single(b"NAD+MS++:'");
1074        let seg = owned.as_borrowed();
1075        let count = DirectoryValidator::effective_component_count(&seg, 2);
1076        assert_eq!(
1077            count,
1078            Some(0),
1079            "all-empty composite should have effective count 0"
1080        );
1081    }
1082
1083    #[test]
1084    fn non_empty_component_not_stripped() {
1085        // DTM+137:20200101:102 — all three components are non-empty
1086        let owned = parse_single(b"DTM+137:20200101:102'");
1087        let seg = owned.as_borrowed();
1088        let count = DirectoryValidator::effective_component_count(&seg, 0);
1089        assert_eq!(
1090            count,
1091            Some(3),
1092            "no components should be stripped when all non-empty"
1093        );
1094    }
1095
1096    #[test]
1097    fn with_code_list_rules_overrides_base() {
1098        // Override code-list rules to require element 0 of TST to be a specific code.
1099        fn custom_rules(tag: &str) -> &'static [(usize, usize, &'static str)] {
1100            match tag {
1101                "TST" => &[(0, 0, "CUSTOM_DE")],
1102                _ => &[],
1103            }
1104        }
1105        fn custom_code_valid(_de: &str, code: &str) -> bool {
1106            code == "VALID"
1107        }
1108        fn no_suggestion(_de: &str, _code: &str) -> Option<&'static str> {
1109            None
1110        }
1111
1112        let input = b"TST+INVALID'";
1113        let segments: Vec<_> = crate::from_bytes(input)
1114            .collect::<Result<Vec<_>, _>>()
1115            .expect("parse should succeed");
1116
1117        let validator = DirectoryValidator::new(
1118            "TEST",
1119            segment_lookup,
1120            custom_code_valid,
1121            no_suggestion,
1122            expected_components,
1123            None,
1124        )
1125        .with_code_list_rules(custom_rules);
1126
1127        let mut report = ValidationReport::default();
1128        validator.validate_batch(
1129            &segments,
1130            &mut report,
1131            &crate::validator::ValidationRuleContext::empty(),
1132        );
1133        assert!(
1134            report.has_warnings(),
1135            "INVALID is not in the custom code list so validation must warn"
1136        );
1137    }
1138}