Skip to content

Commit 0160687

Browse files
committed
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 0160687

File tree

21 files changed

+377
-228
lines changed

21 files changed

+377
-228
lines changed

lychee-bin/src/commands/check.rs

+19-22
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 = Arc::new(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.clone(),
7073
stats,
7174
));
7275

@@ -178,7 +181,7 @@ 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: Arc<Box<dyn ResponseBodyFormatter>>,
182185
mut stats: ResponseStats,
183186
) -> Result<(Option<ProgressBar>, ResponseStats)> {
184187
while let Some(response) = recv_resp.recv().await {
@@ -275,10 +278,11 @@ fn show_progress(
275278
output: &mut dyn Write,
276279
progress_bar: &Option<ProgressBar>,
277280
response: &Response,
278-
formatter: &Arc<Box<dyn ResponseFormatter>>,
281+
formatter: &Box<dyn ResponseBodyFormatter>,
279282
verbose: &Verbosity,
280283
) -> Result<()> {
281-
let out = formatter.write_response(response)?;
284+
let out = formatter.format_response(response.body());
285+
282286
if let Some(pb) = progress_bar {
283287
pb.inc(1);
284288
pb.set_message(out.clone());
@@ -317,25 +321,21 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {
317321
#[cfg(test)]
318322
mod tests {
319323
use log::info;
324+
use lychee_lib::{CacheStatus, InputSource, Uri};
320325

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

325328
use super::*;
326329

327330
#[test]
328331
fn test_skip_cached_responses_in_progress_output() {
329332
let mut buf = Vec::new();
330-
let response = Response(
333+
let response = Response::new(
334+
Uri::try_from("http://127.0.0.1").unwrap(),
335+
Status::Cached(CacheStatus::Ok(200)),
331336
InputSource::Stdin,
332-
ResponseBody {
333-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
334-
status: Status::Cached(CacheStatus::Ok(200)),
335-
},
336337
);
337-
let formatter: Arc<Box<dyn ResponseFormatter>> =
338-
Arc::new(Box::new(formatters::response::Raw::new()));
338+
let formatter = get_response_formatter(&options::ResponseFormat::Plain);
339339
show_progress(
340340
&mut buf,
341341
&None,
@@ -352,15 +352,12 @@ mod tests {
352352
#[test]
353353
fn test_show_cached_responses_in_progress_debug_output() {
354354
let mut buf = Vec::new();
355-
let response = Response(
355+
let response = Response::new(
356+
Uri::try_from("http://127.0.0.1").unwrap(),
357+
Status::Cached(CacheStatus::Ok(200)),
356358
InputSource::Stdin,
357-
ResponseBody {
358-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
359-
status: Status::Cached(CacheStatus::Ok(200)),
360-
},
361359
);
362-
let formatter: Arc<Box<dyn ResponseFormatter>> =
363-
Arc::new(Box::new(formatters::response::Raw::new()));
360+
let formatter = get_response_formatter(&options::ResponseFormat::Plain);
364361
show_progress(&mut buf, &None, &response, &formatter, &Verbosity::debug()).unwrap();
365362

366363
assert!(!buf.is_empty());

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.

0 commit comments

Comments
 (0)