use std::collections::HashSet;
use edifact_rs::{Segment, ValidationIssue, ValidationSeverity};
use super::Profile;
use super::conditions::{
ConditionKind, EvalError, Expr, ExprError, Paket, Scope, Status, Truth, Voraussetzung,
};
use super::model::{Anwendungsfall, Element, ElementRule, SegmentNode};
use super::structure::{InstanceId, Kind, NodeId, Resolution, Structure};
pub const AHB_SKIP_NO_PID: &str = "AHB-SKIP-NO-PID";
pub const AHB_UNKNOWN_PID: &str = "AHB-UNKNOWN-PID";
#[must_use]
pub fn validate(
profile: &Profile,
segments: &[Segment<'_>],
pid: Option<u32>,
) -> Vec<ValidationIssue> {
let structure = &profile.structure;
let res = structure.resolve(segments);
let mut issues = Vec::new();
let selected = select_anwendungsfall(profile, segments, pid);
let af = match &selected {
Selected::Some(af) => Some(*af),
_ => None,
};
mig_checks(structure, &res, segments, af, &mut issues);
match selected {
Selected::Some(af) => ahb_checks(profile, af, &res, segments, &mut issues),
Selected::UnknownPid(p) => issues.push(
ValidationIssue::new(
ValidationSeverity::Warning,
format!(
"Prüfidentifikator {p} is not an Anwendungsfall of {} {} — AHB rules were not applied",
profile.mig.message_type, profile.mig.release
),
)
.with_rule_id(AHB_UNKNOWN_PID)
.with_context_entry("pid", p.to_string()),
),
Selected::None => match best_fit(profile, &res, segments) {
Some(af) => ahb_checks(profile, af, &res, segments, &mut issues),
None => issues.push(
ValidationIssue::new(
ValidationSeverity::Warning,
"no Anwendungsfall could be selected: the message carries no Prüfidentifikator and its BGM matches no column — AHB rules were not applied",
)
.with_rule_id(AHB_SKIP_NO_PID),
),
},
}
issues
}
enum Selected<'p> {
Some(&'p Anwendungsfall),
UnknownPid(u32),
None,
}
fn column_key(profile: &Profile, af: &Anwendungsfall) -> String {
af.pid.map_or_else(
|| {
let index = profile
.ahb
.anwendungsfaelle
.iter()
.position(|a| std::ptr::eq(a, af))
.unwrap_or(0);
format!("col{}", index + 1)
},
|p| p.to_string(),
)
}
fn select_anwendungsfall<'p>(
profile: &'p Profile,
segments: &[Segment<'_>],
pid: Option<u32>,
) -> Selected<'p> {
if let Some(p) = pid {
return match profile.anwendungsfall(p) {
Some(af) => Selected::Some(af),
None if profile.mig.pid_exempt
|| profile.ahb.anwendungsfaelle.iter().all(|a| a.pid.is_none()) =>
{
by_bgm(profile, segments)
}
None => Selected::UnknownPid(p),
};
}
by_bgm(profile, segments)
}
fn by_bgm<'p>(profile: &'p Profile, segments: &[Segment<'_>]) -> Selected<'p> {
let bgm_code = segments
.iter()
.find(|s| s.tag == "BGM")
.and_then(|s| s.component_str(0, 0))
.unwrap_or("");
let bgm_nr = profile
.structure
.layouts
.iter()
.find(|l| l.tag == "BGM")
.map(|l| l.nr.clone())
.unwrap_or_default();
let candidates: Vec<&Anwendungsfall> = profile
.ahb
.anwendungsfaelle
.iter()
.filter(|af| {
af.element_rules(&bgm_nr)
.filter(|e| e.de == "1001")
.any(|e| {
e.operands
.iter()
.any(|o| o.code.as_deref() == Some(bgm_code))
})
})
.collect();
match candidates.as_slice() {
[one] => Selected::Some(one),
[] if profile.ahb.anwendungsfaelle.len() == 1 => {
Selected::Some(&profile.ahb.anwendungsfaelle[0])
}
_ => Selected::None,
}
}
fn best_fit<'p>(
profile: &'p Profile,
res: &Resolution,
segments: &[Segment<'_>],
) -> Option<&'p Anwendungsfall> {
if !profile.mig.pid_exempt && profile.ahb.anwendungsfaelle.iter().any(|a| a.pid.is_some()) {
return None;
}
profile
.ahb
.anwendungsfaelle
.iter()
.map(|af| {
let mut found = Vec::new();
ahb_checks(profile, af, res, segments, &mut found);
(
found
.iter()
.filter(|i| i.severity == ValidationSeverity::Error)
.count(),
af,
)
})
.min_by_key(|(n, _)| *n)
.map(|(_, af)| af)
}
#[allow(clippy::too_many_lines)]
fn mig_checks(
structure: &Structure,
res: &Resolution,
segments: &[Segment<'_>],
af: Option<&Anwendungsfall>,
issues: &mut Vec<ValidationIssue>,
) {
for &i in &res.unresolved {
let seg = &segments[i];
let expected: Vec<String> = candidates_for(structure, &seg.tag);
let hint = if expected.is_empty() {
format!("the MIG defines no place for a {} segment", seg.tag)
} else {
format!("the MIG's {} places are {}", seg.tag, expected.join(", "))
};
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment {} (message segment {i}) fits no place of the Nachrichtenstruktur from here on — out of order, in a wrong group, or with qualifiers no place admits; {hint}",
seg.tag
),
)
.with_rule_id("MIG-STRUCTURE")
.with_segment(seg.tag.to_string())
.with_segment_occurrence(u16::try_from(i).unwrap_or(u16::MAX))
.with_span(seg.span),
);
}
for (inst_id, inst) in res.instances.iter().enumerate() {
let children: &[NodeId] = match inst.node {
Some(n) => &structure.nodes[n].children,
None => &structure.root,
};
for &child in children {
let node = &structure.nodes[child];
match &node.kind {
Kind::Segment { nr, tag, .. } => {
let count = res.count(inst_id, child);
let required = node.status == "M"
|| (node.status == "R"
&& af.is_none_or(|a| a.segment_status(nr).is_some()));
if required && count == 0 {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment {tag} „{}“ (Nr {nr}) is mandatory in the MIG{} but missing",
node.name,
in_group(structure, inst.node)
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-REQUIRED"))
.with_segment(tag.clone())
.with_context_entry("nr", nr.clone()),
);
}
if node.max > 0 && count > node.max as usize {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment {tag} „{}“ (Nr {nr}) occurs {count} times{}; the MIG allows {}",
node.name,
in_group(structure, inst.node),
node.max
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-MAX"))
.with_segment(tag.clone()),
);
}
}
Kind::Group { group } => {
let count = res.group_count(inst_id, child);
let trigger = structure.trigger(child);
let trigger_nr = trigger.and_then(|t| structure.nr(t)).unwrap_or("?");
let required = node.status == "M"
|| (node.status == "R"
&& af.is_none_or(|a| {
a.group_status(group, trigger_nr)
.or_else(|| a.segment_status(trigger_nr))
.is_some()
}));
if required && count == 0 {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment group {group} „{}“ (Nr {trigger_nr}) is mandatory in the MIG{} but missing",
node.name,
in_group(structure, inst.node)
),
)
.with_rule_id(format!("MIG-{group}-{trigger_nr}-REQUIRED"))
.with_segment_group(group.clone())
.with_context_entry("nr", trigger_nr.to_owned()),
);
}
if node.max > 0 && count > node.max as usize {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment group {group} „{}“ (Nr {trigger_nr}) occurs {count} times{}; the MIG allows {}",
node.name,
in_group(structure, inst.node),
node.max
),
)
.with_rule_id(format!("MIG-{group}-{trigger_nr}-MAX"))
.with_segment_group(group.clone()),
);
}
}
}
}
}
for (i, seg) in segments.iter().enumerate() {
let Some(a) = res.assigned[i] else { continue };
let Some(layout) = structure.layout(a.node) else {
continue;
};
element_checks(layout, seg, i, af, issues);
}
}
fn in_group(structure: &Structure, node: Option<NodeId>) -> String {
match node {
Some(n) => format!(" in {}", structure.group(n).unwrap_or("?")),
None => String::new(),
}
}
fn candidates_for(structure: &Structure, tag: &str) -> Vec<String> {
structure
.nodes
.iter()
.filter_map(|n| match &n.kind {
Kind::Segment {
nr,
tag: t,
discriminators,
..
} if t == tag => {
let codes: Vec<&str> = discriminators
.first()
.map(|d| d.codes.iter().map(String::as_str).collect())
.unwrap_or_default();
Some(if codes.is_empty() {
format!("{nr} „{}“", n.name)
} else {
format!("{nr} „{}“ ({}+{})", n.name, tag, codes.join("/"))
})
}
_ => None,
})
.take(12)
.collect()
}
#[allow(clippy::too_many_lines)]
fn element_checks(
layout: &SegmentNode,
seg: &Segment<'_>,
index: usize,
af: Option<&Anwendungsfall>,
issues: &mut Vec<ValidationIssue>,
) {
let nr = &layout.nr;
let tag = &layout.tag;
let listed = |el: &Element| {
af.is_none_or(|a| {
a.element_rules(nr)
.any(|r| r.de == el.id || el.components.iter().any(|c| c.id == r.de))
})
};
let mut checked_positions = 0usize;
for (ei, el) in layout.elements.iter().enumerate() {
checked_positions = ei + 1;
if el.components.is_empty() {
check_leaf(el, seg, ei, 0, nr, tag, index, true, listed(el), issues);
} else {
let present = (0..el.components.len())
.any(|ci| seg.component_str(ei, ci).is_some_and(|v| !v.is_empty()));
let required = el.status == "M" || (el.status == "R" && listed(el));
if required && !present {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): composite {} „{}“ is mandatory in the MIG but empty",
el.id, el.name
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-REQUIRED", el.id))
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX)),
);
}
if el.status == "N" && present {
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): composite {} „{}“ is not used in the MIG but filled",
el.id, el.name
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-NOTUSED", el.id))
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX)),
);
}
for (ci, comp) in el.components.iter().enumerate() {
check_leaf(
comp,
seg,
ei,
ci,
nr,
tag,
index,
present,
listed(comp),
issues,
);
}
if let Some(element) = seg.get_element(ei)
&& element.components().count() > el.components.len()
&& element
.components()
.skip(el.components.len())
.any(|c| !c.is_empty())
{
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): composite {} carries more components than the MIG defines ({})",
el.id,
el.components.len()
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-EXTRA", el.id))
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX)),
);
}
}
}
if seg.elements.len() > checked_positions
&& seg.elements[checked_positions..]
.iter()
.any(|e| e.components().any(|c| !c.is_empty()))
{
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): {} elements on the wire, the MIG defines {}",
seg.elements.len(),
layout.elements.len()
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-EXTRA"))
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX)),
);
}
}
#[allow(clippy::too_many_arguments)]
fn check_leaf(
el: &Element,
seg: &Segment<'_>,
ei: usize,
ci: usize,
nr: &str,
tag: &str,
index: usize,
composite_used: bool,
listed: bool,
issues: &mut Vec<ValidationIssue>,
) {
let value = seg.component_str(ei, ci).unwrap_or("");
let at = |issue: ValidationIssue| {
issue
.with_context_entry("nr", nr.to_owned())
.with_context_entry("de", el.id.clone())
.with_segment(tag.to_owned())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX))
.with_component_index(u8::try_from(ci).unwrap_or(u8::MAX))
};
if value.is_empty() {
if composite_used && (el.status == "M" || (el.status == "R" && listed)) {
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is mandatory in the MIG but empty",
el.id, el.name
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-REQUIRED", el.id))));
}
return;
}
if el.status == "N" {
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is not used in the MIG but carries {value:?}",
el.id, el.name
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-NOTUSED", el.id))));
return;
}
if let Some(format) = &el.format
&& let Some(problem) = format_problem(format, value)
{
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is {format} in the MIG; {value:?} {problem}",
el.id, el.name
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-FORMAT", el.id))));
}
if el.is_code_list() && !el.codes.iter().any(|c| c.code == value) {
let admitted: Vec<&str> = el.codes.iter().map(|c| c.code.as_str()).collect();
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is {value:?}; the MIG admits {}",
el.id,
el.name,
admitted.join(", ")
),
)
.with_rule_id(format!("MIG-{nr}-{tag}-{}-CODE", el.id))));
}
}
fn format_problem(format: &str, value: &str) -> Option<String> {
let (kind, rest) = if let Some(r) = format.strip_prefix("an") {
("an", r)
} else if let Some(r) = format.strip_prefix('a') {
("a", r)
} else if let Some(r) = format.strip_prefix('n') {
("n", r)
} else {
return None;
};
let (variable, len) = match rest.strip_prefix("..") {
Some(l) => (true, l),
None => (false, rest),
};
let len: usize = len.parse().ok()?;
let count = if kind == "n" {
value.chars().filter(char::is_ascii_digit).count()
} else {
value.chars().count()
};
if kind == "n"
&& !value
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, '-' | '.' | ',' | 'E' | 'e'))
{
return Some("is not numeric".into());
}
if kind == "a" && value.chars().any(|c| c.is_ascii_digit()) {
return Some("contains digits".into());
}
if count > len {
return Some(format!("is {count} characters long"));
}
if !variable && count != len {
return Some(format!("is {count} characters long, not {len}"));
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Required,
Optional,
Forbidden,
}
struct Ctx<'a, 'd> {
structure: &'a Structure,
res: &'a Resolution,
segments: &'a [Segment<'d>],
conditions: &'a std::collections::BTreeMap<String, String>,
packages: std::collections::BTreeMap<&'a str, Result<Option<Expr>, ExprError>>,
expanding: std::cell::RefCell<Vec<String>>,
}
impl<'a, 'd> Ctx<'a, 'd> {
fn new(
structure: &'a Structure,
res: &'a Resolution,
segments: &'a [Segment<'d>],
ahb: &'a super::model::AhbProfile,
) -> Self {
let packages = ahb
.packages
.iter()
.map(|(id, text)| {
let parsed = if text.trim().is_empty() {
Ok(None)
} else {
Expr::parse(text).map(Some)
};
(id.as_str(), parsed)
})
.collect();
Self {
structure,
res,
segments,
conditions: &ahb.conditions,
packages,
expanding: std::cell::RefCell::new(Vec::new()),
}
}
fn eval(&self, expr: &Expr, instance: InstanceId) -> Result<Truth, EvalError> {
expr.eval(&mut |id| self.truth(id, instance))
}
fn truth_of(&self, status: &Status, instance: InstanceId) -> Truth {
match status.expr.as_ref().map(|e| self.eval(e, instance)) {
None => Truth::True,
Some(Ok(t)) => t,
Some(Err(_)) => Truth::Unknown,
}
}
fn paket_applies(&self, id: &str, instance: InstanceId) -> Truth {
match self.paket_truth(id, instance) {
Ok(t) => t,
Err(_) => Truth::Unknown,
}
}
fn paket_truth(&self, cited: &str, instance: InstanceId) -> Result<Truth, EvalError> {
let Some(paket) = Paket::parse(cited) else {
return Ok(Truth::Unknown);
};
let Some(entry) = self.packages.get(paket.id.as_str()) else {
return Ok(Truth::Unknown);
};
let expr = match entry {
Ok(None) => return Ok(Truth::True),
Ok(Some(e)) => e,
Err(e) => return Err(EvalError::PaketExpression(paket.id, e.clone())),
};
if self.expanding.borrow().contains(&paket.id) {
return Err(EvalError::PaketCycle(paket.id));
}
self.expanding.borrow_mut().push(paket.id);
let out = self.eval(expr, instance);
self.expanding.borrow_mut().pop();
out
}
fn truth(&self, id: &str, instance: InstanceId) -> Result<Truth, EvalError> {
match ConditionKind::of(id) {
ConditionKind::Voraussetzung => {}
ConditionKind::Paket => return self.paket_truth(id, instance),
ConditionKind::Wiederholbarkeit => {
let Some(text) = self.conditions.get(id) else {
return Ok(Truth::Neutral);
};
let Some(per) = super::conditions::ProSegment::parse(text) else {
return Ok(Truth::Neutral);
};
let range = match self.res.enclosing(self.structure, instance, "SG4") {
Some(i) => self.res.instances[i].first..self.res.instances[i].last,
None => 0..self.segments.len(),
};
let found = self.segments[range].iter().any(|s| per.pattern.matches(s));
return Ok(if found { Truth::True } else { Truth::Unknown });
}
_ => return Ok(Truth::Neutral),
}
let Some(text) = self.conditions.get(id) else {
return Ok(Truth::Unknown);
};
if !super::conditions::is_precondition(text) {
return Ok(Truth::Neutral);
}
let Some(v) = Voraussetzung::parse(text) else {
return Ok(Truth::Unknown);
};
let range = |scope: &Scope| -> std::ops::Range<usize> {
match scope {
Scope::Message => 0..self.segments.len(),
Scope::Group(g) => match self.res.enclosing(self.structure, instance, g) {
Some(i) => self.res.instances[i].first..self.res.instances[i].last,
None => 0..self.segments.len(),
},
}
};
Ok(match v {
Voraussetzung::Present {
scope,
pattern,
negate,
} => {
let found = self.segments[range(&scope)]
.iter()
.any(|s| pattern.matches(s));
Truth::from(found != negate)
}
Voraussetzung::Count {
scope,
pattern,
more_than,
} => {
let n = self.segments[range(&scope)]
.iter()
.filter(|s| pattern.matches(s))
.count();
Truth::from(n > more_than)
}
Voraussetzung::ElementValue {
scope,
tag,
de,
values,
negate,
suffix,
} => {
let r = range(&scope);
let found = (r.start..r.end).any(|i| {
let seg = &self.segments[i];
if seg.tag != tag {
return false;
}
let Some(a) = self.res.assigned[i] else {
return false;
};
let Some(layout) = self.structure.layout(a.node) else {
return false;
};
layout.locate(&de, 0).is_some_and(|(ei, ci, _)| {
seg.component_str(ei, ci).is_some_and(|v| {
values.iter().any(|want| {
if suffix {
v.len() >= 2 && v.ends_with(want.as_str())
} else {
v == want
}
})
})
})
});
Truth::from(found != negate)
}
})
}
fn requirement(&self, statuses: &[String], instance: InstanceId) -> Requirement {
let mut required = false;
let mut permitted = false;
let mut decided = false;
for text in statuses {
let Some(status) = Status::parse(text) else {
permitted = true;
continue;
};
if !status.kind.is_receiver_checkable() {
permitted = true;
continue;
}
match self.truth_of(&status, instance) {
Truth::True | Truth::Neutral => {
required = true;
permitted = true;
decided = true;
}
Truth::False => decided = true,
Truth::Unknown => permitted = true,
}
}
if required {
Requirement::Required
} else if permitted || !decided {
Requirement::Optional
} else {
Requirement::Forbidden
}
}
}
impl From<bool> for Truth {
fn from(b: bool) -> Self {
if b { Truth::True } else { Truth::False }
}
}
#[allow(clippy::too_many_lines)]
fn ahb_checks(
profile: &Profile,
af: &Anwendungsfall,
res: &Resolution,
segments: &[Segment<'_>],
issues: &mut Vec<ValidationIssue>,
) {
let structure = &profile.structure;
let ctx = Ctx::new(structure, res, segments, &profile.ahb);
let pid = column_key(profile, af);
let tag_issue = |issue: ValidationIssue| issue.with_context_entry("pid", pid.clone());
for (inst_id, inst) in res.instances.iter().enumerate() {
let children: &[NodeId] = match inst.node {
Some(n) => &structure.nodes[n].children,
None => &structure.root,
};
for &child in children {
let node = &structure.nodes[child];
match &node.kind {
Kind::Segment { nr, tag, .. } => {
let count = res.count(inst_id, child);
match af.segment_status(nr) {
None => {
if count > 0 {
issues.push(tag_issue(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} „{}“ (Nr {nr}) is not part of the Prüfschablone of {pid}{}",
node.name,
in_group(structure, inst.node)
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-NOT-PERMITTED"))
.with_segment(tag.clone())
.with_context_entry("nr", nr.clone()),
));
}
}
Some(statuses) => match ctx.requirement(statuses, inst_id) {
Requirement::Required if count == 0 => issues.push(tag_issue(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} „{}“ (Nr {nr}) is Muss for {pid}{} but missing — AHB status {}",
node.name,
in_group(structure, inst.node),
statuses.join(" | ")
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-MISSING"))
.with_segment(tag.clone())
.with_context_entry("nr", nr.clone()),
)),
Requirement::Forbidden if count > 0 => issues.push(tag_issue(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} „{}“ (Nr {nr}) is present but its Voraussetzung for {pid} is not met — AHB status {}",
node.name,
statuses.join(" | ")
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-NOT-PERMITTED"))
.with_segment(tag.clone())
.with_context_entry("nr", nr.clone()),
)),
_ => {}
},
}
}
Kind::Group { group } => {
let count = res.group_count(inst_id, child);
let Some(trigger) = structure.trigger(child) else {
continue;
};
let Some(trigger_nr) = structure.nr(trigger) else {
continue;
};
let statuses = af
.group_status(group, trigger_nr)
.or_else(|| af.segment_status(trigger_nr));
let Some(statuses) = statuses else { continue };
match ctx.requirement(statuses, inst_id) {
Requirement::Required if count == 0 => issues.push(tag_issue(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment group {group} „{}“ (Nr {trigger_nr}) is Muss for {pid}{} but missing — AHB status {}",
node.name,
in_group(structure, inst.node),
statuses.join(" | ")
),
)
.with_rule_id(format!("AHB-{pid}-{group}-{trigger_nr}-MISSING"))
.with_segment_group(group.clone())
.with_context_entry("nr", trigger_nr.to_owned()),
)),
Requirement::Forbidden if count > 0 => issues.push(tag_issue(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"segment group {group} „{}“ (Nr {trigger_nr}) is present but its Voraussetzung for {pid} is not met — AHB status {}",
node.name,
statuses.join(" | ")
),
)
.with_rule_id(format!("AHB-{pid}-{group}-{trigger_nr}-NOT-PERMITTED"))
.with_segment_group(group.clone())
.with_context_entry("nr", trigger_nr.to_owned()),
)),
_ => {}
}
}
}
}
}
for (i, seg) in segments.iter().enumerate() {
let Some(a) = res.assigned[i] else { continue };
let Some(layout) = structure.layout(a.node) else {
continue;
};
if af.segment_status(&layout.nr).is_none() {
continue;
}
element_rules(&ctx, af, &pid, layout, seg, i, a.instance, issues);
}
paket_checks(&ctx, af, &pid, issues);
}
fn paket_checks(
ctx: &Ctx<'_, '_>,
af: &Anwendungsfall,
pid: &str,
issues: &mut Vec<ValidationIssue>,
) {
let mut places: Vec<((InstanceId, NodeId), InstanceId)> = Vec::new();
for assigned in ctx.res.assigned.iter().flatten() {
let key = (paket_scope(ctx, *assigned), assigned.node);
if !places.iter().any(|(k, _)| *k == key) {
places.push((key, assigned.instance));
}
}
for ((scope, node), instance) in places {
let Some(layout) = ctx.structure.layout(node) else {
continue;
};
if af.segment_status(&layout.nr).is_none() {
continue;
}
let span = ctx.res.instances[scope].first..ctx.res.instances[scope].last;
let here: Vec<usize> = span
.filter(|&i| ctx.res.assigned[i].is_some_and(|a| a.node == node))
.collect();
for rule in af.element_rules(&layout.nr) {
let Some((ei, ci, el)) = layout.locate(&rule.de, rule.occurrence) else {
continue;
};
for op in &rule.operands {
let (Some(code), Some(status)) = (&op.code, Status::parse(&op.operand)) else {
continue;
};
let Some(expr) = &status.expr else { continue };
let truth = ctx.truth_of(&status, instance);
let count = here
.iter()
.filter(|&&i| ctx.segments[i].component_str(ei, ci) == Some(code.as_str()))
.count();
for paket in expr.pakete() {
if ctx.paket_applies(&paket.id, instance) != Truth::True {
continue;
}
let at = |issue: ValidationIssue| {
issue
.with_context_entry("pid", pid.to_owned())
.with_context_entry("nr", layout.nr.clone())
.with_context_entry("de", el.id.clone())
.with_segment(layout.tag.clone())
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX))
.with_component_index(u8::try_from(ci).unwrap_or(u8::MAX))
};
let where_ = in_group(ctx.structure, ctx.res.instances[scope].node);
if let Some(max) = paket.max
&& count > max
{
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{} (Nr {}): DE {} „{}“ carries {code:?} {count} times{where_}; Paket {} of {pid} allows {max}",
layout.tag, layout.nr, el.id, el.name, paket.id,
),
)
.with_rule_id(format!(
"AHB-{pid}-{}-{}-{}-PAKET-MAX",
layout.nr, layout.tag, el.id
))));
}
if count < paket.min
&& status.kind.is_receiver_checkable()
&& matches!(truth, Truth::True | Truth::Neutral)
{
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{} (Nr {}): DE {} „{}“ carries {code:?} {count} times{where_}; Paket {} of {pid} asks for {} — AHB operand {}",
layout.tag, layout.nr, el.id, el.name, paket.id, paket.min, op.operand,
),
)
.with_rule_id(format!(
"AHB-{pid}-{}-{}-{}-PAKET-MIN",
layout.nr, layout.tag, el.id
))));
}
}
}
}
}
}
fn paket_scope(ctx: &Ctx<'_, '_>, assigned: super::structure::Assigned) -> InstanceId {
let mut instance = assigned.instance;
let mut node = assigned.node;
loop {
if ctx.structure.nodes[node].max != 1 {
return instance;
}
let (Some(group), Some(parent)) = (
ctx.res.instances[instance].node,
ctx.res.instances[instance].parent,
) else {
return instance;
};
node = group;
instance = parent;
}
}
#[allow(clippy::too_many_arguments)]
fn element_rules(
ctx: &Ctx<'_, '_>,
af: &Anwendungsfall,
pid: &str,
layout: &SegmentNode,
seg: &Segment<'_>,
index: usize,
instance: InstanceId,
issues: &mut Vec<ValidationIssue>,
) {
let nr = &layout.nr;
let tag = &layout.tag;
let mut ruled: HashSet<(usize, usize)> = HashSet::new();
for rule in af.element_rules(nr) {
let Some((ei, ci, el)) = layout.locate(&rule.de, rule.occurrence) else {
continue;
};
ruled.insert((ei, ci));
let value = seg.component_str(ei, ci).unwrap_or("");
let (required, mut admitted, mut coded) = operands(ctx, rule, instance);
if !coded && el.is_code_list() {
coded = true;
admitted = el.codes.iter().map(|c| c.code.clone()).collect();
}
let at = |issue: ValidationIssue| {
issue
.with_context_entry("pid", pid.to_owned())
.with_context_entry("nr", nr.to_owned())
.with_context_entry("de", el.id.clone())
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX))
.with_component_index(u8::try_from(ci).unwrap_or(u8::MAX))
};
if value.is_empty() {
if required {
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is required by the Prüfschablone of {pid} but empty{}",
el.id,
el.name,
if coded { format!(" — admitted: {}", admitted.join(", ")) } else { String::new() }
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-{}-MISSING", el.id))));
}
continue;
}
if coded && !admitted.iter().any(|c| c == value) {
issues.push(at(ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ is {value:?}; the Prüfschablone of {pid} admits {}",
el.id,
el.name,
if admitted.is_empty() { "no code here".to_owned() } else { admitted.join(", ") }
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-{}-CODE", el.id))));
}
}
for (ei, ci, el) in layout.leaves() {
if ruled.contains(&(ei, ci)) || el.status == "N" {
continue;
}
let value = seg.component_str(ei, ci).unwrap_or("");
if value.is_empty() {
continue;
}
issues.push(
ValidationIssue::new(
ValidationSeverity::Error,
format!(
"{tag} (Nr {nr}): DE {} „{}“ carries {value:?} but is not part of the Prüfschablone of {pid}",
el.id, el.name
),
)
.with_rule_id(format!("AHB-{pid}-{nr}-{tag}-{}-NOT-PERMITTED", el.id))
.with_context_entry("pid", pid.to_owned())
.with_context_entry("nr", nr.to_owned())
.with_context_entry("de", el.id.clone())
.with_segment(tag.clone())
.with_segment_occurrence(u16::try_from(index).unwrap_or(u16::MAX))
.with_element_index(u8::try_from(ei).unwrap_or(u8::MAX))
.with_component_index(u8::try_from(ci).unwrap_or(u8::MAX)),
);
}
}
fn operands(
ctx: &Ctx<'_, '_>,
rule: &ElementRule,
instance: InstanceId,
) -> (bool, Vec<String>, bool) {
let mut required = false;
let mut admitted: Vec<String> = Vec::new();
let mut coded = false;
for op in &rule.operands {
let Some(status) = Status::parse(&op.operand) else {
if let Some(c) = &op.code {
coded = true;
admitted.push(c.clone());
}
continue;
};
let truth = ctx.truth_of(&status, instance);
let by_paket =
op.code.is_some() && status.expr.as_ref().is_some_and(|e| !e.pakete().is_empty());
let this_required = !by_paket
&& status.kind.is_receiver_checkable()
&& matches!(truth, Truth::True | Truth::Neutral);
let this_admitted = !(status.kind.is_receiver_checkable() && truth == Truth::False);
match &op.code {
Some(c) => {
coded = true;
if this_admitted {
admitted.push(c.clone());
}
if this_required {
required = true;
}
}
None => {
if this_required {
required = true;
}
}
}
}
(required, admitted, coded)
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(packages: &str, operands: &str) -> Profile {
let mig = r#"{
"schema_version": 2, "message_type": "UTILMD", "release": "S2.2",
"valid_from": "2026-10-01", "ahb_version": "2.2", "source": {"file": "x"},
"structure": [
{"nr":"00001","tag":"UNH","status":"M","max":1,"elements":[
{"id":"0062","status":"M","format":"an..14"},
{"id":"S009","status":"M","components":[{"id":"0065","status":"M","codes":[{"code":"UTILMD"}]}]}]},
{"nr":"00002","tag":"BGM","status":"M","max":1,"elements":[
{"id":"C002","status":"M","components":[{"id":"1001","status":"M","codes":[{"code":"E01"},{"code":"E03"}]}]}]},
{"group":"SG10","status":"R","max":9,"children":[
{"nr":"00003","tag":"CCI","status":"M","max":1,"elements":[
{"id":"7059","status":"N"},{"id":"C502","status":"N"},{"id":"C240","status":"R",
"components":[{"id":"7037","status":"R","codes":[{"code":"ZB4"}]}]}]},
{"nr":"00004","tag":"CAV","status":"R","max":3,"elements":[
{"id":"C889","status":"M","components":[
{"id":"7111","status":"R","codes":[{"code":"ZA1"},{"code":"ZA2"},{"code":"ZA3"}]}]}]}]},
{"nr":"00005","tag":"UNT","status":"M","max":1,"elements":[
{"id":"0074","status":"M"},{"id":"0062","status":"M"}]}
]
}"#;
let ahb = format!(
r#"{{
"schema_version": 2, "message_type": "UTILMD", "release": "S2.2",
"ahb_version": "2.2", "source": {{"file": "x"}},
"conditions": {{"1": "Wenn BGM+E03 (Änderungsmeldung) vorhanden"}},
"packages": {packages},
"anwendungsfaelle": [{{
"pid": 11111, "name": "Muster",
"rows": [{{"nr":"00001","status":["Muss"]}},{{"nr":"00002","status":["Muss"]}},
{{"group":"SG10","before":"00003","status":["Muss"]}},
{{"nr":"00003","status":["Muss"]}},{{"nr":"00004","status":["Muss"]}},
{{"nr":"00005","status":["Muss"]}}],
"elements": [{{"nr":"00004","de":"7111","operands": {operands}}}]
}}]
}}"#
);
Profile::from_json(mig, &ahb).expect("the test profile parses")
}
fn message(edi: &str) -> Vec<edifact_rs::OwnedSegment> {
edifact_rs::from_bytes(edi.as_bytes())
.map(|s| s.map(edifact_rs::Segment::into_owned))
.collect::<Result<Vec<_>, _>>()
.expect("the test message parses")
}
const ONE_CAV: &str = "UNH+1+UTILMD:D:11A:UN:S2.2'BGM+E01'CCI+++ZB4'CAV+ZA1'UNT+5+1'";
fn cav_findings(p: &Profile, edi: &str) -> Vec<String> {
validate(p, &message(edi), Some(11111))
.iter()
.filter_map(|i| i.rule_id().map(str::to_owned))
.filter(|r| r.contains("-00004-CAV-"))
.collect()
}
#[test]
fn a_paketvoraussetzung_decides_which_codes_a_column_admits() {
let p = profile(
r#"{"1P": "", "2P": "[1]"}"#,
r#"[{"code":"ZA1","operand":"X [1P0..1]"},{"code":"ZA2","operand":"X [2P0..1]"}]"#,
);
assert!(cav_findings(&p, ONE_CAV).is_empty());
assert_eq!(
cav_findings(
&p,
"UNH+1+UTILMD:D:11A:UN:S2.2'BGM+E01'CCI+++ZB4'CAV+ZA2'UNT+5+1'"
),
["AHB-11111-00004-CAV-7111-CODE"]
);
}
#[test]
fn a_paketmerkmal_counts_the_repetitions_of_its_code() {
let p = profile(
r#"{"1P": ""}"#,
r#"[{"code":"ZA1","operand":"X [1P0..1]"},{"code":"ZA2","operand":"X [1P1..1]"}]"#,
);
assert_eq!(
cav_findings(&p, ONE_CAV),
["AHB-11111-00004-CAV-7111-PAKET-MIN"]
);
assert!(
cav_findings(
&p,
"UNH+1+UTILMD:D:11A:UN:S2.2'BGM+E01'CCI+++ZB4'CAV+ZA1'CAV+ZA2'UNT+6+1'"
)
.is_empty()
);
assert_eq!(
cav_findings(
&p,
"UNH+1+UTILMD:D:11A:UN:S2.2'BGM+E01'CCI+++ZB4'CAV+ZA1'CAV+ZA1'CAV+ZA2'UNT+7+1'"
),
["AHB-11111-00004-CAV-7111-PAKET-MAX"]
);
}
#[test]
fn a_paketvoraussetzung_that_leads_back_to_its_own_paket_is_refused() {
let p = profile(
r#"{"1P": "[2P0..1]", "2P": "[1P0..1]"}"#,
r#"[{"code":"ZA1","operand":"X [1P1..1]"},{"code":"ZA2","operand":"X [2P0..1]"}]"#,
);
assert!(cav_findings(&p, ONE_CAV).is_empty());
}
#[test]
fn representations() {
assert_eq!(format_problem("an..35", "9900357000004"), None);
assert!(format_problem("an..3", "ABCD").is_some());
assert_eq!(format_problem("n11", "51238696781"), None);
assert!(format_problem("n11", "5123869678").is_some());
assert!(format_problem("n..6", "12a").is_some());
assert_eq!(format_problem("a1", "C"), None);
assert!(format_problem("a1", "1").is_some());
}
}