Skip to main content

mx20022_validate/rules/
country.rs

1//! ISO 3166-1 alpha-2 country code validation rule.
2//!
3//! Validates that a 2-letter uppercase code is a recognised ISO 3166-1 alpha-2
4//! country code.  The static list covers all 249 codes in the current
5//! ISO 3166 Maintenance Agency publication (2024 edition), plus the
6//! exceptional reservations commonly encountered in financial messages
7//! (XK for Kosovo).
8
9use crate::error::{Severity, ValidationError};
10use crate::rules::Rule;
11
12/// Static set of recognised ISO 3166-1 alpha-2 country codes.
13static VALID_COUNTRIES: &[&str] = &[
14    "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ",
15    "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS",
16    "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
17    "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE",
18    "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF",
19    "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
20    "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM",
21    "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC",
22    "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
23    "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA",
24    "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG",
25    "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
26    "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
27    "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO",
28    "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
29    "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
30    // User-assigned / exceptional reservations commonly used in finance
31    "XK", // Kosovo (SWIFT, EU usage)
32];
33
34/// Validates a value as an ISO 3166-1 alpha-2 country code.
35///
36/// # Examples
37///
38/// ```
39/// use mx20022_validate::rules::country::CountryCodeRule;
40/// use mx20022_validate::rules::Rule;
41///
42/// let rule = CountryCodeRule;
43///
44/// let errors = rule.validate("GB", "/path");
45/// assert!(errors.is_empty(), "GB is a valid country code");
46///
47/// let errors = rule.validate("gb", "/path");
48/// assert!(!errors.is_empty(), "Lowercase codes are rejected");
49///
50/// let errors = rule.validate("XX", "/path");
51/// assert!(!errors.is_empty(), "XX is not a valid ISO 3166-1 code");
52/// ```
53pub struct CountryCodeRule;
54
55impl Rule for CountryCodeRule {
56    fn id(&self) -> &'static str {
57        "COUNTRY_CHECK"
58    }
59
60    fn validate(&self, value: &str, path: &str) -> Vec<ValidationError> {
61        if VALID_COUNTRIES.contains(&value) {
62            return vec![];
63        }
64
65        let msg = if value.is_empty() {
66            "Country code must not be empty".to_owned()
67        } else if value.len() != 2 {
68            format!(
69                "Country code must be exactly 2 characters, got {}: `{value}`",
70                value.len()
71            )
72        } else if !value.chars().all(|c| c.is_ascii_alphabetic()) {
73            format!("Country code must be 2 alphabetic characters, got: `{value}`")
74        } else if !value.chars().all(|c| c.is_ascii_uppercase()) {
75            format!("Country code must be uppercase, got: `{value}`")
76        } else {
77            format!("Unrecognised ISO 3166-1 alpha-2 country code: `{value}`")
78        };
79
80        vec![ValidationError::new(
81            path,
82            Severity::Error,
83            "COUNTRY_CHECK",
84            msg,
85        )]
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::rules::Rule;
93
94    const VALID_CODES: &[&str] = &[
95        "US", "GB", "DE", "FR", "JP", "CN", "AU", "CA", "IN", "BR", "MX", "RU", "KR", "ZA", "SG",
96        "HK", "NL", "SE", "NO", "DK", "FI", "PL", "CZ", "HU", "RO", "UA", "TR", "SA", "AE", "QA",
97        "KW", "BH", "OM", "EG", "NG", "KE", "GH", "TZ", "MY", "ID", "TH", "PH", "VN", "PK", "BD",
98        "NZ", "CH", "AT", "BE", "IE", "PT", "ES", "IT", "GR", "IL", "AR", "CL", "CO", "PE", "VE",
99        "XK", // Kosovo exceptional reservation
100    ];
101
102    const INVALID_CODES: &[&str] = &[
103        "us",  // lowercase
104        "Gb",  // mixed case
105        "USA", // too long (3 chars)
106        "U",   // too short (1 char)
107        "12",  // numeric
108        "U1",  // contains digit
109        "",    // empty
110        "XX",  // unassigned
111        "AA",  // unassigned
112        "QQ",  // fictional
113    ];
114
115    #[test]
116    fn valid_country_codes_pass() {
117        let rule = CountryCodeRule;
118        for code in VALID_CODES {
119            let errors = rule.validate(code, "/test");
120            assert!(
121                errors.is_empty(),
122                "Expected no errors for valid code `{code}`, got: {errors:?}"
123            );
124        }
125    }
126
127    #[test]
128    fn invalid_country_codes_fail() {
129        let rule = CountryCodeRule;
130        for code in INVALID_CODES {
131            let errors = rule.validate(code, "/test");
132            assert!(
133                !errors.is_empty(),
134                "Expected errors for invalid code `{code}`"
135            );
136        }
137    }
138
139    #[test]
140    fn error_has_correct_rule_id_and_path() {
141        let rule = CountryCodeRule;
142        let errors = rule.validate("XX", "/Document/Ctry");
143        assert_eq!(errors.len(), 1);
144        assert_eq!(errors[0].rule_id, "COUNTRY_CHECK");
145        assert_eq!(errors[0].path, "/Document/Ctry");
146        assert_eq!(errors[0].severity, Severity::Error);
147    }
148
149    #[test]
150    fn rule_id_is_country_check() {
151        assert_eq!(CountryCodeRule.id(), "COUNTRY_CHECK");
152    }
153
154    #[test]
155    fn empty_code_rejected() {
156        let rule = CountryCodeRule;
157        let errors = rule.validate("", "/test");
158        assert!(!errors.is_empty());
159        assert!(errors[0].message.contains("empty"));
160    }
161
162    #[test]
163    fn three_char_code_rejected_with_length_message() {
164        let rule = CountryCodeRule;
165        let errors = rule.validate("USA", "/test");
166        assert!(!errors.is_empty());
167        assert!(
168            errors[0].message.contains("2 characters") || errors[0].message.contains("exactly"),
169            "Expected length message, got: {}",
170            errors[0].message
171        );
172    }
173
174    #[test]
175    fn lowercase_code_rejected() {
176        let rule = CountryCodeRule;
177        let errors = rule.validate("us", "/test");
178        assert!(!errors.is_empty());
179    }
180}