speedtest-tui 0.1.1

A terminal-based network speed test tool with real-time gauges and graphs
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::process::Command;
use std::time::Instant;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceHop {
    pub hop_number: u8,
    pub address: Option<String>,
    pub hostname: Option<String>,
    pub latency_ms: f64,
}

/// Perform a traceroute to the given host
/// Uses system traceroute/tracert command
pub async fn trace_route(host: &str) -> Result<Vec<TraceHop>> {
    let output = tokio::task::spawn_blocking({
        let host = host.to_string();
        move || {
            #[cfg(target_os = "windows")]
            let cmd = Command::new("tracert")
                .args(["-d", "-h", "30", &host])
                .output();

            #[cfg(not(target_os = "windows"))]
            let cmd = Command::new("traceroute")
                .args(["-n", "-m", "30", "-q", "1", &host])
                .output();

            cmd
        }
    })
    .await??;

    if !output.status.success() {
        anyhow::bail!(
            "Traceroute failed: {}",
            String::from_utf8_lossy(&output.stderr)
        );
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    parse_traceroute_output(&stdout)
}

/// Parse traceroute output into structured hops
fn parse_traceroute_output(output: &str) -> Result<Vec<TraceHop>> {
    let mut hops = Vec::new();

    for line in output.lines() {
        let line = line.trim();

        // Skip header lines
        if line.is_empty()
            || line.starts_with("traceroute")
            || line.starts_with("Tracing")
            || line.starts_with("over a maximum")
        {
            continue;
        }

        // Parse hop line
        // Format varies by OS:
        // Linux: "1  192.168.1.1  0.5 ms"
        // macOS: "1  192.168.1.1  0.5 ms"
        // Windows: "1    <1 ms    <1 ms    <1 ms  192.168.1.1"

        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.is_empty() {
            continue;
        }

        // Try to parse hop number
        let hop_number: u8 = match parts[0].parse() {
            Ok(n) => n,
            Err(_) => continue,
        };

        // Check for timeout/unreachable
        if line.contains('*') || line.contains("Request timed out") {
            hops.push(TraceHop {
                hop_number,
                address: None,
                hostname: None,
                latency_ms: 0.0,
            });
            continue;
        }

        // Find IP address and latency
        let mut address: Option<String> = None;
        let mut latency: f64 = 0.0;

        for (i, part) in parts.iter().enumerate() {
            // Check if it's an IP address
            if is_ip_address(part) {
                address = Some(part.to_string());
            }

            // Check if it's a latency value
            if part.ends_with("ms") || (i + 1 < parts.len() && parts[i + 1] == "ms") {
                let latency_str = part.trim_end_matches("ms");
                if let Ok(l) = latency_str.parse::<f64>() {
                    latency = l;
                }
            }

            // Handle "<1 ms" format
            if *part == "<1" && i + 1 < parts.len() && parts[i + 1] == "ms" {
                latency = 0.5;
            }
        }

        if address.is_some() {
            hops.push(TraceHop {
                hop_number,
                address,
                hostname: None,
                latency_ms: latency,
            });
        }
    }

    Ok(hops)
}

/// Check if a string is an IP address
fn is_ip_address(s: &str) -> bool {
    // Simple check for IPv4
    if s.split('.').count() == 4 {
        return s.split('.').all(|part| part.parse::<u8>().is_ok());
    }

    // Simple check for IPv6
    if s.contains(':') && !s.contains("ms") {
        return true;
    }

    false
}

#[allow(dead_code)]
pub async fn trace_route_http(host: &str) -> Result<Vec<TraceHop>> {
    let url = if host.starts_with("http") {
        host.to_string()
    } else {
        format!("https://{}", host)
    };

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build()?;

    let mut hops = Vec::new();

    // Just measure the final hop (HTTP-based "traceroute")
    let start = Instant::now();
    let _ = client.head(&url).send().await?;
    let elapsed = start.elapsed();

    hops.push(TraceHop {
        hop_number: 1,
        address: Some(host.to_string()),
        hostname: Some(host.to_string()),
        latency_ms: elapsed.as_secs_f64() * 1000.0,
    });

    Ok(hops)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_ip_address() {
        assert!(is_ip_address("192.168.1.1"));
        assert!(is_ip_address("8.8.8.8"));
        assert!(!is_ip_address("google.com"));
        assert!(!is_ip_address("10ms"));
    }

    #[test]
    fn test_parse_traceroute_output() {
        let output = r#"
traceroute to google.com (142.250.185.46), 30 hops max, 60 byte packets
 1  192.168.1.1  0.5 ms
 2  10.0.0.1  5.2 ms
 3  * * *
 4  142.250.185.46  15.3 ms
"#;

        let hops = parse_traceroute_output(output).unwrap();
        assert_eq!(hops.len(), 4);
        assert_eq!(hops[0].hop_number, 1);
        assert_eq!(hops[0].address, Some("192.168.1.1".to_string()));
    }
}