1use dashmap::DashMap;
2
3use std::sync::atomic::{AtomicU32, Ordering};
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::error::ScatterProxyError;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ProxyState {
12 Unknown,
13 Active,
14 Dead,
17 Retired,
20}
21
22struct ProxyInfo {
24 state: ProxyState,
25}
26
27pub struct ProxyManager {
30 proxies: DashMap<String, ProxyInfo>,
31 clients: DashMap<String, reqwest::Client>,
32 proxy_timeout: Duration,
33 cookies: DashMap<String, String>,
35 request_counts: DashMap<String, Arc<AtomicU32>>,
37 challenge_locks: DashMap<String, Arc<tokio::sync::Semaphore>>,
39}
40
41impl ProxyManager {
42 pub fn new(proxy_timeout: Duration) -> Self {
44 let proxies = DashMap::new();
45 Self {
46 proxies,
47 clients: DashMap::new(),
48 proxy_timeout,
49 cookies: DashMap::new(),
50 request_counts: DashMap::new(),
51 challenge_locks: DashMap::new(),
52 }
53 }
54
55 pub fn get_cookie(&self, proxy_url: &str) -> Option<String> {
59 self.cookies.get(proxy_url).map(|v| v.clone())
60 }
61
62 pub fn set_cookie(&self, proxy_url: &str, value: String) {
64 self.cookies.insert(proxy_url.to_string(), value);
65 }
66
67 pub fn increment_request_count(&self, proxy_url: &str) -> u32 {
71 self.request_counts
72 .entry(proxy_url.to_string())
73 .or_insert_with(|| Arc::new(AtomicU32::new(0)))
74 .fetch_add(1, Ordering::Relaxed)
75 + 1
76 }
77
78 pub fn request_count(&self, proxy_url: &str) -> u32 {
80 self.request_counts
81 .get(proxy_url)
82 .map(|c| c.load(Ordering::Relaxed))
83 .unwrap_or(0)
84 }
85
86 pub fn reset_request_count(&self, proxy_url: &str) {
89 if let Some(counter) = self.request_counts.get(proxy_url) {
90 counter.store(0, Ordering::Relaxed);
91 }
92 if matches!(self.get_state(proxy_url), ProxyState::Retired) {
93 self.set_state(proxy_url, ProxyState::Active);
94 }
95 }
96
97 pub fn challenge_lock(&self, proxy_url: &str) -> Arc<tokio::sync::Semaphore> {
103 self.challenge_locks
104 .entry(proxy_url.to_string())
105 .or_insert_with(|| Arc::new(tokio::sync::Semaphore::new(1)))
106 .clone()
107 }
108
109 pub async fn fetch_and_add(
116 &self,
117 sources: &[String],
118 prefer_remote_dns: bool,
119 ) -> Result<usize, ScatterProxyError> {
120 let http_client = reqwest::Client::builder()
121 .timeout(Duration::from_secs(30))
122 .build()
123 .map_err(|e| ScatterProxyError::Init(format!("failed to build HTTP client: {e}")))?;
124
125 let mut added = 0usize;
126
127 for source in sources {
128 match http_client.get(source).send().await {
129 Ok(resp) => match resp.text().await {
130 Ok(body) => {
131 for line in body.lines() {
132 let normalized = Self::normalize_proxy_url(line, prefer_remote_dns);
133 if normalized.is_empty() {
134 continue;
135 }
136 if !self.proxies.contains_key(&normalized) {
137 self.proxies.insert(
138 normalized,
139 ProxyInfo {
140 state: ProxyState::Unknown,
141 },
142 );
143 added += 1;
144 }
145 }
146 }
147 Err(e) => {
148 tracing::error!("failed to read body from source {source}: {e}");
149 }
150 },
151 Err(e) => {
152 tracing::error!("failed to fetch proxy source {source}: {e}");
153 }
154 }
155 }
156
157 Ok(added)
158 }
159
160 pub fn all_proxy_urls(&self) -> Vec<String> {
162 self.proxies.iter().map(|r| r.key().clone()).collect()
163 }
164
165 pub fn get_active_proxies(&self) -> Vec<String> {
167 self.proxies
168 .iter()
169 .filter(|r| !matches!(r.value().state, ProxyState::Dead | ProxyState::Retired))
170 .map(|r| r.key().clone())
171 .collect()
172 }
173
174 pub fn set_state(&self, proxy: &str, state: ProxyState) {
176 if let Some(mut entry) = self.proxies.get_mut(proxy) {
177 entry.state = state;
178 }
179 }
180
181 pub fn get_state(&self, proxy: &str) -> ProxyState {
183 self.proxies
184 .get(proxy)
185 .map(|r| r.value().state)
186 .unwrap_or(ProxyState::Unknown)
187 }
188
189 pub fn proxy_counts(&self) -> (usize, usize, usize, usize, usize) {
194 let mut total = 0usize;
195 let mut active_or_unknown = 0usize;
196 let mut dead = 0usize;
197 let mut retired = 0usize;
198
199 for entry in self.proxies.iter() {
200 total += 1;
201 match entry.value().state {
202 ProxyState::Dead => dead += 1,
203 ProxyState::Retired => retired += 1,
204 _ => active_or_unknown += 1,
205 }
206 }
207
208 (total, active_or_unknown, 0, dead, retired)
209 }
210
211 pub fn get_client(&self, proxy_url: &str) -> Result<reqwest::Client, ScatterProxyError> {
214 if let Some(client) = self.clients.get(proxy_url) {
215 return Ok(client.value().clone());
216 }
217
218 let proxy = reqwest::Proxy::all(proxy_url).map_err(|e| {
219 ScatterProxyError::Init(format!("invalid proxy URL '{proxy_url}': {e}"))
220 })?;
221
222 let client = reqwest::Client::builder()
223 .proxy(proxy)
224 .timeout(self.proxy_timeout)
225 .build()
226 .map_err(|e| {
227 ScatterProxyError::Init(format!(
228 "failed to build client for proxy '{proxy_url}': {e}"
229 ))
230 })?;
231
232 self.clients.insert(proxy_url.to_string(), client.clone());
233 Ok(client)
234 }
235
236 pub fn normalize_proxy_url(raw: &str, prefer_remote_dns: bool) -> String {
246 let trimmed = raw.trim();
247
248 if trimmed.is_empty() || trimmed.starts_with('#') {
249 return String::new();
250 }
251
252 if trimmed.starts_with("socks5h://") {
253 return trimmed.to_string();
254 }
255
256 if let Some(rest) = trimmed.strip_prefix("socks5://") {
257 if prefer_remote_dns {
258 return format!("socks5h://{rest}");
259 }
260 return trimmed.to_string();
261 }
262
263 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
265 return trimmed.to_string();
266 }
267
268 if prefer_remote_dns {
270 format!("socks5h://{trimmed}")
271 } else {
272 format!("socks5://{trimmed}")
273 }
274 }
275
276 #[allow(dead_code)]
278 pub fn total_count(&self) -> usize {
279 self.proxies.len()
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 #[test]
292 fn normalize_bare_ip_port_prefer_remote_dns() {
293 let result = ProxyManager::normalize_proxy_url("1.2.3.4:1080", true);
294 assert_eq!(result, "socks5h://1.2.3.4:1080");
295 }
296
297 #[test]
298 fn normalize_bare_ip_port_local_dns() {
299 let result = ProxyManager::normalize_proxy_url("1.2.3.4:1080", false);
300 assert_eq!(result, "socks5://1.2.3.4:1080");
301 }
302
303 #[test]
304 fn normalize_socks5_to_socks5h_when_prefer_remote() {
305 let result = ProxyManager::normalize_proxy_url("socks5://1.2.3.4:1080", true);
306 assert_eq!(result, "socks5h://1.2.3.4:1080");
307 }
308
309 #[test]
310 fn normalize_socks5_stays_when_not_prefer_remote() {
311 let result = ProxyManager::normalize_proxy_url("socks5://1.2.3.4:1080", false);
312 assert_eq!(result, "socks5://1.2.3.4:1080");
313 }
314
315 #[test]
316 fn normalize_socks5h_unchanged() {
317 let result = ProxyManager::normalize_proxy_url("socks5h://1.2.3.4:1080", true);
318 assert_eq!(result, "socks5h://1.2.3.4:1080");
319 }
320
321 #[test]
322 fn normalize_socks5h_unchanged_no_prefer() {
323 let result = ProxyManager::normalize_proxy_url("socks5h://1.2.3.4:1080", false);
324 assert_eq!(result, "socks5h://1.2.3.4:1080");
325 }
326
327 #[test]
328 fn normalize_empty_string() {
329 let result = ProxyManager::normalize_proxy_url("", true);
330 assert!(result.is_empty());
331 }
332
333 #[test]
334 fn normalize_whitespace_only() {
335 let result = ProxyManager::normalize_proxy_url(" ", true);
336 assert!(result.is_empty());
337 }
338
339 #[test]
340 fn normalize_comment_line() {
341 let result = ProxyManager::normalize_proxy_url("# this is a comment", true);
342 assert!(result.is_empty());
343 }
344
345 #[test]
346 fn normalize_trims_whitespace() {
347 let result = ProxyManager::normalize_proxy_url(" 1.2.3.4:1080 ", true);
348 assert_eq!(result, "socks5h://1.2.3.4:1080");
349 }
350
351 #[test]
352 fn normalize_http_proxy_passed_through() {
353 let result = ProxyManager::normalize_proxy_url("http://proxy.example.com:8080", true);
354 assert_eq!(result, "http://proxy.example.com:8080");
355 }
356
357 #[test]
358 fn normalize_https_proxy_passed_through() {
359 let result = ProxyManager::normalize_proxy_url("https://proxy.example.com:8080", false);
360 assert_eq!(result, "https://proxy.example.com:8080");
361 }
362
363 #[test]
364 fn normalize_socks5_with_auth() {
365 let result = ProxyManager::normalize_proxy_url("socks5://user:pass@1.2.3.4:1080", true);
366 assert_eq!(result, "socks5h://user:pass@1.2.3.4:1080");
367 }
368
369 #[test]
378 fn get_state_returns_unknown_for_missing_proxy() {
379 let mgr = ProxyManager::new(Duration::from_secs(5));
380 assert_eq!(mgr.get_state("socks5h://9.9.9.9:1080"), ProxyState::Unknown);
381 }
382
383 #[test]
384 fn set_and_get_state() {
385 let pm = ProxyManager::new(Duration::from_secs(8));
386 pm.proxies.insert(
387 "socks5h://1.2.3.4:1080".to_string(),
388 ProxyInfo {
389 state: ProxyState::Unknown,
390 },
391 );
392
393 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Unknown);
394
395 pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Active);
396 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Active);
397
398 pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Dead);
399 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Dead);
400 }
401
402 #[test]
403 fn set_state_on_missing_proxy_is_noop() {
404 let pm = ProxyManager::new(Duration::from_secs(8));
405 pm.set_state("socks5h://nonexistent:1080", ProxyState::Dead);
406 assert_eq!(pm.total_count(), 0);
407 }
408
409 #[test]
414 fn get_active_proxies_excludes_dead() {
415 let mgr = ProxyManager::new(Duration::from_secs(5));
416
417 mgr.proxies.insert(
419 "socks5h://1.1.1.1:1080".into(),
420 ProxyInfo {
421 state: ProxyState::Active,
422 },
423 );
424 mgr.proxies.insert(
425 "socks5h://2.2.2.2:1080".into(),
426 ProxyInfo {
427 state: ProxyState::Dead,
428 },
429 );
430 mgr.proxies.insert(
431 "socks5h://3.3.3.3:1080".into(),
432 ProxyInfo {
433 state: ProxyState::Unknown,
434 },
435 );
436
437 let active = mgr.get_active_proxies();
438 assert_eq!(active.len(), 2);
439 assert!(active.contains(&"socks5h://1.1.1.1:1080".to_string()));
440 assert!(!active.contains(&"socks5h://2.2.2.2:1080".to_string()));
441 assert!(active.contains(&"socks5h://3.3.3.3:1080".to_string()));
442 }
443
444 #[test]
449 fn proxy_counts_correct() {
450 let pm = ProxyManager::new(Duration::from_secs(8));
451
452 pm.proxies.insert(
453 "a".to_string(),
454 ProxyInfo {
455 state: ProxyState::Active,
456 },
457 );
458 pm.proxies.insert(
459 "b".to_string(),
460 ProxyInfo {
461 state: ProxyState::Unknown,
462 },
463 );
464 pm.proxies.insert(
465 "c".to_string(),
466 ProxyInfo {
467 state: ProxyState::Dead,
468 },
469 );
470 pm.proxies.insert(
471 "d".to_string(),
472 ProxyInfo {
473 state: ProxyState::Dead,
474 },
475 );
476
477 let (total, active_or_unknown, cooldown, dead, retired) = pm.proxy_counts();
478 assert_eq!(total, 4);
479 assert_eq!(active_or_unknown, 2);
480 assert_eq!(cooldown, 0);
481 assert_eq!(dead, 2);
482 assert_eq!(retired, 0);
483 }
484
485 #[test]
486 fn proxy_counts_empty() {
487 let pm = ProxyManager::new(Duration::from_secs(8));
488 let (total, active, cooldown, dead, retired) = pm.proxy_counts();
489 assert_eq!(total, 0);
490 assert_eq!(active, 0);
491 assert_eq!(cooldown, 0);
492 assert_eq!(dead, 0);
493 assert_eq!(retired, 0);
494 }
495
496 #[test]
501 fn get_client_creates_and_caches() {
502 let pm = ProxyManager::new(Duration::from_secs(8));
503 let client1 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
504 let client2 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
505
506 assert_eq!(pm.clients.len(), 1);
509
510 drop(client1);
512 drop(client2);
513 }
514
515 #[test]
516 fn get_client_different_proxies_get_different_clients() {
517 let pm = ProxyManager::new(Duration::from_secs(8));
518 let _c1 = pm.get_client("socks5h://127.0.0.1:1080").unwrap();
519 let _c2 = pm.get_client("socks5h://127.0.0.1:1081").unwrap();
520 assert_eq!(pm.clients.len(), 2);
521 }
522
523 #[test]
524 fn get_client_invalid_url_returns_error() {
525 let pm = ProxyManager::new(Duration::from_secs(8));
526 let result = pm.get_client("not a valid url at all");
527 if let Err(e) = result {
530 match e {
531 ScatterProxyError::Init(msg) => {
532 assert!(msg.contains("proxy") || msg.contains("invalid"));
533 }
534 other => panic!("expected Init error, got: {other:?}"),
535 }
536 }
537 }
538
539 #[test]
544 fn all_proxy_urls_returns_all() {
545 let pm = ProxyManager::new(Duration::from_secs(8));
546 pm.proxies.insert(
547 "a".to_string(),
548 ProxyInfo {
549 state: ProxyState::Active,
550 },
551 );
552 pm.proxies.insert(
553 "b".to_string(),
554 ProxyInfo {
555 state: ProxyState::Dead,
556 },
557 );
558
559 let urls = pm.all_proxy_urls();
560 assert_eq!(urls.len(), 2);
561 assert!(urls.contains(&"a".to_string()));
562 assert!(urls.contains(&"b".to_string()));
563 }
564
565 #[test]
570 fn total_count_tracks_insertions() {
571 let mgr = ProxyManager::new(Duration::from_secs(5));
572 assert_eq!(mgr.total_count(), 0);
573
574 mgr.proxies.insert(
575 "socks5h://1.1.1.1:1080".into(),
576 ProxyInfo {
577 state: ProxyState::Active,
578 },
579 );
580 assert_eq!(mgr.total_count(), 1);
581
582 mgr.proxies.insert(
583 "socks5h://2.2.2.2:1080".into(),
584 ProxyInfo {
585 state: ProxyState::Dead,
586 },
587 );
588 assert_eq!(mgr.total_count(), 2);
589 }
590
591 #[test]
596 fn proxy_state_debug() {
597 assert_eq!(format!("{:?}", ProxyState::Unknown), "Unknown");
598 assert_eq!(format!("{:?}", ProxyState::Active), "Active");
599 assert_eq!(format!("{:?}", ProxyState::Dead), "Dead");
600 assert_eq!(format!("{:?}", ProxyState::Retired), "Retired");
601 }
602
603 #[test]
604 fn proxy_state_clone_and_eq() {
605 let s = ProxyState::Active;
606 let s2 = s;
607 assert_eq!(s, s2);
608 }
609
610 #[test]
615 fn set_state_to_retired() {
616 let pm = ProxyManager::new(Duration::from_secs(8));
617 pm.proxies.insert(
618 "socks5h://1.2.3.4:1080".to_string(),
619 ProxyInfo {
620 state: ProxyState::Active,
621 },
622 );
623 pm.set_state("socks5h://1.2.3.4:1080", ProxyState::Retired);
624 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Retired);
625 }
626
627 #[test]
628 fn get_active_proxies_excludes_retired() {
629 let pm = ProxyManager::new(Duration::from_secs(8));
630 pm.proxies.insert(
631 "a".to_string(),
632 ProxyInfo {
633 state: ProxyState::Active,
634 },
635 );
636 pm.proxies.insert(
637 "b".to_string(),
638 ProxyInfo {
639 state: ProxyState::Retired,
640 },
641 );
642 pm.proxies.insert(
643 "c".to_string(),
644 ProxyInfo {
645 state: ProxyState::Unknown,
646 },
647 );
648 let active = pm.get_active_proxies();
649 assert_eq!(active.len(), 2);
650 assert!(active.contains(&"a".to_string()));
651 assert!(!active.contains(&"b".to_string()));
652 assert!(active.contains(&"c".to_string()));
653 }
654
655 #[test]
656 fn proxy_counts_includes_retired() {
657 let pm = ProxyManager::new(Duration::from_secs(8));
658 pm.proxies.insert(
659 "a".to_string(),
660 ProxyInfo {
661 state: ProxyState::Active,
662 },
663 );
664 pm.proxies.insert(
665 "b".to_string(),
666 ProxyInfo {
667 state: ProxyState::Retired,
668 },
669 );
670 pm.proxies.insert(
671 "c".to_string(),
672 ProxyInfo {
673 state: ProxyState::Dead,
674 },
675 );
676
677 let (total, active, cooldown, dead, retired) = pm.proxy_counts();
678 assert_eq!(total, 3);
679 assert_eq!(active, 1);
680 assert_eq!(cooldown, 0);
681 assert_eq!(dead, 1);
682 assert_eq!(retired, 1);
683 }
684
685 #[test]
690 fn get_cookie_returns_none_when_not_set() {
691 let pm = ProxyManager::new(Duration::from_secs(8));
692 assert!(pm.get_cookie("socks5h://1.2.3.4:1080").is_none());
693 }
694
695 #[test]
696 fn set_and_get_cookie() {
697 let pm = ProxyManager::new(Duration::from_secs(8));
698 pm.set_cookie("socks5h://1.2.3.4:1080", "abc123".to_string());
699 assert_eq!(
700 pm.get_cookie("socks5h://1.2.3.4:1080"),
701 Some("abc123".to_string())
702 );
703 }
704
705 #[test]
706 fn set_cookie_overwrites_existing() {
707 let pm = ProxyManager::new(Duration::from_secs(8));
708 pm.set_cookie("socks5h://1.2.3.4:1080", "first".to_string());
709 pm.set_cookie("socks5h://1.2.3.4:1080", "second".to_string());
710 assert_eq!(
711 pm.get_cookie("socks5h://1.2.3.4:1080"),
712 Some("second".to_string())
713 );
714 }
715
716 #[test]
717 fn cookies_are_per_proxy() {
718 let pm = ProxyManager::new(Duration::from_secs(8));
719 pm.set_cookie("socks5h://1.1.1.1:1080", "cookie_a".to_string());
720 pm.set_cookie("socks5h://2.2.2.2:1080", "cookie_b".to_string());
721 assert_eq!(
722 pm.get_cookie("socks5h://1.1.1.1:1080"),
723 Some("cookie_a".to_string())
724 );
725 assert_eq!(
726 pm.get_cookie("socks5h://2.2.2.2:1080"),
727 Some("cookie_b".to_string())
728 );
729 }
730
731 #[test]
736 fn request_count_starts_at_zero() {
737 let pm = ProxyManager::new(Duration::from_secs(8));
738 assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
739 }
740
741 #[test]
742 fn increment_request_count_increments_correctly() {
743 let pm = ProxyManager::new(Duration::from_secs(8));
744 assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 1);
745 assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 2);
746 assert_eq!(pm.increment_request_count("socks5h://1.2.3.4:1080"), 3);
747 assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 3);
748 }
749
750 #[test]
751 fn request_counts_are_per_proxy() {
752 let pm = ProxyManager::new(Duration::from_secs(8));
753 pm.increment_request_count("socks5h://1.1.1.1:1080");
754 pm.increment_request_count("socks5h://1.1.1.1:1080");
755 pm.increment_request_count("socks5h://2.2.2.2:1080");
756 assert_eq!(pm.request_count("socks5h://1.1.1.1:1080"), 2);
757 assert_eq!(pm.request_count("socks5h://2.2.2.2:1080"), 1);
758 }
759
760 #[test]
761 fn reset_request_count_clears_count() {
762 let pm = ProxyManager::new(Duration::from_secs(8));
763 pm.increment_request_count("socks5h://1.2.3.4:1080");
764 pm.increment_request_count("socks5h://1.2.3.4:1080");
765 pm.reset_request_count("socks5h://1.2.3.4:1080");
766 assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
767 }
768
769 #[test]
770 fn reset_request_count_restores_retired_to_active() {
771 let pm = ProxyManager::new(Duration::from_secs(8));
772 pm.proxies.insert(
773 "socks5h://1.2.3.4:1080".to_string(),
774 ProxyInfo {
775 state: ProxyState::Retired,
776 },
777 );
778 pm.increment_request_count("socks5h://1.2.3.4:1080");
779 pm.reset_request_count("socks5h://1.2.3.4:1080");
780 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Active);
781 assert_eq!(pm.request_count("socks5h://1.2.3.4:1080"), 0);
782 }
783
784 #[test]
785 fn reset_request_count_does_not_restore_dead() {
786 let pm = ProxyManager::new(Duration::from_secs(8));
787 pm.proxies.insert(
788 "socks5h://1.2.3.4:1080".to_string(),
789 ProxyInfo {
790 state: ProxyState::Dead,
791 },
792 );
793 pm.reset_request_count("socks5h://1.2.3.4:1080");
794 assert_eq!(pm.get_state("socks5h://1.2.3.4:1080"), ProxyState::Dead);
795 }
796
797 #[test]
802 fn challenge_lock_returns_semaphore_with_one_permit() {
803 let pm = ProxyManager::new(Duration::from_secs(8));
804 let sem = pm.challenge_lock("socks5h://1.2.3.4:1080");
805 assert_eq!(sem.available_permits(), 1);
806 }
807
808 #[test]
809 fn challenge_lock_same_proxy_returns_same_semaphore() {
810 let pm = ProxyManager::new(Duration::from_secs(8));
811 let sem1 = pm.challenge_lock("socks5h://1.2.3.4:1080");
812 let sem2 = pm.challenge_lock("socks5h://1.2.3.4:1080");
813 assert!(std::ptr::eq(sem1.as_ref(), sem2.as_ref()));
815 }
816
817 #[tokio::test]
822 async fn fetch_and_add_with_empty_sources() {
823 let mgr = ProxyManager::new(Duration::from_secs(5));
824 let added = mgr.fetch_and_add(&[], true).await.unwrap();
825 assert_eq!(added, 0);
826 assert_eq!(mgr.total_count(), 0);
827 }
828
829 #[tokio::test]
830 async fn fetch_and_add_with_unreachable_source_logs_error() {
831 let pm = ProxyManager::new(Duration::from_secs(8));
832 let added = pm
834 .fetch_and_add(
835 &["http://127.0.0.1:1/nonexistent-proxy-list".to_string()],
836 true,
837 )
838 .await
839 .unwrap();
840 assert_eq!(added, 0);
841 assert_eq!(pm.total_count(), 0);
842 }
843
844 #[test]
849 fn normalize_comment_with_leading_whitespace() {
850 let result = ProxyManager::normalize_proxy_url(" # comment", true);
852 assert!(result.is_empty());
853 }
854
855 #[test]
856 fn normalize_ipv6_bare() {
857 let result = ProxyManager::normalize_proxy_url("[::1]:1080", true);
858 assert_eq!(result, "socks5h://[::1]:1080");
859 }
860
861 #[test]
862 fn normalize_socks5_uppercase_not_matched_becomes_bare() {
863 let result = ProxyManager::normalize_proxy_url("SOCKS5://1.2.3.4:1080", true);
865 assert_eq!(result, "socks5h://SOCKS5://1.2.3.4:1080");
866 }
867}