umsh_core/
region.rs

1//! Region-code encoding for the Region Code packet option.
2//!
3//! A region code is a 2-byte routing-domain tag. It is not an RF band plan:
4//! it scopes flood forwarding to a locally agreed area, and repeaters match
5//! it against their configured region list.
6//!
7//! Codes come from two sources, and the encoding keeps them disjoint so a
8//! code can be rendered without knowing which one produced it:
9//!
10//! - **Airport regions** encode a 3-letter IATA code with ARNCE/HAM-16.
11//! - **Named regions** take the first two bytes of the SHA-256 of the name,
12//!   transformed away from the letter space if they happen to land in it.
13//!
14//! Because the transform vacates every three-letter encoding, a code that
15//! decodes to three letters is always an airport code, and one that does
16//! not has no recoverable text form — [`Display`](core::fmt::Display)
17//! renders it as `0xXXXX`.
18
19use core::fmt;
20use core::str::FromStr;
21
22use hamaddr::{HamAddr, HamAddrType};
23use sha2::{Digest, Sha256};
24
25/// Lowest ARNCE index that denotes a letter (`A`).
26const LETTER_MIN: u16 = 1;
27/// Highest ARNCE index that denotes a letter (`Z`).
28const LETTER_MAX: u16 = 26;
29/// First code reserved for transformed named regions.
30const TRANSFORM_BASE: u16 = 27 * 1600;
31
32/// A 2-byte region identifier.
33///
34/// ```
35/// # use umsh_core::RegionCode;
36/// let sjc: RegionCode = "SJC".parse().unwrap();
37/// assert_eq!(sjc.to_bytes(), [0x78, 0x53]);
38/// assert_eq!(sjc.to_string(), "SJC");
39///
40/// let valley: RegionCode = "Rogue Valley".parse().unwrap();
41/// assert_eq!(valley.to_string(), "0xDF6F");
42/// ```
43#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct RegionCode(u16);
45
46impl RegionCode {
47    /// Wrap a raw code.
48    pub const fn from_u16(value: u16) -> Self {
49        Self(value)
50    }
51
52    /// Return the raw code.
53    pub const fn as_u16(self) -> u16 {
54        self.0
55    }
56
57    /// Wrap a code from its two wire bytes.
58    pub const fn from_bytes(bytes: [u8; 2]) -> Self {
59        Self(u16::from_be_bytes(bytes))
60    }
61
62    /// Return the code's two wire bytes.
63    pub const fn to_bytes(self) -> [u8; 2] {
64        self.0.to_be_bytes()
65    }
66
67    /// Encode a 3-letter IATA airport code.
68    ///
69    /// Case-insensitive. Anything that is not exactly three ASCII letters
70    /// is rejected, so that named regions cannot be mistaken for airports.
71    pub fn from_iata(code: &str) -> Result<Self, RegionCodeError> {
72        if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_alphabetic()) {
73            return Err(RegionCodeError::NotIata);
74        }
75        let addr = HamAddr::try_from_callsign(code).map_err(|_| RegionCodeError::NotIata)?;
76        Ok(Self(addr.chunk(0)))
77    }
78
79    /// Derive the code for a named region.
80    ///
81    /// The name is hashed verbatim, so callers that want names to compare
82    /// equal across operators must agree on the exact spelling.
83    pub fn from_name(name: &str) -> Self {
84        let digest = Sha256::digest(name.as_bytes());
85        Self(transform_letter_chunk(u16::from_be_bytes([
86            digest[0], digest[1],
87        ])))
88    }
89
90    /// Return the three letters this code decodes to, if it decodes to
91    /// three letters at all.
92    ///
93    /// `Some` means the code came from [`from_iata`](Self::from_iata):
94    /// named regions are transformed out of this space by construction.
95    pub fn letters(self) -> Option<[u8; 3]> {
96        let addr = HamAddr::from_chunks([self.0, 0, 0, 0]);
97        if !matches!(addr.get_type(), HamAddrType::Callsign) {
98            return None;
99        }
100        let mut sink = Letters::default();
101        fmt::write(&mut sink, format_args!("{addr}")).ok()?;
102        if sink.len != 3 || !sink.buf.iter().all(u8::is_ascii_alphabetic) {
103            return None;
104        }
105        Some(sink.buf)
106    }
107}
108
109/// Move a three-letter encoding into the space reserved for named regions.
110///
111/// Anything that is not three letters is already outside that space and is
112/// returned unchanged.
113fn transform_letter_chunk(encoded: u16) -> u16 {
114    let a = encoded / 1600;
115    let b = (encoded / 40) % 40;
116    let c = encoded % 40;
117
118    if ![a, b, c]
119        .iter()
120        .all(|x| (LETTER_MIN..=LETTER_MAX).contains(x))
121    {
122        return encoded;
123    }
124
125    let rank = (a - 1) * 26 * 26 + (b - 1) * 26 + (c - 1);
126    TRANSFORM_BASE + rank
127}
128
129/// A fixed-capacity sink for the at-most-three characters a single ARNCE
130/// chunk renders to. Overlong writes leave `len` past the buffer and are
131/// rejected by the caller.
132#[derive(Default)]
133struct Letters {
134    buf: [u8; 3],
135    len: usize,
136}
137
138impl fmt::Write for Letters {
139    fn write_str(&mut self, s: &str) -> fmt::Result {
140        for byte in s.bytes() {
141            if let Some(slot) = self.buf.get_mut(self.len) {
142                *slot = byte;
143            }
144            self.len += 1;
145        }
146        Ok(())
147    }
148}
149
150/// Parse a region code from its textual form.
151///
152/// Three ASCII letters are an IATA code, `0xXXXX` is a raw code, and
153/// anything else is a region name.
154impl FromStr for RegionCode {
155    type Err = RegionCodeError;
156
157    fn from_str(s: &str) -> Result<Self, Self::Err> {
158        let trimmed = s.trim();
159        if trimmed.is_empty() {
160            return Err(RegionCodeError::Empty);
161        }
162        if let Some(hex) = trimmed
163            .strip_prefix("0x")
164            .or_else(|| trimmed.strip_prefix("0X"))
165        {
166            if hex.is_empty() || hex.len() > 4 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
167                return Err(RegionCodeError::InvalidHex);
168            }
169            let value = u16::from_str_radix(hex, 16).map_err(|_| RegionCodeError::InvalidHex)?;
170            return Ok(Self(value));
171        }
172        Self::from_iata(trimmed).or_else(|_| Ok(Self::from_name(trimmed)))
173    }
174}
175
176impl fmt::Display for RegionCode {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self.letters().as_ref().map(|l| core::str::from_utf8(l)) {
179            Some(Ok(text)) => f.write_str(text),
180            _ => write!(f, "0x{:04X}", self.0),
181        }
182    }
183}
184
185impl fmt::Debug for RegionCode {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        write!(f, "RegionCode({self})")
188    }
189}
190
191impl From<RegionCode> for [u8; 2] {
192    fn from(code: RegionCode) -> Self {
193        code.to_bytes()
194    }
195}
196
197impl From<[u8; 2]> for RegionCode {
198    fn from(bytes: [u8; 2]) -> Self {
199        Self::from_bytes(bytes)
200    }
201}
202
203/// Why a string could not be read as a region code.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum RegionCodeError {
206    /// The input was empty or only whitespace.
207    Empty,
208    /// A `0x`-prefixed value was not one to four hex digits.
209    InvalidHex,
210    /// The input was not exactly three ASCII letters.
211    NotIata,
212}
213
214impl fmt::Display for RegionCodeError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            Self::Empty => f.write_str("empty region code"),
218            Self::InvalidHex => f.write_str("expected one to four hex digits after `0x`"),
219            Self::NotIata => f.write_str("expected a three-letter IATA code"),
220        }
221    }
222}
223
224#[cfg(feature = "std")]
225impl std::error::Error for RegionCodeError {}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn encodes_the_iata_codes_from_the_specification() {
233        assert_eq!(RegionCode::from_iata("SJC").unwrap().as_u16(), 0x7853);
234        assert_eq!(RegionCode::from_iata("MFR").unwrap().as_u16(), 0x5242);
235    }
236
237    #[test]
238    fn encodes_iata_codes_case_insensitively() {
239        assert_eq!(
240            RegionCode::from_iata("sjc").unwrap(),
241            RegionCode::from_iata("SJC").unwrap()
242        );
243    }
244
245    #[test]
246    fn rejects_anything_that_is_not_three_letters_as_iata() {
247        for input in ["SJ", "SJCA", "SJ1", "SJ-", "", "S J"] {
248            assert_eq!(
249                RegionCode::from_iata(input),
250                Err(RegionCodeError::NotIata),
251                "{input:?} should not parse as an IATA code"
252            );
253        }
254    }
255
256    #[test]
257    fn derives_named_regions_from_the_hash_prefix() {
258        assert_eq!(RegionCode::from_name("Rogue Valley").as_u16(), 0xDF6F);
259        assert_eq!(RegionCode::from_name("SF Bay Area").as_u16(), 0x31D9);
260    }
261
262    #[test]
263    fn transforms_a_named_region_that_lands_on_three_letters() {
264        // SHA-256("Southern Oregon") begins 0x6AF2, which decodes to `QDR`.
265        assert_eq!(transform_letter_chunk(0x6AF2), 0xD35F);
266        assert_eq!(RegionCode::from_name("Southern Oregon").as_u16(), 0xD35F);
267    }
268
269    #[test]
270    fn leaves_a_named_region_outside_the_letter_space_alone() {
271        assert_eq!(transform_letter_chunk(0xDF6F), 0xDF6F);
272        assert_eq!(transform_letter_chunk(0x31D9), 0x31D9);
273    }
274
275    #[test]
276    fn no_named_region_can_collide_with_an_airport_region() {
277        // The transform is what guarantees this, so assert the property
278        // over the whole 16-bit space rather than trusting the examples.
279        for raw in 0..=u16::MAX {
280            let transformed = RegionCode::from_u16(transform_letter_chunk(raw));
281            assert_eq!(
282                transformed.letters(),
283                None,
284                "0x{raw:04X} transformed to a three-letter code"
285            );
286        }
287    }
288
289    #[test]
290    fn every_airport_region_round_trips_through_its_text_form() {
291        for a in b'A'..=b'Z' {
292            for b in b'A'..=b'Z' {
293                for c in b'A'..=b'Z' {
294                    let text = core::str::from_utf8(&[a, b, c]).unwrap().to_string();
295                    let code = RegionCode::from_iata(&text).unwrap();
296                    assert_eq!(code.letters(), Some([a, b, c]));
297                    assert_eq!(code.to_string(), text);
298                    assert_eq!(text.parse::<RegionCode>().unwrap(), code);
299                }
300            }
301        }
302    }
303
304    #[test]
305    fn displays_codes_without_a_text_form_as_hex() {
306        assert_eq!(RegionCode::from_u16(0xDF6F).to_string(), "0xDF6F");
307        // Decodes to `654`, which is not three letters.
308        assert_eq!(RegionCode::from_u16(0xD35F).to_string(), "0xD35F");
309        // Below the chunk range entirely.
310        assert_eq!(RegionCode::from_u16(0x0100).to_string(), "0x0100");
311        assert_eq!(RegionCode::from_u16(0).to_string(), "0x0000");
312    }
313
314    #[test]
315    fn parses_raw_hex_codes() {
316        assert_eq!("0x7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
317        assert_eq!("0X7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
318        assert_eq!("0xdf6f".parse::<RegionCode>().unwrap().as_u16(), 0xDF6F);
319        assert_eq!("0x1".parse::<RegionCode>().unwrap().as_u16(), 1);
320    }
321
322    #[test]
323    fn rejects_malformed_hex_codes() {
324        for input in ["0x", "0x12345", "0xzz", "0x 12", "0x+1"] {
325            assert_eq!(
326                input.parse::<RegionCode>(),
327                Err(RegionCodeError::InvalidHex),
328                "{input:?} should not parse as a hex code"
329            );
330        }
331    }
332
333    #[test]
334    fn parses_anything_else_as_a_region_name() {
335        assert_eq!(
336            "Rogue Valley".parse::<RegionCode>().unwrap(),
337            RegionCode::from_name("Rogue Valley")
338        );
339        // Four letters is a name, not an airport.
340        assert_eq!(
341            "OHIO".parse::<RegionCode>().unwrap(),
342            RegionCode::from_name("OHIO")
343        );
344    }
345
346    #[test]
347    fn rejects_an_empty_region_code() {
348        assert_eq!("".parse::<RegionCode>(), Err(RegionCodeError::Empty));
349        assert_eq!("   ".parse::<RegionCode>(), Err(RegionCodeError::Empty));
350    }
351
352    #[test]
353    fn round_trips_through_the_wire_bytes() {
354        let code = RegionCode::from_iata("SJC").unwrap();
355        assert_eq!(code.to_bytes(), [0x78, 0x53]);
356        assert_eq!(RegionCode::from_bytes([0x78, 0x53]), code);
357    }
358}