#![allow(clippy::mutable_key_type)]
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use lychee_lib::{CacheStatus, InputSource, Redirects, Response, ResponseBody, Status, Uri};
use serde::Serialize;
use crate::formatters::suggestion::Suggestion;
#[derive(Default, Serialize, Debug)]
pub(crate) struct ResponseStats {
pub(crate) total: usize,
pub(crate) unique: usize,
pub(crate) successful: usize,
pub(crate) unknown: usize,
pub(crate) unsupported: usize,
pub(crate) timeouts: usize,
pub(crate) redirects: usize,
pub(crate) remaps: usize,
pub(crate) excludes: usize,
pub(crate) errors: usize,
pub(crate) cached: usize,
pub(crate) success_map: HashMap<InputSource, HashSet<ResponseBody>>,
pub(crate) error_map: HashMap<InputSource, HashSet<ResponseBody>>,
pub(crate) timeout_map: HashMap<InputSource, HashSet<ResponseBody>>,
pub(crate) suggestion_map: HashMap<InputSource, HashSet<Suggestion>>,
pub(crate) redirect_map: HashMap<InputSource, HashSet<Redirects>>,
pub(crate) excluded_map: HashMap<InputSource, HashSet<ResponseBody>>,
pub(crate) duration: Duration,
pub(crate) detailed_stats: bool,
#[serde(skip)]
pub(crate) seen_uris: HashSet<Uri>,
}
impl ResponseStats {
#[inline]
pub(crate) fn extended() -> Self {
Self {
detailed_stats: true,
seen_uris: HashSet::new(),
..Default::default()
}
}
pub(crate) const fn increment_status_counters(&mut self, status: &Status) {
match status {
Status::Ok(_) => self.successful += 1,
Status::Error(_) | Status::RequestError(_) => self.errors += 1,
Status::UnknownStatusCode(_) | Status::UnknownMailStatus(_) => self.unknown += 1,
Status::Timeout(_) => self.timeouts += 1,
Status::Excluded => self.excludes += 1,
Status::Unsupported(_) => self.unsupported += 1,
Status::Cached(cache_status) => {
self.cached += 1;
match cache_status {
CacheStatus::Ok(_) => self.successful += 1,
CacheStatus::Error(_) => self.errors += 1,
CacheStatus::Excluded => self.excludes += 1,
CacheStatus::Unsupported => self.unsupported += 1,
}
}
}
}
fn add_response_status(&mut self, response: Response) {
let status = response.status();
let source: InputSource = response.source().clone();
if self.detailed_stats
&& let Some(redirects) = response.redirects()
{
self.redirect_map
.entry(source.clone())
.or_default()
.insert(redirects.clone());
}
let status_map_entry = if status.is_timeout() {
self.timeout_map.entry(source).or_default()
} else if status.is_error() {
self.error_map.entry(source).or_default()
} else if status.is_excluded() {
self.excluded_map.entry(source).or_default()
} else if status.is_success() && self.detailed_stats {
self.success_map.entry(source).or_default()
} else {
return;
};
status_map_entry.insert(response.into_body());
}
pub(crate) fn add(&mut self, response: Response) {
if self.seen_uris.insert(response.body().uri.clone()) {
self.unique += 1;
}
self.total += 1;
if response.redirects().is_some() {
self.redirects += 1;
}
if response.remap().is_some() {
self.remaps += 1;
}
self.increment_status_counters(response.status());
self.add_response_status(response);
}
#[inline]
pub(crate) fn is_success(&self) -> bool {
self.error_map.is_empty() && self.timeout_map.is_empty()
}
#[inline]
pub(crate) fn is_success_ignoring_timeouts(&self) -> bool {
self.error_map.is_empty()
}
#[inline]
#[cfg(test)]
const fn is_empty(&self) -> bool {
self.total == 0
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use std::collections::{HashMap, HashSet};
use http::StatusCode;
use lychee_lib::{ErrorKind, InputSource, Response, ResponseBody, Status, Uri};
use reqwest::Url;
use super::ResponseStats;
fn website(url: &str) -> Uri {
Uri::from(Url::parse(url).expect("Expected valid Website URI"))
}
fn mock_response(status: Status) -> Response {
let uri = website("https://some-url.com/ok");
Response::new(uri, status, None, None, InputSource::Stdin, None, None)
}
fn dummy_ok() -> Response {
mock_response(Status::Ok(StatusCode::OK))
}
fn dummy_error() -> Response {
mock_response(Status::Error(ErrorKind::InvalidStatusCode(1000)))
}
fn dummy_excluded() -> Response {
mock_response(Status::Excluded)
}
#[tokio::test]
async fn test_stats_is_empty() {
let mut stats = ResponseStats::default();
assert!(stats.is_empty());
stats.add(dummy_error());
assert!(!stats.is_empty());
}
#[tokio::test]
async fn test_stats() {
let mut stats = ResponseStats::default();
assert!(stats.success_map.is_empty());
assert!(stats.excluded_map.is_empty());
stats.add(dummy_error());
stats.add(dummy_ok());
let response = dummy_error();
let expected_error_map: HashMap<InputSource, HashSet<ResponseBody>> =
HashMap::from_iter([(
response.source().clone(),
HashSet::from_iter([response.into_body()]),
)]);
assert_eq!(stats.error_map, expected_error_map);
assert!(stats.success_map.is_empty());
}
#[tokio::test]
async fn test_detailed_stats() {
let mut stats = ResponseStats::extended();
assert!(stats.success_map.is_empty());
assert!(stats.error_map.is_empty());
assert!(stats.excluded_map.is_empty());
stats.add(dummy_error());
stats.add(dummy_excluded());
stats.add(dummy_ok());
let mut expected_error_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
let response = dummy_error();
let entry = expected_error_map
.entry(response.source().clone())
.or_default();
entry.insert(response.into_body());
assert_eq!(stats.error_map, expected_error_map);
let mut expected_success_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
let response = dummy_ok();
let entry = expected_success_map
.entry(response.source().clone())
.or_default();
entry.insert(response.into_body());
assert_eq!(stats.success_map, expected_success_map);
let mut expected_excluded_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
let response = dummy_excluded();
let entry = expected_excluded_map
.entry(response.source().clone())
.or_default();
entry.insert(response.into_body());
assert_eq!(stats.excluded_map, expected_excluded_map);
}
}