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