Skip to main content

geo_kit/
country.rs

1//! ISO 3166-1 alpha-2 country code newtype.
2
3extern crate alloc;
4
5use alloc::string::{String, ToString};
6use core::fmt;
7use core::ops::Deref;
8use core::str::FromStr;
9
10use crate::error::GeoError;
11
12/// A validated ISO 3166-1 alpha-2 country code, e.g. `GB`, `US`.
13///
14/// Validation:
15/// - exactly 2 characters
16/// - `^[A-Z]{2}$` (uppercase ASCII)
17/// - when `regex` feature is enabled, regex is used; otherwise hand-rolled check
18///
19/// The type accepts any `A-Z` pair as syntactically valid; [`CountryCode::country_name`]
20/// returns a human-readable name for known codes and `"Unknown"` otherwise.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23#[cfg_attr(feature = "serde", serde(transparent))]
24pub struct CountryCode(String);
25
26impl CountryCode {
27    /// Parse and validate a country code.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`GeoError::InvalidCountry`] if validation fails.
32    pub fn parse(s: &str) -> Result<Self, GeoError> {
33        validate_country(s)
34    }
35
36    /// Create from an owned string.
37    ///
38    /// # Errors
39    ///
40    /// Returns [`GeoError::InvalidCountry`] if validation fails.
41    pub fn new(s: String) -> Result<Self, GeoError> {
42        validate_country(&s)
43    }
44
45    /// Return as string slice.
46    #[must_use]
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50
51    /// Consume and return inner string.
52    #[must_use]
53    pub fn into_inner(self) -> String {
54        self.0
55    }
56
57    /// Return the English short name for this country.
58    ///
59    /// Returns `"Unknown"` if the code is syntactically valid but not in the known mapping.
60    #[must_use]
61    pub fn country_name(&self) -> &str {
62        match self.0.as_str() {
63            "AD" => "Andorra",
64            "AE" => "United Arab Emirates",
65            "AF" => "Afghanistan",
66            "AG" => "Antigua and Barbuda",
67            "AI" => "Anguilla",
68            "AL" => "Albania",
69            "AM" => "Armenia",
70            "AO" => "Angola",
71            "AQ" => "Antarctica",
72            "AR" => "Argentina",
73            "AS" => "American Samoa",
74            "AT" => "Austria",
75            "AU" => "Australia",
76            "AW" => "Aruba",
77            "AX" => "Åland Islands",
78            "AZ" => "Azerbaijan",
79            "BA" => "Bosnia and Herzegovina",
80            "BB" => "Barbados",
81            "BD" => "Bangladesh",
82            "BE" => "Belgium",
83            "BF" => "Burkina Faso",
84            "BG" => "Bulgaria",
85            "BH" => "Bahrain",
86            "BI" => "Burundi",
87            "BJ" => "Benin",
88            "BM" => "Bermuda",
89            "BN" => "Brunei",
90            "BO" => "Bolivia",
91            "BQ" => "Bonaire, Sint Eustatius and Saba",
92            "BR" => "Brazil",
93            "BS" => "Bahamas",
94            "BT" => "Bhutan",
95            "BV" => "Bouvet Island",
96            "BW" => "Botswana",
97            "BY" => "Belarus",
98            "BZ" => "Belize",
99            "CA" => "Canada",
100            "CC" => "Cocos (Keeling) Islands",
101            "CD" => "Congo, Democratic Republic of the",
102            "CF" => "Central African Republic",
103            "CG" => "Congo",
104            "CH" => "Switzerland",
105            "CI" => "Côte d'Ivoire",
106            "CK" => "Cook Islands",
107            "CL" => "Chile",
108            "CM" => "Cameroon",
109            "CN" => "China",
110            "CO" => "Colombia",
111            "CR" => "Costa Rica",
112            "CU" => "Cuba",
113            "CV" => "Cabo Verde",
114            "CW" => "Curaçao",
115            "CX" => "Christmas Island",
116            "CY" => "Cyprus",
117            "CZ" => "Czechia",
118            "DE" => "Germany",
119            "DJ" => "Djibouti",
120            "DK" => "Denmark",
121            "DM" => "Dominica",
122            "DO" => "Dominican Republic",
123            "DZ" => "Algeria",
124            "EC" => "Ecuador",
125            "EE" => "Estonia",
126            "EG" => "Egypt",
127            "EH" => "Western Sahara",
128            "ER" => "Eritrea",
129            "ES" => "Spain",
130            "ET" => "Ethiopia",
131            "FI" => "Finland",
132            "FJ" => "Fiji",
133            "FK" => "Falkland Islands (Malvinas)",
134            "FM" => "Micronesia (Federated States of)",
135            "FO" => "Faroe Islands",
136            "FR" => "France",
137            "GA" => "Gabon",
138            "GB" => "United Kingdom",
139            "GD" => "Grenada",
140            "GE" => "Georgia",
141            "GF" => "French Guiana",
142            "GG" => "Guernsey",
143            "GH" => "Ghana",
144            "GI" => "Gibraltar",
145            "GL" => "Greenland",
146            "GM" => "Gambia",
147            "GN" => "Guinea",
148            "GP" => "Guadeloupe",
149            "GQ" => "Equatorial Guinea",
150            "GR" => "Greece",
151            "GS" => "South Georgia and the South Sandwich Islands",
152            "GT" => "Guatemala",
153            "GU" => "Guam",
154            "GW" => "Guinea-Bissau",
155            "GY" => "Guyana",
156            "HK" => "Hong Kong",
157            "HM" => "Heard Island and McDonald Islands",
158            "HN" => "Honduras",
159            "HR" => "Croatia",
160            "HT" => "Haiti",
161            "HU" => "Hungary",
162            "ID" => "Indonesia",
163            "IE" => "Ireland",
164            "IL" => "Israel",
165            "IM" => "Isle of Man",
166            "IN" => "India",
167            "IO" => "British Indian Ocean Territory",
168            "IQ" => "Iraq",
169            "IR" => "Iran",
170            "IS" => "Iceland",
171            "IT" => "Italy",
172            "JE" => "Jersey",
173            "JM" => "Jamaica",
174            "JO" => "Jordan",
175            "JP" => "Japan",
176            "KE" => "Kenya",
177            "KG" => "Kyrgyzstan",
178            "KH" => "Cambodia",
179            "KI" => "Kiribati",
180            "KM" => "Comoros",
181            "KN" => "Saint Kitts and Nevis",
182            "KP" => "Korea (Democratic People's Republic of)",
183            "KR" => "Korea, Republic of",
184            "KW" => "Kuwait",
185            "KY" => "Cayman Islands",
186            "KZ" => "Kazakhstan",
187            "LA" => "Lao People's Democratic Republic",
188            "LB" => "Lebanon",
189            "LC" => "Saint Lucia",
190            "LI" => "Liechtenstein",
191            "LK" => "Sri Lanka",
192            "LR" => "Liberia",
193            "LS" => "Lesotho",
194            "LT" => "Lithuania",
195            "LU" => "Luxembourg",
196            "LV" => "Latvia",
197            "LY" => "Libya",
198            "MA" => "Morocco",
199            "MC" => "Monaco",
200            "MD" => "Moldova",
201            "ME" => "Montenegro",
202            "MF" => "Saint Martin (French part)",
203            "MG" => "Madagascar",
204            "MH" => "Marshall Islands",
205            "MK" => "North Macedonia",
206            "ML" => "Mali",
207            "MM" => "Myanmar",
208            "MN" => "Mongolia",
209            "MO" => "Macao",
210            "MP" => "Northern Mariana Islands",
211            "MQ" => "Martinique",
212            "MR" => "Mauritania",
213            "MS" => "Montserrat",
214            "MT" => "Malta",
215            "MU" => "Mauritius",
216            "MV" => "Maldives",
217            "MW" => "Malawi",
218            "MX" => "Mexico",
219            "MY" => "Malaysia",
220            "MZ" => "Mozambique",
221            "NA" => "Namibia",
222            "NC" => "New Caledonia",
223            "NE" => "Niger",
224            "NF" => "Norfolk Island",
225            "NG" => "Nigeria",
226            "NI" => "Nicaragua",
227            "NL" => "Netherlands",
228            "NO" => "Norway",
229            "NP" => "Nepal",
230            "NR" => "Nauru",
231            "NU" => "Niue",
232            "NZ" => "New Zealand",
233            "OM" => "Oman",
234            "PA" => "Panama",
235            "PE" => "Peru",
236            "PF" => "French Polynesia",
237            "PG" => "Papua New Guinea",
238            "PH" => "Philippines",
239            "PK" => "Pakistan",
240            "PL" => "Poland",
241            "PM" => "Saint Pierre and Miquelon",
242            "PN" => "Pitcairn",
243            "PR" => "Puerto Rico",
244            "PS" => "Palestine, State of",
245            "PT" => "Portugal",
246            "PW" => "Palau",
247            "PY" => "Paraguay",
248            "QA" => "Qatar",
249            "RE" => "Réunion",
250            "RO" => "Romania",
251            "RS" => "Serbia",
252            "RU" => "Russian Federation",
253            "RW" => "Rwanda",
254            "SA" => "Saudi Arabia",
255            "SB" => "Solomon Islands",
256            "SC" => "Seychelles",
257            "SD" => "Sudan",
258            "SE" => "Sweden",
259            "SG" => "Singapore",
260            "SH" => "Saint Helena, Ascension and Tristan da Cunha",
261            "SI" => "Slovenia",
262            "SJ" => "Svalbard and Jan Mayen",
263            "SK" => "Slovakia",
264            "SL" => "Sierra Leone",
265            "SM" => "San Marino",
266            "SN" => "Senegal",
267            "SO" => "Somalia",
268            "SR" => "Suriname",
269            "SS" => "South Sudan",
270            "ST" => "Sao Tome and Principe",
271            "SV" => "El Salvador",
272            "SX" => "Sint Maarten (Dutch part)",
273            "SY" => "Syrian Arab Republic",
274            "SZ" => "Eswatini",
275            "TC" => "Turks and Caicos Islands",
276            "TD" => "Chad",
277            "TF" => "French Southern Territories",
278            "TG" => "Togo",
279            "TH" => "Thailand",
280            "TJ" => "Tajikistan",
281            "TK" => "Tokelau",
282            "TL" => "Timor-Leste",
283            "TM" => "Turkmenistan",
284            "TN" => "Tunisia",
285            "TO" => "Tonga",
286            "TR" => "Turkey",
287            "TT" => "Trinidad and Tobago",
288            "TV" => "Tuvalu",
289            "TW" => "Taiwan",
290            "TZ" => "Tanzania",
291            "UA" => "Ukraine",
292            "UG" => "Uganda",
293            "UM" => "United States Minor Outlying Islands",
294            "US" => "United States of America",
295            "UY" => "Uruguay",
296            "UZ" => "Uzbekistan",
297            "VA" => "Holy See",
298            "VC" => "Saint Vincent and the Grenadines",
299            "VE" => "Venezuela",
300            "VG" => "Virgin Islands (British)",
301            "VI" => "Virgin Islands (U.S.)",
302            "VN" => "Viet Nam",
303            "VU" => "Vanuatu",
304            "WF" => "Wallis and Futuna",
305            "WS" => "Samoa",
306            "YE" => "Yemen",
307            "YT" => "Mayotte",
308            "ZA" => "South Africa",
309            "ZM" => "Zambia",
310            "ZW" => "Zimbabwe",
311            _ => "Unknown",
312        }
313    }
314}
315
316fn validate_country(input: &str) -> Result<CountryCode, GeoError> {
317    if input.is_empty() {
318        return Err(GeoError::InvalidCountry("country code is empty".to_string()));
319    }
320    if input.contains('\r') || input.contains('\n') || input.contains(' ') || input.contains('\t') {
321        return Err(GeoError::InvalidCountry(
322            "country code must not contain whitespace or control".to_string(),
323        ));
324    }
325
326    #[cfg(feature = "regex")]
327    {
328        #[cfg(feature = "std")]
329        {
330            use std::sync::OnceLock;
331            static RE: OnceLock<regex::Regex> = OnceLock::new();
332            let re = match RE.get() {
333                Some(r) => r,
334                None => {
335                    let init = match regex::Regex::new(r"^[A-Z]{2}$") {
336                        Ok(r) => r,
337                        Err(_) => {
338                            return Err(GeoError::InvalidCountry("internal regex error".to_string()))
339                        }
340                    };
341                    let _ = RE.set(init);
342                    match RE.get() {
343                        Some(r) => r,
344                        None => return Err(GeoError::InvalidCountry("internal regex error".to_string())),
345                    }
346                }
347            };
348            if !re.is_match(input) {
349                return Err(GeoError::InvalidCountry(alloc::format!(
350                    "country code '{}' must match ISO 3166-1 alpha-2 ^[A-Z]{{2}}$",
351                    input
352                )));
353            }
354        }
355        #[cfg(not(feature = "std"))]
356        {
357            let re = match regex::Regex::new(r"^[A-Z]{2}$") {
358                Ok(r) => r,
359                Err(_) => return Err(GeoError::InvalidCountry("internal regex error".to_string())),
360            };
361            if !re.is_match(input) {
362                return Err(GeoError::InvalidCountry(alloc::format!(
363                    "country code '{}' must match ISO 3166-1 alpha-2 ^[A-Z]{{2}}$",
364                    input
365                )));
366            }
367        }
368        Ok(CountryCode(input.to_string()))
369    }
370
371    #[cfg(not(feature = "regex"))]
372    {
373        if input.len() != 2 {
374            return Err(GeoError::InvalidCountry(alloc::format!(
375                "country code '{}' must be exactly 2 characters",
376                input
377            )));
378        }
379        for ch in input.chars() {
380            if !ch.is_ascii_uppercase() {
381                return Err(GeoError::InvalidCountry(alloc::format!(
382                    "country code '{}' must be 2 uppercase A-Z letters",
383                    input
384                )));
385            }
386        }
387        Ok(CountryCode(input.to_string()))
388    }
389}
390
391/// Returns `true` if `s` is a valid ISO 3166-1 alpha-2 country code.
392#[must_use]
393pub fn is_valid_country_code(s: &str) -> bool {
394    validate_country(s).is_ok()
395}
396
397impl Deref for CountryCode {
398    type Target = str;
399    fn deref(&self) -> &Self::Target {
400        &self.0
401    }
402}
403
404impl fmt::Display for CountryCode {
405    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
406        f.write_str(&self.0)
407    }
408}
409
410impl TryFrom<String> for CountryCode {
411    type Error = GeoError;
412    fn try_from(value: String) -> Result<Self, Self::Error> {
413        CountryCode::new(value)
414    }
415}
416
417impl TryFrom<&str> for CountryCode {
418    type Error = GeoError;
419    fn try_from(value: &str) -> Result<Self, Self::Error> {
420        CountryCode::parse(value)
421    }
422}
423
424impl FromStr for CountryCode {
425    type Err = GeoError;
426    fn from_str(s: &str) -> Result<Self, Self::Err> {
427        CountryCode::parse(s)
428    }
429}
430
431impl AsRef<str> for CountryCode {
432    fn as_ref(&self) -> &str {
433        &self.0
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn valid_codes() {
443        assert!(CountryCode::parse("GB").is_ok());
444        assert_eq!(CountryCode::parse("GB").unwrap().country_name(), "United Kingdom");
445        assert!(CountryCode::parse("US").is_ok());
446        assert_eq!(CountryCode::parse("US").unwrap().country_name(), "United States of America");
447        assert!(CountryCode::parse("DE").is_ok());
448        assert_eq!(CountryCode::parse("FR").unwrap().country_name(), "France");
449        assert!(CountryCode::parse("JP").is_ok());
450        assert!(CountryCode::parse("ZZ").is_ok()); // syntactically valid, unknown name
451        assert_eq!(CountryCode::parse("ZZ").unwrap().country_name(), "Unknown");
452    }
453
454    #[test]
455    fn invalid_lowercase() {
456        assert!(CountryCode::parse("gb").is_err());
457        assert!(CountryCode::parse("Gb").is_err());
458    }
459
460    #[test]
461    fn invalid_length() {
462        assert!(CountryCode::parse("").is_err());
463        assert!(CountryCode::parse("G").is_err());
464        assert!(CountryCode::parse("GBR").is_err());
465        assert!(CountryCode::parse("USA").is_err());
466    }
467
468    #[test]
469    fn invalid_chars() {
470        assert!(CountryCode::parse("12").is_err());
471        assert!(CountryCode::parse("G1").is_err());
472        assert!(CountryCode::parse("G ").is_err());
473        assert!(CountryCode::parse("G\n").is_err());
474    }
475
476    #[test]
477    fn is_valid_helper() {
478        assert!(is_valid_country_code("GB"));
479        assert!(!is_valid_country_code("gb"));
480    }
481}