diff --git a/Cargo.lock b/Cargo.lock index 665f89e415..5c2e741676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2412,6 +2412,7 @@ dependencies = [ "tokio-stream", "toml", "tracing-subscriber", + "url", "uuid", "wiremock", ] diff --git a/fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md b/fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md index 81e0535c5d..253fb9dcdf 100644 --- a/fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md +++ b/fixtures/TEST_DETAILED_JSON_OUTPUT_ERROR.md @@ -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. diff --git a/fixtures/TEST_INVALID_URLS.html b/fixtures/TEST_INVALID_URLS.html new file mode 100644 index 0000000000..974082a61e --- /dev/null +++ b/fixtures/TEST_INVALID_URLS.html @@ -0,0 +1,27 @@ + + + + + + Invalid URLs + + + + + diff --git a/lychee-bin/Cargo.toml b/lychee-bin/Cargo.toml index a56b7e0cc3..4d12cd556c 100644 --- a/lychee-bin/Cargo.toml +++ b/lychee-bin/Cargo.toml @@ -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" diff --git a/lychee-bin/src/commands/check.rs b/lychee-bin/src/commands/check.rs index 2ffc0ed372..944f86683d 100644 --- a/lychee-bin/src/commands/check.rs +++ b/lychee-bin/src/commands/check.rs @@ -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] diff --git a/lychee-bin/src/formatters/response/color.rs b/lychee-bin/src/formatters/response/color.rs index 9aa12df40f..824a2f2ac2 100644 --- a/lychee-bin/src/formatters/response/color.rs +++ b/lychee-bin/src/formatters/response/color.rs @@ -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 { + 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(); + + // 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() + ) + } - let colored_status = status_color.apply_to(status_formatted); + /// 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() + } +} - // Construct the output. +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) } -} -/// 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() - ) + /// 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" + ); + } } diff --git a/lychee-bin/src/formatters/response/emoji.rs b/lychee-bin/src/formatters/response/emoji.rs index 75062f2d1d..7d5daebe96 100644 --- a/lychee-bin/src/formatters/response/emoji.rs +++ b/lychee-bin/src/formatters/response/emoji.rs @@ -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")); + } } diff --git a/lychee-bin/src/formatters/response/mod.rs b/lychee-bin/src/formatters/response/mod.rs index b1734fd199..727324ff31 100644 --- a/lychee-bin/src/formatters/response/mod.rs +++ b/lychee-bin/src/formatters/response/mod.rs @@ -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) + } } diff --git a/lychee-bin/src/formatters/response/plain.rs b/lychee-bin/src/formatters/response/plain.rs index be4c8fae0a..c15ac42098 100644 --- a/lychee-bin/src/formatters/response/plain.rs +++ b/lychee-bin/src/formatters/response/plain.rs @@ -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 )" diff --git a/lychee-bin/src/formatters/stats/compact.rs b/lychee-bin/src/formatters/stats/compact.rs index 2c2a8e6b92..087acbc3a6 100644 --- a/lychee-bin/src/formatters/stats/compact.rs +++ b/lychee-bin/src/formatters/stats/compact.rs @@ -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> = 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> = 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")); + } +} diff --git a/lychee-bin/src/formatters/stats/detailed.rs b/lychee-bin/src/formatters/stats/detailed.rs index 6e4037ca25..ae30d160b5 100644 --- a/lychee-bin/src/formatters/stats/detailed.rs +++ b/lychee-bin/src/formatters/stats/detailed.rs @@ -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> = 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")); + } +} diff --git a/lychee-bin/src/formatters/stats/markdown.rs b/lychee-bin/src/formatters/stats/markdown.rs index 1c650953b4..e4cf11666b 100644 --- a/lychee-bin/src/formatters/stats/markdown.rs +++ b/lychee-bin/src/formatters/stats/markdown.rs @@ -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 diff --git a/lychee-bin/tests/cli.rs b/lychee-bin/tests/cli.rs index 3734094025..aab4fc169c 100644 --- a/lychee-bin/tests/cli.rs +++ b/lychee-bin/tests/cli.rs @@ -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 diff --git a/lychee-lib/src/types/error.rs b/lychee-lib/src/types/error.rs index bb1415fef0..7246fe7e84 100644 --- a/lychee-lib/src/types/error.rs +++ b/lychee-lib/src/types/error.rs @@ -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) => { diff --git a/lychee-lib/src/types/response.rs b/lychee-lib/src/types/response.rs index 2f898aeb09..33ab6f3fd3 100644 --- a/lychee-lib/src/types/response.rs +++ b/lychee-lib/src/types/response.rs @@ -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 { diff --git a/lychee-lib/src/types/status.rs b/lychee-lib/src/types/status.rs index d115f8dc1c..bb845c715e 100644 --- a/lychee-lib/src/types/status.rs +++ b/lychee-lib/src/types/status.rs @@ -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();