lychee 0.10.3

A glorious link checker
use std::collections::{HashMap, HashSet};

use lychee_lib::{CacheStatus, InputSource, Response, ResponseBody, Status};
use serde::Serialize;

#[derive(Default, Serialize)]
pub(crate) struct ResponseStats {
    pub(crate) total: usize,
    pub(crate) successful: usize,
    pub(crate) failures: usize,
    pub(crate) unknown: usize,
    pub(crate) timeouts: usize,
    pub(crate) redirects: usize,
    pub(crate) excludes: usize,
    pub(crate) errors: usize,
    pub(crate) cached: usize,
    pub(crate) fail_map: HashMap<InputSource, HashSet<ResponseBody>>,
}

impl ResponseStats {
    #[inline]
    pub(crate) fn new() -> Self {
        Self::default()
    }

    pub(crate) fn add(&mut self, response: Response) {
        let Response(source, ResponseBody { ref status, .. }) = response;

        // Silently skip unsupported URIs
        if status.is_unsupported() {
            return;
        }

        self.total += 1;

        match status {
            Status::Ok(_) => self.successful += 1,
            Status::Error(_) => self.failures += 1,
            Status::UnknownStatusCode(_) => self.unknown += 1,
            Status::Timeout(_) => self.timeouts += 1,
            Status::Redirected(_) => self.redirects += 1,
            Status::Excluded => self.excludes += 1,
            Status::Unsupported(_) => (), // Just skip unsupported URI
            Status::Cached(_) => self.cached += 1,
        }

        if let Status::Cached(cache_status) = status {
            match cache_status {
                CacheStatus::Ok(_) => self.successful += 1,
                CacheStatus::Error(_) => self.failures += 1,
                CacheStatus::Excluded | CacheStatus::Unsupported => self.excludes += 1,
            }
        }

        if matches!(
            status,
            Status::Error(_)
                | Status::Timeout(_)
                | Status::Redirected(_)
                | Status::Cached(CacheStatus::Error(_))
        ) {
            let fail = self.fail_map.entry(source).or_default();
            fail.insert(response.1);
        };
    }

    #[inline]
    pub(crate) const fn is_success(&self) -> bool {
        self.total == self.successful + self.excludes
    }

    #[inline]
    pub(crate) const fn is_empty(&self) -> bool {
        self.total == 0
    }
}

#[cfg(test)]
mod tests {
    use std::collections::{HashMap, HashSet};

    use http::StatusCode;
    use lychee_lib::{ClientBuilder, InputSource, Response, ResponseBody, Status, Uri};
    use reqwest::Url;
    use wiremock::{matchers::path, Mock, MockServer, ResponseTemplate};

    use super::ResponseStats;

    fn website(url: &str) -> Uri {
        Uri::from(Url::parse(url).expect("Expected valid Website URI"))
    }

    async fn get_mock_status_response<S>(status_code: S) -> Response
    where
        S: Into<StatusCode>,
    {
        let mock_server = MockServer::start().await;
        let template = ResponseTemplate::new(status_code.into());

        Mock::given(path("/"))
            .respond_with(template)
            .mount(&mock_server)
            .await;

        ClientBuilder::default()
            .client()
            .unwrap()
            .check(mock_server.uri())
            .await
            .unwrap()
    }

    #[test]
    fn test_stats_is_empty() {
        let mut stats = ResponseStats::new();
        assert!(stats.is_empty());

        stats.add(Response(
            InputSource::Stdin,
            ResponseBody {
                uri: website("https://example.com/ok"),
                status: Status::Ok(StatusCode::OK),
            },
        ));

        assert!(!stats.is_empty());
    }

    #[tokio::test]
    async fn test_stats() {
        let status_codes = [
            StatusCode::OK,
            StatusCode::PERMANENT_REDIRECT,
            StatusCode::BAD_GATEWAY,
        ];

        let mut stats = ResponseStats::new();
        for status in &status_codes {
            stats.add(get_mock_status_response(status).await);
        }

        let mut expected_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
        for status in &status_codes {
            if status.is_server_error() || status.is_client_error() || status.is_redirection() {
                let Response(source, response_body) = get_mock_status_response(status).await;
                let entry = expected_map.entry(source).or_default();
                entry.insert(response_body);
            }
        }

        assert_eq!(stats.fail_map, expected_map);
    }
}