cloudflare-speed-cli 1.0.2

CLI tool for Cloudflare speed testing with TUI interface
use crate::model::RunResult;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};

/// Get the base directory for storing application data.
fn base_dir() -> PathBuf {
    dirs::data_local_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join("cloudflare-speed-cli")
}

/// Get the directory for storing test run results.
fn runs_dir() -> PathBuf {
    base_dir().join("runs")
}

/// Ensure the necessary directories exist for storing data.
pub fn ensure_dirs() -> Result<()> {
    std::fs::create_dir_all(runs_dir()).context("create runs dir")?;
    Ok(())
}

pub fn save_run(result: &RunResult) -> Result<PathBuf> {
    ensure_dirs()?;
    let path = get_run_path(result)?;
    let data = serde_json::to_vec_pretty(result)?;
    std::fs::write(&path, data).context("write run json")?;
    Ok(path)
}

pub fn get_run_path(result: &RunResult) -> Result<PathBuf> {
    let ts = &result.timestamp_utc;
    let safe_ts = ts.replace(':', "-").replace('T', "_");
    Ok(runs_dir().join(format!("run-{safe_ts}-{}.json", result.meas_id)))
}

pub fn delete_run(result: &RunResult) -> Result<()> {
    let path = get_run_path(result)?;
    if path.exists() {
        std::fs::remove_file(&path).context("delete run file")?;
    }
    Ok(())
}

pub fn export_json(path: &Path, result: &RunResult) -> Result<()> {
    // Create parent directories if they don't exist
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).context("create export directory")?;
    }
    let data = serde_json::to_vec_pretty(result)?;
    std::fs::write(path, data).context("write export json")?;
    Ok(())
}

