use std::{
collections::{HashMap, HashSet},
fmt::{self, Display},
};
use super::StatsFormatter;
use anyhow::Result;
use http::StatusCode;
use lychee_lib::{InputSource, ResponseBody, Status};
use std::fmt::Write;
use tabled::{
Table, Tabled,
settings::{Alignment, Modify, Style, object::Segment},
};
use crate::formatters::{
host_stats::MarkdownHostStats,
stats::{OutputStats, ResponseStats},
};
#[derive(Tabled)]
struct StatsTableEntry {
#[tabled(rename = "Status")]
status: &'static str,
#[tabled(rename = "Count")]
count: usize,
}
fn stats_table(stats: &ResponseStats) -> String {
let stats = vec![
StatsTableEntry {
status: "🔍 Total",
count: stats.total,
},
StatsTableEntry {
status: "🔗 Unique",
count: stats.unique,
},
StatsTableEntry {
status: "✅ Successful",
count: stats.successful,
},
StatsTableEntry {
status: "⏳ Timeouts",
count: stats.timeouts,
},
StatsTableEntry {
status: "🔀 Redirected",
count: stats.redirects,
},
StatsTableEntry {
status: "👻 Excluded",
count: stats.excludes,
},
StatsTableEntry {
status: "❓ Unknown",
count: stats.unknown,
},
StatsTableEntry {
status: "🚫 Errors",
count: stats.errors,
},
StatsTableEntry {
status: "⛔ Unsupported",
count: stats.unsupported,
},
];
let style = Style::markdown();
Table::new(stats)
.with(Modify::new(Segment::all()).with(Alignment::left()))
.with(style)
.to_string()
}
fn markdown_response(response: &ResponseBody) -> Result<String> {
let mut formatted = format!(
"* [{}] <{}>",
response.status.code_as_string(),
response.uri,
);
if let Some(span) = response.span {
formatted = format!("{formatted} (at {span})");
}
if let Status::Ok(StatusCode::OK) = response.status {
return Ok(formatted);
}
let details = response.status.details();
write!(formatted, " | {details}")?;
Ok(formatted)
}
struct MarkdownResponseStats(ResponseStats);
impl Display for MarkdownResponseStats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let stats = &self.0;
writeln!(f, "# Summary")?;
writeln!(f)?;
writeln!(f, "{}", stats_table(&self.0))?;
write_stats_per_input(f, "Errors", &stats.error_map, |response| {
markdown_response(response).map_err(|_| fmt::Error)
})?;
write_stats_per_input(f, "Timeouts", &stats.timeout_map, |response| {
markdown_response(response).map_err(|_| fmt::Error)
})?;
write_stats_per_input(f, "Redirects", &stats.redirect_map, |redirects| {
Ok(format!("* {redirects}"))
})?;
write_stats_per_input(f, "Suggestions", &stats.suggestion_map, |suggestion| {
Ok(format!("* {suggestion}"))
})?;
Ok(())
}
}
fn write_stats_per_input<T, F>(
f: &mut fmt::Formatter<'_>,
name: &'static str,
map: &HashMap<InputSource, HashSet<T>>,
write_stat: F,
) -> fmt::Result
where
T: Display,
F: Fn(&T) -> Result<String, std::fmt::Error>,
{
if !&map.is_empty() {
writeln!(f, "\n## {name} per input")?;
for (source, responses) in super::sort_stat_map(map) {
writeln!(f, "\n### {name} in {source}\n")?;
for response in responses {
writeln!(f, "{}", write_stat(response)?)?;
}
}
}
Ok(())
}
pub(crate) struct Markdown;
impl Markdown {
pub(crate) const fn new() -> Self {
Self {}
}
}
impl StatsFormatter for Markdown {
fn format(&self, stats: OutputStats) -> Result<String> {
let response_stats = MarkdownResponseStats(stats.response_stats);
let host_stats = MarkdownHostStats {
host_stats: stats.host_stats,
};
Ok(format!("{response_stats}\n{host_stats}"))
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroUsize;
use std::time::Duration;
use http::StatusCode;
use lychee_lib::RawUriSpan;
use lychee_lib::{CacheStatus, ResponseBody, Status, Uri};
use pretty_assertions::assert_eq;
use crate::formatters::stats::get_dummy_stats;
use super::*;
const SPAN: Option<RawUriSpan> = Some(RawUriSpan {
line: NonZeroUsize::MIN,
column: Some(NonZeroUsize::MIN),
});
const DURATION: Option<Duration> = Some(Duration::from_secs(1));
#[test]
fn test_markdown_response_ok() {
let response = ResponseBody {
uri: Uri::try_from("http://example.com").unwrap(),
status: Status::Ok(StatusCode::OK),
redirects: None,
remap: None,
span: SPAN,
duration: DURATION,
};
let markdown = markdown_response(&response).unwrap();
assert_eq!(markdown, "* [200] <http://example.com/> (at 1:1)");
}
#[test]
fn test_markdown_response_cached_ok() {
let response = ResponseBody {
uri: Uri::try_from("http://example.com").unwrap(),
status: Status::Cached(CacheStatus::Ok(StatusCode::OK)),
redirects: None,
remap: None,
span: SPAN,
duration: DURATION,
};
let markdown = markdown_response(&response).unwrap();
assert_eq!(
markdown,
"* [200] <http://example.com/> (at 1:1) | OK (cached)"
);
}
#[test]
fn test_markdown_response_cached_err() {
let response = ResponseBody {
uri: Uri::try_from("http://example.com").unwrap(),
status: Status::Cached(CacheStatus::Error(Some(StatusCode::BAD_REQUEST))),
redirects: None,
remap: None,
span: SPAN,
duration: DURATION,
};
let markdown = markdown_response(&response).unwrap();
assert_eq!(
markdown,
"* [400] <http://example.com/> (at 1:1) | Error (cached)"
);
}
#[test]
fn test_render_stats() {
let stats = ResponseStats::default();
let table = stats_table(&stats);
let expected = "| Status | Count |
|----------------|-------|
| 🔍 Total | 0 |
| 🔗 Unique | 0 |
| ✅ Successful | 0 |
| ⏳ Timeouts | 0 |
| 🔀 Redirected | 0 |
| 👻 Excluded | 0 |
| ❓ Unknown | 0 |
| 🚫 Errors | 0 |
| ⛔ Unsupported | 0 |";
assert_eq!(table, expected.to_string());
}
#[test]
fn test_render_summary() {
let summary = MarkdownResponseStats(get_dummy_stats().response_stats);
let expected = "# Summary
| Status | Count |
|----------------|-------|
| 🔍 Total | 2 |
| 🔗 Unique | 2 |
| ✅ Successful | 0 |
| ⏳ Timeouts | 1 |
| 🔀 Redirected | 1 |
| 👻 Excluded | 0 |
| ❓ Unknown | 0 |
| 🚫 Errors | 1 |
| ⛔ Unsupported | 0 |
## Errors per input
### Errors in https://example.com/
* [404] <https://github.com/mre/idiomatic-rust-doesnt-exist-man> (at 1:1) | 404 Not Found
## Timeouts per input
### Timeouts in https://example.com/
* [TIMEOUT] <https://httpbin.org/delay/2> (at 1:1) | Request timed out
## Redirects per input
### Redirects in https://example.com/
* https://1.dev/ --[308]--> https://2.dev/ --[308]--> http://redirected.dev/
## Suggestions per input
### Suggestions in https://example.com/
* https://original.dev/ --> https://suggestion.dev/
";
assert_eq!(summary.to_string(), expected.to_string());
}
}