smirrors 0.1.0

Automatic mirror list updater for Linux distributions
Documentation
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::HashMap;
use std::time::Duration;
use url::Url;

/// Represents a package repository mirror
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mirror {
    pub url: Url,
    pub country: Option<String>,
    pub speed: Option<f64>,
    pub latency: Option<Duration>,
    pub score: Option<f64>,
    #[serde(with = "chrono::serde::ts_seconds_option")]
    pub last_tested: Option<chrono::DateTime<chrono::Utc>>,
    pub is_static: bool,
    #[serde(default)]
    pub metadata: HashMap<String, String>,
}

/// Result of testing a mirror
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
    pub mirror: Mirror,
    pub success: bool,
    pub speed: Option<f64>,
    pub latency: Option<Duration>,
    pub score: Option<f64>,
    pub error: Option<String>,
    #[serde(with = "chrono::serde::ts_seconds")]
    pub tested_at: chrono::DateTime<chrono::Utc>,
}

impl Mirror {
    /// Create a new mirror with default values
    pub fn new(url: Url) -> Self {
        Self {
            url,
            country: None,
            speed: None,
            latency: None,
            score: None,
            last_tested: None,
            is_static: false,
            metadata: HashMap::new(),
        }
    }

    /// Create a static mirror (won't be removed during updates)
    pub fn new_static(url: Url) -> Self {
        Self {
            url,
            country: None,
            speed: None,
            latency: None,
            score: None,
            last_tested: None,
            is_static: true,
            metadata: HashMap::new(),
        }
    }

    /// Calculate weighted score based on speed and latency
    ///
    /// # Algorithm
    /// 1. Normalize speed to 0-1 scale (0-100 MB/s range)
    /// 2. Normalize latency to 0-1 scale (0-1000ms range, inverted)
    /// 3. Apply weighted scoring: score = (speed_norm × speed_weight) + (latency_norm × latency_weight)
    pub fn calculate_score(&mut self, speed_weight: f64, latency_weight: f64) {
        if let (Some(speed), Some(latency)) = (self.speed, self.latency) {
            // Normalize speed (0-100 MB/s range)
            let speed_score = (speed / 100.0).min(1.0);

            // Normalize latency (inverse, 0-1000ms range)
            let latency_ms = latency.as_millis() as f64;
            let latency_score = (1.0 - (latency_ms / 1000.0)).max(0.0);

            // Calculate weighted score
            self.score = Some(speed_score * speed_weight + latency_score * latency_weight);
        }
    }

    /// Update mirror statistics from test result
    pub fn update_from_test(&mut self, speed: f64, latency: Duration, speed_weight: f64, latency_weight: f64) {
        self.speed = Some(speed);
        self.latency = Some(latency);
        self.last_tested = Some(chrono::Utc::now());
        self.calculate_score(speed_weight, latency_weight);
    }

    /// Check if mirror needs retesting (older than threshold)
    pub fn needs_retest(&self, threshold_secs: u64) -> bool {
        match self.last_tested {
            None => true,
            Some(last_test) => {
                let now = chrono::Utc::now();
                let elapsed = now.signed_duration_since(last_test);
                elapsed.num_seconds() as u64 > threshold_secs
            }
        }
    }

    /// Get mirror hostname for display
    pub fn hostname(&self) -> String {
        self.url
            .host_str()
            .unwrap_or("unknown")
            .to_string()
    }

    /// Get mirror domain (hostname without subdomain)
    pub fn domain(&self) -> String {
        let hostname = self.hostname();
        let parts: Vec<&str> = hostname.split('.').collect();

        if parts.len() >= 2 {
            format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1])
        } else {
            hostname
        }
    }

    /// Format speed as human-readable string
    pub fn format_speed(&self) -> String {
        match self.speed {
            Some(speed) => {
                if speed < 1.0 {
                    format!("{:.2} KB/s", speed * 1024.0)
                } else {
                    format!("{:.2} MB/s", speed)
                }
            }
            None => "N/A".to_string(),
        }
    }

    /// Format latency as human-readable string
    pub fn format_latency(&self) -> String {
        match self.latency {
            Some(latency) => format!("{} ms", latency.as_millis()),
            None => "N/A".to_string(),
        }
    }

    /// Format score as percentage
    pub fn format_score(&self) -> String {
        match self.score {
            Some(score) => format!("{:.1}%", score * 100.0),
            None => "N/A".to_string(),
        }
    }

    /// Get URL as string
    pub fn url_string(&self) -> String {
        self.url.to_string()
    }
}

