Skip to main content

irontide_session/
alert.rs

1//! Alert/event system for push-based notifications.
2//!
3//! Consumers subscribe to a broadcast channel of [`Alert`] events, optionally
4//! filtered by [`AlertCategory`] bitmask at both session and per-subscriber level.
5
6use std::net::SocketAddr;
7use std::sync::atomic::{AtomicU32, Ordering};
8use std::time::SystemTime;
9
10use serde::{Deserialize, Serialize};
11use tokio::sync::broadcast;
12
13use crate::types::TorrentState;
14use irontide_core::Id20;
15
16// ── AlertCategory (bitflags) ──────────────────────────────────────────
17
18bitflags::bitflags! {
19    /// Bitmask categories for filtering alerts.
20    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21    pub struct AlertCategory: u32 {
22        /// Torrent lifecycle: added, removed, paused, resumed, finished, state changes.
23        const STATUS       = 0x001;
24        /// Errors from torrents, trackers, storage.
25        const ERROR        = 0x002;
26        /// Peer connect/disconnect/ban events.
27        const PEER         = 0x004;
28        /// Tracker announce replies and errors.
29        const TRACKER      = 0x008;
30        /// Storage/file operations.
31        const STORAGE      = 0x010;
32        /// DHT bootstrap and peer discovery.
33        const DHT          = 0x020;
34        /// Periodic session/torrent statistics.
35        const STATS        = 0x040;
36        /// Piece-level events (verified, hash-failed).
37        const PIECE        = 0x080;
38        /// Block-level events (high volume).
39        const BLOCK        = 0x100;
40        /// Performance warnings.
41        const PERFORMANCE  = 0x200;
42        /// Port mapping (UPnP/NAT-PMP).
43        const PORT_MAPPING = 0x400;
44        /// I2P session events.
45        const I2P          = 0x800;
46        /// All categories enabled.
47        const ALL          = 0xFFF;
48    }
49}
50
51// ── AlertKind ─────────────────────────────────────────────────────────
52
53/// The specific event that occurred.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub enum AlertKind {
56    // ── Torrent lifecycle (STATUS) ──
57    /// A torrent was added to the session.
58    TorrentAdded {
59        /// Info hash of the added torrent.
60        info_hash: Id20,
61        /// Display name of the torrent.
62        name: String,
63    },
64    /// A torrent was removed from the session.
65    TorrentRemoved {
66        /// Info hash of the removed torrent.
67        info_hash: Id20,
68    },
69    /// A torrent was paused.
70    TorrentPaused {
71        /// Info hash of the paused torrent.
72        info_hash: Id20,
73    },
74    /// A torrent was resumed.
75    TorrentResumed {
76        /// Info hash of the resumed torrent.
77        info_hash: Id20,
78    },
79    /// A torrent finished downloading all pieces.
80    TorrentFinished {
81        /// Info hash of the finished torrent.
82        info_hash: Id20,
83    },
84    /// A torrent changed state.
85    StateChanged {
86        /// Info hash of the affected torrent.
87        info_hash: Id20,
88        /// Previous torrent state.
89        prev_state: TorrentState,
90        /// New torrent state.
91        new_state: TorrentState,
92    },
93    /// Torrent metadata received via BEP 9 extension protocol.
94    MetadataReceived {
95        /// Info hash of the torrent whose metadata was received.
96        info_hash: Id20,
97        /// Display name from the received metadata.
98        name: String,
99    },
100    /// Metadata download failed for a magnet link torrent.
101    MetadataFailed {
102        /// Info hash of the torrent whose metadata download failed.
103        info_hash: Id20,
104    },
105
106    // ── Checking (STATUS) ──
107    /// A torrent finished checking (verifying existing data on disk).
108    TorrentChecked {
109        /// Info hash of the checked torrent.
110        info_hash: Id20,
111        /// Number of pieces that passed hash verification.
112        pieces_have: u32,
113        /// Total number of pieces in the torrent.
114        pieces_total: u32,
115    },
116    /// Progress update during piece hash checking.
117    CheckingProgress {
118        /// Info hash of the torrent being checked.
119        info_hash: Id20,
120        /// Fraction complete (0.0 to 1.0).
121        progress: f32,
122    },
123
124    // ── Transfer (PIECE / BLOCK) ──
125    /// A piece passed hash verification and is now complete.
126    PieceFinished {
127        /// Info hash of the affected torrent.
128        info_hash: Id20,
129        /// Zero-based piece index.
130        piece: u32,
131    },
132    /// A block (sub-piece chunk) was received and written.
133    BlockFinished {
134        /// Info hash of the affected torrent.
135        info_hash: Id20,
136        /// Zero-based piece index containing the block.
137        piece: u32,
138        /// Byte offset of the block within the piece.
139        offset: u32,
140    },
141    /// A piece failed hash verification.
142    HashFailed {
143        /// Info hash of the affected torrent.
144        info_hash: Id20,
145        /// Zero-based piece index that failed.
146        piece: u32,
147        /// IP addresses of peers that contributed data to this piece.
148        contributors: Vec<std::net::IpAddr>,
149    },
150
151    // ── Peers (PEER) ──
152    /// A new peer connection was established.
153    PeerConnected {
154        /// Info hash of the torrent swarm.
155        info_hash: Id20,
156        /// Socket address of the connected peer.
157        addr: SocketAddr,
158    },
159    /// A peer disconnected.
160    PeerDisconnected {
161        /// Info hash of the torrent swarm.
162        info_hash: Id20,
163        /// Socket address of the disconnected peer.
164        addr: SocketAddr,
165        /// Human-readable reason for disconnection, if available.
166        reason: Option<String>,
167    },
168    /// A peer was banned (e.g. for sending corrupt data).
169    PeerBanned {
170        /// Info hash of the torrent swarm.
171        info_hash: Id20,
172        /// Socket address of the banned peer.
173        addr: SocketAddr,
174    },
175
176    // ── Tracker (TRACKER) ──
177    /// A tracker announce completed successfully.
178    TrackerReply {
179        /// Info hash announced to the tracker.
180        info_hash: Id20,
181        /// Tracker URL.
182        url: String,
183        /// Number of peers returned by the tracker.
184        num_peers: usize,
185    },
186    /// A tracker returned a warning message.
187    TrackerWarning {
188        /// Info hash announced to the tracker.
189        info_hash: Id20,
190        /// Tracker URL.
191        url: String,
192        /// Warning message from the tracker.
193        message: String,
194    },
195    /// A tracker announce failed.
196    TrackerError {
197        /// Info hash announced to the tracker.
198        info_hash: Id20,
199        /// Tracker URL.
200        url: String,
201        /// Error description.
202        message: String,
203    },
204    /// A tracker scrape completed successfully.
205    ScrapeReply {
206        /// Info hash that was scraped.
207        info_hash: Id20,
208        /// Tracker URL.
209        url: String,
210        /// Number of complete peers (seeders).
211        complete: u32,
212        /// Number of incomplete peers (leechers).
213        incomplete: u32,
214        /// Total number of completed downloads reported by the tracker.
215        downloaded: u32,
216    },
217    /// A tracker scrape failed.
218    ScrapeError {
219        /// Info hash that was scraped.
220        info_hash: Id20,
221        /// Tracker URL.
222        url: String,
223        /// Error description.
224        message: String,
225    },
226
227    // ── DHT ──
228    /// DHT routing table bootstrapping finished.
229    DhtBootstrapComplete,
230    /// DHT get_peers query returned peers for a torrent.
231    DhtGetPeers {
232        /// Info hash that was queried.
233        info_hash: Id20,
234        /// Number of peers returned.
235        num_peers: usize,
236    },
237    /// BEP 42: A DHT node was rejected because its ID doesn't match its IP.
238    DhtNodeIdViolation {
239        /// Node ID that violated the BEP 42 constraint.
240        node_id: Id20,
241        /// Socket address of the violating node.
242        addr: SocketAddr,
243    },
244    /// BEP 51: sample_infohashes response received from a DHT node.
245    DhtSampleInfohashes {
246        /// Number of sampled info hashes received.
247        num_samples: usize,
248        /// The remote node's estimate of its total stored info hashes.
249        total_estimate: i64,
250    },
251
252    // ── Session (STATUS) ──
253    /// The session successfully started listening on a port.
254    ListenSucceeded {
255        /// The port number now being listened on.
256        port: u16,
257    },
258    /// The session failed to listen on a port.
259    ListenFailed {
260        /// The port number that failed to bind.
261        port: u16,
262        /// Error description.
263        message: String,
264    },
265    /// Periodic session statistics snapshot.
266    SessionStatsUpdate(crate::types::SessionStats),
267
268    // ── Storage / Disk ──
269    /// A file within a torrent was renamed.
270    FileRenamed {
271        /// Info hash of the affected torrent.
272        info_hash: Id20,
273        /// Zero-based file index within the torrent.
274        index: usize,
275        /// The new filesystem path of the file.
276        new_path: std::path::PathBuf,
277    },
278    /// All pieces belonging to a file have been downloaded and verified.
279    FileCompleted {
280        /// Info hash of the affected torrent.
281        info_hash: Id20,
282        /// Zero-based file index that is now complete.
283        file_index: usize,
284    },
285    /// A torrent's storage was moved to a new directory.
286    StorageMoved {
287        /// Info hash of the affected torrent.
288        info_hash: Id20,
289        /// The new root directory path.
290        new_path: std::path::PathBuf,
291    },
292    /// A file I/O error occurred.
293    FileError {
294        /// Info hash of the affected torrent.
295        info_hash: Id20,
296        /// Filesystem path where the error occurred.
297        path: std::path::PathBuf,
298        /// Error description.
299        message: String,
300    },
301    /// Periodic disk I/O statistics snapshot.
302    DiskStatsUpdate(crate::disk::DiskStats),
303
304    // ── Resume (STATUS) ──
305    /// Fast resume data was saved for a torrent.
306    ResumeDataSaved {
307        /// Info hash of the torrent whose resume data was saved.
308        info_hash: Id20,
309    },
310
311    // ── Error ──
312    /// A torrent encountered a fatal error.
313    TorrentError {
314        /// Info hash of the affected torrent.
315        info_hash: Id20,
316        /// Error description.
317        message: String,
318    },
319
320    // ── Performance ──
321    /// A performance warning was detected (e.g. too many hash failures).
322    PerformanceWarning {
323        /// Info hash of the affected torrent.
324        info_hash: Id20,
325        /// Warning description.
326        message: String,
327    },
328
329    // ── Queue management (STATUS) ──
330    /// Queue position changed (manual move or torrent removal shifted positions).
331    TorrentQueuePositionChanged {
332        /// Info hash of the affected torrent.
333        info_hash: Id20,
334        /// Previous queue position.
335        old_pos: i32,
336        /// New queue position.
337        new_pos: i32,
338    },
339    /// Torrent was paused or resumed by the auto-manage system.
340    TorrentAutoManaged {
341        /// Info hash of the affected torrent.
342        info_hash: Id20,
343        /// Whether the torrent was paused (`true`) or resumed (`false`).
344        paused: bool,
345    },
346
347    // ── IP filtering (PEER) ──
348    /// An incoming connection was blocked by the IP filter.
349    PeerBlocked {
350        /// Socket address that was blocked.
351        addr: SocketAddr,
352    },
353
354    /// Periodic turnover disconnected underperforming peers and connected replacements.
355    PeerTurnover {
356        /// Info hash of the affected torrent.
357        info_hash: Id20,
358        /// Number of peers disconnected during turnover.
359        disconnected: usize,
360        /// Number of replacement peers connected.
361        replaced: usize,
362    },
363
364    // ── Web seeding (STATUS) ──
365    /// A web seed was banned (e.g. for serving corrupt data).
366    WebSeedBanned {
367        /// Info hash of the affected torrent.
368        info_hash: Id20,
369        /// URL of the banned web seed.
370        url: String,
371    },
372
373    // ── Port mapping ──
374    /// A UPnP/NAT-PMP port mapping succeeded.
375    PortMappingSucceeded {
376        /// The mapped external port.
377        port: u16,
378        /// Protocol name (e.g. "TCP" or "UDP").
379        protocol: String,
380    },
381    /// A UPnP/NAT-PMP port mapping failed.
382    PortMappingFailed {
383        /// The port that failed to map.
384        port: u16,
385        /// Error description.
386        message: String,
387    },
388
389    // ── Hybrid hash conflict (M35) ──
390    /// v1 and v2 hashes disagree on the same piece — the .torrent data is inconsistent.
391    InconsistentHashes {
392        /// Info hash of the affected torrent.
393        info_hash: Id20,
394        /// Zero-based piece index with inconsistent hashes.
395        piece: u32,
396    },
397
398    // ── BEP 44 DHT storage (M38) ──
399    /// An immutable DHT put completed.
400    DhtPutComplete {
401        /// SHA-1 target hash of the stored item.
402        target: Id20,
403    },
404    /// A mutable DHT put completed.
405    DhtMutablePutComplete {
406        /// SHA-1 target hash derived from the public key and salt.
407        target: Id20,
408        /// Sequence number of the stored value.
409        seq: i64,
410    },
411    /// Result of a DHT get (immutable). `value` is None if not found.
412    DhtGetResult {
413        /// SHA-1 target hash that was queried.
414        target: Id20,
415        /// The retrieved value, or `None` if not found.
416        value: Option<Vec<u8>>,
417    },
418    /// Result of a DHT mutable get. `value` is None if not found.
419    DhtMutableGetResult {
420        /// SHA-1 target hash derived from the public key and salt.
421        target: Id20,
422        /// The retrieved value, or `None` if not found.
423        value: Option<Vec<u8>>,
424        /// Sequence number of the value, if found.
425        seq: Option<i64>,
426        /// Ed25519 public key of the item author.
427        public_key: [u8; 32],
428    },
429    /// A BEP 44 DHT operation failed.
430    DhtItemError {
431        /// SHA-1 target hash of the failed operation.
432        target: Id20,
433        /// Error description.
434        message: String,
435    },
436
437    // ── BEP 55 Holepunch (M40) ──
438    /// A holepunch connection attempt succeeded.
439    HolepunchSucceeded {
440        /// Info hash of the torrent swarm.
441        info_hash: Id20,
442        /// Socket address of the peer reached via holepunch.
443        addr: SocketAddr,
444    },
445    /// A holepunch connection attempt failed.
446    HolepunchFailed {
447        /// Info hash of the torrent swarm.
448        info_hash: Id20,
449        /// Socket address of the peer we attempted to reach.
450        addr: SocketAddr,
451        /// BEP 55 error code, if provided by the relay.
452        error_code: Option<u32>,
453        /// Human-readable error description.
454        message: String,
455    },
456
457    // ── I2P (M41) ──
458    /// SAM session successfully created with an ephemeral destination.
459    I2pSessionCreated {
460        /// The b32 address of our I2P destination.
461        b32_address: String,
462    },
463    /// I2P error (SAM bridge unreachable, tunnel failure, etc.)
464    I2pError {
465        /// Error description.
466        message: String,
467    },
468
469    // ── SSL (M42) ──
470    /// SSL/TLS error for an SSL torrent (handshake failure, cert validation, etc.).
471    SslTorrentError {
472        /// Info hash of the affected SSL torrent.
473        info_hash: Id20,
474        /// Error description.
475        message: String,
476    },
477
478    // ── Session Stats (M50) ──
479    /// Session-level statistics counters snapshot (one value per metric).
480    SessionStatsAlert {
481        /// Counter values indexed by [`MetricKind`](crate::MetricKind) ordinal.
482        values: Vec<i64>,
483    },
484
485    // ── Network (STATUS) ──
486    /// An external IP address was detected or updated (e.g. from tracker/NAT/DHT).
487    ExternalIpDetected {
488        /// The detected external IP address.
489        ip: std::net::IpAddr,
490    },
491
492    // ── Settings (M31) ──
493    /// Session settings were changed via `apply_settings()`.
494    SettingsChanged,
495}
496
497impl AlertKind {
498    /// Returns the category bitmask for this alert kind.
499    pub fn category(&self) -> AlertCategory {
500        use AlertKind::*;
501        match self {
502            // STATUS
503            TorrentAdded { .. }
504            | TorrentRemoved { .. }
505            | TorrentPaused { .. }
506            | TorrentResumed { .. }
507            | TorrentFinished { .. }
508            | StateChanged { .. }
509            | MetadataReceived { .. }
510            | ListenSucceeded { .. }
511            | ListenFailed { .. }
512            | ResumeDataSaved { .. }
513            | TorrentChecked { .. }
514            | CheckingProgress { .. } => AlertCategory::STATUS,
515
516            MetadataFailed { .. } => AlertCategory::STATUS | AlertCategory::ERROR,
517
518            SessionStatsUpdate(_) => AlertCategory::STATS,
519
520            // PIECE
521            PieceFinished { .. } => AlertCategory::PIECE,
522            HashFailed { .. } => AlertCategory::PIECE | AlertCategory::ERROR,
523
524            // BLOCK
525            BlockFinished { .. } => AlertCategory::BLOCK,
526
527            // PEER
528            PeerConnected { .. } | PeerDisconnected { .. } | PeerBanned { .. } => {
529                AlertCategory::PEER
530            }
531
532            // TRACKER
533            TrackerReply { .. } => AlertCategory::TRACKER,
534            TrackerWarning { .. } => AlertCategory::TRACKER,
535            TrackerError { .. } => AlertCategory::TRACKER | AlertCategory::ERROR,
536            ScrapeReply { .. } => AlertCategory::TRACKER,
537            ScrapeError { .. } => AlertCategory::TRACKER | AlertCategory::ERROR,
538
539            // DHT
540            DhtBootstrapComplete | DhtGetPeers { .. } | DhtSampleInfohashes { .. } => {
541                AlertCategory::DHT
542            }
543            DhtNodeIdViolation { .. } => AlertCategory::DHT | AlertCategory::ERROR,
544            DhtPutComplete { .. }
545            | DhtMutablePutComplete { .. }
546            | DhtGetResult { .. }
547            | DhtMutableGetResult { .. } => AlertCategory::DHT,
548            DhtItemError { .. } => AlertCategory::DHT | AlertCategory::ERROR,
549
550            // STORAGE
551            FileRenamed { .. } | StorageMoved { .. } | FileCompleted { .. } => {
552                AlertCategory::STORAGE
553            }
554            FileError { .. } => AlertCategory::STORAGE | AlertCategory::ERROR,
555            DiskStatsUpdate(_) => AlertCategory::STATS | AlertCategory::STORAGE,
556
557            // ERROR
558            TorrentError { .. } => AlertCategory::ERROR,
559            InconsistentHashes { .. } => AlertCategory::ERROR,
560
561            // PERFORMANCE
562            PerformanceWarning { .. } => AlertCategory::PERFORMANCE,
563
564            // QUEUE MANAGEMENT (STATUS)
565            TorrentQueuePositionChanged { .. } => AlertCategory::STATUS,
566            TorrentAutoManaged { .. } => AlertCategory::STATUS,
567
568            // IP FILTER
569            PeerBlocked { .. } => AlertCategory::PEER,
570            PeerTurnover { .. } => AlertCategory::PEER,
571
572            // WEB SEED
573            WebSeedBanned { .. } => AlertCategory::STATUS,
574
575            // PORT_MAPPING
576            PortMappingSucceeded { .. } => AlertCategory::PORT_MAPPING,
577            PortMappingFailed { .. } => AlertCategory::PORT_MAPPING | AlertCategory::ERROR,
578
579            // HOLEPUNCH (BEP 55)
580            HolepunchSucceeded { .. } => AlertCategory::PEER,
581            HolepunchFailed { .. } => AlertCategory::PEER | AlertCategory::ERROR,
582
583            // I2P
584            I2pSessionCreated { .. } => AlertCategory::I2P | AlertCategory::STATUS,
585            I2pError { .. } => AlertCategory::I2P | AlertCategory::ERROR,
586
587            // SSL
588            SslTorrentError { .. } => AlertCategory::ERROR,
589
590            // SESSION STATS (M50)
591            SessionStatsAlert { .. } => AlertCategory::STATS,
592
593            // NETWORK
594            ExternalIpDetected { .. } => AlertCategory::STATUS,
595
596            // SETTINGS
597            SettingsChanged => AlertCategory::STATUS,
598        }
599    }
600}
601
602// ── Alert ─────────────────────────────────────────────────────────────
603
604/// A timestamped event from the session or a torrent.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606pub struct Alert {
607    /// Wall-clock time when this alert was created.
608    pub timestamp: SystemTime,
609    /// The specific event that occurred.
610    pub kind: AlertKind,
611}
612
613impl Alert {
614    /// Create a new alert with the current wall-clock time.
615    pub fn new(kind: AlertKind) -> Self {
616        Self {
617            timestamp: SystemTime::now(),
618            kind,
619        }
620    }
621
622    /// Shorthand: returns the category of `self.kind`.
623    pub fn category(&self) -> AlertCategory {
624        self.kind.category()
625    }
626}
627
628// ── AlertStream (per-subscriber filter) ───────────────────────────────
629
630/// A filtered view of the alert broadcast channel.
631///
632/// Drops alerts that don't match the subscriber's filter bitmask.
633pub struct AlertStream {
634    rx: broadcast::Receiver<Alert>,
635    filter: AlertCategory,
636}
637
638impl AlertStream {
639    /// Wrap a broadcast receiver with a category filter.
640    pub fn new(rx: broadcast::Receiver<Alert>, filter: AlertCategory) -> Self {
641        Self { rx, filter }
642    }
643
644    /// Receive the next alert matching this subscriber's filter.
645    ///
646    /// Alerts that don't match are silently dropped.
647    pub async fn recv(&mut self) -> Result<Alert, broadcast::error::RecvError> {
648        loop {
649            let alert = self.rx.recv().await?;
650            if alert.category().intersects(self.filter) {
651                return Ok(alert);
652            }
653        }
654    }
655}
656
657// ── post_alert (free function) ────────────────────────────────────────
658
659/// Fire an alert if its category passes the session-level mask.
660///
661/// Called by both `SessionActor` and `TorrentActor`. The mask is an
662/// `AtomicU32` shared between the handle and actors — no command roundtrip.
663pub(crate) fn post_alert(tx: &broadcast::Sender<Alert>, mask: &AtomicU32, kind: AlertKind) {
664    let alert = Alert::new(kind);
665    let m = AlertCategory::from_bits_truncate(mask.load(Ordering::Relaxed));
666    if alert.category().intersects(m) {
667        let _ = tx.send(alert);
668    }
669}
670
671// ── Tests ─────────────────────────────────────────────────────────────
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn alert_category_all_includes_every_flag() {
679        let all = AlertCategory::ALL;
680        assert!(all.contains(AlertCategory::STATUS));
681        assert!(all.contains(AlertCategory::ERROR));
682        assert!(all.contains(AlertCategory::PEER));
683        assert!(all.contains(AlertCategory::TRACKER));
684        assert!(all.contains(AlertCategory::STORAGE));
685        assert!(all.contains(AlertCategory::DHT));
686        assert!(all.contains(AlertCategory::STATS));
687        assert!(all.contains(AlertCategory::PIECE));
688        assert!(all.contains(AlertCategory::BLOCK));
689        assert!(all.contains(AlertCategory::PERFORMANCE));
690        assert!(all.contains(AlertCategory::PORT_MAPPING));
691        assert!(all.contains(AlertCategory::I2P));
692    }
693
694    #[test]
695    fn alert_category_mapping() {
696        use AlertKind::*;
697        let info_hash = Id20::from_bytes(&[0u8; 20]).unwrap();
698
699        let a = Alert::new(TorrentAdded {
700            info_hash,
701            name: String::new(),
702        });
703        assert!(a.category().contains(AlertCategory::STATUS));
704
705        let a = Alert::new(PieceFinished {
706            info_hash,
707            piece: 0,
708        });
709        assert!(a.category().contains(AlertCategory::PIECE));
710
711        let a = Alert::new(PeerConnected {
712            info_hash,
713            addr: "127.0.0.1:6881".parse().unwrap(),
714        });
715        assert!(a.category().contains(AlertCategory::PEER));
716
717        // TrackerError maps to both TRACKER and ERROR
718        let a = Alert::new(TrackerError {
719            info_hash,
720            url: String::new(),
721            message: String::new(),
722        });
723        assert!(a.category().contains(AlertCategory::TRACKER));
724        assert!(a.category().contains(AlertCategory::ERROR));
725    }
726
727    #[test]
728    fn alert_has_timestamp() {
729        let before = SystemTime::now();
730        let alert = Alert::new(AlertKind::DhtBootstrapComplete);
731        assert!(alert.timestamp >= before);
732    }
733
734    #[test]
735    fn alert_is_send_and_sync() {
736        fn assert_send_sync<T: Send + Sync>() {}
737        assert_send_sync::<Alert>();
738    }
739
740    #[test]
741    fn post_alert_respects_mask() {
742        let (tx, mut rx) = broadcast::channel(16);
743        let mask = AtomicU32::new(AlertCategory::STATUS.bits());
744
745        // STATUS alert should pass
746        post_alert(
747            &tx,
748            &mask,
749            AlertKind::TorrentAdded {
750                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
751                name: "test".into(),
752            },
753        );
754        assert!(rx.try_recv().is_ok());
755
756        // PIECE alert should be filtered out
757        post_alert(
758            &tx,
759            &mask,
760            AlertKind::PieceFinished {
761                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
762                piece: 0,
763            },
764        );
765        assert!(rx.try_recv().is_err());
766    }
767
768    #[test]
769    fn post_alert_empty_mask_blocks_all() {
770        let (tx, mut rx) = broadcast::channel(16);
771        let mask = AtomicU32::new(AlertCategory::empty().bits());
772
773        post_alert(
774            &tx,
775            &mask,
776            AlertKind::TorrentAdded {
777                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
778                name: "test".into(),
779            },
780        );
781        assert!(rx.try_recv().is_err());
782    }
783
784    #[test]
785    fn alert_serializes_to_json() {
786        let alert = Alert::new(AlertKind::TorrentAdded {
787            info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
788            name: "test".into(),
789        });
790        let json = serde_json::to_string(&alert).unwrap();
791        let decoded: Alert = serde_json::from_str(&json).unwrap();
792        assert!(matches!(decoded.kind, AlertKind::TorrentAdded { .. }));
793    }
794
795    #[test]
796    fn alert_category_serializes_as_u32() {
797        let mask = AlertCategory::STATUS | AlertCategory::ERROR;
798        let json = serde_json::to_string(&mask).unwrap();
799        let decoded: AlertCategory = serde_json::from_str(&json).unwrap();
800        assert_eq!(decoded, mask);
801    }
802
803    #[test]
804    fn queue_position_changed_alert_has_status_category() {
805        let alert = Alert::new(AlertKind::TorrentQueuePositionChanged {
806            info_hash: Id20::from([0u8; 20]),
807            old_pos: 3,
808            new_pos: 0,
809        });
810        assert!(alert.category().contains(AlertCategory::STATUS));
811    }
812
813    #[test]
814    fn torrent_auto_managed_alert_has_status_category() {
815        let alert = Alert::new(AlertKind::TorrentAutoManaged {
816            info_hash: Id20::from([0u8; 20]),
817            paused: true,
818        });
819        assert!(alert.category().contains(AlertCategory::STATUS));
820    }
821
822    #[test]
823    fn scrape_reply_alert_has_tracker_category() {
824        let alert = Alert::new(AlertKind::ScrapeReply {
825            info_hash: Id20::from([0u8; 20]),
826            url: "http://tracker.example.com/announce".into(),
827            complete: 10,
828            incomplete: 3,
829            downloaded: 50,
830        });
831        assert!(alert.category().contains(AlertCategory::TRACKER));
832    }
833
834    #[test]
835    fn scrape_error_alert_has_tracker_and_error_category() {
836        let alert = Alert::new(AlertKind::ScrapeError {
837            info_hash: Id20::from([0u8; 20]),
838            url: "http://tracker.example.com/announce".into(),
839            message: "connection refused".into(),
840        });
841        assert!(alert.category().contains(AlertCategory::TRACKER));
842        assert!(alert.category().contains(AlertCategory::ERROR));
843    }
844
845    #[test]
846    fn web_seed_banned_alert_has_status_category() {
847        let alert = Alert::new(AlertKind::WebSeedBanned {
848            info_hash: Id20::from([0u8; 20]),
849            url: "http://example.com/files".into(),
850        });
851        assert!(alert.category().contains(AlertCategory::STATUS));
852    }
853
854    #[test]
855    fn dht_node_id_violation_alert_category() {
856        let alert = Alert::new(AlertKind::DhtNodeIdViolation {
857            node_id: Id20::from([0u8; 20]),
858            addr: "203.0.113.5:6881".parse().unwrap(),
859        });
860        assert!(alert.category().contains(AlertCategory::DHT));
861        assert!(alert.category().contains(AlertCategory::ERROR));
862    }
863
864    #[test]
865    fn dht_put_complete_alert_has_dht_category() {
866        let alert = Alert::new(AlertKind::DhtPutComplete {
867            target: Id20::from([0u8; 20]),
868        });
869        assert!(alert.category().contains(AlertCategory::DHT));
870    }
871
872    #[test]
873    fn dht_sample_infohashes_alert_has_dht_category() {
874        let alert = Alert::new(AlertKind::DhtSampleInfohashes {
875            num_samples: 15,
876            total_estimate: 500,
877        });
878        assert!(alert.category().contains(AlertCategory::DHT));
879    }
880
881    #[test]
882    fn dht_item_error_alert_has_dht_and_error_category() {
883        let alert = Alert::new(AlertKind::DhtItemError {
884            target: Id20::from([0u8; 20]),
885            message: "test".into(),
886        });
887        assert!(alert.category().contains(AlertCategory::DHT));
888        assert!(alert.category().contains(AlertCategory::ERROR));
889    }
890
891    #[test]
892    fn holepunch_succeeded_alert_has_peer_category() {
893        let alert = Alert::new(AlertKind::HolepunchSucceeded {
894            info_hash: Id20::from([0u8; 20]),
895            addr: "203.0.113.5:6881".parse().unwrap(),
896        });
897        assert!(alert.category().contains(AlertCategory::PEER));
898        assert!(!alert.category().contains(AlertCategory::ERROR));
899    }
900
901    #[test]
902    fn holepunch_failed_alert_has_peer_and_error_category() {
903        let alert = Alert::new(AlertKind::HolepunchFailed {
904            info_hash: Id20::from([0u8; 20]),
905            addr: "203.0.113.5:6881".parse().unwrap(),
906            error_code: Some(1),
907            message: "no support".into(),
908        });
909        assert!(alert.category().contains(AlertCategory::PEER));
910        assert!(alert.category().contains(AlertCategory::ERROR));
911    }
912
913    #[test]
914    fn i2p_session_created_alert_category() {
915        let alert = Alert::new(AlertKind::I2pSessionCreated {
916            b32_address: "abcdef1234567890abcdef1234567890abcdef1234567890abcd.b32.i2p".into(),
917        });
918        assert!(alert.category().contains(AlertCategory::I2P));
919        assert!(alert.category().contains(AlertCategory::STATUS));
920    }
921
922    #[test]
923    fn i2p_error_alert_category() {
924        let alert = Alert::new(AlertKind::I2pError {
925            message: "SAM bridge unreachable".into(),
926        });
927        assert!(alert.category().contains(AlertCategory::I2P));
928        assert!(alert.category().contains(AlertCategory::ERROR));
929    }
930
931    #[test]
932    fn alert_category_all_includes_i2p() {
933        let all = AlertCategory::ALL;
934        assert!(all.contains(AlertCategory::I2P));
935    }
936
937    #[test]
938    fn ssl_torrent_error_alert_has_error_category() {
939        let alert = Alert::new(AlertKind::SslTorrentError {
940            info_hash: Id20::from([0u8; 20]),
941            message: "handshake failed".into(),
942        });
943        assert!(alert.category().contains(AlertCategory::ERROR));
944    }
945
946    #[test]
947    fn ssl_torrent_error_alert_serializes_to_json() {
948        let alert = Alert::new(AlertKind::SslTorrentError {
949            info_hash: Id20::from([0u8; 20]),
950            message: "cert validation failed".into(),
951        });
952        let json = serde_json::to_string(&alert).unwrap();
953        let decoded: Alert = serde_json::from_str(&json).unwrap();
954        assert!(matches!(decoded.kind, AlertKind::SslTorrentError { .. }));
955    }
956
957    #[test]
958    fn i2p_alert_serializes_to_json() {
959        let alert = Alert::new(AlertKind::I2pSessionCreated {
960            b32_address: "test.b32.i2p".into(),
961        });
962        let json = serde_json::to_string(&alert).unwrap();
963        let decoded: Alert = serde_json::from_str(&json).unwrap();
964        assert!(matches!(decoded.kind, AlertKind::I2pSessionCreated { .. }));
965    }
966
967    #[test]
968    fn peer_turnover_alert_has_peer_category() {
969        let alert = Alert::new(AlertKind::PeerTurnover {
970            info_hash: Id20::from([0u8; 20]),
971            disconnected: 2,
972            replaced: 1,
973        });
974        assert!(alert.category().contains(AlertCategory::PEER));
975    }
976
977    #[test]
978    fn session_stats_alert_has_stats_category() {
979        let alert = Alert::new(AlertKind::SessionStatsAlert {
980            values: vec![100, 200, 300],
981        });
982        assert!(alert.category().contains(AlertCategory::STATS));
983    }
984
985    #[test]
986    fn session_stats_alert_serializes_to_json() {
987        let alert = Alert::new(AlertKind::SessionStatsAlert {
988            values: vec![1, 2, 3, 4, 5],
989        });
990        let json = serde_json::to_string(&alert).unwrap();
991        let decoded: Alert = serde_json::from_str(&json).unwrap();
992        match decoded.kind {
993            AlertKind::SessionStatsAlert { values } => {
994                assert_eq!(values, vec![1, 2, 3, 4, 5]);
995            }
996            _ => panic!("expected SessionStatsAlert"),
997        }
998    }
999}