Skip to main content

use_region/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5
6/// A normalized region subtag.
7#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
8pub struct RegionCode {
9    value: String,
10}
11
12impl RegionCode {
13    /// Parses and normalizes a region subtag.
14    #[must_use]
15    pub fn new(input: &str) -> Option<Self> {
16        parse_region_code(input)
17    }
18
19    /// Returns the normalized region subtag.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.value
23    }
24
25    /// Consumes the region code and returns the normalized string.
26    #[must_use]
27    pub fn into_string(self) -> String {
28        self.value
29    }
30
31    /// Returns `true` when this region subtag is numeric.
32    #[must_use]
33    pub fn is_numeric(&self) -> bool {
34        self.value.bytes().all(|byte| byte.is_ascii_digit())
35    }
36}
37
38impl AsRef<str> for RegionCode {
39    fn as_ref(&self) -> &str {
40        self.as_str()
41    }
42}
43
44impl fmt::Display for RegionCode {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        formatter.write_str(self.as_str())
47    }
48}
49
50/// Parses a region subtag and normalizes alphabetic regions to uppercase.
51#[must_use]
52pub fn parse_region_code(input: &str) -> Option<RegionCode> {
53    normalize_region_code(input).map(|value| RegionCode { value })
54}
55
56/// Returns `true` when the input is a 2-letter or 3-digit region subtag.
57#[must_use]
58pub fn is_region_code(input: &str) -> bool {
59    normalize_region_code(input).is_some()
60}
61
62/// Normalizes a 2-letter or 3-digit region subtag.
63#[must_use]
64pub fn normalize_region_code(input: &str) -> Option<String> {
65    let trimmed = input.trim();
66    if trimmed.len() == 2 && trimmed.bytes().all(|byte| byte.is_ascii_alphabetic()) {
67        return Some(trimmed.to_ascii_uppercase());
68    }
69
70    if trimmed.len() == 3 && trimmed.bytes().all(|byte| byte.is_ascii_digit()) {
71        return Some(trimmed.to_string());
72    }
73
74    None
75}
76
77#[cfg(test)]
78mod tests {
79    use super::{RegionCode, is_region_code, normalize_region_code, parse_region_code};
80
81    #[test]
82    fn accepts_common_region_examples() {
83        for region in ["US", "GB", "CA", "JP", "DE", "419"] {
84            assert!(is_region_code(region));
85            assert_eq!(parse_region_code(region).unwrap().as_str(), region);
86        }
87    }
88
89    #[test]
90    fn normalizes_alpha_regions_to_uppercase() {
91        assert_eq!(normalize_region_code("us"), Some("US".to_string()));
92        assert_eq!(normalize_region_code("  gb  "), Some("GB".to_string()));
93        assert_eq!(RegionCode::new("419").unwrap().as_str(), "419");
94        assert!(RegionCode::new("419").unwrap().is_numeric());
95    }
96
97    #[test]
98    fn rejects_invalid_region_shapes() {
99        for region in ["", "U", "USA", "U1", "41A", "1234", "US-CA", "日本"] {
100            assert!(!is_region_code(region));
101            assert!(parse_region_code(region).is_none());
102        }
103    }
104}