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(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
198    pub context: Vec<(String, String)>,
199}
200
201impl ValidationIssue {
202    /// Create a new validation issue.
203    pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
204        Self {
205            error_code: None,
206            severity,
207            message: message.into(),
208            offset: None,
209            span: None,
210            segment_tag: None,
211            rule_id: None,
212            element_index: None,
213            component_index: None,
214            segment_occurrence: None,
215            message_ref: None,
216            suggestion: None,
217            segment_group: None,
218            context: Vec::new(),
219        }
220    }
221
222    /// Set stable error code metadata.
223    pub fn with_error_code(mut self, code: &'static str) -> Self {
224        self.error_code = Some(code);
225        self
226    }
227
228    /// Set the byte offset for this issue.
229    pub fn with_offset(mut self, offset: usize) -> Self {
230        self.offset = Some(offset);
231        self
232    }
233
234    /// Set the full byte-range span for this issue.
235    ///
236    /// Also populates [`offset`](Self::offset) with `span.start` so that
237    /// existing code that only reads `offset` continues to work.
238    ///
239    /// Use this in preference to `with_offset` when you have access to the
240    /// source [`Span`] from a parsed [`crate::Segment`] — the full range
241    /// enables precise source-range highlighting in `miette` diagnostics and
242    /// Language Server Protocol tooling.
243    ///
244    /// # Example
245    ///
246    /// ```rust
247    /// # use edifact_rs::{ValidationIssue, ValidationSeverity, Span};
248    /// let span = Span::new(42, 57);
249    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code missing")
250    ///     .with_span(span);
251    /// assert_eq!(issue.offset, Some(42));
252    /// assert_eq!(issue.span, Some(span));
253    /// ```
254    pub fn with_span(mut self, span: Span) -> Self {
255        self.offset = Some(span.start);
256        self.span = Some(span);
257        self
258    }
259
260    /// Set the segment tag for this issue.
261    pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
262        self.segment_tag = Some(tag.into());
263        self
264    }
265
266    /// Set the profile/MIG rule identifier for this issue.
267    pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
268        self.rule_id = Some(rule_id.into());
269        self
270    }
271
272    /// Set the element index (0-based) for this issue.
273    pub fn with_element_index(mut self, element_index: u8) -> Self {
274        self.element_index = Some(element_index);
275        self
276    }
277
278    /// Set the component index (0-based) for this issue.
279    pub fn with_component_index(mut self, component_index: u8) -> Self {
280        self.component_index = Some(component_index);
281        self
282    }
283
284    /// Set a suggestion for resolving this issue.
285    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
286        self.suggestion = Some(suggestion.into());
287        self
288    }
289
290    /// Set the zero-based occurrence index for this issue.
291    ///
292    /// Use this when the same segment tag appears multiple times in a message
293    /// and you want to identify which occurrence is affected.
294    pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
295        self.segment_occurrence = Some(occurrence);
296        self
297    }
298
299    /// Set the message reference (`UNH` element 0) for this issue.
300    ///
301    /// Use this to correlate an issue back to a specific message in a
302    /// multi-message interchange.
303    pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
304        self.message_ref = Some(message_ref.into());
305        self
306    }
307
308    /// Set the segment group (e.g. `"SG6"`) in which this issue occurred.
309    ///
310    /// Use this from group-aware rule functions that evaluate a sub-slice of a
311    /// [`crate::group::SegmentGroupIndexed`] tree so that consumers can identify
312    /// the exact group occurrence without re-reading the raw message.
313    pub fn with_segment_group(mut self, group: impl Into<Arc<str>>) -> Self {
314        self.segment_group = Some(group.into());
315        self
316    }
317
318    /// Insert a single key-value entry into the domain-specific [`context`](Self::context) map.
319    ///
320    /// Calling this multiple times accumulates entries; duplicate keys overwrite
321    /// the previous value.
322    ///
323    /// # Example
324    ///
325    /// ```rust
326    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
327    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
328    ///     .with_rule_id("AHB-13001-BGM-M")
329    ///     .with_context_entry("pid", "13001")
330    ///     .with_context_entry("partner", "9900123456789");
331    ///
332    /// assert_eq!(issue.context_get("pid"), Some("13001"));
333    /// assert_eq!(issue.context_get("partner"), Some("9900123456789"));
334    /// ```
335    pub fn with_context_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
336        let key = key.into();
337        let value = value.into();
338        if let Some(entry) = self.context.iter_mut().find(|(k, _)| k == &key) {
339            entry.1 = value;
340        } else {
341            self.context.push((key, value));
342        }
343        self
344    }
345
346    /// Extend the domain-specific [`context`](Self::context) map from an iterator of
347    /// `(key, value)` pairs.
348    ///
349    /// # Example
350    ///
351    /// ```rust
352    /// # use edifact_rs::{ValidationIssue, ValidationSeverity};
353    /// let meta = [("pid", "13001"), ("partner", "9900123456789")];
354    /// let issue = ValidationIssue::new(ValidationSeverity::Error, "test")
355    ///     .with_context_entries(meta);
356    ///
357    /// assert_eq!(issue.context_get("pid"), Some("13001"));
358    /// ```
359    pub fn with_context_entries<K, V, I>(mut self, entries: I) -> Self
360    where
361        K: Into<String>,
362        V: Into<String>,
363        I: IntoIterator<Item = (K, V)>,
364    {
365        for (k, v) in entries {
366            let k = k.into();
367            let v = v.into();
368            if let Some(entry) = self.context.iter_mut().find(|(key, _)| key == &k) {
369                entry.1 = v;
370            } else {
371                self.context.push((k, v));
372            }
373        }
374        self
375    }
376
377    /// Look up a value in the domain-specific [`context`](Self::context) map.
378    #[must_use]
379    #[inline]
380    pub fn context_get(&self, key: &str) -> Option<&str> {
381        self.context
382            .iter()
383            .find(|(k, _)| k == key)
384            .map(|(_, v)| v.as_str())
385    }
386
387    /// Short label for the severity level, suitable for display.
388    #[must_use]
389    pub fn severity_label(&self) -> &'static str {
390        match self.severity {
391            ValidationSeverity::Critical => "CRITICAL",
392            ValidationSeverity::Error => "ERROR",
393            ValidationSeverity::Warning => "WARNING",
394            ValidationSeverity::Info => "INFO",
395            #[allow(unreachable_patterns)]
396            _ => "UNKNOWN",
397        }
398    }
399
400    // ── Getters ───────────────────────────────────────────────────────────────
401
402    /// Stable error code, if available.
403    #[must_use]
404    #[inline]
405    pub fn error_code(&self) -> Option<&'static str> {
406        self.error_code
407    }
408
409    /// Byte offset in the source, if available.
410    #[must_use]
411    #[inline]
412    pub fn offset(&self) -> Option<usize> {
413        self.offset
414    }
415
416    /// Half-open byte range of the relevant source region, if available.
417    #[must_use]
418    #[inline]
419    pub fn span(&self) -> Option<Span> {
420        self.span
421    }
422
423    /// Segment tag involved in this issue, if known.
424    #[must_use]
425    #[inline]
426    pub fn segment_tag(&self) -> Option<&str> {
427        self.segment_tag.as_deref()
428    }
429
430    /// Profile/MIG rule identifier, if applicable.
431    #[must_use]
432    #[inline]
433    pub fn rule_id(&self) -> Option<&str> {
434        self.rule_id.as_deref()
435    }
436
437    /// Zero-based element index, if known.
438    #[must_use]
439    #[inline]
440    pub fn element_index(&self) -> Option<u8> {
441        self.element_index
442    }
443
444    /// Zero-based component index, if known.
445    #[must_use]
446    #[inline]
447    pub fn component_index(&self) -> Option<u8> {
448        self.component_index
449    }
450
451    /// Zero-based occurrence index among same-tag segments, if known.
452    #[must_use]
453    #[inline]
454    pub fn segment_occurrence(&self) -> Option<u16> {
455        self.segment_occurrence
456    }
457
458    /// Message reference (`UNH` element 0), if set.
459    #[must_use]
460    #[inline]
461    pub fn message_ref(&self) -> Option<&str> {
462        self.message_ref.as_deref()
463    }
464
465    /// Suggested remediation, if available.
466    #[must_use]
467    #[inline]
468    pub fn suggestion(&self) -> Option<&str> {
469        self.suggestion.as_deref()
470    }
471
472    /// Segment group (e.g. `"SG6"`) in which the issue occurred, if known.
473    #[must_use]
474    #[inline]
475    pub fn segment_group(&self) -> Option<&str> {
476        self.segment_group.as_deref()
477    }
478}
479
480impl std::fmt::Display for ValidationIssue {
481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482        write!(f, "[{}] {}", self.severity_label(), self.message)
483    }
484}
485
486impl std::error::Error for ValidationIssue {}
487
488// ── ValidationReport ─────────────────────────────────────────────────────────
489
490/// A collection of validation results: errors, warnings, and informational notes.
491///
492/// Enables batch validation where all issues are collected instead of failing on
493/// the first error.  Produced by [`crate::validator::ValidationContext`] methods
494/// such as `validate_lenient` and `validate_lenient_grouped`.
495///
496/// # Building reports manually
497///
498/// Use [`ValidationReport::from_issues`] to construct a report from pre-built issue
499/// vectors, or the `add_*` methods to push individual issues:
500///
501/// ```rust
502/// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
503///
504/// let mut report = ValidationReport::default();
505/// report.add_warning(
506///     ValidationIssue::new(ValidationSeverity::Warning, "optional field missing")
507///         .with_segment("DTM"),
508/// );
509/// assert!(report.is_valid()); // warnings don't fail validation
510/// ```
511#[derive(Debug, Clone, Default)]
512#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
513pub struct ValidationReport {
514    /// Critical and error-level issues.
515    pub(crate) errors: Vec<ValidationIssue>,
516    /// Warning-level issues.
517    pub(crate) warnings: Vec<ValidationIssue>,
518    /// Informational notes.
519    pub(crate) infos: Vec<ValidationIssue>,
520    /// Cached count of `Critical`-severity issues inside `errors`.
521    ///
522    /// Maintained incrementally by [`add_error`](Self::add_error) and
523    /// [`merge`](Self::merge); recomputed by [`from_issues`](Self::from_issues)
524    /// and `filter_report`.  Used to make the bail-on-first-critical check O(1)
525    /// instead of O(n_errors).  Not serialized (it is derived from `errors`).
526    #[cfg_attr(feature = "serde", serde(skip))]
527    pub(crate) critical_count: usize,
528}
529
530impl PartialEq for ValidationReport {
531    fn eq(&self, other: &Self) -> bool {
532        // Exclude `critical_count` from equality: it is derived from `errors`
533        // and would be zero for deserialized reports (serde(skip)), which would
534        // otherwise cause spurious inequality when comparing live vs. round-tripped
535        // reports.
536        self.errors == other.errors && self.warnings == other.warnings && self.infos == other.infos
537    }
538}
539
540impl ValidationReport {
541    /// Construct a report directly from pre-categorized issue vectors.
542    ///
543    /// This is the primary escape hatch for code that needs to inject advisory
544    /// issues into a report outside the normal validation pipeline — for example,
545    /// a middleware layer that wants to attach AHB-layer skip notices without
546    /// registering a synthetic `ProfileRulePack` rule.
547    ///
548    /// # Example
549    ///
550    /// ```rust,ignore
551    /// let mut report = ctx.validate_lenient(&segments);
552    /// let advisory = ValidationReport::from_issues(
553    ///     vec![],
554    ///     vec![ValidationIssue::new(ValidationSeverity::Warning, "AHB layer skipped")
555    ///         .with_rule_id("AHB-SKIP-001")],
556    ///     vec![],
557    /// );
558    /// report.merge(advisory);
559    /// ```
560    pub fn from_issues(
561        errors: Vec<ValidationIssue>,
562        warnings: Vec<ValidationIssue>,
563        infos: Vec<ValidationIssue>,
564    ) -> Self {
565        let critical_count = errors
566            .iter()
567            .filter(|i| i.severity == ValidationSeverity::Critical)
568            .count();
569        Self {
570            errors,
571            warnings,
572            infos,
573            critical_count,
574        }
575    }
576
577    /// Returns all error-level [`ValidationIssue`]s in this report.
578    pub fn errors(&self) -> &[ValidationIssue] {
579        &self.errors
580    }
581
582    /// Returns all error-level [`ValidationIssue`]s mutably.
583    pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
584        &mut self.errors
585    }
586
587    /// Returns all warning-level [`ValidationIssue`]s in this report.
588    pub fn warnings(&self) -> &[ValidationIssue] {
589        &self.warnings
590    }
591
592    /// Returns all warning-level [`ValidationIssue`]s mutably.
593    pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
594        &mut self.warnings
595    }
596
597    /// Returns all informational [`ValidationIssue`]s in this report.
598    pub fn infos(&self) -> &[ValidationIssue] {
599        &self.infos
600    }
601
602    /// Returns all informational [`ValidationIssue`]s mutably.
603    pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
604        &mut self.infos
605    }
606
607    /// Add an error to the report.
608    pub fn add_error(&mut self, issue: ValidationIssue) {
609        if issue.severity == ValidationSeverity::Critical {
610            self.critical_count += 1;
611        }
612        self.errors.push(issue);
613    }
614
615    /// Add a warning to the report.
616    pub fn add_warning(&mut self, issue: ValidationIssue) {
617        self.warnings.push(issue);
618    }
619
620    /// Add an info message to the report.
621    pub fn add_info(&mut self, issue: ValidationIssue) {
622        self.infos.push(issue);
623    }
624
625    /// Check if the report has any errors (Critical or Error severity).
626    pub fn has_errors(&self) -> bool {
627        !self.errors().is_empty()
628    }
629
630    /// Check if the report contains at least one `Critical`-severity issue.
631    ///
632    /// O(1) — backed by an incrementally maintained counter.
633    pub fn has_critical_errors(&self) -> bool {
634        self.critical_count > 0
635    }
636
637    /// Check if the report has any warnings.
638    pub fn has_warnings(&self) -> bool {
639        !self.warnings().is_empty()
640    }
641
642    /// Get the total count of all issues.
643    pub fn total_issues(&self) -> usize {
644        self.errors().len() + self.warnings().len() + self.infos().len()
645    }
646
647    /// Check if the validation passed (no errors, but may have warnings).
648    pub fn is_valid(&self) -> bool {
649        self.errors().is_empty()
650    }
651
652    /// Convert to a `Result`.
653    ///
654    /// Returns `Ok(self)` when there are no errors.  Returns `Err(self)` when
655    /// there is at least one error-level issue, **preserving warnings and infos**
656    /// in the `Err` variant so callers can inspect the full report.
657    pub fn result(self) -> Result<Self, Self> {
658        if self.is_valid() { Ok(self) } else { Err(self) }
659    }
660
661    /// Iterate over all issues in severity buckets: errors, warnings, then infos.
662    pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
663        self.errors()
664            .iter()
665            .chain(self.warnings().iter())
666            .chain(self.infos().iter())
667    }
668
669    /// Return `true` if the report contains any issues (errors, warnings, or infos).
670    pub fn has_any_issues(&self) -> bool {
671        !self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
672    }
673
674    /// Drain all issues from `other` into `self`.
675    ///
676    /// Issues are appended in severity order: errors, warnings, infos.
677    /// `other` is left empty after this call.
678    pub fn merge(&mut self, mut other: ValidationReport) {
679        self.critical_count += other.critical_count;
680        self.errors.append(&mut other.errors);
681        self.warnings.append(&mut other.warnings);
682        self.infos.append(&mut other.infos);
683    }
684
685    /// Extend `self` with cloned issues from `other` (borrowing).
686    ///
687    /// Unlike [`merge`](Self::merge), this method borrows `other` so the caller
688    /// retains ownership.  Issues are cloned and appended to the respective
689    /// severity buckets.  Use `merge` when you can afford to consume `other`.
690    ///
691    /// # Example
692    ///
693    /// ```rust
694    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
695    ///
696    /// let mut combined = ValidationReport::default();
697    /// let report = ValidationReport::from_issues(
698    ///     vec![ValidationIssue::new(ValidationSeverity::Error, "bad segment")],
699    ///     vec![],
700    ///     vec![],
701    /// );
702    /// combined.extend_from(&report);
703    /// assert_eq!(combined.errors().len(), 1);
704    /// // `report` is still accessible
705    /// assert_eq!(report.errors().len(), 1);
706    /// ```
707    pub fn extend_from(&mut self, other: &ValidationReport) {
708        for issue in &other.errors {
709            self.add_error(issue.clone());
710        }
711        for issue in &other.warnings {
712            self.add_warning(issue.clone());
713        }
714        for issue in &other.infos {
715            self.add_info(issue.clone());
716        }
717    }
718
719    /// Iterate over all issues matching an exact profile/MIG rule identifier.
720    ///
721    /// Searches errors, warnings, and infos in that order.  Returns a lazy
722    /// iterator; collect into `Vec` if you need random access.
723    pub fn issues_for_rule_id<'a>(
724        &'a self,
725        rule_id: &'a str,
726    ) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
727        self.iter_issues()
728            .filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
729    }
730
731    fn filter_report<F>(&self, pred: F) -> Self
732    where
733        F: Fn(&ValidationIssue) -> bool,
734    {
735        let errors: Vec<ValidationIssue> =
736            self.errors().iter().filter(|i| pred(i)).cloned().collect();
737        let critical_count = errors
738            .iter()
739            .filter(|i| i.severity == ValidationSeverity::Critical)
740            .count();
741        Self {
742            errors,
743            warnings: self
744                .warnings()
745                .iter()
746                .filter(|i| pred(i))
747                .cloned()
748                .collect(),
749            infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
750            critical_count,
751        }
752    }
753
754    /// Return a cloned report containing only issues with an exact rule identifier.
755    pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
756        self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
757    }
758
759    /// Return a cloned report containing only issues whose rule identifier starts with `prefix`.
760    pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
761        self.filter_report(|issue| {
762            issue
763                .rule_id
764                .as_deref()
765                .is_some_and(|id| id.starts_with(prefix))
766        })
767    }
768
769    /// Return a cloned report containing only issues that reference `segment_tag`.
770    ///
771    /// Issues whose `segment_tag` field does not match are dropped; the severity
772    /// buckets (errors / warnings / infos) are preserved.
773    ///
774    /// # Example
775    ///
776    /// ```rust
777    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
778    ///
779    /// let mut report = ValidationReport::default();
780    /// report.add_error(
781    ///     ValidationIssue::new(ValidationSeverity::Error, "BGM missing")
782    ///         .with_segment("BGM"),
783    /// );
784    /// report.add_error(
785    ///     ValidationIssue::new(ValidationSeverity::Error, "NAD missing")
786    ///         .with_segment("NAD"),
787    /// );
788    /// let bgm_issues = report.for_segment("BGM");
789    /// assert_eq!(bgm_issues.errors().len(), 1);
790    /// assert_eq!(bgm_issues.errors()[0].segment_tag.as_deref(), Some("BGM"));
791    /// ```
792    pub fn for_segment(&self, segment_tag: &str) -> Self {
793        self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
794    }
795
796    /// Return a deterministic, stable text representation for snapshots and logs.
797    pub fn render_deterministic(&self) -> String {
798        fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
799            let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
800            refs.sort_by(|left, right| {
801                left.offset
802                    .unwrap_or(usize::MAX)
803                    .cmp(&right.offset.unwrap_or(usize::MAX))
804                    .then_with(|| {
805                        left.segment_tag
806                            .as_deref()
807                            .unwrap_or("")
808                            .cmp(right.segment_tag.as_deref().unwrap_or(""))
809                    })
810                    .then_with(|| {
811                        left.rule_id
812                            .as_deref()
813                            .unwrap_or("")
814                            .cmp(right.rule_id.as_deref().unwrap_or(""))
815                    })
816                    .then_with(|| {
817                        left.element_index
818                            .unwrap_or(u8::MAX)
819                            .cmp(&right.element_index.unwrap_or(u8::MAX))
820                    })
821                    .then_with(|| {
822                        left.component_index
823                            .unwrap_or(u8::MAX)
824                            .cmp(&right.component_index.unwrap_or(u8::MAX))
825                    })
826                    .then_with(|| {
827                        left.error_code
828                            .unwrap_or("")
829                            .cmp(right.error_code.unwrap_or(""))
830                    })
831                    .then_with(|| left.message.cmp(&right.message))
832            });
833            refs
834        }
835
836        fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
837            use std::fmt::Write as _;
838            out.push_str("    - ");
839            out.push_str(&issue.message);
840            if let Some(code) = issue.error_code {
841                out.push_str(" [");
842                out.push_str(code);
843                out.push(']');
844            }
845            if let Some(seg) = &issue.segment_tag {
846                out.push_str(" [segment=");
847                out.push_str(seg);
848                out.push(']');
849            }
850            if let Some(rule_id) = &issue.rule_id {
851                out.push_str(" [rule=");
852                out.push_str(rule_id);
853                out.push(']');
854            }
855            if let Some(element_index) = issue.element_index {
856                write!(out, " [element={element_index}]").ok();
857            }
858            if let Some(component_index) = issue.component_index {
859                write!(out, " [component={component_index}]").ok();
860            }
861            if let Some(offset) = issue.offset {
862                write!(out, " [offset={offset}]").ok();
863            }
864            if let Some(suggestion) = &issue.suggestion {
865                out.push_str(" [hint=");
866                out.push_str(suggestion);
867                out.push(']');
868            }
869        }
870
871        use std::fmt::Write as _;
872        let mut out = String::from("Validation Report:");
873        let errors = sorted_refs(self.errors());
874        let warnings = sorted_refs(self.warnings());
875        let infos = sorted_refs(self.infos());
876
877        if !errors.is_empty() {
878            write!(out, "\n  Errors ({})", errors.len()).ok();
879            for issue in &errors {
880                out.push('\n');
881                render_issue_line(&mut out, issue);
882            }
883        }
884        if !warnings.is_empty() {
885            write!(out, "\n  Warnings ({})", warnings.len()).ok();
886            for issue in &warnings {
887                out.push('\n');
888                render_issue_line(&mut out, issue);
889            }
890        }
891        if !infos.is_empty() {
892            write!(out, "\n  Info ({})", infos.len()).ok();
893            for issue in &infos {
894                out.push('\n');
895                render_issue_line(&mut out, issue);
896            }
897        }
898
899        out
900    }
901}
902
903#[cfg(feature = "diagnostics")]
904impl miette::Diagnostic for ValidationReport {
905    fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
906        Some(Box::new("VALIDATION"))
907    }
908
909    fn severity(&self) -> Option<miette::Severity> {
910        if self.has_errors() {
911            Some(miette::Severity::Error)
912        } else if self.has_warnings() {
913            Some(miette::Severity::Warning)
914        } else {
915            Some(miette::Severity::Advice)
916        }
917    }
918
919    fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
920        let msg = format!(
921            "Validation found {} error(s), {} warning(s), {} info(s)",
922            self.errors().len(),
923            self.warnings().len(),
924            self.infos().len()
925        );
926        Some(Box::new(msg))
927    }
928}
929
930impl std::fmt::Display for ValidationReport {
931    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
932        write!(f, "{}", self.render_deterministic())
933    }
934}
935
936impl std::error::Error for ValidationReport {}
937
938impl Extend<ValidationIssue> for ValidationReport {
939    /// Push each issue into the appropriate severity bucket.
940    ///
941    /// This enables ergonomic batch collection:
942    ///
943    /// ```rust
944    /// use edifact_rs::{ValidationReport, ValidationIssue, ValidationSeverity};
945    ///
946    /// let issues = vec![
947    ///     ValidationIssue::new(ValidationSeverity::Error, "bad segment"),
948    ///     ValidationIssue::new(ValidationSeverity::Warning, "optional field missing"),
949    ///     ValidationIssue::new(ValidationSeverity::Info, "advisory note"),
950    /// ];
951    /// let mut report = ValidationReport::default();
952    /// report.extend(issues);
953    /// assert_eq!(report.errors().len(), 1);
954    /// assert_eq!(report.warnings().len(), 1);
955    /// assert_eq!(report.infos().len(), 1);
956    /// ```
957    fn extend<I: IntoIterator<Item = ValidationIssue>>(&mut self, iter: I) {
958        for issue in iter {
959            match issue.severity {
960                ValidationSeverity::Critical | ValidationSeverity::Error => {
961                    self.add_error(issue);
962                }
963                ValidationSeverity::Warning => {
964                    self.add_warning(issue);
965                }
966                _ => {
967                    self.add_info(issue);
968                }
969            }
970        }
971    }
972}
973
974impl FromIterator<ValidationIssue> for ValidationReport {
975    fn from_iter<I: IntoIterator<Item = ValidationIssue>>(iter: I) -> Self {
976        let mut report = ValidationReport::default();
977        report.extend(iter);
978        report
979    }
980}
981
982// ── Tests ─────────────────────────────────────────────────────────────────────
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    #[test]
989    fn report_collects_errors_and_warnings() {
990        let mut report = ValidationReport::default();
991        report.add_error(
992            ValidationIssue::new(ValidationSeverity::Error, "Test error")
993                .with_segment("BGM")
994                .with_offset(42),
995        );
996        report.add_warning(ValidationIssue::new(
997            ValidationSeverity::Warning,
998            "Test warning",
999        ));
1000
1001        assert!(report.has_errors());
1002        assert!(report.has_warnings());
1003        assert_eq!(report.total_issues(), 2);
1004        assert!(!report.is_valid());
1005    }
1006
1007    #[test]
1008    fn report_result_conversion() {
1009        let mut report = ValidationReport::default();
1010        report.add_error(ValidationIssue::new(
1011            ValidationSeverity::Error,
1012            "Critical issue",
1013        ));
1014        assert!(report.result().is_err());
1015    }
1016
1017    #[test]
1018    fn report_valid_with_only_warnings() {
1019        let mut report = ValidationReport::default();
1020        report.add_warning(ValidationIssue::new(
1021            ValidationSeverity::Warning,
1022            "Just a warning",
1023        ));
1024        assert!(report.is_valid());
1025        assert!(report.result().is_ok());
1026    }
1027
1028    #[test]
1029    fn issue_builder_chain() {
1030        let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
1031            .with_error_code("E013")
1032            .with_offset(100)
1033            .with_segment("NAD")
1034            .with_rule_id("DEMO-P001")
1035            .with_element_index(1)
1036            .with_component_index(2)
1037            .with_suggestion("Check element count");
1038
1039        assert_eq!(issue.error_code, Some("E013"));
1040        assert_eq!(issue.message, "test message");
1041        assert_eq!(issue.offset, Some(100));
1042        assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
1043        assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
1044        assert_eq!(issue.element_index, Some(1));
1045        assert_eq!(issue.component_index, Some(2));
1046        assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
1047    }
1048
1049    #[test]
1050    fn report_display_format() {
1051        let mut report = ValidationReport::default();
1052        report.add_error(
1053            ValidationIssue::new(ValidationSeverity::Error, "Error 1")
1054                .with_error_code("E011")
1055                .with_offset(8),
1056        );
1057        report.add_warning(ValidationIssue::new(
1058            ValidationSeverity::Warning,
1059            "Warning 1",
1060        ));
1061        report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
1062
1063        let display_str = format!("{report}");
1064        assert!(display_str.contains("Errors (1)"));
1065        assert!(display_str.contains("Warnings (1)"));
1066        assert!(display_str.contains("Info (1)"));
1067        assert!(display_str.contains("[E011]"));
1068    }
1069
1070    #[test]
1071    fn render_deterministic_sorts_by_offset() {
1072        let mut report = ValidationReport::default();
1073        report.add_error(
1074            ValidationIssue::new(ValidationSeverity::Error, "later")
1075                .with_segment("BGM")
1076                .with_offset(20),
1077        );
1078        report.add_error(
1079            ValidationIssue::new(ValidationSeverity::Error, "earlier")
1080                .with_segment("UNH")
1081                .with_offset(1),
1082        );
1083
1084        let rendered = report.render_deterministic();
1085        let first = rendered.find("earlier").expect("missing first issue");
1086        let second = rendered.find("later").expect("missing second issue");
1087        assert!(first < second, "expected deterministic sort by offset");
1088    }
1089
1090    #[test]
1091    fn filter_by_rule_id() {
1092        let mut report = ValidationReport::default();
1093        report.add_error(
1094            ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
1095                .with_rule_id("ORDERS-P001"),
1096        );
1097        report.add_warning(
1098            ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
1099                .with_rule_id("INVOIC-P001"),
1100        );
1101        report.add_info(
1102            ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
1103                .with_rule_id("ORDERS-P002"),
1104        );
1105
1106        let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
1107        assert_eq!(only_orders_block.errors().len(), 1);
1108        assert!(only_orders_block.warnings().is_empty());
1109        assert!(only_orders_block.infos().is_empty());
1110
1111        let orders_family = report.filter_by_rule_prefix("ORDERS-");
1112        assert_eq!(orders_family.total_issues(), 2);
1113
1114        let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
1115        assert_eq!(exact.len(), 1);
1116        assert_eq!(exact[0].message, "invoic policy warning");
1117    }
1118
1119    #[test]
1120    fn context_map_builder() {
1121        let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
1122            .with_context_entry("pid", "13001")
1123            .with_context_entry("partner", "9900123456789");
1124
1125        assert_eq!(issue.context_get("pid"), Some("13001"));
1126        assert_eq!(issue.context_get("partner"), Some("9900123456789"));
1127        assert_eq!(issue.context_get("missing"), None);
1128    }
1129
1130    #[test]
1131    fn context_map_extend() {
1132        let meta = [("pid", "13001"), ("partner", "9900123456789")];
1133        let issue =
1134            ValidationIssue::new(ValidationSeverity::Error, "test").with_context_entries(meta);
1135        assert_eq!(issue.context_get("pid"), Some("13001"));
1136    }
1137
1138    #[test]
1139    fn context_key_overwrite() {
1140        let issue = ValidationIssue::new(ValidationSeverity::Warning, "demo")
1141            .with_context_entry("pid", "old")
1142            .with_context_entry("pid", "new");
1143        assert_eq!(issue.context_get("pid"), Some("new"));
1144    }
1145}