1use core::fmt;
20use core::str::FromStr;
21
22use hamaddr::{HamAddr, HamAddrType};
23use sha2::{Digest, Sha256};
24
25const LETTER_MIN: u16 = 1;
27const LETTER_MAX: u16 = 26;
29const TRANSFORM_BASE: u16 = 27 * 1600;
31
32#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub struct RegionCode(u16);
45
46impl RegionCode {
47 pub const fn from_u16(value: u16) -> Self {
49 Self(value)
50 }
51
52 pub const fn as_u16(self) -> u16 {
54 self.0
55 }
56
57 pub const fn from_bytes(bytes: [u8; 2]) -> Self {
59 Self(u16::from_be_bytes(bytes))
60 }
61
62 pub const fn to_bytes(self) -> [u8; 2] {
64 self.0.to_be_bytes()
65 }
66
67 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 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 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
109fn 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#[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
150impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum RegionCodeError {
206 Empty,
208 InvalidHex,
210 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 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 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 assert_eq!(RegionCode::from_u16(0xD35F).to_string(), "0xD35F");
309 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 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}