1use std::collections::HashMap;
26use std::hash::{DefaultHasher, Hash, Hasher};
27use std::time::{Duration, Instant};
28
29use ax25_codec::{Ax25Address, Ax25Packet, Ssid};
30
31use crate::error::AprsError;
32
33pub const DEFAULT_DEDUP_TTL: Duration = Duration::from_secs(30);
39
40pub const DEFAULT_VISCOUS_DELAY: Duration = Duration::from_secs(0);
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct DigipeaterAlias(String);
56
57impl DigipeaterAlias {
58 pub fn new(s: &str) -> Result<Self, AprsError> {
64 if s.is_empty() || !s.is_ascii() {
65 return Err(AprsError::InvalidDigipeaterAlias("must be non-empty ASCII"));
66 }
67 Ok(Self(s.to_ascii_uppercase()))
68 }
69
70 #[must_use]
72 pub fn as_str(&self) -> &str {
73 &self.0
74 }
75}
76
77impl std::fmt::Display for DigipeaterAlias {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.write_str(&self.0)
80 }
81}
82
83#[derive(Debug, Clone)]
93pub struct DigipeaterConfig {
94 pub callsign: Ax25Address,
96 pub uidigipeat_aliases: Vec<String>,
99 pub uiflood_alias: Option<String>,
102 pub uitrace_alias: Option<String>,
105 pub dedup_ttl: Duration,
108 pub viscous_delay: Duration,
118 dedup_cache: HashMap<u64, Instant>,
122 pending_viscous: HashMap<u64, (Instant, Ax25Packet)>,
126}
127
128impl DigipeaterConfig {
129 #[must_use]
131 pub fn new(
132 callsign: Ax25Address,
133 uidigipeat_aliases: Vec<String>,
134 uiflood_alias: Option<String>,
135 uitrace_alias: Option<String>,
136 ) -> Self {
137 Self {
138 callsign,
139 uidigipeat_aliases,
140 uiflood_alias,
141 uitrace_alias,
142 dedup_ttl: DEFAULT_DEDUP_TTL,
143 viscous_delay: DEFAULT_VISCOUS_DELAY,
144 dedup_cache: HashMap::new(),
145 pending_viscous: HashMap::new(),
146 }
147 }
148
149 pub fn drain_ready_viscous(&mut self, now: Instant) -> Vec<Ax25Packet> {
158 let delay = self.viscous_delay;
159 let mut ready = Vec::new();
160 let mut remaining = HashMap::new();
161 for (k, (t, p)) in self.pending_viscous.drain() {
162 if now.duration_since(t) >= delay {
163 ready.push(p);
164 let _prev = self.dedup_cache.insert(k, now);
167 } else {
168 let _prev = remaining.insert(k, (t, p));
169 }
170 }
171 self.pending_viscous = remaining;
172 ready
173 }
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum DigiAction {
183 Drop,
185 NotUiFrame,
188 LoopDetected,
190 Duplicate,
193 Relay {
195 modified_packet: Ax25Packet,
197 },
198}
199
200impl DigipeaterConfig {
205 pub fn process(&mut self, packet: &Ax25Packet, now: Instant) -> DigiAction {
225 if packet.control != 0x03 || packet.protocol != 0xF0 {
227 return DigiAction::NotUiFrame;
228 }
229
230 if own_callsign_already_relayed(&self.callsign, &packet.digipeaters) {
232 return DigiAction::LoopDetected;
233 }
234
235 self.prune_dedup(now);
237 let packet_hash = hash_packet_identity(packet);
238 if self.dedup_cache.contains_key(&packet_hash) {
239 return DigiAction::Duplicate;
240 }
241
242 if self.viscous_delay > Duration::from_secs(0)
247 && self.pending_viscous.remove(&packet_hash).is_some()
248 {
249 let _prev = self.dedup_cache.insert(packet_hash, now);
250 return DigiAction::Duplicate;
251 }
252
253 let Some(first_unused) = packet.digipeaters.iter().position(|d| !is_used_digi(d)) else {
255 return DigiAction::Drop;
256 };
257
258 let Some(digi) = packet.digipeaters.get(first_unused) else {
259 return DigiAction::Drop;
263 };
264
265 let action = {
266 let digi_str = format!("{digi}");
267 if self
268 .uidigipeat_aliases
269 .iter()
270 .any(|a| digi_str.eq_ignore_ascii_case(a))
271 {
272 apply_uidigipeat(&self.callsign, packet, first_unused)
273 } else if self.uiflood_alias.as_deref().is_some_and(|a| {
274 digi.callsign.as_str().eq_ignore_ascii_case(a) && digi.ssid.get() > 0
275 }) {
276 apply_uiflood(packet, first_unused)
277 } else if self.uitrace_alias.as_deref().is_some_and(|a| {
278 digi.callsign.as_str().eq_ignore_ascii_case(a) && digi.ssid.get() > 0
279 }) {
280 apply_uitrace(&self.callsign, packet, first_unused)
281 } else {
282 DigiAction::Drop
283 }
284 };
285
286 if let DigiAction::Relay {
288 ref modified_packet,
289 } = action
290 {
291 if self.viscous_delay > Duration::from_secs(0) {
292 let _prev = self
296 .pending_viscous
297 .insert(packet_hash, (now, modified_packet.clone()));
298 return DigiAction::Drop;
299 }
300 let _previous = self.dedup_cache.insert(packet_hash, now);
301 }
302
303 action
304 }
305
306 fn prune_dedup(&mut self, now: Instant) {
308 let ttl = self.dedup_ttl;
309 self.dedup_cache.retain(|_, t| now.duration_since(*t) < ttl);
310 }
311
312 #[must_use]
314 pub fn dedup_cache_len(&self) -> usize {
315 self.dedup_cache.len()
316 }
317}
318
319fn hash_packet_identity(packet: &Ax25Packet) -> u64 {
325 let mut h = DefaultHasher::new();
326 packet.source.callsign.as_str().hash(&mut h);
327 packet.source.ssid.get().hash(&mut h);
328 packet.destination.callsign.as_str().hash(&mut h);
329 packet.destination.ssid.get().hash(&mut h);
330 packet.info.hash(&mut h);
331 h.finish()
332}
333
334fn own_callsign_already_relayed(own: &Ax25Address, path: &[Ax25Address]) -> bool {
338 path.iter().any(|d| {
339 d.repeated
340 && d.callsign
341 .as_str()
342 .eq_ignore_ascii_case(own.callsign.as_str())
343 && d.ssid == own.ssid
344 })
345}
346
347fn apply_uidigipeat(callsign: &Ax25Address, packet: &Ax25Packet, idx: usize) -> DigiAction {
349 let mut modified = packet.clone();
350 if let Some(slot) = modified.digipeaters.get_mut(idx) {
351 *slot = mark_used(callsign);
352 } else {
353 return DigiAction::Drop;
358 }
359 DigiAction::Relay {
360 modified_packet: modified,
361 }
362}
363
364fn apply_uiflood(packet: &Ax25Packet, idx: usize) -> DigiAction {
366 let Some(digi) = packet.digipeaters.get(idx) else {
367 return DigiAction::Drop;
368 };
369 let new_ssid_raw = digi.ssid.get().saturating_sub(1);
370 let new_ssid = Ssid::new(new_ssid_raw).unwrap_or(Ssid::ZERO);
374
375 let mut modified = packet.clone();
376 let Some(slot) = modified.digipeaters.get_mut(idx) else {
377 return DigiAction::Drop;
378 };
379 if new_ssid_raw == 0 {
380 *slot = mark_used(&Ax25Address {
381 callsign: digi.callsign.clone(),
382 ssid: Ssid::ZERO,
383 repeated: false,
384 c_bit: false,
385 });
386 } else {
387 *slot = Ax25Address {
388 callsign: digi.callsign.clone(),
389 ssid: new_ssid,
390 repeated: false,
391 c_bit: false,
392 };
393 }
394 DigiAction::Relay {
395 modified_packet: modified,
396 }
397}
398
399fn apply_uitrace(callsign: &Ax25Address, packet: &Ax25Packet, idx: usize) -> DigiAction {
401 if packet.digipeaters.len() >= 8 {
403 return DigiAction::Drop;
404 }
405
406 let Some(source_digi) = packet.digipeaters.get(idx) else {
410 return DigiAction::Drop;
411 };
412 let alias_callsign = source_digi.callsign.clone();
413 let new_ssid_raw = source_digi.ssid.get().saturating_sub(1);
414 let new_ssid = Ssid::new(new_ssid_raw).unwrap_or(Ssid::ZERO);
415
416 let mut modified = packet.clone();
417
418 modified.digipeaters.insert(idx, mark_used(callsign));
420
421 let trace_idx = idx + 1;
423 let Some(slot) = modified.digipeaters.get_mut(trace_idx) else {
424 return DigiAction::Drop;
425 };
426 if new_ssid_raw == 0 {
427 *slot = mark_used(&Ax25Address {
428 callsign: alias_callsign,
429 ssid: Ssid::ZERO,
430 repeated: false,
431 c_bit: false,
432 });
433 } else {
434 *slot = Ax25Address {
435 callsign: alias_callsign,
436 ssid: new_ssid,
437 repeated: false,
438 c_bit: false,
439 };
440 }
441
442 DigiAction::Relay {
443 modified_packet: modified,
444 }
445}
446
447const fn is_used_digi(addr: &Ax25Address) -> bool {
453 addr.repeated
454}
455
456fn mark_used(addr: &Ax25Address) -> Ax25Address {
458 Ax25Address {
459 callsign: addr.callsign.clone(),
460 ssid: addr.ssid,
461 repeated: true,
462 c_bit: addr.c_bit,
463 }
464}
465
466#[cfg(test)]
471mod tests {
472 use super::*;
473
474 type TestResult = Result<(), Box<dyn std::error::Error>>;
475
476 fn make_addr(call: &str, ssid: u8) -> Ax25Address {
477 let (callsign, repeated) = call
479 .strip_suffix('*')
480 .map_or_else(|| (call.to_owned(), false), |s| (s.to_owned(), true));
481 let mut addr = Ax25Address::new(&callsign, ssid);
482 addr.repeated = repeated;
483 addr
484 }
485
486 fn make_packet(digipeaters: Vec<Ax25Address>) -> Ax25Packet {
487 Ax25Packet {
488 source: make_addr("N0CALL", 7),
489 destination: make_addr("APK005", 0),
490 digipeaters,
491 control: 0x03,
492 protocol: 0xF0,
493 info: b"!3518.00N/08414.00W-test".to_vec(),
494 }
495 }
496
497 fn make_config() -> DigipeaterConfig {
498 DigipeaterConfig::new(
499 make_addr("MYDIGI", 0),
500 vec!["WIDE1-1".to_owned()],
501 Some("CA".to_owned()),
502 Some("WIDE".to_owned()),
503 )
504 }
505
506 #[test]
509 fn uidigipeat_matches_alias() -> TestResult {
510 let mut config = make_config();
511 let packet = make_packet(vec![make_addr("WIDE1", 1), make_addr("WIDE2", 1)]);
512 let t0 = Instant::now();
513
514 match config.process(&packet, t0) {
515 DigiAction::Relay { modified_packet } => {
516 let d0 = modified_packet
517 .digipeaters
518 .first()
519 .ok_or("missing digi 0")?;
520 assert_eq!(d0.callsign, "MYDIGI");
521 assert!(d0.repeated);
522 assert_eq!(d0.ssid, 0);
523 let d1 = modified_packet.digipeaters.get(1).ok_or("missing digi 1")?;
525 assert_eq!(d1.callsign, "WIDE2");
526 assert_eq!(d1.ssid, 1);
527 }
528 other => return Err(format!("expected Relay, got {other:?}").into()),
529 }
530 Ok(())
531 }
532
533 #[test]
534 fn uidigipeat_skips_used_entries() -> TestResult {
535 let mut config = make_config();
536 let packet = make_packet(vec![make_addr("N1ABC*", 0), make_addr("WIDE1", 1)]);
537 let t0 = Instant::now();
538
539 match config.process(&packet, t0) {
540 DigiAction::Relay { modified_packet } => {
541 let d0 = modified_packet
543 .digipeaters
544 .first()
545 .ok_or("missing digi 0")?;
546 assert_eq!(d0.callsign, "N1ABC");
547 assert!(d0.repeated);
548 let d1 = modified_packet.digipeaters.get(1).ok_or("missing digi 1")?;
550 assert_eq!(d1.callsign, "MYDIGI");
551 assert!(d1.repeated);
552 }
553 other => return Err(format!("expected Relay, got {other:?}").into()),
554 }
555 Ok(())
556 }
557
558 #[test]
559 fn uidigipeat_no_match_drops() {
560 let mut config = make_config();
561 let packet = make_packet(vec![make_addr("RELAY", 0)]);
562 let t0 = Instant::now();
563
564 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
565 }
566
567 #[test]
568 fn uidigipeat_all_used_drops() {
569 let mut config = make_config();
570 let packet = make_packet(vec![make_addr("WIDE1*", 1)]);
571 let t0 = Instant::now();
572
573 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
574 }
575
576 #[test]
579 fn uiflood_decrements_hop() -> TestResult {
580 let mut config = make_config();
581 let packet = make_packet(vec![make_addr("N1ABC*", 0), make_addr("CA", 3)]);
582 let t0 = Instant::now();
583
584 match config.process(&packet, t0) {
585 DigiAction::Relay { modified_packet } => {
586 let d1 = modified_packet.digipeaters.get(1).ok_or("missing digi 1")?;
587 assert_eq!(d1.callsign, "CA");
588 assert_eq!(d1.ssid, 2);
589 }
590 other => return Err(format!("expected Relay, got {other:?}").into()),
591 }
592 Ok(())
593 }
594
595 #[test]
596 fn uiflood_last_hop_marks_used() -> TestResult {
597 let mut config = make_config();
598 let packet = make_packet(vec![make_addr("CA", 1)]);
599 let t0 = Instant::now();
600
601 match config.process(&packet, t0) {
602 DigiAction::Relay { modified_packet } => {
603 let d0 = modified_packet
604 .digipeaters
605 .first()
606 .ok_or("missing digi 0")?;
607 assert_eq!(d0.callsign, "CA");
608 assert!(d0.repeated);
609 assert_eq!(d0.ssid, 0);
610 }
611 other => return Err(format!("expected Relay, got {other:?}").into()),
612 }
613 Ok(())
614 }
615
616 #[test]
617 fn uiflood_zero_ssid_drops() {
618 let mut config = make_config();
619 let packet = make_packet(vec![make_addr("CA", 0)]);
620 let t0 = Instant::now();
621
622 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
623 }
624
625 #[test]
628 fn uitrace_inserts_callsign_and_decrements() -> TestResult {
629 let mut config = make_config();
630 let packet = make_packet(vec![make_addr("WIDE", 3)]);
631 let t0 = Instant::now();
632
633 match config.process(&packet, t0) {
634 DigiAction::Relay { modified_packet } => {
635 assert_eq!(modified_packet.digipeaters.len(), 2);
636 let d0 = modified_packet
638 .digipeaters
639 .first()
640 .ok_or("missing digi 0")?;
641 assert_eq!(d0.callsign, "MYDIGI");
642 assert!(d0.repeated);
643 assert_eq!(d0.ssid, 0);
644 let d1 = modified_packet.digipeaters.get(1).ok_or("missing digi 1")?;
646 assert_eq!(d1.callsign, "WIDE");
647 assert_eq!(d1.ssid, 2);
648 }
649 other => return Err(format!("expected Relay, got {other:?}").into()),
650 }
651 Ok(())
652 }
653
654 #[test]
655 fn uitrace_last_hop_marks_exhausted() -> TestResult {
656 let mut config = make_config();
657 let packet = make_packet(vec![make_addr("WIDE", 1)]);
658 let t0 = Instant::now();
659
660 match config.process(&packet, t0) {
661 DigiAction::Relay { modified_packet } => {
662 assert_eq!(modified_packet.digipeaters.len(), 2);
663 let d0 = modified_packet
664 .digipeaters
665 .first()
666 .ok_or("missing digi 0")?;
667 assert_eq!(d0.callsign, "MYDIGI");
668 assert!(d0.repeated);
669 let d1 = modified_packet.digipeaters.get(1).ok_or("missing digi 1")?;
670 assert_eq!(d1.callsign, "WIDE");
671 assert!(d1.repeated);
672 assert_eq!(d1.ssid, 0);
673 }
674 other => return Err(format!("expected Relay, got {other:?}").into()),
675 }
676 Ok(())
677 }
678
679 #[test]
680 fn uitrace_full_path_drops() -> TestResult {
681 let mut config = make_config();
682 let mut digis: Vec<Ax25Address> = (0..8).map(|i| make_addr("USED*", i)).collect();
684 let last = digis.get_mut(7).ok_or("missing digi 7")?;
686 *last = make_addr("WIDE", 2);
687
688 let packet = make_packet(digis);
690 let t0 = Instant::now();
691 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
692 Ok(())
693 }
694
695 #[test]
698 fn non_ui_frame_yields_not_ui_frame() {
699 let mut config = make_config();
700 let mut packet = make_packet(vec![make_addr("WIDE1", 1)]);
701 packet.control = 0x01; let t0 = Instant::now();
703
704 assert_eq!(config.process(&packet, t0), DigiAction::NotUiFrame);
705 }
706
707 #[test]
708 fn empty_digipeater_path_drops() {
709 let mut config = make_config();
710 let packet = make_packet(vec![]);
711 let t0 = Instant::now();
712
713 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
714 }
715
716 #[test]
717 fn case_insensitive_alias_match() -> TestResult {
718 let mut config = DigipeaterConfig::new(
719 make_addr("MYDIGI", 0),
720 vec!["wide1-1".to_owned()],
721 None,
722 None,
723 );
724 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
725 let t0 = Instant::now();
726
727 match config.process(&packet, t0) {
728 DigiAction::Relay { .. } => Ok(()),
729 other => Err(format!("expected case-insensitive match, got {other:?}").into()),
730 }
731 }
732
733 #[test]
734 fn uitrace_priority_over_flood_when_both_configured() -> TestResult {
735 let mut config = DigipeaterConfig::new(
738 make_addr("MYDIGI", 0),
739 vec![],
740 Some("CA".to_owned()),
741 Some("WIDE".to_owned()),
742 );
743
744 let t0 = Instant::now();
745
746 let mut flood_pkt = make_packet(vec![make_addr("CA", 2)]);
748 flood_pkt.info = b"!3518.00N/08414.00W-flood".to_vec();
749 match config.process(&flood_pkt, t0) {
750 DigiAction::Relay { modified_packet } => {
751 assert_eq!(modified_packet.digipeaters.len(), 1);
753 let d0 = modified_packet
754 .digipeaters
755 .first()
756 .ok_or("missing digi 0")?;
757 assert_eq!(d0.ssid, 1);
758 }
759 other => return Err(format!("expected flood relay, got {other:?}").into()),
760 }
761
762 let mut trace_pkt = make_packet(vec![make_addr("WIDE", 2)]);
764 trace_pkt.info = b"!3518.00N/08414.00W-trace".to_vec();
765 match config.process(&trace_pkt, t0) {
766 DigiAction::Relay { modified_packet } => {
767 assert_eq!(modified_packet.digipeaters.len(), 2);
769 }
770 other => return Err(format!("expected trace relay, got {other:?}").into()),
771 }
772 Ok(())
773 }
774
775 #[test]
778 fn duplicate_packet_within_window_is_dropped() {
779 let mut config = make_config();
780 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
781 let t0 = Instant::now();
782
783 assert!(matches!(
785 config.process(&packet, t0),
786 DigiAction::Relay { .. }
787 ));
788 assert_eq!(config.dedup_cache_len(), 1);
789
790 let packet_2 = make_packet(vec![make_addr("WIDE1", 1)]);
792 assert_eq!(config.process(&packet_2, t0), DigiAction::Duplicate);
793 }
794
795 #[test]
796 fn dedup_distinguishes_different_info() {
797 let mut config = make_config();
798 let mut p1 = make_packet(vec![make_addr("WIDE1", 1)]);
799 let mut p2 = make_packet(vec![make_addr("WIDE1", 1)]);
800 p1.info = b"!3518.00N/08414.00W-one".to_vec();
801 p2.info = b"!3518.00N/08414.00W-two".to_vec();
802 let t0 = Instant::now();
803
804 assert!(matches!(config.process(&p1, t0), DigiAction::Relay { .. }));
805 assert!(matches!(config.process(&p2, t0), DigiAction::Relay { .. }));
807 }
808
809 #[test]
810 fn dedup_prunes_expired_entries() {
811 let mut config = make_config();
812 config.dedup_ttl = Duration::from_secs(0);
814
815 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
816 let t0 = Instant::now();
817 assert!(matches!(
818 config.process(&packet, t0),
819 DigiAction::Relay { .. }
820 ));
821 assert!(matches!(
825 config.process(&packet, t0),
826 DigiAction::Relay { .. }
827 ));
828 }
829
830 #[test]
831 fn viscous_delay_defers_initial_relay() {
832 let mut config = make_config();
833 config.viscous_delay = Duration::from_secs(5);
834 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
835 let t0 = Instant::now();
836 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
838 assert_eq!(config.drain_ready_viscous(t0).len(), 0);
839 }
840
841 #[test]
842 fn viscous_delay_cancels_if_someone_else_relays() {
843 let mut config = make_config();
844 config.viscous_delay = Duration::from_secs(5);
845 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
846 let t0 = Instant::now();
847 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
849 assert_eq!(config.process(&packet, t0), DigiAction::Duplicate);
851 assert_eq!(config.drain_ready_viscous(t0).len(), 0);
853 }
854
855 #[test]
856 fn viscous_delay_zero_fires_immediately() {
857 let mut config = make_config();
858 config.viscous_delay = Duration::from_secs(0);
859 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
860 let t0 = Instant::now();
861 assert!(matches!(
862 config.process(&packet, t0),
863 DigiAction::Relay { .. }
864 ));
865 }
866
867 #[test]
868 fn own_callsign_with_h_bit_set_is_loop_detected() {
869 let mut config = make_config(); let packet = make_packet(vec![make_addr("MYDIGI*", 0), make_addr("WIDE2", 1)]);
872 let t0 = Instant::now();
873 assert_eq!(config.process(&packet, t0), DigiAction::LoopDetected);
874 }
875
876 #[test]
877 fn own_callsign_unused_still_processes_first_entry() {
878 let mut config = make_config();
879 let packet = make_packet(vec![make_addr("WIDE1", 1), make_addr("MYDIGI", 0)]);
882 let t0 = Instant::now();
883 assert!(matches!(
884 config.process(&packet, t0),
885 DigiAction::Relay { .. }
886 ));
887 }
888
889 #[test]
892 fn drain_ready_viscous_returns_entries_past_delay() -> TestResult {
893 let mut config = make_config();
894 config.viscous_delay = Duration::from_secs(5);
895 let packet = make_packet(vec![make_addr("WIDE1", 1)]);
896 let t0 = Instant::now();
897 assert_eq!(config.process(&packet, t0), DigiAction::Drop);
898 assert_eq!(config.drain_ready_viscous(t0).len(), 0);
900 let later = t0 + Duration::from_secs(6);
902 let ready = config.drain_ready_viscous(later);
903 assert_eq!(ready.len(), 1);
904 let p = ready.first().ok_or("missing ready packet")?;
905 let d0 = p.digipeaters.first().ok_or("missing digi 0")?;
907 assert_eq!(d0.callsign, "MYDIGI");
908 Ok(())
909 }
910
911 #[test]
914 fn alias_rejects_empty() {
915 assert!(matches!(
916 DigipeaterAlias::new(""),
917 Err(AprsError::InvalidDigipeaterAlias(_))
918 ));
919 }
920
921 #[test]
922 fn alias_rejects_non_ascii() {
923 assert!(matches!(
924 DigipeaterAlias::new("CA\u{00E9}"),
925 Err(AprsError::InvalidDigipeaterAlias(_))
926 ));
927 }
928
929 #[test]
930 fn alias_uppercases_input() -> TestResult {
931 let a = DigipeaterAlias::new("wide1-1")?;
932 assert_eq!(a.as_str(), "WIDE1-1");
933 assert_eq!(format!("{a}"), "WIDE1-1");
934 Ok(())
935 }
936}