use rand::RngExt;
use crate::geo::CountryCode;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RotationStrategy {
#[default]
RoundRobin,
Random,
}
#[derive(Debug, Clone)]
struct ProxyEndpoint {
url: String,
country: Option<CountryCode>,
healthy: bool,
}
#[derive(Debug, Clone)]
pub struct ProxyPool {
endpoints: Vec<ProxyEndpoint>,
strategy: RotationStrategy,
current: Option<usize>,
}
impl ProxyPool {
pub fn new(urls: impl IntoIterator<Item = String>, strategy: RotationStrategy) -> Self {
Self::with_endpoints(urls.into_iter().map(|url| (url, None)), strategy)
}
pub fn with_endpoints(
endpoints: impl IntoIterator<Item = (String, Option<CountryCode>)>,
strategy: RotationStrategy,
) -> Self {
let mut collected: Vec<ProxyEndpoint> = Vec::new();
for (url, country) in endpoints {
let url = url.trim().to_string();
if url.is_empty() || collected.iter().any(|e| e.url == url) {
continue;
}
collected.push(ProxyEndpoint {
url,
country,
healthy: true,
});
}
let current = if collected.is_empty() {
None
} else {
match strategy {
RotationStrategy::RoundRobin => Some(0),
RotationStrategy::Random => Some(rand::rng().random_range(0..collected.len())),
}
};
Self {
endpoints: collected,
strategy,
current,
}
}
pub fn len(&self) -> usize {
self.endpoints.len()
}
pub fn is_empty(&self) -> bool {
self.endpoints.is_empty()
}
pub fn healthy_count(&self) -> usize {
self.endpoints.iter().filter(|e| e.healthy).count()
}
pub fn selected(&self) -> Option<&str> {
self.current.map(|i| self.endpoints[i].url.as_str())
}
pub fn selected_country(&self) -> Option<CountryCode> {
self.current.and_then(|i| self.endpoints[i].country)
}
pub fn rotate(&mut self) -> Option<String> {
if let Some(cur) = self.current {
self.endpoints[cur].healthy = false;
}
let next = match self.strategy {
RotationStrategy::RoundRobin => self.next_round_robin(),
RotationStrategy::Random => self.next_random(),
};
self.current = next;
next.map(|i| self.endpoints[i].url.clone())
}
fn next_round_robin(&self) -> Option<usize> {
let n = self.endpoints.len();
if n == 0 {
return None;
}
let start = self.current.unwrap_or(0);
(1..=n)
.map(|step| (start + step) % n)
.find(|&idx| self.endpoints[idx].healthy)
}
fn next_random(&self) -> Option<usize> {
let healthy: Vec<usize> = self
.endpoints
.iter()
.enumerate()
.filter(|(_, e)| e.healthy)
.map(|(i, _)| i)
.collect();
if healthy.is_empty() {
return None;
}
Some(healthy[rand::rng().random_range(0..healthy.len())])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pool(urls: &[&str], strategy: RotationStrategy) -> ProxyPool {
ProxyPool::new(urls.iter().map(|s| s.to_string()), strategy)
}
#[test]
fn new_empty_pool_has_no_selection() {
let p = ProxyPool::new(Vec::<String>::new(), RotationStrategy::RoundRobin);
assert!(p.is_empty());
assert_eq!(p.selected(), None);
assert_eq!(p.healthy_count(), 0);
}
#[test]
fn new_dedups_and_trims() {
let p = pool(
&["http://a", " http://a ", "http://b", ""],
RotationStrategy::RoundRobin,
);
assert_eq!(p.len(), 2);
}
#[test]
fn round_robin_selects_first_then_advances() {
let mut p = pool(
&["http://a", "http://b", "http://c"],
RotationStrategy::RoundRobin,
);
assert_eq!(p.selected(), Some("http://a"));
assert_eq!(p.rotate().as_deref(), Some("http://b"));
assert_eq!(p.rotate().as_deref(), Some("http://c"));
}
#[test]
fn rotate_retires_current_and_skips_it_when_wrapping() {
let mut p = pool(&["http://a", "http://b"], RotationStrategy::RoundRobin);
assert_eq!(p.rotate().as_deref(), Some("http://b"));
assert_eq!(p.rotate(), None);
assert_eq!(p.healthy_count(), 0);
assert_eq!(p.selected(), None);
}
#[test]
fn rotate_on_empty_pool_is_none() {
let mut p = ProxyPool::new(Vec::<String>::new(), RotationStrategy::RoundRobin);
assert_eq!(p.rotate(), None);
}
#[test]
fn healthy_count_decreases_on_rotate() {
let mut p = pool(
&["http://a", "http://b", "http://c"],
RotationStrategy::RoundRobin,
);
assert_eq!(p.healthy_count(), 3);
p.rotate();
assert_eq!(p.healthy_count(), 2);
}
#[test]
fn endpoints_carry_country_tags_through_rotation() {
let de = CountryCode::new("DE");
let fr = CountryCode::new("FR");
let mut p = ProxyPool::with_endpoints(
[("http://a".to_string(), de), ("http://b".to_string(), fr)],
RotationStrategy::RoundRobin,
);
assert_eq!(p.selected(), Some("http://a"));
assert_eq!(p.selected_country(), de);
assert_eq!(p.rotate().as_deref(), Some("http://b"));
assert_eq!(p.selected_country(), fr);
}
#[test]
fn untagged_pool_has_no_country() {
let p = pool(&["http://a"], RotationStrategy::RoundRobin);
assert_eq!(p.selected_country(), None);
}
#[test]
fn random_strategy_selects_a_member_and_exhausts() {
let urls = ["http://a", "http://b", "http://c"];
let mut p = pool(&urls, RotationStrategy::Random);
assert!(urls.contains(&p.selected().unwrap()));
let mut seen = 0;
while let Some(url) = p.rotate() {
assert!(urls.contains(&url.as_str()));
seen += 1;
}
assert_eq!(seen, 2); assert_eq!(p.healthy_count(), 0);
}
}