mod compact;
mod detailed;
mod json;
mod junit;
mod markdown;
mod response;
pub(crate) use compact::Compact;
pub(crate) use detailed::Detailed;
pub(crate) use json::Json;
pub(crate) use junit::Junit;
pub(crate) use markdown::Markdown;
pub(crate) use response::ResponseStats;
use serde::Serialize;
use std::{
cmp::Eq,
collections::{HashMap, HashSet},
fmt::Display,
fs,
hash::Hash,
io::{Write, stdout},
};
use crate::{config::Config, formatters::get_stats_formatter};
use anyhow::{Context, Result};
use lychee_lib::{InputSource, ratelimit::HostStatsMap};
#[derive(Default, Serialize)]
pub(crate) struct OutputStats {
#[serde(flatten)]
pub(crate) response_stats: ResponseStats,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) host_stats: Option<HostStatsMap>,
}
pub(crate) trait StatsFormatter {
fn format(&self, stats: OutputStats) -> Result<String>;
}
pub(crate) fn output_statistics(stats: OutputStats, config: &Config) -> Result<()> {
let formatter = get_stats_formatter(&config.format(), &config.mode());
let formatted_stats = formatter.format(stats)?;
if let Some(output) = &config.output {
fs::write(output, formatted_stats).context("Cannot write status output to file")?;
} else {
writeln!(stdout(), "{formatted_stats}")?;
}
Ok(())
}
fn sort_stat_map<T>(stat_map: &HashMap<InputSource, HashSet<T>>) -> Vec<(&InputSource, Vec<&T>)>
where
T: Display,
{
sort_stats_iter(stat_map)
}
fn sort_stats_iter<'a, K, V, Entries, Vals>(it: Entries) -> Vec<(&'a K, Vec<&'a V>)>
where
K: Display + Hash + Eq + 'a,
V: Display + 'a,
Entries: IntoIterator<Item = (&'a K, Vals)>,
Vals: IntoIterator<Item = &'a V>,
{
let mut map: HashMap<&K, Vec<&V>> = HashMap::new();
for (k, vs) in it {
map.entry(k).or_default().extend(vs);
}
let mut entries: Vec<(&K, Vec<&V>)> = map.into_iter().collect();
entries.sort_by_cached_key(|(x, _)| (x.to_string().to_lowercase(), x.to_string()));
for (_, vs) in &mut entries {
vs.sort_by_cached_key(|x| (x.to_string().to_lowercase(), x.to_string()));
}
entries
}
#[cfg(test)]
fn get_dummy_stats() -> OutputStats {
use std::{num::NonZeroUsize, time::Duration};
use http::StatusCode;
use lychee_lib::{RawUriSpan, Redirect, Redirects, ResponseBody, Status, ratelimit::HostStats};
use url::Url;
use crate::formatters::suggestion::Suggestion;
const SPAN: Option<RawUriSpan> = Some(RawUriSpan {
column: Some(NonZeroUsize::MIN),
line: NonZeroUsize::MIN,
});
const DURATION: Option<Duration> = Some(Duration::from_secs(1));
let source = InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap()));
let error_map = HashMap::from([(
source.clone(),
HashSet::from([ResponseBody {
uri: "https://github.com/mre/idiomatic-rust-doesnt-exist-man"
.try_into()
.unwrap(),
status: Status::Ok(StatusCode::NOT_FOUND),
redirects: None,
remap: None,
span: SPAN,
duration: DURATION,
}]),
)]);
let timeout_map = HashMap::from([(
source.clone(),
HashSet::from([ResponseBody {
uri: "https://httpbin.org/delay/2".try_into().unwrap(),
status: Status::Timeout(None),
redirects: None,
remap: None,
span: SPAN,
duration: DURATION,
}]),
)]);
let suggestion_map = HashMap::from([(
source.clone(),
HashSet::from([Suggestion {
original: "https://original.dev".try_into().unwrap(),
suggestion: "https://suggestion.dev".try_into().unwrap(),
}]),
)]);
let mut redirects = Redirects::new("https://1.dev".try_into().unwrap());
redirects.push(Redirect {
url: "https://2.dev".try_into().unwrap(),
code: StatusCode::PERMANENT_REDIRECT,
});
redirects.push(Redirect {
url: "http://redirected.dev".try_into().unwrap(),
code: StatusCode::PERMANENT_REDIRECT,
});
let redirect_map = HashMap::from([(source.clone(), HashSet::from([redirects]))]);
let response_stats = ResponseStats {
total: 2,
unique: 2,
errors: 1,
timeouts: 1,
redirects: 1,
suggestion_map,
redirect_map,
error_map,
timeout_map,
detailed_stats: true,
..Default::default()
};
let host_stats = Some(HostStatsMap::from(HashMap::from([(
String::from("example.com"),
HostStats {
total_requests: 5,
successful_requests: 3,
rate_limited: 1,
server_errors: 1,
cache_hits: 1,
cache_misses: 4,
..Default::default()
},
)])));
OutputStats {
response_stats,
host_stats,
}
}
#[cfg(test)]
mod tests {
use super::*;
use lychee_lib::{ErrorKind, Response, Status, Uri};
use url::Url;
fn make_test_url(url: &str) -> Url {
Url::parse(url).expect("Expected valid Website URI")
}
fn make_test_response(url_str: &str, source: InputSource) -> Response {
let uri = Uri::from(make_test_url(url_str));
Response::new(
uri,
Status::Error(ErrorKind::EmptyUrl),
None,
None,
source,
None,
None,
)
}
#[test]
fn test_sorted_stat_map() {
let mut test_stats = ResponseStats::default();
let test_sources = vec![
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/404"))),
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/home"))),
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/1"))),
InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/10"))),
];
let test_response_urls = vec![
"https://example.com/",
"https://github.com/",
"https://itch.io/",
"https://youtube.com/",
];
for source in &test_sources {
for response in &test_response_urls {
test_stats.add(make_test_response(response, source.clone()));
}
}
let sorted_errors = sort_stat_map(&test_stats.error_map);
let sorted_sources: Vec<InputSource> = sorted_errors
.iter()
.map(|(source, _)| (*source).clone())
.collect();
assert_eq!(test_sources, sorted_sources);
for (_, response_bodies) in sorted_errors {
let response_urls: Vec<&str> = response_bodies
.into_iter()
.map(|response| response.uri.as_str())
.collect();
assert_eq!(test_response_urls, response_urls);
}
}
}