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