pub fn export_csv(path: &Path, result: &RunResult) -> Result<()> {
    // Create parent directories if they don't exist
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).context("create export directory")?;
    }
    let mut out = String::new();
    // Header row with all fields including diagnostics
    out.push_str("timestamp_utc,base_url,meas_id,comments,server,download_mbps,upload_mbps,idle_mean_ms,idle_median_ms,idle_p25_ms,idle_p75_ms,idle_loss,dl_loaded_mean_ms,dl_loaded_median_ms,dl_loaded_p25_ms,dl_loaded_p75_ms,dl_loaded_loss,ul_loaded_mean_ms,ul_loaded_median_ms,ul_loaded_p25_ms,ul_loaded_p75_ms,ul_loaded_loss,ip,colo,asn,as_org,interface_name,network_name,is_wireless,interface_mac,local_ipv4,local_ipv6,external_ipv4,external_ipv6,dns_resolution_ms,dns_ipv4_count,dns_ipv6_count,dns_servers,tls_handshake_ms,tls_protocol,tls_cipher,ipv4_download_mbps,ipv4_upload_mbps,ipv4_latency_ms,ipv6_download_mbps,ipv6_upload_mbps,ipv6_latency_ms,traceroute_hops,bufferbloat_grade,bufferbloat_ms,stability_grade,stability_cv_pct,stability_cv_download_pct,stability_cv_upload_pct\n");

    // Extract diagnostic values
    let dns_resolution_ms = result.dns.as_ref().map(|d| d.resolution_time_ms);
    let dns_ipv4_count = result.dns.as_ref().map(|d| d.ipv4_count);
    let dns_ipv6_count = result.dns.as_ref().map(|d| d.ipv6_count);
    let dns_servers = result
        .dns
        .as_ref()
        .map(|d| d.dns_servers.join("; "))
        .unwrap_or_default();
    let tls_handshake_ms = result.tls.as_ref().map(|t| t.handshake_time_ms);
    let tls_protocol = result.tls.as_ref().and_then(|t| t.protocol_version.clone());
    let tls_cipher = result.tls.as_ref().and_then(|t| t.cipher_suite.clone());

    // IPv4 results
    let ipv4_download = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv4_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.download_mbps);
    let ipv4_upload = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv4_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.upload_mbps);
    let ipv4_latency = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv4_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.latency_ms);

    // IPv6 results
    let ipv6_download = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv6_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.download_mbps);
    let ipv6_upload = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv6_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.upload_mbps);
    let ipv6_latency = result
        .ip_comparison
        .as_ref()
        .and_then(|c| c.ipv6_result.as_ref())
        .filter(|r| r.available)
        .map(|r| r.latency_ms);

    // Traceroute hop count
    let traceroute_hops = result.traceroute.as_ref().map(|t| t.hops.len());

    // Connection quality fields
    let (cq_bloat_grade, cq_bloat_ms, cq_stab_grade, cq_stab_cv, cq_stab_cv_dl, cq_stab_cv_ul) =
        match result.connection_quality.as_ref() {
            Some(cq) => (
                cq.bufferbloat_grade.clone(),
                cq.bufferbloat_ms.map(|v| format!("{:.3}", v)).unwrap_or_default(),
                cq.stability_grade.clone(),
                cq.stability_cv_pct.map(|v| format!("{:.3}", v)).unwrap_or_default(),
                cq.stability_cv_download_pct.map(|v| format!("{:.3}", v)).unwrap_or_default(),
                cq.stability_cv_upload_pct.map(|v| format!("{:.3}", v)).unwrap_or_default(),
            ),
            // Legacy runs (None) emit empty CSV cells. New runs with one half
            // uncomputable emit the GRADE_UNAVAILABLE sentinel ("-") in the grade
            // column and an empty value column. Downstream parsers should treat
            // both empty and "-" as "not available" for the grade columns.
            None => (String::new(), String::new(), String::new(), String::new(), String::new(), String::new()),
        };

    out.push_str(&format!(
        "{},{},{},{},{},{:.3},{:.3},{:.3},{:.3},{:.3},{:.3},{:.6},{:.3},{:.3},{:.3},{:.3},{:.6},{:.3},{:.3},{:.3},{:.3},{:.6},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
        csv_escape(&result.timestamp_utc),
        csv_escape(&result.base_url),
        csv_escape(&result.meas_id),
        csv_escape(result.comments.as_deref().unwrap_or("")),
        csv_escape(result.server.as_deref().unwrap_or("")),
        result.download.mbps,
        result.upload.mbps,
        result.idle_latency.mean_ms.unwrap_or(f64::NAN),
        result.idle_latency.median_ms.unwrap_or(f64::NAN),
        result.idle_latency.p25_ms.unwrap_or(f64::NAN),
        result.idle_latency.p75_ms.unwrap_or(f64::NAN),
        result.idle_latency.loss,
        result.loaded_latency_download.mean_ms.unwrap_or(f64::NAN),
        result.loaded_latency_download.median_ms.unwrap_or(f64::NAN),
        result.loaded_latency_download.p25_ms.unwrap_or(f64::NAN),
        result.loaded_latency_download.p75_ms.unwrap_or(f64::NAN),
        result.loaded_latency_download.loss,
        result.loaded_latency_upload.mean_ms.unwrap_or(f64::NAN),
        result.loaded_latency_upload.median_ms.unwrap_or(f64::NAN),
        result.loaded_latency_upload.p25_ms.unwrap_or(f64::NAN),
        result.loaded_latency_upload.p75_ms.unwrap_or(f64::NAN),
        result.loaded_latency_upload.loss,
        csv_escape(result.ip.as_deref().unwrap_or("")),
        csv_escape(result.colo.as_deref().unwrap_or("")),
        csv_escape(result.asn.as_deref().unwrap_or("")),
        csv_escape(result.as_org.as_deref().unwrap_or("")),
        csv_escape(result.interface_name.as_deref().unwrap_or("")),
        csv_escape(result.network_name.as_deref().unwrap_or("")),
        result.is_wireless.map(|w| if w { "true" } else { "false" }).unwrap_or(""),
        csv_escape(result.interface_mac.as_deref().unwrap_or("")),
        csv_escape(result.local_ipv4.as_deref().unwrap_or("")),
        csv_escape(result.local_ipv6.as_deref().unwrap_or("")),
        csv_escape(result.external_ipv4.as_deref().unwrap_or("")),
        csv_escape(result.external_ipv6.as_deref().unwrap_or("")),
        // Diagnostic fields
        dns_resolution_ms.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        dns_ipv4_count.map(|v| v.to_string()).unwrap_or_default(),
        dns_ipv6_count.map(|v| v.to_string()).unwrap_or_default(),
        csv_escape(&dns_servers),
        tls_handshake_ms.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        csv_escape(tls_protocol.as_deref().unwrap_or("")),
        csv_escape(tls_cipher.as_deref().unwrap_or("")),
        ipv4_download.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        ipv4_upload.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        ipv4_latency.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        ipv6_download.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        ipv6_upload.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        ipv6_latency.map(|v| format!("{:.3}", v)).unwrap_or_default(),
        traceroute_hops.map(|v| v.to_string()).unwrap_or_default(),
        csv_escape(&cq_bloat_grade),
        cq_bloat_ms,
        csv_escape(&cq_stab_grade),
        cq_stab_cv,
        cq_stab_cv_dl,
        cq_stab_cv_ul,
    ));
    std::fs::write(path, out).context("write export csv")?;
    Ok(())
}

/// Escape a string for CSV format (handles commas, quotes, and newlines).
fn csv_escape(s: &str) -> String {
    if s.contains(',') || s.contains('"') || s.contains('\n') {
        format!("\"{}\"", s.replace('"', "\"\""))
    } else {
        s.to_string()
    }
}

pub fn load_recent(limit: usize) -> Result<Vec<RunResult>> {
    ensure_dirs()?;
    let dir = runs_dir();
    // Sort by filename: run files are named `run-{timestamp}-{meas_id}.json` where the
    // timestamp is the run's own ISO timestamp. Lex order on filename matches run order,
    // and is stable against file rewrites (e.g. editing a comment) which would otherwise
    // push an old run to the top if we sorted by mtime.
    let mut entries: Vec<PathBuf> = Vec::new();
    for e in std::fs::read_dir(&dir).context("read runs dir")? {
        let e = e?;
        let p = e.path();
        if p.extension().and_then(|e| e.to_str()) != Some("json") {
            continue;
        }
        entries.push(p);
    }
    entries.sort_by(|a, b| {
        let an = a.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let bn = b.file_name().and_then(|n| n.to_str()).unwrap_or("");
        bn.cmp(an) // descending: newest first
    });

    let mut out = Vec::new();
    for p in entries.into_iter().take(limit) {
        let data = std::fs::read(&p).with_context(|| format!("read {}", p.display()))?;
        let r: RunResult =
            serde_json::from_slice(&data).with_context(|| format!("parse {}", p.display()))?;
        out.push(r);
    }
    Ok(out)
}