use anyhow::{anyhow, Context};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
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 server: Option<String>,
pub list_servers: bool,
}
pub async fn run(engine: Engine, options: Options) -> anyhow::Result<()> {
let log = |line: String| {
if !options.json {
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(());
}
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.into_iter().take(MAX_ATTEMPTS).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);
let record = HistoryRecord::from_report(&report);
if options.json {
println!("{}", record.to_json().context("failed to encode 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),
);
}
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(EngineEvent::PhaseStarted { phase }) => {
log(format!("{}…", phase.label().to_lowercase()));
}
Some(EngineEvent::PingFinished { stats }) => {
log(format!(
" ping {} jitter {} loss {:.0}%",
format_millis(stats.average_ms),
format_millis(stats.jitter_ms),
stats.packet_loss_pct
));
}
Some(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)
));
}
Some(EngineEvent::Finished { report: done }) => report = Some(done),
Some(EngineEvent::Failed { message }) => failure = Some(message),
Some(EngineEvent::PingSample { .. } | EngineEvent::Progress { .. }) => {}
None => break,
},
result = &mut run => {
result?;
while let Ok(event) = events_rx.try_recv() {
if let EngineEvent::Finished { report: done } = event {
report = Some(done);
}
}
break;
}
}
}
if let Some(message) = failure {
return Err(anyhow!(message));
}
report.ok_or_else(|| anyhow!("test produced no report"))
}