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