1use core::fmt;
29use core::str::FromStr;
30
31use hamaddr::{HamAddr, HamAddrType};
32use sha2::{Digest, Sha256};
33
34const LETTER_MIN: u16 = 1;
36const LETTER_MAX: u16 = 26;
38const LETTERS: u16 = 26;
40
41const TRANSFORM_BASE: u16 = 27 * 1600;
44const TWO_LETTER_BASE: u16 = TRANSFORM_BASE + LETTERS * LETTERS * LETTERS;
46const ONE_LETTER_BASE: u16 = TWO_LETTER_BASE + LETTERS * LETTERS;
48
49const SHORT_CODE_MAX_LEN: usize = 3;
51
52pub const REGION_NAME_MAX_LEN: usize = 24;
58
59#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub struct RegionCode(u16);
75
76impl RegionCode {
77 pub const fn from_u16(value: u16) -> Self {
79 Self(value)
80 }
81
82 pub const fn as_u16(self) -> u16 {
84 self.0
85 }
86
87 pub const fn from_bytes(bytes: [u8; 2]) -> Self {
89 Self(u16::from_be_bytes(bytes))
90 }
91
92 pub const fn to_bytes(self) -> [u8; 2] {
94 self.0.to_be_bytes()
95 }
96
97 pub fn from_short_code(code: &str) -> Result<Self, RegionCodeError> {
107 if code.is_empty()
108 || code.len() > SHORT_CODE_MAX_LEN
109 || !code.bytes().all(|b| b.is_ascii_alphanumeric())
110 {
111 return Err(RegionCodeError::NotShortCode);
112 }
113 let addr = HamAddr::try_from_callsign(code).map_err(|_| RegionCodeError::NotShortCode)?;
114 Ok(Self(addr.chunk(0)))
115 }
116
117 pub fn from_name(name: &str) -> Self {
124 let mut hasher = Sha256::new();
125 for byte in name.bytes() {
126 hasher.update([byte.to_ascii_lowercase()]);
127 }
128 let digest = hasher.finalize();
129 Self(transform_letter_chunk(u16::from_be_bytes([
130 digest[0], digest[1],
131 ])))
132 }
133
134 pub fn letters(self) -> Option<Letters> {
142 let addr = HamAddr::from_chunks([self.0, 0, 0, 0]);
143 if !matches!(addr.get_type(), HamAddrType::Callsign) {
144 return None;
145 }
146 let mut sink = LetterSink::default();
147 fmt::write(&mut sink, format_args!("{addr}")).ok()?;
148 let text = sink.buf.get(..sink.len)?;
149 if text.is_empty() || !text.iter().all(u8::is_ascii_alphabetic) {
150 return None;
151 }
152 Some(Letters {
153 buf: sink.buf,
154 len: sink.len as u8,
155 })
156 }
157}
158
159#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
164pub struct Letters {
165 buf: [u8; SHORT_CODE_MAX_LEN],
166 len: u8,
167}
168
169impl Letters {
170 pub fn as_str(&self) -> &str {
172 core::str::from_utf8(&self.buf[..self.len as usize]).unwrap_or_default()
174 }
175}
176
177impl core::ops::Deref for Letters {
178 type Target = str;
179
180 fn deref(&self) -> &str {
181 self.as_str()
182 }
183}
184
185impl fmt::Display for Letters {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 f.write_str(self.as_str())
188 }
189}
190
191impl fmt::Debug for Letters {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 fmt::Debug::fmt(self.as_str(), f)
194 }
195}
196
197fn transform_letter_chunk(encoded: u16) -> u16 {
204 let a = encoded / 1600;
205 let b = (encoded / 40) % 40;
206 let c = encoded % 40;
207
208 let is_letter = |x: u16| (LETTER_MIN..=LETTER_MAX).contains(&x);
209 if !is_letter(a) {
210 return encoded;
211 }
212
213 match (is_letter(b), b, is_letter(c), c) {
214 (true, _, true, _) => {
215 TRANSFORM_BASE + (a - 1) * LETTERS * LETTERS + (b - 1) * LETTERS + (c - 1)
216 }
217 (true, _, false, 0) => TWO_LETTER_BASE + (a - 1) * LETTERS + (b - 1),
218 (false, 0, false, 0) => ONE_LETTER_BASE + (a - 1),
219 _ => encoded,
220 }
221}
222
223#[derive(Default)]
227struct LetterSink {
228 buf: [u8; SHORT_CODE_MAX_LEN],
229 len: usize,
230}
231
232impl fmt::Write for LetterSink {
233 fn write_str(&mut self, s: &str) -> fmt::Result {
234 for byte in s.bytes() {
235 if let Some(slot) = self.buf.get_mut(self.len) {
236 *slot = byte;
237 }
238 self.len += 1;
239 }
240 Ok(())
241 }
242}
243
244impl FromStr for RegionCode {
253 type Err = RegionCodeError;
254
255 fn from_str(s: &str) -> Result<Self, Self::Err> {
256 let trimmed = s.trim();
257 if trimmed.is_empty() {
258 return Err(RegionCodeError::Empty);
259 }
260 if trimmed.len() > REGION_NAME_MAX_LEN {
261 return Err(RegionCodeError::TooLong);
262 }
263 if let Some(hex) = trimmed
264 .strip_prefix("0x")
265 .or_else(|| trimmed.strip_prefix("0X"))
266 && hex.len() == 4
267 && hex.bytes().all(|b| b.is_ascii_hexdigit())
268 && let Ok(value) = u16::from_str_radix(hex, 16)
269 {
270 return Ok(Self(value));
271 }
272 Self::from_short_code(trimmed).or_else(|_| Ok(Self::from_name(trimmed)))
273 }
274}
275
276impl fmt::Display for RegionCode {
277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
278 match self.letters() {
279 Some(letters) => f.write_str(&letters),
280 None => write!(f, "0x{:04X}", self.0),
281 }
282 }
283}
284
285impl fmt::Debug for RegionCode {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 write!(f, "RegionCode({self})")
288 }
289}
290
291impl From<RegionCode> for [u8; 2] {
292 fn from(code: RegionCode) -> Self {
293 code.to_bytes()
294 }
295}
296
297impl From<[u8; 2]> for RegionCode {
298 fn from(bytes: [u8; 2]) -> Self {
299 Self::from_bytes(bytes)
300 }
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum RegionCodeError {
306 Empty,
308 TooLong,
310 NotShortCode,
312}
313
314impl fmt::Display for RegionCodeError {
315 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
316 match self {
317 Self::Empty => f.write_str("empty region code"),
318 Self::TooLong => write!(f, "region name longer than {REGION_NAME_MAX_LEN} bytes"),
319 Self::NotShortCode => f.write_str("expected one to three letters or digits"),
320 }
321 }
322}
323
324#[cfg(feature = "std")]
325impl std::error::Error for RegionCodeError {}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn encodes_the_short_codes_from_the_specification() {
333 assert_eq!(RegionCode::from_short_code("SJC").unwrap().as_u16(), 0x7853);
334 assert_eq!(RegionCode::from_short_code("MFR").unwrap().as_u16(), 0x5242);
335 assert_eq!(RegionCode::from_short_code("US").unwrap().as_u16(), 0x8638);
336 assert_eq!(RegionCode::from_short_code("WA").unwrap().as_u16(), 0x8FE8);
337 }
338
339 #[test]
340 fn encodes_short_codes_case_insensitively() {
341 for (lower, upper) in [("sjc", "SJC"), ("us", "US"), ("w7", "W7")] {
342 assert_eq!(
343 RegionCode::from_short_code(lower).unwrap(),
344 RegionCode::from_short_code(upper).unwrap(),
345 "{lower:?} and {upper:?} should be one code"
346 );
347 }
348 }
349
350 #[test]
351 fn rejects_anything_that_is_not_one_to_three_alphanumerics() {
352 for input in ["SJCA", "SJ-", "", "S J", "SJ/", "^SJ", "Rogue"] {
356 assert_eq!(
357 RegionCode::from_short_code(input),
358 Err(RegionCodeError::NotShortCode),
359 "{input:?} should not parse as a short code"
360 );
361 }
362 }
363
364 #[test]
365 fn encodes_short_codes_bearing_digits_without_making_them_readable() {
366 for input in ["W7", "5", "0A1"] {
369 let code = RegionCode::from_short_code(input).unwrap();
370 assert_eq!(
371 input.parse::<RegionCode>().unwrap(),
372 code,
373 "{input:?} should parse as the short code it is"
374 );
375 assert_eq!(code.letters(), None, "{input:?} should have no reading");
376 assert_eq!(code.to_string(), format!("0x{:04X}", code.as_u16()));
377 }
378 }
379
380 #[test]
381 fn distinct_short_codes_never_share_a_region_code() {
382 let mut seen = std::collections::HashMap::new();
385 let alphabet: Vec<u8> = (b'A'..=b'Z').chain(b'0'..=b'9').collect();
386 let mut push = |text: String| {
387 let code = RegionCode::from_short_code(&text).unwrap();
388 if let Some(other) = seen.insert(code, text.clone()) {
389 panic!("{text:?} and {other:?} both encode to {code:?}");
390 }
391 };
392 for &a in &alphabet {
393 push(String::from_utf8(vec![a]).unwrap());
394 for &b in &alphabet {
395 push(String::from_utf8(vec![a, b]).unwrap());
396 for &c in &alphabet {
397 push(String::from_utf8(vec![a, b, c]).unwrap());
398 }
399 }
400 }
401 assert_eq!(seen.len(), 36 + 36 * 36 + 36 * 36 * 36);
402 }
403
404 #[test]
405 fn derives_named_regions_from_the_hash_prefix() {
406 assert_eq!(RegionCode::from_name("Willamette Valley").as_u16(), 0xB02D);
407 assert_eq!(RegionCode::from_name("East Bay").as_u16(), 0x36E2);
408 }
409
410 #[test]
411 fn derives_named_regions_case_insensitively() {
412 for spelling in ["rogue valley", "ROGUE VALLEY", "RoGuE vAlLeY"] {
413 assert_eq!(
414 RegionCode::from_name(spelling),
415 RegionCode::from_name("Rogue Valley"),
416 "{spelling:?} should derive the same region"
417 );
418 assert_eq!(
419 spelling.parse::<RegionCode>().unwrap(),
420 "Rogue Valley".parse::<RegionCode>().unwrap(),
421 "{spelling:?} should parse to the same region"
422 );
423 }
424 }
425
426 #[test]
427 fn transforms_a_named_region_that_lands_on_three_letters() {
428 assert_eq!(transform_letter_chunk(0x3F56), 0xC0F9);
430 assert_eq!(RegionCode::from_name("Rogue Valley").as_u16(), 0xC0F9);
431 }
432
433 #[test]
434 fn transforms_a_named_region_that_lands_on_two_letters() {
435 assert_eq!(transform_letter_chunk(0x5FA0), 0xEEDF);
439 assert_eq!(RegionCode::from_name("Wasatch Front").as_u16(), 0xEEDF);
440 assert_eq!(RegionCode::from_u16(0xEEDF).to_string(), "0xEEDF");
441 }
442
443 #[test]
444 fn the_transform_blocks_sit_where_the_specification_says() {
445 assert_eq!(TRANSFORM_BASE, 0xA8C0);
446 assert_eq!(TWO_LETTER_BASE, 0xED68);
447 assert_eq!(ONE_LETTER_BASE, 0xF00C);
448 assert_eq!(ONE_LETTER_BASE + LETTERS - 1, 0xF025);
450 assert_eq!(0xF025 - TRANSFORM_BASE + 1, 18278);
451 }
452
453 #[test]
454 fn leaves_a_named_region_outside_the_letter_space_alone() {
455 assert_eq!(transform_letter_chunk(0xB02D), 0xB02D);
456 assert_eq!(transform_letter_chunk(0x36E2), 0x36E2);
457 }
458
459 #[test]
460 fn no_named_region_can_collide_with_a_letter_region() {
461 for raw in 0..=u16::MAX {
464 let transformed = RegionCode::from_u16(transform_letter_chunk(raw));
465 assert_eq!(
466 transformed.letters(),
467 None,
468 "0x{raw:04X} transformed to a code that reads as letters"
469 );
470 }
471 }
472
473 #[test]
474 fn every_letter_region_round_trips_through_its_text_form() {
475 let mut cases = Vec::new();
476 for a in b'A'..=b'Z' {
477 cases.push(vec![a]);
478 for b in b'A'..=b'Z' {
479 cases.push(vec![a, b]);
480 for c in b'A'..=b'Z' {
481 cases.push(vec![a, b, c]);
482 }
483 }
484 }
485 assert_eq!(cases.len(), 26 + 26 * 26 + 26 * 26 * 26);
486 for bytes in cases {
487 let text = String::from_utf8(bytes).unwrap();
488 let code = RegionCode::from_short_code(&text).unwrap();
489 assert_eq!(code.letters().as_deref(), Some(text.as_str()));
490 assert_eq!(code.to_string(), text);
491 assert_eq!(text.parse::<RegionCode>().unwrap(), code);
492 assert_eq!(text.to_lowercase().parse::<RegionCode>().unwrap(), code);
494 }
495 }
496
497 #[test]
498 fn displays_codes_without_a_text_form_as_hex() {
499 assert_eq!(RegionCode::from_u16(0xDF6F).to_string(), "0xDF6F");
500 assert_eq!(RegionCode::from_u16(0xD35F).to_string(), "0xD35F");
502 assert_eq!(RegionCode::from_u16(0x0100).to_string(), "0x0100");
504 assert_eq!(RegionCode::from_u16(0).to_string(), "0x0000");
505 }
506
507 #[test]
508 fn parses_literal_codes_of_exactly_four_hex_digits() {
509 assert_eq!("0x7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
510 assert_eq!("0X7853".parse::<RegionCode>().unwrap().as_u16(), 0x7853);
511 assert_eq!("0xdf6f".parse::<RegionCode>().unwrap().as_u16(), 0xDF6F);
512 assert_eq!("0x0001".parse::<RegionCode>().unwrap().as_u16(), 1);
513 }
514
515 #[test]
516 fn hashes_anything_that_only_looks_like_a_literal_code() {
517 for input in ["0x12345", "0xzz", "0x 12", "0x+1"] {
523 assert_eq!(
524 input.parse::<RegionCode>().unwrap(),
525 RegionCode::from_name(input),
526 "{input:?} should hash as a name"
527 );
528 }
529 }
530
531 #[test]
532 fn rejects_a_name_longer_than_the_wire_allows() {
533 let long = "R".repeat(REGION_NAME_MAX_LEN + 1);
534 assert_eq!(long.parse::<RegionCode>(), Err(RegionCodeError::TooLong));
535
536 let limit = "R".repeat(REGION_NAME_MAX_LEN);
537 assert_eq!(
538 limit.parse::<RegionCode>().unwrap(),
539 RegionCode::from_name(&limit)
540 );
541 }
542
543 #[test]
544 fn parses_anything_else_as_a_region_name() {
545 assert_eq!(
546 "Rogue Valley".parse::<RegionCode>().unwrap(),
547 RegionCode::from_name("Rogue Valley")
548 );
549 assert_eq!(
551 "OHIO".parse::<RegionCode>().unwrap(),
552 RegionCode::from_name("OHIO")
553 );
554 }
555
556 #[test]
557 fn rejects_an_empty_region_code() {
558 assert_eq!("".parse::<RegionCode>(), Err(RegionCodeError::Empty));
559 assert_eq!(" ".parse::<RegionCode>(), Err(RegionCodeError::Empty));
560 }
561
562 #[test]
563 fn round_trips_through_the_wire_bytes() {
564 let code = RegionCode::from_short_code("SJC").unwrap();
565 assert_eq!(code.to_bytes(), [0x78, 0x53]);
566 assert_eq!(RegionCode::from_bytes([0x78, 0x53]), code);
567 }
568}