1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
9 pub use crate::{Bic, BicError};
10}
11
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14pub struct Bic(String);
15
16impl Bic {
17 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 #[must_use]
57 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60
61 #[must_use]
63 pub fn bank_code(&self) -> &str {
64 &self.0[..4]
65 }
66
67 #[must_use]
69 pub fn country_code(&self) -> &str {
70 &self.0[4..6]
71 }
72
73 #[must_use]
75 pub fn location_code(&self) -> &str {
76 &self.0[6..8]
77 }
78
79 #[must_use]
81 pub fn branch_code(&self) -> Option<&str> {
82 (self.0.len() == 11).then(|| &self.0[8..11])
83 }
84
85 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum BicError {
123 InvalidLength,
125 InvalidBankCode,
127 InvalidCountryCode,
129 InvalidLocationCode,
131 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}