Fix: Bring back error output for links (#1553)

With the last lychee release, we simplified the status output for links.

While this reduced the visual noise, it also accidentally caused the source of errors to not be printed anymore. This change brings back the additional error information as part of the final report output. Furthermore, it shows the error information in the progress output if verbose mode is activated.

Fixes #1487
This commit is contained in:
Matthias Endler 2024-11-07 00:22:50 +01:00 committed by GitHub
parent 2cdec324c2
commit 71564344de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 388 additions and 89 deletions

1
Cargo.lock generated
View file

@ -2412,6 +2412,7 @@ dependencies = [
"tokio-stream",
"toml",
"tracing-subscriber",
"url",
"uuid",
"wiremock",
]

View file

@ -1,8 +1,8 @@
# Test detailed JSON output error
# Test Detailed JSON Output Error
This file is used to test if the error details are parsed properly in the json
This file is used to test if the error details are parsed properly in the JSON
format.
[The website](https://expired.badssl.com/) produce SSL expired certificate
error. Such network error has no status code but it can be identified by error
status details.
[The website](https://expired.badssl.com/) produces an SSL expired certificate
error. Such a network error has no status code, but it can be identified by
error status details.

27
fixtures/TEST_INVALID_URLS.html vendored Normal file
View file

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Invalid URLs</title>
</head>
<body>
<ul>
<li>
<a href="https://httpbin.org/status/404"
>https://httpbin.org/status/404</a
>
</li>
<li>
<a href="https://httpbin.org/status/500"
>https://httpbin.org/status/500</a
>
</li>
<li>
<a href="https://httpbin.org/status/502"
>https://httpbin.org/status/502</a
>
</li>
</ul>
</body>
</html>

View file

@ -54,6 +54,7 @@ tabled = "0.16.0"
tokio = { version = "1.41.0", features = ["full"] }
tokio-stream = "0.1.16"
toml = "0.8.19"
url = "2.5.2"
[dev-dependencies]
assert_cmd = "2.0.16"

View file

@ -336,7 +336,15 @@ fn show_progress(
formatter: &dyn ResponseFormatter,
verbose: &Verbosity,
) -> Result<()> {
let out = formatter.format_response(response.body());
// In case the log level is set to info, we want to show the detailed
// response output. Otherwise, we only show the essential information
// (typically the status code and the URL, but this is dependent on the
// formatter).
let out = if verbose.log_level() >= log::Level::Info {
formatter.format_detailed_response(response.body())
} else {
formatter.format_response(response.body())
};
if let Some(pb) = progress_bar {
pb.inc(1);
@ -424,7 +432,7 @@ mod tests {
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/ | OK (cached)\n");
}
#[tokio::test]

View file

@ -10,10 +10,11 @@ use super::{ResponseFormatter, MAX_RESPONSE_OUTPUT_WIDTH};
/// has not explicitly requested raw, uncolored output.
pub(crate) struct ColorFormatter;
impl ResponseFormatter for ColorFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
// Determine the color based on the status.
let status_color = match body.status {
impl ColorFormatter {
/// Determine the color for formatted output based on the status of the
/// response.
fn status_color(status: &Status) -> &'static once_cell::sync::Lazy<console::Style> {
match status {
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => &GREEN,
Status::Excluded
| Status::Unsupported(_)
@ -21,34 +22,49 @@ impl ResponseFormatter for ColorFormatter {
Status::Redirected(_) => &NORMAL,
Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW,
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK,
};
}
}
let status_formatted = format_status(&body.status);
/// 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();
let colored_status = status_color.apply_to(status_formatted);
// 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
// Construct the output.
format!("{} {}", colored_status, body.uri)
format!(
"{}[{:>width$}]",
" ".repeat(padding),
status_code_or_text,
width = status_code_or_text.len()
)
}
/// Color and format the response status.
fn format_response_status(status: &Status) -> String {
let status_color = ColorFormatter::status_color(status);
let formatted_status = ColorFormatter::format_status(status);
status_color.apply_to(formatted_status).to_string()
}
}
/// 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();
impl ResponseFormatter for ColorFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
let colored_status = ColorFormatter::format_response_status(&body.status);
format!("{} {}", colored_status, body.uri)
}
// 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()
)
/// Provide some more detailed information about the response
/// This prints the entire response body, including the exact error message
/// (if available).
fn format_detailed_response(&self, body: &ResponseBody) -> String {
let colored_status = ColorFormatter::format_response_status(&body.status);
format!("{colored_status} {body}")
}
}
#[cfg(test)]
@ -56,6 +72,12 @@ mod tests {
use super::*;
use http::StatusCode;
use lychee_lib::{ErrorKind, Status, Uri};
use pretty_assertions::assert_eq;
/// Helper function to strip ANSI color codes for tests
fn strip_ansi_codes(s: &str) -> String {
console::strip_ansi_codes(s).to_string()
}
// Helper function to create a ResponseBody with a given status and URI
fn mock_response_body(status: Status, uri: &str) -> ResponseBody {
@ -65,20 +87,18 @@ mod tests {
}
}
#[cfg(test)]
/// Helper function to strip ANSI color codes for tests
fn strip_ansi_codes(s: &str) -> String {
console::strip_ansi_codes(s).to_string()
#[test]
fn test_format_status() {
let status = Status::Ok(StatusCode::OK);
assert_eq!(ColorFormatter::format_status(&status).trim_start(), "[200]");
}
#[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!(
strip_ansi_codes(&formatter.format_response(&body)),
" [200] https://example.com/"
);
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
assert_eq!(formatted_response, " [200] https://example.com/");
}
#[test]
@ -88,10 +108,8 @@ mod tests {
Status::Error(ErrorKind::InvalidUrlHost),
"https://example.com/404",
);
assert_eq!(
strip_ansi_codes(&formatter.format_response(&body)),
" [ERROR] https://example.com/404"
);
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
assert_eq!(formatted_response, " [ERROR] https://example.com/404");
}
#[test]
@ -100,7 +118,22 @@ mod tests {
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);
let formatted_response = strip_ansi_codes(&formatter.format_response(&body));
assert!(formatted_response.contains(long_uri));
}
#[test]
fn test_detailed_response_output() {
let formatter = ColorFormatter;
let body = mock_response_body(
Status::Error(ErrorKind::InvalidUrlHost),
"https://example.com/404",
);
let response = strip_ansi_codes(&formatter.format_detailed_response(&body));
assert_eq!(
response,
" [ERROR] https://example.com/404 | URL is missing a host"
);
}
}

