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}