Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0a68ae9

Browse files
committedApr 4, 2024·
Add response formatter; refactor stats formatter
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
1 parent 13f4339 commit 0a68ae9

File tree

21 files changed

+393
-231
lines changed

21 files changed

+393
-231
lines changed
 

‎lychee-bin/src/commands/check.rs

+35-25
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use lychee_lib::{InputSource, Result};
1515
use lychee_lib::{ResponseBody, Status};
1616

1717
use crate::archive::{Archive, Suggestion};
18-
use crate::formatters::response::ResponseFormatter;
18+
use crate::formatters::get_response_formatter;
19+
use crate::formatters::response::ResponseBodyFormatter;
1920
use crate::verbosity::Verbosity;
2021
use crate::{cache::Cache, stats::ResponseStats, ExitCode};
2122

@@ -62,11 +63,13 @@ where
6263
accept,
6364
));
6465

66+
let formatter = get_response_formatter(&params.cfg.mode);
67+
6568
let show_results_task = tokio::spawn(progress_bar_task(
6669
recv_resp,
6770
params.cfg.verbose,
6871
pb.clone(),
69-
Arc::new(params.formatter),
72+
formatter,
7073
stats,
7174
));
7275

@@ -178,11 +181,17 @@ async fn progress_bar_task(
178181
mut recv_resp: mpsc::Receiver<Response>,
179182
verbose: Verbosity,
180183
pb: Option<ProgressBar>,
181-
formatter: Arc<Box<dyn ResponseFormatter>>,
184+
formatter: Box<dyn ResponseBodyFormatter>,
182185
mut stats: ResponseStats,
183186
) -> Result<(Option<ProgressBar>, ResponseStats)> {
184187
while let Some(response) = recv_resp.recv().await {
185-
show_progress(&mut io::stderr(), &pb, &response, &formatter, &verbose)?;
188+
show_progress(
189+
&mut io::stderr(),
190+
&pb,
191+
&response,
192+
formatter.as_ref(),
193+
&verbose,
194+
)?;
186195
stats.add(response);
187196
}
188197
Ok((pb, stats))
@@ -275,10 +284,11 @@ fn show_progress(
275284
output: &mut dyn Write,
276285
progress_bar: &Option<ProgressBar>,
277286
response: &Response,
278-
formatter: &Arc<Box<dyn ResponseFormatter>>,
287+
formatter: &dyn ResponseBodyFormatter,
279288
verbose: &Verbosity,
280289
) -> Result<()> {
281-
let out = formatter.write_response(response)?;
290+
let out = formatter.format_response(response.body());
291+
282292
if let Some(pb) = progress_bar {
283293
pb.inc(1);
284294
pb.set_message(out.clone());
@@ -317,30 +327,26 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {
317327
#[cfg(test)]
318328
mod tests {
319329
use log::info;
330+
use lychee_lib::{CacheStatus, InputSource, Uri};
320331

321-
use lychee_lib::{CacheStatus, InputSource, ResponseBody, Uri};
322-
323-
use crate::formatters;
332+
use crate::{formatters::get_response_formatter, options};
324333

325334
use super::*;
326335

327336
#[test]
328337
fn test_skip_cached_responses_in_progress_output() {
329338
let mut buf = Vec::new();
330-
let response = Response(
339+
let response = Response::new(
340+
Uri::try_from("http://127.0.0.1").unwrap(),
341+
Status::Cached(CacheStatus::Ok(200)),
331342
InputSource::Stdin,
332-
ResponseBody {
333-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
334-
status: Status::Cached(CacheStatus::Ok(200)),
335-
},
336343
);
337-
let formatter: Arc<Box<dyn ResponseFormatter>> =
338-
Arc::new(Box::new(formatters::response::Raw::new()));
344+
let formatter = get_response_formatter(&options::ResponseFormat::Plain);
339345
show_progress(
340346
&mut buf,
341347
&None,
342348
&response,
343-
&formatter,
349+
formatter.as_ref(),
344350
&Verbosity::default(),
345351
)
346352
.unwrap();
@@ -352,16 +358,20 @@ mod tests {
352358
#[test]
353359
fn test_show_cached_responses_in_progress_debug_output() {
354360
let mut buf = Vec::new();
355-
let response = Response(
361+
let response = Response::new(
362+
Uri::try_from("http://127.0.0.1").unwrap(),
363+
Status::Cached(CacheStatus::Ok(200)),
356364
InputSource::Stdin,
357-
ResponseBody {
358-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
359-
status: Status::Cached(CacheStatus::Ok(200)),
360-
},
361365
);
362-
let formatter: Arc<Box<dyn ResponseFormatter>> =
363-
Arc::new(Box::new(formatters::response::Raw::new()));
364-
show_progress(&mut buf, &None, &response, &formatter, &Verbosity::debug()).unwrap();
366+
let formatter = get_response_formatter(&options::ResponseFormat::Plain);
367+
show_progress(
368+
&mut buf,
369+
&None,
370+
&response,
371+
formatter.as_ref(),
372+
&Verbosity::debug(),
373+
)
374+
.unwrap();
365375

366376
assert!(!buf.is_empty());
367377
let buf = String::from_utf8_lossy(&buf);

‎lychee-bin/src/commands/mod.rs

-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ pub(crate) use dump::dump_inputs;
88
use std::sync::Arc;
99

1010
use crate::cache::Cache;
11-
use crate::formatters::response::ResponseFormatter;
1211
use crate::options::Config;
1312
use lychee_lib::Result;
1413
use lychee_lib::{Client, Request};
@@ -18,6 +17,5 @@ pub(crate) struct CommandParams<S: futures::Stream<Item = Result<Request>>> {
1817
pub(crate) client: Client,
1918
pub(crate) cache: Arc<Cache>,
2019
pub(crate) requests: S,
21-
pub(crate) formatter: Box<dyn ResponseFormatter>,
2220
pub(crate) cfg: Config,
2321
}

‎lychee-bin/src/color.rs ‎lychee-bin/src/formatters/color.rs

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
//! Defines the colors used in the output of the CLI.
2+
13
use console::Style;
24
use once_cell::sync::Lazy;
35

46
pub(crate) static NORMAL: Lazy<Style> = Lazy::new(Style::new);
57
pub(crate) static DIM: Lazy<Style> = Lazy::new(|| Style::new().dim());
68

7-
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bright());
9+
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(2).bold().bright());
810
pub(crate) static BOLD_GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bold().bright());
911
pub(crate) static YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bright());
1012
pub(crate) static BOLD_YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bold().bright());
1113
pub(crate) static PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197));
1214
pub(crate) static BOLD_PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bold());
1315

16+
// Used for debug log messages
17+
pub(crate) static BLUE: Lazy<Style> = Lazy::new(|| Style::new().blue().bright());
18+
1419
// Write output using predefined colors
1520
macro_rules! color {
1621
($f:ident, $color:ident, $text:tt, $($tts:tt)*) => {

‎lychee-bin/src/formatters/log.rs

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use log::Level;
2+
use std::io::Write;
3+
4+
use crate::{formatters, options::ResponseFormat, verbosity::Verbosity};
5+
6+
/// Initialize the logging system with the given verbosity level
7+
pub(crate) fn init_logging(verbose: &Verbosity, mode: &ResponseFormat) {
8+
let mut builder = env_logger::Builder::new();
9+
10+
builder
11+
.format_timestamp(None) // Disable timestamps
12+
.format_module_path(false) // Disable module path to reduce clutter
13+
.format_target(false) // Disable target
14+
.filter_module("lychee", verbose.log_level_filter()) // Re-add module filtering
15+
.filter_module("lychee_lib", verbose.log_level_filter()); // Re-add module filtering
16+
17+
// Enable color unless the user has disabled it
18+
if !matches!(mode, ResponseFormat::Plain) {
19+
builder.format(|buf, record| {
20+
let level = record.level();
21+
let level_text = match level {
22+
Level::Error => "ERROR",
23+
Level::Warn => " WARN",
24+
Level::Info => " INFO",
25+
Level::Debug => "DEBUG",
26+
Level::Trace => "TRACE",
27+
};
28+
29+
// Desired total width including brackets
30+
let numeric_padding: usize = 10;
31+
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
32+
let effective_padding = numeric_padding.saturating_sub(level_text.len() + 2); // +2 for brackets
33+
34+
// Construct the log prefix with the log level.
35+
// The spaces added before "WARN" and "INFO" are to visually align them with "ERROR", "DEBUG", and "TRACE"
36+
let level_label = format!("[{level_text}]");
37+
let c = match level {
38+
Level::Error => &formatters::color::BOLD_PINK,
39+
Level::Warn => &formatters::color::BOLD_YELLOW,
40+
Level::Info | Level::Debug => &formatters::color::BLUE,
41+
Level::Trace => &formatters::color::DIM,
42+
};
43+
let colored_level = c.apply_to(level_label);
44+
45+
let prefix = format!("{}{}", " ".repeat(effective_padding), colored_level);
46+
47+
// Write formatted log message with aligned level and original log message.
48+
writeln!(buf, "{} {}", prefix, record.args())
49+
});
50+
}
51+
52+
builder.init();
53+
}

‎lychee-bin/src/formatters/mod.rs

+23-29
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,41 @@
1+
pub(crate) mod color;
12
pub(crate) mod duration;
3+
pub(crate) mod log;
24
pub(crate) mod response;
35
pub(crate) mod stats;
46

5-
use lychee_lib::{CacheStatus, ResponseBody, Status};
7+
use self::{response::ResponseBodyFormatter, stats::StatsFormatter};
8+
use crate::options::{ResponseFormat, StatsFormat};
69
use supports_color::Stream;
710

8-
use crate::{
9-
color::{DIM, GREEN, NORMAL, PINK, YELLOW},
10-
options::{self, Format},
11-
};
12-
13-
use self::response::ResponseFormatter;
14-
1511
/// Detects whether a terminal supports color, and gives details about that
1612
/// support. It takes into account the `NO_COLOR` environment variable.
1713
fn supports_color() -> bool {
1814
supports_color::on(Stream::Stdout).is_some()
1915
}
2016

21-
/// Color the response body for TTYs that support it
22-
pub(crate) fn color_response(body: &ResponseBody) -> String {
23-
if supports_color() {
24-
let out = match body.status {
25-
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => GREEN.apply_to(body),
26-
Status::Excluded
27-
| Status::Unsupported(_)
28-
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => {
29-
DIM.apply_to(body)
30-
}
31-
Status::Redirected(_) => NORMAL.apply_to(body),
32-
Status::UnknownStatusCode(_) | Status::Timeout(_) => YELLOW.apply_to(body),
33-
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => PINK.apply_to(body),
34-
};
35-
out.to_string()
36-
} else {
37-
body.to_string()
17+
pub(crate) fn get_stats_formatter(
18+
format: &StatsFormat,
19+
response_format: &ResponseFormat,
20+
) -> Box<dyn StatsFormatter> {
21+
match format {
22+
StatsFormat::Compact => Box::new(stats::Compact::new(response_format.clone())),
23+
StatsFormat::Detailed => Box::new(stats::Detailed::new(response_format.clone())),
24+
StatsFormat::Json => Box::new(stats::Json::new()),
25+
StatsFormat::Markdown => Box::new(stats::Markdown::new()),
26+
StatsFormat::Raw => Box::new(stats::Raw::new()),
3827
}
3928
}
4029

4130
/// Create a response formatter based on the given format option
42-
pub(crate) fn get_formatter(format: &options::Format) -> Box<dyn ResponseFormatter> {
43-
if matches!(format, Format::Raw) || !supports_color() {
44-
return Box::new(response::Raw::new());
31+
///
32+
pub(crate) fn get_response_formatter(format: &ResponseFormat) -> Box<dyn ResponseBodyFormatter> {
33+
if !supports_color() {
34+
return Box::new(response::PlainFormatter);
35+
}
36+
match format {
37+
ResponseFormat::Plain => Box::new(response::PlainFormatter),
38+
ResponseFormat::Color => Box::new(response::ColorFormatter),
39+
ResponseFormat::Emoji => Box::new(response::EmojiFormatter),
4540
}
46-
Box::new(response::Color::new())
4741
}

‎lychee-bin/src/formatters/response.rs

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use lychee_lib::{CacheStatus, ResponseBody, Status};
2+
3+
use super::color::{DIM, GREEN, NORMAL, PINK, YELLOW};
4+
5+
/// A trait for formatting a response body
6+
///
7+
/// This trait is used to format a response body into a string.
8+
/// It can be implemented for different formatting styles such as
9+
/// colorized output or plain text.
10+
pub(crate) trait ResponseBodyFormatter: Send + Sync {
11+
fn format_response(&self, body: &ResponseBody) -> String;
12+
}
13+
14+
/// A basic formatter that just returns the response body as a string
15+
/// without any color codes or other formatting.
16+
///
17+
/// Under the hood, it calls the `Display` implementation of the `ResponseBody`
18+
/// type.
19+
///
20+
/// This formatter is used when the user has requested raw output
21+
/// or when the terminal does not support color.
22+
pub(crate) struct PlainFormatter;
23+
24+
impl ResponseBodyFormatter for PlainFormatter {
25+
fn format_response(&self, body: &ResponseBody) -> String {
26+
body.to_string()
27+
}
28+
}
29+
30+
/// A colorized formatter for the response body
31+
///
32+
/// This formatter is used when the terminal supports color and the user
33+
/// has not explicitly requested raw, uncolored output.
34+
pub(crate) struct ColorFormatter;
35+
36+
impl ResponseBodyFormatter for ColorFormatter {
37+
fn format_response(&self, body: &ResponseBody) -> String {
38+
// Determine the color based on the status.
39+
let status_color = match body.status {
40+
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => &GREEN,
41+
Status::Excluded
42+
| Status::Unsupported(_)
43+
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => &DIM,
44+
Status::Redirected(_) => &NORMAL,
45+
Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW,
46+
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK,
47+
};
48+
49+
let status_formatted = format_status(&body.status);
50+
51+
let colored_status = status_color.apply_to(status_formatted);
52+
53+
// Construct the output.
54+
format!("{} {}", colored_status, body.uri)
55+
}
56+
}
57+
58+
/// Desired total width of formatted string for color formatter
59+
///
60+
/// The longest string, which needs to be formatted, is currently `[Excluded]`
61+
/// which is 10 characters long (including brackets).
62+
///
63+
/// Keep in sync with `Status::code_as_string`, which converts status codes to
64+
/// strings.
65+
const STATUS_CODE_PADDING: usize = 10;
66+
67+
/// Format the status code or text for the color formatter.
68+
///
69+
/// Numeric status codes are right-aligned.
70+
/// Textual statuses are left-aligned.
71+
/// Padding is taken into account.
72+
fn format_status(status: &Status) -> String {
73+
let status_code_or_text = status.code_as_string();
74+
75+
// Calculate the effective padding. Ensure it's non-negative to avoid panic.
76+
let padding = STATUS_CODE_PADDING.saturating_sub(status_code_or_text.len() + 2); // +2 for brackets
77+
78+
format!(
79+
"{}[{:>width$}]",
80+
" ".repeat(padding),
81+
status_code_or_text,
82+
width = status_code_or_text.len()
83+
)
84+
}
85+
86+
/// An emoji formatter for the response body
87+
///
88+
/// This formatter replaces certain textual elements with emojis for a more
89+
/// visual output.
90+
pub(crate) struct EmojiFormatter;
91+
92+
impl ResponseBodyFormatter for EmojiFormatter {
93+
fn format_response(&self, body: &ResponseBody) -> String {
94+
let emoji = match body.status {
95+
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => "✅",
96+
Status::Excluded
97+
| Status::Unsupported(_)
98+
| Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => "🚫",
99+
Status::Redirected(_) => "↪️",
100+
Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️",
101+
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "❌",
102+
};
103+
format!("{} {}", emoji, body.uri)
104+
}
105+
}

‎lychee-bin/src/formatters/response/color.rs

-21
This file was deleted.

‎lychee-bin/src/formatters/response/mod.rs

-14
This file was deleted.

‎lychee-bin/src/formatters/response/raw.rs

-18
This file was deleted.

‎lychee-bin/src/formatters/stats/compact.rs

+22-15
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
1+
use anyhow::Result;
12
use std::{
23
fmt::{self, Display},
34
time::Duration,
45
};
56

6-
use crate::{
7-
color::{color, BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, DIM, NORMAL},
8-
formatters::color_response,
9-
stats::ResponseStats,
10-
};
7+
use crate::formatters::color::{color, BOLD_GREEN, BOLD_PINK, BOLD_YELLOW, DIM, NORMAL};
8+
use crate::{formatters::get_response_formatter, options, stats::ResponseStats};
119

1210
use super::StatsFormatter;
1311

14-
use anyhow::Result;
15-
16-
struct CompactResponseStats(ResponseStats);
12+
struct CompactResponseStats {
13+
stats: ResponseStats,
14+
mode: options::ResponseFormat,
15+
}
1716

1817
impl Display for CompactResponseStats {
1918
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20-
let stats = &self.0;
19+
let stats = &self.stats;
2120

2221
if !stats.fail_map.is_empty() {
2322
let input = if stats.fail_map.len() == 1 {
@@ -33,10 +32,13 @@ impl Display for CompactResponseStats {
3332
stats.fail_map.len()
3433
)?;
3534
}
35+
36+
let response_formatter = get_response_formatter(&self.mode);
37+
3638
for (source, responses) in &stats.fail_map {
3739
color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
3840
for response in responses {
39-
writeln!(f, "{}", color_response(response))?;
41+
writeln!(f, "{}", response_formatter.format_response(response))?;
4042
}
4143

4244
if let Some(suggestions) = &stats.suggestion_map.get(source) {
@@ -68,17 +70,22 @@ impl Display for CompactResponseStats {
6870
}
6971
}
7072

71-
pub(crate) struct Compact;
73+
pub(crate) struct Compact {
74+
mode: options::ResponseFormat,
75+
}
7276

7377
impl Compact {
74-
pub(crate) const fn new() -> Self {
75-
Self {}
78+
pub(crate) const fn new(mode: options::ResponseFormat) -> Self {
79+
Self { mode }
7680
}
7781
}
7882

7983
impl StatsFormatter for Compact {
80-
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
81-
let compact = CompactResponseStats(stats);
84+
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
85+
let compact = CompactResponseStats {
86+
stats,
87+
mode: self.mode.clone(),
88+
};
8289
Ok(Some(compact.to_string()))
8390
}
8491
}

‎lychee-bin/src/formatters/stats/detailed.rs

+23-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use super::StatsFormatter;
2-
use crate::{formatters::color_response, stats::ResponseStats};
2+
use crate::{formatters::get_response_formatter, options, stats::ResponseStats};
33

44
use anyhow::Result;
55
use pad::{Alignment, PadStr};
@@ -24,9 +24,16 @@ fn write_stat(f: &mut fmt::Formatter, title: &str, stat: usize, newline: bool) -
2424
Ok(())
2525
}
2626

27+
/// Wrap as newtype because multiple `Display` implementations are not allowed
28+
/// for `ResponseStats`
29+
struct DetailedResponseStats {
30+
stats: ResponseStats,
31+
mode: options::ResponseFormat,
32+
}
33+
2734
impl Display for DetailedResponseStats {
2835
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29-
let stats = &self.0;
36+
let stats = &self.stats;
3037
let separator = "-".repeat(MAX_PADDING + 1);
3138

3239
writeln!(f, "\u{1f4dd} Summary")?; // 📝
@@ -39,12 +46,15 @@ impl Display for DetailedResponseStats {
3946
write_stat(f, "\u{2753} Unknown", stats.unknown, true)?; //❓
4047
write_stat(f, "\u{1f6ab} Errors", stats.errors, false)?; // 🚫
4148

49+
let response_formatter = get_response_formatter(&self.mode);
50+
4251
for (source, responses) in &stats.fail_map {
4352
// Using leading newlines over trailing ones (e.g. `writeln!`)
4453
// lets us avoid extra newlines without any additional logic.
4554
write!(f, "\n\nErrors in {source}")?;
55+
4656
for response in responses {
47-
write!(f, "\n{}", color_response(response))?;
57+
write!(f, "\n{}", response_formatter.format_response(response))?;
4858

4959
if let Some(suggestions) = &stats.suggestion_map.get(source) {
5060
writeln!(f, "\nSuggestions in {source}")?;
@@ -59,21 +69,22 @@ impl Display for DetailedResponseStats {
5969
}
6070
}
6171

62-
/// Wrap as newtype because multiple `Display` implementations are not allowed
63-
/// for `ResponseStats`
64-
struct DetailedResponseStats(ResponseStats);
65-
66-
pub(crate) struct Detailed;
72+
pub(crate) struct Detailed {
73+
mode: options::ResponseFormat,
74+
}
6775

6876
impl Detailed {
69-
pub(crate) const fn new() -> Self {
70-
Self
77+
pub(crate) const fn new(mode: options::ResponseFormat) -> Self {
78+
Self { mode }
7179
}
7280
}
7381

7482
impl StatsFormatter for Detailed {
75-
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
76-
let detailed = DetailedResponseStats(stats);
83+
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
84+
let detailed = DetailedResponseStats {
85+
stats,
86+
mode: self.mode.clone(),
87+
};
7788
Ok(Some(detailed.to_string()))
7889
}
7990
}

‎lychee-bin/src/formatters/stats/json.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ impl Json {
1313

1414
impl StatsFormatter for Json {
1515
/// Format stats as JSON object
16-
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
16+
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
1717
serde_json::to_string_pretty(&stats)
1818
.map(Some)
1919
.context("Cannot format stats as JSON")

‎lychee-bin/src/formatters/stats/markdown.rs

+4-6
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ impl Markdown {
147147
}
148148

149149
impl StatsFormatter for Markdown {
150-
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>> {
150+
fn format(&self, stats: ResponseStats) -> Result<Option<String>> {
151151
let markdown = MarkdownResponseStats(stats);
152152
Ok(Some(markdown.to_string()))
153153
}
@@ -222,12 +222,10 @@ mod tests {
222222
#[test]
223223
fn test_render_summary() {
224224
let mut stats = ResponseStats::default();
225-
let response = Response(
225+
let response = Response::new(
226+
Uri::try_from("http://127.0.0.1").unwrap(),
227+
Status::Cached(CacheStatus::Error(Some(404))),
226228
InputSource::Stdin,
227-
ResponseBody {
228-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
229-
status: Status::Cached(CacheStatus::Error(Some(404))),
230-
},
231229
);
232230
stats.add(response);
233231
stats

‎lychee-bin/src/formatters/stats/mod.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ use anyhow::Result;
1515

1616
pub(crate) trait StatsFormatter {
1717
/// Format the stats of all responses and write them to stdout
18-
fn format_stats(&self, stats: ResponseStats) -> Result<Option<String>>;
18+
fn format(&self, stats: ResponseStats) -> Result<Option<String>>;
1919
}

‎lychee-bin/src/formatters/stats/raw.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ impl Raw {
1212

1313
impl StatsFormatter for Raw {
1414
/// Don't print stats in raw mode
15-
fn format_stats(&self, _stats: ResponseStats) -> Result<Option<String>> {
15+
fn format(&self, _stats: ResponseStats) -> Result<Option<String>> {
1616
Ok(None)
1717
}
1818
}

‎lychee-bin/src/main.rs

+12-31
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,8 @@ use std::sync::Arc;
6565

6666
use anyhow::{bail, Context, Error, Result};
6767
use clap::Parser;
68-
use color::YELLOW;
6968
use commands::CommandParams;
70-
use formatters::response::ResponseFormatter;
69+
use formatters::{get_stats_formatter, log::init_logging};
7170
use log::{error, info, warn};
7271

7372
#[cfg(feature = "native-tls")]
@@ -83,7 +82,6 @@ use lychee_lib::CookieJar;
8382
mod archive;
8483
mod cache;
8584
mod client;
86-
mod color;
8785
mod commands;
8886
mod formatters;
8987
mod options;
@@ -92,12 +90,12 @@ mod stats;
9290
mod time;
9391
mod verbosity;
9492

93+
use crate::formatters::color;
9594
use crate::formatters::duration::Duration;
9695
use crate::{
9796
cache::{Cache, StoreExt},
98-
color::color,
9997
formatters::stats::StatsFormatter,
100-
options::{Config, Format, LycheeOptions, LYCHEE_CACHE_FILE, LYCHEE_IGNORE_FILE},
98+
options::{Config, LycheeOptions, LYCHEE_CACHE_FILE, LYCHEE_IGNORE_FILE},
10199
};
102100

103101
/// 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>> {
143141
fn load_config() -> Result<LycheeOptions> {
144142
let mut opts = LycheeOptions::parse();
145143

146-
env_logger::Builder::new()
147-
// super basic formatting; no timestamps, no module path, no target
148-
.format_timestamp(None)
149-
.format_indent(Some(0))
150-
.format_module_path(false)
151-
.format_target(false)
152-
.filter_module("lychee", opts.config.verbose.log_level_filter())
153-
.filter_module("lychee_lib", opts.config.verbose.log_level_filter())
154-
.init();
144+
init_logging(&opts.config.verbose, &opts.config.mode);
155145

156146
// Load a potentially existing config file and merge it into the config from
157147
// the CLI
@@ -333,16 +323,12 @@ async fn run(opts: &LycheeOptions) -> Result<i32> {
333323
)
334324
})?;
335325

336-
let response_formatter: Box<dyn ResponseFormatter> =
337-
formatters::get_formatter(&opts.config.format);
338-
339326
let client = client::create(&opts.config, cookie_jar.as_deref())?;
340327

341328
let params = CommandParams {
342329
client,
343330
cache,
344331
requests,
345-
formatter: response_formatter,
346332
cfg: opts.config.clone(),
347333
};
348334

@@ -357,33 +343,28 @@ async fn run(opts: &LycheeOptions) -> Result<i32> {
357343
.flatten()
358344
.any(|body| body.uri.domain() == Some("github.com"));
359345

360-
let writer: Box<dyn StatsFormatter> = match opts.config.format {
361-
Format::Compact => Box::new(formatters::stats::Compact::new()),
362-
Format::Detailed => Box::new(formatters::stats::Detailed::new()),
363-
Format::Json => Box::new(formatters::stats::Json::new()),
364-
Format::Markdown => Box::new(formatters::stats::Markdown::new()),
365-
Format::Raw => Box::new(formatters::stats::Raw::new()),
366-
};
346+
let stats_formatter: Box<dyn StatsFormatter> =
347+
get_stats_formatter(&opts.config.format, &opts.config.mode);
348+
367349
let is_empty = stats.is_empty();
368-
let formatted = writer.format_stats(stats)?;
350+
let formatted_stats = stats_formatter.format(stats)?;
369351

370-
if let Some(formatted) = formatted {
352+
if let Some(formatted_stats) = formatted_stats {
371353
if let Some(output) = &opts.config.output {
372-
fs::write(output, formatted).context("Cannot write status output to file")?;
354+
fs::write(output, formatted_stats).context("Cannot write status output to file")?;
373355
} else {
374356
if opts.config.verbose.log_level() >= log::Level::Info && !is_empty {
375357
// separate summary from the verbose list of links above
376358
// with a newline
377359
writeln!(io::stdout())?;
378360
}
379361
// we assume that the formatted stats don't have a final newline
380-
writeln!(io::stdout(), "{formatted}")?;
362+
writeln!(io::stdout(), "{formatted_stats}")?;
381363
}
382364
}
383365

384366
if github_issues && opts.config.github_token.is_none() {
385-
let mut handle = io::stderr();
386-
color!(handle, YELLOW, "\u{1f4a1} There were issues with Github URLs. You could try setting a Github token and running lychee again.",)?;
367+
warn!("There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",);
387368
}
388369

389370
if opts.config.cache {

‎lychee-bin/src/options.rs

+43-10
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ const HELP_MSG_CONFIG_FILE: &str = formatcp!(
4444
const TIMEOUT_STR: &str = concatcp!(DEFAULT_TIMEOUT_SECS);
4545
const RETRY_WAIT_TIME_STR: &str = concatcp!(DEFAULT_RETRY_WAIT_TIME_SECS);
4646

47+
/// The format to use for the final status report
4748
#[derive(Debug, Deserialize, Default, Clone)]
48-
pub(crate) enum Format {
49+
pub(crate) enum StatsFormat {
4950
#[default]
5051
Compact,
5152
Detailed,
@@ -54,20 +55,46 @@ pub(crate) enum Format {
5455
Raw,
5556
}
5657

57-
impl FromStr for Format {
58+
impl FromStr for StatsFormat {
5859
type Err = Error;
60+
5961
fn from_str(format: &str) -> Result<Self, Self::Err> {
6062
match format.to_lowercase().as_str() {
61-
"compact" | "string" => Ok(Format::Compact),
62-
"detailed" => Ok(Format::Detailed),
63-
"json" => Ok(Format::Json),
64-
"markdown" | "md" => Ok(Format::Markdown),
65-
"raw" => Ok(Format::Raw),
63+
"compact" | "string" => Ok(StatsFormat::Compact),
64+
"detailed" => Ok(StatsFormat::Detailed),
65+
"json" => Ok(StatsFormat::Json),
66+
"markdown" | "md" => Ok(StatsFormat::Markdown),
67+
"raw" => Ok(StatsFormat::Raw),
6668
_ => Err(anyhow!("Unknown format {}", format)),
6769
}
6870
}
6971
}
7072

73+
/// The different formatter modes
74+
///
75+
/// This decides over whether to use color,
76+
/// emojis, or plain text for the output.
77+
#[derive(Debug, Deserialize, Default, Clone)]
78+
pub(crate) enum ResponseFormat {
79+
Plain,
80+
#[default]
81+
Color,
82+
Emoji,
83+
}
84+
85+
impl FromStr for ResponseFormat {
86+
type Err = Error;
87+
88+
fn from_str(mode: &str) -> Result<Self, Self::Err> {
89+
match mode.to_lowercase().as_str() {
90+
"plain" | "plaintext" | "raw" => Ok(ResponseFormat::Plain),
91+
"color" => Ok(ResponseFormat::Color),
92+
"emoji" => Ok(ResponseFormat::Emoji), // Shows emojis
93+
_ => Err(anyhow!("Unknown formatter mode {mode}")),
94+
}
95+
}
96+
}
97+
7198
// Macro for generating default functions to be used by serde
7299
macro_rules! default_function {
73100
( $( $name:ident : $T:ty = $e:expr; )* ) => {
@@ -105,12 +132,12 @@ macro_rules! fold_in {
105132
};
106133
}
107134

108-
#[derive(Parser, Debug)]
109-
#[command(version, about)]
110135
/// A fast, async link checker
111136
///
112137
/// Finds broken URLs and mail addresses inside Markdown, HTML,
113138
/// `reStructuredText`, websites and more!
139+
#[derive(Parser, Debug)]
140+
#[command(version, about)]
114141
pub(crate) struct LycheeOptions {
115142
/// The inputs (where to get links to check from).
116143
/// These can be: files (e.g. `README.md`), file globs (e.g. `"~/git/*/README.md"`),
@@ -147,6 +174,7 @@ impl LycheeOptions {
147174
}
148175
}
149176

177+
/// The main configuration for lychee
150178
#[allow(clippy::struct_excessive_bools)]
151179
#[derive(Parser, Debug, Deserialize, Clone, Default)]
152180
pub(crate) struct Config {
@@ -385,10 +413,15 @@ separated list of accepted status codes. This example will accept 200, 201,
385413
#[serde(default)]
386414
pub(crate) output: Option<PathBuf>,
387415

416+
/// Set the output display mode. Determines how results are presented in the terminal (color, plain, emoji).
417+
#[arg(long, default_value = "color")]
418+
#[serde(default)]
419+
pub(crate) mode: ResponseFormat,
420+
388421
/// Output format of final status report (compact, detailed, json, markdown)
389422
#[arg(short, long, default_value = "compact")]
390423
#[serde(default)]
391-
pub(crate) format: Format,
424+
pub(crate) format: StatsFormat,
392425

393426
/// When HTTPS is available, treat HTTP links as errors
394427
#[arg(long)]

‎lychee-bin/src/stats.rs

+30-28
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,22 @@ impl ResponseStats {
5555

5656
pub(crate) fn add(&mut self, response: Response) {
5757
self.total += 1;
58+
let status = response.status();
5859

59-
let Response(source, ResponseBody { ref status, .. }) = response;
6060
self.increment_status_counters(status);
6161

62-
match status {
63-
_ if status.is_failure() => {
64-
let fail = self.fail_map.entry(source).or_default();
65-
fail.insert(response.1);
66-
}
67-
Status::Ok(_) if self.detailed_stats => {
68-
let success = self.success_map.entry(source).or_default();
69-
success.insert(response.1);
70-
}
71-
Status::Excluded if self.detailed_stats => {
72-
let excluded = self.excluded_map.entry(source).or_default();
73-
excluded.insert(response.1);
62+
let input_source = {
63+
let source: InputSource = response.source().clone();
64+
match status {
65+
_ if status.is_failure() => self.fail_map.entry(source).or_default(),
66+
Status::Ok(_) if self.detailed_stats => self.success_map.entry(source).or_default(),
67+
Status::Excluded if self.detailed_stats => {
68+
self.excluded_map.entry(source).or_default()
69+
}
70+
_ => return,
7471
}
75-
_ => (),
76-
}
72+
};
73+
input_source.insert(response.1);
7774
}
7875

7976
#[inline]
@@ -107,8 +104,7 @@ mod tests {
107104
// and it's a lot faster to just generate a fake response
108105
fn mock_response(status: Status) -> Response {
109106
let uri = website("https://some-url.com/ok");
110-
let response_body = ResponseBody { uri, status };
111-
Response(InputSource::Stdin, response_body)
107+
Response::new(uri, status, InputSource::Stdin)
112108
}
113109

114110
fn dummy_ok() -> Response {
@@ -142,9 +138,9 @@ mod tests {
142138
stats.add(dummy_error());
143139
stats.add(dummy_ok());
144140

145-
let Response(source, body) = dummy_error();
141+
let response = dummy_error();
146142
let expected_fail_map: HashMap<InputSource, HashSet<ResponseBody>> =
147-
HashMap::from_iter([(source, HashSet::from_iter([body]))]);
143+
HashMap::from_iter([(response.source().clone(), HashSet::from_iter([response.1]))]);
148144
assert_eq!(stats.fail_map, expected_fail_map);
149145

150146
assert!(stats.success_map.is_empty());
@@ -162,21 +158,27 @@ mod tests {
162158
stats.add(dummy_ok());
163159

164160
let mut expected_fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
165-
let Response(source, response_body) = dummy_error();
166-
let entry = expected_fail_map.entry(source).or_default();
167-
entry.insert(response_body);
161+
let response = dummy_error();
162+
let entry = expected_fail_map
163+
.entry(response.source().clone())
164+
.or_default();
165+
entry.insert(response.1);
168166
assert_eq!(stats.fail_map, expected_fail_map);
169167

170168
let mut expected_success_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
171-
let Response(source, response_body) = dummy_ok();
172-
let entry = expected_success_map.entry(source).or_default();
173-
entry.insert(response_body);
169+
let response = dummy_ok();
170+
let entry = expected_success_map
171+
.entry(response.source().clone())
172+
.or_default();
173+
entry.insert(response.1);
174174
assert_eq!(stats.success_map, expected_success_map);
175175

176176
let mut expected_excluded_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
177-
let Response(source, response_body) = dummy_excluded();
178-
let entry = expected_excluded_map.entry(source).or_default();
179-
entry.insert(response_body);
177+
let response = dummy_excluded();
178+
let entry = expected_excluded_map
179+
.entry(response.source().clone())
180+
.or_default();
181+
entry.insert(response.1);
180182
assert_eq!(stats.excluded_map, expected_excluded_map);
181183
}
182184
}

‎lychee-bin/tests/cli.rs

+6-4
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,13 @@ mod cli {
153153
// Parse site error status from the fail_map
154154
let output_json = serde_json::from_slice::<Value>(&output.stdout).unwrap();
155155
let site_error_status = &output_json["fail_map"][&test_path.to_str().unwrap()][0]["status"];
156-
let error_details = site_error_status["details"].to_string();
157156

158-
assert!(error_details
159-
.contains("error:0A000086:SSL routines:tls_post_process_server_certificate:"));
160-
assert!(error_details.contains("(certificate has expired)"));
157+
assert_eq!(
158+
"error:0A000086:SSL routines:tls_post_process_server_certificate:\
159+
certificate verify failed:../ssl/statem/statem_clnt.c:1883: \
160+
(certificate has expired)",
161+
site_error_status["details"]
162+
);
161163
Ok(())
162164
}
163165

‎lychee-lib/src/types/response.rs

+24-8
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@ use serde::Serialize;
66
use crate::{InputSource, Status, Uri};
77

88
/// Response type returned by lychee after checking a URI
9+
//
10+
// Body is public to allow inserting into stats maps (fail_map, success_map,
11+
// etc.) without `Clone`, because the inner `ErrorKind` in `response.status` is
12+
// not `Clone`. Use `body()` to access the body in the rest of the code.
13+
//
14+
// `pub(crate)` is insufficient, because the `stats` module is in the `bin`
15+
// crate crate.
916
#[derive(Debug)]
10-
pub struct Response(pub InputSource, pub ResponseBody);
17+
pub struct Response(InputSource, pub ResponseBody);
1118

1219
impl Response {
1320
#[inline]
@@ -23,6 +30,21 @@ impl Response {
2330
pub const fn status(&self) -> &Status {
2431
&self.1.status
2532
}
33+
34+
#[inline]
35+
#[must_use]
36+
/// Retrieve the underlying source of the response
37+
/// (e.g. the input file or the URL)
38+
pub const fn source(&self) -> &InputSource {
39+
&self.0
40+
}
41+
42+
#[inline]
43+
#[must_use]
44+
/// Retrieve the underlying body of the response
45+
pub const fn body(&self) -> &ResponseBody {
46+
&self.1
47+
}
2648
}
2749

2850
impl Display for Response {
@@ -57,13 +79,7 @@ pub struct ResponseBody {
5779
// matching in these cases.
5880
impl Display for ResponseBody {
5981
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60-
write!(
61-
f,
62-
"{} [{}] {}",
63-
self.status.icon(),
64-
self.status.code_as_string(),
65-
self.uri
66-
)?;
82+
write!(f, "[{}] {}", self.status.code_as_string(), self.uri)?;
6783

6884
if let Status::Ok(StatusCode::OK) = self.status {
6985
// Don't print anything else if the status code is 200.

‎lychee-lib/src/types/status.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ impl Status {
209209
}
210210
}
211211

212-
/// Return the HTTP status code (if any)
213212
#[must_use]
213+
/// Return the HTTP status code (if any)
214214
pub fn code(&self) -> Option<StatusCode> {
215215
match self {
216216
Status::Ok(code)
@@ -250,9 +250,9 @@ impl Status {
250250
| ErrorKind::ReadResponseBody(e)
251251
| ErrorKind::BuildRequestClient(e) => match e.status() {
252252
Some(code) => code.as_str().to_string(),
253-
None => "ERR".to_string(),
253+
None => "ERROR".to_string(),
254254
},
255-
_ => "ERR".to_string(),
255+
_ => "ERROR".to_string(),
256256
},
257257
Status::Timeout(code) => match code {
258258
Some(code) => code.as_str().to_string(),
@@ -263,7 +263,7 @@ impl Status {
263263
CacheStatus::Ok(code) => code.to_string(),
264264
CacheStatus::Error(code) => match code {
265265
Some(code) => code.to_string(),
266-
None => "ERR".to_string(),
266+
None => "ERROR".to_string(),
267267
},
268268
CacheStatus::Excluded => "EXCLUDED".to_string(),
269269
CacheStatus::Unsupported => "IGNORED".to_string(),

0 commit comments

Comments
 (0)
Please sign in to comment.