View file

@ -8,9 +8,11 @@ use super::ResponseFormatter;
/// visual output.
pub(crate) struct EmojiFormatter;
impl ResponseFormatter for EmojiFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
let emoji = match body.status {
impl EmojiFormatter {
/// Determine the color for formatted output based on the status of the
/// response.
const fn emoji_for_status(status: &Status) -> &'static str {
match status {
Status::Ok(_) | Status::Cached(CacheStatus::Ok(_)) => "",
Status::Excluded
| Status::Unsupported(_)
@ -18,9 +20,20 @@ impl ResponseFormatter for EmojiFormatter {
Status::Redirected(_) => "↪️",
Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️",
Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "",
};
}
}
}
impl ResponseFormatter for EmojiFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
let emoji = EmojiFormatter::emoji_for_status(&body.status);
format!("{} {}", emoji, body.uri)
}
fn format_detailed_response(&self, body: &ResponseBody) -> String {
let emoji = EmojiFormatter::emoji_for_status(&body.status);
format!("{emoji} {body}")
}
}
#[cfg(test)]
@ -92,4 +105,18 @@ mod emoji_tests {
"⚠️ https://example.com/unknown"
);
}
#[test]
fn test_detailed_response_output() {
let formatter = EmojiFormatter;
let body = mock_response_body(
Status::Error(ErrorKind::InvalidUrlHost),
"https://example.com/404",
);
// Just assert the output contains the string
assert!(formatter
.format_detailed_response(&body)
.ends_with("| URL is missing a host"));
}
}

View file

@ -25,4 +25,13 @@ pub(crate) const MAX_RESPONSE_OUTPUT_WIDTH: usize = 10;
pub(crate) trait ResponseFormatter: Send + Sync {
/// Format the response body into a human-readable string
fn format_response(&self, body: &ResponseBody) -> String;
/// Detailed response formatter (defaults to the normal formatter)
///
/// This can be used for output modes which want to provide more detailed
/// information. It is also used if the output is set to verbose mode
/// (i.e. `-v`, `-vv` and above).
fn format_detailed_response(&self, body: &ResponseBody) -> String {
self.format_response(body)
}
}

View file

