use std::str::FromStr;
use crate::validate::ValidationError;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PurposeCodeError {
#[error("purpose code {code:?} must be 1–4 characters")]
InvalidLength {
code: String,
},
#[error("purpose code {code:?} must be alphanumeric")]
InvalidCharacter {
code: String,
},
}
fn check_code(code: &str) -> Result<String, PurposeCodeError> {
let upper = code.trim().to_ascii_uppercase();
if upper.is_empty() || upper.chars().count() > 4 {
return Err(PurposeCodeError::InvalidLength { code: upper });
}
if !upper.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(PurposeCodeError::InvalidCharacter { code: upper });
}
Ok(upper)
}
macro_rules! code_enum {
($(#[$meta:meta])* $name:ident { $($(#[$vmeta:meta])* $variant:ident => $code:literal),* $(,)? }) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum $name {
$($(#[$vmeta])* $variant,)*
Other(String),
}
impl $name {
#[must_use]
pub fn as_code(&self) -> &str {
match self {
$(Self::$variant => $code,)*
Self::Other(s) => s,
}
}
pub fn validate(&self, field: &'static str) -> Result<(), ValidationError> {
let code = self.as_code();
match check_code(code) {
Ok(_) => Ok(()),
Err(PurposeCodeError::InvalidLength { .. }) => {
Err(ValidationError::TooLong {
field,
max: 4,
actual: code.chars().count(),
})
}
Err(PurposeCodeError::InvalidCharacter { .. }) => {
let ch = code.chars().find(|c| !c.is_ascii_alphanumeric()).unwrap_or('?');
Err(ValidationError::InvalidCharacter { field, ch })
}
}
}
}
impl FromStr for $name {
type Err = PurposeCodeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let upper = check_code(s)?;
Ok(match upper.as_str() {
$($code => Self::$variant,)*
_ => Self::Other(upper),
})
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_code())
}
}
};
}
code_enum! {
Purpose {
Sala => "SALA",
Bonu => "BONU",
Pens => "PENS",
Payr => "PAYR",
Spsp => "SPSP",
Supp => "SUPP",
Taxs => "TAXS",
Vatx => "VATX",
Gdds => "GDDS",
Scve => "SCVE",
Rent => "RENT",
Loan => "LOAN",
Insu => "INSU",
Elec => "ELEC",
Gasb => "GASB",
Wter => "WTER",
Phon => "PHON",
Educ => "EDUC",
Char => "CHAR",
Divd => "DIVD",
Inte => "INTE",
Govt => "GOVT",
Ssbe => "SSBE",
Bene => "BENE",
Ivpt => "IVPT",
Rinp => "RINP",
Trad => "TRAD",
Trea => "TREA",
Cash => "CASH",
Intc => "INTC",
Cbff => "CBFF",
Cbfr => "CBFR",
Rrct => "RRCT",
Rrtp => "RRTP",
Othr => "OTHR",
}
}
code_enum! {
CategoryPurpose {
Sala => "SALA",
Pens => "PENS",
Supp => "SUPP",
Taxs => "TAXS",
Trea => "TREA",
Cash => "CASH",
Intc => "INTC",
Govt => "GOVT",
Loan => "LOAN",
Bonu => "BONU",
Ssbe => "SSBE",
Trad => "TRAD",
Epay => "EPAY",
Cort => "CORT",
Hedg => "HEDG",
Inte => "INTE",
Secu => "SECU",
Vatx => "VATX",
Whld => "WHLD",
Divi => "DIVI",
Cipc => "CIPC",
Conc => "CONC",
Swep => "SWEP",
Topg => "TOPG",
Zaba => "ZABA",
Lbox => "LBOX",
Othr => "OTHR",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codes_round_trip() {
for code in ["SALA", "SUPP", "RENT", "CBFF", "OTHR"] {
assert_eq!(code.parse::<Purpose>().unwrap().as_code(), code);
}
for code in ["SALA", "INTC", "DIVI", "OTHR"] {
assert_eq!(code.parse::<CategoryPurpose>().unwrap().as_code(), code);
}
}
#[test]
fn the_two_code_sets_are_genuinely_different() {
assert_eq!("RENT".parse::<Purpose>().unwrap(), Purpose::Rent);
assert_eq!(
"RENT".parse::<CategoryPurpose>().unwrap(),
CategoryPurpose::Other("RENT".to_owned())
);
assert_eq!("DIVD".parse::<Purpose>().unwrap(), Purpose::Divd);
assert_eq!(
"DIVI".parse::<CategoryPurpose>().unwrap(),
CategoryPurpose::Divi
);
assert!(matches!(
"DIVI".parse::<Purpose>().unwrap(),
Purpose::Other(_)
));
assert!(matches!(
"DIVD".parse::<CategoryPurpose>().unwrap(),
CategoryPurpose::Other(_)
));
for c in ["CIPC", "CONC", "SWEP", "TOPG", "ZABA", "LBOX"] {
assert!(
matches!(c.parse::<Purpose>().unwrap(), Purpose::Other(_)),
"{c} is category-only"
);
}
}
#[test]
fn unknown_but_well_formed_codes_are_carried_through() {
assert_eq!(
"ZZZZ".parse::<Purpose>().unwrap(),
Purpose::Other("ZZZZ".to_owned())
);
assert_eq!("zzzz".parse::<Purpose>().unwrap().as_code(), "ZZZZ");
}
#[test]
fn malformed_codes_are_rejected() {
for bad in ["", "TOOLONG", "SAL A", "SA-A"] {
assert!(bad.parse::<Purpose>().is_err(), "{bad:?} must be rejected");
assert!(bad.parse::<CategoryPurpose>().is_err());
}
assert!("A".parse::<Purpose>().is_ok());
assert!("ABCD".parse::<Purpose>().is_ok());
assert!("ABCDE".parse::<Purpose>().is_err());
}
#[test]
fn validate_catches_a_hand_built_other() {
assert!(matches!(
Purpose::Other("TOOLONG".to_owned()).validate("Purp/Cd"),
Err(ValidationError::TooLong { .. })
));
assert!(matches!(
Purpose::Other("A-B".to_owned()).validate("Purp/Cd"),
Err(ValidationError::InvalidCharacter { .. })
));
assert!(Purpose::Supp.validate("Purp/Cd").is_ok());
}
#[test]
fn display_matches_the_code() {
assert_eq!(Purpose::Cbff.to_string(), "CBFF");
assert_eq!(CategoryPurpose::Sala.to_string(), "SALA");
assert_eq!(Purpose::Other("XYZ".to_owned()).to_string(), "XYZ");
}
}