Skip to main content

irontide_session_core/
queue.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_possible_wrap,
4    clippy::cast_sign_loss,
5    reason = "M175: queue position arithmetic — torrent counts fit u32"
6)]
7
8//! Queue position arithmetic for auto-managed torrents.
9
10use irontide_core::Id20;
11
12/// Compact representation of a queued torrent for position operations.
13#[derive(Debug, Clone)]
14pub struct QueueEntry {
15    /// Info hash of the queued torrent.
16    pub info_hash: Id20,
17    /// Zero-based queue position; lower values are earlier in the queue.
18    pub position: i32,
19}
20
21/// Assigns a position at the end of the queue. Returns the assigned position.
22#[allow(dead_code)] // used by tests; session uses SessionActor::next_queue_position() instead
23pub(crate) fn append_position(entries: &[QueueEntry]) -> i32 {
24    entries
25        .iter()
26        .map(|e| e.position)
27        .max()
28        .map_or(0, |m| m + 1)
29}
30
31/// Removes a position and shifts all positions above it down by 1.
32/// Returns the list of (`info_hash`, `old_pos`, `new_pos`) that changed.
33pub fn remove_position(entries: &mut Vec<QueueEntry>, pos: i32) -> Vec<(Id20, i32, i32)> {
34    entries.retain(|e| e.position != pos);
35    let mut changed = Vec::new();
36    for entry in entries.iter_mut() {
37        if entry.position > pos {
38            let old = entry.position;
39            entry.position -= 1;
40            changed.push((entry.info_hash, old, entry.position));
41        }
42    }
43    changed
44}
45
46/// Moves a torrent to a new absolute position. Shifts others to maintain density.
47/// Returns the list of (`info_hash`, `old_pos`, `new_pos`) for all changed entries.
48pub fn set_position(
49    entries: &mut [QueueEntry],
50    info_hash: Id20,
51    new_pos: i32,
52) -> Vec<(Id20, i32, i32)> {
53    let new_pos = new_pos.clamp(0, entries.len().saturating_sub(1) as i32);
54    let old_pos = match entries.iter().find(|e| e.info_hash == info_hash) {
55        Some(e) => e.position,
56        None => return Vec::new(),
57    };
58    if old_pos == new_pos {
59        return Vec::new();
60    }
61
62    let mut changed = Vec::new();
63
64    if new_pos < old_pos {
65        // Moving up: shift entries in [new_pos, old_pos) down by +1
66        for entry in entries.iter_mut() {
67            if entry.info_hash == info_hash {
68                changed.push((entry.info_hash, old_pos, new_pos));
69                entry.position = new_pos;
70            } else if entry.position >= new_pos && entry.position < old_pos {
71                let old = entry.position;
72                entry.position += 1;
73                changed.push((entry.info_hash, old, entry.position));
74            }
75        }
76    } else {
77        // Moving down: shift entries in (old_pos, new_pos] up by -1
78        for entry in entries.iter_mut() {
79            if entry.info_hash == info_hash {
80                changed.push((entry.info_hash, old_pos, new_pos));
81                entry.position = new_pos;
82            } else if entry.position > old_pos && entry.position <= new_pos {
83                let old = entry.position;
84                entry.position -= 1;
85                changed.push((entry.info_hash, old, entry.position));
86            }
87        }
88    }
89
90    changed
91}
92
93/// Move one position up (lower number = higher priority). Returns changes.
94pub fn move_up(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
95    let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
96        Some(e) if e.position > 0 => e.position,
97        _ => return Vec::new(),
98    };
99    set_position(entries, info_hash, pos - 1)
100}
101
102/// Move one position down. Returns changes.
103pub fn move_down(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
104    let max_pos = entries.iter().map(|e| e.position).max().unwrap_or(0);
105    let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
106        Some(e) if e.position < max_pos => e.position,
107        _ => return Vec::new(),
108    };
109    set_position(entries, info_hash, pos + 1)
110}
111
112/// Move to position 0 (highest priority). Returns changes.
113pub fn move_top(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
114    set_position(entries, info_hash, 0)
115}
116
117/// Move to last position (lowest priority). Returns changes.
118pub fn move_bottom(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
119    let max_pos = entries.len().saturating_sub(1) as i32;
120    set_position(entries, info_hash, max_pos)
121}
122
123// ---------------------------------------------------------------------------
124// Auto-manage evaluation
125// ---------------------------------------------------------------------------
126
127/// Classification of a torrent for queue evaluation.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum QueueCategory {
130    /// Incomplete torrent — counts against `active_downloads`.
131    Downloading,
132    /// Complete torrent — counts against `active_seeds`.
133    Seeding,
134    /// Hash-checking / verifying — counts against `active_checking`.
135    Checking,
136}
137
138/// Snapshot of a torrent for queue evaluation.
139#[derive(Debug, Clone)]
140pub struct QueueCandidate {
141    /// Info hash of the torrent under evaluation.
142    pub info_hash: Id20,
143    /// Current queue position; lower values are evaluated first.
144    pub position: i32,
145    /// Classification (downloading / seeding / checking) for slot accounting.
146    pub category: QueueCategory,
147    /// Whether the torrent currently occupies an active slot.
148    pub is_active: bool,
149    /// Whether the torrent is below the slow-transfer threshold; excluded from
150    /// the active counts when [`QueueConfig::dont_count_slow`] is set.
151    pub is_inactive: bool,
152    /// Recently started torrents are exempt from being paused (anti-flap).
153    pub recently_started: bool,
154    /// Swarm demand rank for seeding torrents (higher = more demand). `None` for non-seeding.
155    pub seed_rank: Option<i32>,
156}
157
158/// Named configuration for queue evaluation limits.
159#[derive(Debug, Clone)]
160pub struct QueueConfig {
161    /// Maximum number of concurrently active downloading torrents.
162    pub active_downloads: i32,
163    /// Maximum number of concurrently active seeding torrents.
164    pub active_seeds: i32,
165    /// Maximum number of concurrently active hash-checking torrents.
166    pub active_checking: i32,
167    /// Maximum total active torrents; negative means unlimited.
168    pub active_limit: i32,
169    /// When `true`, torrents below the slow-transfer threshold do not count
170    /// toward the active limits.
171    pub dont_count_slow: bool,
172    /// When `true`, seeding torrents are allotted slots ahead of downloads.
173    pub prefer_seeds: bool,
174}
175
176/// Result of queue evaluation: which torrents to start and stop.
177#[derive(Debug, Default)]
178pub struct QueueDecision {
179    /// Torrents to resume (promote into an active slot).
180    pub to_resume: Vec<Id20>,
181    /// Torrents to pause (demote out of an active slot).
182    pub to_pause: Vec<Id20>,
183}
184
185/// Compute swarm demand rank for a seeding torrent.
186///
187/// Higher values = more demand. Returns `i32::MAX` when seeders is 0 (infinite
188/// demand), 0 when leechers is 0 (no demand). Negative inputs (unknown from
189/// tracker) are treated as 0.
190#[must_use]
191pub fn compute_seed_rank(num_complete: i32, num_incomplete: i32) -> i32 {
192    let seeders = i64::from(num_complete.max(0));
193    let leechers = i64::from(num_incomplete.max(0));
194    if leechers == 0 {
195        return 0;
196    }
197    if seeders == 0 {
198        return i32::MAX;
199    }
200    i32::try_from((leechers * 1000) / seeders).unwrap_or(i32::MAX)
201}
202
203/// Evaluate the queue and decide which torrents to start/stop.
204///
205/// Negative limits mean "unlimited" for that category.
206/// Checking torrents are processed first (transient, shouldn't be blocked by
207/// DL/seed slot accounting). They count toward `active_limit` but NOT toward
208/// `active_downloads` or `active_seeds`.
209/// Seeding group is sorted by `seed_rank` descending (higher demand first),
210/// breaking ties by queue position.
211#[must_use]
212pub fn evaluate(candidates: &[QueueCandidate], config: &QueueConfig) -> QueueDecision {
213    let mut decision = QueueDecision::default();
214
215    let mut checking: Vec<_> = candidates
216        .iter()
217        .filter(|c| c.category == QueueCategory::Checking)
218        .collect();
219    checking.sort_by_key(|c| c.position);
220
221    let mut downloads: Vec<_> = candidates
222        .iter()
223        .filter(|c| c.category == QueueCategory::Downloading)
224        .collect();
225    downloads.sort_by_key(|c| c.position);
226
227    let mut seeds: Vec<_> = candidates
228        .iter()
229        .filter(|c| c.category == QueueCategory::Seeding)
230        .collect();
231    seeds.sort_by(|a, b| {
232        let rank_a = a.seed_rank.unwrap_or(0);
233        let rank_b = b.seed_rank.unwrap_or(0);
234        rank_b.cmp(&rank_a).then(a.position.cmp(&b.position))
235    });
236
237    let mut total_active: i32 = 0;
238
239    // Checking always first — transient operations that shouldn't be blocked
240    evaluate_group(
241        &checking,
242        config.active_checking,
243        config.active_limit,
244        config.dont_count_slow,
245        &mut total_active,
246        &mut decision,
247    );
248
249    let dl_seed_groups: Vec<(&[&QueueCandidate], i32)> = if config.prefer_seeds {
250        vec![
251            (&seeds, config.active_seeds),
252            (&downloads, config.active_downloads),
253        ]
254    } else {
255        vec![
256            (&downloads, config.active_downloads),
257            (&seeds, config.active_seeds),
258        ]
259    };
260
261    for (group, limit) in dl_seed_groups {
262        evaluate_group(
263            group,
264            limit,
265            config.active_limit,
266            config.dont_count_slow,
267            &mut total_active,
268            &mut decision,
269        );
270    }
271
272    decision
273}
274
275fn evaluate_group(
276    group: &[&QueueCandidate],
277    limit: i32,
278    active_limit: i32,
279    dont_count_slow: bool,
280    total_active: &mut i32,
281    decision: &mut QueueDecision,
282) {
283    let mut category_active: i32 = 0;
284
285    for candidate in group {
286        let counts_toward_limit = !(dont_count_slow && candidate.is_inactive);
287
288        if candidate.is_active {
289            if counts_toward_limit {
290                category_active += 1;
291                *total_active += 1;
292            }
293            let over_category = limit >= 0 && category_active > limit;
294            let over_total = active_limit >= 0 && *total_active > active_limit;
295            if (over_category || over_total) && !candidate.recently_started {
296                decision.to_pause.push(candidate.info_hash);
297                if counts_toward_limit {
298                    category_active -= 1;
299                    *total_active -= 1;
300                }
301            }
302        } else {
303            let under_category = limit < 0 || category_active < limit;
304            let under_total = active_limit < 0 || *total_active < active_limit;
305            if under_category && under_total {
306                decision.to_resume.push(candidate.info_hash);
307                if counts_toward_limit {
308                    category_active += 1;
309                    *total_active += 1;
310                }
311            }
312        }
313    }
314}
315
316/// Post-pass: for each category, if a queued torrent has strictly higher
317/// priority (lower position) than the lowest-priority active torrent, swap
318/// them. One swap per category per tick to prevent cascade instability.
319pub fn apply_preemption(decision: &mut QueueDecision, candidates: &[QueueCandidate]) {
320    for category in [
321        QueueCategory::Downloading,
322        QueueCategory::Seeding,
323        QueueCategory::Checking,
324    ] {
325        let cat_candidates: Vec<_> = candidates
326            .iter()
327            .filter(|c| c.category == category)
328            .collect();
329
330        // Find lowest-priority active torrent (not recently_started, not already in to_pause)
331        let worst_active = cat_candidates
332            .iter()
333            .filter(|c| c.is_active && !c.recently_started)
334            .filter(|c| !decision.to_pause.contains(&c.info_hash))
335            .max_by_key(|c| c.position);
336
337        // Find highest-priority queued torrent (not already in to_resume)
338        let best_queued = cat_candidates
339            .iter()
340            .filter(|c| !c.is_active)
341            .filter(|c| !decision.to_resume.contains(&c.info_hash))
342            .min_by_key(|c| c.position);
343
344        if let (Some(active), Some(queued)) = (worst_active, best_queued)
345            && queued.position < active.position
346        {
347            decision.to_pause.push(active.info_hash);
348            decision.to_resume.push(queued.info_hash);
349        }
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Tests
355// ---------------------------------------------------------------------------
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    fn make_hash(n: u8) -> Id20 {
362        Id20::from([n; 20])
363    }
364
365    fn make_entries(n: usize) -> Vec<QueueEntry> {
366        (0..n)
367            .map(|i| QueueEntry {
368                info_hash: make_hash(i as u8),
369                position: i as i32,
370            })
371            .collect()
372    }
373
374    fn positions(entries: &[QueueEntry]) -> Vec<(u8, i32)> {
375        let mut v: Vec<_> = entries
376            .iter()
377            .map(|e| (e.info_hash.as_ref()[0], e.position))
378            .collect();
379        v.sort_by_key(|&(_, pos)| pos);
380        v
381    }
382
383    // ---- Position arithmetic ----
384
385    #[test]
386    fn append_to_empty() {
387        assert_eq!(append_position(&[]), 0);
388    }
389
390    #[test]
391    fn append_to_existing() {
392        let entries = make_entries(3);
393        assert_eq!(append_position(&entries), 3);
394    }
395
396    #[test]
397    fn remove_middle_shifts_down() {
398        let mut entries = make_entries(4);
399        let changed = remove_position(&mut entries, 1);
400        assert_eq!(entries.len(), 3);
401        assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (3, 2)]);
402        assert_eq!(changed.len(), 2);
403    }
404
405    #[test]
406    fn remove_last_no_shifts() {
407        let mut entries = make_entries(3);
408        let changed = remove_position(&mut entries, 2);
409        assert_eq!(entries.len(), 2);
410        assert_eq!(positions(&entries), vec![(0, 0), (1, 1)]);
411        assert!(changed.is_empty());
412    }
413
414    #[test]
415    fn set_position_move_up() {
416        let mut entries = make_entries(4);
417        let changed = set_position(&mut entries, make_hash(3), 1);
418        assert_eq!(positions(&entries), vec![(0, 0), (3, 1), (1, 2), (2, 3)]);
419        assert_eq!(changed.len(), 3);
420    }
421
422    #[test]
423    fn set_position_move_down() {
424        let mut entries = make_entries(4);
425        let changed = set_position(&mut entries, make_hash(0), 2);
426        assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (0, 2), (3, 3)]);
427        assert_eq!(changed.len(), 3);
428    }
429
430    #[test]
431    fn set_position_same_is_noop() {
432        let mut entries = make_entries(3);
433        let changed = set_position(&mut entries, make_hash(1), 1);
434        assert!(changed.is_empty());
435    }
436
437    #[test]
438    fn move_up_from_zero_is_noop() {
439        let mut entries = make_entries(3);
440        let changed = move_up(&mut entries, make_hash(0));
441        assert!(changed.is_empty());
442    }
443
444    #[test]
445    fn move_up_swaps_adjacent() {
446        let mut entries = make_entries(3);
447        let changed = move_up(&mut entries, make_hash(2));
448        assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (1, 2)]);
449        assert_eq!(changed.len(), 2);
450    }
451
452    #[test]
453    fn move_down_from_last_is_noop() {
454        let mut entries = make_entries(3);
455        let changed = move_down(&mut entries, make_hash(2));
456        assert!(changed.is_empty());
457    }
458
459    #[test]
460    fn move_top_sends_to_front() {
461        let mut entries = make_entries(4);
462        let _changed = move_top(&mut entries, make_hash(3));
463        assert_eq!(positions(&entries), vec![(3, 0), (0, 1), (1, 2), (2, 3)]);
464    }
465
466    #[test]
467    fn move_bottom_sends_to_end() {
468        let mut entries = make_entries(4);
469        let _changed = move_bottom(&mut entries, make_hash(0));
470        assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (3, 2), (0, 3)]);
471    }
472
473    // ---- Evaluate algorithm ----
474
475    fn default_config() -> QueueConfig {
476        QueueConfig {
477            active_downloads: 3,
478            active_seeds: 5,
479            active_checking: 1,
480            active_limit: 500,
481            dont_count_slow: true,
482            prefer_seeds: false,
483        }
484    }
485
486    #[test]
487    fn evaluate_starts_up_to_limit() {
488        let candidates = vec![
489            QueueCandidate {
490                info_hash: make_hash(0),
491                position: 0,
492                category: QueueCategory::Downloading,
493                is_active: false,
494                is_inactive: false,
495                recently_started: false,
496                seed_rank: None,
497            },
498            QueueCandidate {
499                info_hash: make_hash(1),
500                position: 1,
501                category: QueueCategory::Downloading,
502                is_active: false,
503                is_inactive: false,
504                recently_started: false,
505                seed_rank: None,
506            },
507            QueueCandidate {
508                info_hash: make_hash(2),
509                position: 2,
510                category: QueueCategory::Downloading,
511                is_active: false,
512                is_inactive: false,
513                recently_started: false,
514                seed_rank: None,
515            },
516        ];
517        let config = QueueConfig {
518            active_downloads: 2,
519            ..default_config()
520        };
521        let decision = evaluate(&candidates, &config);
522        assert_eq!(decision.to_resume.len(), 2);
523        assert_eq!(decision.to_resume[0], make_hash(0));
524        assert_eq!(decision.to_resume[1], make_hash(1));
525        assert!(decision.to_pause.is_empty());
526    }
527
528    #[test]
529    fn evaluate_pauses_over_limit() {
530        let candidates = vec![
531            QueueCandidate {
532                info_hash: make_hash(0),
533                position: 0,
534                category: QueueCategory::Downloading,
535                is_active: true,
536                is_inactive: false,
537                recently_started: false,
538                seed_rank: None,
539            },
540            QueueCandidate {
541                info_hash: make_hash(1),
542                position: 1,
543                category: QueueCategory::Downloading,
544                is_active: true,
545                is_inactive: false,
546                recently_started: false,
547                seed_rank: None,
548            },
549            QueueCandidate {
550                info_hash: make_hash(2),
551                position: 2,
552                category: QueueCategory::Downloading,
553                is_active: true,
554                is_inactive: false,
555                recently_started: false,
556                seed_rank: None,
557            },
558        ];
559        let config = QueueConfig {
560            active_downloads: 2,
561            ..default_config()
562        };
563        let decision = evaluate(&candidates, &config);
564        assert!(decision.to_resume.is_empty());
565        assert_eq!(decision.to_pause.len(), 1);
566        assert_eq!(decision.to_pause[0], make_hash(2));
567    }
568
569    #[test]
570    fn evaluate_inactive_dont_count() {
571        let candidates = vec![
572            QueueCandidate {
573                info_hash: make_hash(0),
574                position: 0,
575                category: QueueCategory::Downloading,
576                is_active: true,
577                is_inactive: true,
578                recently_started: false,
579                seed_rank: None,
580            },
581            QueueCandidate {
582                info_hash: make_hash(1),
583                position: 1,
584                category: QueueCategory::Downloading,
585                is_active: true,
586                is_inactive: true,
587                recently_started: false,
588                seed_rank: None,
589            },
590            QueueCandidate {
591                info_hash: make_hash(2),
592                position: 2,
593                category: QueueCategory::Downloading,
594                is_active: true,
595                is_inactive: false,
596                recently_started: false,
597                seed_rank: None,
598            },
599        ];
600        let config = QueueConfig {
601            active_downloads: 2,
602            ..default_config()
603        };
604        let decision = evaluate(&candidates, &config);
605        assert!(decision.to_resume.is_empty());
606        assert!(decision.to_pause.is_empty());
607    }
608
609    #[test]
610    fn evaluate_respects_active_limit() {
611        let candidates = vec![
612            QueueCandidate {
613                info_hash: make_hash(0),
614                position: 0,
615                category: QueueCategory::Downloading,
616                is_active: true,
617                is_inactive: false,
618                recently_started: false,
619                seed_rank: None,
620            },
621            QueueCandidate {
622                info_hash: make_hash(1),
623                position: 1,
624                category: QueueCategory::Downloading,
625                is_active: true,
626                is_inactive: false,
627                recently_started: false,
628                seed_rank: None,
629            },
630            QueueCandidate {
631                info_hash: make_hash(10),
632                position: 0,
633                category: QueueCategory::Seeding,
634                is_active: true,
635                is_inactive: false,
636                recently_started: false,
637                seed_rank: None,
638            },
639            QueueCandidate {
640                info_hash: make_hash(11),
641                position: 1,
642                category: QueueCategory::Seeding,
643                is_active: true,
644                is_inactive: false,
645                recently_started: false,
646                seed_rank: None,
647            },
648            QueueCandidate {
649                info_hash: make_hash(12),
650                position: 2,
651                category: QueueCategory::Seeding,
652                is_active: true,
653                is_inactive: false,
654                recently_started: false,
655                seed_rank: None,
656            },
657        ];
658        let config = QueueConfig {
659            active_limit: 4,
660            ..default_config()
661        };
662        let decision = evaluate(&candidates, &config);
663        assert_eq!(decision.to_pause.len(), 1);
664        assert_eq!(decision.to_pause[0], make_hash(12));
665    }
666
667    #[test]
668    fn evaluate_unlimited_limits() {
669        let candidates = vec![
670            QueueCandidate {
671                info_hash: make_hash(0),
672                position: 0,
673                category: QueueCategory::Downloading,
674                is_active: false,
675                is_inactive: false,
676                recently_started: false,
677                seed_rank: None,
678            },
679            QueueCandidate {
680                info_hash: make_hash(1),
681                position: 1,
682                category: QueueCategory::Downloading,
683                is_active: false,
684                is_inactive: false,
685                recently_started: false,
686                seed_rank: None,
687            },
688        ];
689        let config = QueueConfig {
690            active_downloads: -1,
691            active_seeds: -1,
692            active_checking: -1,
693            active_limit: -1,
694            ..default_config()
695        };
696        let decision = evaluate(&candidates, &config);
697        assert_eq!(decision.to_resume.len(), 2);
698        assert!(decision.to_pause.is_empty());
699    }
700
701    #[test]
702    fn paused_torrents_excluded_from_candidates_never_resume() {
703        let paused_hash = make_hash(99);
704        let candidates = vec![QueueCandidate {
705            info_hash: make_hash(0),
706            position: 0,
707            category: QueueCategory::Downloading,
708            is_active: true,
709            is_inactive: false,
710            recently_started: false,
711            seed_rank: None,
712        }];
713        let config = QueueConfig {
714            active_downloads: 5,
715            ..default_config()
716        };
717        let decision = evaluate(&candidates, &config);
718        assert!(!decision.to_resume.contains(&paused_hash));
719        assert!(!decision.to_pause.contains(&paused_hash));
720    }
721
722    #[test]
723    fn evaluate_resumes_queued_when_slots_open() {
724        let candidates = vec![
725            QueueCandidate {
726                info_hash: make_hash(0),
727                position: 0,
728                category: QueueCategory::Downloading,
729                is_active: true,
730                is_inactive: false,
731                recently_started: false,
732                seed_rank: None,
733            },
734            QueueCandidate {
735                info_hash: make_hash(1),
736                position: 1,
737                category: QueueCategory::Downloading,
738                is_active: true,
739                is_inactive: false,
740                recently_started: false,
741                seed_rank: None,
742            },
743            QueueCandidate {
744                info_hash: make_hash(2),
745                position: 2,
746                category: QueueCategory::Downloading,
747                is_active: false,
748                is_inactive: false,
749                recently_started: false,
750                seed_rank: None,
751            },
752        ];
753        let config = QueueConfig {
754            dont_count_slow: false,
755            ..default_config()
756        };
757        let decision = evaluate(&candidates, &config);
758        assert!(decision.to_pause.is_empty());
759        assert_eq!(decision.to_resume, vec![make_hash(2)]);
760    }
761
762    #[test]
763    fn evaluate_queued_stays_queued_when_over_limit() {
764        let candidates = vec![
765            QueueCandidate {
766                info_hash: make_hash(0),
767                position: 0,
768                category: QueueCategory::Downloading,
769                is_active: true,
770                is_inactive: false,
771                recently_started: false,
772                seed_rank: None,
773            },
774            QueueCandidate {
775                info_hash: make_hash(1),
776                position: 1,
777                category: QueueCategory::Downloading,
778                is_active: true,
779                is_inactive: false,
780                recently_started: false,
781                seed_rank: None,
782            },
783            QueueCandidate {
784                info_hash: make_hash(2),
785                position: 2,
786                category: QueueCategory::Downloading,
787                is_active: true,
788                is_inactive: false,
789                recently_started: false,
790                seed_rank: None,
791            },
792            QueueCandidate {
793                info_hash: make_hash(3),
794                position: 3,
795                category: QueueCategory::Downloading,
796                is_active: false,
797                is_inactive: false,
798                recently_started: false,
799                seed_rank: None,
800            },
801        ];
802        let config = QueueConfig {
803            dont_count_slow: false,
804            ..default_config()
805        };
806        let decision = evaluate(&candidates, &config);
807        assert!(!decision.to_resume.contains(&make_hash(3)));
808    }
809
810    // ---- Checking queue ----
811
812    #[test]
813    fn evaluate_checking_separate_from_downloads() {
814        let candidates = vec![
815            QueueCandidate {
816                info_hash: make_hash(0),
817                position: 0,
818                category: QueueCategory::Downloading,
819                is_active: true,
820                is_inactive: false,
821                recently_started: false,
822                seed_rank: None,
823            },
824            QueueCandidate {
825                info_hash: make_hash(1),
826                position: 1,
827                category: QueueCategory::Downloading,
828                is_active: true,
829                is_inactive: false,
830                recently_started: false,
831                seed_rank: None,
832            },
833            QueueCandidate {
834                info_hash: make_hash(2),
835                position: 2,
836                category: QueueCategory::Downloading,
837                is_active: true,
838                is_inactive: false,
839                recently_started: false,
840                seed_rank: None,
841            },
842            QueueCandidate {
843                info_hash: make_hash(20),
844                position: 0,
845                category: QueueCategory::Checking,
846                is_active: false,
847                is_inactive: false,
848                recently_started: false,
849                seed_rank: None,
850            },
851        ];
852        let config = QueueConfig {
853            active_checking: 1,
854            ..default_config()
855        };
856        let decision = evaluate(&candidates, &config);
857        assert!(
858            decision.to_pause.is_empty(),
859            "3 DLs at limit + 1 checking: nothing paused"
860        );
861        assert_eq!(
862            decision.to_resume,
863            vec![make_hash(20)],
864            "checking runs independently"
865        );
866    }
867
868    #[test]
869    fn evaluate_checking_respects_own_limit() {
870        let candidates = vec![
871            QueueCandidate {
872                info_hash: make_hash(20),
873                position: 0,
874                category: QueueCategory::Checking,
875                is_active: true,
876                is_inactive: false,
877                recently_started: false,
878                seed_rank: None,
879            },
880            QueueCandidate {
881                info_hash: make_hash(21),
882                position: 1,
883                category: QueueCategory::Checking,
884                is_active: true,
885                is_inactive: false,
886                recently_started: false,
887                seed_rank: None,
888            },
889        ];
890        let config = QueueConfig {
891            active_checking: 1,
892            ..default_config()
893        };
894        let decision = evaluate(&candidates, &config);
895        assert_eq!(decision.to_pause, vec![make_hash(21)]);
896    }
897
898    #[test]
899    fn evaluate_checking_counts_toward_active_limit() {
900        let candidates = vec![
901            QueueCandidate {
902                info_hash: make_hash(20),
903                position: 0,
904                category: QueueCategory::Checking,
905                is_active: true,
906                is_inactive: false,
907                recently_started: false,
908                seed_rank: None,
909            },
910            QueueCandidate {
911                info_hash: make_hash(0),
912                position: 0,
913                category: QueueCategory::Downloading,
914                is_active: true,
915                is_inactive: false,
916                recently_started: false,
917                seed_rank: None,
918            },
919            QueueCandidate {
920                info_hash: make_hash(10),
921                position: 0,
922                category: QueueCategory::Seeding,
923                is_active: true,
924                is_inactive: false,
925                recently_started: false,
926                seed_rank: None,
927            },
928        ];
929        // active_limit=2: checking(1) + DL(1) = 2, seed should be paused
930        let config = QueueConfig {
931            active_limit: 2,
932            active_checking: 5,
933            ..default_config()
934        };
935        let decision = evaluate(&candidates, &config);
936        assert_eq!(decision.to_pause, vec![make_hash(10)]);
937    }
938
939    #[test]
940    fn evaluate_checking_processed_first() {
941        // With active_limit=2: checking takes a slot first, then DL gets 1 slot
942        let candidates = vec![
943            QueueCandidate {
944                info_hash: make_hash(20),
945                position: 0,
946                category: QueueCategory::Checking,
947                is_active: false,
948                is_inactive: false,
949                recently_started: false,
950                seed_rank: None,
951            },
952            QueueCandidate {
953                info_hash: make_hash(0),
954                position: 0,
955                category: QueueCategory::Downloading,
956                is_active: false,
957                is_inactive: false,
958                recently_started: false,
959                seed_rank: None,
960            },
961            QueueCandidate {
962                info_hash: make_hash(1),
963                position: 1,
964                category: QueueCategory::Downloading,
965                is_active: false,
966                is_inactive: false,
967                recently_started: false,
968                seed_rank: None,
969            },
970        ];
971        let config = QueueConfig {
972            active_limit: 2,
973            active_checking: 1,
974            ..default_config()
975        };
976        let decision = evaluate(&candidates, &config);
977        assert!(
978            decision.to_resume.contains(&make_hash(20)),
979            "checking resumed"
980        );
981        assert!(
982            decision.to_resume.contains(&make_hash(0)),
983            "first DL resumed"
984        );
985        assert!(
986            !decision.to_resume.contains(&make_hash(1)),
987            "second DL blocked by active_limit"
988        );
989    }
990
991    // ---- Metadata uses checking budget ----
992
993    #[test]
994    fn evaluate_metadata_uses_checking_budget() {
995        // 1 metadata (Checking) + 3 downloads under DL limit: no interference
996        let candidates = vec![
997            QueueCandidate {
998                info_hash: make_hash(20),
999                position: 0,
1000                category: QueueCategory::Checking,
1001                is_active: true,
1002                is_inactive: false,
1003                recently_started: false,
1004                seed_rank: None,
1005            },
1006            QueueCandidate {
1007                info_hash: make_hash(0),
1008                position: 0,
1009                category: QueueCategory::Downloading,
1010                is_active: true,
1011                is_inactive: false,
1012                recently_started: false,
1013                seed_rank: None,
1014            },
1015            QueueCandidate {
1016                info_hash: make_hash(1),
1017                position: 1,
1018                category: QueueCategory::Downloading,
1019                is_active: true,
1020                is_inactive: false,
1021                recently_started: false,
1022                seed_rank: None,
1023            },
1024            QueueCandidate {
1025                info_hash: make_hash(2),
1026                position: 2,
1027                category: QueueCategory::Downloading,
1028                is_active: true,
1029                is_inactive: false,
1030                recently_started: false,
1031                seed_rank: None,
1032            },
1033        ];
1034        let config = QueueConfig {
1035            active_checking: 3,
1036            ..default_config()
1037        };
1038        let decision = evaluate(&candidates, &config);
1039        assert!(
1040            decision.to_pause.is_empty(),
1041            "metadata in Checking doesn't consume DL slot"
1042        );
1043        assert!(decision.to_resume.is_empty());
1044    }
1045
1046    #[test]
1047    fn evaluate_metadata_respects_checking_limit() {
1048        // 4 metadata torrents, checking limit 3: 1 queued
1049        let candidates = vec![
1050            QueueCandidate {
1051                info_hash: make_hash(20),
1052                position: 0,
1053                category: QueueCategory::Checking,
1054                is_active: true,
1055                is_inactive: false,
1056                recently_started: false,
1057                seed_rank: None,
1058            },
1059            QueueCandidate {
1060                info_hash: make_hash(21),
1061                position: 1,
1062                category: QueueCategory::Checking,
1063                is_active: true,
1064                is_inactive: false,
1065                recently_started: false,
1066                seed_rank: None,
1067            },
1068            QueueCandidate {
1069                info_hash: make_hash(22),
1070                position: 2,
1071                category: QueueCategory::Checking,
1072                is_active: true,
1073                is_inactive: false,
1074                recently_started: false,
1075                seed_rank: None,
1076            },
1077            QueueCandidate {
1078                info_hash: make_hash(23),
1079                position: 3,
1080                category: QueueCategory::Checking,
1081                is_active: true,
1082                is_inactive: false,
1083                recently_started: false,
1084                seed_rank: None,
1085            },
1086        ];
1087        let config = QueueConfig {
1088            active_checking: 3,
1089            ..default_config()
1090        };
1091        let decision = evaluate(&candidates, &config);
1092        assert_eq!(decision.to_pause, vec![make_hash(23)]);
1093    }
1094
1095    // ---- Anti-flap timer ----
1096
1097    #[test]
1098    fn evaluate_recently_started_exempt_from_pause() {
1099        // 4 active DLs, limit 3, lowest-priority is recently_started: NOT paused
1100        let candidates = vec![
1101            QueueCandidate {
1102                info_hash: make_hash(0),
1103                position: 0,
1104                category: QueueCategory::Downloading,
1105                is_active: true,
1106                is_inactive: false,
1107                recently_started: false,
1108                seed_rank: None,
1109            },
1110            QueueCandidate {
1111                info_hash: make_hash(1),
1112                position: 1,
1113                category: QueueCategory::Downloading,
1114                is_active: true,
1115                is_inactive: false,
1116                recently_started: false,
1117                seed_rank: None,
1118            },
1119            QueueCandidate {
1120                info_hash: make_hash(2),
1121                position: 2,
1122                category: QueueCategory::Downloading,
1123                is_active: true,
1124                is_inactive: false,
1125                recently_started: false,
1126                seed_rank: None,
1127            },
1128            QueueCandidate {
1129                info_hash: make_hash(3),
1130                position: 3,
1131                category: QueueCategory::Downloading,
1132                is_active: true,
1133                is_inactive: false,
1134                recently_started: true,
1135                seed_rank: None,
1136            },
1137        ];
1138        let config = default_config();
1139        let decision = evaluate(&candidates, &config);
1140        assert!(
1141            !decision.to_pause.contains(&make_hash(3)),
1142            "recently_started torrent must not be paused"
1143        );
1144    }
1145
1146    #[test]
1147    fn evaluate_recently_started_still_counts_toward_limit() {
1148        // 3 active DLs (one recently_started) + 1 queued, limit 3:
1149        // queued stays queued because recently_started occupies a slot
1150        let candidates = vec![
1151            QueueCandidate {
1152                info_hash: make_hash(0),
1153                position: 0,
1154                category: QueueCategory::Downloading,
1155                is_active: true,
1156                is_inactive: false,
1157                recently_started: false,
1158                seed_rank: None,
1159            },
1160            QueueCandidate {
1161                info_hash: make_hash(1),
1162                position: 1,
1163                category: QueueCategory::Downloading,
1164                is_active: true,
1165                is_inactive: false,
1166                recently_started: false,
1167                seed_rank: None,
1168            },
1169            QueueCandidate {
1170                info_hash: make_hash(2),
1171                position: 2,
1172                category: QueueCategory::Downloading,
1173                is_active: true,
1174                is_inactive: false,
1175                recently_started: true,
1176                seed_rank: None,
1177            },
1178            QueueCandidate {
1179                info_hash: make_hash(3),
1180                position: 3,
1181                category: QueueCategory::Downloading,
1182                is_active: false,
1183                is_inactive: false,
1184                recently_started: false,
1185                seed_rank: None,
1186            },
1187        ];
1188        let config = default_config();
1189        let decision = evaluate(&candidates, &config);
1190        assert!(
1191            !decision.to_resume.contains(&make_hash(3)),
1192            "queued torrent blocked: recently_started holds the slot"
1193        );
1194    }
1195
1196    #[test]
1197    fn evaluate_not_recently_started_can_be_paused() {
1198        // 4 active DLs, limit 3, lowest-priority NOT recently_started: paused
1199        let candidates = vec![
1200            QueueCandidate {
1201                info_hash: make_hash(0),
1202                position: 0,
1203                category: QueueCategory::Downloading,
1204                is_active: true,
1205                is_inactive: false,
1206                recently_started: false,
1207                seed_rank: None,
1208            },
1209            QueueCandidate {
1210                info_hash: make_hash(1),
1211                position: 1,
1212                category: QueueCategory::Downloading,
1213                is_active: true,
1214                is_inactive: false,
1215                recently_started: false,
1216                seed_rank: None,
1217            },
1218            QueueCandidate {
1219                info_hash: make_hash(2),
1220                position: 2,
1221                category: QueueCategory::Downloading,
1222                is_active: true,
1223                is_inactive: false,
1224                recently_started: false,
1225                seed_rank: None,
1226            },
1227            QueueCandidate {
1228                info_hash: make_hash(3),
1229                position: 3,
1230                category: QueueCategory::Downloading,
1231                is_active: true,
1232                is_inactive: false,
1233                recently_started: false,
1234                seed_rank: None,
1235            },
1236        ];
1237        let config = default_config();
1238        let decision = evaluate(&candidates, &config);
1239        assert_eq!(decision.to_pause, vec![make_hash(3)]);
1240    }
1241
1242    // ---- Per-peer rate inactive classification ----
1243
1244    #[test]
1245    fn rate_based_inactive_uses_realtime_rate() {
1246        // Torrent with download_rate=0 (idle now) should be classified inactive
1247        // regardless of how many bytes it has downloaded historically. This test
1248        // validates the evaluator uses instantaneous rate, not a byte delta.
1249        let candidates = vec![QueueCandidate {
1250            info_hash: make_hash(0),
1251            position: 0,
1252            category: QueueCategory::Downloading,
1253            is_active: true,
1254            // is_inactive is computed by session.rs from stats.download_rate;
1255            // here we set it directly to simulate rate=0 (below threshold)
1256            is_inactive: true,
1257            recently_started: false,
1258            seed_rank: None,
1259        }];
1260        let config = QueueConfig {
1261            active_downloads: 1,
1262            ..default_config()
1263        };
1264        let decision = evaluate(&candidates, &config);
1265        // dont_count_slow=true (default): inactive torrent doesn't count toward limit
1266        assert!(
1267            decision.to_pause.is_empty(),
1268            "inactive torrent exempt from pausing"
1269        );
1270    }
1271
1272    // ---- Demand-ranked seeding queue ----
1273
1274    #[test]
1275    fn evaluate_seed_rank_high_demand_first() {
1276        // 3 seeders, 2 slots: rank 5000 (5:1 leech:seed) beats rank 100
1277        let candidates = vec![
1278            QueueCandidate {
1279                info_hash: make_hash(10),
1280                position: 0,
1281                category: QueueCategory::Seeding,
1282                is_active: false,
1283                is_inactive: false,
1284                recently_started: false,
1285                seed_rank: Some(100),
1286            },
1287            QueueCandidate {
1288                info_hash: make_hash(11),
1289                position: 1,
1290                category: QueueCategory::Seeding,
1291                is_active: false,
1292                is_inactive: false,
1293                recently_started: false,
1294                seed_rank: Some(5000),
1295            },
1296            QueueCandidate {
1297                info_hash: make_hash(12),
1298                position: 2,
1299                category: QueueCategory::Seeding,
1300                is_active: false,
1301                is_inactive: false,
1302                recently_started: false,
1303                seed_rank: Some(200),
1304            },
1305        ];
1306        let config = QueueConfig {
1307            active_seeds: 2,
1308            ..default_config()
1309        };
1310        let decision = evaluate(&candidates, &config);
1311        assert_eq!(decision.to_resume.len(), 2);
1312        assert_eq!(decision.to_resume[0], make_hash(11), "highest demand first");
1313        assert_eq!(
1314            decision.to_resume[1],
1315            make_hash(12),
1316            "second highest demand"
1317        );
1318    }
1319
1320    #[test]
1321    fn evaluate_seed_rank_tie_breaks_by_position() {
1322        let candidates = vec![
1323            QueueCandidate {
1324                info_hash: make_hash(10),
1325                position: 0,
1326                category: QueueCategory::Seeding,
1327                is_active: false,
1328                is_inactive: false,
1329                recently_started: false,
1330                seed_rank: Some(500),
1331            },
1332            QueueCandidate {
1333                info_hash: make_hash(11),
1334                position: 1,
1335                category: QueueCategory::Seeding,
1336                is_active: false,
1337                is_inactive: false,
1338                recently_started: false,
1339                seed_rank: Some(500),
1340            },
1341        ];
1342        let config = QueueConfig {
1343            active_seeds: 1,
1344            ..default_config()
1345        };
1346        let decision = evaluate(&candidates, &config);
1347        assert_eq!(
1348            decision.to_resume,
1349            vec![make_hash(10)],
1350            "lower position wins on tie"
1351        );
1352    }
1353
1354    #[test]
1355    fn evaluate_seed_rank_none_treated_as_zero() {
1356        // num_complete=-1 (unknown) gets rank 0 via compute_seed_rank
1357        let candidates = vec![
1358            QueueCandidate {
1359                info_hash: make_hash(10),
1360                position: 0,
1361                category: QueueCategory::Seeding,
1362                is_active: false,
1363                is_inactive: false,
1364                recently_started: false,
1365                seed_rank: None,
1366            },
1367            QueueCandidate {
1368                info_hash: make_hash(11),
1369                position: 1,
1370                category: QueueCategory::Seeding,
1371                is_active: false,
1372                is_inactive: false,
1373                recently_started: false,
1374                seed_rank: Some(100),
1375            },
1376        ];
1377        let config = QueueConfig {
1378            active_seeds: 1,
1379            ..default_config()
1380        };
1381        let decision = evaluate(&candidates, &config);
1382        assert_eq!(
1383            decision.to_resume,
1384            vec![make_hash(11)],
1385            "known demand beats unknown"
1386        );
1387    }
1388
1389    #[test]
1390    fn compute_seed_rank_zero_seeders_returns_max() {
1391        assert_eq!(compute_seed_rank(0, 5), i32::MAX);
1392    }
1393
1394    #[test]
1395    fn compute_seed_rank_zero_leechers_returns_zero() {
1396        assert_eq!(compute_seed_rank(5, 0), 0);
1397    }
1398
1399    // ---- Priority preemption (apply_preemption post-pass) ----
1400
1401    #[test]
1402    fn preemption_displaces_lower_priority() {
1403        // apply_preemption on a decision where evaluate didn't swap:
1404        // 3 active at pos 0,1,4, queued at pos 2. Preemption swaps pos 4 ↔ pos 2.
1405        let candidates = vec![
1406            QueueCandidate {
1407                info_hash: make_hash(0),
1408                position: 0,
1409                category: QueueCategory::Downloading,
1410                is_active: true,
1411                is_inactive: false,
1412                recently_started: false,
1413                seed_rank: None,
1414            },
1415            QueueCandidate {
1416                info_hash: make_hash(1),
1417                position: 1,
1418                category: QueueCategory::Downloading,
1419                is_active: true,
1420                is_inactive: false,
1421                recently_started: false,
1422                seed_rank: None,
1423            },
1424            QueueCandidate {
1425                info_hash: make_hash(2),
1426                position: 2,
1427                category: QueueCategory::Downloading,
1428                is_active: false,
1429                is_inactive: false,
1430                recently_started: false,
1431                seed_rank: None,
1432            },
1433            QueueCandidate {
1434                info_hash: make_hash(4),
1435                position: 4,
1436                category: QueueCategory::Downloading,
1437                is_active: true,
1438                is_inactive: false,
1439                recently_started: false,
1440                seed_rank: None,
1441            },
1442        ];
1443        // Start with empty decision (simulating evaluate at-limit, no changes)
1444        let mut decision = QueueDecision::default();
1445        apply_preemption(&mut decision, &candidates);
1446        assert!(
1447            decision.to_pause.contains(&make_hash(4)),
1448            "position 4 paused"
1449        );
1450        assert!(
1451            decision.to_resume.contains(&make_hash(2)),
1452            "position 2 resumed"
1453        );
1454    }
1455
1456    #[test]
1457    fn preemption_respects_recently_started() {
1458        // Only active torrent is recently_started: no swap
1459        let candidates = vec![
1460            QueueCandidate {
1461                info_hash: make_hash(0),
1462                position: 0,
1463                category: QueueCategory::Downloading,
1464                is_active: false,
1465                is_inactive: false,
1466                recently_started: false,
1467                seed_rank: None,
1468            },
1469            QueueCandidate {
1470                info_hash: make_hash(4),
1471                position: 4,
1472                category: QueueCategory::Downloading,
1473                is_active: true,
1474                is_inactive: false,
1475                recently_started: true,
1476                seed_rank: None,
1477            },
1478        ];
1479        let mut decision = QueueDecision::default();
1480        apply_preemption(&mut decision, &candidates);
1481        assert!(
1482            decision.to_pause.is_empty(),
1483            "recently_started not displaced"
1484        );
1485        assert!(decision.to_resume.is_empty(), "no swap");
1486    }
1487
1488    #[test]
1489    fn preemption_noop_when_optimal() {
1490        // All active have lower positions than queued: no change
1491        let candidates = vec![
1492            QueueCandidate {
1493                info_hash: make_hash(0),
1494                position: 0,
1495                category: QueueCategory::Downloading,
1496                is_active: true,
1497                is_inactive: false,
1498                recently_started: false,
1499                seed_rank: None,
1500            },
1501            QueueCandidate {
1502                info_hash: make_hash(1),
1503                position: 1,
1504                category: QueueCategory::Downloading,
1505                is_active: true,
1506                is_inactive: false,
1507                recently_started: false,
1508                seed_rank: None,
1509            },
1510            QueueCandidate {
1511                info_hash: make_hash(5),
1512                position: 5,
1513                category: QueueCategory::Downloading,
1514                is_active: false,
1515                is_inactive: false,
1516                recently_started: false,
1517                seed_rank: None,
1518            },
1519        ];
1520        let mut decision = QueueDecision::default();
1521        apply_preemption(&mut decision, &candidates);
1522        assert!(decision.to_pause.is_empty(), "no swaps needed");
1523        assert!(decision.to_resume.is_empty(), "no swaps needed");
1524    }
1525
1526    #[test]
1527    fn preemption_single_swap_per_category() {
1528        // 3 active at pos 3,4,5 + 2 queued at pos 0,1.
1529        // Single swap: pos 5 ↔ pos 0 only. Pos 1 stays queued.
1530        let candidates = vec![
1531            QueueCandidate {
1532                info_hash: make_hash(0),
1533                position: 0,
1534                category: QueueCategory::Downloading,
1535                is_active: false,
1536                is_inactive: false,
1537                recently_started: false,
1538                seed_rank: None,
1539            },
1540            QueueCandidate {
1541                info_hash: make_hash(1),
1542                position: 1,
1543                category: QueueCategory::Downloading,
1544                is_active: false,
1545                is_inactive: false,
1546                recently_started: false,
1547                seed_rank: None,
1548            },
1549            QueueCandidate {
1550                info_hash: make_hash(3),
1551                position: 3,
1552                category: QueueCategory::Downloading,
1553                is_active: true,
1554                is_inactive: false,
1555                recently_started: false,
1556                seed_rank: None,
1557            },
1558            QueueCandidate {
1559                info_hash: make_hash(4),
1560                position: 4,
1561                category: QueueCategory::Downloading,
1562                is_active: true,
1563                is_inactive: false,
1564                recently_started: false,
1565                seed_rank: None,
1566            },
1567            QueueCandidate {
1568                info_hash: make_hash(5),
1569                position: 5,
1570                category: QueueCategory::Downloading,
1571                is_active: true,
1572                is_inactive: false,
1573                recently_started: false,
1574                seed_rank: None,
1575            },
1576        ];
1577        let mut decision = QueueDecision::default();
1578        apply_preemption(&mut decision, &candidates);
1579        assert_eq!(
1580            decision.to_pause,
1581            vec![make_hash(5)],
1582            "only pos 5 displaced"
1583        );
1584        assert_eq!(
1585            decision.to_resume,
1586            vec![make_hash(0)],
1587            "only pos 0 promoted"
1588        );
1589    }
1590}