use crate::{
EdifactError, OwnedSegment, Segment, ValidationIssue, ValidationReport, ValidationSeverity,
};
use std::any::Any;
use std::sync::Arc;
#[derive(Clone, Copy)]
pub struct ValidationRuleContext<'a> {
metadata: Option<&'a (dyn Any + Send + Sync)>,
pub message_ref: Option<&'a str>,
}
impl<'a> ValidationRuleContext<'a> {
pub fn empty() -> Self {
Self {
metadata: None,
message_ref: None,
}
}
pub fn new<T: Any + Send + Sync>(value: &'a T) -> Self {
Self {
metadata: Some(value as &(dyn Any + Send + Sync)),
message_ref: None,
}
}
pub fn with_message_ref(mut self, msg_ref: &'a str) -> Self {
self.message_ref = Some(msg_ref);
self
}
pub fn metadata<T: Any + Send + Sync>(&self) -> Option<&T> {
self.metadata?.downcast_ref::<T>()
}
pub fn has_metadata(&self) -> bool {
self.metadata.is_some()
}
}
impl std::fmt::Debug for ValidationRuleContext<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidationRuleContext")
.field("has_metadata", &self.metadata.is_some())
.field("message_ref", &self.message_ref)
.finish()
}
}
pub trait ProfileRule: Send + Sync {
fn evaluate(
&self,
segments: &[Segment<'_>],
context: &ValidationRuleContext<'_>,
issues: &mut Vec<ValidationIssue>,
);
}
struct ClosureProfileRule<F>(F);
impl<F> ProfileRule for ClosureProfileRule<F>
where
F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
+ Send
+ Sync,
{
fn evaluate(
&self,
segments: &[Segment<'_>],
context: &ValidationRuleContext<'_>,
issues: &mut Vec<ValidationIssue>,
) {
(self.0)(segments, context, issues);
}
}
struct StatelessClosureProfileRule<F>(F);
impl<F> ProfileRule for StatelessClosureProfileRule<F>
where
F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync,
{
fn evaluate(
&self,
segments: &[Segment<'_>],
_context: &ValidationRuleContext<'_>,
issues: &mut Vec<ValidationIssue>,
) {
(self.0)(segments, issues);
}
}
struct NamedRule {
id: Option<Arc<str>>,
rule: Arc<dyn ProfileRule + Send + Sync>,
}
impl Clone for NamedRule {
fn clone(&self) -> Self {
Self {
id: self.id.clone(),
rule: Arc::clone(&self.rule),
}
}
}
pub struct ProfileRulePack {
name: String,
message_types: std::collections::BTreeSet<String>,
release: Option<String>,
rules: Vec<NamedRule>,
bail_on_first_error: bool,
}
impl ProfileRulePack {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
message_types: std::collections::BTreeSet::new(),
release: None,
rules: Vec::new(),
bail_on_first_error: false,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn message_types(&self) -> impl Iterator<Item = &str> {
self.message_types.iter().map(|s| s.as_str())
}
pub fn rule_count(&self) -> usize {
self.rules.len()
}
pub fn rule_ids(&self) -> impl Iterator<Item = &str> {
self.rules.iter().filter_map(|r| r.id.as_deref())
}
pub fn release(&self) -> Option<&str> {
self.release.as_deref()
}
pub fn for_message_type(mut self, message_type: impl Into<String>) -> Self {
self.message_types.insert(message_type.into());
self
}
pub fn for_release(mut self, release: impl Into<String>) -> Self {
self.release = Some(release.into());
self
}
pub fn bail_on_first_error(mut self, bail: bool) -> Self {
self.bail_on_first_error = bail;
self
}
pub fn with_rule_fn<F>(mut self, rule: F) -> Self
where
F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
+ Send
+ Sync
+ 'static,
{
self.rules.push(NamedRule {
id: None,
rule: Arc::new(ClosureProfileRule(rule)),
});
self
}
pub fn with_named_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
where
F: for<'a> Fn(&[Segment<'a>], &ValidationRuleContext<'_>, &mut Vec<ValidationIssue>)
+ Send
+ Sync
+ 'static,
{
self.rules.push(NamedRule {
id: Some(id.into()),
rule: Arc::new(ClosureProfileRule(rule)),
});
self
}
pub fn with_stateless_rule_fn<F>(mut self, rule: F) -> Self
where
F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
{
self.rules.push(NamedRule {
id: None,
rule: Arc::new(StatelessClosureProfileRule(rule)),
});
self
}
pub fn with_named_stateless_rule_fn<F>(mut self, id: impl Into<Arc<str>>, rule: F) -> Self
where
F: for<'a> Fn(&[Segment<'a>], &mut Vec<ValidationIssue>) + Send + Sync + 'static,
{
self.rules.push(NamedRule {
id: Some(id.into()),
rule: Arc::new(StatelessClosureProfileRule(rule)),
});
self
}
pub fn with_rule(mut self, rule: impl ProfileRule + 'static) -> Self {
self.rules.push(NamedRule {
id: None,
rule: Arc::new(rule),
});
self
}
pub fn with_named_rule(
mut self,
id: impl Into<Arc<str>>,
rule: impl ProfileRule + 'static,
) -> Self {
self.rules.push(NamedRule {
id: Some(id.into()),
rule: Arc::new(rule),
});
self
}
pub fn extend_from(mut self, base: &ProfileRulePack) -> Result<Self, EdifactError> {
let mut combined = base.rules.clone();
combined.append(&mut self.rules);
self.rules = combined;
for mt in &base.message_types {
self.message_types.insert(mt.clone());
}
self.release = merge_release_scopes(self.release.take(), base.release.clone())?;
Ok(self)
}
pub fn merge(mut self, mut other: Self) -> Result<Self, EdifactError> {
self.message_types.append(&mut other.message_types);
self.release = merge_release_scopes(self.release.take(), other.release.take())?;
self.rules.append(&mut other.rules);
Ok(self)
}
pub fn merge_unchecked(mut self, mut other: Self) -> Self {
self.message_types.append(&mut other.message_types);
self.release = match (self.release.take(), other.release.take()) {
(_, Some(r)) => Some(r),
(current, None) => current,
};
self.rules.append(&mut other.rules);
self
}
pub fn merge_with_override(mut self, mut other: Self) -> Result<Self, EdifactError> {
let mut id_to_index: std::collections::HashMap<Arc<str>, usize> = Default::default();
for (idx, rule) in self.rules.iter().enumerate() {
if let Some(id) = &rule.id {
id_to_index.insert(id.clone(), idx);
}
}
let mut replacements: Vec<(usize, NamedRule)> = Vec::new();
let mut to_append = Vec::new();
for other_rule in other.rules.drain(..) {
if let Some(id) = &other_rule.id {
if let Some(&idx) = id_to_index.get(id) {
replacements.push((idx, other_rule));
} else {
to_append.push(other_rule);
}
} else {
to_append.push(other_rule);
}
}
for (idx, rule) in replacements {
if idx < self.rules.len() {
self.rules[idx] = rule;
}
}
self.rules.append(&mut to_append);
self.message_types.append(&mut other.message_types);
self.release = merge_release_scopes(self.release.take(), other.release.take())?;
Ok(self)
}
}
fn merge_release_scopes(
current: Option<String>,
incoming: Option<String>,
) -> Result<Option<String>, EdifactError> {
match (current, incoming) {
(Some(x), Some(y)) if x != y => Err(EdifactError::IncompatibleReleaseScopes {
current: x,
incoming: y,
}),
(Some(x), Some(_)) => Ok(Some(x)),
(Some(x), None) => Ok(Some(x)),
(None, incoming) => Ok(incoming),
}
}
impl Validator for ProfileRulePack {
fn validate_batch(
&self,
segments: &[Segment<'_>],
report: &mut ValidationReport,
context: &ValidationRuleContext<'_>,
) {
let unh = segments.iter().find(|segment| segment.tag == "UNH");
let unh_e1 = unh.and_then(|s| s.get_element(1));
let message_type = unh_e1.and_then(|e| e.get_component(0));
if !self.message_types.is_empty()
&& !message_type.is_some_and(|mt| self.message_types.contains(mt))
{
return;
}
if let Some(bound_release) = &self.release {
let msg_association = unh_e1.and_then(|e| e.get_component(4));
if msg_association != Some(bound_release.as_str()) {
return;
}
}
let mut rule_issues: Vec<ValidationIssue> = Vec::new();
for named in &self.rules {
let errors_before = report.errors.len();
named.rule.evaluate(segments, context, &mut rule_issues);
for issue in rule_issues.drain(..) {
match issue.severity {
ValidationSeverity::Critical | ValidationSeverity::Error => {
report.add_error(issue);
}
ValidationSeverity::Warning => {
report.add_warning(issue);
}
ValidationSeverity::Info => {
report.add_info(issue);
}
}
}
if self.bail_on_first_error && report.errors.len() > errors_before {
return;
}
}
}
}
impl std::fmt::Debug for ProfileRulePack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProfileRulePack")
.field("name", &self.name)
.field("message_types", &self.message_types)
.field("release", &self.release)
.field("rule_count", &self.rules.len())
.field("bail_on_first_error", &self.bail_on_first_error)
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValidationLayer {
Envelope,
Structure,
CodeList,
Profile,
}
struct LayeredValidator {
layer: ValidationLayer,
validator: Box<dyn Validator + Send + Sync>,
}
pub struct ValidationContext {
validators: Vec<LayeredValidator>,
envelope_enabled: bool,
structure_enabled: bool,
code_list_enabled: bool,
profile_enabled: bool,
message_type: Option<String>,
message_ref: Option<String>,
metadata: Option<Arc<dyn Any + Send + Sync>>,
}
#[must_use = "call `.build()` to produce a `ValidationContext`"]
pub struct ValidationContextBuilder {
inner: ValidationContext,
}
impl Default for ValidationContextBuilder {
fn default() -> Self {
Self::new()
}
}
impl ValidationContextBuilder {
pub fn new() -> Self {
Self {
inner: ValidationContext {
validators: Vec::new(),
envelope_enabled: false,
structure_enabled: true,
code_list_enabled: true,
profile_enabled: true,
message_type: None,
message_ref: None,
metadata: None,
},
}
}
pub fn with_metadata<T: Any + Send + Sync + 'static>(mut self, value: T) -> Self {
self.inner.metadata = Some(Arc::new(value));
self
}
pub fn with_message_ref(mut self, message_ref: impl Into<String>) -> Self {
self.inner.message_ref = Some(message_ref.into());
self
}
pub fn with_message_type(mut self, message_type: impl Into<String>) -> Self {
self.inner.message_type = Some(message_type.into());
let configured = self.inner.message_type.as_deref();
for layered in &mut self.inner.validators {
layered.validator.set_message_type(configured);
}
self
}
pub fn structure(mut self, enabled: bool) -> Self {
self.inner.structure_enabled = enabled;
self
}
pub fn code_list(mut self, enabled: bool) -> Self {
self.inner.code_list_enabled = enabled;
self
}
pub fn profile(mut self, enabled: bool) -> Self {
self.inner.profile_enabled = enabled;
self
}
pub fn envelope(mut self, enabled: bool) -> Self {
self.inner.envelope_enabled = enabled;
self
}
pub fn with_envelope_validation(mut self) -> Self {
self.inner.envelope_enabled = true;
self.inner.validators.push(LayeredValidator {
layer: ValidationLayer::Envelope,
validator: Box::new(EnvelopeValidator),
});
self
}
pub fn with_validator<V>(mut self, layer: ValidationLayer, mut validator: V) -> Self
where
V: Validator + 'static,
{
validator.set_message_type(self.inner.message_type.as_deref());
self.inner.validators.push(LayeredValidator {
layer,
validator: Box::new(validator),
});
self
}
pub fn with_profile_pack(mut self, mut pack: ProfileRulePack) -> Self {
pack.set_message_type(self.inner.message_type.as_deref());
self.inner.validators.push(LayeredValidator {
layer: ValidationLayer::Profile,
validator: Box::new(pack),
});
self
}
#[must_use = "call `.validate_lenient()` or `.validate_strict()` on the resulting context"]
pub fn build(self) -> ValidationContext {
self.inner
}
}
impl ValidationContext {
pub fn builder() -> ValidationContextBuilder {
ValidationContextBuilder::new()
}
pub fn validate_lenient(&self, segments: &[Segment<'_>]) -> ValidationReport {
self.validate_with_context(segments, &self.build_rule_context())
}
pub fn validate_lenient_with<T: Any + Send + Sync>(
&self,
segments: &[Segment<'_>],
value: &T,
) -> ValidationReport {
let ctx = ValidationRuleContext {
metadata: Some(value as &(dyn Any + Send + Sync)),
message_ref: self.message_ref.as_deref(),
};
self.validate_with_context(segments, &ctx)
}
pub fn validate_strict(
&self,
segments: &[Segment<'_>],
) -> Result<ValidationReport, ValidationReport> {
self.validate_lenient(segments).result()
}
pub fn validate_strict_with<T: Any + Send + Sync>(
&self,
segments: &[Segment<'_>],
value: &T,
) -> Result<ValidationReport, ValidationReport> {
self.validate_lenient_with(segments, value).result()
}
pub fn validate_lenient_owned(&self, segments: &[OwnedSegment]) -> ValidationReport {
if self.validators.is_empty()
&& !self.envelope_enabled
&& !self.structure_enabled
&& !self.code_list_enabled
&& !self.profile_enabled
{
return ValidationReport::default();
}
self.validate_with_context_owned(segments, &self.build_rule_context())
}
fn build_rule_context(&self) -> ValidationRuleContext<'_> {
self.metadata
.as_ref()
.map(|arc| ValidationRuleContext {
metadata: Some(arc.as_ref() as &(dyn Any + Send + Sync)),
message_ref: self.message_ref.as_deref(),
})
.unwrap_or_else(|| ValidationRuleContext {
metadata: None,
message_ref: self.message_ref.as_deref(),
})
}
fn validate_with_context_owned(
&self,
segments: &[OwnedSegment],
context: &ValidationRuleContext<'_>,
) -> ValidationReport {
let mut report = ValidationReport::default();
let mut full_borrowed: Option<Vec<Segment<'_>>> = None;
let mut filtered_borrowed: Option<Vec<Segment<'_>>> = None;
let mut envelope_ran = false;
for lv in &self.validators {
if !self.layer_enabled(lv.layer) {
continue;
}
if lv.layer == ValidationLayer::Envelope {
let borrowed: Vec<Segment<'_>> = segments.iter().map(|s| s.as_borrowed()).collect();
lv.validator.validate_batch(&borrowed, &mut report, context);
envelope_ran = true;
} else if envelope_ran {
let active = filtered_borrowed.get_or_insert_with(|| {
segments
.iter()
.filter(|s| !matches!(s.tag.as_str(), "UNB" | "UNZ" | "UNG" | "UNE"))
.map(|s| s.as_borrowed())
.collect()
});
lv.validator.validate_batch(active, &mut report, context);
} else {
let active = full_borrowed
.get_or_insert_with(|| segments.iter().map(|s| s.as_borrowed()).collect());
lv.validator.validate_batch(active, &mut report, context);
}
}
if let Some(ref msg_ref) = self.message_ref {
for issue in report
.errors
.iter_mut()
.chain(report.warnings.iter_mut())
.chain(report.infos.iter_mut())
{
if issue.message_ref.is_none() {
issue.message_ref = Some(msg_ref.clone());
}
}
}
report
}
pub fn validate_strict_owned(
&self,
segments: &[OwnedSegment],
) -> Result<ValidationReport, ValidationReport> {
self.validate_lenient_owned(segments).result()
}
fn validate_with_context(
&self,
segments: &[Segment<'_>],
context: &ValidationRuleContext<'_>,
) -> ValidationReport {
let mut report = ValidationReport::default();
let mut filtered: Option<Vec<Segment<'_>>> = None;
let mut envelope_ran = false;
for lv in &self.validators {
if !self.layer_enabled(lv.layer) {
continue;
}
if lv.layer == ValidationLayer::Envelope {
lv.validator.validate_batch(segments, &mut report, context);
envelope_ran = true;
} else {
let active: &[Segment<'_>] = if envelope_ran {
filtered.get_or_insert_with(|| {
segments
.iter()
.filter(|s| !matches!(s.tag, "UNB" | "UNZ" | "UNG" | "UNE"))
.cloned()
.collect()
})
} else {
segments
};
lv.validator.validate_batch(active, &mut report, context);
}
}
if let Some(ref msg_ref) = self.message_ref {
for issue in report
.errors
.iter_mut()
.chain(report.warnings.iter_mut())
.chain(report.infos.iter_mut())
{
if issue.message_ref.is_none() {
issue.message_ref = Some(msg_ref.clone());
}
}
}
report
}
pub fn message_type(&self) -> Option<&str> {
self.message_type.as_deref()
}
pub fn message_ref(&self) -> Option<&str> {
self.message_ref.as_deref()
}
fn layer_enabled(&self, layer: ValidationLayer) -> bool {
match layer {
ValidationLayer::Envelope => self.envelope_enabled,
ValidationLayer::Structure => self.structure_enabled,
ValidationLayer::CodeList => self.code_list_enabled,
ValidationLayer::Profile => self.profile_enabled,
}
}
}
pub trait Validator: Send + Sync {
fn validate_batch(
&self,
segments: &[Segment<'_>],
report: &mut ValidationReport,
context: &ValidationRuleContext<'_>,
);
fn set_message_type(&mut self, _message_type: Option<&str>) {}
}
pub fn validate_each<F>(segments: &[Segment<'_>], report: &mut ValidationReport, mut f: F)
where
F: FnMut(&Segment<'_>) -> Result<(), EdifactError>,
{
for segment in segments {
if let Err(err) = f(segment) {
report_error(report, err);
}
}
}
pub(crate) fn report_error(report: &mut ValidationReport, err: EdifactError) {
let issue = issue_from_error(err);
match issue.severity {
ValidationSeverity::Critical | ValidationSeverity::Error => report.add_error(issue),
ValidationSeverity::Warning => report.add_warning(issue),
ValidationSeverity::Info => report.add_info(issue),
}
}
pub struct EnvelopeValidator;
impl Validator for EnvelopeValidator {
fn validate_batch(
&self,
segments: &[Segment<'_>],
report: &mut ValidationReport,
_ctx: &ValidationRuleContext<'_>,
) {
if let Err(e) = crate::envelope::validate_envelope(segments) {
report_error(report, e);
}
}
}
fn issue_from_error(err: EdifactError) -> ValidationIssue {
let code = err.stable_code();
let mut issue = ValidationIssue::new(severity_for(&err), err.to_string()).with_error_code(code);
let default_hint = err.recovery_hint();
match err {
EdifactError::InvalidSegmentForMessage { tag, offset, .. } => {
issue = issue.with_segment(tag).with_offset(offset);
}
EdifactError::InvalidElementCount { tag, offset, .. } => {
issue = issue.with_segment(tag).with_offset(offset);
}
EdifactError::InvalidComponentCount {
tag,
element_index,
offset,
..
} => {
issue = issue
.with_segment(tag)
.with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
.with_offset(offset);
}
EdifactError::InvalidCodeValue {
tag,
element_index,
offset,
suggestion,
..
} => {
issue = issue
.with_segment(tag)
.with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
.with_offset(offset);
if let Some(s) = suggestion {
issue = issue.with_suggestion(s);
}
}
EdifactError::MissingSegment { tag, .. } => {
issue = issue.with_segment(tag);
}
EdifactError::QualifierMismatch { tag, offset, .. } => {
issue = issue
.with_segment(tag)
.with_element_index(0)
.with_offset(offset);
}
EdifactError::ConditionalRequirementNotMet {
tag,
element_index,
offset,
..
} => {
issue = issue
.with_segment(tag)
.with_element_index(u8::try_from(element_index).unwrap_or(u8::MAX))
.with_offset(offset);
}
EdifactError::MissingRequiredElement { tag, element_index } => {
issue = issue.with_segment(tag);
if let Ok(idx) = u8::try_from(element_index) {
issue = issue.with_element_index(idx);
}
}
EdifactError::MissingRequiredComponent {
tag,
element_index,
component_index,
} => {
issue = issue.with_segment(tag);
if let Ok(ei) = u8::try_from(element_index) {
issue = issue.with_element_index(ei);
}
if let Ok(ci) = u8::try_from(component_index) {
issue = issue.with_component_index(ci);
}
}
EdifactError::InvalidReleaseSequence { offset }
| EdifactError::InvalidDelimiter { offset, .. }
| EdifactError::InvalidText { offset }
| EdifactError::UnexpectedEof { offset }
| EdifactError::UnexpectedDataToken { offset }
| EdifactError::FunctionalGroupNotSupported { offset } => {
issue = issue.with_offset(offset);
}
_ => {}
}
if issue.suggestion.is_none() {
if let Some(hint) = default_hint {
issue = issue.with_suggestion(hint);
}
}
issue
}
fn severity_for(err: &EdifactError) -> ValidationSeverity {
match err {
EdifactError::InvalidCodeValue { .. } | EdifactError::QualifierMismatch { .. } => {
ValidationSeverity::Warning
}
_ => ValidationSeverity::Error,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Element;
fn demo_orders_profile_pack() -> ProfileRulePack {
ProfileRulePack::new("ORDERS-DEMO")
.for_message_type("ORDERS")
.with_stateless_rule_fn(|segments, issues| {
issues.extend((|| -> Option<ValidationIssue> {
let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
let document_code = bgm.get_element(0)?.get_component(0)?;
(document_code == "220").then(|| {
ValidationIssue::new(
ValidationSeverity::Error,
"profile rule DEMO-P001 violated: BGM document code 220 is rejected in this demo pack",
)
.with_rule_id("DEMO-P001")
.with_segment("BGM")
.with_element_index(0)
.with_suggestion("Use a different BGM document code in this demo pack")
})
})());
})
.with_stateless_rule_fn(|segments, issues| {
issues.extend((|| -> Option<ValidationIssue> {
let bgm = segments.iter().find(|segment| segment.tag == "BGM")?;
let reference = bgm.get_element(1)?.get_component(0)?;
(reference == "PO123").then(|| {
ValidationIssue::new(
ValidationSeverity::Warning,
"profile rule DEMO-P002 warning: purchase-order reference PO123 is reserved in this demo pack",
)
.with_rule_id("DEMO-P002")
.with_segment("BGM")
.with_element_index(1)
.with_suggestion("Use a non-reserved reference in this demo pack")
})
})());
})
}
struct RejectBgm;
struct WarnBgm;
impl Validator for RejectBgm {
fn validate_batch(
&self,
segments: &[Segment<'_>],
report: &mut ValidationReport,
_context: &ValidationRuleContext<'_>,
) {
validate_each(segments, report, |segment| {
if segment.tag == "BGM" {
return Err(EdifactError::InvalidSegmentForMessage {
tag: "BGM".to_owned(),
message_type: "TEST".to_owned(),
offset: segment.tag_span.start,
});
}
Ok(())
});
}
}
impl Validator for WarnBgm {
fn validate_batch(
&self,
segments: &[Segment<'_>],
report: &mut ValidationReport,
_context: &ValidationRuleContext<'_>,
) {
validate_each(segments, report, |segment| {
if segment.tag == "BGM" {
return Err(EdifactError::InvalidCodeValue {
tag: "BGM".to_owned(),
element_index: 0,
value: "XXX".to_owned(),
code_list: "1001".to_owned(),
offset: segment.span.start,
suggestion: None,
});
}
Ok(())
});
}
}
fn test_segment(tag: &'static str) -> Segment<'static> {
Segment {
tag,
span: crate::Span::new(0, 0),
tag_span: crate::Span::new(0, 0),
elements: vec![Element::of(&["x"])],
}
}
#[test]
fn lenient_collects_issues() {
let segments = vec![test_segment("UNH"), test_segment("BGM")];
let mut report = ValidationReport::default();
RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
assert!(report.has_errors());
assert_eq!(report.errors().len(), 1);
}
#[test]
fn strict_fails_on_errors() {
let segments = vec![test_segment("BGM")];
let mut report = ValidationReport::default();
RejectBgm.validate_batch(&segments, &mut report, &ValidationRuleContext::empty());
assert!(report.has_errors());
assert_eq!(report.errors().len(), 1);
}
#[test]
fn context_builder_respects_layer_toggles() {
let segments = vec![test_segment("BGM")];
let ctx = ValidationContext::builder()
.structure(false)
.with_validator(ValidationLayer::Structure, RejectBgm)
.with_validator(ValidationLayer::CodeList, WarnBgm)
.build();
let report = ctx.validate_lenient(&segments);
assert!(!report.has_errors());
assert_eq!(report.warnings().len(), 1);
}
#[test]
fn context_strict_fails_when_structure_enabled() {
let segments = vec![test_segment("BGM")];
let ctx = ValidationContext::builder()
.with_message_type("ORDERS")
.with_validator(ValidationLayer::Structure, RejectBgm)
.build();
assert_eq!(ctx.message_type(), Some("ORDERS"));
let result = ctx.validate_strict(&segments);
assert!(result.is_err());
assert!(result.unwrap_err().has_errors());
}
#[test]
fn report_error_applies_default_recovery_hint() {
let mut report = ValidationReport::default();
report_error(
&mut report,
EdifactError::InvalidReleaseSequence { offset: 9 },
);
let issue = report
.errors()
.first()
.expect("expected one issue in the report");
let hint = issue
.suggestion
.as_deref()
.expect("expected default hint to be set");
assert!(hint.contains("Release character"));
assert_eq!(issue.error_code, Some("E019"));
}
#[test]
fn missing_required_component_maps_metadata_to_issue() {
let mut report = ValidationReport::default();
report_error(
&mut report,
EdifactError::MissingRequiredComponent {
tag: "BGM".to_owned(),
element_index: 2,
component_index: 1,
},
);
let issue = report.errors().first().expect("expected one issue");
assert_eq!(issue.error_code, Some("E021"));
assert_eq!(issue.segment_tag.as_deref(), Some("BGM"));
assert_eq!(issue.element_index, Some(2));
assert_eq!(issue.component_index, Some(1));
}
#[test]
fn profile_pack_lenient_collects_profile_rule_issues() {
let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
let segments = crate::from_bytes(input)
.collect::<Result<Vec<_>, _>>()
.expect("expected parse success");
let ctx = ValidationContext::builder()
.with_profile_pack(demo_orders_profile_pack())
.build();
let report = ctx.validate_lenient(&segments);
assert!(report.has_errors());
assert!(
report
.errors()
.iter()
.any(|issue| issue.rule_id.as_deref() == Some("DEMO-P001"))
);
assert!(
report
.warnings()
.iter()
.any(|issue| issue.rule_id.as_deref() == Some("DEMO-P002"))
);
}
#[test]
fn profile_pack_strict_fails_when_profile_errors_exist() {
let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+PO123+9'UNT+3+1'";
let segments = crate::from_bytes(input)
.collect::<Result<Vec<_>, _>>()
.expect("expected parse success");
let ctx = ValidationContext::builder()
.with_profile_pack(demo_orders_profile_pack())
.build();
let result = ctx.validate_strict(&segments);
assert!(result.is_err());
assert!(result.unwrap_err().has_errors());
}
fn two_dtm_errors_rule() -> ProfileRulePack {
ProfileRulePack::new("TEST-BAIL")
.with_stateless_rule_fn(|segments, issues| {
for seg in segments.iter().filter(|s| s.tag == "DTM") {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!("DTM error at offset {}", seg.span.start),
)
.with_rule_id("BAIL-R1")
.with_segment("DTM"),
);
}
})
.with_stateless_rule_fn(|segments, issues| {
for seg in segments.iter().filter(|s| s.tag == "BGM") {
issues.push(
ValidationIssue::new(ValidationSeverity::Error, "BGM error")
.with_rule_id("BAIL-R2")
.with_segment(seg.tag),
);
}
})
}
#[test]
fn bail_on_first_error_fires_at_rule_invocation_granularity() {
let input =
b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'DTM+163:20240201:102'UNT+5+1'";
let segments = crate::from_bytes(input)
.collect::<Result<Vec<_>, _>>()
.expect("parse failed");
let pack_with_bail = two_dtm_errors_rule().bail_on_first_error(true);
let ctx = ValidationContext::builder()
.with_profile_pack(pack_with_bail)
.build();
let report = ctx.validate_lenient(&segments);
assert_eq!(
report
.errors()
.iter()
.filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
.count(),
2,
"both DTM errors from Rule A should be present"
);
assert_eq!(
report
.errors()
.iter()
.filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
.count(),
0,
"Rule B should have been skipped by bail"
);
}
#[test]
fn bail_disabled_runs_all_rules() {
let input = b"UNH+1+ORDERS:D:96A:UN'BGM+220+9'DTM+137:20240101:102'UNT+4+1'";
let segments = crate::from_bytes(input)
.collect::<Result<Vec<_>, _>>()
.expect("parse failed");
let pack_no_bail = two_dtm_errors_rule(); let ctx = ValidationContext::builder()
.with_profile_pack(pack_no_bail)
.build();
let report = ctx.validate_lenient(&segments);
assert_eq!(
report
.errors()
.iter()
.filter(|i| i.rule_id.as_deref() == Some("BAIL-R1"))
.count(),
1
);
assert_eq!(
report
.errors()
.iter()
.filter(|i| i.rule_id.as_deref() == Some("BAIL-R2"))
.count(),
1
);
}
#[test]
fn message_ref_is_visible_inside_rule_closure() {
let input = b"UNH+MSG001+ORDERS:D:96A:UN'BGM+220+9'UNT+3+1'";
let segments = crate::from_bytes(input)
.collect::<Result<Vec<_>, _>>()
.expect("parse failed");
let pack = ProfileRulePack::new("MSG-REF-TEST").with_rule_fn(|_segs, ctx, issues| {
if let Some(mref) = ctx.message_ref {
issues.push(
ValidationIssue::new(
ValidationSeverity::Info,
format!("validating message {mref}"),
)
.with_rule_id("CTX-REF"),
);
}
});
let ctx = ValidationContext::builder()
.with_profile_pack(pack)
.with_message_ref("MSG001")
.build();
let report = ctx.validate_lenient(&segments);
let info = report
.infos()
.iter()
.find(|i| i.rule_id.as_deref() == Some("CTX-REF"))
.expect("expected info issue from CTX-REF rule");
assert!(info.message.contains("MSG001"));
assert_eq!(info.message_ref.as_deref(), Some("MSG001"));
}
}