use anyhow::{anyhow, Context};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::app::score::CompositeScore;
use crate::engine::models::{Server, TestPhase, TestReport};
use crate::engine::{Engine, EngineEvent};
use crate::utils::format::{format_bps, format_bytes, format_millis};
use super::history::{self, HistoryRecord};
const CHANNEL_CAPACITY: usize = 64;
const MAX_ATTEMPTS: usize = 3;
#[derive(Debug, Clone, Default)]
pub struct Options {
pub json: bool,
pub csv: bool,
pub server: Option<String>,
pub list_servers: bool,
pub interval: Option<std::time::Duration>,
pub compare: Option<usize>,
pub fail_below: Option<f64>,
pub prom_textfile: Option<std::path::PathBuf>,
pub one_line: bool,
}
pub async fn run(engine: Engine, options: Options) -> anyhow::Result<()> {
let log = |line: String| {
if !options.json && !options.csv && !options.one_line {
eprintln!("{line}");
}
};
log(format!("netspd {} — headless", env!("CARGO_PKG_VERSION")));
log("locating servers…".to_owned());
let servers = engine
.load_servers()
.await
.context("server discovery failed")?;
if options.list_servers {
for server in &servers {
println!("{} ({})", server.name, server.description);
}
return Ok(());
}
if options.csv {
println!("{}", HistoryRecord::CSV_HEADER);
}
if let Some(count) = options.compare {
return compare_servers(&engine, &servers, count.clamp(2, 8), &options, &log).await;
}
let Some(interval) = options.interval else {
return test_once(&engine, &servers, &options, &log).await;
};
log(format!(
"repeating every {} (Ctrl+C to stop)",
crate::utils::format::format_eta(interval)
));
loop {
let started = std::time::Instant::now();
if let Err(err) = test_once(&engine, &servers, &options, &log).await {
log(format!("run failed ({err}); continuing"));
}
let wait = interval.saturating_sub(started.elapsed());
log(format!(
"next run in {}",
crate::utils::format::format_eta(wait)
));
tokio::time::sleep(wait).await;
}
}
async fn compare_servers(
engine: &Engine,
servers: &[Server],
count: usize,
options: &Options,
log: &impl Fn(String),
) -> anyhow::Result<()> {
let mut results: Vec<HistoryRecord> = Vec::new();
for server in servers.iter().take(count) {
log(format!("server: {} ({})", server.name, server.description));
match run_once(engine, server, log).await {
Ok(report) => {
history::record_report(&report);
results.push(HistoryRecord::from_report(&report));
}
Err(err) => log(format!(" skipped ({err})")),
}
}
if results.is_empty() {
return Err(anyhow!("every compared server failed"));
}
results.sort_by(|a, b| b.download_mbps.total_cmp(&a.download_mbps));
if options.json {
println!(
"{}",
serde_json::to_string(&results).context("failed to encode comparison")?
);
} else if options.csv {
for record in &results {
println!("{}", record.to_csv_row());
}
} else {
println!(
"{rank:<3} {server:<40} {ping:>9} {down:>12} {up:>12} bloat",
rank = "#",
server = "server",
ping = "ping",
down = "down",
up = "up",
);
for (rank, record) in results.iter().enumerate() {
println!(
"{:<3} {:<40} {:>9} {:>12} {:>12} {}",
rank + 1,
record.server.chars().take(40).collect::<String>(),
format_millis(record.ping_ms),
format!("{} Mbps", record.download_mbps),
format!("{} Mbps", record.upload_mbps),
record.bufferbloat.as_deref().unwrap_or("-"),
);
}
}
Ok(())
}
pub fn print_history(json: bool, csv: bool) {
let records = history::load_recent(usize::MAX);
if csv {
println!("{}", HistoryRecord::CSV_HEADER);
}
for record in &records {
if json {
if let Ok(line) = record.to_json() {
println!("{line}");
}
} else if csv {
println!("{}", record.to_csv_row());
} else {
println!(
"{} {}: v {} Mbps ^ {} Mbps ping {}",
record.timestamp,
record.server,
record.download_mbps,
record.upload_mbps,
format_millis(record.ping_ms),
);
}
}
if records.is_empty() && !json && !csv {
eprintln!("no stored results yet — run a test first");
}
}
async fn test_once(
engine: &Engine,
servers: &[Server],
options: &Options,
log: &impl Fn(String),
) -> anyhow::Result<()> {
let candidates: Vec<Server> = match &options.server {
Some(query) => {
let server = servers
.iter()
.find(|server| server.matches(query))
.ok_or_else(|| anyhow!("no server matching {query:?}; try --list-servers"))?;
vec![server.clone()]
}
None => servers.iter().take(MAX_ATTEMPTS).cloned().collect(),
};
let total = candidates.len();
let mut last_error = anyhow!("no servers attempted");
for (attempt, server) in candidates.into_iter().enumerate() {
log(format!("server: {} ({})", server.name, server.description));
match run_once(engine, &server, log).await {
Ok(report) => {
history::record_report(&report);
if let Some(path) = &options.prom_textfile {
if let Err(err) =
super::prom::write_textfile(path, &report, engine.provider_name())
{
log(format!("failed to write {}: {err}", path.display()));
}
}
let record = HistoryRecord::from_report(&report);
if options.json {
println!("{}", record.to_json().context("failed to encode report")?);
} else if options.csv {
println!("{}", record.to_csv_row());
} else if options.one_line {
println!("{}", format_one_line(&report));
} else {
println!(
"{}: ↓ {} ↑ {} ping {}",
report.server_name,
format_bps(report.download.average_bps),
format_bps(report.upload.average_bps),
format_millis(report.latency.average_ms),
);
println!("{}", super::verdict::verdict(&report));
}
if let Some(threshold) = options.fail_below {
let mbps = report.download.average_bps / 1_000_000.0;
if mbps < threshold {
eprintln!("download {mbps:.1} Mbps below threshold {threshold} Mbps");
std::process::exit(2);
}
}
return Ok(());
}
Err(err) => {
if attempt + 1 < total {
log(format!("failed ({err}); trying next server…"));
}
last_error = err;
}
}
}
Err(last_error)
}
async fn run_once(
engine: &Engine,
server: &Server,
log: &impl Fn(String),
) -> anyhow::Result<TestReport> {
let (events_tx, mut events_rx) = mpsc::channel(CHANNEL_CAPACITY);
let cancel = CancellationToken::new();
let mut report = None;
let mut failure = None;
let run = engine.run_test(server, events_tx, cancel);
tokio::pin!(run);
loop {
tokio::select! {
maybe = events_rx.recv() => match maybe {
Some(event) => note_event(event, log, &mut report, &mut failure),
None => break,
},
result = &mut run => {
result?;
while let Ok(event) = events_rx.try_recv() {
note_event(event, log, &mut report, &mut failure);
}
break;
}
}
}
if let Some(message) = failure {
return Err(anyhow!(message));
}
report.ok_or_else(|| anyhow!("test produced no report"))
}
fn format_one_line(report: &TestReport) -> String {
let dl = report.download.average_bps / 1_000_000.0;
let ul = report.upload.average_bps / 1_000_000.0;
let ping = report.latency.average_ms.round() as u64;
let grade = CompositeScore::compute(report).grade.label();
format!("↓{dl:.1} ↑{ul:.1} ~{ping}ms {grade}")
}
fn note_event(
event: EngineEvent,
log: &impl Fn(String),
report: &mut Option<TestReport>,
failure: &mut Option<String>,
) {
match event {
EngineEvent::PhaseStarted { phase } => {
log(format!("{}…", phase.label().to_lowercase()));
}
EngineEvent::PingFinished { stats } => {
log(format!(
" ping {} jitter {} loss {:.0}%",
format_millis(stats.average_ms),
format_millis(stats.jitter_ms),
stats.packet_loss_pct
));
}
EngineEvent::TransferFinished { phase, stats } => {
let arrow = match phase {
TestPhase::Download => "↓",
TestPhase::Upload => "↑",
TestPhase::Ping => " ",
};
log(format!(
" {arrow} {} peak {} ({})",
format_bps(stats.average_bps),
format_bps(stats.peak_bps),
format_bytes(stats.bytes)
));
}
EngineEvent::Finished { report: done } => {
if let Some(bloat) = done.bufferbloat {
log(format!(
" bufferbloat {} (idle {} → down {} / up {})",
bloat.grade.label(),
format_millis(bloat.idle_ms),
format_millis(bloat.download_ms),
format_millis(bloat.upload_ms)
));
}
*report = Some(done);
}
EngineEvent::Failed { message } => *failure = Some(message),
EngineEvent::PingSample { .. } | EngineEvent::Progress { .. } => {}
}
}