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