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