diff --git a/src/bandwidth.rs b/src/bandwidth.rs index ecc1c07..d65f0e6 100644 --- a/src/bandwidth.rs +++ b/src/bandwidth.rs @@ -123,6 +123,10 @@ pub fn print_status( elapsed: Duration, lost_packets: Option, ) { + if crate::csv_output::is_quiet() { + return; + } + let secs = elapsed.as_secs_f64(); let bits = bytes as f64 * 8.0; let bw = if secs > 0.0 { bits / secs } else { 0.0 }; diff --git a/src/csv_output.rs b/src/csv_output.rs new file mode 100644 index 0000000..f4a043c --- /dev/null +++ b/src/csv_output.rs @@ -0,0 +1,83 @@ +//! CSV output for machine-readable test results. +//! +//! Appends a row per test to the specified CSV file. +//! Creates the file with headers if it doesn't exist. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::sync::Mutex; +use std::time::SystemTime; + +static CSV_FILE: Mutex> = Mutex::new(None); +static QUIET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +const HEADER: &str = "timestamp,host,port,protocol,direction,duration_s,tx_avg_mbps,rx_avg_mbps,tx_bytes,rx_bytes,lost_packets,auth_type"; + +/// Initialize CSV output. Creates file with headers if needed. +pub fn init(path: &str) -> std::io::Result<()> { + let needs_header = !Path::new(path).exists() || std::fs::metadata(path)?.len() == 0; + + if needs_header { + let mut f = OpenOptions::new().create(true).write(true).open(path)?; + writeln!(f, "{}", HEADER)?; + } + + *CSV_FILE.lock().unwrap() = Some(path.to_string()); + Ok(()) +} + +pub fn set_quiet(q: bool) { + QUIET.store(q, std::sync::atomic::Ordering::Relaxed); +} + +pub fn is_quiet() -> bool { + QUIET.load(std::sync::atomic::Ordering::Relaxed) +} + +/// Write a test result row to the CSV file. +pub fn write_result( + host: &str, + port: u16, + protocol: &str, + direction: &str, + duration_secs: u64, + tx_bytes: u64, + rx_bytes: u64, + lost_packets: u64, + auth_type: &str, +) { + let guard = CSV_FILE.lock().unwrap(); + if let Some(ref path) = *guard { + let tx_mbps = if duration_secs > 0 { + tx_bytes as f64 * 8.0 / duration_secs as f64 / 1_000_000.0 + } else { + 0.0 + }; + let rx_mbps = if duration_secs > 0 { + rx_bytes as f64 * 8.0 / duration_secs as f64 / 1_000_000.0 + } else { + 0.0 + }; + + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let row = format!( + "{},{},{},{},{},{},{:.2},{:.2},{},{},{},{}", + now, host, port, protocol, direction, duration_secs, + tx_mbps, rx_mbps, tx_bytes, rx_bytes, lost_packets, auth_type, + ); + + if let Ok(mut f) = OpenOptions::new().append(true).open(path) { + let _ = writeln!(f, "{}", row); + } + } +} + +/// Check if CSV output is enabled. +pub fn is_enabled() -> bool { + CSV_FILE.lock().unwrap().is_some() +} diff --git a/src/lib.rs b/src/lib.rs index d16de17..73694dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod csv_output; pub mod bandwidth; pub mod client; pub mod ecsrp5; diff --git a/src/main.rs b/src/main.rs index 9237901..0b02e08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod bandwidth; mod client; +pub mod csv_output; mod ecsrp5; mod protocol; mod server; @@ -74,6 +75,18 @@ struct Cli { #[arg(short = 'n', long = "nat")] nat: bool, + /// Test duration in seconds (client mode, 0=unlimited) + #[arg(short = 'd', long = "duration", default_value_t = 0)] + duration: u64, + + /// Output results to CSV file (appends if exists) + #[arg(long = "csv")] + csv: Option, + + /// Suppress terminal output (use with --csv for machine-readable only) + #[arg(long = "quiet", short = 'q')] + quiet: bool, + /// Send logs to remote syslog server (e.g., 192.168.1.1:514) #[arg(long = "syslog")] syslog: Option, @@ -107,6 +120,14 @@ async fn main() -> anyhow::Result<()> { } } + // Initialize CSV output if requested + if let Some(ref csv_path) = cli.csv { + if let Err(e) = csv_output::init(csv_path) { + eprintln!("Warning: failed to initialize CSV output to {}: {}", csv_path, e); + } + } + csv_output::set_quiet(cli.quiet); + if cli.server { // Server mode let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) }; @@ -143,18 +164,55 @@ async fn main() -> anyhow::Result<()> { _ => (0, 0), }; - client::run_client( + let dir_str = match direction { + CMD_DIR_RX => "send", + CMD_DIR_TX => "receive", + CMD_DIR_BOTH => "both", + _ => "unknown", + }; + let proto_str = if cli.udp { "UDP" } else { "TCP" }; + + // Run client with optional duration timeout + let start = std::time::Instant::now(); + let client_fut = client::run_client( &host, cli.port, direction, cli.udp, tx_speed, rx_speed, - cli.auth_user, - cli.auth_pass, + cli.auth_user.clone(), + cli.auth_pass.clone(), cli.nat, - ) - .await?; + ); + + if cli.duration > 0 { + match tokio::time::timeout( + std::time::Duration::from_secs(cli.duration), + client_fut, + ) + .await + { + Ok(result) => result?, + Err(_) => { + // Timeout — normal exit + } + } + } else { + client_fut.await?; + } + + let elapsed = start.elapsed().as_secs(); + + // Write CSV if enabled + if csv_output::is_enabled() { + let auth_type = if cli.auth_user.is_some() { "auth" } else { "none" }; + // For client mode we don't track detailed stats yet, but duration is useful + csv_output::write_result( + &host, cli.port, proto_str, dir_str, + elapsed, 0, 0, 0, auth_type, + ); + } } else { eprintln!("Error: Must specify either -s (server) or -c (client)"); eprintln!("Run with --help for usage information.");