use crate::engine::{EngineControl, TestEngine};
use crate::model::{RunConfig, TestEvent};
use anyhow::{Context, Result};
use clap::Parser;
use rand::RngCore;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Parser, Clone)]
#[command(
name = "cloudflare-speed-cli",
version,
about = "Cloudflare-based speed test with optional TUI"
)]
pub struct Cli {
#[arg(long, default_value = "https://speed.cloudflare.com")]
pub base_url: String,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub text: bool,
#[arg(long)]
pub silent: bool,
#[arg(long, default_value = "10s")]
pub download_duration: humantime::Duration,
#[arg(long, default_value = "10s")]
pub upload_duration: humantime::Duration,
#[arg(long, default_value = "2s")]
pub idle_latency_duration: humantime::Duration,
#[arg(long, default_value_t = 6)]
pub concurrency: usize,
#[arg(long, default_value_t = 10_000_000)]
pub download_bytes_per_req: u64,
#[arg(long, default_value_t = 5_000_000)]
pub upload_bytes_per_req: u64,
#[arg(long, default_value_t = 250)]
pub probe_interval_ms: u64,
#[arg(long, default_value_t = 2000)]
pub probe_timeout_ms: u64,
#[arg(long)]
pub experimental: bool,
#[arg(long)]
pub export_json: Option<std::path::PathBuf>,
#[arg(long)]
pub export_csv: Option<std::path::PathBuf>,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pub auto_save: bool,
#[arg(long)]
pub interface: Option<String>,
#[arg(long)]
pub source: Option<String>,
#[arg(long)]
pub proxy: Option<String>,
#[arg(long)]
pub certificate: Option<std::path::PathBuf>,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
pub test_on_launch: bool,
#[arg(long)]
pub comments: Option<String>,
#[arg(long)]
pub compare_ip_versions: bool,
#[arg(long)]
pub traceroute: bool,
#[arg(long, default_value_t = 30)]
pub traceroute_max_hops: u8,
#[arg(short = '4', long, conflicts_with = "ipv6_only")]
pub ipv4_only: bool,
#[arg(short = '6', long)]
pub ipv6_only: bool,
#[arg(long)]
pub skip_diagnostics: bool,
#[arg(long, default_value_t = 50)]
pub udp_packets: u64,
#[arg(long)]
pub hide_network_info: bool,
}
pub async fn run(args: Cli) -> Result<()> {
if args.silent && !args.json {
return Err(anyhow::anyhow!(
"--silent can only be used with --json. Use --silent --json together."
));
}
if let Some(ref proxy_url) = args.proxy {
eprintln!(
"Warning: using proxy {}. Speed results reflect performance through the proxy, not your direct connection.",
proxy_url
);
}
if args.silent {
return run_test_engine(args, true).await;
}
if !args.json && !args.text {
#[cfg(feature = "tui")]
{
return crate::tui::run(args).await;
}
#[cfg(not(feature = "tui"))]
{
return run_text(args).await;
}
}
if args.json {
return run_test_engine(args, false).await;
}
run_text(args).await
}
fn gen_meas_id() -> String {
let mut b = [0u8; 8];
rand::thread_rng().fill_bytes(&mut b);
u64::from_le_bytes(b).to_string()
}
pub fn build_config(args: &Cli) -> Result<RunConfig> {
use crate::engine::network_bind;
let skip = args.skip_diagnostics;
let resolved_bind_ip = network_bind::resolve_bind_address(
args.interface.as_ref(),
args.source.as_ref(),
)?
.map(|addr| addr.ip());
if let Some(ip) = resolved_bind_ip {
if let Some(ref iface) = args.interface {
eprintln!("Binding HTTP connections to interface {} (IP: {})", iface, ip);
} else {
eprintln!("Binding HTTP connections to source IP: {}", ip);
}
}
network_bind::resolve_ip_family(args.ipv4_only, args.ipv6_only, resolved_bind_ip)?;
Ok(RunConfig {
base_url: args.base_url.clone(),
meas_id: gen_meas_id(),
comments: args.comments.clone(),
download_bytes_per_req: args.download_bytes_per_req,
upload_bytes_per_req: args.upload_bytes_per_req,
concurrency: args.concurrency,
idle_latency_duration: Duration::from(args.idle_latency_duration),
download_duration: Duration::from(args.download_duration),
upload_duration: Duration::from(args.upload_duration),
probe_interval_ms: args.probe_interval_ms,
probe_timeout_ms: args.probe_timeout_ms,
user_agent: format!("cloudflare-speed-cli/{}", env!("CARGO_PKG_VERSION")),
experimental: args.experimental,
interface: args.interface.clone(),
source_ip: args.source.clone(),
resolved_bind_ip,
proxy: args.proxy.clone(),
certificate_path: args.certificate.clone(),
measure_dns: !skip,
measure_tls: !skip,
compare_ip_versions: args.compare_ip_versions,
traceroute: args.traceroute,
traceroute_max_hops: args.traceroute_max_hops,
ipv4_only: args.ipv4_only,
ipv6_only: args.ipv6_only,
udp_packets: args.udp_packets,
})
}
async fn run_test_engine(args: Cli, silent: bool) -> Result<()> {
let cfg = build_config(&args)?;
let network_info = crate::network::gather_network_info(&args);
let (evt_tx, mut evt_rx) = mpsc::channel::<TestEvent>(2048);
let (_, ctrl_rx) = mpsc::channel::<EngineControl>(16);
let engine = TestEngine::new(cfg);
let handle = tokio::spawn(async move { engine.run(evt_tx, ctrl_rx).await });
let run_start = std::time::Instant::now();
let mut dl_points: Vec<(f64, f64)> = Vec::new();
let mut ul_points: Vec<(f64, f64)> = Vec::new();
while let Some(ev) = evt_rx.recv().await {
if let TestEvent::ThroughputTick {
phase, bps_instant, ..
} = ev
{
if matches!(
phase,
crate::model::Phase::Download | crate::model::Phase::Upload
) {
let elapsed = run_start.elapsed().as_secs_f64();
let mbps = (bps_instant * 8.0) / 1_000_000.0;
match phase {
crate::model::Phase::Download => dl_points.push((elapsed, mbps)),
crate::model::Phase::Upload => ul_points.push((elapsed, mbps)),
_ => {}
}
}
}
}
let mut result = handle
.await
.context("test engine task failed")?
.context("speed test failed")?;
result.connection_quality = crate::quality::compute(&result, &dl_points, &ul_points);
let enriched = crate::network::enrich_result(&result, &network_info);
handle_exports(&args, &enriched)?;
if !silent {
println!("{}", serde_json::to_string_pretty(&enriched)?);
}
if args.auto_save {
if silent {
crate::storage::save_run(&enriched).context("failed to save run results")?;
} else if let Ok(p) = crate::storage::save_run(&enriched) {
eprintln!("{}", crate::event_format::format_saved_line(&p));
}
}
Ok(())
}
async fn run_text(args: Cli) -> Result<()> {
let cfg = build_config(&args)?;
let (evt_tx, mut evt_rx) = mpsc::channel::<TestEvent>(2048);
let (_, ctrl_rx) = mpsc::channel::<EngineControl>(16);
let engine = TestEngine::new(cfg);
let handle = tokio::spawn(async move { engine.run(evt_tx, ctrl_rx).await });
let run_start = std::time::Instant::now();
let mut idle_latency_samples: Vec<f64> = Vec::new();
let mut loaded_dl_latency_samples: Vec<f64> = Vec::new();
let mut loaded_ul_latency_samples: Vec<f64> = Vec::new();
let mut dl_points: Vec<(f64, f64)> = Vec::new();
let mut ul_points: Vec<(f64, f64)> = Vec::new();
while let Some(ev) = evt_rx.recv().await {
for line in crate::event_format::format_event_lines(&ev) {
eprintln!("{}", line);
}
match ev {
TestEvent::ThroughputTick {
phase, bps_instant, ..
} if matches!(
phase,
crate::model::Phase::Download | crate::model::Phase::Upload
) =>
{
let elapsed = run_start.elapsed().as_secs_f64();
let mbps = (bps_instant * 8.0) / 1_000_000.0;
match phase {
crate::model::Phase::Download => dl_points.push((elapsed, mbps)),
crate::model::Phase::Upload => ul_points.push((elapsed, mbps)),
_ => {}
}
}
TestEvent::LatencySample {
phase,
ok: true,
rtt_ms: Some(ms),
during,
} => match (phase, during) {
(crate::model::Phase::IdleLatency, None) => {
idle_latency_samples.push(ms);
}
(crate::model::Phase::Download, Some(crate::model::Phase::Download)) => {
loaded_dl_latency_samples.push(ms);
}
(crate::model::Phase::Upload, Some(crate::model::Phase::Upload)) => {
loaded_ul_latency_samples.push(ms);
}
_ => {}
},
_ => {}
}
}
let mut result = handle.await??;
result.connection_quality =
crate::quality::compute(&result, &dl_points, &ul_points);
let network_info = crate::network::gather_network_info(&args);
let enriched = crate::network::enrich_result(&result, &network_info);
handle_exports(&args, &enriched)?;
for line in crate::event_format::format_result_summary(
&enriched,
&dl_points,
&ul_points,
&idle_latency_samples,
&loaded_dl_latency_samples,
&loaded_ul_latency_samples,
) {
println!("{}", line);
}
if args.auto_save {
if let Ok(p) = crate::storage::save_run(&enriched) {
eprintln!("{}", crate::event_format::format_saved_line(&p));
}
}
Ok(())
}
fn handle_exports(args: &Cli, result: &crate::model::RunResult) -> Result<()> {
if let Some(p) = args.export_json.as_deref() {
crate::storage::export_json(p, result)?;
}
if let Some(p) = args.export_csv.as_deref() {
crate::storage::export_csv(p, result)?;
}
Ok(())
}