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