use std::sync::Arc;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ValidationSeverity {
Critical,
Error,
Warning,
Info,
}
impl ValidationSeverity {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Critical => "critical",
Self::Error => "error",
Self::Warning => "warning",
Self::Info => "info",
#[allow(unreachable_patterns)]
_ => "unknown",
}
}
#[must_use]
pub fn numeric_level(self) -> u8 {
match self {
Self::Info => 0,
Self::Warning => 1,
Self::Error => 2,
Self::Critical => 3,
}
}
}
impl std::fmt::Display for ValidationSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ValidationIssue {
#[cfg_attr(feature = "serde", serde(skip_deserializing, default))]
pub error_code: Option<&'static str>,
pub severity: ValidationSeverity,
pub message: String,
pub offset: Option<usize>,
pub segment_tag: Option<String>,
pub rule_id: Option<String>,
pub element_index: Option<u8>,
pub component_index: Option<u8>,
pub segment_occurrence: Option<u16>,
pub message_ref: Option<String>,
pub suggestion: Option<String>,
pub segment_group: Option<Arc<str>>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
pub context: Vec<(String, String)>,
}
impl ValidationIssue {
pub fn new(severity: ValidationSeverity, message: impl Into<String>) -> Self {
Self {
error_code: None,
severity,
message: message.into(),
offset: None,
segment_tag: None,
rule_id: None,
element_index: None,
component_index: None,
segment_occurrence: None,
message_ref: None,
suggestion: None,
segment_group: None,
context: Vec::new(),
}
}
pub fn with_error_code(mut self, code: &'static str) -> Self {
self.error_code = Some(code);
self
}
pub fn with_offset(mut self, offset: usize) -> Self {
self.offset = Some(offset);
self
}
pub fn with_segment(mut self, tag: impl Into<String>) -> Self {
self.segment_tag = Some(tag.into());
self
}
pub fn with_rule_id(mut self, rule_id: impl Into<String>) -> Self {
self.rule_id = Some(rule_id.into());
self
}
pub fn with_element_index(mut self, element_index: u8) -> Self {
self.element_index = Some(element_index);
self
}
pub fn with_component_index(mut self, component_index: u8) -> Self {
self.component_index = Some(component_index);
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
pub fn with_segment_occurrence(mut self, occurrence: u16) -> Self {
self.segment_occurrence = Some(occurrence);
self
}
pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
self.message_ref = Some(message_ref.into());
self
}
pub fn with_segment_group(mut self, group: impl Into<Arc<str>>) -> Self {
self.segment_group = Some(group.into());
self
}
pub fn with_context_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key = key.into();
let value = value.into();
if let Some(entry) = self.context.iter_mut().find(|(k, _)| k == &key) {
entry.1 = value;
} else {
self.context.push((key, value));
}
self
}
pub fn with_context_entries<K, V, I>(mut self, entries: I) -> Self
where
K: Into<String>,
V: Into<String>,
I: IntoIterator<Item = (K, V)>,
{
for (k, v) in entries {
let k = k.into();
let v = v.into();
if let Some(entry) = self.context.iter_mut().find(|(key, _)| key == &k) {
entry.1 = v;
} else {
self.context.push((k, v));
}
}
self
}
#[must_use]
#[inline]
pub fn context_get(&self, key: &str) -> Option<&str> {
self.context
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn severity_label(&self) -> &'static str {
match self.severity {
ValidationSeverity::Critical => "CRITICAL",
ValidationSeverity::Error => "ERROR",
ValidationSeverity::Warning => "WARNING",
ValidationSeverity::Info => "INFO",
#[allow(unreachable_patterns)]
_ => "UNKNOWN",
}
}
#[must_use]
#[inline]
pub fn error_code(&self) -> Option<&'static str> {
self.error_code
}
#[must_use]
#[inline]
pub fn offset(&self) -> Option<usize> {
self.offset
}
#[must_use]
#[inline]
pub fn segment_tag(&self) -> Option<&str> {
self.segment_tag.as_deref()
}
#[must_use]
#[inline]
pub fn rule_id(&self) -> Option<&str> {
self.rule_id.as_deref()
}
#[must_use]
#[inline]
pub fn element_index(&self) -> Option<u8> {
self.element_index
}
#[must_use]
#[inline]
pub fn component_index(&self) -> Option<u8> {
self.component_index
}
#[must_use]
#[inline]
pub fn segment_occurrence(&self) -> Option<u16> {
self.segment_occurrence
}
#[must_use]
#[inline]
pub fn message_ref(&self) -> Option<&str> {
self.message_ref.as_deref()
}
#[must_use]
#[inline]
pub fn suggestion(&self) -> Option<&str> {
self.suggestion.as_deref()
}
#[must_use]
#[inline]
pub fn segment_group(&self) -> Option<&str> {
self.segment_group.as_deref()
}
}
impl std::fmt::Display for ValidationIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.severity_label(), self.message)
}
}
impl std::error::Error for ValidationIssue {}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ValidationReport {
pub(crate) errors: Vec<ValidationIssue>,
pub(crate) warnings: Vec<ValidationIssue>,
pub(crate) infos: Vec<ValidationIssue>,
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) critical_count: usize,
}
impl PartialEq for ValidationReport {
fn eq(&self, other: &Self) -> bool {
self.errors == other.errors && self.warnings == other.warnings && self.infos == other.infos
}
}
impl ValidationReport {
pub fn from_issues(
errors: Vec<ValidationIssue>,
warnings: Vec<ValidationIssue>,
infos: Vec<ValidationIssue>,
) -> Self {
let critical_count = errors
.iter()
.filter(|i| i.severity == ValidationSeverity::Critical)
.count();
Self {
errors,
warnings,
infos,
critical_count,
}
}
pub fn errors(&self) -> &[ValidationIssue] {
&self.errors
}
pub fn errors_mut(&mut self) -> &mut [ValidationIssue] {
&mut self.errors
}
pub fn warnings(&self) -> &[ValidationIssue] {
&self.warnings
}
pub fn warnings_mut(&mut self) -> &mut [ValidationIssue] {
&mut self.warnings
}
pub fn infos(&self) -> &[ValidationIssue] {
&self.infos
}
pub fn infos_mut(&mut self) -> &mut [ValidationIssue] {
&mut self.infos
}
pub fn add_error(&mut self, issue: ValidationIssue) {
if issue.severity == ValidationSeverity::Critical {
self.critical_count += 1;
}
self.errors.push(issue);
}
pub fn add_warning(&mut self, issue: ValidationIssue) {
self.warnings.push(issue);
}
pub fn add_info(&mut self, issue: ValidationIssue) {
self.infos.push(issue);
}
pub fn has_errors(&self) -> bool {
!self.errors().is_empty()
}
pub fn has_critical_errors(&self) -> bool {
self.critical_count > 0
}
pub fn has_warnings(&self) -> bool {
!self.warnings().is_empty()
}
pub fn total_issues(&self) -> usize {
self.errors().len() + self.warnings().len() + self.infos().len()
}
pub fn is_valid(&self) -> bool {
self.errors().is_empty()
}
pub fn result(self) -> Result<Self, Self> {
if self.is_valid() { Ok(self) } else { Err(self) }
}
pub fn iter_issues(&self) -> impl Iterator<Item = &ValidationIssue> {
self.errors()
.iter()
.chain(self.warnings().iter())
.chain(self.infos().iter())
}
pub fn has_any_issues(&self) -> bool {
!self.errors().is_empty() || !self.warnings().is_empty() || !self.infos().is_empty()
}
pub fn merge(&mut self, mut other: ValidationReport) {
self.critical_count += other.critical_count;
self.errors.append(&mut other.errors);
self.warnings.append(&mut other.warnings);
self.infos.append(&mut other.infos);
}
pub fn issues_for_rule_id<'a>(
&'a self,
rule_id: &'a str,
) -> impl Iterator<Item = &'a ValidationIssue> + 'a {
self.iter_issues()
.filter(move |issue| issue.rule_id.as_deref() == Some(rule_id))
}
fn filter_report<F>(&self, pred: F) -> Self
where
F: Fn(&ValidationIssue) -> bool,
{
let errors: Vec<ValidationIssue> =
self.errors().iter().filter(|i| pred(i)).cloned().collect();
let critical_count = errors
.iter()
.filter(|i| i.severity == ValidationSeverity::Critical)
.count();
Self {
errors,
warnings: self
.warnings()
.iter()
.filter(|i| pred(i))
.cloned()
.collect(),
infos: self.infos().iter().filter(|i| pred(i)).cloned().collect(),
critical_count,
}
}
pub fn filter_by_rule_id(&self, rule_id: &str) -> Self {
self.filter_report(|issue| issue.rule_id.as_deref() == Some(rule_id))
}
pub fn filter_by_rule_prefix(&self, prefix: &str) -> Self {
self.filter_report(|issue| {
issue
.rule_id
.as_deref()
.is_some_and(|id| id.starts_with(prefix))
})
}
pub fn for_segment(&self, segment_tag: &str) -> Self {
self.filter_report(|issue| issue.segment_tag.as_deref() == Some(segment_tag))
}
pub fn render_deterministic(&self) -> String {
fn sorted_refs(issues: &[ValidationIssue]) -> Vec<&ValidationIssue> {
let mut refs: Vec<&ValidationIssue> = issues.iter().collect();
refs.sort_by(|left, right| {
left.offset
.unwrap_or(usize::MAX)
.cmp(&right.offset.unwrap_or(usize::MAX))
.then_with(|| {
left.segment_tag
.as_deref()
.unwrap_or("")
.cmp(right.segment_tag.as_deref().unwrap_or(""))
})
.then_with(|| {
left.rule_id
.as_deref()
.unwrap_or("")
.cmp(right.rule_id.as_deref().unwrap_or(""))
})
.then_with(|| {
left.element_index
.unwrap_or(u8::MAX)
.cmp(&right.element_index.unwrap_or(u8::MAX))
})
.then_with(|| {
left.component_index
.unwrap_or(u8::MAX)
.cmp(&right.component_index.unwrap_or(u8::MAX))
})
.then_with(|| {
left.error_code
.unwrap_or("")
.cmp(right.error_code.unwrap_or(""))
})
.then_with(|| left.message.cmp(&right.message))
});
refs
}
fn render_issue_line(out: &mut String, issue: &ValidationIssue) {
use std::fmt::Write as _;
out.push_str(" - ");
out.push_str(&issue.message);
if let Some(code) = issue.error_code {
out.push_str(" [");
out.push_str(code);
out.push(']');
}
if let Some(seg) = &issue.segment_tag {
out.push_str(" [segment=");
out.push_str(seg);
out.push(']');
}
if let Some(rule_id) = &issue.rule_id {
out.push_str(" [rule=");
out.push_str(rule_id);
out.push(']');
}
if let Some(element_index) = issue.element_index {
write!(out, " [element={element_index}]").ok();
}
if let Some(component_index) = issue.component_index {
write!(out, " [component={component_index}]").ok();
}
if let Some(offset) = issue.offset {
write!(out, " [offset={offset}]").ok();
}
if let Some(suggestion) = &issue.suggestion {
out.push_str(" [hint=");
out.push_str(suggestion);
out.push(']');
}
}
use std::fmt::Write as _;
let mut out = String::from("Validation Report:");
let errors = sorted_refs(self.errors());
let warnings = sorted_refs(self.warnings());
let infos = sorted_refs(self.infos());
if !errors.is_empty() {
write!(out, "\n Errors ({})", errors.len()).ok();
for issue in &errors {
out.push('\n');
render_issue_line(&mut out, issue);
}
}
if !warnings.is_empty() {
write!(out, "\n Warnings ({})", warnings.len()).ok();
for issue in &warnings {
out.push('\n');
render_issue_line(&mut out, issue);
}
}
if !infos.is_empty() {
write!(out, "\n Info ({})", infos.len()).ok();
for issue in &infos {
out.push('\n');
render_issue_line(&mut out, issue);
}
}
out
}
}
#[cfg(feature = "diagnostics")]
impl miette::Diagnostic for ValidationReport {
fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
Some(Box::new("VALIDATION"))
}
fn severity(&self) -> Option<miette::Severity> {
if self.has_errors() {
Some(miette::Severity::Error)
} else if self.has_warnings() {
Some(miette::Severity::Warning)
} else {
Some(miette::Severity::Advice)
}
}
fn help<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> {
let msg = format!(
"Validation found {} error(s), {} warning(s), {} info(s)",
self.errors().len(),
self.warnings().len(),
self.infos().len()
);
Some(Box::new(msg))
}
}
impl std::fmt::Display for ValidationReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.render_deterministic())
}
}
impl std::error::Error for ValidationReport {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_collects_errors_and_warnings() {
let mut report = ValidationReport::default();
report.add_error(
ValidationIssue::new(ValidationSeverity::Error, "Test error")
.with_segment("BGM")
.with_offset(42),
);
report.add_warning(ValidationIssue::new(
ValidationSeverity::Warning,
"Test warning",
));
assert!(report.has_errors());
assert!(report.has_warnings());
assert_eq!(report.total_issues(), 2);
assert!(!report.is_valid());
}
#[test]
fn report_result_conversion() {
let mut report = ValidationReport::default();
report.add_error(ValidationIssue::new(
ValidationSeverity::Error,
"Critical issue",
));
assert!(report.result().is_err());
}
#[test]
fn report_valid_with_only_warnings() {
let mut report = ValidationReport::default();
report.add_warning(ValidationIssue::new(
ValidationSeverity::Warning,
"Just a warning",
));
assert!(report.is_valid());
assert!(report.result().is_ok());
}
#[test]
fn issue_builder_chain() {
let issue = ValidationIssue::new(ValidationSeverity::Warning, "test message")
.with_error_code("E013")
.with_offset(100)
.with_segment("NAD")
.with_rule_id("DEMO-P001")
.with_element_index(1)
.with_component_index(2)
.with_suggestion("Check element count");
assert_eq!(issue.error_code, Some("E013"));
assert_eq!(issue.message, "test message");
assert_eq!(issue.offset, Some(100));
assert_eq!(issue.segment_tag, Some("NAD".to_owned()));
assert_eq!(issue.rule_id, Some("DEMO-P001".to_owned()));
assert_eq!(issue.element_index, Some(1));
assert_eq!(issue.component_index, Some(2));
assert_eq!(issue.suggestion, Some("Check element count".to_owned()));
}
#[test]
fn report_display_format() {
let mut report = ValidationReport::default();
report.add_error(
ValidationIssue::new(ValidationSeverity::Error, "Error 1")
.with_error_code("E011")
.with_offset(8),
);
report.add_warning(ValidationIssue::new(
ValidationSeverity::Warning,
"Warning 1",
));
report.add_info(ValidationIssue::new(ValidationSeverity::Info, "Info 1"));
let display_str = format!("{report}");
assert!(display_str.contains("Errors (1)"));
assert!(display_str.contains("Warnings (1)"));
assert!(display_str.contains("Info (1)"));
assert!(display_str.contains("[E011]"));
}
#[test]
fn render_deterministic_sorts_by_offset() {
let mut report = ValidationReport::default();
report.add_error(
ValidationIssue::new(ValidationSeverity::Error, "later")
.with_segment("BGM")
.with_offset(20),
);
report.add_error(
ValidationIssue::new(ValidationSeverity::Error, "earlier")
.with_segment("UNH")
.with_offset(1),
);
let rendered = report.render_deterministic();
let first = rendered.find("earlier").expect("missing first issue");
let second = rendered.find("later").expect("missing second issue");
assert!(first < second, "expected deterministic sort by offset");
}
#[test]
fn filter_by_rule_id() {
let mut report = ValidationReport::default();
report.add_error(
ValidationIssue::new(ValidationSeverity::Error, "orders policy blocked")
.with_rule_id("ORDERS-P001"),
);
report.add_warning(
ValidationIssue::new(ValidationSeverity::Warning, "invoic policy warning")
.with_rule_id("INVOIC-P001"),
);
report.add_info(
ValidationIssue::new(ValidationSeverity::Info, "orders policy info")
.with_rule_id("ORDERS-P002"),
);
let only_orders_block = report.filter_by_rule_id("ORDERS-P001");
assert_eq!(only_orders_block.errors().len(), 1);
assert!(only_orders_block.warnings().is_empty());
assert!(only_orders_block.infos().is_empty());
let orders_family = report.filter_by_rule_prefix("ORDERS-");
assert_eq!(orders_family.total_issues(), 2);
let exact: Vec<_> = report.issues_for_rule_id("INVOIC-P001").collect();
assert_eq!(exact.len(), 1);
assert_eq!(exact[0].message, "invoic policy warning");
}
#[test]
fn context_map_builder() {
let issue = ValidationIssue::new(ValidationSeverity::Error, "BGM code invalid")
.with_context_entry("pid", "13001")
.with_context_entry("partner", "9900123456789");
assert_eq!(issue.context_get("pid"), Some("13001"));
assert_eq!(issue.context_get("partner"), Some("9900123456789"));
assert_eq!(issue.context_get("missing"), None);
}
#[test]
fn context_map_extend() {
let meta = [("pid", "13001"), ("partner", "9900123456789")];
let issue =
ValidationIssue::new(ValidationSeverity::Error, "test").with_context_entries(meta);
assert_eq!(issue.context_get("pid"), Some("13001"));
}
#[test]
fn context_key_overwrite() {
let issue = ValidationIssue::new(ValidationSeverity::Warning, "demo")
.with_context_entry("pid", "old")
.with_context_entry("pid", "new");
assert_eq!(issue.context_get("pid"), Some("new"));
}
}