regit_identifiers/bic.rs
1// Copyright 2026 Regit.io — Nicolas Koenig
2// SPDX-License-Identifier: Apache-2.0
3
4//! BIC — Business Identifier Code (ISO 9362), the SWIFT address of a
5//! financial institution.
6//!
7//! A BIC identifies a bank or other institution on the SWIFT network. It is
8//! either **8** characters (an institution's primary office) or **11**
9//! characters (the same with an explicit branch suffix), in up to four
10//! segments:
11//!
12//! ```text
13//! D E U T D E F F 5 0 0
14//! └──┬──┘ └┬┘ └┬┘ └─┬─┘
15//! │ │ │ └ branch [8..11] three [A-Z0-9], 11-char only
16//! │ │ └─────── location [6..8] two [A-Z0-9]
17//! │ └─────────── country [4..6] ISO 3166-1 alpha-2 letters
18//! └────────────────── institution [0..4] four letters
19//! ```
20//!
21//! - The **institution code** is four letters naming the institution.
22//! - The **country code** is an ISO 3166-1 alpha-2 code — see
23//! [`crate::country`].
24//! - The **location code** is two `[A-Z0-9]` characters. Its second character
25//! carries a convention: `0` marks a test/training BIC and `1` a passive
26//! SWIFT participant.
27//! - The **branch code**, present only in an 11-character BIC, is three
28//! `[A-Z0-9]` characters identifying a specific branch.
29//!
30//! A BIC has **no check digit**: [`Bic::parse`] enforces structure only —
31//! one of the two permitted lengths, the per-segment character set, and a
32//! country code that is a recognised ISO 3166-1 alpha-2 code.
33//!
34//! # References
35//!
36//! - ISO 9362, *Banking — Banking telecommunication messages — Business
37//! identifier code (BIC)*.
38
39use crate::country;
40use crate::errors::ValidationError;
41
42/// A validated Business Identifier Code (ISO 9362).
43///
44/// A `Bic` can only be created by [`Bic::parse`] (or the explicitly unchecked
45/// [`Bic::from_bytes_unchecked`]), so a value of this type is a proof that 8
46/// or 11 characters form a structurally valid BIC. It stores the identifier
47/// inline as `[u8; 11]` with a `len` field; the unused tail bytes of an
48/// 8-character BIC are zeroed, so the derived `PartialEq`/`Eq`/`Hash` compare
49/// only the significant characters. It is `Copy` and allocates nothing.
50///
51/// # Examples
52///
53/// ```
54/// use regit_identifiers::Bic;
55///
56/// let bic = Bic::parse("DEUTDEFF500").unwrap();
57/// assert_eq!(bic.institution(), "DEUT");
58/// assert_eq!(bic.country_code(), "DE");
59/// assert_eq!(bic.location_code(), "FF");
60/// assert_eq!(bic.branch_code(), Some("500"));
61/// assert_eq!(bic.as_str(), "DEUTDEFF500");
62/// ```
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub struct Bic {
65 /// The validated ASCII bytes, left-aligned; tail bytes past `len` zeroed.
66 bytes: [u8; Self::MAX_LENGTH],
67 /// The number of significant bytes — always either 8 or 11.
68 len: u8,
69}
70
71impl Bic {
72 /// The length of a BIC without a branch code.
73 pub const SHORT_LENGTH: usize = 8;
74
75 /// The length of a BIC with an explicit branch code.
76 pub const MAX_LENGTH: usize = 11;
77
78 /// Parses and validates a BIC.
79 ///
80 /// Validation is strict and structural — a BIC carries no check digit.
81 /// In order: the input must be exactly 8 or 11 characters; the
82 /// institution and country segments (characters 1–6) must each be an
83 /// upper-case ASCII letter; the location and branch segments must each be
84 /// an ASCII digit or upper-case letter; and the country segment must be a
85 /// recognised ISO 3166-1 alpha-2 code.
86 ///
87 /// # Errors
88 ///
89 /// - [`ValidationError::Structure`] with rule `"BIC length must be 8 or
90 /// 11"` if the input is neither 8 nor 11 characters.
91 /// - [`ValidationError::InvalidCharacter`] if a character falls outside
92 /// the set its position allows (this also rejects lower-case input and
93 /// any non-ASCII character).
94 /// - [`ValidationError::InvalidCountryCode`] if characters 5–6 are not a
95 /// recognised ISO 3166-1 alpha-2 country code.
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use regit_identifiers::Bic;
101 /// use regit_identifiers::errors::ValidationError;
102 ///
103 /// assert!(Bic::parse("DEUTDEFF").is_ok());
104 /// assert!(Bic::parse("DEUTDEFF500").is_ok());
105 ///
106 /// // A 9-character string is neither permitted length.
107 /// assert_eq!(
108 /// Bic::parse("DEUTDEFF5"),
109 /// Err(ValidationError::Structure { rule: "BIC length must be 8 or 11" }),
110 /// );
111 /// ```
112 pub fn parse(s: &str) -> Result<Self, ValidationError> {
113 // A BIC is exactly 8 or 11 characters; any other length is rejected.
114 let found = s.chars().count();
115 if found != Self::SHORT_LENGTH && found != Self::MAX_LENGTH {
116 return Err(ValidationError::Structure {
117 rule: "BIC length must be 8 or 11",
118 });
119 }
120 // Per-position character set: [0..6] are letters, [6..11] are
121 // [A-Z0-9]. A non-ASCII character fails both predicates and is
122 // rejected here.
123 for (i, ch) in s.chars().enumerate() {
124 let legal = if i < 6 {
125 ch.is_ascii_uppercase()
126 } else {
127 ch.is_ascii_uppercase() || ch.is_ascii_digit()
128 };
129 if !legal {
130 return Err(ValidationError::InvalidCharacter {
131 position: i + 1,
132 found: ch,
133 });
134 }
135 }
136 // Every character is ASCII, so the string is exactly `found` ASCII
137 // bytes; copy them into a zeroed buffer so the tail stays zero.
138 let mut bytes = [0u8; Self::MAX_LENGTH];
139 let src = s.as_bytes();
140 if let Some(slot) = bytes.get_mut(..found) {
141 slot.copy_from_slice(src);
142 }
143 // Characters 5–6 must be a recognised ISO 3166-1 alpha-2 code.
144 let country_code = core::str::from_utf8(&bytes[4..6]).unwrap_or("");
145 if !country::is_iso_country(country_code) {
146 return Err(ValidationError::InvalidCountryCode);
147 }
148 Ok(Self {
149 bytes,
150 len: u8::try_from(found).unwrap_or(0),
151 })
152 }
153
154 /// Validates a BIC without constructing one.
155 ///
156 /// Equivalent to `Bic::parse(s).map(|_| ())`; use it when only the
157 /// verdict is needed.
158 ///
159 /// # Errors
160 ///
161 /// Returns the same [`ValidationError`] variants as [`Bic::parse`].
162 ///
163 /// # Examples
164 ///
165 /// ```
166 /// use regit_identifiers::Bic;
167 ///
168 /// assert!(Bic::validate("CHASUS33").is_ok());
169 /// assert!(Bic::validate("CHASUS3").is_err());
170 /// ```
171 pub fn validate(s: &str) -> Result<(), ValidationError> {
172 Self::parse(s).map(|_| ())
173 }
174
175 /// Wraps raw bytes as a `Bic` without any validation.
176 ///
177 /// The caller asserts that the first `len` bytes hold the characters of a
178 /// valid BIC, that `len` is 8 or 11, and that every byte from `len`
179 /// onwards is zero. This exists for reconstructing a `Bic` from bytes
180 /// that were validated earlier; prefer [`Bic::parse`] for any untrusted
181 /// input.
182 ///
183 /// # Examples
184 ///
185 /// ```
186 /// use regit_identifiers::Bic;
187 ///
188 /// // The 3 trailing zero bytes are REQUIRED, not arbitrary padding —
189 /// // the derived `PartialEq`/`Eq`/`Hash` compare the full 11-byte buffer.
190 /// let bic = Bic::from_bytes_unchecked(*b"DEUTDEFF\0\0\0", 8);
191 /// assert_eq!(bic.as_str(), "DEUTDEFF");
192 /// ```
193 #[must_use]
194 pub const fn from_bytes_unchecked(bytes: [u8; Self::MAX_LENGTH], len: u8) -> Self {
195 Self { bytes, len }
196 }
197
198 /// Returns the number of characters in this BIC — 8 or 11.
199 ///
200 /// # Examples
201 ///
202 /// ```
203 /// use regit_identifiers::Bic;
204 ///
205 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().len(), 8);
206 /// assert_eq!(Bic::parse("DEUTDEFF500").unwrap().len(), 11);
207 /// ```
208 #[must_use]
209 #[inline]
210 pub fn len(&self) -> usize {
211 self.len as usize
212 }
213
214 /// Always `false` — a valid BIC is never empty (it is 8 or 11
215 /// characters). Provided for API consistency with [`Bic::len`].
216 ///
217 /// # Examples
218 ///
219 /// ```
220 /// use regit_identifiers::Bic;
221 ///
222 /// assert!(!Bic::parse("DEUTDEFF").unwrap().is_empty());
223 /// ```
224 #[must_use]
225 #[inline]
226 pub fn is_empty(&self) -> bool {
227 self.len == 0
228 }
229
230 /// Returns the BIC as a string slice.
231 ///
232 /// # Examples
233 ///
234 /// ```
235 /// use regit_identifiers::Bic;
236 ///
237 /// assert_eq!(Bic::parse("DEUTDEFF500").unwrap().as_str(), "DEUTDEFF500");
238 /// ```
239 #[must_use]
240 #[inline]
241 pub fn as_str(&self) -> &str {
242 core::str::from_utf8(self.as_bytes()).unwrap_or("")
243 }
244
245 /// Returns the BIC as its raw ASCII bytes — 8 or 11 bytes, with no
246 /// trailing zero padding.
247 ///
248 /// # Examples
249 ///
250 /// ```
251 /// use regit_identifiers::Bic;
252 ///
253 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().as_bytes(), b"DEUTDEFF");
254 /// ```
255 #[must_use]
256 #[inline]
257 pub fn as_bytes(&self) -> &[u8] {
258 self.bytes.get(..self.len()).unwrap_or(&[])
259 }
260
261 /// Returns the four-character institution code, characters 1–4.
262 ///
263 /// # Examples
264 ///
265 /// ```
266 /// use regit_identifiers::Bic;
267 ///
268 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().institution(), "DEUT");
269 /// ```
270 #[must_use]
271 #[inline]
272 pub fn institution(&self) -> &str {
273 core::str::from_utf8(&self.bytes[0..4]).unwrap_or("")
274 }
275
276 /// Returns the two-character ISO 3166-1 country code, characters 5–6.
277 ///
278 /// # Examples
279 ///
280 /// ```
281 /// use regit_identifiers::Bic;
282 ///
283 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().country_code(), "DE");
284 /// ```
285 #[must_use]
286 #[inline]
287 pub fn country_code(&self) -> &str {
288 core::str::from_utf8(&self.bytes[4..6]).unwrap_or("")
289 }
290
291 /// Returns the two-character location code, characters 7–8.
292 ///
293 /// # Examples
294 ///
295 /// ```
296 /// use regit_identifiers::Bic;
297 ///
298 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().location_code(), "FF");
299 /// ```
300 #[must_use]
301 #[inline]
302 pub fn location_code(&self) -> &str {
303 core::str::from_utf8(&self.bytes[6..8]).unwrap_or("")
304 }
305
306 /// Returns the three-character branch code, characters 9–11, or `None`
307 /// for an 8-character BIC with no branch suffix.
308 ///
309 /// # Examples
310 ///
311 /// ```
312 /// use regit_identifiers::Bic;
313 ///
314 /// assert_eq!(Bic::parse("DEUTDEFF500").unwrap().branch_code(), Some("500"));
315 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().branch_code(), None);
316 /// ```
317 #[must_use]
318 #[inline]
319 pub fn branch_code(&self) -> Option<&str> {
320 if self.has_branch() {
321 Some(core::str::from_utf8(&self.bytes[8..11]).unwrap_or(""))
322 } else {
323 None
324 }
325 }
326
327 /// Returns `true` if this BIC carries an explicit branch code, i.e. it is
328 /// 11 characters long.
329 ///
330 /// # Examples
331 ///
332 /// ```
333 /// use regit_identifiers::Bic;
334 ///
335 /// assert!(Bic::parse("DEUTDEFF500").unwrap().has_branch());
336 /// assert!(!Bic::parse("DEUTDEFF").unwrap().has_branch());
337 /// ```
338 #[must_use]
339 #[inline]
340 pub fn has_branch(&self) -> bool {
341 self.len() == Self::MAX_LENGTH
342 }
343
344 /// Returns `true` if this is a test/training BIC, i.e. the second
345 /// character of the location code (character 8) is `'0'`.
346 ///
347 /// # Examples
348 ///
349 /// ```
350 /// use regit_identifiers::Bic;
351 ///
352 /// assert!(Bic::parse("DEUTDEF0").unwrap().is_test_bic());
353 /// assert!(!Bic::parse("DEUTDEFF").unwrap().is_test_bic());
354 /// ```
355 #[must_use]
356 #[inline]
357 pub fn is_test_bic(&self) -> bool {
358 self.bytes[7] == b'0'
359 }
360
361 /// Returns `true` if this BIC belongs to a passive SWIFT participant,
362 /// i.e. the second character of the location code (character 8) is `'1'`.
363 ///
364 /// # Examples
365 ///
366 /// ```
367 /// use regit_identifiers::Bic;
368 ///
369 /// assert!(Bic::parse("DEUTDEF1").unwrap().is_passive());
370 /// assert!(!Bic::parse("DEUTDEFF").unwrap().is_passive());
371 /// ```
372 #[must_use]
373 #[inline]
374 pub fn is_passive(&self) -> bool {
375 self.bytes[7] == b'1'
376 }
377
378 /// Returns the second character of the location code (character 8 of
379 /// the BIC) — the "status character" by SWIFT convention.
380 ///
381 /// SWIFT attaches a convention to this character: `'0'` marks a test or
382 /// training BIC ([`Bic::is_test_bic`]) and `'1'` a passive participant
383 /// ([`Bic::is_passive`]). Any other value denotes a connected,
384 /// non-test, non-passive participant. Use this accessor when you want
385 /// the raw character; use the predicates when you only need a yes/no.
386 ///
387 /// # Examples
388 ///
389 /// ```
390 /// use regit_identifiers::Bic;
391 ///
392 /// assert_eq!(Bic::parse("DEUTDEFF").unwrap().location_status(), 'F');
393 /// assert_eq!(Bic::parse("DEUTDEF0").unwrap().location_status(), '0');
394 /// assert_eq!(Bic::parse("DEUTDEF1").unwrap().location_status(), '1');
395 /// ```
396 #[must_use]
397 #[inline]
398 pub fn location_status(&self) -> char {
399 char::from(self.bytes[7])
400 }
401}
402
403impl core::fmt::Display for Bic {
404 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
405 f.write_str(self.as_str())
406 }
407}
408
409impl core::str::FromStr for Bic {
410 type Err = ValidationError;
411
412 fn from_str(s: &str) -> Result<Self, Self::Err> {
413 Self::parse(s)
414 }
415}
416
417impl AsRef<str> for Bic {
418 fn as_ref(&self) -> &str {
419 self.as_str()
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::test_support::display;
427 use core::str::FromStr;
428
429 /// Real, well-known BICs used as regression anchors.
430 const GOLDEN: &[&str] = &[
431 "DEUTDEFF", // Deutsche Bank, Frankfurt (8 characters)
432 "DEUTDEFF500", // Deutsche Bank, Frankfurt, branch 500 (11 characters)
433 "CHASUS33", // JPMorgan Chase Bank, New York
434 "BOFAUS3N", // Bank of America, New York
435 "NDEAFIHH", // Nordea Bank, Helsinki
436 ];
437
438 #[test]
439 fn parses_golden_bics() {
440 for &s in GOLDEN {
441 let bic = Bic::parse(s).unwrap_or_else(|e| panic!("{s} should parse: {e}"));
442 assert_eq!(bic.as_str(), s);
443 }
444 }
445
446 #[test]
447 fn segment_accessors_short() {
448 let bic = Bic::parse("DEUTDEFF").unwrap();
449 assert_eq!(bic.institution(), "DEUT");
450 assert_eq!(bic.country_code(), "DE");
451 assert_eq!(bic.location_code(), "FF");
452 assert_eq!(bic.branch_code(), None);
453 assert!(!bic.has_branch());
454 assert_eq!(bic.len(), 8);
455 assert_eq!(bic.as_bytes(), b"DEUTDEFF");
456 }
457
458 #[test]
459 fn segment_accessors_with_branch() {
460 let bic = Bic::parse("DEUTDEFF500").unwrap();
461 assert_eq!(bic.institution(), "DEUT");
462 assert_eq!(bic.country_code(), "DE");
463 assert_eq!(bic.location_code(), "FF");
464 assert_eq!(bic.branch_code(), Some("500"));
465 assert!(bic.has_branch());
466 assert_eq!(bic.len(), 11);
467 assert_eq!(bic.as_bytes(), b"DEUTDEFF500");
468 }
469
470 #[test]
471 fn length_constants() {
472 assert_eq!(Bic::SHORT_LENGTH, 8);
473 assert_eq!(Bic::MAX_LENGTH, 11);
474 }
475
476 #[test]
477 fn test_bic_flag() {
478 let bic = Bic::parse("DEUTDEF0").unwrap();
479 assert!(bic.is_test_bic());
480 assert!(!bic.is_passive());
481 }
482
483 #[test]
484 fn passive_participant_flag() {
485 let bic = Bic::parse("DEUTDEF1").unwrap();
486 assert!(bic.is_passive());
487 assert!(!bic.is_test_bic());
488 }
489
490 #[test]
491 fn live_bic_is_neither_test_nor_passive() {
492 let bic = Bic::parse("DEUTDEFF").unwrap();
493 assert!(!bic.is_test_bic());
494 assert!(!bic.is_passive());
495 }
496
497 #[test]
498 fn location_status_returns_eighth_character() {
499 assert_eq!(Bic::parse("DEUTDEFF").unwrap().location_status(), 'F');
500 assert_eq!(Bic::parse("DEUTDEF0").unwrap().location_status(), '0');
501 assert_eq!(Bic::parse("DEUTDEF1").unwrap().location_status(), '1');
502 // Same character in an 11-character BIC.
503 assert_eq!(Bic::parse("DEUTDEFF500").unwrap().location_status(), 'F');
504 }
505
506 #[test]
507 fn rejects_wrong_length() {
508 // Nine characters is neither 8 nor 11.
509 assert_eq!(
510 Bic::parse("DEUTDEFF5"),
511 Err(ValidationError::Structure {
512 rule: "BIC length must be 8 or 11",
513 })
514 );
515 // Ten characters likewise.
516 assert_eq!(
517 Bic::parse("DEUTDEFF50"),
518 Err(ValidationError::Structure {
519 rule: "BIC length must be 8 or 11",
520 })
521 );
522 // Empty input.
523 assert_eq!(
524 Bic::parse(""),
525 Err(ValidationError::Structure {
526 rule: "BIC length must be 8 or 11",
527 })
528 );
529 // Twelve characters.
530 assert_eq!(
531 Bic::parse("DEUTDEFF5000"),
532 Err(ValidationError::Structure {
533 rule: "BIC length must be 8 or 11",
534 })
535 );
536 }
537
538 #[test]
539 fn rejects_digit_in_institution() {
540 assert!(matches!(
541 Bic::parse("DEU1DEFF"),
542 Err(ValidationError::InvalidCharacter { position: 4, .. })
543 ));
544 }
545
546 #[test]
547 fn rejects_digit_in_country() {
548 // A digit anywhere in characters 5–6 must be rejected as a character
549 // error, before the country lookup.
550 assert!(matches!(
551 Bic::parse("DEUT1EFF"),
552 Err(ValidationError::InvalidCharacter { position: 5, .. })
553 ));
554 }
555
556 #[test]
557 fn rejects_lower_case() {
558 assert!(matches!(
559 Bic::parse("deutdeff"),
560 Err(ValidationError::InvalidCharacter { position: 1, .. })
561 ));
562 }
563
564 #[test]
565 fn rejects_unknown_country_code() {
566 // ZZ is two letters but not an assigned ISO 3166-1 code.
567 assert_eq!(
568 Bic::parse("DEUTZZFF"),
569 Err(ValidationError::InvalidCountryCode)
570 );
571 }
572
573 #[test]
574 fn rejects_substitute_prefix_as_country() {
575 // XS is an ISIN substitute prefix, not an ISO country, so it is not a
576 // valid BIC country code.
577 assert_eq!(
578 Bic::parse("DEUTXS33"),
579 Err(ValidationError::InvalidCountryCode)
580 );
581 }
582
583 #[test]
584 fn rejects_bad_character_in_branch() {
585 assert!(matches!(
586 Bic::parse("DEUTDEFF50/"),
587 Err(ValidationError::InvalidCharacter { position: 11, .. })
588 ));
589 }
590
591 #[test]
592 fn rejects_non_ascii_without_panic() {
593 // A multi-byte character must be rejected cleanly.
594 assert!(Bic::parse("DEUTDEFé").is_err());
595 assert!(Bic::parse("ÉEUTDEFF").is_err());
596 assert!(Bic::parse("DEUTDEFF50é").is_err());
597 }
598
599 #[test]
600 fn round_trips_through_str() {
601 for &s in GOLDEN {
602 assert_eq!(Bic::parse(s).unwrap().as_str(), s);
603 }
604 }
605
606 #[test]
607 fn from_str_matches_parse() {
608 assert_eq!(Bic::from_str("DEUTDEFF"), Bic::parse("DEUTDEFF"));
609 assert_eq!(Bic::from_str("DEUTDEFF500"), Bic::parse("DEUTDEFF500"));
610 assert!(Bic::from_str("nonsense!").is_err());
611 }
612
613 #[test]
614 fn display_renders_identifier() {
615 assert_eq!(
616 display(Bic::parse("DEUTDEFF").unwrap()).as_str(),
617 "DEUTDEFF"
618 );
619 assert_eq!(
620 display(Bic::parse("DEUTDEFF500").unwrap()).as_str(),
621 "DEUTDEFF500"
622 );
623 }
624
625 #[test]
626 fn as_ref_str() {
627 let bic = Bic::parse("CHASUS33").unwrap();
628 let s: &str = bic.as_ref();
629 assert_eq!(s, "CHASUS33");
630 }
631
632 #[test]
633 fn validate_agrees_with_parse() {
634 assert!(Bic::validate("BOFAUS3N").is_ok());
635 assert!(Bic::validate("BOFAUS3").is_err());
636 }
637
638 #[test]
639 fn from_bytes_unchecked_round_trip() {
640 let short = Bic::from_bytes_unchecked(*b"DEUTDEFF\0\0\0", 8);
641 assert_eq!(short, Bic::parse("DEUTDEFF").unwrap());
642 let long = Bic::from_bytes_unchecked(*b"DEUTDEFF500", 11);
643 assert_eq!(long, Bic::parse("DEUTDEFF500").unwrap());
644 }
645
646 #[test]
647 fn unused_tail_bytes_are_zeroed() {
648 // The tail of an 8-character BIC must be zero, so two equal 8-char
649 // BICs compare equal regardless of how they were built.
650 let parsed = Bic::parse("DEUTDEFF").unwrap();
651 let built = Bic::from_bytes_unchecked(*b"DEUTDEFF\0\0\0", 8);
652 assert_eq!(parsed, built);
653 }
654
655 #[test]
656 fn is_copy_and_eq_and_hashable() {
657 let a = Bic::parse("DEUTDEFF").unwrap();
658 let b = a; // Copy
659 assert_eq!(a, b);
660 assert_ne!(a, Bic::parse("CHASUS33").unwrap());
661 // An 8-char BIC and the 11-char BIC sharing its prefix differ.
662 assert_ne!(a, Bic::parse("DEUTDEFF500").unwrap());
663 // Usable as a map key (Eq + Hash) — checked by constructing a slice.
664 let keys = [a, b];
665 assert_eq!(keys[0], keys[1]);
666 }
667}