Skip to content

Commit dedc554

Browse files
authored
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
1 parent cc7acfb commit dedc554

24 files changed

+713
-238
lines changed

README.md

+8-2
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,17 @@ Options:
458458
-o, --output <OUTPUT>
459459
Output file of status report
460460
461+
--mode <MODE>
462+
Set the output display mode. Determines how results are presented in the terminal
463+
464+
[default: color]
465+
[possible values: plain, color, emoji]
466+
461467
-f, --format <FORMAT>
462-
Output format of final status report (compact, detailed, json, markdown)
468+
Output format of final status report
463469
464470
[default: compact]
471+
[possible values: compact, detailed, json, markdown, raw]
465472
466473
--require-https
467474
When HTTPS is available, treat HTTP links as errors
@@ -474,7 +481,6 @@ Options:
474481
475482
-V, --version
476483
Print version
477-
478484
```
479485

480486
### Exit codes

lychee-bin/src/commands/check.rs

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

1717
use crate::archive::{Archive, Suggestion};
18+
use crate::formatters::get_response_formatter;
1819
use crate::formatters::response::ResponseFormatter;
1920
use crate::verbosity::Verbosity;
2021
use crate::{cache::Cache, stats::ResponseStats, ExitCode};
@@ -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 ResponseFormatter>,
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))
@@ -289,10 +298,11 @@ fn show_progress(
289298
output: &mut dyn Write,
290299
progress_bar: &Option<ProgressBar>,
291300
response: &Response,
292-
formatter: &Arc<Box<dyn ResponseFormatter>>,
301+
formatter: &dyn ResponseFormatter,
293302
verbose: &Verbosity,
294303
) -> Result<()> {
295-
let out = formatter.write_response(response)?;
304+
let out = formatter.format_response(response.body());
305+
296306
if let Some(pb) = progress_bar {
297307
pb.inc(1);
298308
pb.set_message(out.clone());
@@ -330,31 +340,26 @@ fn get_failed_urls(stats: &mut ResponseStats) -> Vec<(InputSource, Url)> {
330340

331341
#[cfg(test)]
332342
mod tests {
343+
use crate::{formatters::get_response_formatter, options};
333344
use log::info;
334-
335-
use lychee_lib::{CacheStatus, ClientBuilder, InputSource, ResponseBody, Uri};
336-
337-
use crate::formatters;
345+
use lychee_lib::{CacheStatus, ClientBuilder, InputSource, Uri};
338346

339347
use super::*;
340348

341349
#[test]
342350
fn test_skip_cached_responses_in_progress_output() {
343351
let mut buf = Vec::new();
344-
let response = Response(
352+
let response = Response::new(
353+
Uri::try_from("http://127.0.0.1").unwrap(),
354+
Status::Cached(CacheStatus::Ok(200)),
345355
InputSource::Stdin,
346-
ResponseBody {
347-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
348-
status: Status::Cached(CacheStatus::Ok(200)),
349-
},
350356
);
351-
let formatter: Arc<Box<dyn ResponseFormatter>> =
352-
Arc::new(Box::new(formatters::response::Raw::new()));
357+
let formatter = get_response_formatter(&options::OutputMode::Plain);
353358
show_progress(
354359
&mut buf,
355360
&None,
356361
&response,
357-
&formatter,
362+
formatter.as_ref(),
358363
&Verbosity::default(),
359364
)
360365
.unwrap();
@@ -366,20 +371,24 @@ mod tests {
366371
#[test]
367372
fn test_show_cached_responses_in_progress_debug_output() {
368373
let mut buf = Vec::new();
369-
let response = Response(
374+
let response = Response::new(
375+
Uri::try_from("http://127.0.0.1").unwrap(),
376+
Status::Cached(CacheStatus::Ok(200)),
370377
InputSource::Stdin,
371-
ResponseBody {
372-
uri: Uri::try_from("http://127.0.0.1").unwrap(),
373-
status: Status::Cached(CacheStatus::Ok(200)),
374-
},
375378
);
376-
let formatter: Arc<Box<dyn ResponseFormatter>> =
377-
Arc::new(Box::new(formatters::response::Raw::new()));
378-
show_progress(&mut buf, &None, &response, &formatter, &Verbosity::debug()).unwrap();
379+
let formatter = get_response_formatter(&options::OutputMode::Plain);
380+
show_progress(
381+
&mut buf,
382+
&None,
383+
&response,
384+
formatter.as_ref(),
385+
&Verbosity::debug(),
386+
)
387+
.unwrap();
379388

380389
assert!(!buf.is_empty());
381390
let buf = String::from_utf8_lossy(&buf);
382-
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
391+
assert_eq!(buf, "[200] http://127.0.0.1/ | Cached: OK (cached)\n");
383392
}
384393

385394
#[tokio::test]

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
}
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
1+
//! Defines the colors used in the output of the CLI.
2+
13
use console::Style;
4+
use log::Level;
25
use once_cell::sync::Lazy;
36

47
pub(crate) static NORMAL: Lazy<Style> = Lazy::new(Style::new);
58
pub(crate) static DIM: Lazy<Style> = Lazy::new(|| Style::new().dim());
69

7-
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bright());
10+
pub(crate) static GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(2).bold().bright());
811
pub(crate) static BOLD_GREEN: Lazy<Style> = Lazy::new(|| Style::new().color256(82).bold().bright());
912
pub(crate) static YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bright());
1013
pub(crate) static BOLD_YELLOW: Lazy<Style> = Lazy::new(|| Style::new().yellow().bold().bright());
1114
pub(crate) static PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197));
1215
pub(crate) static BOLD_PINK: Lazy<Style> = Lazy::new(|| Style::new().color256(197).bold());
1316

17+
// Used for debug log messages
18+
pub(crate) static BLUE: Lazy<Style> = Lazy::new(|| Style::new().blue().bright());
19+
1420
// Write output using predefined colors
1521
macro_rules! color {
1622
($f:ident, $color:ident, $text:tt, $($tts:tt)*) => {
1723
write!($f, "{}", $color.apply_to(format!($text, $($tts)*)))
1824
};
1925
}
2026

27+
/// Returns the appropriate color for a given log level.
28+
pub(crate) fn color_for_level(level: Level) -> &'static Style {
29+
match level {
30+
Level::Error => &BOLD_PINK,
31+
Level::Warn => &BOLD_YELLOW,
32+
Level::Info | Level::Debug => &BLUE,
33+
Level::Trace => &DIM,
34+
}
35+
}
36+
2137
pub(crate) use color;

lychee-bin/src/formatters/log.rs

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
use env_logger::{Builder, Env};
2+
use log::LevelFilter;
3+
use std::io::Write;
4+
5+
use crate::{
6+
formatters::{self, response::MAX_RESPONSE_OUTPUT_WIDTH},
7+
options::OutputMode,
8+
verbosity::Verbosity,
9+
};
10+
11+
/// Initialize the logging system with the given verbosity level.
12+
pub(crate) fn init_logging(verbose: &Verbosity, mode: &OutputMode) {
13+
// Set a base level for all modules to `warn`, which is a reasonable default.
14+
// It will be overridden by RUST_LOG if it's set.
15+
let env = Env::default().filter_or("RUST_LOG", "warn");
16+
17+
let mut builder = Builder::from_env(env);
18+
builder
19+
.format_timestamp(None)
20+
.format_module_path(false)
21+
.format_target(false);
22+
23+
if std::env::var("RUST_LOG").is_err() {
24+
// Adjust the base log level filter based on the verbosity from CLI.
25+
// This applies to all modules not explicitly mentioned in RUST_LOG.
26+
let level_filter = verbose.log_level_filter();
27+
28+
// Apply a global filter. This ensures that, by default, other modules don't log at the debug level.
29+
builder.filter_level(LevelFilter::Info);
30+
31+
// Apply more specific filters to your own crates, enabling more verbose logging as per `-vv`.
32+
builder
33+
.filter_module("lychee", level_filter)
34+
.filter_module("lychee_lib", level_filter);
35+
}
36+
37+
// Calculate the longest log level text, including brackets.
38+
let max_level_text_width = log::LevelFilter::iter()
39+
.map(|level| level.as_str().len() + 2)
40+
.max()
41+
.unwrap_or(0);
42+
43+
// Customize the log message format according to the output mode
44+
if mode.is_plain() {
45+
// Explicitly disable colors for plain output
46+
builder.format(move |buf, record| writeln!(buf, "[{}] {}", record.level(), record.args()));
47+
} else if mode.is_emoji() {
48+
// Disable padding, keep colors
49+
builder.format(move |buf, record| {
50+
let level = record.level();
51+
let color = formatters::color::color_for_level(level);
52+
writeln!(
53+
buf,
54+
"{} {}",
55+
color.apply_to(format!("[{level}]")),
56+
record.args()
57+
)
58+
});
59+
} else {
60+
builder.format(move |buf, record| {
61+
let level = record.level();
62+
let level_text = format!("{level:5}");
63+
let padding = (MAX_RESPONSE_OUTPUT_WIDTH.saturating_sub(max_level_text_width)).max(0);
64+
let prefix = format!(
65+
"{:<width$}",
66+
format!("[{}]", level_text),
67+
width = max_level_text_width
68+
);
69+
let color = formatters::color::color_for_level(level);
70+
let colored_level = color.apply_to(&prefix);
71+
writeln!(
72+
buf,
73+
"{:<padding$}{} {}",
74+
"",
75+
colored_level,
76+
record.args(),
77+
padding = padding
78+
)
79+
});
80+
}
81+
82+
builder.init();
83+
}

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::ResponseFormatter, stats::StatsFormatter};
8+
use crate::options::{OutputMode, 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+
/// Create a stats formatter based on the given format option
18+
pub(crate) fn get_stats_formatter(
19+
format: &StatsFormat,
20+
mode: &OutputMode,
21+
) -> Box<dyn StatsFormatter> {
22+
match format {
23+
StatsFormat::Compact => Box::new(stats::Compact::new(mode.clone())),
24+
StatsFormat::Detailed => Box::new(stats::Detailed::new(mode.clone())),
25+
StatsFormat::Json => Box::new(stats::Json::new()),
26+
StatsFormat::Markdown => Box::new(stats::Markdown::new()),
27+
StatsFormat::Raw => Box::new(stats::Raw::new()),
3828
}
3929
}
4030

4131
/// 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());
32+
pub(crate) fn get_response_formatter(mode: &OutputMode) -> Box<dyn ResponseFormatter> {
33+
if !supports_color() {
34+
return Box::new(response::PlainFormatter);
35+
}
36+
match mode {
37+
OutputMode::Plain => Box::new(response::PlainFormatter),
38+
OutputMode::Color => Box::new(response::ColorFormatter),
39+
OutputMode::Emoji => Box::new(response::EmojiFormatter),
4540
}
46-
Box::new(response::Color::new())
4741
}

0 commit comments

Comments
 (0)