Skip to content

Commit 5d26931

Browse files
committed
centralized protocol and country filtering before speed tests; added --disable-untested-fallback flag; added mirror deduplication by host, port, and path with https preference
1 parent 8dc0591 commit 5d26931

20 files changed

+174
-152
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
# 0.26.0 (2026-02-08)
2+
3+
- added `--disable-untested-fallback` to exit with error when all speed tests fail
4+
- centralized protocol and country filtering before speed tests
5+
- added mirror deduplication by host, port, and path with `https` preference
6+
- added positive-value validation for `--max-mirrors-to-output`
7+
- added startup version comment and explicit blank-output error handling
8+
19
# 0.25.0 (2026-01-16)
210

311
- improved error messages when fetching mirrors fails:

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rate_mirrors"
3-
version = "0.25.0"
3+
version = "0.26.0"
44
authors = ["Nikita Almakov <nikita.almakov@gmail.com>"]
55
edition = "2024"
66
description = "Everyday-use client-side map-aware mirror ranking tool (Arch Linux; Manjaro; custom ones)"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ rate-mirrors [OPTIONS] <SUBCOMMAND> [SUBCOMMAND-OPTIONS]
9494
| `--protocol=PROTO` | Test only specified protocol (http/https) | - |
9595
| `--max-mirrors-to-output=N` | Maximum mirrors to output | - |
9696
| `--disable-comments` | Disable printing comments | false |
97+
| `--disable-untested-fallback` | Exit with error when all speed tests fail instead of outputting untested mirrors | false |
9798
| `--allow-root` | Allow running as root | false |
9899

99100
### Subcommand Options (arch example)

