mirror of
https://github.com/Hopiu/lychee.git
synced 2026-04-27 08:24:47 +00:00
Add support for different output formats (compact, detailed, markdown) (#375)
This commit is contained in:
parent
d3ed133f10
commit
b97fda34d0
16 changed files with 421 additions and 119 deletions
31
Cargo.lock
generated
31
Cargo.lock
generated
|
|
@ -1486,6 +1486,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"structopt",
|
||||
"tabled",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
|
@ -1803,6 +1804,15 @@ dependencies = [
|
|||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papergrid"
|
||||
version = "0.1.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fe93a33fc67c8a9d21f3fefa54c4a991e08d2eba5f2fd6f327ca520862eddfb"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.0.0"
|
||||
|
|
@ -2582,6 +2592,27 @@ dependencies = [
|
|||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabled"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465c45048a4b177eee43527a3741866dd66839f15c3223b5047dbc43d8a5076e"
|
||||
dependencies = [
|
||||
"papergrid",
|
||||
"tabled_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabled_derive"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b366a04a152b687a209b2459f29da3650b74bc859390308949305211e28af4b9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.2.0"
|
||||
|
|
|
|||
|
|
@ -230,7 +230,8 @@ OPTIONS:
|
|||
-c, --config <config-file> Configuration file to use [default: ./lychee.toml]
|
||||
--exclude <exclude>... Exclude URLs from checking (supports regex)
|
||||
--exclude-file <exclude-file>... A file or files that contains URLs to exclude from checking
|
||||
-f, --format <format> Output file format of status report (json, string) [default: string]
|
||||
-f, --format <format> Output format of final status report (compact, detailed, json, markdown)
|
||||
[default: compact]
|
||||
--github-token <github-token> GitHub API token to use when checking github.com links, to avoid rate
|
||||
limiting [env: GITHUB_TOKEN=]
|
||||
-h, --headers <headers>... Custom request headers
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ ring = "0.16.20"
|
|||
serde = { version = "1.0.125", features = ["derive"] }
|
||||
serde_json = "1.0.70"
|
||||
structopt = "0.3.25"
|
||||
tabled = "0.3.0"
|
||||
tokio = { version = "1.14.0", features = ["full"] }
|
||||
toml = "0.5.8"
|
||||
once_cell = "1.8.0"
|
||||
|
|
|
|||
21
lychee-bin/src/color.rs
Normal file
21
lychee-bin/src/color.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use console::Style;
|
||||
use once_cell::sync::Lazy;
|
||||
|
||||
pub(crate) static NORMAL: Lazy<Style> = Lazy::new(Style::new);
|
||||
pub(crate) static DIM: Lazy<Style> = Lazy::new(|| Style::new().dim());
|
||||
|
||||
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().green().bright());
|
||||
pub(crate) static BOLD_GREEN: Lazy<Style> = Lazy::new(|| Style::new().green().bold().bright());
|
||||
pub(crate) static YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bright());
|
||||
pub(crate) static BOLD_YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bold().bright());
|
||||
pub(crate) static PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bright());
|
||||
pub(crate) static BOLD_PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bold().bright());
|
||||
|
||||
// Write output using predefined colors
|
||||
macro_rules! color {
|
||||
($f:ident, $color:ident, $text:tt, $($tts:tt)*) => {
|
||||
write!($f, "{}", $color.apply_to(format!($text, $($tts)*)))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use color;
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
|
||||
// required for apple silicon
|
||||
use ring as _;
|
||||
use stats::color_response;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
|
@ -76,14 +77,17 @@ use ring as _; // required for apple silicon
|
|||
use structopt::StructOpt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
mod color;
|
||||
mod options;
|
||||
mod parse;
|
||||
mod stats;
|
||||
mod writer;
|
||||
|
||||
use crate::parse::{parse_basic_auth, parse_headers, parse_statuscodes, parse_timeout};
|
||||
use crate::{
|
||||
options::{Config, Format, LycheeOptions},
|
||||
stats::{color_response, ResponseStats},
|
||||
stats::ResponseStats,
|
||||
writer::StatsWriter,
|
||||
};
|
||||
|
||||
/// A C-like enum that can be cast to `i32` and used as process exit code.
|
||||
|
|
@ -157,13 +161,6 @@ fn show_progress(progress_bar: &Option<ProgressBar>, response: &Response, verbos
|
|||
}
|
||||
}
|
||||
|
||||
fn fmt(stats: &ResponseStats, format: &Format) -> Result<String> {
|
||||
Ok(match format {
|
||||
Format::String => stats.to_string(),
|
||||
Format::Json => serde_json::to_string_pretty(&stats)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run(cfg: &Config, inputs: Vec<Input>) -> Result<i32> {
|
||||
let mut headers = parse_headers(&cfg.headers)?;
|
||||
if let Some(auth) = &cfg.basic_auth {
|
||||
|
|
@ -261,13 +258,22 @@ async fn run(cfg: &Config, inputs: Vec<Input>) -> Result<i32> {
|
|||
pb.finish_and_clear();
|
||||
}
|
||||
|
||||
write_stats(&stats, cfg)?;
|
||||
let writer: Box<dyn StatsWriter> = match cfg.format {
|
||||
Format::Compact => Box::new(writer::Compact::new()),
|
||||
Format::Detailed => Box::new(writer::Detailed::new()),
|
||||
Format::Json => Box::new(writer::Json::new()),
|
||||
Format::Markdown => Box::new(writer::Markdown::new()),
|
||||
};
|
||||
|
||||
if stats.is_success() {
|
||||
Ok(ExitCode::Success as i32)
|
||||
let code = if stats.is_success() {
|
||||
ExitCode::Success
|
||||
} else {
|
||||
Ok(ExitCode::LinkCheckFailure as i32)
|
||||
}
|
||||
ExitCode::LinkCheckFailure
|
||||
};
|
||||
|
||||
write_stats(&*writer, stats, cfg)?;
|
||||
|
||||
Ok(code as i32)
|
||||
}
|
||||
|
||||
/// Dump all detected links to stdout without checking them
|
||||
|
|
@ -289,18 +295,19 @@ fn dump_links<'a>(links: impl Iterator<Item = &'a Request>) -> ExitCode {
|
|||
}
|
||||
|
||||
/// Write final statistics to stdout or to file
|
||||
fn write_stats(stats: &ResponseStats, cfg: &Config) -> Result<()> {
|
||||
let formatted = fmt(stats, &cfg.format)?;
|
||||
fn write_stats(writer: &dyn StatsWriter, stats: ResponseStats, cfg: &Config) -> Result<()> {
|
||||
let is_empty = stats.is_empty();
|
||||
let formatted = writer.write(stats)?;
|
||||
|
||||
if let Some(output) = &cfg.output {
|
||||
fs::write(output, formatted).context("Cannot write status output to file")?;
|
||||
} else {
|
||||
if cfg.verbose && !stats.is_empty() {
|
||||
if cfg.verbose && !is_empty {
|
||||
// separate summary from the verbose list of links above
|
||||
println!();
|
||||
}
|
||||
// we assume that the formatted stats don't have a final newline
|
||||
println!("{}", stats);
|
||||
println!("{}", formatted);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,16 +22,20 @@ lazy_static! {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) enum Format {
|
||||
String,
|
||||
Compact,
|
||||
Detailed,
|
||||
Json,
|
||||
Markdown,
|
||||
}
|
||||
|
||||
impl FromStr for Format {
|
||||
type Err = Error;
|
||||
fn from_str(format: &str) -> Result<Self, Self::Err> {
|
||||
match format {
|
||||
"string" => Ok(Format::String),
|
||||
"compact" | "string" => Ok(Format::Compact),
|
||||
"detailed" => Ok(Format::Detailed),
|
||||
"json" => Ok(Format::Json),
|
||||
"markdown" | "md" => Ok(Format::Markdown),
|
||||
_ => Err(anyhow!("Could not parse format {}", format)),
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +43,7 @@ impl FromStr for Format {
|
|||
|
||||
impl Default for Format {
|
||||
fn default() -> Self {
|
||||
Format::String
|
||||
Format::Compact
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,8 +266,8 @@ pub(crate) struct Config {
|
|||
#[serde(default)]
|
||||
pub(crate) output: Option<PathBuf>,
|
||||
|
||||
/// Output file format of status report (json, string)
|
||||
#[structopt(short, long, default_value = "string")]
|
||||
/// Output format of final status report (compact, detailed, json, markdown)
|
||||
#[structopt(short, long, default_value = "compact")]
|
||||
#[serde(default)]
|
||||
pub(crate) format: Format,
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,9 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::{self, Display},
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use console::Style;
|
||||
use lychee_lib::{Input, Response, ResponseBody, Status};
|
||||
use once_cell::sync::Lazy;
|
||||
use pad::{Alignment, PadStr};
|
||||
use serde::Serialize;
|
||||
|
||||
static GREEN: Lazy<Style> = Lazy::new(|| Style::new().green().bright());
|
||||
static DIM: Lazy<Style> = Lazy::new(|| Style::new().dim());
|
||||
static NORMAL: Lazy<Style> = Lazy::new(Style::new);
|
||||
static YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bright());
|
||||
static RED: Lazy<Style> = Lazy::new(|| Style::new().red().bright());
|
||||
|
||||
// Maximum padding for each entry in the final statistics output
|
||||
const MAX_PADDING: usize = 20;
|
||||
use crate::color::{DIM, GREEN, NORMAL, PINK, YELLOW};
|
||||
|
||||
pub(crate) fn color_response(response: &ResponseBody) -> String {
|
||||
let out = match response.status {
|
||||
|
|
@ -24,22 +11,22 @@ pub(crate) fn color_response(response: &ResponseBody) -> String {
|
|||
Status::Excluded | Status::Unsupported(_) => DIM.apply_to(response),
|
||||
Status::Redirected(_) => NORMAL.apply_to(response),
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => YELLOW.apply_to(response),
|
||||
Status::Error(_) => RED.apply_to(response),
|
||||
Status::Error(_) => PINK.apply_to(response),
|
||||
};
|
||||
out.to_string()
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub(crate) struct ResponseStats {
|
||||
total: usize,
|
||||
successful: usize,
|
||||
failures: usize,
|
||||
unknown: usize,
|
||||
timeouts: usize,
|
||||
redirects: usize,
|
||||
excludes: usize,
|
||||
errors: usize,
|
||||
fail_map: HashMap<Input, HashSet<ResponseBody>>,
|
||||
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) fail_map: HashMap<Input, HashSet<ResponseBody>>,
|
||||
}
|
||||
|
||||
impl ResponseStats {
|
||||
|
|
@ -50,8 +37,9 @@ impl ResponseStats {
|
|||
|
||||
pub(crate) fn add(&mut self, response: Response) {
|
||||
let Response(source, ResponseBody { ref status, .. }) = response;
|
||||
|
||||
// Silently skip unsupported URIs
|
||||
if status.is_unsupported() {
|
||||
// Silently skip unsupported URIs
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -87,49 +75,6 @@ impl ResponseStats {
|
|||
}
|
||||
}
|
||||
|
||||
fn write_stat(f: &mut fmt::Formatter, title: &str, stat: usize, newline: bool) -> fmt::Result {
|
||||
let fill = title.chars().count();
|
||||
f.write_str(title)?;
|
||||
f.write_str(
|
||||
&stat
|
||||
.to_string()
|
||||
.pad(MAX_PADDING - fill, '.', Alignment::Right, false),
|
||||
)?;
|
||||
|
||||
if newline {
|
||||
f.write_str("\n")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Display for ResponseStats {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let separator = "-".repeat(MAX_PADDING + 1);
|
||||
|
||||
writeln!(f, "\u{1f4dd} Summary")?; // 📝
|
||||
writeln!(f, "{}", separator)?;
|
||||
write_stat(f, "\u{1f50d} Total", self.total, true)?; // 🔍
|
||||
write_stat(f, "\u{2705} Successful", self.successful, true)?; // ✅
|
||||
write_stat(f, "\u{23f3} Timeouts", self.timeouts, true)?; // ⏳
|
||||
write_stat(f, "\u{1f500} Redirected", self.redirects, true)?; // 🔀
|
||||
write_stat(f, "\u{1f47b} Excluded", self.excludes, true)?; // 👻
|
||||
write_stat(f, "\u{26a0} Unknown", self.unknown, true)?; // ⚠️
|
||||
write_stat(f, "\u{1f6ab} Errors", self.errors + self.failures, false)?; // 🚫
|
||||
|
||||
for (input, responses) in &self.fail_map {
|
||||
// Using leading newlines over trailing ones (e.g. `writeln!`)
|
||||
// lets us avoid extra newlines without any additional logic.
|
||||
write!(f, "\n\nErrors in {}", input)?;
|
||||
for response in responses {
|
||||
write!(f, "\n{}", color_response(response))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
|
|
|||
86
lychee-bin/src/writer/compact.rs
Normal file
86
lychee-bin/src/writer/compact.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::{self, Display},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
color::{color, BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, NORMAL, PINK},
|
||||
stats::{color_response, ResponseStats},
|
||||
};
|
||||
|
||||
use super::StatsWriter;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
struct CompactResponseStats(ResponseStats);
|
||||
|
||||
// Helper function, which prints the detailed list of errors
|
||||
pub(crate) fn print_errors(stats: &ResponseStats) -> String {
|
||||
let mut errors = HashMap::new();
|
||||
errors.insert("HTTP", stats.failures);
|
||||
errors.insert("Redirects", stats.redirects);
|
||||
errors.insert("Timeouts", stats.timeouts);
|
||||
errors.insert("Unknown", stats.unknown);
|
||||
|
||||
// Creates an output like `(HTTP:3|Timeouts:1|Unknown:1)`
|
||||
let mut err: Vec<_> = errors
|
||||
.into_iter()
|
||||
.filter(|(_, v)| *v > 0)
|
||||
.map(|(k, v)| format!("{}:{}", k, v))
|
||||
.collect();
|
||||
err.sort();
|
||||
err.join("|")
|
||||
}
|
||||
|
||||
impl Display for CompactResponseStats {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = &self.0;
|
||||
|
||||
if !stats.fail_map.is_empty() {
|
||||
color!(
|
||||
f,
|
||||
BOLD_PINK,
|
||||
"Issues found in {} input(s). Find details below.\n\n",
|
||||
stats.fail_map.len()
|
||||
)?;
|
||||
}
|
||||
for (input, responses) in &stats.fail_map {
|
||||
color!(f, BOLD_YELLOW, "[{}]:\n", input)?;
|
||||
for response in responses {
|
||||
writeln!(f, "{}", color_response(response))?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
}
|
||||
|
||||
color!(f, NORMAL, "\u{1F50D} {} Total", stats.total)?;
|
||||
color!(f, BOLD_GREEN, " \u{2705} {} OK", stats.successful)?;
|
||||
|
||||
let total_errors = stats.errors + stats.failures;
|
||||
|
||||
let err_str = if total_errors == 1 { "Error" } else { "Errors" };
|
||||
color!(f, BOLD_PINK, " \u{1f6ab} {} {}", total_errors, err_str)?;
|
||||
if total_errors > 0 {
|
||||
write!(f, " ")?;
|
||||
color!(f, PINK, "({})", print_errors(stats))?;
|
||||
}
|
||||
if stats.excludes > 0 {
|
||||
color!(f, BOLD_YELLOW, " \u{1F4A4} {} Excluded", stats.excludes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Compact;
|
||||
|
||||
impl Compact {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Compact {}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsWriter for Compact {
|
||||
fn write(&self, stats: ResponseStats) -> Result<String> {
|
||||
let compact = CompactResponseStats(stats);
|
||||
Ok(compact.to_string())
|
||||
}
|
||||
}
|
||||
72
lychee-bin/src/writer/detailed.rs
Normal file
72
lychee-bin/src/writer/detailed.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use super::StatsWriter;
|
||||
use crate::stats::{color_response, ResponseStats};
|
||||
|
||||
use anyhow::Result;
|
||||
use pad::{Alignment, PadStr};
|
||||
use std::fmt::{self, Display};
|
||||
|
||||
// Maximum padding for each entry in the final statistics output
|
||||
const MAX_PADDING: usize = 20;
|
||||
|
||||
fn write_stat(f: &mut fmt::Formatter, title: &str, stat: usize, newline: bool) -> fmt::Result {
|
||||
let fill = title.chars().count();
|
||||
f.write_str(title)?;
|
||||
f.write_str(
|
||||
&stat
|
||||
.to_string()
|
||||
.pad(MAX_PADDING - fill, '.', Alignment::Right, false),
|
||||
)?;
|
||||
|
||||
if newline {
|
||||
f.write_str("\n")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Display for DetailedResponseStats {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = &self.0;
|
||||
let separator = "-".repeat(MAX_PADDING + 1);
|
||||
|
||||
writeln!(f, "\u{1f4dd} Summary")?; // 📝
|
||||
writeln!(f, "{}", separator)?;
|
||||
write_stat(f, "\u{1f50d} Total", stats.total, true)?; // 🔍
|
||||
write_stat(f, "\u{2705} Successful", stats.successful, true)?; // ✅
|
||||
write_stat(f, "\u{23f3} Timeouts", stats.timeouts, true)?; // ⏳
|
||||
write_stat(f, "\u{1f500} Redirected", stats.redirects, true)?; // 🔀
|
||||
write_stat(f, "\u{1f47b} Excluded", stats.excludes, true)?; // 👻
|
||||
write_stat(f, "\u{2753} Unknown", stats.unknown, true)?; //❓
|
||||
write_stat(f, "\u{1f6ab} Errors", stats.errors + stats.failures, false)?; // 🚫
|
||||
|
||||
for (input, responses) in &stats.fail_map {
|
||||
// Using leading newlines over trailing ones (e.g. `writeln!`)
|
||||
// lets us avoid extra newlines without any additional logic.
|
||||
write!(f, "\n\nErrors in {}", input)?;
|
||||
for response in responses {
|
||||
write!(f, "\n{}", color_response(response))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap as newtype because multiple `Display` implementations are not allowed
|
||||
/// for `ResponseStats`
|
||||
struct DetailedResponseStats(ResponseStats);
|
||||
|
||||
pub(crate) struct Detailed;
|
||||
|
||||
impl Detailed {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Detailed {}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsWriter for Detailed {
|
||||
fn write(&self, stats: ResponseStats) -> Result<String> {
|
||||
let detailed = DetailedResponseStats(stats);
|
||||
Ok(detailed.to_string())
|
||||
}
|
||||
}
|
||||
18
lychee-bin/src/writer/json.rs
Normal file
18
lychee-bin/src/writer/json.rs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
use super::StatsWriter;
|
||||
use crate::stats::ResponseStats;
|
||||
|
||||
pub(crate) struct Json;
|
||||
|
||||
impl Json {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Json {}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsWriter for Json {
|
||||
fn write(&self, stats: ResponseStats) -> Result<String> {
|
||||
serde_json::to_string_pretty(&stats).context("Cannot format stats as JSON")
|
||||
}
|
||||
}
|
||||
100
lychee-bin/src/writer/markdown.rs
Normal file
100
lychee-bin/src/writer/markdown.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
use std::fmt::{self, Display};
|
||||
|
||||
use super::StatsWriter;
|
||||
use anyhow::Result;
|
||||
use tabled::{style::Line, Alignment, Full, Modify, Table, Tabled};
|
||||
|
||||
use crate::stats::ResponseStats;
|
||||
|
||||
#[derive(Tabled)]
|
||||
struct StatsTableEntry {
|
||||
#[header("Status")]
|
||||
status: &'static str,
|
||||
#[header("Count")]
|
||||
count: usize,
|
||||
}
|
||||
|
||||
fn stats_table(stats: &ResponseStats) -> String {
|
||||
let stats = vec![
|
||||
StatsTableEntry {
|
||||
status: "\u{1f50d} Total",
|
||||
count: stats.total,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{2705} Successful",
|
||||
count: stats.successful,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{23f3} Timeouts",
|
||||
count: stats.timeouts,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{1f500} Redirected",
|
||||
count: stats.redirects,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{1f47b} Excluded",
|
||||
count: stats.excludes,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{2753} Unknown",
|
||||
count: stats.unknown,
|
||||
},
|
||||
StatsTableEntry {
|
||||
status: "\u{1f6ab} Errors",
|
||||
count: stats.errors + stats.failures,
|
||||
},
|
||||
];
|
||||
let style = tabled::Style::github_markdown().header(Some(Line::bordered('-', '|', '|', '|')));
|
||||
|
||||
Table::new(stats)
|
||||
.with(Modify::new(Full).with(Alignment::left()))
|
||||
.with(style)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
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))?;
|
||||
|
||||
if !&stats.fail_map.is_empty() {
|
||||
writeln!(f, "## Errors per input")?;
|
||||
for (input, responses) in &stats.fail_map {
|
||||
// Using leading newlines over trailing ones (e.g. `writeln!`)
|
||||
// lets us avoid extra newlines without any additional logic.
|
||||
writeln!(f, "### Errors in {}", input)?;
|
||||
for response in responses {
|
||||
writeln!(
|
||||
f,
|
||||
"* [{}]({}): {}",
|
||||
response.uri, response.uri, response.status
|
||||
)?;
|
||||
}
|
||||
writeln!(f)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Markdown;
|
||||
|
||||
impl Markdown {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Markdown {}
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsWriter for Markdown {
|
||||
fn write(&self, stats: ResponseStats) -> Result<String> {
|
||||
let markdown = MarkdownResponseStats(stats);
|
||||
Ok(markdown.to_string())
|
||||
}
|
||||
}
|
||||
16
lychee-bin/src/writer/mod.rs
Normal file
16
lychee-bin/src/writer/mod.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
mod compact;
|
||||
mod detailed;
|
||||
mod json;
|
||||
mod markdown;
|
||||
|
||||
use crate::stats::ResponseStats;
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) use compact::Compact;
|
||||
pub(crate) use detailed::Detailed;
|
||||
pub(crate) use json::Json;
|
||||
pub(crate) use markdown::Markdown;
|
||||
|
||||
pub(crate) trait StatsWriter {
|
||||
fn write(&self, stats: ResponseStats) -> Result<String>;
|
||||
}
|
||||
|
|
@ -146,9 +146,9 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............2"))
|
||||
.stdout(contains("Successful.......1"))
|
||||
.stdout(contains("Excluded.........1"));
|
||||
.stdout(contains("2 Total"))
|
||||
.stdout(contains("1 OK"))
|
||||
.stdout(contains("1 Excluded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -163,8 +163,8 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............3"))
|
||||
.stdout(contains("Successful.......3"));
|
||||
.stdout(contains("3 Total"))
|
||||
.stdout(contains("3 OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -210,9 +210,9 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............3"))
|
||||
.stdout(contains("Successful.......2"))
|
||||
.stdout(contains("Excluded.........1"));
|
||||
.stdout(contains("3 Total"))
|
||||
.stdout(contains("2 OK"))
|
||||
.stdout(contains("1 Excluded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -225,8 +225,8 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............1"))
|
||||
.stdout(contains("Successful.......1"));
|
||||
.stdout(contains("1 Total"))
|
||||
.stdout(contains("1 OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -248,8 +248,8 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............1"))
|
||||
.stdout(contains("Successful.......1"));
|
||||
.stdout(contains("1 TOTAL"))
|
||||
.stdout(contains("1 OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -263,8 +263,8 @@ mod cli {
|
|||
.assert()
|
||||
.failure()
|
||||
.code(2)
|
||||
.stdout(contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man \
|
||||
(GitHub token not specified. To check GitHub links reliably, use `--github-token` flag / `GITHUB_TOKEN` env var.)"));
|
||||
.stdout(contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man: \
|
||||
GitHub token not specified. To check GitHub links reliably, use `--github-token` flag / `GITHUB_TOKEN` env var."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -309,7 +309,7 @@ mod cli {
|
|||
#[test]
|
||||
fn test_missing_file_error() {
|
||||
let mut cmd = main_command();
|
||||
let filename = format!("non-existing-file-{}", uuid::Uuid::new_v4().to_string());
|
||||
let filename = format!("non-existing-file-{}", uuid::Uuid::new_v4());
|
||||
|
||||
cmd.arg(&filename)
|
||||
.assert()
|
||||
|
|
@ -324,7 +324,7 @@ mod cli {
|
|||
#[test]
|
||||
fn test_missing_file_ok_if_skip_missing() {
|
||||
let mut cmd = main_command();
|
||||
let filename = format!("non-existing-file-{}", uuid::Uuid::new_v4().to_string());
|
||||
let filename = format!("non-existing-file-{}", uuid::Uuid::new_v4());
|
||||
|
||||
cmd.arg(&filename).arg("--skip-missing").assert().success();
|
||||
}
|
||||
|
|
@ -347,7 +347,7 @@ mod cli {
|
|||
.arg("--verbose")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............2"));
|
||||
.stdout(contains("2 Total"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -371,7 +371,7 @@ mod cli {
|
|||
.arg("--glob-ignore-case")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............2"));
|
||||
.stdout(contains("2 Total"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -394,7 +394,7 @@ mod cli {
|
|||
.arg("--verbose")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............1"));
|
||||
.stdout(contains("1 Total"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -432,7 +432,7 @@ mod cli {
|
|||
.arg(".*")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Excluded........11"));
|
||||
.stdout(contains("11 Excluded"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -448,7 +448,7 @@ mod cli {
|
|||
.arg("https://ldra.com/")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Excluded.........2"));
|
||||
.stdout(contains("2 Excluded"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -464,7 +464,7 @@ mod cli {
|
|||
.arg(excludes_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Excluded.........2"));
|
||||
.stdout(contains("2 Excluded"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -482,7 +482,7 @@ mod cli {
|
|||
.arg(excludes_path2)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Excluded.........3"));
|
||||
.stdout(contains("3 Excluded"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -512,7 +512,7 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............0"));
|
||||
.stdout(contains("0 Total"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ mod cli {
|
|||
.env_clear()
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Total............1"))
|
||||
.stdout(contains("1 Total"))
|
||||
.stdout(contains("foo.html"));
|
||||
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ impl Client {
|
|||
.repo(owner, repo)
|
||||
.get()
|
||||
.await
|
||||
.map_or_else(|e| e.into(), |_| Status::Ok(StatusCode::OK)),
|
||||
.map_or_else(std::convert::Into::into, |_| Status::Ok(StatusCode::OK)),
|
||||
None => ErrorKind::MissingGitHubToken.into(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ impl Display for ResponseBody {
|
|||
write!(f, " [{}]", code)
|
||||
}
|
||||
Status::Timeout(Some(code)) => write!(f, " [{}]", code),
|
||||
Status::Error(e) => write!(f, " ({})", e),
|
||||
Status::Error(e) => write!(f, ": {}", e),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue