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    #[must_use]
500    pub fn category(&self) -> AlertCategory {
501        use AlertKind::{
502            BlockFinished, CheckingProgress, DhtBootstrapComplete, DhtGetPeers, DhtGetResult,
503            DhtItemError, DhtMutableGetResult, DhtMutablePutComplete, DhtNodeIdViolation,
504            DhtPutComplete, DhtSampleInfohashes, DiskStatsUpdate, ExternalIpDetected,
505            FileCompleted, FileError, FileRenamed, HashFailed, HolepunchFailed, HolepunchSucceeded,
506            I2pError, I2pSessionCreated, InconsistentHashes, ListenFailed, ListenSucceeded,
507            MetadataFailed, MetadataReceived, PeerBanned, PeerBlocked, PeerConnected,
508            PeerDisconnected, PeerTurnover, PerformanceWarning, PieceFinished, PortMappingFailed,
509            PortMappingSucceeded, ResumeDataSaved, ScrapeError, ScrapeReply, SessionStatsAlert,
510            SessionStatsUpdate, SettingsChanged, SslTorrentError, StateChanged, StorageMoved,
511            TorrentAdded, TorrentAutoManaged, TorrentChecked, TorrentError, TorrentFinished,
512            TorrentPaused, TorrentQueuePositionChanged, TorrentRemoved, TorrentResumed,
513            TrackerError, TrackerReply, TrackerWarning, WebSeedBanned,
514        };
515        match self {
516            // STATUS — torrent lifecycle, queue management, web seed, network, settings
517            TorrentAdded { .. }
518            | TorrentRemoved { .. }
519            | TorrentPaused { .. }
520            | TorrentResumed { .. }
521            | TorrentFinished { .. }
522            | StateChanged { .. }
523            | MetadataReceived { .. }
524            | ListenSucceeded { .. }
525            | ListenFailed { .. }
526            | ResumeDataSaved { .. }
527            | TorrentChecked { .. }
528            | CheckingProgress { .. }
529            | TorrentQueuePositionChanged { .. }
530            | TorrentAutoManaged { .. }
531            | WebSeedBanned { .. }
532            | ExternalIpDetected { .. }
533            | SettingsChanged => AlertCategory::STATUS,
534
535            MetadataFailed { .. } => AlertCategory::STATUS | AlertCategory::ERROR,
536
537            // STATS — session counters and disk I/O counters
538            SessionStatsUpdate(_) | SessionStatsAlert { .. } => AlertCategory::STATS,
539
540            // PIECE
541            PieceFinished { .. } => AlertCategory::PIECE,
542            HashFailed { .. } => AlertCategory::PIECE | AlertCategory::ERROR,
543
544            // BLOCK
545            BlockFinished { .. } => AlertCategory::BLOCK,
546
547            // PEER — connections, IP filter, holepunch
548            PeerConnected { .. }
549            | PeerDisconnected { .. }
550            | PeerBanned { .. }
551            | PeerBlocked { .. }
552            | PeerTurnover { .. }
553            | HolepunchSucceeded { .. } => AlertCategory::PEER,
554
555            HolepunchFailed { .. } => AlertCategory::PEER | AlertCategory::ERROR,
556
557            // TRACKER
558            TrackerReply { .. } | TrackerWarning { .. } | ScrapeReply { .. } => {
559                AlertCategory::TRACKER
560            }
561            TrackerError { .. } | ScrapeError { .. } => {
562                AlertCategory::TRACKER | AlertCategory::ERROR
563            }
564
565            // DHT — bootstrap, lookups, put/get operations
566            DhtBootstrapComplete
567            | DhtGetPeers { .. }
568            | DhtSampleInfohashes { .. }
569            | DhtPutComplete { .. }
570            | DhtMutablePutComplete { .. }
571            | DhtGetResult { .. }
572            | DhtMutableGetResult { .. } => AlertCategory::DHT,
573
574            DhtNodeIdViolation { .. } | DhtItemError { .. } => {
575                AlertCategory::DHT | AlertCategory::ERROR
576            }
577
578            // STORAGE
579            FileRenamed { .. } | StorageMoved { .. } | FileCompleted { .. } => {
580                AlertCategory::STORAGE
581            }
582            FileError { .. } => AlertCategory::STORAGE | AlertCategory::ERROR,
583            DiskStatsUpdate(_) => AlertCategory::STATS | AlertCategory::STORAGE,
584
585            // ERROR
586            TorrentError { .. } | InconsistentHashes { .. } | SslTorrentError { .. } => {
587                AlertCategory::ERROR
588            }
589
590            // PERFORMANCE
591            PerformanceWarning { .. } => AlertCategory::PERFORMANCE,
592
593            // PORT_MAPPING
594            PortMappingSucceeded { .. } => AlertCategory::PORT_MAPPING,
595            PortMappingFailed { .. } => AlertCategory::PORT_MAPPING | AlertCategory::ERROR,
596
597            // I2P
598            I2pSessionCreated { .. } => AlertCategory::I2P | AlertCategory::STATUS,
599            I2pError { .. } => AlertCategory::I2P | AlertCategory::ERROR,
600        }
601    }
602}
603
604// ── Alert ─────────────────────────────────────────────────────────────
605
606/// A timestamped event from the session or a torrent.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct Alert {
609    /// Wall-clock time when this alert was created.
610    pub timestamp: SystemTime,
611    /// The specific event that occurred.
612    pub kind: AlertKind,
613}
614
615impl Alert {
616    /// Create a new alert with the current wall-clock time.
617    #[must_use]
618    pub fn new(kind: AlertKind) -> Self {
619        Self {
620            timestamp: SystemTime::now(),
621            kind,
622        }
623    }
624
625    /// Shorthand: returns the category of `self.kind`.
626    #[must_use]
627    pub fn category(&self) -> AlertCategory {
628        self.kind.category()
629    }
630}
631
632// ── AlertStream (per-subscriber filter) ───────────────────────────────
633
634/// A filtered view of the alert broadcast channel.
635///
636/// Drops alerts that don't match the subscriber's filter bitmask.
637pub struct AlertStream {
638    rx: broadcast::Receiver<Alert>,
639    filter: AlertCategory,
640}
641
642impl AlertStream {
643    /// Wrap a broadcast receiver with a category filter.
644    #[must_use]
645    pub fn new(rx: broadcast::Receiver<Alert>, filter: AlertCategory) -> Self {
646        Self { rx, filter }
647    }
648
649    /// Receive the next alert matching this subscriber's filter.
650    ///
651    /// Alerts that don't match are silently dropped.
652    ///
653    /// # Errors
654    ///
655    /// Returns an error if the broadcast channel is closed or lagged.
656    pub async fn recv(&mut self) -> Result<Alert, broadcast::error::RecvError> {
657        loop {
658            let alert = self.rx.recv().await?;
659            if alert.category().intersects(self.filter) {
660                return Ok(alert);
661            }
662        }
663    }
664}
665
666// ── post_alert (free function) ────────────────────────────────────────
667
668/// Fire an alert if its category passes the session-level mask.
669///
670/// Called by both `SessionActor` and `TorrentActor`. The mask is an
671/// `AtomicU32` shared between the handle and actors — no command roundtrip.
672pub(crate) fn post_alert(tx: &broadcast::Sender<Alert>, mask: &AtomicU32, kind: AlertKind) {
673    let alert = Alert::new(kind);
674    let m = AlertCategory::from_bits_truncate(mask.load(Ordering::Relaxed));
675    if alert.category().intersects(m) {
676        let _ = tx.send(alert);
677    }
678}
679
680// ── Tests ─────────────────────────────────────────────────────────────
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn alert_category_all_includes_every_flag() {
688        let all = AlertCategory::ALL;
689        assert!(all.contains(AlertCategory::STATUS));
690        assert!(all.contains(AlertCategory::ERROR));
691        assert!(all.contains(AlertCategory::PEER));
692        assert!(all.contains(AlertCategory::TRACKER));
693        assert!(all.contains(AlertCategory::STORAGE));
694        assert!(all.contains(AlertCategory::DHT));
695        assert!(all.contains(AlertCategory::STATS));
696        assert!(all.contains(AlertCategory::PIECE));
697        assert!(all.contains(AlertCategory::BLOCK));
698        assert!(all.contains(AlertCategory::PERFORMANCE));
699        assert!(all.contains(AlertCategory::PORT_MAPPING));
700        assert!(all.contains(AlertCategory::I2P));
701    }
702
703    #[test]
704    fn alert_category_mapping() {
705        use AlertKind::*;
706        let info_hash = Id20::from_bytes(&[0u8; 20]).unwrap();
707
708        let a = Alert::new(TorrentAdded {
709            info_hash,
710            name: String::new(),
711        });
712        assert!(a.category().contains(AlertCategory::STATUS));
713
714        let a = Alert::new(PieceFinished {
715            info_hash,
716            piece: 0,
717        });
718        assert!(a.category().contains(AlertCategory::PIECE));
719
720        let a = Alert::new(PeerConnected {
721            info_hash,
722            addr: "127.0.0.1:6881".parse().unwrap(),
723        });
724        assert!(a.category().contains(AlertCategory::PEER));
725
726        // TrackerError maps to both TRACKER and ERROR
727        let a = Alert::new(TrackerError {
728            info_hash,
729            url: String::new(),
730            message: String::new(),
731        });
732        assert!(a.category().contains(AlertCategory::TRACKER));
733        assert!(a.category().contains(AlertCategory::ERROR));
734    }
735
736    #[test]
737    fn alert_has_timestamp() {
738        let before = SystemTime::now();
739        let alert = Alert::new(AlertKind::DhtBootstrapComplete);
740        assert!(alert.timestamp >= before);
741    }
742
743    #[test]
744    fn alert_is_send_and_sync() {
745        fn assert_send_sync<T: Send + Sync>() {}
746        assert_send_sync::<Alert>();
747    }
748
749    #[test]
750    fn post_alert_respects_mask() {
751        let (tx, mut rx) = broadcast::channel(16);
752        let mask = AtomicU32::new(AlertCategory::STATUS.bits());
753
754        // STATUS alert should pass
755        post_alert(
756            &tx,
757            &mask,
758            AlertKind::TorrentAdded {
759                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
760                name: "test".into(),
761            },
762        );
763        assert!(rx.try_recv().is_ok());
764
765        // PIECE alert should be filtered out
766        post_alert(
767            &tx,
768            &mask,
769            AlertKind::PieceFinished {
770                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
771                piece: 0,
772            },
773        );
774        assert!(rx.try_recv().is_err());
775    }
776
777    #[test]
778    fn post_alert_empty_mask_blocks_all() {
779        let (tx, mut rx) = broadcast::channel(16);
780        let mask = AtomicU32::new(AlertCategory::empty().bits());
781
782        post_alert(
783            &tx,
784            &mask,
785            AlertKind::TorrentAdded {
786                info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
787                name: "test".into(),
788            },
789        );
790        assert!(rx.try_recv().is_err());
791    }
792
793    #[test]
794    fn alert_serializes_to_json() {
795        let alert = Alert::new(AlertKind::TorrentAdded {
796            info_hash: Id20::from_bytes(&[0u8; 20]).unwrap(),
797            name: "test".into(),
798        });
799        let json = serde_json::to_string(&alert).unwrap();
800        let decoded: Alert = serde_json::from_str(&json).unwrap();
801        assert!(matches!(decoded.kind, AlertKind::TorrentAdded { .. }));
802    }
803
804    #[test]
805    fn alert_category_serializes_as_u32() {
806        let mask = AlertCategory::STATUS | AlertCategory::ERROR;
807        let json = serde_json::to_string(&mask).unwrap();
808        let decoded: AlertCategory = serde_json::from_str(&json).unwrap();
809        assert_eq!(decoded, mask);
810    }
811
812    #[test]
813    fn queue_position_changed_alert_has_status_category() {
814        let alert = Alert::new(AlertKind::TorrentQueuePositionChanged {
815            info_hash: Id20::from([0u8; 20]),
816            old_pos: 3,
817            new_pos: 0,
818        });
819        assert!(alert.category().contains(AlertCategory::STATUS));
820    }
821
822    #[test]
823    fn torrent_auto_managed_alert_has_status_category() {
824        let alert = Alert::new(AlertKind::TorrentAutoManaged {
825            info_hash: Id20::from([0u8; 20]),
826            paused: true,
827        });
828        assert!(alert.category().contains(AlertCategory::STATUS));
829    }
830
831    #[test]
832    fn scrape_reply_alert_has_tracker_category() {
833        let alert = Alert::new(AlertKind::ScrapeReply {
834            info_hash: Id20::from([0u8; 20]),
835            url: "http://tracker.example.com/announce".into(),
836            complete: 10,
837            incomplete: 3,
838            downloaded: 50,
839        });
840        assert!(alert.category().contains(AlertCategory::TRACKER));
841    }
842
843    #[test]
844    fn scrape_error_alert_has_tracker_and_error_category() {
845        let alert = Alert::new(AlertKind::ScrapeError {
846            info_hash: Id20::from([0u8; 20]),
847            url: "http://tracker.example.com/announce".into(),
848            message: "connection refused".into(),
849        });
850        assert!(alert.category().contains(AlertCategory::TRACKER));
851        assert!(alert.category().contains(AlertCategory::ERROR));
852    }
853
854    #[test]
855    fn web_seed_banned_alert_has_status_category() {
856        let alert = Alert::new(AlertKind::WebSeedBanned {
857            info_hash: Id20::from([0u8; 20]),
858            url: "http://example.com/files".into(),
859        });
860        assert!(alert.category().contains(AlertCategory::STATUS));
861    }
862
863    #[test]
864    fn dht_node_id_violation_alert_category() {
865        let alert = Alert::new(AlertKind::DhtNodeIdViolation {
866            node_id: Id20::from([0u8; 20]),
867            addr: "203.0.113.5:6881".parse().unwrap(),
868        });
869        assert!(alert.category().contains(AlertCategory::DHT));
870        assert!(alert.category().contains(AlertCategory::ERROR));
871    }
872
873    #[test]
874    fn dht_put_complete_alert_has_dht_category() {
875        let alert = Alert::new(AlertKind::DhtPutComplete {
876            target: Id20::from([0u8; 20]),
877        });
878        assert!(alert.category().contains(AlertCategory::DHT));
879    }
880
881    #[test]
882    fn dht_sample_infohashes_alert_has_dht_category() {
883        let alert = Alert::new(AlertKind::DhtSampleInfohashes {
884            num_samples: 15,
885            total_estimate: 500,
886        });
887        assert!(alert.category().contains(AlertCategory::DHT));
888    }
889
890    #[test]
891    fn dht_item_error_alert_has_dht_and_error_category() {
892        let alert = Alert::new(AlertKind::DhtItemError {
893            target: Id20::from([0u8; 20]),
894            message: "test".into(),
895        });
896        assert!(alert.category().contains(AlertCategory::DHT));
897        assert!(alert.category().contains(AlertCategory::ERROR));
898    }
899
900    #[test]
901    fn holepunch_succeeded_alert_has_peer_category() {
902        let alert = Alert::new(AlertKind::HolepunchSucceeded {
903            info_hash: Id20::from([0u8; 20]),
904            addr: "203.0.113.5:6881".parse().unwrap(),
905        });
906        assert!(alert.category().contains(AlertCategory::PEER));
907        assert!(!alert.category().contains(AlertCategory::ERROR));
908    }
909
910    #[test]
911    fn holepunch_failed_alert_has_peer_and_error_category() {
912        let alert = Alert::new(AlertKind::HolepunchFailed {
913            info_hash: Id20::from([0u8; 20]),
914            addr: "203.0.113.5:6881".parse().unwrap(),
915            error_code: Some(1),
916            message: "no support".into(),
917        });
918        assert!(alert.category().contains(AlertCategory::PEER));
919        assert!(alert.category().contains(AlertCategory::ERROR));
920    }
921
922    #[test]
923    fn i2p_session_created_alert_category() {
924        let alert = Alert::new(AlertKind::I2pSessionCreated {
925            b32_address: "abcdef1234567890abcdef1234567890abcdef1234567890abcd.b32.i2p".into(),
926        });
927        assert!(alert.category().contains(AlertCategory::I2P));
928        assert!(alert.category().contains(AlertCategory::STATUS));
929    }
930
931    #[test]
932    fn i2p_error_alert_category() {
933        let alert = Alert::new(AlertKind::I2pError {
934            message: "SAM bridge unreachable".into(),
935        });
936        assert!(alert.category().contains(AlertCategory::I2P));
937        assert!(alert.category().contains(AlertCategory::ERROR));
938    }
939
940    #[test]
941    fn alert_category_all_includes_i2p() {
942        let all = AlertCategory::ALL;
943        assert!(all.contains(AlertCategory::I2P));
944    }
945
946    #[test]
947    fn ssl_torrent_error_alert_has_error_category() {
948        let alert = Alert::new(AlertKind::SslTorrentError {
949            info_hash: Id20::from([0u8; 20]),
950            message: "handshake failed".into(),
951        });
952        assert!(alert.category().contains(AlertCategory::ERROR));
953    }
954
955    #[test]
956    fn ssl_torrent_error_alert_serializes_to_json() {
957        let alert = Alert::new(AlertKind::SslTorrentError {
958            info_hash: Id20::from([0u8; 20]),
959            message: "cert validation failed".into(),
960        });
961        let json = serde_json::to_string(&alert).unwrap();
962        let decoded: Alert = serde_json::from_str(&json).unwrap();
963        assert!(matches!(decoded.kind, AlertKind::SslTorrentError { .. }));
964    }
965
966    #[test]
967    fn i2p_alert_serializes_to_json() {
968        let alert = Alert::new(AlertKind::I2pSessionCreated {
969            b32_address: "test.b32.i2p".into(),
970        });
971        let json = serde_json::to_string(&alert).unwrap();
972        let decoded: Alert = serde_json::from_str(&json).unwrap();
973        assert!(matches!(decoded.kind, AlertKind::I2pSessionCreated { .. }));
974    }
975
976    #[test]
977    fn peer_turnover_alert_has_peer_category() {
978        let alert = Alert::new(AlertKind::PeerTurnover {
979            info_hash: Id20::from([0u8; 20]),
980            disconnected: 2,
981            replaced: 1,
982        });
983        assert!(alert.category().contains(AlertCategory::PEER));
984    }
985
986    #[test]
987    fn session_stats_alert_has_stats_category() {
988        let alert = Alert::new(AlertKind::SessionStatsAlert {
989            values: vec![100, 200, 300],
990        });
991        assert!(alert.category().contains(AlertCategory::STATS));
992    }
993
994    #[test]
995    fn session_stats_alert_serializes_to_json() {
996        let alert = Alert::new(AlertKind::SessionStatsAlert {
997            values: vec![1, 2, 3, 4, 5],
998        });
999        let json = serde_json::to_string(&alert).unwrap();
1000        let decoded: Alert = serde_json::from_str(&json).unwrap();
1001        match decoded.kind {
1002            AlertKind::SessionStatsAlert { values } => {
1003                assert_eq!(values, vec![1, 2, 3, 4, 5]);
1004            }
1005            _ => panic!("expected SessionStatsAlert"),
1006        }
1007    }
1008}