smirrors 0.1.0

Automatic mirror list updater for Linux distributions
Documentation
use anyhow::{Context, Result};
use reqwest::Client;
use std::time::Duration;

/// Create HTTP client with appropriate settings
pub fn create_client(timeout_secs: u64, user_agent: Option<&str>) -> Result<Client> {
    let mut builder = Client::builder()
        .timeout(Duration::from_secs(timeout_secs))
        .connect_timeout(Duration::from_secs(10))
        .pool_max_idle_per_host(10)
        .tcp_keepalive(Duration::from_secs(60));

    if let Some(ua) = user_agent {
        builder = builder.user_agent(ua);
    } else {
        builder = builder.user_agent(format!(
            "smirrors/{} (Linux mirror tester)",
            crate::VERSION
        ));
    }

    builder
        .build()
        .context("Failed to create HTTP client")
}

/// Parse size string (e.g., "1MB", "500KB") to bytes
pub fn parse_size(size_str: &str) -> Result<usize> {
    let size_str = size_str.trim().to_uppercase();

    let (number_part, unit) = if size_str.ends_with("GB") {
        (size_str.trim_end_matches("GB"), 1024 * 1024 * 1024)
    } else if size_str.ends_with("MB") {
        (size_str.trim_end_matches("MB"), 1024 * 1024)
    } else if size_str.ends_with("KB") {
        (size_str.trim_end_matches("KB"), 1024)
    } else if size_str.ends_with("B") {
        (size_str.trim_end_matches("B"), 1)
    } else {
        (size_str.as_str(), 1)
    };

    let number: f64 = number_part
        .trim()
        .parse()
        .context("Invalid size format")?;

    Ok((number * unit as f64) as usize)
}

/// Parse duration string (e.g., "1h", "30m", "10s", "100ms") to seconds
pub fn parse_duration(duration_str: &str) -> Result<u64> {
    let duration_str = duration_str.trim().to_lowercase();

    let (number_part, multiplier) = if duration_str.ends_with("ms") {
        // Handle milliseconds specially - return minimum 1 second for any ms value
        let num_str = duration_str.trim_end_matches("ms");
        let number: f64 = num_str
            .trim()
            .parse()
            .context("Invalid duration format")?;
        // For testing purposes, round to nearest second, with minimum of 1
        let secs = (number / 1000.0).max(0.0);
        return Ok(if secs < 1.0 && secs > 0.0 { 1 } else { secs.round() as u64 });
    } else if duration_str.ends_with("h") {
        (duration_str.trim_end_matches('h'), 3600)
    } else if duration_str.ends_with("m") {
        (duration_str.trim_end_matches('m'), 60)
    } else if duration_str.ends_with("s") {
        (duration_str.trim_end_matches('s'), 1)
    } else if duration_str.ends_with("d") {
        (duration_str.trim_end_matches('d'), 86400)
    } else {
        (duration_str.as_str(), 1)
    };

    let number: u64 = number_part
        .trim()
        .parse()
        .context("Invalid duration format")?;

    Ok(number * multiplier)
}

/// Format bytes as human-readable size
pub fn format_size(bytes: usize) -> String {
    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
    let mut size = bytes as f64;
    let mut unit_index = 0;

    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
        size /= 1024.0;
        unit_index += 1;
    }

    if unit_index == 0 {
        format!("{} {}", size as usize, UNITS[unit_index])
    } else {
        format!("{:.2} {}", size, UNITS[unit_index])
    }
}

/// Format duration as human-readable string
pub fn format_duration(secs: u64) -> String {
    if secs < 60 {
        format!("{}s", secs)
    } else if secs < 3600 {
        format!("{}m", secs / 60)
    } else if secs < 86400 {
        format!("{}h", secs / 3600)
    } else {
        format!("{}d", secs / 86400)
    }
}

/// Extract hostname from URL
pub fn extract_hostname(url: &str) -> Result<String> {
    let parsed = url::Url::parse(url)?;
    parsed
        .host_str()
        .map(|s| s.to_string())
        .context("URL has no hostname")
}

/// Check if URL is reachable with HEAD request
pub async fn check_url_reachable(url: &url::Url) -> Result<bool> {
    let client = create_client(10, None)?;
    match client.head(url.as_str()).send().await {
        Ok(response) => Ok(response.status().is_success()),
        Err(e) => Err(anyhow::anyhow!("Failed to reach URL: {}", e)),
    }
}

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

    #[test]
    fn test_parse_size() {
        assert_eq!(parse_size("1MB").unwrap(), 1024 * 1024);
        assert_eq!(parse_size("500KB").unwrap(), 500 * 1024);
        assert_eq!(parse_size("1GB").unwrap(), 1024 * 1024 * 1024);
        assert_eq!(parse_size("100B").unwrap(), 100);
        assert_eq!(parse_size("2.5MB").unwrap(), (2.5 * 1024.0 * 1024.0) as usize);
    }

    #[test]
    fn test_parse_duration() {
        assert_eq!(parse_duration("1h").unwrap(), 3600);
        assert_eq!(parse_duration("30m").unwrap(), 1800);
        assert_eq!(parse_duration("45s").unwrap(), 45);
        assert_eq!(parse_duration("2d").unwrap(), 172800);
    }

    #[test]
    fn test_format_size() {
        assert_eq!(format_size(1024), "1.00 KB");
        assert_eq!(format_size(1024 * 1024), "1.00 MB");
        assert_eq!(format_size(500), "500 B");
    }

    #[test]
    fn test_format_duration() {
        assert_eq!(format_duration(45), "45s");
        assert_eq!(format_duration(120), "2m");
        assert_eq!(format_duration(3600), "1h");
        assert_eq!(format_duration(86400), "1d");
    }

    #[test]
    fn test_extract_hostname() {
        assert_eq!(
            extract_hostname("https://example.com/path").unwrap(),
            "example.com"
        );
        assert_eq!(
            extract_hostname("http://mirror.site.org:8080/").unwrap(),
            "mirror.site.org"
        );
    }
}