use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Bic(String);
impl Bic {
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
#[must_use]
pub fn institution_code(&self) -> &str {
&self.0[..4]
}
#[inline]
#[must_use]
pub fn country_code(&self) -> &str {
&self.0[4..6]
}
#[inline]
#[must_use]
pub fn location_code(&self) -> &str {
&self.0[6..8]
}
#[inline]
#[must_use]
pub fn branch_code(&self) -> Option<&str> {
if self.0.len() == 11 {
Some(&self.0[8..])
} else {
None
}
}
#[inline]
#[must_use]
pub fn is_test(&self) -> bool {
self.0.as_bytes()[6] == b'0'
}
}
impl std::fmt::Display for Bic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for Bic {
fn as_ref(&self) -> &str {
&self.0
}
}
impl std::ops::Deref for Bic {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::borrow::Borrow<str> for Bic {
fn borrow(&self) -> &str {
&self.0
}
}
impl From<Bic> for String {
fn from(bic: Bic) -> Self {
bic.0
}
}
impl FromStr for Bic {
type Err = BicError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
validate_bic(s)
}
}
impl TryFrom<&str> for Bic {
type Error = BicError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
validate_bic(s)
}
}
impl TryFrom<String> for Bic {
type Error = BicError;
fn try_from(s: String) -> Result<Self, Self::Error> {
validate_bic(&s)
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for Bic {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Bic {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
validate_bic(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum BicError {
#[error("BIC length {len} is invalid — must be 8 or 11 characters")]
InvalidLength {
len: usize,
},
#[error("BIC contains invalid character {ch:?} at position {pos}")]
InvalidCharacter {
ch: char,
pos: usize,
},
#[error("BIC institution code must be 4 ASCII letters (A–Z), got: {code:?}")]
InvalidInstitutionCode {
code: String,
},
#[error("BIC country code must be 2 ASCII letters (A–Z), got: {code:?}")]
InvalidCountryCode {
code: String,
},
#[error("\"NOTPROVIDED\" is an EPC placeholder, not a valid BIC — use Option<Bic> with None")]
Placeholder,
}
#[must_use = "ignoring a validated BIC loses the result"]
pub fn validate_bic(raw: &str) -> Result<Bic, BicError> {
let upper: String = raw.to_ascii_uppercase();
let len = upper.len();
if upper == "NOTPROVIDED" {
return Err(BicError::Placeholder);
}
if len != 8 && len != 11 {
return Err(BicError::InvalidLength { len });
}
for (pos, ch) in upper.chars().enumerate() {
if !ch.is_ascii_alphanumeric() {
return Err(BicError::InvalidCharacter { ch, pos });
}
}
let institution = &upper[..4];
if !institution.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(BicError::InvalidInstitutionCode {
code: institution.to_owned(),
});
}
let country = &upper[4..6];
if !country.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(BicError::InvalidCountryCode {
code: country.to_owned(),
});
}
Ok(Bic(upper))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cobadeff_8_char() {
let bic = validate_bic("COBADEFF").unwrap();
assert_eq!(bic.institution_code(), "COBA");
assert_eq!(bic.country_code(), "DE");
assert_eq!(bic.location_code(), "FF");
assert!(bic.branch_code().is_none());
}
#[test]
fn cobadeffxxx_11_char() {
let bic = validate_bic("COBADEFFXXX").unwrap();
assert_eq!(bic.branch_code(), Some("XXX"));
}
#[test]
fn lowercase_normalised() {
assert!(validate_bic("cobadeff").is_ok());
}
#[test]
fn too_short() {
assert!(matches!(
validate_bic("COBA").unwrap_err(),
BicError::InvalidLength { len: 4 }
));
}
#[test]
fn too_long() {
assert!(matches!(
validate_bic("COBADEFFXXXX").unwrap_err(),
BicError::InvalidLength { len: 12 }
));
}
#[test]
fn not_provided_is_placeholder_error() {
assert!(matches!(
validate_bic("NOTPROVIDED").unwrap_err(),
BicError::Placeholder
));
}
#[test]
fn invalid_institution_code_with_digit() {
assert!(matches!(
validate_bic("C0BADEFF").unwrap_err(),
BicError::InvalidInstitutionCode { .. }
));
}
#[test]
fn test_bic_is_test_when_location_starts_with_zero() {
let bic = validate_bic("DEUTDE0B").unwrap();
assert!(bic.is_test());
}
#[test]
fn production_bic_is_not_test() {
let bic = validate_bic("COBADEFF").unwrap();
assert!(!bic.is_test()); }
#[test]
fn sspkdehhxxx() {
assert!(validate_bic("SSPKDEHHXXX").is_ok());
}
#[test]
fn from_str() {
let bic: Bic = "COBADEFF".parse().unwrap();
assert_eq!(bic.as_str(), "COBADEFF");
}
#[test]
fn try_from_str() {
assert!(Bic::try_from("COBADEFF").is_ok());
assert!(Bic::try_from("BAD").is_err());
}
#[test]
fn into_string() {
let bic = validate_bic("COBADEFF").unwrap();
let s: String = bic.into();
assert_eq!(s, "COBADEFF");
}
#[test]
fn ord() {
let a = validate_bic("COBADEFF").unwrap();
let b = validate_bic("DEUTDEDB").unwrap();
assert!(a < b);
}
#[test]
fn deref_to_str() {
let bic = validate_bic("COBADEFF").unwrap();
assert_eq!(bic.len(), 8); }
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let bic = validate_bic("COBADEFFXXX").unwrap();
let json = serde_json::to_string(&bic).unwrap();
assert_eq!(json, r#""COBADEFFXXX""#);
let back: Bic = serde_json::from_str(&json).unwrap();
assert_eq!(back, bic);
}
#[cfg(feature = "serde")]
#[test]
fn serde_invalid_rejected() {
let result: Result<Bic, _> = serde_json::from_str(r#""NOTPROVIDED""#);
assert!(result.is_err());
}
}