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:
Matthias Endler 2024-06-14 19:47:52 +02:00 committed by GitHub
parent cc7acfb0e0
commit dedc554eda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 714 additions and 239 deletions

View file

@ -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

View file

@ -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(&params.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]

View file

@ -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,
}

View file

@ -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;

View 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();
}

View file

@ -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())
}

View file

@ -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));
}
}

View 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"
);
}
}

View file

@ -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;
}

View 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>)"
);
}
}

View file

@ -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())
}
}

View file

@ -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()))
}
}

View file

@ -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()))
}
}

View file

@ -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")

View file

@ -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

View file

@ -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>>;
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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)]

View file

@ -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);
}
}

View file

@ -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(())
}

View file

@ -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.

View file

@ -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.

View file

@ -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(),