use anyhow::bail;
use rust_decimal::Decimal;
pub(crate) fn canonical_iban(s: &str) -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| c.to_ascii_uppercase())
.collect()
}
pub(crate) fn canonical_amount(amount: Decimal) -> String {
let mut normalized = amount.round_dp(2);
if normalized.is_zero() {
normalized = Decimal::ZERO;
}
normalized.rescale(2);
normalized.to_string()
}
pub(crate) fn is_zero_placeholder(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c == '0')
}
pub(crate) fn canonical_text(s: &str) -> String {
s.replace(['\r', '\n'], " ").trim().to_owned()
}
pub(crate) fn clean_optional(value: Option<String>) -> Option<String> {
value.map(|s| canonical_text(&s)).filter(|s| !s.is_empty())
}
pub(crate) fn read_to_utf8(bytes: &[u8]) -> anyhow::Result<String> {
if bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
bail!("UTF-16 encoded files are not supported");
}
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
Ok(match std::str::from_utf8(bytes) {
Ok(s) => s.to_owned(),
Err(_) => decode_windows_1252(bytes),
})
}
fn decode_windows_1252(bytes: &[u8]) -> String {
bytes.iter().map(|&byte| windows_1252_char(byte)).collect()
}
fn windows_1252_char(byte: u8) -> char {
match byte {
0x80 => '€',
0x82 => '‚',
0x83 => 'ƒ',
0x84 => '„',
0x85 => '…',
0x86 => '†',
0x87 => '‡',
0x88 => 'ˆ',
0x89 => '‰',
0x8A => 'Š',
0x8B => '‹',
0x8C => 'Œ',
0x8E => 'Ž',
0x91 => '‘',
0x92 => '’',
0x93 => '“',
0x94 => '”',
0x95 => '•',
0x96 => '–',
0x97 => '—',
0x98 => '˜',
0x99 => '™',
0x9A => 'š',
0x9B => '›',
0x9C => 'œ',
0x9E => 'ž',
0x9F => 'Ÿ',
_ => char::from(byte),
}
}
pub(crate) mod german_number {
use std::str::FromStr as _;
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer};
fn normalize_german_number(s: &str) -> String {
s.trim().replace([' ', '.'], "").replace(',', ".")
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
let norm = normalize_german_number(&raw);
Decimal::from_str(&norm).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_all_zero_values_are_placeholders() {
assert!(is_zero_placeholder("0"));
assert!(is_zero_placeholder("0000000000"));
assert!(!is_zero_placeholder(""));
assert!(!is_zero_placeholder("0123456789"));
assert!(!is_zero_placeholder("DE44500105175407324931"));
}
#[test]
fn zero_amount_is_canonicalized() {
assert_eq!(canonical_amount(Decimal::ZERO), "0.00");
assert_eq!(canonical_amount("-0.00".parse().unwrap()), "0.00");
assert_eq!(canonical_amount(Decimal::new(-350, 2)), "-3.50");
}
#[test]
fn utf8_passes_through_and_bom_is_stripped() {
assert_eq!(read_to_utf8("Bäckerei".as_bytes()).unwrap(), "Bäckerei");
assert_eq!(
read_to_utf8(b"\xEF\xBB\xBFB\xC3\xA4ckerei").unwrap(),
"Bäckerei"
);
}
#[test]
fn windows_1252_is_decoded() {
assert_eq!(
read_to_utf8(b"B\xE4ckerei M\xFCller").unwrap(),
"Bäckerei Müller"
);
assert_eq!(read_to_utf8(b"5 \x80").unwrap(), "5 €");
}
#[test]
fn utf16_is_rejected() {
assert!(read_to_utf8(b"\xFF\xFEB\x00").is_err());
}
}