src/config.rs

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ use crate::mirror::Mirror;
22
use crate::target_configs::archarm::ArcharmTarget;
33
use crate::target_configs::archlinux::ArchTarget;
44
use crate::target_configs::archlinuxcn::ArchCNTarget;
5-
use crate::target_configs::artix::ArtixTarget;
65
use crate::target_configs::arcolinux::ArcoLinuxTarget;
6+
use crate::target_configs::artix::ArtixTarget;
77
use crate::target_configs::blackarch::BlackArchTarget;
88
use crate::target_configs::cachyos::CachyOSTarget;
99
use crate::target_configs::chaotic::ChaoticTarget;
@@ -14,14 +14,13 @@ use crate::target_configs::openbsd::OpenBSDTarget;
1414
use crate::target_configs::rebornos::RebornOSTarget;
1515
use crate::target_configs::stdin::StdinTarget;
1616
// use crate::target_configs::ubuntu::UbuntuTarget;
17-
use ambassador::{delegatable_trait, Delegate};
17+
use ambassador::{Delegate, delegatable_trait};
1818
use clap::{Parser, Subcommand};
19-
use itertools::Itertools;
2019
use serde::de::DeserializeOwned;
2120
use std::collections::HashSet;
2221
use std::fmt;
2322
use std::str::FromStr;
24-
use std::sync::{mpsc, Arc};
23+
use std::sync::mpsc;
2524
use std::time::Duration;
2625
use thiserror::Error;
2726
use tokio::runtime::Runtime;
@@ -56,6 +55,10 @@ pub enum AppError {
5655
HttpError { status: u16, url: String },
5756
#[error("no mirrors after filtering")]
5857
NoMirrorsAfterFiltering,
58+
#[error("all speed tests failed")]
59+
SpeedTestsFailed,
60+
#[error("no mirror output produced")]
61+
BlankOutput,
5962
#[error(transparent)]
6063
UrlParseError(#[from] url::ParseError),
6164
#[error(transparent)]
@@ -88,7 +91,6 @@ pub trait LogFormatter {
8891
pub trait FetchMirrors {
8992
fn fetch_mirrors(
9093
&self,
91-
config: Arc<Config>,
9294
tx_progress: mpsc::Sender<String>,
9395
) -> Result<Vec<Mirror>, AppError>;
9496
}
@@ -145,6 +147,14 @@ pub enum Target {
145147
RebornOS(RebornOSTarget),
146148
}
147149

150+
fn parse_positive_usize(s: &str) -> Result<usize, String> {
151+
let n: usize = s.parse().map_err(|e| format!("{e}"))?;
152+
if n == 0 {
153+
return Err("value must be at least 1".into());
154+
}
155+
Ok(n)
156+
}
157+
148158
#[derive(Debug, Parser)]
149159
#[command(
150160
name = "rate-mirrors config",
@@ -266,7 +276,7 @@ pub struct Config {
266276
pub top_mirrors_number_to_retest: usize,
267277

268278
/// Max number of mirrors to output
269-
#[arg(env = "RATE_MIRRORS_MAX_MIRRORS_TO_OUTPUT", long)]
279+
#[arg(env = "RATE_MIRRORS_MAX_MIRRORS_TO_OUTPUT", long, value_parser = parse_positive_usize)]
270280
pub max_mirrors_to_output: Option<usize>,
271281

272282
/// Filename to save the output to in case of success
@@ -285,6 +295,10 @@ pub struct Config {
285295
#[arg(env = "RATE_MIRRORS_DISABLE_COMMENTS_IN_FILE", long)]
286296
pub disable_comments_in_file: bool,
287297

298+
/// Exit with error instead of outputting untested mirrors when all speed tests fail
299+
#[arg(env = "RATE_MIRRORS_DISABLE_UNTESTED_FALLBACK", long)]
300+
pub disable_untested_fallback: bool,
301+
288302
/// Pre-parsed set of excluded country codes (lowercase)
289303
#[arg(skip)]
290304
pub excluded_countries_set: HashSet<String>,
@@ -306,34 +320,22 @@ impl Config {
306320
config
307321
}
308322

309-
pub fn is_protocol_allowed(&self, protocol: &Protocol) -> bool {
310-
self.protocols.is_empty() || self.protocols.contains(protocol)
311-
}
312-
313323
pub fn is_country_excluded(&self, code: &str) -> bool {
314324
self.excluded_countries_set
315325
.contains(&code.to_ascii_lowercase())
316326
}
317327

318328
pub fn is_protocol_allowed_for_url(&self, url: &Url) -> bool {
319-
self.protocols.is_empty()
320-
|| url
321-
.scheme()
329+
if self.protocols.is_empty() {
330+
matches!(url.scheme(), "http" | "https")
331+
} else {
332+
url.scheme()
322333
.parse()
323334
.map(|p| self.protocols.contains(&p))
324335
.unwrap_or(false)
336+
}
325337
}
326338

327-
pub fn get_preferred_url<'a>(&self, urls: &'a [Url]) -> Option<&'a Url> {
328-
urls.iter()
329-
.filter(|u| self.is_protocol_allowed_for_url(u))
330-
.sorted_by_key(|u| match u.scheme() {
331-
"https" => 0,
332-
"http" => 1,
333-
_ => 2,
334-
})
335-
.next()
336-
}
337339
}
338340

339341
fn convert_reqwest_error(e: reqwest::Error, url: &str) -> AppError {

src/main.rs

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct OutputSink<'a, T: LogFormatter> {
3030
formatter: &'a T,
3131
comments_enabled: bool,
3232
comments_in_file_enabled: bool,
33+
mirror_count: usize,
3334
}
3435

3536
impl<'a, T: LogFormatter> OutputSink<'a, T> {
@@ -46,13 +47,15 @@ impl<'a, T: LogFormatter> OutputSink<'a, T> {
4647
output_lines: Some(Vec::new()),
4748
comments_enabled,
4849
comments_in_file_enabled,
50+
mirror_count: 0,
4951
},
5052
None => Self {
5153
formatter,
5254
filename: None,
5355
output_lines: None,
5456
comments_enabled,
5557
comments_in_file_enabled,
58+
mirror_count: 0,
5659
},
5760
};
5861
Ok(output)
@@ -76,6 +79,7 @@ impl<'a, T: LogFormatter> OutputSink<'a, T> {
7679
if let Some(output_lines) = &mut self.output_lines {
7780
output_lines.push(s);
7881
}
82+
self.mirror_count += 1;
7983
}
8084

8185
pub fn save_to_file(&mut self) -> Result<(), io::Error> {
@@ -96,6 +100,7 @@ fn main() -> Result<(), AppError> {
96100
return Err(AppError::Root);
97101
}
98102
let max_mirrors_to_output = config.max_mirrors_to_output.clone();
103+
let disable_untested_fallback = config.disable_untested_fallback;
99104

100105
let ref formatter = Arc::clone(&config).target;
101106
let mut output = OutputSink::new(
@@ -106,18 +111,79 @@ fn main() -> Result<(), AppError> {
106111
)?;
107112

108113
output.display_comment(format!("STARTED AT: {}", Local::now()));
114+
output.display_comment(format!("VERSION: {}", env!("CARGO_PKG_VERSION")));
109115
output.display_comment(format!("ARGS: {}", env::args().join(" ")));
110116

111117
let (tx_progress, rx_progress) = mpsc::channel::<String>();
112118
let (tx_results, rx_results) = mpsc::channel::<SpeedTestResults>();
113119
let (tx_mirrors, rx_mirrors) = mpsc::channel::<Mirror>();
114120

115121
let thread_handle = thread::spawn(move || -> Result<(), AppError> {
116-
let mirrors = config
122+
let mut mirrors = config
117123
.target
118-
.fetch_mirrors(Arc::clone(&config), tx_progress.clone())?;
124+
.fetch_mirrors(tx_progress.clone())?;
125+
126+
// Centralized protocol filtering
127+
let before_protocol = mirrors.len();
128+
mirrors.retain(|m| config.is_protocol_allowed_for_url(&m.url));
129+
if mirrors.len() < before_protocol {
130+
tx_progress
131+
.send(format!(
132+
"PROTOCOL FILTER: {} -> {} mirrors",
133+
before_protocol,
134+
mirrors.len()
135+
))
136+
.unwrap();
137+
}
138+
139+
// Country filtering before dedup so excluded-country duplicates
140+
// don't shadow valid mirrors from non-excluded countries
141+
let before_country = mirrors.len();
142+
mirrors.retain(|m| {
143+
m.country
144+
.map(|c| !config.is_country_excluded(c.code))
145+
.unwrap_or(true)
146+
});
147+
if mirrors.len() < before_country {
148+
tx_progress
149+
.send(format!(
150+
"COUNTRY FILTER: {} -> {} mirrors",
151+
before_country,
152+
mirrors.len()
153+
))
154+
.unwrap();
155+
}
119156

120-
// sending untested mirrors back so we have a fallback in case if all tests fail
157+
// Prefer https over http when both are available for the same host
158+
mirrors.sort_by_key(|m| match m.url.scheme() {
159+
"https" => 0,
160+
"http" => 1,
161+
_ => 2,
162+
});
163+
164+
// Deduplicate mirrors by host+port+path (keeps first = preferred protocol)
165+
let before_dedup = mirrors.len();
166+
let mut seen = std::collections::HashSet::new();
167+
mirrors.retain(|m| {
168+
let key = format!(
169+
"{}{}{}",
170+
m.url.host_str().unwrap_or(""),
171+
m.url.port().map(|p| format!(":{}", p)).unwrap_or_default(),
172+
m.url.path()
173+
);
174+
seen.insert(key)
175+
});
176+
if mirrors.len() < before_dedup {
177+
tx_progress
178+
.send(format!(
179+
"DEDUP: {} -> {} mirrors",
180+
before_dedup,
181+
mirrors.len()
182+
))
183+
.unwrap();
184+
}
185+
186+
// sending filtered mirrors back so we have a fallback in case if all tests fail
121187
for mirror in mirrors.iter().cloned() {
122188
tx_mirrors.send(mirror).unwrap();
123189
}
@@ -144,6 +210,10 @@ fn main() -> Result<(), AppError> {
144210
output.display_comment("==== NO MIRRORS AFTER FILTERING ====");
145211
return Err(AppError::NoMirrorsAfterFiltering);
146212
}
213+
if disable_untested_fallback {
214+
output.display_comment("==== ALL SPEED TESTS FAILED ====");
215+
return Err(AppError::SpeedTestsFailed);
216+
}
147217
output.display_comment("==== FAILED TO TEST SPEEDS, RETURNING UNTESTED MIRRORS ====");
148218
for mirror in untested_mirrors.into_iter() {
149219
output.display_mirror(&mirror);
@@ -167,6 +237,9 @@ fn main() -> Result<(), AppError> {
167237
}
168238
}
169239

240+
if output.mirror_count == 0 {
241+
return Err(AppError::BlankOutput);
242+
}
170243
output.save_to_file()?;
171244
Ok(())
172245
}

src/speed_test.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,9 @@ pub fn test_speed_by_countries(
290290
let mut unlabeled_mirrors: Vec<Mirror> = Vec::new();
291291
for mirror in mirrors.into_iter() {
292292
match mirror.country {
293-
Some(country) if !config.is_country_excluded(country.code) => {
293+
Some(country) => {
294294
map.entry(country).or_insert_with(Vec::new).push(mirror);
295295
}
296-
Some(_) => {}
297296
None => {
298297
unlabeled_mirrors.push(mirror);
299298
}

src/targets/archarm.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use crate::config::{fetch_text, AppError, Config, FetchMirrors, LogFormatter};
1+
use crate::config::{fetch_text, AppError, FetchMirrors, LogFormatter};
22
use crate::mirror::Mirror;
33
use crate::target_configs::archarm::ArcharmTarget;
44
use std::fmt::Display;
5-
use std::sync::{mpsc, Arc};
5+
use std::sync::mpsc;
66
use url::Url;
77

88
impl LogFormatter for ArcharmTarget {
@@ -24,7 +24,6 @@ impl LogFormatter for ArcharmTarget {
2424
impl FetchMirrors for ArcharmTarget {
2525
fn fetch_mirrors(
2626
&self,
27-
config: Arc<Config>,
2827
_tx_progress: mpsc::Sender<String>,
2928
) -> Result<Vec<Mirror>, AppError> {
3029
let url = "https://raw.githubusercontent.com/archlinuxarm/PKGBUILDs/master/core/pacman-mirrorlist/mirrorlist";
@@ -42,8 +41,7 @@ impl FetchMirrors for ArcharmTarget {
4241
None
4342
}
4443
})
45-
.filter_map(|line| Url::parse(&line.replace("$arch/$repo", "")).ok())
46-
.filter(|url| config.is_protocol_allowed_for_url(url));
44+
.filter_map(|line| Url::parse(&line.replace("$arch/$repo", "")).ok());
4745
let result: Vec<_> = urls
4846
.map(|url| {
4947
let url_to_test = url

src/targets/archlinux.rs

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
1-
use crate::config::{fetch_json, AppError, Config, FetchMirrors, LogFormatter};
1+
use crate::config::{AppError, FetchMirrors, LogFormatter, fetch_json};
22
use crate::countries::Country;
33
use crate::mirror::Mirror;
44
use crate::target_configs::archlinux::{ArchMirrorsSortingStrategy, ArchTarget};
55
use rand::prelude::SliceRandom;
66
use rand::rng;
77
use serde::Deserialize;
88
use std::fmt::Display;
9-
use std::sync::{mpsc, Arc};
9+
use std::sync::mpsc;
1010
use url::Url;
1111

1212
#[derive(Deserialize, Debug, Clone)]
1313
pub struct ArchMirror {
14+
#[allow(dead_code)]
1415
protocol: String,
1516
url: String,
1617
score: Option<f64>,
@@ -36,11 +37,7 @@ impl LogFormatter for ArchTarget {
3637
}
3738

3839
impl FetchMirrors for ArchTarget {
39-
fn fetch_mirrors(
40-
&self,
41-
config: Arc<Config>,
42-
tx_progress: mpsc::Sender<String>,
43-
) -> Result<Vec<Mirror>, AppError> {
40+
fn fetch_mirrors(&self, tx_progress: mpsc::Sender<String>) -> Result<Vec<Mirror>, AppError> {
4441
let url = if self.fetch_first_tier_only {
4542
"https://archlinux.org/mirrors/status/tier/1/json/"
4643
} else {
@@ -59,12 +56,7 @@ impl FetchMirrors for ArchTarget {
5956
.filter(|mirror| {
6057
if let Some(completion_pct) = mirror.completion_pct {
6158
if let Some(delay) = mirror.delay {
62-
if let Ok(protocol) = mirror.protocol.parse() {
63-
return completion_pct >= self.completion
64-
&& delay <= self.max_delay
65-
&& config.is_protocol_allowed(&protocol)
66-
&& !mirror.country_code.is_empty();
67-
}
59+
return completion_pct >= self.completion && delay <= self.max_delay;
6860
}
6961
}
7062
false

0 commit comments

Comments
 (0)