mirror of
https://github.com/Hopiu/lychee.git
synced 2026-05-01 10:24:47 +00:00
Add response formatter; refactor stats formatter (#1398)
This adds support for formatting responses in different ways. For now, the options are: * `plain`: No color, basic formatting * `color`: Color, indented formatting (default) * `emoji`: Fancy mode with emoji icons Fixes #546 Related to #271
This commit is contained in:
parent
cc7acfb0e0
commit
dedc554eda
24 changed files with 714 additions and 239 deletions
10
README.md
10
README.md
|
|
@ -458,10 +458,17 @@ Options:
|
|||
-o, --output <OUTPUT>
|
||||
Output file of status report
|
||||
|
||||
--mode <MODE>
|
||||
Set the output display mode. Determines how results are presented in the terminal
|
||||
|
||||
[default: color]
|
||||
[possible values: plain, color, emoji]
|
||||
|
||||
-f, --format <FORMAT>
|
||||
Output format of final status report (compact, detailed, json, markdown)
|
||||
Output format of final status report
|
||||
|
||||
[default: compact]
|
||||
[possible values: compact, detailed, json, markdown, raw]
|
||||
|
||||
--require-https
|
||||
When HTTPS is available, treat HTTP links as errors
|
||||
|
|
@ -474,7 +481,6 @@ Options:
|
|||
|
||||
-V, --version
|
||||
Print version
|
||||
|
||||
```
|
||||
|
||||
### Exit codes
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use lychee_lib::{InputSource, Result};
|
|||
use lychee_lib::{ResponseBody, Status};
|
||||
|
||||
use crate::archive::{Archive, Suggestion};
|
||||
use crate::formatters::get_response_formatter;
|
||||
use crate::formatters::response::ResponseFormatter;
|
||||
use crate::verbosity::Verbosity;
|
||||
use crate::{cache::Cache, stats::ResponseStats, ExitCode};
|
||||
|
|
@ -62,11 +63,13 @@ where
|
|||
accept,
|
||||
));
|
||||
|
||||
let formatter = get_response_formatter(¶ms.cfg.mode);
|
||||
|
||||
let show_results_task = tokio::spawn(progress_bar_task(
|
||||
recv_resp,
|
||||
params.cfg.verbose,
|
||||
pb.clone(),
|
||||
Arc::new(params.formatter),
|
||||
formatter,
|
||||
stats,
|
||||
));
|
||||
|
||||
|
|
@ -178,11 +181,17 @@ async fn progress_bar_task(
|
|||
mut recv_resp: mpsc::Receiver<Response>,
|
||||
verbose: Verbosity,
|
||||
pb: Option<ProgressBar>,
|
||||
formatter: Arc<Box<dyn ResponseFormatter>>,
|
||||
formatter: Box<dyn ResponseFormatter>,
|
||||
mut stats: ResponseStats,
|
||||
) -> Result<(Option<ProgressBar>, ResponseStats)> {
|
||||
while let Some(response) = recv_resp.recv().await {
|
||||
show_progress(&mut io::stderr(), &pb, &response, &formatter, &verbose)?;
|
||||
show_progress(
|
||||
&mut io::stderr(),
|
||||
&pb,
|
||||
&response,
|
||||
formatter.as_ref(),
|
||||
&verbose,
|
||||
)?;
|
||||
stats.add(response);
|
||||
}
|
||||
Ok((pb, stats))
|
||||
|
|
@ -289,10 +298,11 @@ fn show_progress(
|
|||
output: &mut dyn Write,
|
||||
progress_bar: &Option<ProgressBar>,
|
||||
response: &Response,
|
||||
formatter: &Arc<Box<dyn ResponseFormatter>>,
|
||||
formatter: &dyn ResponseFormatter,
|
||||
verbose: &Verbosity,
|
||||
) -> Result<()> {
|
||||
let out = formatter.write_response(response)?;
|
||||
let out = formatter.format_response(response.body());
|
||||
|
||||
if let Some(pb) = progress_bar {
|
||||
pb.inc(1);
|
||||
pb.set_message(out.clone());
|
||||
|
|
@ -330,31 +340,26 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{formatters::get_response_formatter, options};
|
||||
use log::info;
|
||||
|
||||
use lychee_lib::{CacheStatus, ClientBuilder, InputSource, ResponseBody, Uri};
|
||||
|
||||
use crate::formatters;
|
||||
use lychee_lib::{CacheStatus, ClientBuilder, InputSource, Uri};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_skip_cached_responses_in_progress_output() {
|
||||
let mut buf = Vec::new();
|
||||
let response = Response(
|
||||
let response = Response::new(
|
||||
Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
Status::Cached(CacheStatus::Ok(200)),
|
||||
InputSource::Stdin,
|
||||
ResponseBody {
|
||||
uri: Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
status: Status::Cached(CacheStatus::Ok(200)),
|
||||
},
|
||||
);
|
||||
let formatter: Arc<Box<dyn ResponseFormatter>> =
|
||||
Arc::new(Box::new(formatters::response::Raw::new()));
|
||||
let formatter = get_response_formatter(&options::OutputMode::Plain);
|
||||
show_progress(
|
||||
&mut buf,
|
||||
&None,
|
||||
&response,
|
||||
&formatter,
|
||||
formatter.as_ref(),
|
||||
&Verbosity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -366,20 +371,24 @@ mod tests {
|
|||
#[test]
|
||||
fn test_show_cached_responses_in_progress_debug_output() {
|
||||
let mut buf = Vec::new();
|
||||
let response = Response(
|
||||
let response = Response::new(
|
||||
Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
Status::Cached(CacheStatus::Ok(200)),
|
||||
InputSource::Stdin,
|
||||
ResponseBody {
|
||||
uri: Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
status: Status::Cached(CacheStatus::Ok(200)),
|
||||
},
|
||||
);
|
||||
let formatter: Arc<Box<dyn ResponseFormatter>> =
|
||||
Arc::new(Box::new(formatters::response::Raw::new()));
|
||||
show_progress(&mut buf, &None, &response, &formatter, &Verbosity::debug()).unwrap();
|
||||
let formatter = get_response_formatter(&options::OutputMode::Plain);
|
||||
show_progress(
|
||||
&mut buf,
|
||||
&None,
|
||||
&response,
|
||||
formatter.as_ref(),
|
||||
&Verbosity::debug(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!buf.is_empty());
|
||||
let buf = String::from_utf8_lossy(&buf);
|
||||
assert_eq!(buf, "↻ [200] http://127.0.0.1/ | Cached: OK (cached)\n");
|
||||
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub(crate) use dump::dump_inputs;
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::cache::Cache;
|
||||
use crate::formatters::response::ResponseFormatter;
|
||||
use crate::options::Config;
|
||||
use lychee_lib::Result;
|
||||
use lychee_lib::{Client, Request};
|
||||
|
|
@ -18,6 +17,5 @@ pub(crate) struct CommandParams<S: futures::Stream<Item = Result<Request>>> {
|
|||
pub(crate) client: Client,
|
||||
pub(crate) cache: Arc<Cache>,
|
||||
pub(crate) requests: S,
|
||||
pub(crate) formatter: Box<dyn ResponseFormatter>,
|
||||
pub(crate) cfg: Config,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
//! Defines the colors used in the output of the CLI.
|
||||
|
||||
use console::Style;
|
||||
use log::Level;
|
||||
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().color256(82).bright());
|
||||
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(2).bold().bright());
|
||||
pub(crate) static BOLD_GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).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));
|
||||
pub(crate) static BOLD_PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bold());
|
||||
|
||||
// Used for debug log messages
|
||||
pub(crate) static BLUE: Lazy<Style> = Lazy::new(|| Style::new().blue().bright());
|
||||
|
||||
// Write output using predefined colors
|
||||
macro_rules! color {
|
||||
($f:ident, $color:ident, $text:tt, $($tts:tt)*) => {
|
||||
|
|
@ -18,4 +24,14 @@ macro_rules! color {
|
|||
};
|
||||
}
|
||||
|
||||
/// Returns the appropriate color for a given log level.
|
||||
pub(crate) fn color_for_level(level: Level) -> &'static Style {
|
||||
match level {
|
||||
Level::Error => &BOLD_PINK,
|
||||
Level::Warn => &BOLD_YELLOW,
|
||||
Level::Info | Level::Debug => &BLUE,
|
||||
Level::Trace => &DIM,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use color;
|
||||
83
lychee-bin/src/formatters/log.rs
Normal file
83
lychee-bin/src/formatters/log.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use env_logger::{Builder, Env};
|
||||
use log::LevelFilter;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::{
|
||||
formatters::{self, response::MAX_RESPONSE_OUTPUT_WIDTH},
|
||||
options::OutputMode,
|
||||
verbosity::Verbosity,
|
||||
};
|
||||
|
||||
/// Initialize the logging system with the given verbosity level.
|
||||
pub(crate) fn init_logging(verbose: &Verbosity, mode: &OutputMode) {
|
||||
// Set a base level for all modules to `warn`, which is a reasonable default.
|
||||
// It will be overridden by RUST_LOG if it's set.
|
||||
let env = Env::default().filter_or("RUST_LOG", "warn");
|
||||
|
||||
let mut builder = Builder::from_env(env);
|
||||
builder
|
||||
.format_timestamp(None)
|
||||
.format_module_path(false)
|
||||
.format_target(false);
|
||||
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
// Adjust the base log level filter based on the verbosity from CLI.
|
||||
// This applies to all modules not explicitly mentioned in RUST_LOG.
|
||||
let level_filter = verbose.log_level_filter();
|
||||
|
||||
// Apply a global filter. This ensures that, by default, other modules don't log at the debug level.
|
||||
builder.filter_level(LevelFilter::Info);
|
||||
|
||||
// Apply more specific filters to your own crates, enabling more verbose logging as per `-vv`.
|
||||
builder
|
||||
.filter_module("lychee", level_filter)
|
||||
.filter_module("lychee_lib", level_filter);
|
||||
}
|
||||
|
||||
// Calculate the longest log level text, including brackets.
|
||||
let max_level_text_width = log::LevelFilter::iter()
|
||||
.map(|level| level.as_str().len() + 2)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
// Customize the log message format according to the output mode
|
||||
if mode.is_plain() {
|
||||
// Explicitly disable colors for plain output
|
||||
builder.format(move |buf, record| writeln!(buf, "[{}] {}", record.level(), record.args()));
|
||||
} else if mode.is_emoji() {
|
||||
// Disable padding, keep colors
|
||||
builder.format(move |buf, record| {
|
||||
let level = record.level();
|
||||
let color = formatters::color::color_for_level(level);
|
||||
writeln!(
|
||||
buf,
|
||||
"{} {}",
|
||||
color.apply_to(format!("[{level}]")),
|
||||
record.args()
|
||||
)
|
||||
});
|
||||
} else {
|
||||
builder.format(move |buf, record| {
|
||||
let level = record.level();
|
||||
let level_text = format!("{level:5}");
|
||||
let padding = (MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(max_level_text_width)).max(0);
|
||||
let prefix = format!(
|
||||
"{:<width$}",
|
||||
format!("[{}]", level_text),
|
||||
width = max_level_text_width
|
||||
);
|
||||
let color = formatters::color::color_for_level(level);
|
||||
let colored_level = color.apply_to(&prefix);
|
||||
writeln!(
|
||||
buf,
|
||||
"{:<padding$}{} {}",
|
||||
"",
|
||||
colored_level,
|
||||
record.args(),
|
||||
padding = padding
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
builder.init();
|
||||
}
|
||||
|
|
@ -1,47 +1,41 @@
|
|||
pub(crate) mod color;
|
||||
pub(crate) mod duration;
|
||||
pub(crate) mod log;
|
||||
pub(crate) mod response;
|
||||
pub(crate) mod stats;
|
||||
|
||||
use lychee_lib::{CacheStatus, ResponseBody, Status};
|
||||
use self::{response::ResponseFormatter, stats::StatsFormatter};
|
||||
use crate::options::{OutputMode, StatsFormat};
|
||||
use supports_color::Stream;
|
||||
|
||||
use crate::{
|
||||
color::{DIM, GREEN, NORMAL, PINK, YELLOW},
|
||||
options::{self, Format},
|
||||
};
|
||||
|
||||
use self::response::ResponseFormatter;
|
||||
|
||||
/// Detects whether a terminal supports color, and gives details about that
|
||||
/// support. It takes into account the `NO_COLOR` environment variable.
|
||||
fn supports_color() -> bool {
|
||||
supports_color::on(Stream::Stdout).is_some()
|
||||
}
|
||||
|
||||
/// Color the response body for TTYs that support it
|
||||
pub(crate) fn color_response(body: &ResponseBody) -> String {
|
||||
if supports_color() {
|
||||
let out = match body.status {
|
||||
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => GREEN.apply_to(body),
|
||||
Status::Excluded
|
||||
| Status::Unsupported(_)
|
||||
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => {
|
||||
DIM.apply_to(body)
|
||||
}
|
||||
Status::Redirected(_) => NORMAL.apply_to(body),
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => YELLOW.apply_to(body),
|
||||
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => PINK.apply_to(body),
|
||||
};
|
||||
out.to_string()
|
||||
} else {
|
||||
body.to_string()
|
||||
/// Create a stats formatter based on the given format option
|
||||
pub(crate) fn get_stats_formatter(
|
||||
format: &StatsFormat,
|
||||
mode: &OutputMode,
|
||||
) -> Box<dyn StatsFormatter> {
|
||||
match format {
|
||||
StatsFormat::Compact => Box::new(stats::Compact::new(mode.clone())),
|
||||
StatsFormat::Detailed => Box::new(stats::Detailed::new(mode.clone())),
|
||||
StatsFormat::Json => Box::new(stats::Json::new()),
|
||||
StatsFormat::Markdown => Box::new(stats::Markdown::new()),
|
||||
StatsFormat::Raw => Box::new(stats::Raw::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a response formatter based on the given format option
|
||||
pub(crate) fn get_formatter(format: &options::Format) -> Box<dyn ResponseFormatter> {
|
||||
if matches!(format, Format::Raw) || !supports_color() {
|
||||
return Box::new(response::Raw::new());
|
||||
pub(crate) fn get_response_formatter(mode: &OutputMode) -> Box<dyn ResponseFormatter> {
|
||||
if !supports_color() {
|
||||
return Box::new(response::PlainFormatter);
|
||||
}
|
||||
match mode {
|
||||
OutputMode::Plain => Box::new(response::PlainFormatter),
|
||||
OutputMode::Color => Box::new(response::ColorFormatter),
|
||||
OutputMode::Emoji => Box::new(response::EmojiFormatter),
|
||||
}
|
||||
Box::new(response::Color::new())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,100 @@
|
|||
use crate::formatters::color_response;
|
||||
use lychee_lib::{CacheStatus, ResponseBody, Status};
|
||||
|
||||
use super::ResponseFormatter;
|
||||
use crate::formatters::color::{DIM, GREEN, NORMAL, PINK, YELLOW};
|
||||
|
||||
use lychee_lib::{Response, Result};
|
||||
use super::{ResponseFormatter, MAX_RESPONSE_OUTPUT_WIDTH};
|
||||
|
||||
/// A formatter which colors the response as long as that is supported by the
|
||||
/// environment (and not overwritten with `NO_COLOR=1`)
|
||||
pub(crate) struct Color;
|
||||
/// A colorized formatter for the response body
|
||||
///
|
||||
/// This formatter is used if the terminal supports color and the user
|
||||
/// has not explicitly requested raw, uncolored output.
|
||||
pub(crate) struct ColorFormatter;
|
||||
|
||||
impl Color {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {}
|
||||
impl ResponseFormatter for ColorFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
// Determine the color based on the status.
|
||||
let status_color = match body.status {
|
||||
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => &GREEN,
|
||||
Status::Excluded
|
||||
| Status::Unsupported(_)
|
||||
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => &DIM,
|
||||
Status::Redirected(_) => &NORMAL,
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW,
|
||||
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK,
|
||||
};
|
||||
|
||||
let status_formatted = format_status(&body.status);
|
||||
|
||||
let colored_status = status_color.apply_to(status_formatted);
|
||||
|
||||
// Construct the output.
|
||||
format!("{} {}", colored_status, body.uri)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseFormatter for Color {
|
||||
fn write_response(&self, response: &Response) -> Result<String> {
|
||||
Ok(color_response(&response.1))
|
||||
/// Format the status code or text for the color formatter.
|
||||
///
|
||||
/// Numeric status codes are right-aligned.
|
||||
/// Textual statuses are left-aligned.
|
||||
/// Padding is taken into account.
|
||||
fn format_status(status: &Status) -> String {
|
||||
let status_code_or_text = status.code_as_string();
|
||||
|
||||
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
|
||||
let padding = MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(status_code_or_text.len() + 2); // +2 for brackets
|
||||
|
||||
format!(
|
||||
"{}[{:>width$}]",
|
||||
" ".repeat(padding),
|
||||
status_code_or_text,
|
||||
width = status_code_or_text.len()
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{ErrorKind, Status, Uri};
|
||||
|
||||
// Helper function to create a ResponseBody with a given status and URI
|
||||
fn mock_response_body(status: Status, uri: &str) -> ResponseBody {
|
||||
ResponseBody {
|
||||
uri: Uri::try_from(uri).unwrap(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_ok_status() {
|
||||
let formatter = ColorFormatter;
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), "https://example.com");
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
" [200] https://example.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_error_status() {
|
||||
let formatter = ColorFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
" [ERROR] https://example.com/404"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_long_uri() {
|
||||
let formatter = ColorFormatter;
|
||||
let long_uri =
|
||||
"https://example.com/some/very/long/path/to/a/resource/that/exceeds/normal/lengths";
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), long_uri);
|
||||
let formatted_response = formatter.format_response(&body);
|
||||
assert!(formatted_response.contains(long_uri));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
95
lychee-bin/src/formatters/response/emoji.rs
Normal file
95
lychee-bin/src/formatters/response/emoji.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
use lychee_lib::{CacheStatus, ResponseBody, Status};
|
||||
|
||||
use super::ResponseFormatter;
|
||||
|
||||
/// An emoji formatter for the response body
|
||||
///
|
||||
/// This formatter replaces certain textual elements with emojis for a more
|
||||
/// visual output.
|
||||
pub(crate) struct EmojiFormatter;
|
||||
|
||||
impl ResponseFormatter for EmojiFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
let emoji = match body.status {
|
||||
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => "✅",
|
||||
Status::Excluded
|
||||
| Status::Unsupported(_)
|
||||
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => "🚫",
|
||||
Status::Redirected(_) => "↪️",
|
||||
Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️",
|
||||
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "❌",
|
||||
};
|
||||
format!("{} {}", emoji, body.uri)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod emoji_tests {
|
||||
use super::*;
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{ErrorKind, Status, Uri};
|
||||
|
||||
// Helper function to create a ResponseBody with a given status and URI
|
||||
fn mock_response_body(status: Status, uri: &str) -> ResponseBody {
|
||||
ResponseBody {
|
||||
uri: Uri::try_from(uri).unwrap(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_ok_status() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), "https://example.com");
|
||||
assert_eq!(formatter.format_response(&body), "✅ https://example.com/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_error_status() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"❌ https://example.com/404"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_excluded_status() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(Status::Excluded, "https://example.com/not-checked");
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"🚫 https://example.com/not-checked"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_redirect_status() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Redirected(StatusCode::MOVED_PERMANENTLY),
|
||||
"https://example.com/redirect",
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"↪️ https://example.com/redirect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_unknown_status_code() {
|
||||
let formatter = EmojiFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()),
|
||||
"https://example.com/unknown",
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"⚠️ https://example.com/unknown"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,28 @@
|
|||
use lychee_lib::{Response, Result};
|
||||
use lychee_lib::ResponseBody;
|
||||
|
||||
pub(crate) mod color;
|
||||
pub(crate) mod raw;
|
||||
mod color;
|
||||
mod emoji;
|
||||
mod plain;
|
||||
|
||||
pub(crate) use color::Color;
|
||||
pub(crate) use raw::Raw;
|
||||
pub(crate) use color::ColorFormatter;
|
||||
pub(crate) use emoji::EmojiFormatter;
|
||||
pub(crate) use plain::PlainFormatter;
|
||||
|
||||
/// A `ResponseFormatter` knows how to format a response for different output
|
||||
/// preferences based on user settings or the environment
|
||||
/// Desired total width of formatted string for color formatter
|
||||
///
|
||||
/// The longest string, which needs to be formatted, is currently `[Excluded]`
|
||||
/// which is 10 characters long (including brackets).
|
||||
///
|
||||
/// Keep in sync with `Status::code_as_string`, which converts status codes to
|
||||
/// strings.
|
||||
pub(crate) const MAX_RESPONSE_OUTPUT_WIDTH: usize = 10;
|
||||
|
||||
/// A trait for formatting a response body
|
||||
///
|
||||
/// This trait is used to convert response body into a human-readable string.
|
||||
/// It can be implemented for different formatting styles such as
|
||||
/// colorized output or plaintext.
|
||||
pub(crate) trait ResponseFormatter: Send + Sync {
|
||||
/// Format a single link check response and write it to stdout
|
||||
fn write_response(&self, response: &Response) -> Result<String>;
|
||||
/// Format the response body into a human-readable string
|
||||
fn format_response(&self, body: &ResponseBody) -> String;
|
||||
}
|
||||
|
|
|
|||
97
lychee-bin/src/formatters/response/plain.rs
Normal file
97
lychee-bin/src/formatters/response/plain.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use lychee_lib::ResponseBody;
|
||||
|
||||
use super::ResponseFormatter;
|
||||
|
||||
/// A basic formatter that just returns the response body as a string
|
||||
/// without any color codes or other formatting.
|
||||
///
|
||||
/// Under the hood, it calls the `Display` implementation of the `ResponseBody`
|
||||
/// type.
|
||||
///
|
||||
/// This formatter is used when the user has requested raw output
|
||||
/// or when the terminal does not support color.
|
||||
pub(crate) struct PlainFormatter;
|
||||
|
||||
impl ResponseFormatter for PlainFormatter {
|
||||
fn format_response(&self, body: &ResponseBody) -> String {
|
||||
body.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plain_tests {
|
||||
use super::*;
|
||||
use http::StatusCode;
|
||||
use lychee_lib::{ErrorKind, Status, Uri};
|
||||
|
||||
// Helper function to create a ResponseBody with a given status and URI
|
||||
fn mock_response_body(status: Status, uri: &str) -> ResponseBody {
|
||||
ResponseBody {
|
||||
uri: Uri::try_from(uri).unwrap(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_ok_status() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(Status::Ok(StatusCode::OK), "https://example.com");
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[200] https://example.com/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_error_status() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Error(ErrorKind::InvalidUrlHost),
|
||||
"https://example.com/404",
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[ERROR] https://example.com/404 | Failed: URL is missing a host"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_excluded_status() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(Status::Excluded, "https://example.com/not-checked");
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[EXCLUDED] https://example.com/not-checked | Excluded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_redirect_status() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::Redirected(StatusCode::MOVED_PERMANENTLY),
|
||||
"https://example.com/redirect",
|
||||
);
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[301] https://example.com/redirect | Redirect (301 Moved Permanently): Moved Permanently"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_response_with_unknown_status_code() {
|
||||
let formatter = PlainFormatter;
|
||||
let body = mock_response_body(
|
||||
Status::UnknownStatusCode(StatusCode::from_u16(999).unwrap()),
|
||||
"https://example.com/unknown",
|
||||
);
|
||||
assert_eq!(formatter.format_response(&body), body.to_string());
|
||||
// Check the actual string representation of the status code
|
||||
assert_eq!(
|
||||
formatter.format_response(&body),
|
||||
"[999] https://example.com/unknown | Unknown status (999 <unknown status code>)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
use super::ResponseFormatter;
|
||||
|
||||
use lychee_lib::{Response, Result};
|
||||
|
||||
/// Formatter which returns an unmodified response status
|
||||
pub(crate) struct Raw;
|
||||
|
||||
impl Raw {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Raw {}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseFormatter for Raw {
|
||||
fn write_response(&self, response: &Response) -> Result<String> {
|
||||
Ok(response.1.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +1,22 @@
|
|||
use anyhow::Result;
|
||||
use std::{
|
||||
fmt::{self, Display},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
color::{color, BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, DIM, NORMAL},
|
||||
formatters::color_response,
|
||||
stats::ResponseStats,
|
||||
};
|
||||
use crate::formatters::color::{color, BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, DIM, NORMAL};
|
||||
use crate::{formatters::get_response_formatter, options, stats::ResponseStats};
|
||||
|
||||
use super::StatsFormatter;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
struct CompactResponseStats(ResponseStats);
|
||||
struct CompactResponseStats {
|
||||
stats: ResponseStats,
|
||||
mode: options::OutputMode,
|
||||
}
|
||||
|
||||
impl Display for CompactResponseStats {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = &self.0;
|
||||
let stats = &self.stats;
|
||||
|
||||
if !stats.fail_map.is_empty() {
|
||||
let input = if stats.fail_map.len() == 1 {
|
||||
|
|
@ -33,10 +32,13 @@ impl Display for CompactResponseStats {
|
|||
stats.fail_map.len()
|
||||
)?;
|
||||
}
|
||||
|
||||
let response_formatter = get_response_formatter(&self.mode);
|
||||
|
||||
for (source, responses) in &stats.fail_map {
|
||||
color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
|
||||
for response in responses {
|
||||
writeln!(f, "{}", color_response(response))?;
|
||||
writeln!(f, "{}", response_formatter.format_response(response))?;
|
||||
}
|
||||
|
||||
if let Some(suggestions) = &stats.suggestion_map.get(source) {
|
||||
|
|
@ -68,17 +70,22 @@ impl Display for CompactResponseStats {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Compact;
|
||||
pub(crate) struct Compact {
|
||||
mode: options::OutputMode,
|
||||
}
|
||||
|
||||
impl Compact {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {}
|
||||
pub(crate) const fn new(mode: options::OutputMode) -> Self {
|
||||
Self { mode }
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsFormatter for Compact {
|
||||
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
let compact = CompactResponseStats(stats);
|
||||
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
let compact = CompactResponseStats {
|
||||
stats,
|
||||
mode: self.mode.clone(),
|
||||
};
|
||||
Ok(Some(compact.to_string()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use super::StatsFormatter;
|
||||
use crate::{formatters::color_response, stats::ResponseStats};
|
||||
use crate::{formatters::get_response_formatter, options, stats::ResponseStats};
|
||||
|
||||
use anyhow::Result;
|
||||
use pad::{Alignment, PadStr};
|
||||
|
|
@ -24,9 +24,17 @@ fn write_stat(f: &mut fmt::Formatter, title: &str, stat: usize, newline: bool) -
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A wrapper struct that combines `ResponseStats` with an additional `OutputMode`.
|
||||
/// Multiple `Display` implementations are not allowed for `ResponseStats`, so this struct is used to
|
||||
/// encapsulate additional context.
|
||||
struct DetailedResponseStats {
|
||||
stats: ResponseStats,
|
||||
mode: options::OutputMode,
|
||||
}
|
||||
|
||||
impl Display for DetailedResponseStats {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let stats = &self.0;
|
||||
let stats = &self.stats;
|
||||
let separator = "-".repeat(MAX_PADDING + 1);
|
||||
|
||||
writeln!(f, "\u{1f4dd} Summary")?; // 📝
|
||||
|
|
@ -39,12 +47,15 @@ impl Display for DetailedResponseStats {
|
|||
write_stat(f, "\u{2753} Unknown", stats.unknown, true)?; //❓
|
||||
write_stat(f, "\u{1f6ab} Errors", stats.errors, false)?; // 🚫
|
||||
|
||||
let response_formatter = get_response_formatter(&self.mode);
|
||||
|
||||
for (source, 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 {source}")?;
|
||||
|
||||
for response in responses {
|
||||
write!(f, "\n{}", color_response(response))?;
|
||||
write!(f, "\n{}", response_formatter.format_response(response))?;
|
||||
|
||||
if let Some(suggestions) = &stats.suggestion_map.get(source) {
|
||||
writeln!(f, "\nSuggestions in {source}")?;
|
||||
|
|
@ -59,21 +70,22 @@ impl Display for DetailedResponseStats {
|
|||
}
|
||||
}
|
||||
|
||||
/// Wrap as newtype because multiple `Display` implementations are not allowed
|
||||
/// for `ResponseStats`
|
||||
struct DetailedResponseStats(ResponseStats);
|
||||
|
||||
pub(crate) struct Detailed;
|
||||
pub(crate) struct Detailed {
|
||||
mode: options::OutputMode,
|
||||
}
|
||||
|
||||
impl Detailed {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self
|
||||
pub(crate) const fn new(mode: options::OutputMode) -> Self {
|
||||
Self { mode }
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsFormatter for Detailed {
|
||||
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
let detailed = DetailedResponseStats(stats);
|
||||
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
let detailed = DetailedResponseStats {
|
||||
stats,
|
||||
mode: self.mode.clone(),
|
||||
};
|
||||
Ok(Some(detailed.to_string()))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ impl Json {
|
|||
|
||||
impl StatsFormatter for Json {
|
||||
/// Format stats as JSON object
|
||||
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
serde_json::to_string_pretty(&stats)
|
||||
.map(Some)
|
||||
.context("Cannot format stats as JSON")
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ impl Markdown {
|
|||
}
|
||||
|
||||
impl StatsFormatter for Markdown {
|
||||
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
|
||||
let markdown = MarkdownResponseStats(stats);
|
||||
Ok(Some(markdown.to_string()))
|
||||
}
|
||||
|
|
@ -222,12 +222,10 @@ mod tests {
|
|||
#[test]
|
||||
fn test_render_summary() {
|
||||
let mut stats = ResponseStats::default();
|
||||
let response = Response(
|
||||
let response = Response::new(
|
||||
Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
Status::Cached(CacheStatus::Error(Some(404))),
|
||||
InputSource::Stdin,
|
||||
ResponseBody {
|
||||
uri: Uri::try_from("http://127.0.0.1").unwrap(),
|
||||
status: Status::Cached(CacheStatus::Error(Some(404))),
|
||||
},
|
||||
);
|
||||
stats.add(response);
|
||||
stats
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@ use anyhow::Result;
|
|||
|
||||
pub(crate) trait StatsFormatter {
|
||||
/// Format the stats of all responses and write them to stdout
|
||||
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>>;
|
||||
fn format(&self, stats: ResponseStats) -> Result<Option<String>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ impl Raw {
|
|||
|
||||
impl StatsFormatter for Raw {
|
||||
/// Don't print stats in raw mode
|
||||
fn format_stats(&self, _stats: ResponseStats) -> Result<Option<String>> {
|
||||
fn format(&self, _stats: ResponseStats) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,9 +65,8 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::{bail, Context, Error, Result};
|
||||
use clap::Parser;
|
||||
use color::YELLOW;
|
||||
use commands::CommandParams;
|
||||
use formatters::response::ResponseFormatter;
|
||||
use formatters::{get_stats_formatter, log::init_logging};
|
||||
use log::{error, info, warn};
|
||||
|
||||
#[cfg(feature = "native-tls")]
|
||||
|
|
@ -83,7 +82,6 @@ use lychee_lib::CookieJar;
|
|||
mod archive;
|
||||
mod cache;
|
||||
mod client;
|
||||
mod color;
|
||||
mod commands;
|
||||
mod formatters;
|
||||
mod options;
|
||||
|
|
@ -92,12 +90,12 @@ mod stats;
|
|||
mod time;
|
||||
mod verbosity;
|
||||
|
||||
use crate::formatters::color;
|
||||
use crate::formatters::duration::Duration;
|
||||
use crate::{
|
||||
cache::{Cache, StoreExt},
|
||||
color::color,
|
||||
formatters::stats::StatsFormatter,
|
||||
options::{Config, Format, LycheeOptions, LYCHEE_CACHE_FILE, LYCHEE_IGNORE_FILE},
|
||||
options::{Config, LycheeOptions, LYCHEE_CACHE_FILE, LYCHEE_IGNORE_FILE},
|
||||
};
|
||||
|
||||
/// A C-like enum that can be cast to `i32` and used as process exit code.
|
||||
|
|
@ -143,15 +141,7 @@ fn read_lines(file: &File) -> Result<Vec<String>> {
|
|||
fn load_config() -> Result<LycheeOptions> {
|
||||
let mut opts = LycheeOptions::parse();
|
||||
|
||||
env_logger::Builder::new()
|
||||
// super basic formatting; no timestamps, no module path, no target
|
||||
.format_timestamp(None)
|
||||
.format_indent(Some(0))
|
||||
.format_module_path(false)
|
||||
.format_target(false)
|
||||
.filter_module("lychee", opts.config.verbose.log_level_filter())
|
||||
.filter_module("lychee_lib", opts.config.verbose.log_level_filter())
|
||||
.init();
|
||||
init_logging(&opts.config.verbose, &opts.config.mode);
|
||||
|
||||
// Load a potentially existing config file and merge it into the config from
|
||||
// the CLI
|
||||
|
|
@ -333,16 +323,12 @@ async fn run(opts: &LycheeOptions) -> Result<i32> {
|
|||
)
|
||||
})?;
|
||||
|
||||
let response_formatter: Box<dyn ResponseFormatter> =
|
||||
formatters::get_formatter(&opts.config.format);
|
||||
|
||||
let client = client::create(&opts.config, cookie_jar.as_deref())?;
|
||||
|
||||
let params = CommandParams {
|
||||
client,
|
||||
cache,
|
||||
requests,
|
||||
formatter: response_formatter,
|
||||
cfg: opts.config.clone(),
|
||||
};
|
||||
|
||||
|
|
@ -357,19 +343,15 @@ async fn run(opts: &LycheeOptions) -> Result<i32> {
|
|||
.flatten()
|
||||
.any(|body| body.uri.domain() == Some("github.com"));
|
||||
|
||||
let writer: Box<dyn StatsFormatter> = match opts.config.format {
|
||||
Format::Compact => Box::new(formatters::stats::Compact::new()),
|
||||
Format::Detailed => Box::new(formatters::stats::Detailed::new()),
|
||||
Format::Json => Box::new(formatters::stats::Json::new()),
|
||||
Format::Markdown => Box::new(formatters::stats::Markdown::new()),
|
||||
Format::Raw => Box::new(formatters::stats::Raw::new()),
|
||||
};
|
||||
let is_empty = stats.is_empty();
|
||||
let formatted = writer.format_stats(stats)?;
|
||||
let stats_formatter: Box<dyn StatsFormatter> =
|
||||
get_stats_formatter(&opts.config.format, &opts.config.mode);
|
||||
|
||||
if let Some(formatted) = formatted {
|
||||
let is_empty = stats.is_empty();
|
||||
let formatted_stats = stats_formatter.format(stats)?;
|
||||
|
||||
if let Some(formatted_stats) = formatted_stats {
|
||||
if let Some(output) = &opts.config.output {
|
||||
fs::write(output, formatted).context("Cannot write status output to file")?;
|
||||
fs::write(output, formatted_stats).context("Cannot write status output to file")?;
|
||||
} else {
|
||||
if opts.config.verbose.log_level() >= log::Level::Info && !is_empty {
|
||||
// separate summary from the verbose list of links above
|
||||
|
|
@ -377,13 +359,12 @@ async fn run(opts: &LycheeOptions) -> Result<i32> {
|
|||
writeln!(io::stdout())?;
|
||||
}
|
||||
// we assume that the formatted stats don't have a final newline
|
||||
writeln!(io::stdout(), "{formatted}")?;
|
||||
writeln!(io::stdout(), "{formatted_stats}")?;
|
||||
}
|
||||
}
|
||||
|
||||
if github_issues && opts.config.github_token.is_none() {
|
||||
let mut handle = io::stderr();
|
||||
color!(handle, YELLOW, "\u{1f4a1} There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",)?;
|
||||
warn!("There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",);
|
||||
}
|
||||
|
||||
if opts.config.cache {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use crate::archive::Archive;
|
|||
use crate::parse::parse_base;
|
||||
use crate::verbosity::Verbosity;
|
||||
use anyhow::{anyhow, Context, Error, Result};
|
||||
use clap::builder::PossibleValuesParser;
|
||||
use clap::{arg, builder::TypedValueParser, Parser};
|
||||
use const_format::{concatcp, formatcp};
|
||||
use lychee_lib::{
|
||||
|
|
@ -12,7 +13,7 @@ use secrecy::{ExposeSecret, SecretString};
|
|||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
use std::{fs, path::PathBuf, str::FromStr, time::Duration};
|
||||
use strum::VariantNames;
|
||||
use strum::{Display, EnumIter, EnumString, VariantNames};
|
||||
|
||||
pub(crate) const LYCHEE_IGNORE_FILE: &str = ".lycheeignore";
|
||||
pub(crate) const LYCHEE_CACHE_FILE: &str = ".lycheecache";
|
||||
|
|
@ -44,8 +45,11 @@ const HELP_MSG_CONFIG_FILE: &str = formatcp!(
|
|||
const TIMEOUT_STR: &str = concatcp!(DEFAULT_TIMEOUT_SECS);
|
||||
const RETRY_WAIT_TIME_STR: &str = concatcp!(DEFAULT_RETRY_WAIT_TIME_SECS);
|
||||
|
||||
#[derive(Debug, Deserialize, Default, Clone)]
|
||||
pub(crate) enum Format {
|
||||
/// The format to use for the final status report
|
||||
#[derive(Debug, Deserialize, Default, Clone, Display, EnumIter, VariantNames)]
|
||||
#[non_exhaustive]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub(crate) enum StatsFormat {
|
||||
#[default]
|
||||
Compact,
|
||||
Detailed,
|
||||
|
|
@ -54,20 +58,70 @@ pub(crate) enum Format {
|
|||
Raw,
|
||||
}
|
||||
|
||||
impl FromStr for Format {
|
||||
impl FromStr for StatsFormat {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(format: &str) -> Result<Self, Self::Err> {
|
||||
match format.to_lowercase().as_str() {
|
||||
"compact" | "string" => Ok(Format::Compact),
|
||||
"detailed" => Ok(Format::Detailed),
|
||||
"json" => Ok(Format::Json),
|
||||
"markdown" | "md" => Ok(Format::Markdown),
|
||||
"raw" => Ok(Format::Raw),
|
||||
"compact" | "string" => Ok(StatsFormat::Compact),
|
||||
"detailed" => Ok(StatsFormat::Detailed),
|
||||
"json" => Ok(StatsFormat::Json),
|
||||
"markdown" | "md" => Ok(StatsFormat::Markdown),
|
||||
"raw" => Ok(StatsFormat::Raw),
|
||||
_ => Err(anyhow!("Unknown format {}", format)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The different formatter modes
|
||||
///
|
||||
/// This decides over whether to use color,
|
||||
/// emojis, or plain text for the output.
|
||||
#[derive(Debug, Deserialize, Default, Clone, Display, EnumIter, EnumString, VariantNames)]
|
||||
#[non_exhaustive]
|
||||
pub(crate) enum OutputMode {
|
||||
/// Plain text output.
|
||||
///
|
||||
/// This is the most basic output mode for terminals that do not support
|
||||
/// color or emojis. It can also be helpful for scripting or when you want
|
||||
/// to pipe the output to another program.
|
||||
#[serde(rename = "plain")]
|
||||
#[strum(serialize = "plain", ascii_case_insensitive)]
|
||||
Plain,
|
||||
|
||||
/// Colorful output.
|
||||
///
|
||||
/// This mode uses colors to highlight the status of the requests.
|
||||
/// It is useful for terminals that support colors and you want to
|
||||
/// provide a more visually appealing output.
|
||||
///
|
||||
/// This is the default output mode.
|
||||
#[serde(rename = "color")]
|
||||
#[strum(serialize = "color", ascii_case_insensitive)]
|
||||
#[default]
|
||||
Color,
|
||||
|
||||
/// Emoji output.
|
||||
///
|
||||
/// This mode uses emojis to represent the status of the requests.
|
||||
/// Some people may find this mode more intuitive and fun to use.
|
||||
#[serde(rename = "emoji")]
|
||||
#[strum(serialize = "emoji", ascii_case_insensitive)]
|
||||
Emoji,
|
||||
}
|
||||
|
||||
impl OutputMode {
|
||||
/// Returns `true` if the response format is `Plain`
|
||||
pub(crate) const fn is_plain(&self) -> bool {
|
||||
matches!(self, OutputMode::Plain)
|
||||
}
|
||||
|
||||
/// Returns `true` if the response format is `Emoji`
|
||||
pub(crate) const fn is_emoji(&self) -> bool {
|
||||
matches!(self, OutputMode::Emoji)
|
||||
}
|
||||
}
|
||||
|
||||
// Macro for generating default functions to be used by serde
|
||||
macro_rules! default_function {
|
||||
( $( $name:ident : $T:ty = $e:expr; )* ) => {
|
||||
|
|
@ -105,12 +159,12 @@ macro_rules! fold_in {
|
|||
};
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
/// A fast, async link checker
|
||||
///
|
||||
/// Finds broken URLs and mail addresses inside Markdown, HTML,
|
||||
/// `reStructuredText`, websites and more!
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about)]
|
||||
pub(crate) struct LycheeOptions {
|
||||
/// The inputs (where to get links to check from).
|
||||
/// These can be: files (e.g. `README.md`), file globs (e.g. `"~/git/*/README.md"`),
|
||||
|
|
@ -147,6 +201,7 @@ impl LycheeOptions {
|
|||
}
|
||||
}
|
||||
|
||||
/// The main configuration for lychee
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Parser, Debug, Deserialize, Clone, Default)]
|
||||
pub(crate) struct Config {
|
||||
|
|
@ -190,7 +245,7 @@ pub(crate) struct Config {
|
|||
|
||||
/// Specify the use of a specific web archive.
|
||||
/// Can be used in combination with `--suggest`
|
||||
#[arg(long, value_parser = clap::builder::PossibleValuesParser::new(Archive::VARIANTS).map(|s| s.parse::<Archive>().unwrap()))]
|
||||
#[arg(long, value_parser = PossibleValuesParser::new(Archive::VARIANTS).map(|s| s.parse::<Archive>().unwrap()))]
|
||||
#[serde(default)]
|
||||
pub(crate) archive: Option<Archive>,
|
||||
|
||||
|
|
@ -398,10 +453,15 @@ separated list of accepted status codes. This example will accept 200, 201,
|
|||
#[serde(default)]
|
||||
pub(crate) output: Option<PathBuf>,
|
||||
|
||||
/// Output format of final status report (compact, detailed, json, markdown)
|
||||
#[arg(short, long, default_value = "compact")]
|
||||
/// Set the output display mode. Determines how results are presented in the terminal
|
||||
#[arg(long, default_value = "color", value_parser = PossibleValuesParser::new(OutputMode::VARIANTS).map(|s| s.parse::<OutputMode>().unwrap()))]
|
||||
#[serde(default)]
|
||||
pub(crate) format: Format,
|
||||
pub(crate) mode: OutputMode,
|
||||
|
||||
/// Output format of final status report
|
||||
#[arg(short, long, default_value = "compact", value_parser = PossibleValuesParser::new(StatsFormat::VARIANTS).map(|s| s.parse::<StatsFormat>().unwrap()))]
|
||||
#[serde(default)]
|
||||
pub(crate) format: StatsFormat,
|
||||
|
||||
/// When HTTPS is available, treat HTTP links as errors
|
||||
#[arg(long)]
|
||||
|
|
|
|||
|
|
@ -7,27 +7,53 @@ use crate::archive::Suggestion;
|
|||
use lychee_lib::{CacheStatus, InputSource, Response, ResponseBody, Status};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Response statistics
|
||||
///
|
||||
/// This struct contains various counters for the responses received during a
|
||||
/// run. It also contains maps to store the responses for each status (success,
|
||||
/// fail, excluded, etc.) and the sources of the responses.
|
||||
///
|
||||
/// The `detailed_stats` field is used to enable or disable the storage of the
|
||||
/// responses in the maps for successful and excluded responses. If it's set to
|
||||
/// `false`, the maps will be empty and only the counters will be updated.
|
||||
#[derive(Default, Serialize, Debug)]
|
||||
pub(crate) struct ResponseStats {
|
||||
/// Total number of responses
|
||||
pub(crate) total: usize,
|
||||
/// Number of successful responses
|
||||
pub(crate) successful: usize,
|
||||
/// Number of responses with an unknown status code
|
||||
pub(crate) unknown: usize,
|
||||
/// Number of responses, which lychee does not support right now
|
||||
pub(crate) unsupported: usize,
|
||||
/// Number of timeouts
|
||||
pub(crate) timeouts: usize,
|
||||
/// Redirects encountered while checking links
|
||||
pub(crate) redirects: usize,
|
||||
/// Number of links excluded from the run (e.g. due to the `--exclude` flag)
|
||||
pub(crate) excludes: usize,
|
||||
/// Number of responses with an error status
|
||||
pub(crate) errors: usize,
|
||||
/// Number of responses that were cached from a previous run
|
||||
pub(crate) cached: usize,
|
||||
/// Map to store successful responses (if `detailed_stats` is enabled)
|
||||
pub(crate) success_map: HashMap<InputSource, HashSet<ResponseBody>>,
|
||||
/// Map to store failed responses (if `detailed_stats` is enabled)
|
||||
pub(crate) fail_map: HashMap<InputSource, HashSet<ResponseBody>>,
|
||||
/// Replacement suggestions for failed responses (if `--suggest` is enabled)
|
||||
pub(crate) suggestion_map: HashMap<InputSource, HashSet<Suggestion>>,
|
||||
/// Map to store excluded responses (if `detailed_stats` is enabled)
|
||||
pub(crate) excluded_map: HashMap<InputSource, HashSet<ResponseBody>>,
|
||||
/// Used to store the duration of the run in seconds.
|
||||
pub(crate) duration_secs: u64,
|
||||
/// Also track successful and excluded responses
|
||||
pub(crate) detailed_stats: bool,
|
||||
}
|
||||
|
||||
impl ResponseStats {
|
||||
#[inline]
|
||||
/// Create a new `ResponseStats` instance with extended statistics counters
|
||||
/// enabled
|
||||
pub(crate) fn extended() -> Self {
|
||||
Self {
|
||||
detailed_stats: true,
|
||||
|
|
@ -35,6 +61,10 @@ impl ResponseStats {
|
|||
}
|
||||
}
|
||||
|
||||
/// Increment the counters for the given status
|
||||
///
|
||||
/// This function is used to update the counters (success, error, etc.)
|
||||
/// based on the given response status.
|
||||
pub(crate) fn increment_status_counters(&mut self, status: &Status) {
|
||||
match status {
|
||||
Status::Ok(_) => self.successful += 1,
|
||||
|
|
@ -56,35 +86,34 @@ impl ResponseStats {
|
|||
}
|
||||
}
|
||||
|
||||
/// Add a response status to the appropriate map (success, fail, excluded)
|
||||
fn add_response_status(&mut self, response: Response) {
|
||||
let status = response.status();
|
||||
let source = response.source().clone();
|
||||
let status_map_entry = match status {
|
||||
_ if status.is_error() => self.fail_map.entry(source).or_default(),
|
||||
Status::Ok(_) if self.detailed_stats => self.success_map.entry(source).or_default(),
|
||||
Status::Excluded if self.detailed_stats => self.excluded_map.entry(source).or_default(),
|
||||
_ => return,
|
||||
};
|
||||
status_map_entry.insert(response.1);
|
||||
}
|
||||
|
||||
/// Update the stats with a new response
|
||||
pub(crate) fn add(&mut self, response: Response) {
|
||||
self.total += 1;
|
||||
|
||||
let Response(source, ResponseBody { ref status, .. }) = response;
|
||||
self.increment_status_counters(status);
|
||||
|
||||
match status {
|
||||
_ if status.is_error() => {
|
||||
let fail = self.fail_map.entry(source).or_default();
|
||||
fail.insert(response.1);
|
||||
}
|
||||
Status::Ok(_) if self.detailed_stats => {
|
||||
let success = self.success_map.entry(source).or_default();
|
||||
success.insert(response.1);
|
||||
}
|
||||
Status::Excluded if self.detailed_stats => {
|
||||
let excluded = self.excluded_map.entry(source).or_default();
|
||||
excluded.insert(response.1);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
self.increment_status_counters(response.status());
|
||||
self.add_response_status(response);
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Check if the entire run was successful
|
||||
pub(crate) const fn is_success(&self) -> bool {
|
||||
self.total == self.successful + self.excludes + self.unsupported
|
||||
}
|
||||
|
||||
#[inline]
|
||||
/// Check if no responses were received
|
||||
pub(crate) const fn is_empty(&self) -> bool {
|
||||
self.total == 0
|
||||
}
|
||||
|
|
@ -110,8 +139,7 @@ mod tests {
|
|||
// and it's a lot faster to just generate a fake response
|
||||
fn mock_response(status: Status) -> Response {
|
||||
let uri = website("https://some-url.com/ok");
|
||||
let response_body = ResponseBody { uri, status };
|
||||
Response(InputSource::Stdin, response_body)
|
||||
Response::new(uri, status, InputSource::Stdin)
|
||||
}
|
||||
|
||||
fn dummy_ok() -> Response {
|
||||
|
|
@ -145,9 +173,9 @@ mod tests {
|
|||
stats.add(dummy_error());
|
||||
stats.add(dummy_ok());
|
||||
|
||||
let Response(source, body) = dummy_error();
|
||||
let response = dummy_error();
|
||||
let expected_fail_map: HashMap<InputSource, HashSet<ResponseBody>> =
|
||||
HashMap::from_iter([(source, HashSet::from_iter([body]))]);
|
||||
HashMap::from_iter([(response.source().clone(), HashSet::from_iter([response.1]))]);
|
||||
assert_eq!(stats.fail_map, expected_fail_map);
|
||||
|
||||
assert!(stats.success_map.is_empty());
|
||||
|
|
@ -165,21 +193,27 @@ mod tests {
|
|||
stats.add(dummy_ok());
|
||||
|
||||
let mut expected_fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
let Response(source, response_body) = dummy_error();
|
||||
let entry = expected_fail_map.entry(source).or_default();
|
||||
entry.insert(response_body);
|
||||
let response = dummy_error();
|
||||
let entry = expected_fail_map
|
||||
.entry(response.source().clone())
|
||||
.or_default();
|
||||
entry.insert(response.1);
|
||||
assert_eq!(stats.fail_map, expected_fail_map);
|
||||
|
||||
let mut expected_success_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
let Response(source, response_body) = dummy_ok();
|
||||
let entry = expected_success_map.entry(source).or_default();
|
||||
entry.insert(response_body);
|
||||
let response = dummy_ok();
|
||||
let entry = expected_success_map
|
||||
.entry(response.source().clone())
|
||||
.or_default();
|
||||
entry.insert(response.1);
|
||||
assert_eq!(stats.success_map, expected_success_map);
|
||||
|
||||
let mut expected_excluded_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
|
||||
let Response(source, response_body) = dummy_excluded();
|
||||
let entry = expected_excluded_map.entry(source).or_default();
|
||||
entry.insert(response_body);
|
||||
let response = dummy_excluded();
|
||||
let entry = expected_excluded_map
|
||||
.entry(response.source().clone())
|
||||
.or_default();
|
||||
entry.insert(response.1);
|
||||
assert_eq!(stats.excluded_map, expected_excluded_map);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,11 +153,13 @@ mod cli {
|
|||
// Parse site error status from the fail_map
|
||||
let output_json = serde_json::from_slice::<Value>(&output.stdout).unwrap();
|
||||
let site_error_status = &output_json["fail_map"][&test_path.to_str().unwrap()][0]["status"];
|
||||
let error_details = site_error_status["details"].to_string();
|
||||
|
||||
assert!(error_details
|
||||
.contains("error:0A000086:SSL routines:tls_post_process_server_certificate:"));
|
||||
assert!(error_details.contains("(certificate has expired)"));
|
||||
assert_eq!(
|
||||
"error:0A000086:SSL routines:tls_post_process_server_certificate:\
|
||||
certificate verify failed:../ssl/statem/statem_clnt.c:1883: \
|
||||
(certificate has expired)",
|
||||
site_error_status["details"]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -507,7 +507,7 @@ impl Client {
|
|||
let status = match uri.scheme() {
|
||||
_ if uri.is_file() => self.check_file(uri).await,
|
||||
_ if uri.is_mail() => self.check_mail(uri).await,
|
||||
_ if uri.is_tel() => self.check_tel(uri).await,
|
||||
_ if uri.is_tel() => Status::Excluded,
|
||||
_ => self.check_website(uri, default_chain).await?,
|
||||
};
|
||||
|
||||
|
|
@ -726,14 +726,6 @@ impl Client {
|
|||
pub async fn check_mail(&self, _uri: &Uri) -> Status {
|
||||
Status::Excluded
|
||||
}
|
||||
|
||||
/// Check a tel
|
||||
///
|
||||
/// This implementation simply excludes all tel.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn check_tel(&self, _uri: &Uri) -> Status {
|
||||
Status::Excluded
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the given `Url` would cause `reqwest` to panic.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,15 @@ use serde::Serialize;
|
|||
use crate::{InputSource, Status, Uri};
|
||||
|
||||
/// Response type returned by lychee after checking a URI
|
||||
//
|
||||
// Body is public to allow inserting into stats maps (fail_map, success_map,
|
||||
// etc.) without `Clone`, because the inner `ErrorKind` in `response.status` is
|
||||
// not `Clone`. Use `body()` to access the body in the rest of the code.
|
||||
//
|
||||
// `pub(crate)` is insufficient, because the `stats` module is in the `bin`
|
||||
// crate crate.
|
||||
#[derive(Debug)]
|
||||
pub struct Response(pub InputSource, pub ResponseBody);
|
||||
pub struct Response(InputSource, pub ResponseBody);
|
||||
|
||||
impl Response {
|
||||
#[inline]
|
||||
|
|
@ -23,6 +30,21 @@ impl Response {
|
|||
pub const fn status(&self) -> &Status {
|
||||
&self.1.status
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
/// Retrieve the underlying source of the response
|
||||
/// (e.g. the input file or the URL)
|
||||
pub const fn source(&self) -> &InputSource {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[must_use]
|
||||
/// Retrieve the underlying body of the response
|
||||
pub const fn body(&self) -> &ResponseBody {
|
||||
&self.1
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Response {
|
||||
|
|
@ -57,13 +79,7 @@ pub struct ResponseBody {
|
|||
// matching in these cases.
|
||||
impl Display for ResponseBody {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{} [{}] {}",
|
||||
self.status.icon(),
|
||||
self.status.code_as_string(),
|
||||
self.uri
|
||||
)?;
|
||||
write!(f, "[{}] {}", self.status.code_as_string(), self.uri)?;
|
||||
|
||||
if let Status::Ok(StatusCode::OK) = self.status {
|
||||
// Don't print anything else if the status code is 200.
|
||||
|
|
|
|||
|
|
@ -209,8 +209,8 @@ impl Status {
|
|||
}
|
||||
}
|
||||
|
||||
/// Return the HTTP status code (if any)
|
||||
#[must_use]
|
||||
/// Return the HTTP status code (if any)
|
||||
pub fn code(&self) -> Option<StatusCode> {
|
||||
match self {
|
||||
Status::Ok(code)
|
||||
|
|
@ -250,9 +250,9 @@ impl Status {
|
|||
| ErrorKind::ReadResponseBody(e)
|
||||
| ErrorKind::BuildRequestClient(e) => match e.status() {
|
||||
Some(code) => code.as_str().to_string(),
|
||||
None => "ERR".to_string(),
|
||||
None => "ERROR".to_string(),
|
||||
},
|
||||
_ => "ERR".to_string(),
|
||||
_ => "ERROR".to_string(),
|
||||
},
|
||||
Status::Timeout(code) => match code {
|
||||
Some(code) => code.as_str().to_string(),
|
||||
|
|
@ -263,7 +263,7 @@ impl Status {
|
|||
CacheStatus::Ok(code) => code.to_string(),
|
||||
CacheStatus::Error(code) => match code {
|
||||
Some(code) => code.to_string(),
|
||||
None => "ERR".to_string(),
|
||||
None => "ERROR".to_string(),
|
||||
},
|
||||
CacheStatus::Excluded => "EXCLUDED".to_string(),
|
||||
CacheStatus::Unsupported => "IGNORED".to_string(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue