Skip to main content

mx20022_validate/rules/
bic.rs

1//! BIC (Bank Identifier Code) / SWIFT code validation rule.
2//!
3//! Validates per ISO 9362:2022:
4//! - 4 alpha-only characters: institution code
5//! - 2 alpha-only characters: country code (ISO 3166-1 alpha-2)
6//! - 2 alphanumeric characters: location code
7//! - Optional 3 alphanumeric characters: branch code
8//!
9//! Total length: 8 or 11 characters.
10//!
11//! The pattern also matches the generated type annotation:
12//! `[A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1}`
13
14use crate::error::{Severity, ValidationError};
15use crate::rules::Rule;
16
17/// Validates a value as a BIC/SWIFT code.
18///
19/// # Examples
20///
21/// ```
22/// use mx20022_validate::rules::bic::BicRule;
23/// use mx20022_validate::rules::Rule;
24///
25/// let rule = BicRule;
26/// let errors = rule.validate("AAAAGB2L", "/path");
27/// assert!(errors.is_empty(), "Valid BIC-8 should produce no errors");
28///
29/// let errors = rule.validate("AAAAGB2LXXX", "/path");
30/// assert!(errors.is_empty(), "Valid BIC-11 should produce no errors");
31///
32/// let errors = rule.validate("INVALID", "/path");
33/// assert!(!errors.is_empty(), "Invalid BIC should produce errors");
34/// ```
35pub struct BicRule;
36
37impl Rule for BicRule {
38    fn id(&self) -> &'static str {
39        "BIC_CHECK"
40    }
41
42    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
43        match validate_bic(value) {
44            Ok(()) => vec![],
45            Err(msg) => vec![ValidationError::new(
46                path,
47                Severity::Error,
48                "BIC_CHECK",
49                msg,
50            )],
51        }
52    }
53}
54
55fn validate_bic(bic: &str) -> Result<(), String> {
56    let len = bic.len();
57    if len != 8 && len != 11 {
58        return Err(format!(
59            "BIC must be 8 or 11 characters, got {len}: `{bic}`"
60        ));
61    }
62
63    let bytes = bic.as_bytes();
64
65    // Characters 1-4: institution code — uppercase alpha only
66    for (i, &b) in bytes[..4].iter().enumerate() {
67        if !b.is_ascii_uppercase() {
68            return Err(format!(
69                "BIC institution code (chars 1-4) must be uppercase letters; \
70                 char {} is `{}`",
71                i + 1,
72                char::from(b)
73            ));
74        }
75    }
76
77    // Characters 5-6: country code — uppercase alpha only
78    for (i, &b) in bytes[4..6].iter().enumerate() {
79        if !b.is_ascii_uppercase() {
80            return Err(format!(
81                "BIC country code (chars 5-6) must be uppercase letters; \
82                 char {} is `{}`",
83                i + 5,
84                char::from(b)
85            ));
86        }
87    }
88
89    // Characters 7-8: location code — alphanumeric (uppercase)
90    for (i, &b) in bytes[6..8].iter().enumerate() {
91        if !b.is_ascii_uppercase() && !b.is_ascii_digit() {
92            return Err(format!(
93                "BIC location code (chars 7-8) must be uppercase alphanumeric; \
94                 char {} is `{}`",
95                i + 7,
96                char::from(b)
97            ));
98        }
99    }
100
101    // Characters 9-11 (optional): branch code — alphanumeric (uppercase)
102    if len == 11 {
103        for (i, &b) in bytes[8..11].iter().enumerate() {
104            if !b.is_ascii_uppercase() && !b.is_ascii_digit() {
105                return Err(format!(
106                    "BIC branch code (chars 9-11) must be uppercase alphanumeric; \
107                     char {} is `{}`",
108                    i + 9,
109                    char::from(b)
110                ));
111            }
112        }
113    }
114
115    Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::rules::Rule;
122
123    const VALID_BICS: &[&str] = &[
124        "AAAAGB2L",    // 8-char BIC
125        "AAAAGB2LXXX", // 11-char BIC (XXX = primary office)
126        "DEUTDEDB",    // Deutsche Bank Frankfurt
127        "DEUTDEDBFRA", // Deutsche Bank Frankfurt with branch
128        "BOFAUS3N",    // Bank of America
129        "BOFAUS3NXXX", // Bank of America with branch
130        "CHASUS33",    // JPMorgan Chase
131        "CHASUS33XXX", // JPMorgan Chase with branch
132    ];
133
134    const INVALID_BICS: &[&str] = &[
135        "AAAA",          // too short
136        "AAAAGB2LXXXXX", // too long (13 chars)
137        "1AAAGB2L",      // institution code has digit
138        "AAAA1B2L",      // country code has digit
139        "AAAAgb2L",      // lowercase
140        "AAAAGB2l",      // lowercase location
141        "",              // empty
142        "AAAA GB2L",     // contains space
143    ];
144
145    #[test]
146    fn valid_bics_pass() {
147        let rule = BicRule;
148        for bic in VALID_BICS {
149            let errors = rule.validate(bic, "/test");
150            assert!(
151                errors.is_empty(),
152                "Expected no errors for valid BIC `{bic}`, got: {errors:?}"
153            );
154        }
155    }
156
157    #[test]
158    fn invalid_bics_fail() {
159        let rule = BicRule;
160        for bic in INVALID_BICS {
161            let errors = rule.validate(bic, "/test");
162            assert!(
163                !errors.is_empty(),
164                "Expected errors for invalid BIC `{bic}`"
165            );
166        }
167    }
168
169    #[test]
170    fn error_has_correct_rule_id_and_path() {
171        let rule = BicRule;
172        let errors = rule.validate("INVALID", "/some/path");
173        assert_eq!(errors[0].rule_id, "BIC_CHECK");
174        assert_eq!(errors[0].path, "/some/path");
175    }
176
177    #[test]
178    fn rule_id_is_bic_check() {
179        assert_eq!(BicRule.id(), "BIC_CHECK");
180    }
181}