Skip to main content

edifact_rs/
report.rs

1//! Validation report types: [`ValidationSeverity`], [`ValidationIssue`], [`ValidationReport`].
2//!
3//! These types are also re-exported from the crate root.
4
5use std::borrow::Cow;
6use std::sync::Arc;
7
8use crate::model::Span;
9
10// ── ValidationSeverity ────────────────────────────────────────────────────────
11
12/// Priority level for a validation error or warning.
13///
14/// Marked `#[non_exhaustive]` so that adding new severity levels in future
15/// releases is not a breaking change for downstream match arms.
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub enum ValidationSeverity {
20    /// Structural parse failure; processing cannot continue.
21    Critical,
22    /// Structural validation failed; message is invalid.
23    Error,
24    /// Data validation warning (e.g., code-list mismatch); message may be usable.
25    Warning,
26    /// Informational note; message is valid but noteworthy.
27    Info,
28}
29
30impl ValidationSeverity {
31    /// Return a lowercase ASCII string for this severity level.
32    ///
33    /// Stable for the four known variants.  Because the enum is
34    /// `#[non_exhaustive]`, new variants added in future releases are
35    /// handled by a catch-all arm that returns `"unknown"` so that
36    /// existing code keeps compiling and serialising gracefully.
37    #[must_use]
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Critical => "critical",
41            Self::Error => "error",
42            Self::Warning => "warning",
43            Self::Info => "info",
44            #[allow(unreachable_patterns)]
45            _ => "unknown",
46        }
47    }
48
49    /// Return a numeric priority for this severity level.
50    ///
51    /// Higher values indicate higher severity: `Critical = 3`, `Error = 2`,
52    /// `Warning = 1`, `Info = 0`.
53    #[must_use]
54    pub fn numeric_level(self) -> u8 {
55        match self {
56            Self::Info => 0,
57            Self::Warning => 1,
58            Self::Error => 2,
59            Self::Critical => 3,
60        }
61    }
62}
63
64impl std::fmt::Display for ValidationSeverity {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.as_str())
67    }
68}
69
70// ── ValidationIssue ───────────────────────────────────────────────────────────
71
72/// A structured validation issue.
73///
74/// Marked `#[non_exhaustive]` so that new diagnostic fields (e.g. `segment_group`)
75/// can be added in future releases without breaking downstream code that constructs
76/// issues via struct literals.  Always use [`ValidationIssue::new`] + builder
77/// methods (`with_*`) rather than constructing directly.
78///
79/// ## Rule ID prefix convention
80///
81/// The `rule_id` field doubles as a lightweight metadata carrier when no full
82/// `context` map is needed.  Use a namespaced, structured prefix so consumers can
83/// extract domain-specific information without parsing the human-readable message:
84///
85/// ```text
86/// "<PACK>-<SCOPE>-<TAG>-<STATUS>"
87///  ^^^^^^^^                        — identifies the pack / profile (e.g. "AHB-13001")
88///              ^^^^^^^             — identifies the rule scope (e.g. "SG5", "BGM")
89///                      ^^^         — identifies the affected segment
90///                          ^^^^^^^  — M/C/... status or short discriminator
91/// ```
92///
93/// Example: `"AHB-13001-BGM-M"` encodes the AHB process identifier (`13001`),
94/// the affected segment (`BGM`), and the mandatory status (`M`).  Downstream code
95/// can extract the PID with a simple string split:
96///
97/// ```rust
98/// # let rule_id = "AHB-13001-BGM-M";
99/// if let Some(pid) = rule_id.strip_prefix("AHB-").and_then(|s| s.splitn(2, '-').next()) {
100///     println!("process identifier: {pid}"); // "13001"
101/// }
102/// ```
103///
104/// For truly arbitrary domain metadata, use the [`context`](Self::context) map and
105/// `with_context_entry`.
106#[derive(Debug, Clone, PartialEq)]
107#[non_exhaustive]
108#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
109pub struct ValidationIssue {
110    /// Stable error code, if known.
111    ///
112    /// Library-produced codes are `&'static str` constants (`"E014"`, …), so the
113    /// [`Cow`] borrows and costs nothing to construct.  Callers may also supply
114    /// an owned code from an external rule catalogue.  Either way the field
115    /// **round-trips through serialization**: a report persisted to an audit
116    /// store and read back can still be filtered and routed on `error_code`.
117    #[cfg_attr(
118        feature = "serde",
119        serde(default, skip_serializing_if = "Option::is_none")
120    )]
121    pub error_code: Option<Cow<'static, str>>,
122    /// The severity of this issue.
123    pub severity: ValidationSeverity,
124    /// The error or warning message.
125    pub message: String,
126    /// Half-open byte range of the relevant segment, element, or component.
127    ///
128    /// The single source of byte position for an issue — read `span.start` when
129    /// only the start offset is needed.  Set it with [`with_span`](Self::with_span)
130    /// from a [`Span`] carried by a parsed [`crate::Segment`], `Element`, or
131    /// component, which is what gives `miette` diagnostics and Language Server
132    /// Protocol `Range` values their precision.
133    ///
134    /// Issues derived from a purely lexical fault (a dangling release character,
135    /// unexpected end of input) carry a zero-width span at the offending byte:
136    /// there is no meaningful end position for a point diagnostic.
137    pub span: Option<Span>,
138    /// Segment tag involved (if known).
139    pub segment_tag: Option<String>,
140    /// Profile/MIG rule identifier, if applicable.
141    ///
142    /// By convention, rule IDs are namespaced hierarchically so that downstream
143    /// code can extract domain-specific metadata (pack name, process ID, rule scope)
144    /// from the string.  See the [`ValidationIssue`] type-level docs for the
145    /// recommended naming convention.
146    pub rule_id: Option<String>,
147    /// Element index (0-based), if known.
148    ///
149    /// `u8` is sufficient: EDIFACT segments have at most 99 data elements per
150    /// the UN/EDIFACT standard, so an index fits comfortably in one byte.
151    pub element_index: Option<u8>,
152    /// Component index (0-based), if known.
153    ///
154    /// `u8` is sufficient: composite data elements have at most 99 components
155    /// per the UN/EDIFACT standard.
156    pub component_index: Option<u8>,
157    /// Zero-based occurrence index among segments with the same tag in the message.
158    ///
159    /// When multiple segments share the same tag (e.g. repeated `DTM` lines),
160    /// this field indicates which occurrence (0 = first) was the source of
161    /// this issue.  `None` when occurrence tracking is not available for this rule.
162    pub segment_occurrence: Option<u16>,
163    /// Message reference (`UNH` element 0, DE 0062) that this issue belongs to.
164    ///
165    /// Populated automatically when the context was built with
166    /// `ValidationContextBuilder::with_message_ref`.  Useful in batch processing
167    /// where many messages are validated and issues from different messages must
168    /// be correlated back to the originating `UNH`/`UNT` envelope.
169    pub message_ref: Option<String>,
170    /// Suggested remediation (if available).
171    pub suggestion: Option<String>,
172    /// Segment group (e.g. `"SG6"`) in which the issue occurred, if known.
173    ///
174    /// Populated by group-aware rule functions when they evaluate sub-slices of a
175    /// [`crate::group::SegmentGroupIndexed`] tree.  `None` for flat-segment rules
176    /// that do not have group context.
177    pub segment_group: Option<Arc<str>>,
178    /// Arbitrary domain-specific key-value metadata attached to this issue.
179    ///
180    /// Use this for information that does not fit into the structured fields above
181    /// — for example the PID a downstream MIG crate is validating against, a
182    /// trading-partner identifier, or a document UUID:
183    ///
184    /// ```rust
185    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
186    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
187    ///     .with_rule_id("AHB-13001-BGM-M")
188    ///     .with_context_entry("pid", "13001")
189    ///     .with_context_entry("partner", "9900123456789");
190    /// assert_eq!(issue.context_get("pid"), Some("13001"));
191    /// ```
192    ///
193    /// The vec is empty by default and is never populated by the built-in rules;
194    /// it is reserved exclusively for caller-supplied metadata.
195    ///
196    /// Entries are stored in insertion order; duplicate keys are allowed and
197    /// [`context_get`](Self::context_get) returns the first match.
198    /// [`with_context_entry`](Self::with_context_entry) uses upsert semantics
199    /// (updates an existing key in place rather than duplicating it).
200    #[cfg_attr(
201        feature = "serde",
202        serde(default, skip_serializing_if = "Vec::is_empty")
203    )]
204    pub context: Vec<(String, String)>,
205}
206
207impl ValidationIssue {
208    /// Create a new validation issue.
209    pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
210        Self {
211            error_code: None,
212            severity,
213            message: message.into(),
214            span: None,
215            segment_tag: None,
216            rule_id: None,
217            element_index: None,
218            component_index: None,
219            segment_occurrence: None,
220            message_ref: None,
221            suggestion: None,
222            segment_group: None,
223            context: Vec::new(),
224        }
225    }
226
227    /// Set stable error code metadata.
228    ///
229    /// Accepts both a `&'static str` library constant (no allocation) and an
230    /// owned `String` from an external rule catalogue.
231    ///
232    /// # Example
233    ///
234    /// ```rust
235    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
236    /// let from_const = ValidationIssue::new(ValidationSeverity::Error, "bad code")
237    ///     .with_error_code("E014");
238    /// let from_owned = ValidationIssue::new(ValidationSeverity::Error, "bad code")
239    ///     .with_error_code(format!("AHB-{}", 13001));
240    /// assert_eq!(from_const.error_code(), Some("E014"));
241    /// assert_eq!(from_owned.error_code(), Some("AHB-13001"));
242    /// ```
243    pub fn with_error_code(mut self, code: impl Into<Cow<'static, str>>) -> Self {
244        self.error_code = Some(code.into());
245        self
246    }
247
248    /// Set the byte-range span for this issue.
249    ///
250    /// [`span`](Self::span) is the only positional field on a
251    /// `ValidationIssue`; read `issue.span.map(|s| s.start)` when you need the
252    /// start offset alone.  Prefer the narrowest span you have — a component
253    /// span over an element span over a segment span — since that is what
254    /// diagnostics underline.
255    ///
256    /// # Example
257    ///
258    /// ```rust
259    /// # use edifact_rs::{ValidationIssue, ValidationSeverity, Span};
260    /// let span = Span::new(42, 57);
261    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code missing")
262    ///     .with_span(span);
263    /// assert_eq!(issue.span, Some(span));
264    /// assert_eq!(issue.span.map(|s| s.start), Some(42));
265    /// ```
266    pub fn with_span(mut self, span: Span) -> Self {
267        self.span = Some(span);
268        self
269    }
270
271    /// Set the segment tag for this issue.
272    pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
273        self.segment_tag = Some(tag.into());
274        self
275    }
276
277    /// Set the profile/MIG rule identifier for this issue.
278    pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
279        self.rule_id = Some(rule_id.into());
280        self
281    }
282
283    /// Set the element index (0-based) for this issue.
284    pub fn with_element_index(mut self, element_index: u8) -> Self {
285        self.element_index = Some(element_index);
286        self
287    }
288
289    /// Set the component index (0-based) for this issue.
290    pub fn with_component_index(mut self, component_index: u8) -> Self {
291        self.component_index = Some(component_index);
292        self
293    }
294
295    /// Set a suggestion for resolving this issue.
296    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
297        self.suggestion = Some(suggestion.into());
298        self
299    }
300
301    /// Set the zero-based occurrence index for this issue.
302    ///
303    /// Use this when the same segment tag appears multiple times in a message
304    /// and you want to identify which occurrence is affected.
305    pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
306        self.segment_occurrence = Some(occurrence);
307        self
308    }
309
310    /// Set the message reference (`UNH` element 0) for this issue.
311    ///
312    /// Use this to correlate an issue back to a specific message in a
313    /// multi-message interchange.
314    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
315        self.message_ref = Some(message_ref.into());
316        self
317    }
318
319    /// Set the segment group (e.g. `"SG6"`) in which this issue occurred.
320    ///
321    /// Use this from group-aware rule functions that evaluate a sub-slice of a
322    /// [`crate::group::SegmentGroupIndexed`] tree so that consumers can identify
323    /// the exact group occurrence without re-reading the raw message.
324    pub fn with_segment_group(mut self, group: impl Into<Arc<str>>) -> Self {
325        self.segment_group = Some(group.into());
326        self
327    }
328
329    /// Insert a single key-value entry into the domain-specific [`context`](Self::context) map.
330    ///
331    /// Calling this multiple times accumulates entries; duplicate keys overwrite
332    /// the previous value.
333    ///
334    /// # Example
335    ///
336    /// ```rust
337    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
338    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
339    ///     .with_rule_id("AHB-13001-BGM-M")
340    ///     .with_context_entry("pid", "13001")
341    ///     .with_context_entry("partner", "9900123456789");
342    ///
343    /// assert_eq!(issue.context_get("pid"), Some("13001"));
344    /// assert_eq!(issue.context_get("partner"), Some("9900123456789"));
345    /// ```
346    pub fn with_context_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
347        let key = key.into();
348        let value = value.into();
349        if let Some(entry) = self.context.iter_mut().find(|(k, _)| k == &key) {
350            entry.1 = value;
351        } else {
352            self.context.push((key, value));
353        }
354        self
355    }
356
357    /// Extend the domain-specific [`context`](Self::context) map from an iterator of
358    /// `(key, value)` pairs.
359    ///
360    /// # Example
361    ///
362    /// ```rust
363    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
364    /// let meta = [("pid", "13001"), ("partner", "9900123456789")];
365    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "test")
366    ///     .with_context_entries(meta);
367    ///
368    /// assert_eq!(issue.context_get("pid"), Some("13001"));
369    /// ```
370    pub fn with_context_entries<K, V, I>(mut self, entries: I) -> Self
371    where
372        K: Into<String>,
373        V: Into<String>,
374        I: IntoIterator<Item = (K, V)>,
375    {
376        for (k, v) in entries {
377            let k = k.into();
378            let v = v.into();
379            if let Some(entry) = self.context.iter_mut().find(|(key, _)| key == &k) {
380                entry.1 = v;
381            } else {
382                self.context.push((k, v));
383            }
384        }
385        self
386    }
387
388    /// Look up a value in the domain-specific [`context`](Self::context) map.
389    #[must_use]
390    #[inline]
391    pub fn context_get(&self, key: &str) -> Option<&str> {
392        self.context
393            .iter()
394            .find(|(k, _)| k == key)
395            .map(|(_, v)| v.as_str())
396    }
397
398    /// Short label for the severity level, suitable for display.
399    #[must_use]
400    pub fn severity_label(&self) -> &'static str {
401        match self.severity {
402            ValidationSeverity::Critical => "CRITICAL",
403            ValidationSeverity::Error => "ERROR",
404            ValidationSeverity::Warning => "WARNING",
405            ValidationSeverity::Info => "INFO",
406            #[allow(unreachable_patterns)]
407            _ => "UNKNOWN",
408        }
409    }
410
411    // ── Getters ───────────────────────────────────────────────────────────────
412
413    /// Stable error code, if available.
414    #[must_use]
415    #[inline]
416    pub fn error_code(&self) -> Option<&str> {
417        self.error_code.as_deref()
418    }
419
420    /// Half-open byte range of the relevant source region, if available.
421    #[must_use]
422    #[inline]
423    pub fn span(&self) -> Option<Span> {
424        self.span
425    }
426
427    /// Start byte offset of [`span`](Self::span), if available.
428    ///
429    /// Convenience for consumers that only need a position, not a range.
430    #[must_use]
431    #[inline]
432    pub fn start_offset(&self) -> Option<usize> {
433        self.span.map(|s| s.start)
434    }
435
436    /// Segment tag involved in this issue, if known.
437    #[must_use]
438    #[inline]
439    pub fn segment_tag(&self) -> Option<&str> {
440        self.segment_tag.as_deref()
441    }
442
443    /// Profile/MIG rule identifier, if applicable.
444    #[must_use]
445    #[inline]
446    pub fn rule_id(&self) -> Option<&str> {
447        self.rule_id.as_deref()
448    }
449
450    /// Zero-based element index, if known.
451    #[must_use]
452    #[inline]
453    pub fn element_index(&self) -> Option<u8> {
454        self.element_index
455    }
456
457    /// Zero-based component index, if known.
458    #[must_use]
459    #[inline]
460    pub fn component_index(&self) -> Option<u8> {
461        self.component_index
462    }
463
464    /// Zero-based occurrence index among same-tag segments, if known.
465    #[must_use]
466    #[inline]
467    pub fn segment_occurrence(&self) -> Option<u16> {
468        self.segment_occurrence
469    }
470
471    /// Message reference (`UNH` element 0), if set.
472    #[must_use]
473    #[inline]
474    pub fn message_ref(&self) -> Option<&str> {
475        self.message_ref.as_deref()
476    }
477
478    /// Suggested remediation, if available.
479    #[must_use]
480    #[inline]
481    pub fn suggestion(&self) -> Option<&str> {
482        self.suggestion.as_deref()
483    }
484
485    /// Segment group (e.g. `"SG6"`) in which the issue occurred, if known.
486    #[must_use]
487    #[inline]
488    pub fn segment_group(&self) -> Option<&str> {
489        self.segment_group.as_deref()
490    }
491}
492
493impl std::fmt::Display for ValidationIssue {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        write!(f, "[{}] {}", self.severity_label(), self.message)
496    }
497}
498
499impl std::error::Error for ValidationIssue {}
500
501// ── ValidationReport ─────────────────────────────────────────────────────────
502
503/// A collection of validation results: errors, warnings, and informational notes.
504///
505/// Enables batch validation where all issues are collected instead of failing on
506/// the first error.  Produced by [`crate::validator::ValidationContext`] methods
507/// such as `validate_lenient` and `validate_lenient_grouped`.
508///
509/// # Building reports manually
510///
511/// Use [`ValidationReport::from_issues`] to construct a report from pre-built issue
512/// vectors, or the `add_*` methods to push individual issues:
513///
514/// ```rust
515/// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
516///
517/// let mut report = ValidationReport::default();
518/// report.add_warning(
519///     ValidationIssue::new(ValidationSeverity::Warning, "optional field missing")
520///         .with_segment("DTM"),
521/// );
522/// assert!(report.is_valid()); // warnings don't fail validation
523/// ```
524#[derive(Debug, Clone, Default, PartialEq)]
525#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
526pub struct ValidationReport {
527    /// Critical and error-level issues.
528    pub(crate) errors: Vec<ValidationIssue>,
529    /// Warning-level issues.
530    pub(crate) warnings: Vec<ValidationIssue>,
531    /// Informational notes.
532    pub(crate) infos: Vec<ValidationIssue>,
533}
534
535impl ValidationReport {
536    /// Construct a report directly from pre-categorized issue vectors.
537    ///
538    /// This is the primary escape hatch for code that needs to inject advisory
539    /// issues into a report outside the normal validation pipeline — for example,
540    /// a middleware layer that wants to attach AHB-layer skip notices without
541    /// registering a synthetic `ProfileRulePack` rule.
542    ///
543    /// # Example
544    ///
545    /// ```rust,ignore
546    /// let mut report = ctx.validate_lenient(&segments);
547    /// let advisory = ValidationReport::from_issues(
548    ///     vec![],
549    ///     vec![ValidationIssue::new(ValidationSeverity::Warning, "AHB layer skipped")
550    ///         .with_rule_id("AHB-SKIP-001")],
551    ///     vec![],
552    /// );
553    /// report.merge(advisory);
554    /// ```
555    pub fn from_issues(
556        errors: Vec<ValidationIssue>,
557        warnings: Vec<ValidationIssue>,
558        infos: Vec<ValidationIssue>,
559    ) -> Self {
560        Self {
561            errors,
562            warnings,
563            infos,
564        }
565    }
566
567    /// Returns all error-level [`ValidationIssue`]s in this report.
568    pub fn errors(&self) -> &[ValidationIssue] {
569        &self.errors
570    }
571
572    /// Returns all error-level [`ValidationIssue`]s mutably.
573    pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
574        &mut self.errors
575    }
576
577    /// Returns all warning-level [`ValidationIssue`]s in this report.
578    pub fn warnings(&self) -> &[ValidationIssue] {
579        &self.warnings
580    }
581
582    /// Returns all warning-level [`ValidationIssue`]s mutably.
583    pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
584        &mut self.warnings
585    }
586
587    /// Returns all informational [`ValidationIssue`]s in this report.
588    pub fn infos(&self) -> &[ValidationIssue] {
589        &self.infos
590    }
591
592    /// Returns all informational [`ValidationIssue`]s mutably.
593    pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
594        &mut self.infos
595    }
596
597    /// Add an error to the report.
598    pub fn add_error(&mut self, issue: ValidationIssue) {
599        self.errors.push(issue);
600    }
601
602    /// Add a warning to the report.
603    pub fn add_warning(&mut self, issue: ValidationIssue) {
604        self.warnings.push(issue);
605    }
606
607    /// Add an info message to the report.
608    pub fn add_info(&mut self, issue: ValidationIssue) {
609        self.infos.push(issue);
610    }
611
612    /// Check if the report has any errors (Critical or Error severity).
613    pub fn has_errors(&self) -> bool {
614        !self.errors().is_empty()
615    }
616
617    /// Check if the report contains at least one `Critical`-severity issue.
618    ///
619    /// O(1) — backed by an incrementally maintained counter.
620    pub fn has_critical_errors(&self) -> bool {
621        self.errors
622            .iter()
623            .any(|i| i.severity == ValidationSeverity::Critical)
624    }
625
626    /// Check if the report has any warnings.
627    pub fn has_warnings(&self) -> bool {
628        !self.warnings().is_empty()
629    }
630
631    /// Get the total count of all issues.
632    pub fn total_issues(&self) -> usize {
633        self.errors().len() + self.warnings().len() + self.infos().len()
634    }
635
636    /// Check if the validation passed (no errors, but may have warnings).
637    pub fn is_valid(&self) -> bool {
638        self.errors().is_empty()
639    }
640
641    /// Convert to a `Result`.
642    ///
643    /// Returns `Ok(self)` when there are no errors.  Returns `Err(self)` when
644    /// there is at least one error-level issue, **preserving warnings and infos**
645    /// in the `Err` variant so callers can inspect the full report.
646    pub fn result(self) -> Result<Self, Self> {
647        if self.is_valid() { Ok(self) } else { Err(self) }
648    }
649
650    /// Iterate over all issues in severity buckets: errors, warnings, then infos.
651    pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
652        self.errors()
653            .iter()
654            .chain(self.warnings().iter())
655            .chain(self.infos().iter())
656    }
657
658    /// Return `true` if the report contains any issues (errors, warnings, or infos).
659    pub fn has_any_issues(&self) -> bool {
660        !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
661    }
662
663    /// Drain all issues from `other` into `self`.
664    ///
665    /// Issues are appended in severity order: errors, warnings, infos.
666    /// `other` is left empty after this call.
667    pub fn merge(&mut self, mut other: ValidationReport) {
668        self.errors.append(&mut other.errors);
669        self.warnings.append(&mut other.warnings);
670        self.infos.append(&mut other.infos);
671    }
672
673    /// Extend `self` with cloned issues from `other` (borrowing).
674    ///
675    /// Unlike [`merge`](Self::merge), this method borrows `other` so the caller
676    /// retains ownership.  Issues are cloned and appended to the respective
677    /// severity buckets.  Use `merge` when you can afford to consume `other`.
678    ///
679    /// # Example
680    ///
681    /// ```rust
682    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
683    ///
684    /// let mut combined = ValidationReport::default();
685    /// let report = ValidationReport::from_issues(
686    ///     vec![ValidationIssue::new(ValidationSeverity::Error, "bad segment")],
687    ///     vec![],
688    ///     vec![],
689    /// );
690    /// combined.extend_from(&report);
691    /// assert_eq!(combined.errors().len(), 1);
692    /// // `report` is still accessible
693    /// assert_eq!(report.errors().len(), 1);
694    /// ```
695    pub fn extend_from(&mut self, other: &ValidationReport) {
696        for issue in &other.errors {
697            self.add_error(issue.clone());
698        }
699        for issue in &other.warnings {
700            self.add_warning(issue.clone());
701        }
702        for issue in &other.infos {
703            self.add_info(issue.clone());
704        }
705    }
706
707    /// Iterate over all issues matching an exact profile/MIG rule identifier.
708    ///
709    /// Searches errors, warnings, and infos in that order.  Returns a lazy
710    /// iterator; collect into `Vec` if you need random access.
711    pub fn issues_for_rule_id<'a>(
712        &'a self,
713        rule_id: &'a str,
714    ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
715        self.iter_issues()
716            .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
717    }
718
719    fn filter_report<F>(&self, pred: F) -> Self
720    where
721        F: Fn(&ValidationIssue) -> bool,
722    {
723        let errors: Vec<ValidationIssue> =
724            self.errors().iter().filter(|i| pred(i)).cloned().collect();
725        Self {
726            errors,
727            warnings: self
728                .warnings()
729                .iter()
730                .filter(|i| pred(i))
731                .cloned()
732                .collect(),
733            infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
734        }
735    }
736
737    /// Return a cloned report containing only issues with an exact rule identifier.
738    pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
739        self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
740    }
741
742    /// Return a cloned report containing only issues whose rule identifier starts with `prefix`.
743    pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
744        self.filter_report(|issue| {
745            issue
746                .rule_id
747                .as_deref()
748                .is_some_and(|id| id.starts_with(prefix))
749        })
750    }
751
752    /// Return a cloned report containing only issues that reference `segment_tag`.
753    ///
754    /// Issues whose `segment_tag` field does not match are dropped; the severity
755    /// buckets (errors / warnings / infos) are preserved.
756    ///
757    /// # Example
758    ///
759    /// ```rust
760    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
761    ///
762    /// let mut report = ValidationReport::default();
763    /// report.add_error(
764    ///     ValidationIssue::new(ValidationSeverity::Error, "BGM missing")
765    ///         .with_segment("BGM"),
766    /// );
767    /// report.add_error(
768    ///     ValidationIssue::new(ValidationSeverity::Error, "NAD missing")
769    ///         .with_segment("NAD"),
770    /// );
771    /// let bgm_issues = report.for_segment("BGM");
772    /// assert_eq!(bgm_issues.errors().len(), 1);
773    /// assert_eq!(bgm_issues.errors()[0].segment_tag.as_deref(), Some("BGM"));
774    /// ```
775    pub fn for_segment(&self, segment_tag: &str) -> Self {
776        self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
777    }
778
779    /// Return a deterministic, stable text representation for snapshots and logs.
780    pub fn render_deterministic(&self) -> String {
781        fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
782            let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
783            refs.sort_by(|left, right| {
784                left.start_offset()
785                    .unwrap_or(usize::MAX)
786                    .cmp(&right.start_offset().unwrap_or(usize::MAX))
787                    .then_with(|| {
788                        left.segment_tag
789                            .as_deref()
790                            .unwrap_or("")
791                            .cmp(right.segment_tag.as_deref().unwrap_or(""))
792                    })
793                    .then_with(|| {
794                        left.rule_id
795                            .as_deref()
796                            .unwrap_or("")
797                            .cmp(right.rule_id.as_deref().unwrap_or(""))
798                    })
799                    .then_with(|| {
800                        left.element_index
801                            .unwrap_or(u8::MAX)
802                            .cmp(&right.element_index.unwrap_or(u8::MAX))
803                    })
804                    .then_with(|| {
805                        left.component_index
806                            .unwrap_or(u8::MAX)
807                            .cmp(&right.component_index.unwrap_or(u8::MAX))
808                    })
809                    .then_with(|| {
810                        left.error_code()
811                            .unwrap_or("")
812                            .cmp(right.error_code().unwrap_or(""))
813                    })
814                    .then_with(|| left.message.cmp(&right.message))
815            });
816            refs
817        }
818
819        fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
820            use std::fmt::Write as _;
821            out.push_str("    - ");
822            out.push_str(&issue.message);
823            if let Some(code) = issue.error_code() {
824                out.push_str(" [");
825                out.push_str(code);
826                out.push(']');
827            }
828            if let Some(seg) = &issue.segment_tag {
829                out.push_str(" [segment=");
830                out.push_str(seg);
831                out.push(']');
832            }
833            if let Some(rule_id) = &issue.rule_id {
834                out.push_str(" [rule=");
835                out.push_str(rule_id);
836                out.push(']');
837            }
838            if let Some(element_index) = issue.element_index {
839                write!(out, " [element={element_index}]").ok();
840            }
841            if let Some(component_index) = issue.component_index {
842                write!(out, " [component={component_index}]").ok();
843            }
844            if let Some(span) = issue.span {
845                write!(out, " [span={span}]").ok();
846            }
847            if let Some(suggestion) = &issue.suggestion {
848                out.push_str(" [hint=");
849                out.push_str(suggestion);
850                out.push(']');
851            }
852        }
853
854        use std::fmt::Write as _;
855        let mut out = String::from("Validation Report:");
856        let errors = sorted_refs(self.errors());
857        let warnings = sorted_refs(self.warnings());
858        let infos = sorted_refs(self.infos());
859
860        if !errors.is_empty() {
861            write!(out, "\n  Errors ({})", errors.len()).ok();
862            for issue in &errors {
863                out.push('\n');
864                render_issue_line(&mut out, issue);
865            }
866        }
867        if !warnings.is_empty() {
868            write!(out, "\n  Warnings ({})", warnings.len()).ok();
869            for issue in &warnings {
870                out.push('\n');
871                render_issue_line(&mut out, issue);
872            }
873        }
874        if !infos.is_empty() {
875            write!(out, "\n  Info ({})", infos.len()).ok();
876            for issue in &infos {
877                out.push('\n');
878                render_issue_line(&mut out, issue);
879            }
880        }
881
882        out
883    }
884}
885
886#[cfg(feature = "diagnostics")]
887impl miette::Diagnostic for ValidationReport {
888    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
889        Some(Box::new("VALIDATION"))
890    }
891
892    fn severity(&self) -> Option<miette::Severity> {
893        if self.has_errors() {
894            Some(miette::Severity::Error)
895        } else if self.has_warnings() {
896            Some(miette::Severity::Warning)
897        } else {
898            Some(miette::Severity::Advice)
899        }
900    }
901
902    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
903        let msg = format!(
904            "Validation found {} error(s), {} warning(s), {} info(s)",
905            self.errors().len(),
906            self.warnings().len(),
907            self.infos().len()
908        );
909        Some(Box::new(msg))
910    }
911}
912
913impl std::fmt::Display for ValidationReport {
914    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
915        write!(f, "{}", self.render_deterministic())
916    }
917}
918
919impl std::error::Error for ValidationReport {}
920
921impl Extend<ValidationIssue> for ValidationReport {
922    /// Push each issue into the appropriate severity bucket.
923    ///
924    /// This enables ergonomic batch collection:
925    ///
926    /// ```rust
927    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
928    ///
929    /// let issues = vec![
930    ///     ValidationIssue::new(ValidationSeverity::Error, "bad segment"),
931    ///     ValidationIssue::new(ValidationSeverity::Warning, "optional field missing"),
932    ///     ValidationIssue::new(ValidationSeverity::Info, "advisory note"),
933    /// ];
934    /// let mut report = ValidationReport::default();
935    /// report.extend(issues);
936    /// assert_eq!(report.errors().len(), 1);
937    /// assert_eq!(report.warnings().len(), 1);
938    /// assert_eq!(report.infos().len(), 1);
939    /// ```
940    fn extend<I: IntoIterator<Item = ValidationIssue>>(&mut self, iter: I) {
941        for issue in iter {
942            match issue.severity {
943                ValidationSeverity::Critical | ValidationSeverity::Error => {
944                    self.add_error(issue);
945                }
946                ValidationSeverity::Warning => {
947                    self.add_warning(issue);
948                }
949                _ => {
950                    self.add_info(issue);
951                }
952            }
953        }
954    }
955}
956
957impl FromIterator<ValidationIssue> for ValidationReport {
958    fn from_iter<I: IntoIterator<Item = ValidationIssue>>(iter: I) -> Self {
959        let mut report = ValidationReport::default();
960        report.extend(iter);
961        report
962    }
963}
964
965// ── Tests ─────────────────────────────────────────────────────────────────────
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    #[test]
972    fn report_collects_errors_and_warnings() {
973        let mut report = ValidationReport::default();
974        report.add_error(
975            ValidationIssue::new(ValidationSeverity::Error, "Test error")
976                .with_segment("BGM")
977                .with_span(Span::new(42, 57)),
978        );
979        report.add_warning(ValidationIssue::new(
980            ValidationSeverity::Warning,
981            "Test warning",
982        ));
983
984        assert!(report.has_errors());
985        assert!(report.has_warnings());
986        assert_eq!(report.total_issues(), 2);
987        assert!(!report.is_valid());
988    }
989
990    #[test]
991    fn report_result_conversion() {
992        let mut report = ValidationReport::default();
993        report.add_error(ValidationIssue::new(
994            ValidationSeverity::Error,
995            "Critical issue",
996        ));
997        assert!(report.result().is_err());
998    }
999
1000    #[test]
1001    fn report_valid_with_only_warnings() {
1002        let mut report = ValidationReport::default();
1003        report.add_warning(ValidationIssue::new(
1004            ValidationSeverity::Warning,
1005            "Just a warning",
1006        ));
1007        assert!(report.is_valid());
1008        assert!(report.result().is_ok());
1009    }
1010
1011    #[test]
1012    fn issue_builder_chain() {
1013        let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1014            .with_error_code("E013")
1015            .with_span(Span::new(100, 118))
1016            .with_segment("NAD")
1017            .with_rule_id("DEMO-P001")
1018            .with_element_index(1)
1019            .with_component_index(2)
1020            .with_suggestion("Check element count");
1021
1022        assert_eq!(issue.error_code(), Some("E013"));
1023        assert_eq!(issue.message, "test message");
1024        assert_eq!(issue.span, Some(Span::new(100, 118)));
1025        assert_eq!(issue.start_offset(), Some(100));
1026        assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1027        assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1028        assert_eq!(issue.element_index, Some(1));
1029        assert_eq!(issue.component_index, Some(2));
1030        assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1031    }
1032
1033    #[test]
1034    fn report_display_format() {
1035        let mut report = ValidationReport::default();
1036        report.add_error(
1037            ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1038                .with_error_code("E011")
1039                .with_span(Span::new(8, 20)),
1040        );
1041        report.add_warning(ValidationIssue::new(
1042            ValidationSeverity::Warning,
1043            "Warning 1",
1044        ));
1045        report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1046
1047        let display_str = format!("{report}");
1048        assert!(display_str.contains("Errors (1)"));
1049        assert!(display_str.contains("Warnings (1)"));
1050        assert!(display_str.contains("Info (1)"));
1051        assert!(display_str.contains("[E011]"));
1052    }
1053
1054    #[test]
1055    fn render_deterministic_sorts_by_span_start() {
1056        let mut report = ValidationReport::default();
1057        report.add_error(
1058            ValidationIssue::new(ValidationSeverity::Error, "later")
1059                .with_segment("BGM")
1060                .with_span(Span::new(20, 30)),
1061        );
1062        report.add_error(
1063            ValidationIssue::new(ValidationSeverity::Error, "earlier")
1064                .with_segment("UNH")
1065                .with_span(Span::new(1, 19)),
1066        );
1067
1068        let rendered = report.render_deterministic();
1069        let first = rendered.find("earlier").expect("missing first issue");
1070        let second = rendered.find("later").expect("missing second issue");
1071        assert!(first < second, "expected deterministic sort by span start");
1072    }
1073
1074    #[cfg(feature = "serde")]
1075    #[test]
1076    fn error_code_survives_a_serde_round_trip() {
1077        // A persisted report that loses its codes cannot be filtered or routed
1078        // on them after reload — the reason the field is owned on the wire.
1079        let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
1080            .with_error_code("E014")
1081            .with_span(Span::new(9, 22))
1082            .with_rule_id("AHB-13001-BGM-M");
1083        let json = serde_json::to_string(&issue).expect("serialize");
1084        let back: ValidationIssue = serde_json::from_str(&json).expect("deserialize");
1085
1086        assert_eq!(back.error_code(), Some("E014"));
1087        assert_eq!(back.span, Some(Span::new(9, 22)));
1088        assert_eq!(back, issue);
1089    }
1090
1091    #[test]
1092    fn filter_by_rule_id() {
1093        let mut report = ValidationReport::default();
1094        report.add_error(
1095            ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1096                .with_rule_id("ORDERS-P001"),
1097        );
1098        report.add_warning(
1099            ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1100                .with_rule_id("INVOIC-P001"),
1101        );
1102        report.add_info(
1103            ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1104                .with_rule_id("ORDERS-P002"),
1105        );
1106
1107        let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1108        assert_eq!(only_orders_block.errors().len(), 1);
1109        assert!(only_orders_block.warnings().is_empty());
1110        assert!(only_orders_block.infos().is_empty());
1111
1112        let orders_family = report.filter_by_rule_prefix("ORDERS-");
1113        assert_eq!(orders_family.total_issues(), 2);
1114
1115        let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1116        assert_eq!(exact.len(), 1);
1117        assert_eq!(exact[0].message, "invoic policy warning");
1118    }
1119
1120    #[test]
1121    fn context_map_builder() {
1122        let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
1123            .with_context_entry("pid", "13001")
1124            .with_context_entry("partner", "9900123456789");
1125
1126        assert_eq!(issue.context_get("pid"), Some("13001"));
1127        assert_eq!(issue.context_get("partner"), Some("9900123456789"));
1128        assert_eq!(issue.context_get("missing"), None);
1129    }
1130
1131    #[test]
1132    fn context_map_extend() {
1133        let meta = [("pid", "13001"), ("partner", "9900123456789")];
1134        let issue =
1135            ValidationIssue::new(ValidationSeverity::Error, "test").with_context_entries(meta);
1136        assert_eq!(issue.context_get("pid"), Some("13001"));
1137    }
1138
1139    #[test]
1140    fn context_key_overwrite() {
1141        let issue = ValidationIssue::new(ValidationSeverity::Warning, "demo")
1142            .with_context_entry("pid", "old")
1143            .with_context_entry("pid", "new");
1144        assert_eq!(issue.context_get("pid"), Some("new"));
1145    }
1146}