Skip to main content

use_bic/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7/// Common BIC primitives.
8pub mod prelude {
9    pub use crate::{Bic, BicError};
10}
11
12/// A validated SWIFT/BIC-style bank identifier code.
13#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct Bic(String);
15
16impl Bic {
17    /// Creates a BIC after uppercase normalization and position-specific validation.
18    ///
19    /// # Errors
20    ///
21    /// Returns [`BicError::InvalidLength`] when the trimmed input is not 8 or 11 bytes,
22    /// [`BicError::InvalidBankCode`] when the first four characters are not letters,
23    /// [`BicError::InvalidCountryCode`] when the country code is not two letters,
24    /// [`BicError::InvalidLocationCode`] when the location code is not alphanumeric, and
25    /// [`BicError::InvalidBranchCode`] when the optional branch code is not alphanumeric.
26    pub fn new(value: impl AsRef<str>) -> Result<Self, BicError> {
27        let value = value.as_ref().trim();
28        if value.len() != 8 && value.len() != 11 {
29            return Err(BicError::InvalidLength);
30        }
31
32        let mut normalized = String::with_capacity(value.len());
33        for (index, byte) in value.bytes().enumerate() {
34            let uppercase = byte.to_ascii_uppercase();
35            match index {
36                0..=3 if !uppercase.is_ascii_uppercase() => {
37                    return Err(BicError::InvalidBankCode);
38                },
39                4..=5 if !uppercase.is_ascii_uppercase() => {
40                    return Err(BicError::InvalidCountryCode);
41                },
42                6..=7 if !uppercase.is_ascii_alphanumeric() => {
43                    return Err(BicError::InvalidLocationCode);
44                },
45                8..=10 if !uppercase.is_ascii_alphanumeric() => {
46                    return Err(BicError::InvalidBranchCode);
47                },
48                _ => normalized.push(char::from(uppercase)),
49            }
50        }
51
52        Ok(Self(normalized))
53    }
54
55    /// Returns the normalized BIC.
56    #[must_use]
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60
61    /// Returns the four-letter bank code.
62    #[must_use]
63    pub fn bank_code(&self) -> &str {
64        &self.0[..4]
65    }
66
67    /// Returns the two-letter country code.
68    #[must_use]
69    pub fn country_code(&self) -> &str {
70        &self.0[4..6]
71    }
72
73    /// Returns the two-character location code.
74    #[must_use]
75    pub fn location_code(&self) -> &str {
76        &self.0[6..8]
77    }
78
79    /// Returns the optional three-character branch code.
80    #[must_use]
81    pub fn branch_code(&self) -> Option<&str> {
82        (self.0.len() == 11).then(|| &self.0[8..11])
83    }
84
85    /// Returns whether the BIC identifies a primary office.
86    #[must_use]
87    pub fn is_primary_office(&self) -> bool {
88        matches!(self.branch_code(), None | Some("XXX"))
89    }
90}
91
92impl AsRef<str> for Bic {
93    fn as_ref(&self) -> &str {
94        self.as_str()
95    }
96}
97
98impl fmt::Display for Bic {
99    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
100        formatter.write_str(self.as_str())
101    }
102}
103
104impl FromStr for Bic {
105    type Err = BicError;
106
107    fn from_str(value: &str) -> Result<Self, Self::Err> {
108        Self::new(value)
109    }
110}
111
112impl TryFrom<&str> for Bic {
113    type Error = BicError;
114
115    fn try_from(value: &str) -> Result<Self, Self::Error> {
116        Self::new(value)
117    }
118}
119
120/// Errors returned while constructing BIC values.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum BicError {
123    /// BIC values must be exactly 8 or 11 characters.
124    InvalidLength,
125    /// The bank code must be four letters.
126    InvalidBankCode,
127    /// The country code must be two letters.
128    InvalidCountryCode,
129    /// The location code must be two alphanumeric characters.
130    InvalidLocationCode,
131    /// The branch code must be three alphanumeric characters when present.
132    InvalidBranchCode,
133}
134
135impl fmt::Display for BicError {
136    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Self::InvalidLength => formatter.write_str("BIC must be exactly 8 or 11 characters"),
139            Self::InvalidBankCode => formatter.write_str("BIC bank code must be four letters"),
140            Self::InvalidCountryCode => formatter.write_str("BIC country code must be two letters"),
141            Self::InvalidLocationCode => {
142                formatter.write_str("BIC location code must be two alphanumeric characters")
143            },
144            Self::InvalidBranchCode => {
145                formatter.write_str("BIC branch code must be three alphanumeric characters")
146            },
147        }
148    }
149}
150
151impl Error for BicError {}
152
153#[cfg(test)]
154mod tests {
155    use super::{Bic, BicError};
156
157    #[test]
158    fn accepts_valid_8_character_bic() -> Result<(), BicError> {
159        let bic = Bic::new("DEUTDEFF")?;
160
161        assert_eq!(bic.as_str(), "DEUTDEFF");
162        assert_eq!(bic.bank_code(), "DEUT");
163        assert_eq!(bic.country_code(), "DE");
164        assert_eq!(bic.location_code(), "FF");
165        assert_eq!(bic.branch_code(), None);
166        assert!(bic.is_primary_office());
167        Ok(())
168    }
169
170    #[test]
171    fn accepts_valid_11_character_bic() -> Result<(), BicError> {
172        let bic = Bic::new("deutdeff500")?;
173
174        assert_eq!(bic.as_str(), "DEUTDEFF500");
175        assert_eq!(bic.branch_code(), Some("500"));
176        assert!(!bic.is_primary_office());
177        Ok(())
178    }
179
180    #[test]
181    fn treats_xxx_branch_as_primary_office() -> Result<(), BicError> {
182        let bic = Bic::new("NEDSZAJJXXX")?;
183
184        assert_eq!(bic.branch_code(), Some("XXX"));
185        assert!(bic.is_primary_office());
186        Ok(())
187    }
188
189    #[test]
190    fn rejects_invalid_lengths() {
191        assert_eq!(Bic::new("DEUTDEF"), Err(BicError::InvalidLength));
192        assert_eq!(Bic::new("DEUTDEFF5000"), Err(BicError::InvalidLength));
193    }
194
195    #[test]
196    fn rejects_invalid_character_positions() {
197        assert_eq!(Bic::new("D3UTDEFF"), Err(BicError::InvalidBankCode));
198        assert_eq!(Bic::new("DEUTD3FF"), Err(BicError::InvalidCountryCode));
199        assert_eq!(Bic::new("DEUTDE@F"), Err(BicError::InvalidLocationCode));
200        assert_eq!(Bic::new("DEUTDEFF50@"), Err(BicError::InvalidBranchCode));
201    }
202}