use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DiscoverySource {
Dht,
Mdns,
Bootstrap,
Relay,
Manual,
}
#[derive(Clone, Debug)]
pub struct PeerRecord {
pub peer_id: String,
pub addresses: Vec<String>,
pub source: DiscoverySource,
pub discovered_at_secs: u64,
pub last_seen_secs: u64,
pub successful_connections: u32,
pub failed_connections: u32,
}
impl PeerRecord {
pub fn reliability_score(&self) -> f64 {
let total = self.successful_connections + self.failed_connections;
if total == 0 {
0.5
} else {
self.successful_connections as f64 / total as f64
}
}
}
#[derive(Clone, Debug)]
pub struct DiscoveryCacheStats {
pub total_peers: usize,
pub by_source: HashMap<DiscoverySource, usize>,
pub avg_reliability: f64,
}
pub struct PeerDiscoveryCache {
pub records: HashMap<String, PeerRecord>,
pub max_size: usize,
pub ttl_secs: u64,
}
impl PeerDiscoveryCache {
pub fn new(max_size: usize, ttl_secs: u64) -> Self {
Self {
records: HashMap::new(),
max_size,
ttl_secs,
}
}
pub fn upsert(&mut self, record: PeerRecord) {
if let Some(existing) = self.records.get_mut(&record.peer_id) {
for addr in &record.addresses {
if !existing.addresses.contains(addr) {
existing.addresses.push(addr.clone());
}
}
if record.last_seen_secs > existing.last_seen_secs {
existing.last_seen_secs = record.last_seen_secs;
}
existing.source = record.source;
return;
}
if self.records.len() >= self.max_size {
self.evict_lowest_reliability();
}
self.records.insert(record.peer_id.clone(), record);
}
fn evict_lowest_reliability(&mut self) {
let victim = self
.records
.iter()
.min_by(|a, b| {
let score_a = a.1.reliability_score();
let score_b = b.1.reliability_score();
score_a
.partial_cmp(&score_b)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.1.last_seen_secs.cmp(&b.1.last_seen_secs))
})
.map(|(k, _)| k.clone());
if let Some(key) = victim {
self.records.remove(&key);
}
}
pub fn record_success(&mut self, peer_id: &str, now_secs: u64) -> bool {
match self.records.get_mut(peer_id) {
Some(record) => {
record.successful_connections += 1;
record.last_seen_secs = now_secs;
true
}
None => false,
}
}
pub fn record_failure(&mut self, peer_id: &str) -> bool {
match self.records.get_mut(peer_id) {
Some(record) => {
record.failed_connections += 1;
true
}
None => false,
}
}
pub fn evict_stale(&mut self, now_secs: u64) {
let ttl = self.ttl_secs;
self.records
.retain(|_, record| record.last_seen_secs + ttl >= now_secs);
}
pub fn get(&self, peer_id: &str) -> Option<&PeerRecord> {
self.records.get(peer_id)
}
pub fn peers_by_reliability(&self) -> Vec<&PeerRecord> {
let mut peers: Vec<&PeerRecord> = self.records.values().collect();
peers.sort_by(|a, b| {
b.reliability_score()
.partial_cmp(&a.reliability_score())
.unwrap_or(std::cmp::Ordering::Equal)
});
peers
}
pub fn peers_from_source(&self, source: DiscoverySource) -> Vec<&PeerRecord> {
let mut peers: Vec<&PeerRecord> = self
.records
.values()
.filter(|r| r.source == source)
.collect();
peers.sort_by_key(|b| std::cmp::Reverse(b.last_seen_secs));
peers
}
pub fn remove(&mut self, peer_id: &str) -> bool {
self.records.remove(peer_id).is_some()
}
pub fn stats(&self) -> DiscoveryCacheStats {
let total_peers = self.records.len();
let mut by_source: HashMap<DiscoverySource, usize> = HashMap::new();
let mut reliability_sum = 0.0_f64;
for record in self.records.values() {
*by_source.entry(record.source).or_insert(0) += 1;
reliability_sum += record.reliability_score();
}
let avg_reliability = if total_peers == 0 {
0.0
} else {
reliability_sum / total_peers as f64
};
DiscoveryCacheStats {
total_peers,
by_source,
avg_reliability,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_record(
peer_id: &str,
source: DiscoverySource,
last_seen_secs: u64,
successful: u32,
failed: u32,
) -> PeerRecord {
PeerRecord {
peer_id: peer_id.to_string(),
addresses: vec![format!("/ip4/127.0.0.1/tcp/{}", peer_id.len())],
source,
discovered_at_secs: 1000,
last_seen_secs,
successful_connections: successful,
failed_connections: failed,
}
}
#[test]
fn test_new_starts_empty() {
let cache = PeerDiscoveryCache::new(10, 300);
assert_eq!(cache.records.len(), 0);
assert_eq!(cache.max_size, 10);
assert_eq!(cache.ttl_secs, 300);
}
#[test]
fn test_upsert_adds_new_record() {
let mut cache = PeerDiscoveryCache::new(10, 300);
let rec = make_record("peer1", DiscoverySource::Dht, 2000, 0, 0);
cache.upsert(rec);
assert!(cache.records.contains_key("peer1"));
}
#[test]
fn test_upsert_updates_existing_merge_addresses() {
let mut cache = PeerDiscoveryCache::new(10, 300);
let rec1 = PeerRecord {
peer_id: "peer1".to_string(),
addresses: vec!["/ip4/1.2.3.4/tcp/4001".to_string()],
source: DiscoverySource::Dht,
discovered_at_secs: 1000,
last_seen_secs: 2000,
successful_connections: 0,
failed_connections: 0,
};
cache.upsert(rec1);
let rec2 = PeerRecord {
peer_id: "peer1".to_string(),
addresses: vec![
"/ip4/1.2.3.4/tcp/4001".to_string(),
"/ip4/5.6.7.8/tcp/4002".to_string(),
],
source: DiscoverySource::Mdns,
discovered_at_secs: 1500,
last_seen_secs: 3000,
successful_connections: 0,
failed_connections: 0,
};
cache.upsert(rec2);
let record = cache
.records
.get("peer1")
.expect("test: peer1 should exist in cache after upsert");
assert_eq!(record.addresses.len(), 2);
assert_eq!(record.last_seen_secs, 3000);
assert_eq!(record.source, DiscoverySource::Mdns);
}
#[test]
fn test_upsert_at_capacity_evicts_lowest_reliability() {
let mut cache = PeerDiscoveryCache::new(2, 300);
cache.upsert(make_record("peer_a", DiscoverySource::Dht, 2000, 1, 0));
cache.upsert(make_record("peer_b", DiscoverySource::Dht, 2000, 0, 1));
cache.upsert(make_record("peer_c", DiscoverySource::Dht, 2000, 1, 1));
assert!(cache.records.contains_key("peer_a"));
assert!(!cache.records.contains_key("peer_b"));
assert!(cache.records.contains_key("peer_c"));
}
#[test]
fn test_upsert_at_capacity_tie_broken_by_oldest_last_seen() {
let mut cache = PeerDiscoveryCache::new(2, 300);
cache.upsert(make_record("peer_a", DiscoverySource::Dht, 1000, 0, 0));
cache.upsert(make_record("peer_b", DiscoverySource::Dht, 2000, 0, 0));
cache.upsert(make_record("peer_c", DiscoverySource::Dht, 3000, 0, 0));
assert!(
!cache.records.contains_key("peer_a"),
"peer_a should have been evicted"
);
assert!(cache.records.contains_key("peer_b"));
assert!(cache.records.contains_key("peer_c"));
}
#[test]
fn test_record_success_increments_counter() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
assert!(cache.record_success("peer1", 3000));
assert_eq!(cache.records["peer1"].successful_connections, 1);
}
#[test]
fn test_record_success_updates_last_seen() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
cache.record_success("peer1", 5000);
assert_eq!(cache.records["peer1"].last_seen_secs, 5000);
}
#[test]
fn test_record_success_false_if_unknown() {
let mut cache = PeerDiscoveryCache::new(10, 300);
assert!(!cache.record_success("unknown_peer", 5000));
}
#[test]
fn test_record_failure_increments_counter() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
assert!(cache.record_failure("peer1"));
assert_eq!(cache.records["peer1"].failed_connections, 1);
}
#[test]
fn test_record_failure_false_if_unknown() {
let mut cache = PeerDiscoveryCache::new(10, 300);
assert!(!cache.record_failure("unknown_peer"));
}
#[test]
fn test_reliability_score_no_connections() {
let rec = make_record("peer1", DiscoverySource::Dht, 2000, 0, 0);
assert!((rec.reliability_score() - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_reliability_score_computed_correctly() {
let rec = make_record("peer1", DiscoverySource::Dht, 2000, 3, 1);
assert!((rec.reliability_score() - 0.75).abs() < f64::EPSILON);
let rec2 = make_record("peer2", DiscoverySource::Dht, 2000, 0, 4);
assert!(rec2.reliability_score().abs() < f64::EPSILON);
let rec3 = make_record("peer3", DiscoverySource::Dht, 2000, 5, 0);
assert!((rec3.reliability_score() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn test_evict_stale_removes_old() {
let mut cache = PeerDiscoveryCache::new(10, 100);
cache.upsert(make_record("stale_peer", DiscoverySource::Dht, 500, 0, 0));
cache.evict_stale(700);
assert!(!cache.records.contains_key("stale_peer"));
}
#[test]
fn test_evict_stale_keeps_fresh() {
let mut cache = PeerDiscoveryCache::new(10, 100);
cache.upsert(make_record("fresh_peer", DiscoverySource::Dht, 650, 0, 0));
cache.evict_stale(700);
assert!(cache.records.contains_key("fresh_peer"));
}
#[test]
fn test_peers_by_reliability_sorted_desc() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("low", DiscoverySource::Dht, 2000, 1, 9)); cache.upsert(make_record("mid", DiscoverySource::Dht, 2000, 5, 5)); cache.upsert(make_record("high", DiscoverySource::Dht, 2000, 9, 1));
let sorted = cache.peers_by_reliability();
assert_eq!(sorted.len(), 3);
assert!(sorted[0].reliability_score() >= sorted[1].reliability_score());
assert!(sorted[1].reliability_score() >= sorted[2].reliability_score());
assert_eq!(sorted[0].peer_id, "high");
assert_eq!(sorted[2].peer_id, "low");
}
#[test]
fn test_peers_from_source_filters_correctly() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("dht1", DiscoverySource::Dht, 2000, 0, 0));
cache.upsert(make_record("dht2", DiscoverySource::Dht, 2001, 0, 0));
cache.upsert(make_record("mdns1", DiscoverySource::Mdns, 2002, 0, 0));
let dht_peers = cache.peers_from_source(DiscoverySource::Dht);
assert_eq!(dht_peers.len(), 2);
for p in &dht_peers {
assert_eq!(p.source, DiscoverySource::Dht);
}
let mdns_peers = cache.peers_from_source(DiscoverySource::Mdns);
assert_eq!(mdns_peers.len(), 1);
assert_eq!(mdns_peers[0].peer_id, "mdns1");
}
#[test]
fn test_peers_from_source_sorted_by_last_seen_desc() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("a", DiscoverySource::Bootstrap, 1000, 0, 0));
cache.upsert(make_record("b", DiscoverySource::Bootstrap, 3000, 0, 0));
cache.upsert(make_record("c", DiscoverySource::Bootstrap, 2000, 0, 0));
let peers = cache.peers_from_source(DiscoverySource::Bootstrap);
assert_eq!(peers.len(), 3);
assert_eq!(peers[0].last_seen_secs, 3000);
assert_eq!(peers[1].last_seen_secs, 2000);
assert_eq!(peers[2].last_seen_secs, 1000);
}
#[test]
fn test_remove_returns_true_when_present() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Dht, 2000, 0, 0));
assert!(cache.remove("peer1"));
assert!(!cache.records.contains_key("peer1"));
}
#[test]
fn test_remove_returns_false_when_absent() {
let mut cache = PeerDiscoveryCache::new(10, 300);
assert!(!cache.remove("nonexistent"));
}
#[test]
fn test_get_returns_some() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Relay, 2000, 2, 1));
let rec = cache.get("peer1");
assert!(rec.is_some());
assert_eq!(
rec.expect("test: peer1 record should be found by get()")
.peer_id,
"peer1"
);
}
#[test]
fn test_get_returns_none() {
let cache = PeerDiscoveryCache::new(10, 300);
assert!(cache.get("nobody").is_none());
}
#[test]
fn test_stats_total_peers() {
let mut cache = PeerDiscoveryCache::new(10, 300);
assert_eq!(cache.stats().total_peers, 0);
cache.upsert(make_record("p1", DiscoverySource::Dht, 2000, 0, 0));
cache.upsert(make_record("p2", DiscoverySource::Dht, 2000, 0, 0));
assert_eq!(cache.stats().total_peers, 2);
}
#[test]
fn test_stats_by_source_counts() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("d1", DiscoverySource::Dht, 2000, 0, 0));
cache.upsert(make_record("d2", DiscoverySource::Dht, 2000, 0, 0));
cache.upsert(make_record("m1", DiscoverySource::Mdns, 2000, 0, 0));
cache.upsert(make_record("b1", DiscoverySource::Bootstrap, 2000, 0, 0));
let stats = cache.stats();
assert_eq!(*stats.by_source.get(&DiscoverySource::Dht).unwrap_or(&0), 2);
assert_eq!(
*stats.by_source.get(&DiscoverySource::Mdns).unwrap_or(&0),
1
);
assert_eq!(
*stats
.by_source
.get(&DiscoverySource::Bootstrap)
.unwrap_or(&0),
1
);
assert_eq!(
*stats.by_source.get(&DiscoverySource::Relay).unwrap_or(&0),
0
);
}
#[test]
fn test_stats_avg_reliability() {
let mut cache = PeerDiscoveryCache::new(10, 300);
assert!((cache.stats().avg_reliability - 0.0).abs() < f64::EPSILON);
cache.upsert(make_record("good", DiscoverySource::Dht, 2000, 4, 0));
cache.upsert(make_record("bad", DiscoverySource::Dht, 2000, 0, 4));
let avg = cache.stats().avg_reliability;
assert!((avg - 0.5).abs() < f64::EPSILON);
}
#[test]
fn test_upsert_does_not_update_last_seen_if_older() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("peer1", DiscoverySource::Dht, 5000, 0, 0));
let older = PeerRecord {
peer_id: "peer1".to_string(),
addresses: vec![],
source: DiscoverySource::Mdns,
discovered_at_secs: 1000,
last_seen_secs: 3000,
successful_connections: 0,
failed_connections: 0,
};
cache.upsert(older);
assert_eq!(cache.records["peer1"].last_seen_secs, 5000);
}
#[test]
fn test_manual_source() {
let mut cache = PeerDiscoveryCache::new(10, 300);
cache.upsert(make_record("manual1", DiscoverySource::Manual, 9000, 10, 0));
let peers = cache.peers_from_source(DiscoverySource::Manual);
assert_eq!(peers.len(), 1);
assert!((peers[0].reliability_score() - 1.0).abs() < f64::EPSILON);
}
}