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