use crate::cli::{CliArgs, ShellType};
use crate::common;
use crate::config::Config;
use crate::error::SpeedtestError;
use crate::formatter::{OutputFormat, format_list};
use crate::history;
use crate::http;
use crate::progress::{create_spinner, finish_ok, no_color};
use crate::servers::{fetch_servers, ping_test, select_best_server};
use crate::test_runner::{self, TestRunResult};
use crate::types::Server;
use crate::types::{self, TestResult};
use crate::{download, upload};
use owo_colors::OwoColorize;
pub struct SpeedTestOrchestrator {
args: CliArgs,
config: Config,
client: reqwest::Client,
}
impl SpeedTestOrchestrator {
pub fn new(args: CliArgs) -> Result<Self, SpeedtestError> {
let config = Config::from_args(&args);
let client = http::create_client(&config)?;
Ok(Self {
args,
config,
client,
})
}
pub async fn run(&self) -> Result<(), SpeedtestError> {
if let Some(shell) = self.args.generate_completion {
Self::generate_shell_completion(shell);
return Ok(());
}
if self.args.history {
history::print_history()?;
return Ok(());
}
let is_verbose = self.is_verbose();
if is_verbose {
Self::print_header();
}
let servers = self.fetch_and_filter_servers(is_verbose).await?;
if self.config.list {
return Ok(());
}
let server = select_best_server(&servers)?;
if is_verbose {
Self::print_server_info(&server);
}
let client_ip = http::discover_client_ip(&self.client).await.ok();
let (ping, jitter, packet_loss, ping_samples) =
self.run_ping_test(&server, is_verbose).await?;
let dl_result = self.run_download_test(&server, is_verbose).await?;
let ul_result = self.run_upload_test(&server, is_verbose).await?;
let result = TestResult::from_test_runs(
types::ServerInfo {
id: server.id.clone(),
name: server.name.clone(),
sponsor: server.sponsor.clone(),
country: server.country.clone(),
distance: server.distance,
},
ping,
jitter,
packet_loss,
ping_samples,
&dl_result,
&ul_result,
client_ip,
);
if !self.config.json && !self.config.csv {
history::save_result(&result).ok();
}
self.output_results(&result, &dl_result, &ul_result)?;
Ok(())
}
pub fn is_verbose(&self) -> bool {
use crate::cli::OutputFormatType;
if self.config.quiet {
return false;
}
let format_non_verbose = matches!(
self.args.format,
Some(
OutputFormatType::Simple
| OutputFormatType::Json
| OutputFormatType::Csv
| OutputFormatType::Dashboard
)
);
!self.config.simple
&& !self.config.json
&& !self.config.csv
&& !self.config.list
&& !format_non_verbose
}
fn print_header() {
eprintln!(
"{}",
format!(" ═══ NetSpeed CLI v{} ═══", env!("CARGO_PKG_VERSION"))
.dimmed()
.bold()
);
eprintln!("{}", " Bandwidth test · speedtest.net".dimmed());
eprintln!();
}
fn print_server_info(server: &Server) {
let dist = common::format_distance(server.distance);
eprintln!();
if no_color() {
eprintln!(" Server: {} ({})", server.sponsor, server.name);
eprintln!(" Location: {} ({dist})", server.country);
} else {
eprintln!(
" {} {} ({})",
"Server:".dimmed(),
server.sponsor.white().bold(),
server.name
);
eprintln!(" {} {} ({dist})", "Location:".dimmed(), server.country);
}
eprintln!();
}
async fn fetch_and_filter_servers(
&self,
is_verbose: bool,
) -> Result<Vec<Server>, SpeedtestError> {
let fetch_spinner = if is_verbose {
Some(create_spinner("Finding servers..."))
} else {
None
};
let mut servers = fetch_servers(&self.client).await?;
if let Some(ref pb) = fetch_spinner {
finish_ok(pb, &format!("Found {} servers", servers.len()));
eprintln!();
}
if self.config.list {
format_list(&servers)?;
return Ok(Vec::new()); }
if !self.config.server_ids.is_empty() {
servers.retain(|s| self.config.server_ids.contains(&s.id));
}
if !self.config.exclude_ids.is_empty() {
servers.retain(|s| !self.config.exclude_ids.contains(&s.id));
}
if servers.is_empty() {
return Err(SpeedtestError::ServerNotFound(
"No servers match your criteria. Try running without --server/--exclude filters, or use --list to see available servers.".to_string(),
));
}
Ok(servers)
}
async fn run_ping_test(
&self,
server: &Server,
is_verbose: bool,
) -> Result<(Option<f64>, Option<f64>, Option<f64>, Vec<f64>), SpeedtestError> {
if self.config.no_download && self.config.no_upload {
return Ok((None, None, None, Vec::new()));
}
let ping_spinner = if is_verbose {
Some(create_spinner("Testing latency..."))
} else {
None
};
let ping_result = ping_test(&self.client, server).await?;
if let Some(ref pb) = ping_spinner {
let msg = if no_color() {
format!("Latency: {:.2} ms", ping_result.0)
} else {
format!(
"Latency: {}",
format!("{:.2} ms", ping_result.0).cyan().bold()
)
};
finish_ok(pb, &msg);
}
Ok((
Some(ping_result.0),
Some(ping_result.1),
Some(ping_result.2),
ping_result.3,
))
}
async fn run_download_test(
&self,
server: &Server,
is_verbose: bool,
) -> Result<TestRunResult, SpeedtestError> {
if self.config.no_download {
return Ok(TestRunResult::default());
}
test_runner::run_bandwidth_test(
&self.config,
server,
"Download",
is_verbose,
|progress| async {
download::download_test(&self.client, server, self.config.single, progress).await
},
)
.await
}
async fn run_upload_test(
&self,
server: &Server,
is_verbose: bool,
) -> Result<TestRunResult, SpeedtestError> {
if self.config.no_upload {
return Ok(TestRunResult::default());
}
test_runner::run_bandwidth_test(
&self.config,
server,
"Upload",
is_verbose,
|progress| async {
upload::upload_test(&self.client, server, self.config.single, progress).await
},
)
.await
}
fn output_results(
&self,
result: &TestResult,
dl_result: &TestRunResult,
ul_result: &TestRunResult,
) -> Result<(), SpeedtestError> {
use crate::cli::OutputFormatType;
let output_format = match self.args.format {
Some(OutputFormatType::Json) => OutputFormat::Json,
Some(OutputFormatType::Csv) => OutputFormat::Csv {
delimiter: self.config.csv_delimiter,
header: self.config.csv_header,
},
Some(OutputFormatType::Simple) => OutputFormat::Simple,
Some(OutputFormatType::Dashboard) => OutputFormat::Dashboard {
dl_mbps: dl_result.avg_bps / 1_000_000.0,
dl_peak_mbps: dl_result.peak_bps / 1_000_000.0,
dl_bytes: dl_result.total_bytes,
dl_duration: dl_result.duration_secs,
ul_mbps: ul_result.avg_bps / 1_000_000.0,
ul_peak_mbps: ul_result.peak_bps / 1_000_000.0,
ul_bytes: ul_result.total_bytes,
ul_duration: ul_result.duration_secs,
},
Some(OutputFormatType::Detailed) => OutputFormat::Detailed {
dl_bytes: dl_result.total_bytes,
ul_bytes: ul_result.total_bytes,
dl_duration: dl_result.duration_secs,
ul_duration: ul_result.duration_secs,
dl_skipped: self.config.no_download,
ul_skipped: self.config.no_upload,
},
None => {
if self.config.json {
OutputFormat::Json
} else if self.config.csv {
OutputFormat::Csv {
delimiter: self.config.csv_delimiter,
header: self.config.csv_header,
}
} else if self.config.simple {
OutputFormat::Simple
} else {
OutputFormat::Detailed {
dl_bytes: dl_result.total_bytes,
ul_bytes: ul_result.total_bytes,
dl_duration: dl_result.duration_secs,
ul_duration: ul_result.duration_secs,
dl_skipped: self.config.no_download,
ul_skipped: self.config.no_upload,
}
}
}
};
output_format.format(result, self.config.bytes)?;
Ok(())
}
fn generate_shell_completion(shell: ShellType) {
use clap::CommandFactory;
use clap_complete::{Shell as CompleteShell, generate};
use std::io;
let shell_type = match shell {
ShellType::Bash => CompleteShell::Bash,
ShellType::Zsh => CompleteShell::Zsh,
ShellType::Fish => CompleteShell::Fish,
ShellType::PowerShell => CompleteShell::PowerShell,
ShellType::Elvish => CompleteShell::Elvish,
};
let mut cmd = CliArgs::command();
let bin_name = "netspeed-cli";
generate(shell_type, &mut cmd, bin_name, &mut io::stdout());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::CliArgs;
use clap::Parser;
#[test]
fn test_is_verbose_default() {
let args = CliArgs::parse_from(["netspeed-cli"]);
let orch = SpeedTestOrchestrator::new(args).unwrap();
assert!(orch.is_verbose());
}
#[test]
fn test_is_verbose_simple() {
let args = CliArgs::parse_from(["netspeed-cli", "--simple"]);
let orch = SpeedTestOrchestrator::new(args).unwrap();
assert!(!orch.is_verbose());
}
#[test]
fn test_is_verbose_json() {
let args = CliArgs::parse_from(["netspeed-cli", "--json"]);
let orch = SpeedTestOrchestrator::new(args).unwrap();
assert!(!orch.is_verbose());
}
#[test]
fn test_is_verbose_csv() {
let args = CliArgs::parse_from(["netspeed-cli", "--csv"]);
let orch = SpeedTestOrchestrator::new(args).unwrap();
assert!(!orch.is_verbose());
}
#[test]
fn test_is_verbose_list() {
let args = CliArgs::parse_from(["netspeed-cli", "--list"]);
let orch = SpeedTestOrchestrator::new(args).unwrap();
assert!(!orch.is_verbose());
}
#[test]
fn test_orchestrator_creation() {
let args = CliArgs::parse_from(["netspeed-cli"]);
let orch = SpeedTestOrchestrator::new(args);
assert!(orch.is_ok());
}
#[test]
fn test_orchestrator_creation_default() {
let args = CliArgs::parse_from(["netspeed-cli"]);
let orch = SpeedTestOrchestrator::new(args);
assert!(orch.is_ok());
}
}