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