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