howfastly 2026.813.0

measure your connection speed to the fastly network
mod output;
mod run;

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use clap::{Parser, ValueEnum};
use howfastly::types::{self, SizePlan, TestConfig};

#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum OutputFormat {
    Human,
    Json,
    Csv,
}

#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum PayloadSize {
    #[value(name = "100k")]
    K100,
    #[value(name = "1m")]
    M1,
    #[value(name = "10m")]
    M10,
    #[value(name = "25m")]
    M25,
    #[value(name = "100m")]
    M100,
}

impl PayloadSize {
    pub fn bytes(self) -> u64 {
        match self {
            Self::K100 => 100_000,
            Self::M1 => 1_000_000,
            Self::M10 => 10_000_000,
            Self::M25 => 25_000_000,
            Self::M100 => 100_000_000,
        }
    }
}

#[derive(Debug, Parser)]
#[command(
    name = "howfastly",
    about = concat!(
        "HowFastly ",
        env!("CARGO_PKG_VERSION"),
        "\nHow fast is your connection to the Fastly network?"
    )
)]
pub struct Args {
    #[arg(
        long,
        short = 'U',
        env = "HOWFASTLY_URL",
        default_value = "https://speed.edgecompute.app"
    )]
    pub url: String,

    // flat override for the per size iteration plan
    #[arg(long, short)]
    pub nr_tests: Option<usize>,

    #[arg(long, short = 'l', default_value_t = types::LATENCY_SAMPLES)]
    pub nr_latency_tests: usize,

    #[arg(long, short, value_enum, default_value = "100m")]
    pub max_payload_size: PayloadSize,

    #[arg(long, short, conflicts_with = "upload_only")]
    pub download_only: bool,

    #[arg(long, short = 'u')]
    pub upload_only: bool,

    // bare flag binds the family's unspecified address
    // the kernel then picks the default outbound address at connect time
    #[arg(long, value_name = "ADDR", num_args = 0..=1, default_missing_value = "0.0.0.0", conflicts_with = "ipv6")]
    pub ipv4: Option<Ipv4Addr>,

    #[arg(long, value_name = "ADDR", num_args = 0..=1, default_missing_value = "::")]
    pub ipv6: Option<Ipv6Addr>,

    #[arg(long, short, value_enum, default_value = "human")]
    pub format: OutputFormat,

    #[arg(long, short)]
    pub verbose: bool,
}

impl Args {
    pub fn local_addr(&self) -> Option<IpAddr> {
        self.ipv4.map(IpAddr::V4).or(self.ipv6.map(IpAddr::V6))
    }

    pub fn config(&self) -> TestConfig {
        let cap = self.max_payload_size.bytes();
        let keep = |plan: &[SizePlan]| {
            plan.iter()
                .copied()
                .filter(|p| p.bytes <= cap)
                .map(|p| SizePlan {
                    iterations: self.nr_tests.unwrap_or(p.iterations),
                    ..p
                })
                .collect()
        };
        TestConfig {
            latency_samples: self.nr_latency_tests,
            download: keep(&types::DOWNLOAD_PLAN),
            upload: keep(&types::UPLOAD_PLAN),
            time_budget_secs: types::TIME_BUDGET_SECS,
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let args = Args::parse();
    let results = run::run(&args).await?;
    print!("{}", output::render(&results, args.format)?);
    Ok(())
}

#[cfg(test)]
mod tests {
    use clap::CommandFactory;

    use super::*;

    // catches duplicate shorts and malformed arg definitions
    #[test]
    fn cli() {
        Args::command().debug_assert();
    }

    #[test]
    fn shorts() {
        let a = Args::try_parse_from([
            "howfastly",
            "-U",
            "http://x",
            "-n",
            "3",
            "-l",
            "5",
            "-m",
            "1m",
            "-d",
            "-f",
            "json",
        ])
        .unwrap();
        assert_eq!(a.url, "http://x");
        assert_eq!(a.nr_tests, Some(3));
        assert_eq!(a.nr_latency_tests, 5);
        assert!(matches!(a.max_payload_size, PayloadSize::M1));
        assert!(a.download_only);
        assert!(matches!(a.format, OutputFormat::Json));

        let a = Args::try_parse_from(["howfastly", "-u"]).unwrap();
        assert!(a.upload_only);
        assert!(Args::try_parse_from(["howfastly", "-d", "-u"]).is_err());
        assert!(Args::try_parse_from(["howfastly", "-f", "json-pretty"]).is_err());
    }

    #[test]
    fn ip_binding() {
        let a = Args::try_parse_from(["howfastly"]).unwrap();
        assert_eq!(a.local_addr(), None);

        let a = Args::try_parse_from(["howfastly", "--ipv4"]).unwrap();
        assert_eq!(a.local_addr(), Some(IpAddr::V4(Ipv4Addr::UNSPECIFIED)));
        let a = Args::try_parse_from(["howfastly", "--ipv4", "192.0.2.7"]).unwrap();
        assert_eq!(
            a.local_addr(),
            Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 7)))
        );

        let a = Args::try_parse_from(["howfastly", "--ipv6"]).unwrap();
        assert_eq!(a.local_addr(), Some(IpAddr::V6(Ipv6Addr::UNSPECIFIED)));
        let a = Args::try_parse_from(["howfastly", "--ipv6", "2001:db8::1"]).unwrap();
        assert_eq!(a.local_addr(), Some("2001:db8::1".parse().unwrap()));

        assert!(Args::try_parse_from(["howfastly", "--ipv4", "--ipv6"]).is_err());
        assert!(Args::try_parse_from(["howfastly", "--ipv4", "::1"]).is_err());
        assert!(Args::try_parse_from(["howfastly", "--ipv6", "127.0.0.1"]).is_err());
    }
}