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();