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::{Iban, IbanError};
10}
11
12const MAX_IBAN_LENGTH: usize = 34;
13
14const IBAN_COUNTRY_LENGTHS: &[(&str, usize)] = &[
15 ("AD", 24),
16 ("AE", 23),
17 ("AL", 28),
18 ("AT", 20),
19 ("AZ", 28),
20 ("BA", 20),
21 ("BE", 16),
22 ("BG", 22),
23 ("BH", 22),
24 ("BI", 16),
25 ("BR", 29),
26 ("BY", 28),
27 ("CH", 21),
28 ("CR", 22),
29 ("CY", 28),
30 ("CZ", 24),
31 ("DE", 22),
32 ("DK", 18),
33 ("DO", 28),
34 ("EE", 20),
35 ("EG", 29),
36 ("ES", 24),
37 ("FI", 18),
38 ("FO", 18),
39 ("FR", 27),
40 ("GB", 22),
41 ("GE", 22),
42 ("GI", 23),
43 ("GL", 18),
44 ("GR", 27),
45 ("GT", 28),
46 ("HR", 21),
47 ("HU", 28),
48 ("IE", 22),
49 ("IL", 23),
50 ("IQ", 23),
51 ("IS", 26),
52 ("IT", 27),
53 ("JO", 30),
54 ("KW", 30),
55 ("KZ", 20),
56 ("LB", 28),
57 ("LC", 32),
58 ("LI", 21),
59 ("LT", 20),
60 ("LU", 20),
61 ("LV", 21),
62 ("LY", 25),
63 ("MC", 27),
64 ("MD", 24),
65 ("ME", 22),
66 ("MK", 19),
67 ("MR", 27),
68 ("MT", 31),
69 ("MU", 30),
70 ("NL", 18),
71 ("NO", 15),
72 ("PK", 24),
73 ("PL", 28),
74 ("PS", 29),
75 ("PT", 25),
76 ("QA", 29),
77 ("RO", 24),
78 ("RS", 22),
79 ("SA", 24),
80 ("SC", 31),
81 ("SE", 24),
82 ("SI", 19),
83 ("SK", 24),
84 ("SM", 27),
85 ("SO", 23),
86 ("ST", 25),
87 ("SV", 28),
88 ("TL", 23),
89 ("TN", 24),
90 ("TR", 26),
91 ("UA", 29),
92 ("VA", 22),
93 ("VG", 24),
94 ("XK", 20),
95];
96
97#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct Iban(String);
100
101impl Iban {
102 pub fn new(value: impl AsRef<str>) -> Result<Self, IbanError> {
109 let compact = compact_iban(value.as_ref())?;
110
111 if compact.len() < 4 || compact.len() > MAX_IBAN_LENGTH {
112 return Err(IbanError::InvalidLength);
113 }
114
115 if !compact.as_bytes()[0].is_ascii_uppercase()
116 || !compact.as_bytes()[1].is_ascii_uppercase()
117 {
118 return Err(IbanError::InvalidCountryCode);
119 }
120
121 if !compact.as_bytes()[2].is_ascii_digit() || !compact.as_bytes()[3].is_ascii_digit() {
122 return Err(IbanError::InvalidCheckDigits);
123 }
124
125 let expected_length =
126 country_length(&compact[..2]).ok_or(IbanError::UnsupportedCountryCode)?;
127 if compact.len() != expected_length {
128 return Err(IbanError::InvalidCountryLength);
129 }
130
131 if !has_valid_checksum(&compact) {
132 return Err(IbanError::InvalidChecksum);
133 }
134
135 Ok(Self(compact))
136 }
137
138 #[must_use]
140 pub fn as_str(&self) -> &str {
141 &self.0
142 }
143
144 #[must_use]
146 pub fn compact(&self) -> &str {
147 self.as_str()
148 }
149
150 #[must_use]
152 pub fn format_grouped(&self) -> String {
153 let space_count = self.0.len().saturating_sub(1) / 4;
154 let mut grouped = String::with_capacity(self.0.len() + space_count);
155
156 for (index, byte) in self.0.bytes().enumerate() {
157 if index > 0 && index % 4 == 0 {
158 grouped.push(' ');
159 }
160 grouped.push(char::from(byte));
161 }
162
163 grouped
164 }
165
166 #[must_use]
168 pub fn country_code(&self) -> &str {
169 &self.0[..2]
170 }
171
172 #[must_use]
174 pub fn check_digits(&self) -> &str {
175 &self.0[2..4]
176 }
177
178 #[must_use]
180 pub fn bban(&self) -> &str {
181 &self.0[4..]
182 }
183}
184
185impl AsRef<str> for Iban {
186 fn as_ref(&self) -> &str {
187 self.as_str()
188 }
189}
190
191impl fmt::Display for Iban {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 formatter.write_str(self.as_str())
194 }
195}
196
197impl FromStr for Iban {
198 type Err = IbanError;
199
200 fn from_str(value: &str) -> Result<Self, Self::Err> {
201 Self::new(value)
202 }
203}
204
205impl TryFrom<&str> for Iban {
206 type Error = IbanError;
207
208 fn try_from(value: &str) -> Result<Self, Self::Error> {
209 Self::new(value)
210 }
211}
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum IbanError {
216 Empty,
218 InvalidLength,
220 InvalidCountryCode,
222 InvalidCheckDigits,
224 InvalidCharacter,
226 UnsupportedCountryCode,
228 InvalidCountryLength,
230 InvalidChecksum,
232}
233
234impl fmt::Display for IbanError {
235 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
236 match self {
237 Self::Empty => formatter.write_str("IBAN cannot be empty"),
238 Self::InvalidLength => formatter.write_str("IBAN length is invalid"),
239 Self::InvalidCountryCode => {
240 formatter.write_str("IBAN country code must be two letters")
241 },
242 Self::InvalidCheckDigits => formatter.write_str("IBAN check digits must be two digits"),
243 Self::InvalidCharacter => {
244 formatter.write_str("IBAN must contain only ASCII letters, digits, or spaces")
245 },
246 Self::UnsupportedCountryCode => formatter.write_str("IBAN country code is unsupported"),
247 Self::InvalidCountryLength => {
248 formatter.write_str("IBAN length does not match the country-specific length")
249 },
250 Self::InvalidChecksum => formatter.write_str("IBAN mod-97 checksum is invalid"),
251 }
252 }
253}
254
255impl Error for IbanError {}
256
257fn compact_iban(value: &str) -> Result<String, IbanError> {
258 let value = value.trim();
259 if value.is_empty() {
260 return Err(IbanError::Empty);
261 }
262
263 let mut compact = String::with_capacity(value.len());
264 for byte in value.bytes() {
265 match byte {
266 b' ' => {},
267 b'a'..=b'z' => compact.push(char::from(byte.to_ascii_uppercase())),
268 b'A'..=b'Z' | b'0'..=b'9' => compact.push(char::from(byte)),
269 _ => return Err(IbanError::InvalidCharacter),
270 }
271 }
272
273 if compact.is_empty() {
274 return Err(IbanError::Empty);
275 }
276
277 Ok(compact)
278}
279
280fn country_length(country_code: &str) -> Option<usize> {
281 IBAN_COUNTRY_LENGTHS
282 .iter()
283 .find_map(|(country, length)| (*country == country_code).then_some(*length))
284}
285
286fn has_valid_checksum(value: &str) -> bool {
287 let rearranged = value[4..].bytes().chain(value[..4].bytes());
288 let mut remainder = 0_u32;
289
290 for byte in rearranged {
291 if byte.is_ascii_digit() {
292 remainder = ((remainder * 10) + u32::from(byte - b'0')) % 97;
293 } else if byte.is_ascii_uppercase() {
294 let letter_value = u32::from(byte - b'A') + 10;
295 remainder = ((remainder * 10) + (letter_value / 10)) % 97;
296 remainder = ((remainder * 10) + (letter_value % 10)) % 97;
297 } else {
298 return false;
299 }
300 }
301
302 remainder == 1
303}
304
305#[cfg(test)]
306mod tests {
307 use super::{Iban, IbanError};
308
309 #[test]
310 fn accepts_valid_ibans() -> Result<(), IbanError> {
311 let cases = [
312 ("GB82 WEST 1234 5698 7654 32", "GB82WEST12345698765432"),
313 ("DE89 3704 0044 0532 0130 00", "DE89370400440532013000"),
314 (
315 "FR14 2004 1010 0505 0001 3M02 606",
316 "FR1420041010050500013M02606",
317 ),
318 ];
319
320 for (input, compact) in cases {
321 let iban = Iban::new(input)?;
322 assert_eq!(iban.as_str(), compact);
323 assert_eq!(iban.compact(), compact);
324 }
325
326 Ok(())
327 }
328
329 #[test]
330 fn normalizes_lowercase_and_formats_groups() -> Result<(), IbanError> {
331 let iban = Iban::new("gb82 west 1234 5698 7654 32")?;
332
333 assert_eq!(iban.as_str(), "GB82WEST12345698765432");
334 assert_eq!(iban.format_grouped(), "GB82 WEST 1234 5698 7654 32");
335 assert_eq!(iban.country_code(), "GB");
336 assert_eq!(iban.check_digits(), "82");
337 assert_eq!(iban.bban(), "WEST12345698765432");
338 Ok(())
339 }
340
341 #[test]
342 fn rejects_mod97_failures() {
343 assert_eq!(
344 Iban::new("GB82 WEST 1234 5698 7654 33"),
345 Err(IbanError::InvalidChecksum)
346 );
347 }
348
349 #[test]
350 fn rejects_invalid_characters_and_country_parts() {
351 assert_eq!(Iban::new(""), Err(IbanError::Empty));
352 assert_eq!(
353 Iban::new("1B82WEST12345698765432"),
354 Err(IbanError::InvalidCountryCode)
355 );
356 assert_eq!(
357 Iban::new("GBXXWEST12345698765432"),
358 Err(IbanError::InvalidCheckDigits)
359 );
360 assert_eq!(
361 Iban::new("GB82-WEST-1234"),
362 Err(IbanError::InvalidCharacter)
363 );
364 }
365
366 #[test]
367 fn rejects_unsupported_or_wrong_country_lengths() {
368 assert_eq!(
369 Iban::new("US82WEST12345698765432"),
370 Err(IbanError::UnsupportedCountryCode)
371 );
372 assert_eq!(
373 Iban::new("DE8937040044053201300"),
374 Err(IbanError::InvalidCountryLength)
375 );
376 }
377}