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