aprs/
digipeater.rs

1//! APRS digipeater processing logic.
2//!
3//! Implements the three digipeater algorithms supported by the TH-D75
4//! (per Operating Tips section 2.4):
5//!
6//! - **`UIdigipeat`**: Simple alias replacement. When a path entry matches
7//!   a configured alias, replace it with our callsign and mark as used.
8//! - **`UIflood`**: Decrement the hop count on a flooding alias (e.g., `CA3-3`).
9//!   Drop when the count reaches zero.
10//! - **`UItrace`**: Like `UIflood`, but also inserts our callsign into the
11//!   path before the decremented hop entry.
12//!
13//! In addition, the [`DigipeaterConfig`] carries a rolling dedup cache so
14//! that packets seen more than once within [`DigipeaterConfig::dedup_ttl`]
15//! are not re-transmitted, and it performs own-callsign loop detection to
16//! prevent relaying a packet that has already been through this station.
17//!
18//! # Time handling
19//!
20//! Per the crate-level convention, this module is sans-io and never calls
21//! `std::time::Instant::now()` internally. Every stateful method accepts
22//! a `now: Instant` parameter; callers (typically the tokio shell) read
23//! the wall clock once per iteration and thread it down.
24
25use 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
33/// Default rolling dedup window for digipeater retransmission suppression.
34///
35/// A packet whose (source, destination, info) hash has been seen within
36/// this interval will not be relayed a second time. 30 seconds is the
37/// conventional value used by UIDIGI and other APRS digis.
38pub const DEFAULT_DEDUP_TTL: Duration = Duration::from_secs(30);
39
40/// Default viscous delay for fill-in digipeaters.
41///
42/// When nonzero, relay candidates are held for up to this duration to
43/// let other digipeaters (with clearer paths) go first; if any digi
44/// actually relays the packet within the window, we cancel our own
45/// pending relay. Disabled (0) by default.
46pub const DEFAULT_VISCOUS_DELAY: Duration = Duration::from_secs(0);
47
48/// A typed digipeater alias.
49///
50/// APRS digipeater configurations use named aliases (`WIDE1`, `CA`,
51/// `TRACE`, etc.) to identify which path entries should be relayed.
52/// This newtype wraps the alias string with ergonomic equality checks
53/// and validation (ASCII, uppercase, non-empty).
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55pub struct DigipeaterAlias(String);
56
57impl DigipeaterAlias {
58    /// Create a new alias, rejecting empty or non-ASCII input.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`AprsError::InvalidDigipeaterAlias`] on invalid input.
63    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    /// Return the alias as a string slice.
71    #[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// ---------------------------------------------------------------------------
84// Configuration
85// ---------------------------------------------------------------------------
86
87/// Digipeater configuration.
88///
89/// Controls which packets are relayed and how the digipeater path is modified.
90/// Also carries the rolling dedup cache used to suppress retransmission of
91/// packets seen more than once within [`DigipeaterConfig::dedup_ttl`].
92#[derive(Debug, Clone)]
93pub struct DigipeaterConfig {
94    /// Our callsign (used for `UIdigipeat` and `UItrace` path insertion).
95    pub callsign: Ax25Address,
96    /// `UIdigipeat` aliases (e.g., `["WIDE1-1"]`). Relay if path contains
97    /// this alias, replace with our callsign + completion flag.
98    pub uidigipeat_aliases: Vec<String>,
99    /// `UIflood` alias base (e.g., `"CA"`). Relay and decrement hop count.
100    /// The SSID encodes the remaining hop count.
101    pub uiflood_alias: Option<String>,
102    /// `UItrace` alias base (e.g., `"WIDE"`). Relay, decrement hop count,
103    /// and insert our callsign in the path.
104    pub uitrace_alias: Option<String>,
105    /// How long a recently-seen packet is remembered in the dedup cache.
106    /// Defaults to [`DEFAULT_DEDUP_TTL`] (30 s).
107    pub dedup_ttl: Duration,
108    /// Viscous delay — how long to hold a relay candidate before
109    /// actually transmitting it. `0` disables the feature (default).
110    ///
111    /// Viscous digis defer relay for a short window so that nearby
112    /// full digipeaters have a chance to transmit first; if any other
113    /// digi relays the packet within the window, the viscous digi
114    /// cancels its own pending relay. This lets a fill-in digi stay
115    /// quiet in well-covered areas while still providing coverage in
116    /// RF gaps.
117    pub viscous_delay: Duration,
118    /// Rolling cache of recently-relayed packet hashes. Populated on
119    /// successful relay and pruned of expired entries on each call to
120    /// [`Self::process`].
121    dedup_cache: HashMap<u64, Instant>,
122    /// Pending viscous relays, keyed on the packet hash. Each entry is
123    /// the time we first saw the packet; when the delay elapses and
124    /// we haven't seen anyone else relay it, we transmit ourselves.
125    pending_viscous: HashMap<u64, (Instant, Ax25Packet)>,
126}
127
128impl DigipeaterConfig {
129    /// Build a new config with an empty dedup cache and the default TTL.
130    #[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    /// Drain any pending viscous relays whose delay window has elapsed.
150    ///
151    /// Call this periodically (e.g. from the client event loop) to pick
152    /// up relays whose viscous delay has expired without anyone else
153    /// transmitting the same packet. Returns the frames ready to send.
154    ///
155    /// The caller provides `now` so this module remains sans-io; pass the
156    /// same `Instant` used for the surrounding [`Self::process`] calls.
157    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                // Record this relay in the dedup cache to prevent
165                // re-relaying if the packet comes around again.
166                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// ---------------------------------------------------------------------------
177// Result
178// ---------------------------------------------------------------------------
179
180/// Result of digipeater processing.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum DigiAction {
183    /// Do not relay this packet (no alias matched).
184    Drop,
185    /// The packet was not a UI frame (control != 0x03 or PID != 0xF0).
186    /// APRS uses only UI frames, so this is effectively a pass-through.
187    NotUiFrame,
188    /// Loop detected — our own callsign is already in the used path.
189    LoopDetected,
190    /// Duplicate packet — we already relayed this one within the TTL
191    /// window.
192    Duplicate,
193    /// Relay with modified digipeater path.
194    Relay {
195        /// The packet with its path modified for retransmission.
196        modified_packet: Ax25Packet,
197    },
198}
199
200// ---------------------------------------------------------------------------
201// Processing
202// ---------------------------------------------------------------------------
203
204impl DigipeaterConfig {
205    /// Process an incoming AX.25 UI frame through digipeater logic.
206    ///
207    /// Performs, in order:
208    /// 1. UI frame sanity (`control=0x03`, `PID=0xF0`).
209    /// 2. Own-callsign loop detection — if our callsign appears anywhere
210    ///    in the digipeater path with the H-bit set, the packet has already
211    ///    been through us and we must drop it to prevent routing loops.
212    /// 3. Dedup cache lookup — if we've relayed a packet with the same
213    ///    source/destination/info hash within [`Self::dedup_ttl`], drop.
214    /// 4. First-unused entry alias matching (`UIdigipeat`, `UIflood`,
215    ///    `UItrace`).
216    /// 5. On successful relay, the packet hash is recorded in the dedup
217    ///    cache with the current time.
218    ///
219    /// The caller provides `now` so this module remains sans-io. Passing
220    /// the same `Instant` to every stateful call in a single loop
221    /// iteration keeps timing invariants consistent.
222    ///
223    /// Returns [`DigiAction::Drop`] if any check fails or no alias matches.
224    pub fn process(&mut self, packet: &Ax25Packet, now: Instant) -> DigiAction {
225        // --- 1. UI frame check ---
226        if packet.control != 0x03 || packet.protocol != 0xF0 {
227            return DigiAction::NotUiFrame;
228        }
229
230        // --- 2. Own-callsign loop detection ---
231        if own_callsign_already_relayed(&self.callsign, &packet.digipeaters) {
232            return DigiAction::LoopDetected;
233        }
234
235        // --- 3. Prune expired dedup entries and check ---
236        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        // --- 3a. Viscous cancellation ---
243        // If we have a pending viscous relay for this packet and the
244        // packet arrives again, it means someone else relayed it. Drop
245        // the pending entry and suppress our own relay.
246        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        // --- 4. First-unused entry alias matching ---
254        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            // `position` just returned `Some(first_unused)`, so this
260            // branch is unreachable; fall through as a drop to preserve
261            // the "no relay" invariant without panicking.
262            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        // --- 5. Record successful relay in dedup cache ---
287        if let DigiAction::Relay {
288            ref modified_packet,
289        } = action
290        {
291            if self.viscous_delay > Duration::from_secs(0) {
292                // Defer the relay — hold it in the viscous queue. The
293                // dedup cache is only populated once we actually
294                // transmit (in `drain_ready_viscous`).
295                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    /// Remove dedup entries older than [`Self::dedup_ttl`].
307    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    /// Number of entries currently in the dedup cache (for tests/metrics).
313    #[must_use]
314    pub fn dedup_cache_len(&self) -> usize {
315        self.dedup_cache.len()
316    }
317}
318
319/// Hash a packet's identity tuple `(source, destination, info)` for dedup.
320///
321/// Uses `DefaultHasher` which is SipHash-1-3 in std. The hash is only used
322/// locally within one process lifetime for dedup, so randomized seeding is
323/// fine (actually preferred, as it makes the cache unpredictable).
324fn 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
334/// Check whether our callsign appears in the digipeater path with the
335/// has-been-repeated bit set. If so, the packet has already passed through
336/// this station and relaying it again would create a routing loop.
337fn 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
347/// `UIdigipeat`: replace the alias entry with our callsign, marked as used.
348fn 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        // Caller only invokes this with an `idx` produced by `position`
354        // on `packet.digipeaters`, so the slot is always present. If
355        // the packet has been mutated in the meantime, prefer a drop
356        // over a panic.
357        return DigiAction::Drop;
358    }
359    DigiAction::Relay {
360        modified_packet: modified,
361    }
362}
363
364/// `UIflood`: decrement the hop count. Mark as used when exhausted.
365fn 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    // SSID is already validated 0-15, and new_ssid_raw is strictly
371    // smaller, so `new(...)` cannot fail. Fall back to zero if the
372    // codec's validator ever disagrees.
373    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
399/// `UItrace`: like `UIflood` but also inserts our callsign before the hop entry.
400fn apply_uitrace(callsign: &Ax25Address, packet: &Ax25Packet, idx: usize) -> DigiAction {
401    // AX.25 supports at most 8 digipeater entries.
402    if packet.digipeaters.len() >= 8 {
403        return DigiAction::Drop;
404    }
405
406    // Snapshot the alias digipeater's callsign + current hop count;
407    // after `modified.digipeaters.insert` the indices shift and we can
408    // no longer borrow from the original slice without re-indexing.
409    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    // Insert our callsign (marked as used) before the current entry.
419    modified.digipeaters.insert(idx, mark_used(callsign));
420
421    // The original entry shifted to idx+1; update its hop count.
422    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
447// ---------------------------------------------------------------------------
448// Helpers
449// ---------------------------------------------------------------------------
450
451/// Check if a digipeater entry has been used (has-been-repeated).
452const fn is_used_digi(addr: &Ax25Address) -> bool {
453    addr.repeated
454}
455
456/// Create a copy of an address with the H-bit (has-been-repeated) set.
457fn 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// ---------------------------------------------------------------------------
467// Tests
468// ---------------------------------------------------------------------------
469
470#[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        // If call ends with '*', strip it and set repeated=true.
478        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    // ---- UIdigipeat tests ----
507
508    #[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                // Second entry unchanged.
524                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                // First entry untouched (already used).
542                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                // Second entry replaced.
549                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    // ---- UIflood tests ----
577
578    #[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    // ---- UItrace tests ----
626
627    #[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                // Our callsign inserted first, marked used.
637                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                // Original entry with decremented hop.
645                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        // 8 digipeaters = maximum, can't insert another.
683        let mut digis: Vec<Ax25Address> = (0..8).map(|i| make_addr("USED*", i)).collect();
684        // Replace last one with an unused WIDE entry.
685        let last = digis.get_mut(7).ok_or("missing digi 7")?;
686        *last = make_addr("WIDE", 2);
687
688        // But the first unused is at index 7, and there are already 8 entries.
689        let packet = make_packet(digis);
690        let t0 = Instant::now();
691        assert_eq!(config.process(&packet, t0), DigiAction::Drop);
692        Ok(())
693    }
694
695    // ---- Edge cases ----
696
697    #[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; // Not a UI frame.
702        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        // If both uiflood and uitrace are configured for different aliases,
736        // the correct one should match.
737        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        // UIflood packet (distinct info so dedup doesn't fire between cases).
747        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                // Should NOT insert callsign (flood, not trace).
752                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        // UItrace packet.
763        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                // Should insert callsign (trace).
768                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    // ---- Dedup cache tests ----
776
777    #[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        // First sighting → relay.
784        assert!(matches!(
785            config.process(&packet, t0),
786            DigiAction::Relay { .. }
787        ));
788        assert_eq!(config.dedup_cache_len(), 1);
789
790        // Second sighting within TTL → duplicate.
791        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        // Different info → different hash → should relay.
806        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        // Zero TTL so any "past" entry is instantly expired.
813        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        // With zero TTL the previous entry is pruned, so the same packet
822        // can be relayed again — pass the same instant to force the
823        // pruning branch (`now.duration_since(t) < 0s` is false).
824        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        // With viscous_delay enabled, the first sighting is deferred.
837        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        // Defer.
848        assert_eq!(config.process(&packet, t0), DigiAction::Drop);
849        // Same packet arrives again (someone else relayed).
850        assert_eq!(config.process(&packet, t0), DigiAction::Duplicate);
851        // Drained queue is empty because the pending relay was cancelled.
852        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(); // our callsign is MYDIGI
870        // Packet already shows us as a used digi — must not be re-relayed.
871        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        // Our callsign appears later in the path but the first entry is an
880        // alias we should handle. The loop detector only trips on H-bit set.
881        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    // ---- Viscous drain timing ----
890
891    #[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        // Still inside the delay window: nothing ready yet.
899        assert_eq!(config.drain_ready_viscous(t0).len(), 0);
900        // Past the delay window: the pending relay is returned.
901        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        // Our callsign was inserted by UIdigipeat substitution.
906        let d0 = p.digipeaters.first().ok_or("missing digi 0")?;
907        assert_eq!(d0.callsign, "MYDIGI");
908        Ok(())
909    }
910
911    // ---- DigipeaterAlias ----
912
913    #[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}