use crate::charset::{Transliteration, first_invalid_char, transliterate};
pub const MAX_ID_LEN: usize = 35;
pub const MAX_NAME_LEN: usize = 70;
pub const MAX_REMITTANCE_LEN: usize = 140;
pub const MAX_CODE_LEN: usize = 4;
pub const MAX_BUILDING_LEN: usize = 16;
pub const MAX_STRUCTURED_REMITTANCE_LEN: usize = 140;
pub const MAX_ADDITIONAL_INFO_LEN: usize = 105;
pub const MIN_AMOUNT_CT: i64 = 1;
pub const MAX_AMOUNT_CT: i64 = 99_999_999_999;
pub const MAX_CTRL_SUM_CT: i64 = 999_999_999_999_999_999;
#[allow(
clippy::match_same_arms,
reason = "grouped by element kind, not by the coincident bound"
)]
#[must_use]
pub fn max_text_len(parent: Option<&str>, element: &str) -> Option<usize> {
if let Some(parent) = parent {
match (parent, element) {
("SvcLvl" | "CtgyPurp" | "Purp" | "Rsn" | "CdOrPrtry", "Cd") => {
return Some(MAX_CODE_LEN);
}
("LclInstrm", "Cd") => return Some(MAX_ID_LEN),
("Othr", "Id") => return Some(MAX_ID_LEN),
("Assgnmt" | "Case" | "RslvdCase", "Id") => return Some(MAX_ID_LEN),
("SchmeNm" | "Rsn", "Prtry") => return Some(MAX_ID_LEN),
("CdtrRefInf", "Ref") | ("Tp", "Issr") => return Some(MAX_ID_LEN),
_ => {}
}
}
match element {
"Nm" => Some(MAX_NAME_LEN),
"Ustrd" => Some(MAX_REMITTANCE_LEN),
"InstrInf" => Some(MAX_REMITTANCE_LEN),
"MsgId" | "PmtInfId" | "EndToEndId" | "MndtId" | "OrgnlMndtId" | "OrgnlMsgId"
| "OrgnlMsgNmId" | "OrgnlPmtInfId" | "OrgnlEndToEndId" | "RvslPmtInfId" | "CxlId"
| "PmtCxlId" | "OrgnlInstrId" | "GrpCxlId" => Some(MAX_ID_LEN),
"Dept" | "SubDept" | "StrtNm" | "AdrLine" => Some(MAX_NAME_LEN),
"TwnNm" | "CtrySubDvsn" => Some(MAX_ID_LEN),
"BldgNb" | "PstCd" => Some(MAX_BUILDING_LEN),
"AddtlInf" => Some(MAX_ADDITIONAL_INFO_LEN),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ValidationError {
#[error("{field} contains an unknown or non-normalized country code {code:?}")]
UnknownCountry {
field: &'static str,
code: String,
},
#[error("batch contains no transactions: at least one is required")]
EmptyBatch,
#[error("{field} must not be empty")]
Empty {
field: &'static str,
},
#[error("{field} is {actual} characters, exceeding the maximum of {max}")]
TooLong {
field: &'static str,
max: usize,
actual: usize,
},
#[error("{field} occurs {actual} times, exceeding the maximum of {max}")]
TooMany {
field: &'static str,
max: usize,
actual: usize,
},
#[error("{field} contains {ch:?}, which is not in the SEPA character set")]
InvalidCharacter {
field: &'static str,
ch: char,
},
#[error("{field} contains {ch:?}, which is not a legal XML 1.0 character")]
InvalidXmlCharacter {
field: &'static str,
ch: char,
},
#[error("{field} must not start or end with '/' nor contain '//' (EPC230-15)")]
SlashRule {
field: &'static str,
},
#[error("{field} is {amount_ct} ct, outside the permitted 1 – {MAX_AMOUNT_CT} ct")]
AmountOutOfRange {
field: &'static str,
amount_ct: i64,
},
#[error("charge bearer {bearer} is not allowed under service level {scheme}")]
ChargeBearerNotAllowed {
bearer: &'static str,
scheme: &'static str,
},
#[error(
"batch control sum exceeds the CtrlSum bound of {} EUR (DecimalNumber, totalDigits=18)",
crate::ct_to_eur_str(MAX_CTRL_SUM_CT)
)]
ControlSumOverflow,
#[error("{field} must be set at either payment-information or transaction level, not both")]
ConflictingLevels {
field: &'static str,
},
#[error("{field}: {first} and {second} are alternatives — set one, not both")]
MutuallyExclusive {
field: &'static str,
first: &'static str,
second: &'static str,
},
#[error("{feature} is not available in {schema}")]
UnsupportedBySchema {
feature: &'static str,
schema: &'static str,
},
#[error("{later} {later_value} must not be after {earlier} {earlier_value}")]
DateOrder {
later: &'static str,
later_value: crate::IsoDate,
earlier: &'static str,
earlier_value: crate::IsoDate,
},
#[error("{feature} requires {requires}")]
Requires {
feature: &'static str,
requires: &'static str,
},
#[error("{field} {value:?} does not match {expected}, the pattern {schema} uses")]
SchemaPattern {
field: &'static str,
value: String,
schema: &'static str,
expected: &'static str,
},
#[error("{field} {value:?} is used by more than one group — it must be unique in a message")]
Duplicate {
field: &'static str,
value: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown schema {value:?}: expected one of {supported}")]
pub struct UnknownSchema {
pub value: String,
pub supported: &'static str,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Location {
pub group: Option<usize>,
pub transaction: Option<usize>,
}
impl Location {
#[must_use]
pub const fn message() -> Self {
Self {
group: None,
transaction: None,
}
}
#[must_use]
pub const fn group(index: usize) -> Self {
Self {
group: Some(index),
transaction: None,
}
}
#[must_use]
pub const fn transaction(group: usize, transaction: usize) -> Self {
Self {
group: Some(group),
transaction: Some(transaction),
}
}
}
impl std::fmt::Display for Location {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.group, self.transaction) {
(None, _) => f.write_str("message"),
(Some(g), None) => write!(f, "PmtInf[{g}]"),
(Some(g), Some(t)) => write!(f, "PmtInf[{g}]/Tx[{t}]"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{location}: {kind}")]
pub struct BuildError {
pub location: Location,
#[source]
pub kind: ValidationError,
}
impl BuildError {
#[must_use]
pub const fn message(kind: ValidationError) -> Self {
Self {
location: Location::message(),
kind,
}
}
#[must_use]
pub const fn group(index: usize, kind: ValidationError) -> Self {
Self {
location: Location::group(index),
kind,
}
}
#[must_use]
pub const fn transaction(group: usize, transaction: usize, kind: ValidationError) -> Self {
Self {
location: Location::transaction(group, transaction),
kind,
}
}
}
pub(crate) trait Locate<T> {
fn at(self, location: Location) -> Result<T, BuildError>;
}
impl<T> Locate<T> for Result<T, ValidationError> {
fn at(self, location: Location) -> Result<T, BuildError> {
self.map_err(|kind| BuildError { location, kind })
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WriteError {
#[error(transparent)]
Validation(#[from] BuildError),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum CharsetPolicy {
Transliterate(Transliteration),
Strict,
}
impl CharsetPolicy {
pub fn apply<'a>(
self,
field: &'static str,
text: &'a str,
) -> Result<std::borrow::Cow<'a, str>, ValidationError> {
match self {
Self::Transliterate(style) => Ok(transliterate(text, style)),
Self::Strict => match first_invalid_char(text) {
Some(ch) => Err(ValidationError::InvalidCharacter { field, ch }),
None => Ok(std::borrow::Cow::Borrowed(text)),
},
}
}
pub(crate) fn render(self, text: &str) -> std::borrow::Cow<'_, str> {
match self {
Self::Transliterate(style) => transliterate(text, style),
Self::Strict => std::borrow::Cow::Borrowed(text),
}
}
}
impl Default for CharsetPolicy {
fn default() -> Self {
Self::Transliterate(Transliteration::German)
}
}
pub fn check_id(field: &'static str, value: &str) -> Result<(), ValidationError> {
check_len(field, value, MAX_ID_LEN)?;
if let Some(ch) = first_invalid_char(value) {
return Err(ValidationError::InvalidCharacter { field, ch });
}
let trimmed = value.trim();
if trimmed.starts_with('/') || trimmed.ends_with('/') || trimmed.contains("//") {
return Err(ValidationError::SlashRule { field });
}
Ok(())
}
pub fn check_name(field: &'static str, value: &str) -> Result<(), ValidationError> {
check_len(field, value, MAX_NAME_LEN)
}
pub fn check_remittance(field: &'static str, value: &str) -> Result<(), ValidationError> {
check_len(field, value, MAX_REMITTANCE_LEN)
}
pub fn check_text(field: &'static str, value: &str, max: usize) -> Result<(), ValidationError> {
check_len(field, value, max)
}
fn check_len(field: &'static str, value: &str, max: usize) -> Result<(), ValidationError> {
if let Some(ch) = value
.chars()
.find(|character| !crate::xml::is_xml_character(*character))
{
return Err(ValidationError::InvalidXmlCharacter { field, ch });
}
if value.trim().is_empty() {
return Err(ValidationError::Empty { field });
}
let actual = value.chars().count();
if actual > max {
return Err(ValidationError::TooLong { field, max, actual });
}
Ok(())
}
pub fn check_amount(field: &'static str, amount_ct: i64) -> Result<(), ValidationError> {
if !(MIN_AMOUNT_CT..=MAX_AMOUNT_CT).contains(&amount_ct) {
return Err(ValidationError::AmountOutOfRange { field, amount_ct });
}
Ok(())
}
pub fn accumulate_control_sum(total: i64, amount_ct: i64) -> Result<i64, ValidationError> {
total
.checked_add(amount_ct)
.filter(|sum| *sum <= MAX_CTRL_SUM_CT)
.ok_or(ValidationError::ControlSumOverflow)
}
#[must_use]
pub fn truncate_chars(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((byte_idx, _)) => &s[..byte_idx],
None => s,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_length_counts_the_untrimmed_value_that_writers_emit() {
assert!(check_text("Text", " A ", 3).is_ok());
assert_eq!(
check_text("Text", " A ", 2),
Err(ValidationError::TooLong {
field: "Text",
max: 2,
actual: 3
})
);
assert!(check_text("Text", "\tA\n", 2).is_err());
assert!(check_text("Text", " ", 3).is_err());
}
#[test]
fn text_refuses_xml_forbidden_characters_without_rewriting() {
for character in [
'\0', '\u{1}', '\u{b}', '\u{c}', '\u{1f}', '\u{fffe}', '\u{ffff}',
] {
let text = format!("A{character}B");
assert_eq!(
check_text("Text", &text, 3),
Err(ValidationError::InvalidXmlCharacter {
field: "Text",
ch: character
})
);
}
for character in [
'\t',
'\n',
'\r',
'\u{20}',
'\u{d7ff}',
'\u{e000}',
'\u{fffd}',
'\u{10000}',
'\u{10ffff}',
] {
let text = format!("A{character}B");
assert!(check_text("Text", &text, 3).is_ok(), "{character:?}");
}
}
#[test]
fn ids_respect_length_and_slash_rules() {
assert!(check_id("MsgId", "DD-2026-07-001").is_ok());
assert!(check_id("MsgId", &"X".repeat(35)).is_ok());
assert_eq!(
check_id("MsgId", &"X".repeat(36)),
Err(ValidationError::TooLong {
field: "MsgId",
max: 35,
actual: 36
})
);
assert_eq!(
check_id("MsgId", ""),
Err(ValidationError::Empty { field: "MsgId" })
);
assert_eq!(
check_id("MsgId", " "),
Err(ValidationError::Empty { field: "MsgId" })
);
for bad in ["/leading", "trailing/", "double//slash"] {
assert_eq!(
check_id("EndToEndId", bad),
Err(ValidationError::SlashRule {
field: "EndToEndId"
}),
"{bad:?} must violate the slash rule"
);
}
assert!(check_id("EndToEndId", "INV/2026/07").is_ok());
}
#[test]
fn lengths_are_counted_in_characters_not_bytes() {
let name = "ä".repeat(70);
assert_eq!(name.len(), 140);
assert!(check_name("Nm", &name).is_ok());
assert!(check_name("Nm", &"ä".repeat(71)).is_err());
}
#[test]
fn name_and_remittance_limits() {
assert!(check_name("Cdtr/Nm", &"A".repeat(70)).is_ok());
assert!(check_name("Cdtr/Nm", &"A".repeat(71)).is_err());
assert!(check_remittance("Ustrd", &"A".repeat(140)).is_ok());
assert!(check_remittance("Ustrd", &"A".repeat(141)).is_err());
}
#[test]
fn amount_range_matches_the_epc_rule() {
assert!(check_amount("InstdAmt", 1).is_ok());
assert!(check_amount("InstdAmt", MAX_AMOUNT_CT).is_ok());
for bad in [0, -1, MAX_AMOUNT_CT + 1, i64::MIN, i64::MAX] {
assert_eq!(
check_amount("InstdAmt", bad),
Err(ValidationError::AmountOutOfRange {
field: "InstdAmt",
amount_ct: bad
}),
"{bad} must be rejected"
);
}
}
#[test]
fn sct_inst_100k_cap_is_not_enforced() {
assert!(check_amount("InstdAmt", 25_000_000).is_ok());
}
#[test]
fn identifiers_reject_non_sepa_characters_under_every_policy() {
for field in ["MsgId", "EndToEndId", "MndtId"] {
assert_eq!(
check_id(field, "MND-Straße"),
Err(ValidationError::InvalidCharacter { field, ch: 'ß' })
);
}
assert!(check_id("MsgId", "MND-Strasse-01").is_ok());
}
#[test]
fn slash_rule_survives_whitespace_padding() {
assert_eq!(
check_id("MsgId", " /x "),
Err(ValidationError::SlashRule { field: "MsgId" })
);
assert_eq!(
check_id("MsgId", " x/ "),
Err(ValidationError::SlashRule { field: "MsgId" })
);
}
#[test]
fn locations_read_as_element_paths() {
assert_eq!(Location::message().to_string(), "message");
assert_eq!(Location::group(0).to_string(), "PmtInf[0]");
assert_eq!(Location::transaction(1, 42).to_string(), "PmtInf[1]/Tx[42]");
}
#[test]
fn build_errors_carry_both_the_rule_and_the_position() {
let err = BuildError::transaction(
2,
7,
ValidationError::AmountOutOfRange {
field: "InstdAmt",
amount_ct: 0,
},
);
assert_eq!(err.location, Location::transaction(2, 7));
assert!(err.to_string().starts_with("PmtInf[2]/Tx[7]: "));
assert!(matches!(err.kind, ValidationError::AmountOutOfRange { .. }));
}
#[test]
fn strict_policy_rejects_non_sepa_characters() {
let policy = CharsetPolicy::Strict;
assert_eq!(policy.apply("Nm", "Mueller").unwrap(), "Mueller");
assert_eq!(
policy.apply("Nm", "Müller"),
Err(ValidationError::InvalidCharacter {
field: "Nm",
ch: 'ü'
})
);
}
#[test]
fn default_policy_transliterates() {
let policy = CharsetPolicy::default();
assert_eq!(policy.apply("Nm", "Müller & Co").unwrap(), "Mueller + Co");
}
#[test]
fn truncation_never_splits_a_character() {
let s = format!("{}ü", "A".repeat(139));
assert_eq!(s.len(), 141, "141 bytes, 140 characters");
let out = truncate_chars(&s, 140);
assert_eq!(out.chars().count(), 140);
assert!(out.ends_with('ü'), "the whole character must survive");
let s2 = format!("{}ü", "A".repeat(140));
let out2 = truncate_chars(&s2, 140);
assert_eq!(out2, "A".repeat(140));
}
#[test]
fn truncation_is_a_no_op_when_within_limit() {
assert_eq!(truncate_chars("short", 140), "short");
assert_eq!(truncate_chars("", 140), "");
}
#[test]
fn render_is_apply_without_the_error() {
assert_eq!(
CharsetPolicy::default().render("Müller & Co"),
"Mueller + Co"
);
assert_eq!(CharsetPolicy::Strict.render("Mueller"), "Mueller");
}
#[test]
fn epc_policy_transliterates_one_to_one() {
let policy = CharsetPolicy::Transliterate(Transliteration::Epc);
assert_eq!(policy.apply("Nm", "Müller & Co").unwrap(), "Muller + Co");
}
}