1use std::collections::{HashMap, HashSet};
28
29#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35pub enum DiscoveryMethod {
36 Bootstrap,
38 Mdns,
40 Dht,
42 PeerExchange,
44 Manual,
46}
47
48impl std::fmt::Display for DiscoveryMethod {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 DiscoveryMethod::Bootstrap => write!(f, "Bootstrap"),
52 DiscoveryMethod::Mdns => write!(f, "Mdns"),
53 DiscoveryMethod::Dht => write!(f, "Dht"),
54 DiscoveryMethod::PeerExchange => write!(f, "PeerExchange"),
55 DiscoveryMethod::Manual => write!(f, "Manual"),
56 }
57 }
58}
59
60#[derive(Clone, Debug)]
62pub struct PeerCandidate {
63 pub peer_id: String,
65 pub addresses: Vec<String>,
67 pub discovered_via: DiscoveryMethod,
69 pub discovered_at: u64,
71 pub connect_score: f64,
73 pub attempted: bool,
75 pub connected: bool,
77 pub last_attempt: Option<u64>,
79}
80
81impl PeerCandidate {
82 pub fn new(
84 peer_id: impl Into<String>,
85 addresses: Vec<String>,
86 discovered_via: DiscoveryMethod,
87 discovered_at: u64,
88 ) -> Self {
89 Self {
90 peer_id: peer_id.into(),
91 addresses,
92 discovered_via,
93 discovered_at,
94 connect_score: 1.0,
95 attempted: false,
96 connected: false,
97 last_attempt: None,
98 }
99 }
100}
101
102#[derive(Clone, Debug, PartialEq, Eq)]
104pub enum ConnectOutcome {
105 Success,
107 Refused,
109 Timeout,
111 Unreachable,
113}
114
115#[derive(Clone, Debug)]
117pub struct DiscoveryConfig {
118 pub max_candidates: usize,
120 pub max_connected: usize,
122 pub rescore_interval_ms: u64,
124 pub retry_backoff_ms: u64,
126 pub stale_threshold_ms: u64,
129}
130
131impl Default for DiscoveryConfig {
132 fn default() -> Self {
133 Self {
134 max_candidates: 500,
135 max_connected: 50,
136 rescore_interval_ms: 60_000,
137 retry_backoff_ms: 5_000,
138 stale_threshold_ms: 300_000,
139 }
140 }
141}
142
143#[derive(Clone, Debug, Default)]
145pub struct DiscoveryStats {
146 pub total_candidates: usize,
148 pub connected: usize,
150 pub attempted: usize,
152 pub avg_score: f64,
154 pub total_discovered: u64,
156 pub total_connected: u64,
158 pub total_failed: u64,
160 pub method_distribution: HashMap<String, usize>,
162}
163
164const SCORE_MIN: f64 = 0.0;
169const SCORE_MAX: f64 = 10.0;
170const SCORE_SUCCESS_DELTA: f64 = 0.5;
171const SCORE_REFUSED_DELTA: f64 = -0.3;
172const SCORE_TIMEOUT_DELTA: f64 = -0.2;
173const SCORE_UNREACHABLE_DELTA: f64 = -0.5;
174const SCORE_DISCONNECT_DELTA: f64 = -0.1;
175
176#[inline]
177fn clamp_score(score: f64) -> f64 {
178 score.clamp(SCORE_MIN, SCORE_MAX)
179}
180
181pub struct PeerDiscoveryManager {
191 pub config: DiscoveryConfig,
193 candidates: HashMap<String, PeerCandidate>,
195 connected: HashSet<String>,
197 total_discovered: u64,
199 total_connected: u64,
201 total_failed: u64,
203}
204
205impl PeerDiscoveryManager {
206 pub fn new(config: DiscoveryConfig) -> Self {
212 Self {
213 config,
214 candidates: HashMap::new(),
215 connected: HashSet::new(),
216 total_discovered: 0,
217 total_connected: 0,
218 total_failed: 0,
219 }
220 }
221
222 pub fn add_candidate(&mut self, candidate: PeerCandidate) -> bool {
235 if let Some(existing) = self.candidates.get_mut(&candidate.peer_id) {
237 for addr in &candidate.addresses {
238 if !existing.addresses.contains(addr) {
239 existing.addresses.push(addr.clone());
240 }
241 }
242 return true;
243 }
244
245 if self.candidates.len() >= self.config.max_candidates && !self.evict_lowest_non_connected()
247 {
248 return false;
249 }
250
251 self.total_discovered += 1;
252 self.candidates.insert(candidate.peer_id.clone(), candidate);
253 true
254 }
255
256 pub fn record_outcome(&mut self, peer_id: &str, outcome: ConnectOutcome, now: u64) -> bool {
261 let candidate = match self.candidates.get_mut(peer_id) {
262 Some(c) => c,
263 None => return false,
264 };
265
266 candidate.last_attempt = Some(now);
267
268 match outcome {
269 ConnectOutcome::Success => {
270 candidate.connected = true;
271 candidate.connect_score =
272 clamp_score(candidate.connect_score + SCORE_SUCCESS_DELTA);
273 let pid = peer_id.to_owned();
274 self.connected.insert(pid);
275 self.total_connected += 1;
276 }
277 ConnectOutcome::Refused => {
278 candidate.attempted = true;
279 candidate.connect_score =
280 clamp_score(candidate.connect_score + SCORE_REFUSED_DELTA);
281 self.total_failed += 1;
282 }
283 ConnectOutcome::Timeout => {
284 candidate.attempted = true;
285 candidate.connect_score =
286 clamp_score(candidate.connect_score + SCORE_TIMEOUT_DELTA);
287 self.total_failed += 1;
288 }
289 ConnectOutcome::Unreachable => {
290 candidate.attempted = true;
291 candidate.connect_score =
292 clamp_score(candidate.connect_score + SCORE_UNREACHABLE_DELTA);
293 self.total_failed += 1;
294 }
295 }
296
297 true
298 }
299
300 pub fn mark_disconnected(&mut self, peer_id: &str) -> bool {
305 let candidate = match self.candidates.get_mut(peer_id) {
306 Some(c) => c,
307 None => return false,
308 };
309
310 candidate.connected = false;
311 candidate.connect_score = clamp_score(candidate.connect_score + SCORE_DISCONNECT_DELTA);
312 self.connected.remove(peer_id);
313 true
314 }
315
316 pub fn candidates_to_try(&self, n: usize, now: u64) -> Vec<&PeerCandidate> {
329 let backoff = self.config.retry_backoff_ms;
330 let mut eligible: Vec<&PeerCandidate> = self
331 .candidates
332 .values()
333 .filter(|c| {
334 if c.connected {
335 return false;
336 }
337 match c.last_attempt {
338 None => true,
339 Some(t) => now.saturating_sub(t) >= backoff,
340 }
341 })
342 .collect();
343
344 eligible.sort_by(|a, b| {
345 b.connect_score
346 .partial_cmp(&a.connect_score)
347 .unwrap_or(std::cmp::Ordering::Equal)
348 });
349 eligible.truncate(n);
350 eligible
351 }
352
353 pub fn evict_stale(&mut self, now: u64) -> usize {
361 let threshold = self.config.stale_threshold_ms;
362 let cutoff = now.saturating_sub(threshold);
363
364 let stale_ids: Vec<String> = self
365 .candidates
366 .iter()
367 .filter(|(_, c)| {
368 if c.connected {
369 return false;
370 }
371 let old_discovery = c.discovered_at < cutoff;
372 let old_attempt = match c.last_attempt {
373 None => true,
374 Some(t) => t < cutoff,
375 };
376 old_discovery && old_attempt
377 })
378 .map(|(k, _)| k.clone())
379 .collect();
380
381 let count = stale_ids.len();
382 for id in stale_ids {
383 self.candidates.remove(&id);
384 }
385 count
386 }
387
388 pub fn connected_peers(&self) -> Vec<&PeerCandidate> {
394 self.candidates.values().filter(|c| c.connected).collect()
395 }
396
397 pub fn peer_count(&self) -> usize {
399 self.candidates.len()
400 }
401
402 pub fn connected_count(&self) -> usize {
404 self.connected.len()
405 }
406
407 pub fn best_candidate(&self) -> Option<&PeerCandidate> {
409 self.candidates
410 .values()
411 .filter(|c| !c.connected)
412 .max_by(|a, b| {
413 a.connect_score
414 .partial_cmp(&b.connect_score)
415 .unwrap_or(std::cmp::Ordering::Equal)
416 })
417 }
418
419 pub fn candidates_by_method(&self, method: &DiscoveryMethod) -> Vec<&PeerCandidate> {
421 self.candidates
422 .values()
423 .filter(|c| &c.discovered_via == method)
424 .collect()
425 }
426
427 pub fn stats(&self) -> DiscoveryStats {
429 let total_candidates = self.candidates.len();
430 let connected = self.connected.len();
431 let attempted = self.candidates.values().filter(|c| c.attempted).count();
432
433 let avg_score = if total_candidates == 0 {
434 0.0
435 } else {
436 let sum: f64 = self.candidates.values().map(|c| c.connect_score).sum();
437 sum / total_candidates as f64
438 };
439
440 let mut method_distribution: HashMap<String, usize> = HashMap::new();
441 for c in self.candidates.values() {
442 *method_distribution
443 .entry(c.discovered_via.to_string())
444 .or_insert(0) += 1;
445 }
446
447 DiscoveryStats {
448 total_candidates,
449 connected,
450 attempted,
451 avg_score,
452 total_discovered: self.total_discovered,
453 total_connected: self.total_connected,
454 total_failed: self.total_failed,
455 method_distribution,
456 }
457 }
458
459 fn evict_lowest_non_connected(&mut self) -> bool {
466 let victim_id: Option<String> = self
467 .candidates
468 .iter()
469 .filter(|(_, c)| !c.connected)
470 .min_by(|(_, a), (_, b)| {
471 a.connect_score
472 .partial_cmp(&b.connect_score)
473 .unwrap_or(std::cmp::Ordering::Equal)
474 })
475 .map(|(k, _)| k.clone());
476
477 if let Some(id) = victim_id {
478 self.candidates.remove(&id);
479 true
480 } else {
481 false
482 }
483 }
484}
485
486#[cfg(test)]
491mod tests {
492 use std::collections::HashMap;
493
494 use crate::peer_discovery_manager::{
495 ConnectOutcome, DiscoveryConfig, DiscoveryMethod, PeerCandidate, PeerDiscoveryManager,
496 };
497
498 fn default_config() -> DiscoveryConfig {
503 DiscoveryConfig::default()
504 }
505
506 fn make_candidate(id: &str, method: DiscoveryMethod, discovered_at: u64) -> PeerCandidate {
507 PeerCandidate::new(
508 id,
509 vec![format!("/ip4/1.2.3.4/tcp/{id}")],
510 method,
511 discovered_at,
512 )
513 }
514
515 fn make_candidate_with_addrs(
516 id: &str,
517 addrs: Vec<String>,
518 method: DiscoveryMethod,
519 discovered_at: u64,
520 ) -> PeerCandidate {
521 PeerCandidate::new(id, addrs, method, discovered_at)
522 }
523
524 #[test]
529 fn test_new_empty_state() {
530 let mgr = PeerDiscoveryManager::new(default_config());
531 assert_eq!(mgr.peer_count(), 0);
532 assert_eq!(mgr.connected_count(), 0);
533 }
534
535 #[test]
536 fn test_default_config_values() {
537 let cfg = DiscoveryConfig::default();
538 assert_eq!(cfg.max_candidates, 500);
539 assert_eq!(cfg.max_connected, 50);
540 assert_eq!(cfg.rescore_interval_ms, 60_000);
541 assert_eq!(cfg.retry_backoff_ms, 5_000);
542 assert_eq!(cfg.stale_threshold_ms, 300_000);
543 }
544
545 #[test]
546 fn test_peer_candidate_new_defaults() {
547 let c = make_candidate("p1", DiscoveryMethod::Bootstrap, 1000);
548 assert_eq!(c.peer_id, "p1");
549 assert!((c.connect_score - 1.0).abs() < f64::EPSILON);
550 assert!(!c.attempted);
551 assert!(!c.connected);
552 assert!(c.last_attempt.is_none());
553 }
554
555 #[test]
560 fn test_add_candidate_returns_true() {
561 let mut mgr = PeerDiscoveryManager::new(default_config());
562 let c = make_candidate("p1", DiscoveryMethod::Bootstrap, 1000);
563 assert!(mgr.add_candidate(c));
564 assert_eq!(mgr.peer_count(), 1);
565 }
566
567 #[test]
568 fn test_add_duplicate_merges_addresses() {
569 let mut mgr = PeerDiscoveryManager::new(default_config());
570 let c1 = make_candidate_with_addrs(
571 "p1",
572 vec!["/ip4/1.0.0.1/tcp/4001".to_string()],
573 DiscoveryMethod::Bootstrap,
574 1000,
575 );
576 let c2 = make_candidate_with_addrs(
577 "p1",
578 vec![
579 "/ip4/1.0.0.1/tcp/4001".to_string(), "/ip4/2.0.0.2/tcp/4001".to_string(), ],
582 DiscoveryMethod::Mdns,
583 2000,
584 );
585 mgr.add_candidate(c1);
586 let added = mgr.add_candidate(c2);
587 assert!(added);
588 assert_eq!(mgr.peer_count(), 1);
589 let stored = mgr.candidates.get("p1").expect("should exist");
590 assert_eq!(stored.addresses.len(), 2);
591 }
592
593 #[test]
594 fn test_add_duplicate_no_address_duplication() {
595 let mut mgr = PeerDiscoveryManager::new(default_config());
596 let addr = "/ip4/1.0.0.1/tcp/4001".to_string();
597 mgr.add_candidate(make_candidate_with_addrs(
598 "p1",
599 vec![addr.clone()],
600 DiscoveryMethod::Bootstrap,
601 0,
602 ));
603 mgr.add_candidate(make_candidate_with_addrs(
604 "p1",
605 vec![addr.clone()],
606 DiscoveryMethod::Bootstrap,
607 0,
608 ));
609 assert_eq!(
610 mgr.candidates.get("p1").expect("present").addresses.len(),
611 1
612 );
613 }
614
615 #[test]
616 fn test_add_candidate_at_capacity_evicts_lowest() {
617 let cfg = DiscoveryConfig {
618 max_candidates: 3,
619 ..Default::default()
620 };
621 let mut mgr = PeerDiscoveryManager::new(cfg);
622
623 let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
624 c_low.connect_score = 0.1;
625
626 let mut c_mid = make_candidate("mid", DiscoveryMethod::Bootstrap, 0);
627 c_mid.connect_score = 1.0;
628
629 let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
630 c_high.connect_score = 5.0;
631
632 mgr.add_candidate(c_low);
633 mgr.add_candidate(c_mid);
634 mgr.add_candidate(c_high);
635 assert_eq!(mgr.peer_count(), 3);
636
637 let c_new = make_candidate("new", DiscoveryMethod::Dht, 0);
639 assert!(mgr.add_candidate(c_new));
640 assert_eq!(mgr.peer_count(), 3);
641 assert!(!mgr.candidates.contains_key("low"));
642 assert!(mgr.candidates.contains_key("new"));
643 }
644
645 #[test]
646 fn test_add_candidate_at_capacity_all_connected_returns_false() {
647 let cfg = DiscoveryConfig {
648 max_candidates: 2,
649 ..Default::default()
650 };
651 let mut mgr = PeerDiscoveryManager::new(cfg);
652
653 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
654 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
655
656 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
658 mgr.record_outcome("p2", ConnectOutcome::Success, 100);
659
660 let c_new = make_candidate("p3", DiscoveryMethod::Dht, 0);
661 assert!(!mgr.add_candidate(c_new));
662 assert_eq!(mgr.peer_count(), 2);
663 }
664
665 #[test]
666 fn test_total_discovered_increments() {
667 let mut mgr = PeerDiscoveryManager::new(default_config());
668 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
669 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
670 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
672 assert_eq!(mgr.total_discovered, 2);
673 }
674
675 #[test]
680 fn test_record_outcome_success_marks_connected() {
681 let mut mgr = PeerDiscoveryManager::new(default_config());
682 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
683 assert!(mgr.record_outcome("p1", ConnectOutcome::Success, 1000));
684 let c = mgr.candidates.get("p1").expect("present");
685 assert!(c.connected);
686 assert_eq!(mgr.connected_count(), 1);
687 assert_eq!(mgr.total_connected, 1);
688 }
689
690 #[test]
691 fn test_record_outcome_success_increases_score() {
692 let mut mgr = PeerDiscoveryManager::new(default_config());
693 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
694 mgr.record_outcome("p1", ConnectOutcome::Success, 1000);
695 let score = mgr.candidates.get("p1").expect("present").connect_score;
696 assert!((score - 1.5).abs() < 1e-10, "expected 1.5, got {score}");
697 }
698
699 #[test]
700 fn test_record_outcome_success_score_capped_at_10() {
701 let mut mgr = PeerDiscoveryManager::new(default_config());
702 let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
703 c.connect_score = 9.9;
704 mgr.add_candidate(c);
705 mgr.record_outcome("p1", ConnectOutcome::Success, 1000);
706 let score = mgr.candidates.get("p1").expect("present").connect_score;
707 assert!(score <= 10.0, "score must not exceed 10.0");
708 assert!((score - 10.0).abs() < 1e-10, "expected 10.0, got {score}");
709 }
710
711 #[test]
712 fn test_record_outcome_refused_decreases_score() {
713 let mut mgr = PeerDiscoveryManager::new(default_config());
714 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
715 mgr.record_outcome("p1", ConnectOutcome::Refused, 1000);
716 let c = mgr.candidates.get("p1").expect("present");
717 assert!(c.attempted);
718 assert!((c.connect_score - 0.7).abs() < 1e-10);
719 assert_eq!(mgr.total_failed, 1);
720 }
721
722 #[test]
723 fn test_record_outcome_timeout_decreases_score() {
724 let mut mgr = PeerDiscoveryManager::new(default_config());
725 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
726 mgr.record_outcome("p1", ConnectOutcome::Timeout, 1000);
727 let c = mgr.candidates.get("p1").expect("present");
728 assert!((c.connect_score - 0.8).abs() < 1e-10);
729 }
730
731 #[test]
732 fn test_record_outcome_unreachable_decreases_score() {
733 let mut mgr = PeerDiscoveryManager::new(default_config());
734 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
735 mgr.record_outcome("p1", ConnectOutcome::Unreachable, 1000);
736 let c = mgr.candidates.get("p1").expect("present");
737 assert!((c.connect_score - 0.5).abs() < 1e-10);
738 }
739
740 #[test]
741 fn test_record_outcome_score_floor_at_zero() {
742 let mut mgr = PeerDiscoveryManager::new(default_config());
743 let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
744 c.connect_score = 0.1;
745 mgr.add_candidate(c);
746 mgr.record_outcome("p1", ConnectOutcome::Unreachable, 1000);
747 let score = mgr.candidates.get("p1").expect("present").connect_score;
748 assert!(score >= 0.0, "score must not go below 0");
749 assert!((score - 0.0).abs() < 1e-10, "expected 0.0, got {score}");
750 }
751
752 #[test]
753 fn test_record_outcome_unknown_peer_returns_false() {
754 let mut mgr = PeerDiscoveryManager::new(default_config());
755 assert!(!mgr.record_outcome("ghost", ConnectOutcome::Success, 0));
756 }
757
758 #[test]
759 fn test_record_outcome_sets_last_attempt() {
760 let mut mgr = PeerDiscoveryManager::new(default_config());
761 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
762 mgr.record_outcome("p1", ConnectOutcome::Refused, 9999);
763 assert_eq!(
764 mgr.candidates.get("p1").expect("present").last_attempt,
765 Some(9999)
766 );
767 }
768
769 #[test]
774 fn test_mark_disconnected_clears_connected_flag() {
775 let mut mgr = PeerDiscoveryManager::new(default_config());
776 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
777 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
778 assert!(mgr.mark_disconnected("p1"));
779 assert!(!mgr.candidates.get("p1").expect("present").connected);
780 assert_eq!(mgr.connected_count(), 0);
781 }
782
783 #[test]
784 fn test_mark_disconnected_reduces_score() {
785 let mut mgr = PeerDiscoveryManager::new(default_config());
786 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
787 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
788 let score_before = mgr.candidates.get("p1").expect("present").connect_score;
789 mgr.mark_disconnected("p1");
790 let score_after = mgr.candidates.get("p1").expect("present").connect_score;
791 assert!(score_after < score_before);
792 }
793
794 #[test]
795 fn test_mark_disconnected_score_floor_zero() {
796 let mut mgr = PeerDiscoveryManager::new(default_config());
797 let mut c = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
798 c.connect_score = 0.05;
799 c.connected = true;
800 mgr.add_candidate(c);
801 mgr.connected.insert("p1".to_owned());
802 mgr.mark_disconnected("p1");
803 assert!(mgr.candidates.get("p1").expect("present").connect_score >= 0.0);
804 }
805
806 #[test]
807 fn test_mark_disconnected_unknown_peer_returns_false() {
808 let mut mgr = PeerDiscoveryManager::new(default_config());
809 assert!(!mgr.mark_disconnected("ghost"));
810 }
811
812 #[test]
817 fn test_candidates_to_try_excludes_connected() {
818 let mut mgr = PeerDiscoveryManager::new(default_config());
819 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
820 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
821 mgr.record_outcome("p1", ConnectOutcome::Success, 500);
822
823 let candidates = mgr.candidates_to_try(10, 1000);
824 assert_eq!(candidates.len(), 1);
825 assert_eq!(candidates[0].peer_id, "p2");
826 }
827
828 #[test]
829 fn test_candidates_to_try_respects_backoff() {
830 let cfg = DiscoveryConfig {
831 retry_backoff_ms: 5_000,
832 ..Default::default()
833 };
834 let mut mgr = PeerDiscoveryManager::new(cfg);
835 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
836 mgr.record_outcome("p1", ConnectOutcome::Refused, 1000);
838
839 let too_early = mgr.candidates_to_try(10, 5_999);
840 assert!(too_early.is_empty(), "should be in backoff");
841
842 let after_backoff = mgr.candidates_to_try(10, 6_001);
843 assert_eq!(after_backoff.len(), 1);
844 }
845
846 #[test]
847 fn test_candidates_to_try_sorts_by_score_descending() {
848 let mut mgr = PeerDiscoveryManager::new(default_config());
849
850 let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
851 c_low.connect_score = 0.5;
852 let mut c_mid = make_candidate("mid", DiscoveryMethod::Bootstrap, 0);
853 c_mid.connect_score = 2.0;
854 let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
855 c_high.connect_score = 7.0;
856
857 mgr.add_candidate(c_low);
858 mgr.add_candidate(c_mid);
859 mgr.add_candidate(c_high);
860
861 let result = mgr.candidates_to_try(3, 100_000);
862 assert_eq!(result.len(), 3);
863 assert_eq!(result[0].peer_id, "high");
864 assert_eq!(result[1].peer_id, "mid");
865 assert_eq!(result[2].peer_id, "low");
866 }
867
868 #[test]
869 fn test_candidates_to_try_truncates_to_n() {
870 let mut mgr = PeerDiscoveryManager::new(default_config());
871 for i in 0..10 {
872 mgr.add_candidate(make_candidate(&format!("p{i}"), DiscoveryMethod::Dht, 0));
873 }
874 assert_eq!(mgr.candidates_to_try(3, 100_000).len(), 3);
875 }
876
877 #[test]
878 fn test_candidates_to_try_never_attempted_always_eligible() {
879 let mut mgr = PeerDiscoveryManager::new(default_config());
880 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
881 let result = mgr.candidates_to_try(10, 0);
883 assert_eq!(result.len(), 1);
884 }
885
886 #[test]
891 fn test_evict_stale_removes_old_unattempted() {
892 let cfg = DiscoveryConfig {
893 stale_threshold_ms: 10_000,
894 ..Default::default()
895 };
896 let mut mgr = PeerDiscoveryManager::new(cfg);
897 mgr.add_candidate(make_candidate("old", DiscoveryMethod::Bootstrap, 0));
898 mgr.add_candidate(make_candidate("recent", DiscoveryMethod::Bootstrap, 5_000));
899
900 let evicted = mgr.evict_stale(20_000);
904 assert_eq!(evicted, 2);
905 assert_eq!(mgr.peer_count(), 0);
906 }
907
908 #[test]
909 fn test_evict_stale_keeps_recently_attempted() {
910 let cfg = DiscoveryConfig {
911 stale_threshold_ms: 10_000,
912 ..Default::default()
913 };
914 let mut mgr = PeerDiscoveryManager::new(cfg);
915 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
916 mgr.record_outcome("p1", ConnectOutcome::Refused, 15_000);
918
919 let evicted = mgr.evict_stale(20_000);
920 assert_eq!(evicted, 0);
921 assert_eq!(mgr.peer_count(), 1);
922 }
923
924 #[test]
925 fn test_evict_stale_never_evicts_connected() {
926 let cfg = DiscoveryConfig {
927 stale_threshold_ms: 1,
928 ..Default::default()
929 };
930 let mut mgr = PeerDiscoveryManager::new(cfg);
931 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
932 mgr.record_outcome("p1", ConnectOutcome::Success, 0);
933
934 let evicted = mgr.evict_stale(100_000);
937 assert_eq!(evicted, 0);
938 assert_eq!(mgr.peer_count(), 1);
939 }
940
941 #[test]
942 fn test_evict_stale_returns_count() {
943 let cfg = DiscoveryConfig {
944 stale_threshold_ms: 100,
945 ..Default::default()
946 };
947 let mut mgr = PeerDiscoveryManager::new(cfg);
948 for i in 0..5 {
949 mgr.add_candidate(make_candidate(&format!("p{i}"), DiscoveryMethod::Dht, 0));
950 }
951 let evicted = mgr.evict_stale(200);
952 assert_eq!(evicted, 5);
953 }
954
955 #[test]
960 fn test_connected_peers_only_returns_connected() {
961 let mut mgr = PeerDiscoveryManager::new(default_config());
962 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
963 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
964 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
965
966 let connected = mgr.connected_peers();
967 assert_eq!(connected.len(), 1);
968 assert_eq!(connected[0].peer_id, "p1");
969 }
970
971 #[test]
972 fn test_connected_peers_empty_initially() {
973 let mgr = PeerDiscoveryManager::new(default_config());
974 assert!(mgr.connected_peers().is_empty());
975 }
976
977 #[test]
982 fn test_best_candidate_returns_highest_score() {
983 let mut mgr = PeerDiscoveryManager::new(default_config());
984 let mut c_low = make_candidate("low", DiscoveryMethod::Bootstrap, 0);
985 c_low.connect_score = 0.5;
986 let mut c_high = make_candidate("high", DiscoveryMethod::Bootstrap, 0);
987 c_high.connect_score = 8.0;
988 mgr.add_candidate(c_low);
989 mgr.add_candidate(c_high);
990
991 let best = mgr.best_candidate().expect("should have a best");
992 assert_eq!(best.peer_id, "high");
993 }
994
995 #[test]
996 fn test_best_candidate_excludes_connected() {
997 let mut mgr = PeerDiscoveryManager::new(default_config());
998 let mut c1 = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
999 c1.connect_score = 9.0;
1000 let mut c2 = make_candidate("p2", DiscoveryMethod::Bootstrap, 0);
1001 c2.connect_score = 3.0;
1002 mgr.add_candidate(c1);
1003 mgr.add_candidate(c2);
1004 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1005
1006 let best = mgr.best_candidate().expect("p2 should be best");
1007 assert_eq!(best.peer_id, "p2");
1008 }
1009
1010 #[test]
1011 fn test_best_candidate_returns_none_when_all_connected() {
1012 let mut mgr = PeerDiscoveryManager::new(default_config());
1013 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1014 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1015 assert!(mgr.best_candidate().is_none());
1016 }
1017
1018 #[test]
1019 fn test_best_candidate_empty_manager() {
1020 let mgr = PeerDiscoveryManager::new(default_config());
1021 assert!(mgr.best_candidate().is_none());
1022 }
1023
1024 #[test]
1029 fn test_candidates_by_method_filters_correctly() {
1030 let mut mgr = PeerDiscoveryManager::new(default_config());
1031 mgr.add_candidate(make_candidate("b1", DiscoveryMethod::Bootstrap, 0));
1032 mgr.add_candidate(make_candidate("b2", DiscoveryMethod::Bootstrap, 0));
1033 mgr.add_candidate(make_candidate("m1", DiscoveryMethod::Mdns, 0));
1034
1035 let bootstrap_peers = mgr.candidates_by_method(&DiscoveryMethod::Bootstrap);
1036 assert_eq!(bootstrap_peers.len(), 2);
1037
1038 let mdns_peers = mgr.candidates_by_method(&DiscoveryMethod::Mdns);
1039 assert_eq!(mdns_peers.len(), 1);
1040
1041 let dht_peers = mgr.candidates_by_method(&DiscoveryMethod::Dht);
1042 assert!(dht_peers.is_empty());
1043 }
1044
1045 #[test]
1046 fn test_candidates_by_method_all_variants() {
1047 let mut mgr = PeerDiscoveryManager::new(default_config());
1048 mgr.add_candidate(make_candidate("b", DiscoveryMethod::Bootstrap, 0));
1049 mgr.add_candidate(make_candidate("m", DiscoveryMethod::Mdns, 0));
1050 mgr.add_candidate(make_candidate("d", DiscoveryMethod::Dht, 0));
1051 mgr.add_candidate(make_candidate("px", DiscoveryMethod::PeerExchange, 0));
1052 mgr.add_candidate(make_candidate("man", DiscoveryMethod::Manual, 0));
1053
1054 for method in [
1055 DiscoveryMethod::Bootstrap,
1056 DiscoveryMethod::Mdns,
1057 DiscoveryMethod::Dht,
1058 DiscoveryMethod::PeerExchange,
1059 DiscoveryMethod::Manual,
1060 ] {
1061 assert_eq!(mgr.candidates_by_method(&method).len(), 1);
1062 }
1063 }
1064
1065 #[test]
1070 fn test_stats_initial_empty() {
1071 let mgr = PeerDiscoveryManager::new(default_config());
1072 let s = mgr.stats();
1073 assert_eq!(s.total_candidates, 0);
1074 assert_eq!(s.connected, 0);
1075 assert_eq!(s.attempted, 0);
1076 assert!((s.avg_score - 0.0).abs() < f64::EPSILON);
1077 assert_eq!(s.total_discovered, 0);
1078 }
1079
1080 #[test]
1081 fn test_stats_avg_score() {
1082 let mut mgr = PeerDiscoveryManager::new(default_config());
1083 let mut c1 = make_candidate("p1", DiscoveryMethod::Bootstrap, 0);
1084 c1.connect_score = 2.0;
1085 let mut c2 = make_candidate("p2", DiscoveryMethod::Bootstrap, 0);
1086 c2.connect_score = 4.0;
1087 mgr.add_candidate(c1);
1088 mgr.add_candidate(c2);
1089 let s = mgr.stats();
1090 assert!((s.avg_score - 3.0).abs() < 1e-10);
1091 }
1092
1093 #[test]
1094 fn test_stats_method_distribution() {
1095 let mut mgr = PeerDiscoveryManager::new(default_config());
1096 mgr.add_candidate(make_candidate("b1", DiscoveryMethod::Bootstrap, 0));
1097 mgr.add_candidate(make_candidate("b2", DiscoveryMethod::Bootstrap, 0));
1098 mgr.add_candidate(make_candidate("m1", DiscoveryMethod::Mdns, 0));
1099 let s = mgr.stats();
1100 assert_eq!(
1101 s.method_distribution.get("Bootstrap").copied().unwrap_or(0),
1102 2
1103 );
1104 assert_eq!(s.method_distribution.get("Mdns").copied().unwrap_or(0), 1);
1105 }
1106
1107 #[test]
1108 fn test_stats_totals_after_lifecycle() {
1109 let mut mgr = PeerDiscoveryManager::new(default_config());
1110 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1111 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1112 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1113 mgr.record_outcome("p2", ConnectOutcome::Refused, 100);
1114
1115 let s = mgr.stats();
1116 assert_eq!(s.total_discovered, 2);
1117 assert_eq!(s.total_connected, 1);
1118 assert_eq!(s.total_failed, 1);
1119 assert_eq!(s.connected, 1);
1120 assert_eq!(s.attempted, 1); }
1122
1123 #[test]
1124 fn test_stats_attempted_count() {
1125 let mut mgr = PeerDiscoveryManager::new(default_config());
1126 for i in 0..5 {
1127 mgr.add_candidate(make_candidate(
1128 &format!("p{i}"),
1129 DiscoveryMethod::Bootstrap,
1130 0,
1131 ));
1132 }
1133 mgr.record_outcome("p0", ConnectOutcome::Refused, 100);
1134 mgr.record_outcome("p1", ConnectOutcome::Timeout, 100);
1135 let s = mgr.stats();
1136 assert_eq!(s.attempted, 2);
1137 }
1138
1139 #[test]
1144 fn test_discovery_method_display() {
1145 assert_eq!(DiscoveryMethod::Bootstrap.to_string(), "Bootstrap");
1146 assert_eq!(DiscoveryMethod::Mdns.to_string(), "Mdns");
1147 assert_eq!(DiscoveryMethod::Dht.to_string(), "Dht");
1148 assert_eq!(DiscoveryMethod::PeerExchange.to_string(), "PeerExchange");
1149 assert_eq!(DiscoveryMethod::Manual.to_string(), "Manual");
1150 }
1151
1152 #[test]
1153 fn test_discovery_method_in_hashmap() {
1154 let mut map: HashMap<DiscoveryMethod, u32> = HashMap::new();
1155 map.insert(DiscoveryMethod::Bootstrap, 1);
1156 map.insert(DiscoveryMethod::Mdns, 2);
1157 assert_eq!(map[&DiscoveryMethod::Bootstrap], 1);
1158 assert_eq!(map[&DiscoveryMethod::Mdns], 2);
1159 }
1160
1161 #[test]
1166 fn test_multiple_successes_cap_score() {
1167 let mut mgr = PeerDiscoveryManager::new(default_config());
1168 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1169 for i in 0..30 {
1170 mgr.record_outcome("p1", ConnectOutcome::Success, i * 100);
1171 }
1172 let score = mgr.candidates.get("p1").expect("present").connect_score;
1173 assert!(score <= 10.0, "score must never exceed 10.0");
1174 }
1175
1176 #[test]
1177 fn test_multiple_failures_floor_score() {
1178 let mut mgr = PeerDiscoveryManager::new(default_config());
1179 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1180 for i in 0..30 {
1181 mgr.record_outcome("p1", ConnectOutcome::Unreachable, i * 100);
1182 }
1183 let score = mgr.candidates.get("p1").expect("present").connect_score;
1184 assert!(score >= 0.0, "score must never go below 0.0");
1185 }
1186
1187 #[test]
1188 fn test_reconnect_after_disconnect_increments_total_connected() {
1189 let mut mgr = PeerDiscoveryManager::new(default_config());
1190 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1191 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1192 mgr.mark_disconnected("p1");
1193 mgr.record_outcome("p1", ConnectOutcome::Success, 200);
1194 assert_eq!(mgr.total_connected, 2);
1195 }
1196
1197 #[test]
1198 fn test_peer_count_and_connected_count_consistent() {
1199 let mut mgr = PeerDiscoveryManager::new(default_config());
1200 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1201 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1202 mgr.add_candidate(make_candidate("p3", DiscoveryMethod::Bootstrap, 0));
1203 mgr.record_outcome("p1", ConnectOutcome::Success, 100);
1204 mgr.record_outcome("p2", ConnectOutcome::Success, 100);
1205
1206 assert_eq!(mgr.peer_count(), 3);
1207 assert_eq!(mgr.connected_count(), 2);
1208 }
1209
1210 #[test]
1211 fn test_evict_stale_does_not_touch_non_stale() {
1212 let cfg = DiscoveryConfig {
1213 stale_threshold_ms: 10_000,
1214 ..Default::default()
1215 };
1216 let mut mgr = PeerDiscoveryManager::new(cfg);
1217 mgr.add_candidate(make_candidate("fresh", DiscoveryMethod::Bootstrap, 15_000));
1219 mgr.add_candidate(make_candidate("stale", DiscoveryMethod::Bootstrap, 0));
1220
1221 let evicted = mgr.evict_stale(20_000);
1222 assert_eq!(evicted, 1);
1223 assert!(mgr.candidates.contains_key("fresh"));
1224 assert!(!mgr.candidates.contains_key("stale"));
1225 }
1226
1227 #[test]
1228 fn test_no_candidates_to_try_when_all_in_backoff() {
1229 let cfg = DiscoveryConfig {
1230 retry_backoff_ms: 10_000,
1231 ..Default::default()
1232 };
1233 let mut mgr = PeerDiscoveryManager::new(cfg);
1234 mgr.add_candidate(make_candidate("p1", DiscoveryMethod::Bootstrap, 0));
1235 mgr.add_candidate(make_candidate("p2", DiscoveryMethod::Bootstrap, 0));
1236 mgr.record_outcome("p1", ConnectOutcome::Refused, 5_000);
1237 mgr.record_outcome("p2", ConnectOutcome::Timeout, 5_000);
1238
1239 assert!(mgr.candidates_to_try(10, 6_000).is_empty());
1241 }
1242}