impl TestResult {
    /// Create a successful test result
    pub fn success(
        mirror: Mirror,
        speed: f64,
        latency: Duration,
        score: f64,
    ) -> Self {
        Self {
            mirror,
            success: true,
            speed: Some(speed),
            latency: Some(latency),
            score: Some(score),
            error: None,
            tested_at: chrono::Utc::now(),
        }
    }

    /// Create a failed test result
    pub fn failure(mirror: Mirror, error: String) -> Self {
        Self {
            mirror,
            success: false,
            speed: None,
            latency: None,
            score: None,
            error: Some(error),
            tested_at: chrono::Utc::now(),
        }
    }

    /// Convert test result to updated mirror
    pub fn into_mirror(self) -> Mirror {
        self.mirror
    }
}

// Implement ordering for Mirror based on score
impl PartialEq for Mirror {
    fn eq(&self, other: &Self) -> bool {
        self.url == other.url
    }
}

impl Eq for Mirror {}

impl PartialOrd for Mirror {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Mirror {
    fn cmp(&self, other: &Self) -> Ordering {
        // First compare by score (descending - higher is better)
        match (self.score, other.score) {
            (Some(a), Some(b)) => {
                // Reverse for descending order
                b.partial_cmp(&a).unwrap_or(Ordering::Equal)
            }
            (Some(_), None) => Ordering::Less,
            (None, Some(_)) => Ordering::Greater,
            (None, None) => {
                // If no scores, compare by URL for stable sorting
                self.url.as_str().cmp(other.url.as_str())
            }
        }
    }
}

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

    #[test]
    fn test_mirror_creation() {
        let url = Url::parse("https://mirror.example.com/repo").unwrap();
        let mirror = Mirror::new(url.clone());

        assert_eq!(mirror.url, url);
        assert_eq!(mirror.is_static, false);
        assert!(mirror.score.is_none());
    }

    #[test]
    fn test_static_mirror_creation() {
        let url = Url::parse("https://mirror.example.com/repo").unwrap();
        let mirror = Mirror::new_static(url);

        assert_eq!(mirror.is_static, true);
    }

    #[test]
    fn test_score_calculation() {
        let url = Url::parse("https://mirror.example.com/repo").unwrap();
        let mut mirror = Mirror::new(url);

        mirror.speed = Some(50.0);
        mirror.latency = Some(Duration::from_millis(100));
        mirror.calculate_score(0.7, 0.3);

        assert!(mirror.score.is_some());
        let score = mirror.score.unwrap();

        // Speed score: 50/100 = 0.5
        // Latency score: 1 - (100/1000) = 0.9
        // Total: 0.5 * 0.7 + 0.9 * 0.3 = 0.35 + 0.27 = 0.62
        assert!((score - 0.62).abs() < 0.01);
    }

    #[test]
    fn test_mirror_ordering() {
        let url1 = Url::parse("https://mirror1.example.com/repo").unwrap();
        let url2 = Url::parse("https://mirror2.example.com/repo").unwrap();

        let mut mirror1 = Mirror::new(url1);
        let mut mirror2 = Mirror::new(url2);

        mirror1.score = Some(0.8);
        mirror2.score = Some(0.6);

        assert!(mirror1 < mirror2);
    }

    #[test]
    fn test_hostname_extraction() {
        let url = Url::parse("https://mirrors.kernel.org/debian/").unwrap();
        let mirror = Mirror::new(url);

        assert_eq!(mirror.hostname(), "mirrors.kernel.org");
        assert_eq!(mirror.domain(), "kernel.org");
    }

    #[test]
    fn test_needs_retest() {
        let url = Url::parse("https://mirror.example.com/repo").unwrap();
        let mut mirror = Mirror::new(url);

        assert!(mirror.needs_retest(3600));

        mirror.last_tested = Some(chrono::Utc::now());
        assert!(!mirror.needs_retest(3600));
    }
}