@ -14,7 +14,7 @@ pub(crate) struct PlainFormatter;
impl ResponseFormatter for PlainFormatter {
fn format_response(&self, body: &ResponseBody) -> String {
body.to_string()
format!("[{}] {}", body.status.code_as_string(), body)
}
}
@ -51,7 +51,7 @@ mod plain_tests {
);
assert_eq!(
formatter.format_response(&body),
"[ERROR] https://example.com/404 | Failed: URL is missing a host"
"[ERROR] https://example.com/404 | URL is missing a host"
);
}
@ -59,10 +59,9 @@ mod plain_tests {
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"
"[EXCLUDED] https://example.com/not-checked"
);
}
@ -73,7 +72,6 @@ mod plain_tests {
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"
@ -87,8 +85,6 @@ mod plain_tests {
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

@ -40,7 +40,11 @@ impl Display for CompactResponseStats {
for (source, responses) in &stats.fail_map {
color!(f, BOLD_YELLOW, "[{}]:\n", source)?;
for response in responses {
writeln!(f, "{}", response_formatter.format_response(response))?;
writeln!(
f,
"{}",
response_formatter.format_detailed_response(response)
)?;
}
if let Some(suggestions) = &stats.suggestion_map.get(source) {
@ -106,3 +110,76 @@ impl StatsFormatter for Compact {
Ok(Some(compact.to_string()))
}
}
#[cfg(test)]
mod tests {
use crate::formatters::stats::StatsFormatter;
use crate::{options::OutputMode, stats::ResponseStats};
use http::StatusCode;
use lychee_lib::{InputSource, ResponseBody, Status, Uri};
use std::collections::{HashMap, HashSet};
use url::Url;
use super::*;
#[test]
fn test_formatter() {
// A couple of dummy successes
let mut success_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
success_map.insert(
InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap())),
HashSet::from_iter(vec![ResponseBody {
uri: Uri::from(Url::parse("https://example.com").unwrap()),
status: Status::Ok(StatusCode::OK),
}]),
);
let err1 = ResponseBody {
uri: Uri::try_from("https://github.com/mre/idiomatic-rust-doesnt-exist-man").unwrap(),
status: Status::Ok(StatusCode::NOT_FOUND),
};
let err2 = ResponseBody {
uri: Uri::try_from("https://github.com/mre/boom").unwrap(),
status: Status::Ok(StatusCode::INTERNAL_SERVER_ERROR),
};
let mut fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
let source = InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap()));
fail_map.insert(source, HashSet::from_iter(vec![err1, err2]));
let stats = ResponseStats {
total: 1,
successful: 1,
errors: 2,
unknown: 0,
excludes: 0,
timeouts: 0,
duration_secs: 0,
fail_map,
suggestion_map: HashMap::default(),
unsupported: 0,
redirects: 0,
cached: 0,
success_map,
excluded_map: HashMap::default(),
detailed_stats: false,
};
let formatter = Compact::new(OutputMode::Plain);
let result = formatter.format(stats).unwrap().unwrap();
println!("{result}");
assert!(result.contains("🔍 1 Total"));
assert!(result.contains("✅ 1 OK"));
assert!(result.contains("🚫 2 Errors"));
assert!(result.contains("[https://example.com/]:"));
assert!(result
.contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man | 404 Not Found"));
assert!(result.contains("https://github.com/mre/boom | 500 Internal Server Error"));
}
}

View file

@ -55,7 +55,11 @@ impl Display for DetailedResponseStats {
write!(f, "\n\nErrors in {source}")?;
for response in responses {
write!(f, "\n{}", response_formatter.format_response(response))?;
write!(
f,
"\n{}",
response_formatter.format_detailed_response(response)
)?;
if let Some(suggestions) = &stats.suggestion_map.get(source) {
writeln!(f, "\nSuggestions in {source}")?;
@ -89,3 +93,65 @@ impl StatsFormatter for Detailed {
Ok(Some(detailed.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::options::OutputMode;
use http::StatusCode;
use lychee_lib::{InputSource, ResponseBody, Status, Uri};
use std::collections::{HashMap, HashSet};
use url::Url;
#[test]
fn test_detailed_formatter_github_404() {
let err1 = ResponseBody {
uri: Uri::try_from("https://github.com/mre/idiomatic-rust-doesnt-exist-man").unwrap(),
status: Status::Ok(StatusCode::NOT_FOUND),
};
let err2 = ResponseBody {
uri: Uri::try_from("https://github.com/mre/boom").unwrap(),
status: Status::Ok(StatusCode::INTERNAL_SERVER_ERROR),
};
let mut fail_map: HashMap<InputSource, HashSet<ResponseBody>> = HashMap::new();
let source = InputSource::RemoteUrl(Box::new(Url::parse("https://example.com").unwrap()));
fail_map.insert(source, HashSet::from_iter(vec![err1, err2]));
let stats = ResponseStats {
total: 2,
successful: 0,
errors: 2,
unknown: 0,
excludes: 0,
timeouts: 0,
duration_secs: 0,
unsupported: 0,
redirects: 0,
cached: 0,
suggestion_map: HashMap::default(),
success_map: HashMap::default(),
fail_map,
excluded_map: HashMap::default(),
detailed_stats: true,
};
let formatter = Detailed::new(OutputMode::Plain);
let result = formatter.format(stats).unwrap().unwrap();
// Check for the presence of expected content
assert!(result.contains("📝 Summary"));
assert!(result.contains("🔍 Total............2"));
assert!(result.contains("✅ Successful.......0"));
assert!(result.contains("⏳ Timeouts.........0"));
assert!(result.contains("🔀 Redirected.......0"));
assert!(result.contains("👻 Excluded.........0"));
assert!(result.contains("❓ Unknown..........0"));
assert!(result.contains("🚫 Errors...........2"));
assert!(result.contains("Errors in https://example.com/"));
assert!(result
.contains("https://github.com/mre/idiomatic-rust-doesnt-exist-man | 404 Not Found"));
assert!(result.contains("https://github.com/mre/boom | 500 Internal Server Error"));
}
}

View file

@ -186,7 +186,7 @@ mod tests {
let markdown = markdown_response(&response).unwrap();
assert_eq!(
markdown,
"* [200] [http://example.com/](http://example.com/) | Cached: OK (cached)"
"* [200] [http://example.com/](http://example.com/) | OK (cached)"
);
}
@ -199,7 +199,7 @@ mod tests {
let markdown = markdown_response(&response).unwrap();
assert_eq!(
markdown,
"* [400] [http://example.com/](http://example.com/) | Cached: Error (cached)"
"* [400] [http://example.com/](http://example.com/) | Error (cached)"
);
}
@ -253,7 +253,7 @@ mod tests {
### Errors in stdin
* [404] [http://127.0.0.1/](http://127.0.0.1/) | Cached: Error (cached)
* [404] [http://127.0.0.1/](http://127.0.0.1/) | Error (cached)
## Suggestions per input

View file

@ -112,6 +112,50 @@ mod cli {
}};
}
/// Test that the default report output format (compact) and mode (color)
/// prints the failed URLs as well as their status codes on error. Make
/// sure that the status code only occurs once.
#[test]
fn test_compact_output_format_contains_status() -> Result<()> {
let test_path = fixtures_path().join("TEST_INVALID_URLS.html");
let mut cmd = main_command();
cmd.arg("--format")
.arg("compact")
.arg("--mode")
.arg("color")
.arg(test_path)
.env("FORCE_COLOR", "1")
.assert()
.failure()
.code(2);
let output = cmd.output()?;
// Check that the output contains the status code (once) and the URL
let output_str = String::from_utf8_lossy(&output.stdout);
// The expected output is as follows:
// "Find details below."
// [EMPTY LINE]
// [path/to/file]:
// [400] https://httpbin.org/status/404
// [500] https://httpbin.org/status/500
// [502] https://httpbin.org/status/502
// (the order of the URLs may vary)
// Check that the output contains the file path
assert!(output_str.contains("TEST_INVALID_URLS.html"));
let re = Regex::new(r"\s{5}\[\d{3}\] https://httpbin\.org/status/\d{3}").unwrap();
let matches: Vec<&str> = re.find_iter(&output_str).map(|m| m.as_str()).collect();
// Check that the status code occurs only once
assert_eq!(matches.len(), 3);
Ok(())
}
/// JSON-formatted output should always be valid JSON.
/// Additional hints and error messages should be printed to `stderr`.
/// See https://github.com/lycheeverse/lychee/issues/1355
@ -156,7 +200,7 @@ mod cli {
let site_error_status = &output_json["fail_map"][&test_path.to_str().unwrap()][0]["status"];
assert_eq!(
"error sending request for url (https://expired.badssl.com/)",
"error sending request for url (https://expired.badssl.com/) Maybe a certificate error?",
site_error_status["details"]
);
Ok(())
@ -425,7 +469,7 @@ mod cli {
.failure()
.code(2)
.stdout(contains(
"[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man"
"[404] https://github.com/mre/idiomatic-rust-doesnt-exist-man | Network error: Not Found"
))
.stderr(contains(
"There were issues with GitHub URLs. You could try setting a GitHub token and running lychee again.",
@ -902,11 +946,11 @@ mod cli {
// Run again to verify cache behavior
cmd.assert()
.stderr(contains(format!(
"[200] {}/ | Cached: OK (cached)\n",
"[200] {}/ | OK (cached)\n",
mock_server_ok.uri()
)))
.stderr(contains(format!(
"[404] {}/ | Cached: Error (cached)\n",
"[404] {}/ | Error (cached)\n",
mock_server_err.uri()
)));
@ -955,11 +999,11 @@ mod cli {
.assert()
.stderr(contains(format!("[200] {}/\n", mock_server_ok.uri())))
.stderr(contains(format!(
"[204] {}/ | OK (204 No Content): No Content\n",
"[204] {}/ | 204 No Content: No Content\n",
mock_server_no_content.uri()
)))
.stderr(contains(format!(
"[429] {}/ | Failed: Network error: Too Many Requests\n",
"[429] {}/ | Network error: Too Many Requests\n",
mock_server_too_many_requests.uri()
)));
@ -1017,11 +1061,11 @@ mod cli {
.failure()
.code(2)
.stdout(contains(format!(
"[418] {}/ | Failed: Network error: I\'m a teapot",
"[418] {}/ | Network error: I\'m a teapot",
mock_server_teapot.uri()
)))
.stdout(contains(format!(
"[500] {}/ | Failed: Network error: Internal Server Error",
"[500] {}/ | Network error: Internal Server Error",
mock_server_server_error.uri()
)));
@ -1040,11 +1084,11 @@ mod cli {
.assert()
.success()
.stderr(contains(format!(
"[418] {}/ | Cached: OK (cached)",
"[418] {}/ | OK (cached)",
mock_server_teapot.uri()
)))
.stderr(contains(format!(
"[500] {}/ | Cached: OK (cached)",
"[500] {}/ | OK (cached)",
mock_server_server_error.uri()
)));
@ -1080,7 +1124,7 @@ mod cli {
.stderr(contains(format!(
"[IGNORED] {unsupported_url} | Unsupported: Error creating request client"
)))
.stderr(contains(format!("[EXCLUDED] {excluded_url} | Excluded\n")));
.stderr(contains(format!("[EXCLUDED] {excluded_url}\n")));
// The cache file should be empty, because the only checked URL is
// unsupported and we don't want to cache that. It might be supported in

View file

@ -177,7 +177,15 @@ impl ErrorKind {
.to_string(),
)
} else {
Some(utils::reqwest::trim_error_output(e))
// Get the relevant details from the specific reqwest error
let details = utils::reqwest::trim_error_output(e);
// Provide support for common error types
if e.is_connect() {
Some(format!("{details} Maybe a certificate error?"))
} else {
Some(details)
}
}
}
ErrorKind::GithubRequest(e) => {

View file

@ -79,19 +79,24 @@ 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.code_as_string(), self.uri)?;
// Always write the URI
write!(f, "{}", self.uri)?;
if let Status::Ok(StatusCode::OK) = self.status {
// Don't print anything else if the status code is 200.
// The output gets too verbose then.
// Early return for OK status to avoid verbose output
if matches!(self.status, Status::Ok(StatusCode::OK)) {
return Ok(());
}
// Add a separator between the URI and the additional details below.
// Note: To make the links clickable in some terminals,
// we add a space before the separator.
write!(f, " | {}", self.status)?;
// Format status and return early if empty
let status_output = self.status.to_string();
if status_output.is_empty() {
return Ok(());
}
// Write status with separator
write!(f, " | {status_output}")?;
// Add details if available
if let Some(details) = self.status.details() {
write!(f, ": {details}")
} else {

View file

@ -45,15 +45,15 @@ pub enum Status {
impl Display for Status {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Status::Ok(code) => write!(f, "OK ({code})"),
Status::Ok(code) => write!(f, "{code}"),
Status::Redirected(code) => write!(f, "Redirect ({code})"),
Status::UnknownStatusCode(code) => write!(f, "Unknown status ({code})"),
Status::Excluded => f.write_str("Excluded"),
Status::Timeout(Some(code)) => write!(f, "Timeout ({code})"),
Status::Timeout(None) => f.write_str("Timeout"),
Status::Unsupported(e) => write!(f, "Unsupported: {e}"),
Status::Error(e) => write!(f, "Failed: {e}"),
Status::Cached(status) => write!(f, "Cached: {status}"),
Status::Error(e) => write!(f, "{e}"),
Status::Cached(status) => write!(f, "{status}"),
Status::Excluded => Ok(()),
}
}
}
@ -310,10 +310,7 @@ mod tests {
fn test_status_serialization() {
let status_ok = Status::Ok(StatusCode::from_u16(200).unwrap());
let serialized_with_code = serde_json::to_string(&status_ok).unwrap();
assert_eq!(
"{\"text\":\"OK (200 OK)\",\"code\":200}",
serialized_with_code
);
assert_eq!("{\"text\":\"200 OK\",\"code\":200}", serialized_with_code);
let status_timeout = Status::Timeout(None);
let serialized_without_code = serde_json::to_string(&status_timeout).unwrap();