From f3b1e4ed525f766bbc4f586274c46185603c89d7 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:11:54 +0000 Subject: [PATCH 1/9] fix: improve missing rustdoc page errors --- crates/bin/docs_rs_web/src/error.rs | 217 +++++++++++++++++- .../bin/docs_rs_web/src/handlers/rustdoc.rs | 13 +- crates/bin/docs_rs_web/templates/error.html | 9 + 3 files changed, 237 insertions(+), 2 deletions(-) diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index 080bb3e5e5..b41179b006 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -17,6 +17,15 @@ use docs_rs_uri::EscapedURI; use std::borrow::Cow; use tracing::error; +/// A single navigation link offered on an error page to help the user recover. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct RecoveryLink { + /// The user-visible link text. + pub label: Cow<'static, str>, + /// The link target. + pub href: EscapedURI, +} + #[derive(Template)] #[template(path = "error.html")] #[derive(Debug, Clone, PartialEq)] @@ -26,6 +35,8 @@ pub(crate) struct AxumErrorPage { /// The error message, displayed as a description pub message: Cow<'static, str>, pub status: StatusCode, + /// Optional navigation links to help the user recover. Empty for most errors. + pub recovery: Vec, } impl_axum_webpage! { @@ -38,6 +49,20 @@ impl_axum_webpage! { pub enum AxumNope { #[error("Requested resource not found")] ResourceNotFound, + /// A specific doc page/file was not found, but its crate and version *do* + /// exist and have docs. Renders a 404 that acknowledges the crate/version + /// and offers recovery links (issue #2568). + #[error("Requested resource not found in an existing crate version")] + ResourceNotFoundInVersion { + name: String, + version: String, + /// Whether the request used the `/latest/` alias (vs. a pinned version). + is_latest_url: bool, + /// Root of the docs for the requested version. + version_root_url: EscapedURI, + /// The crate details page for the requested version. + crate_details_url: EscapedURI, + }, #[error("Requested build not found")] BuildNotFound, #[error("Requested crate not found")] @@ -76,6 +101,32 @@ impl AxumNope { status: StatusCode::NOT_FOUND, } } + AxumNope::ResourceNotFoundInVersion { + name, + version, + is_latest_url, + .. + } => { + // The crate & version exist and have docs, but this specific + // page within them does not (e.g. a module removed/renamed in a + // new release reached via `/latest/`). Acknowledge the version + // and offer recovery links instead of a bare 404 (issue #2568). + // The recovery links themselves are built in `recovery_links`. + let version_label = if is_latest_url { + format!("the latest version ({version})") + } else { + format!("version {version}") + }; + ErrorInfo { + title: "The requested resource does not exist", + message: format!( + "`{name}` {version_label} exists, but this page within it does not. \ + It may have been moved or removed in this release." + ) + .into(), + status: StatusCode::NOT_FOUND, + } + } AxumNope::BuildNotFound => ErrorInfo { title: "The requested build does not exist", message: "no such build".into(), @@ -137,6 +188,29 @@ impl AxumNope { AxumNope::Redirect(_target, _cache_policy) => unreachable!(), } } + + /// Navigation links offered on the error page to help the user recover. + /// Empty for every error except the contextual missing-page 404 (#2568). + fn recovery_links(&self) -> Vec { + match self { + AxumNope::ResourceNotFoundInVersion { + name, + version_root_url, + crate_details_url, + .. + } => vec![ + RecoveryLink { + label: "Documentation home for this version".into(), + href: version_root_url.clone(), + }, + RecoveryLink { + label: format!("All versions of {name}").into(), + href: crate_details_url.clone(), + }, + ], + _ => Vec::new(), + } + } } struct ErrorInfo { @@ -169,6 +243,7 @@ impl IntoResponse for AxumNope { } AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), _ => { + let recovery = self.recovery_links(); let ErrorInfo { title, message, @@ -178,6 +253,7 @@ impl IntoResponse for AxumNope { title, message, status, + recovery, } .into_response() } @@ -198,16 +274,27 @@ impl IntoResponse for JsonAxumNope { } AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), _ => { + let recovery = self.0.recovery_links(); let ErrorInfo { title, message, status, } = self.0.into_error_info(); + let links: Vec<_> = recovery + .iter() + .map(|link| { + serde_json::json!({ + "label": link.label, + "href": link.href, + }) + }) + .collect(); ( status, Json(serde_json::json!({ "title": title, "message": message, + "links": links, })), ) .into_response() @@ -245,7 +332,7 @@ pub(crate) type JsonAxumResult = Result; #[cfg(test)] mod tests { - use super::{AxumNope, EscapedURI, IntoResponse}; + use super::{AxumNope, EscapedURI, IntoResponse, JsonAxumNope}; use crate::cache::CachePolicy; use crate::testing::{ AxumResponseTestExt, AxumRouterTestExt, TestEnvironmentExt as _, async_wrapper, @@ -387,4 +474,132 @@ mod tests { Ok(()) }); } + + /// Helper: hrefs of the recovery links rendered on an error page. + fn recovery_hrefs(html: &str) -> Vec { + kuchikiki::parse_html() + .one(html) + .select("#recovery-links a") + .unwrap() + .map(|a| { + a.attributes + .borrow() + .get("href") + .unwrap_or_default() + .to_string() + }) + .collect() + } + + #[test] + fn check_404_missing_child_latest_offers_recovery_links() { + async_wrapper(|env| async move { + env.fake_release() + .await + .name("dummy") + .version("0.1.0") + .rustdoc_file("dummy/index.html") + .create() + .await?; + + let response = env + .web_app() + .await + .get("/dummy/latest/dummy/removed_module/index.html") + .await?; + assert_eq!(response.status(), 404); + + let body = response.text().await?; + let page = kuchikiki::parse_html().one(body.as_str()); + assert_eq!( + page.select("#crate-title") + .unwrap() + .next() + .unwrap() + .text_contents(), + "The requested resource does not exist", + ); + + // Two recovery links, preserving the `/latest/` alias. + let hrefs = recovery_hrefs(&body); + assert_eq!(hrefs.len(), 2); + assert!(hrefs.iter().any(|h| h == "/dummy/latest/dummy/")); + assert!(hrefs.iter().any(|h| h == "/crate/dummy/latest")); + + Ok(()) + }); + } + + #[test] + fn check_404_missing_child_pinned_keeps_pinned_version() { + async_wrapper(|env| async move { + env.fake_release() + .await + .name("dummy") + .version("0.1.0") + .rustdoc_file("dummy/index.html") + .create() + .await?; + + let response = env + .web_app() + .await + .get("/dummy/0.1.0/dummy/removed_module/index.html") + .await?; + assert_eq!(response.status(), 404); + + let body = response.text().await?; + let hrefs = recovery_hrefs(&body); + assert_eq!(hrefs.len(), 2); + assert!(hrefs.iter().any(|h| h == "/dummy/0.1.0/dummy/")); + assert!(hrefs.iter().any(|h| h == "/crate/dummy/0.1.0")); + // Pinned requests must not be rewritten to `latest`. + assert!(hrefs.iter().all(|h| !h.contains("latest"))); + + Ok(()) + }); + } + + #[test] + fn check_404_generic_resource_has_no_recovery_links() { + async_wrapper(|env| async move { + // A resource outside any existing crate keeps the plain 404 with no + // recovery links (regression: other error types are unaffected). + let body = env + .web_app() + .await + .get("/resource-which-doesnt-exist.js") + .await? + .text() + .await?; + let page = kuchikiki::parse_html().one(body.as_str()); + assert_eq!(page.select("#recovery-links").unwrap().count(), 0); + + Ok(()) + }); + } + + #[test] + fn json_error_body_includes_recovery_links() { + async_wrapper(|_env| async move { + let response = JsonAxumNope(AxumNope::ResourceNotFoundInVersion { + name: "dummy".into(), + version: "0.1.0".into(), + is_latest_url: true, + version_root_url: EscapedURI::from_path("/dummy/latest/dummy/"), + crate_details_url: EscapedURI::from_path("/crate/dummy/latest"), + }) + .into_response(); + + assert_eq!(response.status(), 404); + + let body: serde_json::Value = response.json().await?; + let links = body["links"].as_array().unwrap(); + assert_eq!(links.len(), 2); + assert_eq!(links[0]["href"], "/dummy/latest/dummy/"); + assert_eq!(links[1]["href"], "/crate/dummy/latest"); + + Ok(()) + }); + } } diff --git a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs index 8baebabdc9..21ac050195 100644 --- a/crates/bin/docs_rs_web/src/handlers/rustdoc.rs +++ b/crates/bin/docs_rs_web/src/handlers/rustdoc.rs @@ -725,7 +725,18 @@ pub(crate) async fn rustdoc_html_server_handler( ) } - return Err(AxumNope::ResourceNotFound); + // The crate and version exist and have docs, but this specific + // page within them does not (e.g. a module removed/renamed in a + // newer release reached via `/latest/`). Return a 404 that + // acknowledges the version and offers recovery links instead of a + // bare "resource not found" (issue #2568). + return Err(AxumNope::ResourceNotFoundInVersion { + name: params.name().to_string(), + version: krate.version.to_string(), + is_latest_url: params.req_version().is_latest(), + version_root_url: params.clone().with_inner_path("").rustdoc_url(), + crate_details_url: params.crate_details_url(), + }); } }; diff --git a/crates/bin/docs_rs_web/templates/error.html b/crates/bin/docs_rs_web/templates/error.html index 339a4c585b..c213487fdd 100644 --- a/crates/bin/docs_rs_web/templates/error.html +++ b/crates/bin/docs_rs_web/templates/error.html @@ -7,6 +7,15 @@

{{ title }}

{{ message }}
+ {%- if !recovery.is_empty() -%} +
+ +
+ {%- endif -%} {%- endblock header -%} {%- block topbar -%} From f55bb6fae9fee38afd9d53f88cac939b62495316 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:38:23 +0300 Subject: [PATCH 2/9] Initialize empty recovery in about 404 response Include an empty `recovery` Vec in the about handler's not-found page response. This ensures the response struct has the newly required `recovery` field and prevents missing-field errors after the page/template was extended. --- crates/bin/docs_rs_web/src/handlers/about.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 928d2de60f..5c90e62eaa 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -82,6 +82,7 @@ pub(crate) async fn about_handler(subpage: Option>) -> AxumResult Date: Thu, 23 Jul 2026 15:05:09 +0300 Subject: [PATCH 3/9] Update about.rs --- crates/bin/docs_rs_web/src/handlers/about.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 5c90e62eaa..03befbba50 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -82,7 +82,7 @@ pub(crate) async fn about_handler(subpage: Option>) -> AxumResult Date: Thu, 23 Jul 2026 15:16:08 +0300 Subject: [PATCH 4/9] Update about.rs --- crates/bin/docs_rs_web/src/handlers/about.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bin/docs_rs_web/src/handlers/about.rs b/crates/bin/docs_rs_web/src/handlers/about.rs index 03befbba50..5c90e62eaa 100644 --- a/crates/bin/docs_rs_web/src/handlers/about.rs +++ b/crates/bin/docs_rs_web/src/handlers/about.rs @@ -82,7 +82,7 @@ pub(crate) async fn about_handler(subpage: Option>) -> AxumResult Date: Thu, 23 Jul 2026 15:50:25 +0300 Subject: [PATCH 5/9] Only include recovery links when present Refactor JSON error response construction to avoid emitting an empty "links" array. The code now builds a base body with title and message and only inserts the "links" field if recovery links are non-empty. Also minor cleanup: clarified the NoResults comment and added braces around the Redirect match arm. --- crates/bin/docs_rs_web/src/error.rs | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index b41179b006..5d790f22ec 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -268,11 +268,12 @@ impl IntoResponse for JsonAxumNope { fn into_response(self) -> AxumResponse { match self.0 { AxumNope::NoResults => { - // user did a search with no search terms; invalid, - // return 404 + // User searched without providing search terms. StatusCode::NOT_FOUND.into_response() } - AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), + AxumNope::Redirect(target, cache_policy) => { + redirect_with_policy(target, cache_policy) + } _ => { let recovery = self.0.recovery_links(); let ErrorInfo { @@ -280,24 +281,27 @@ impl IntoResponse for JsonAxumNope { message, status, } = self.0.into_error_info(); - let links: Vec<_> = recovery - .iter() - .map(|link| { - serde_json::json!({ - "label": link.label, - "href": link.href, + + let mut body = serde_json::json!({ + "title": title, + "message": message, + }); + + if !recovery.is_empty() { + let links: Vec<_> = recovery + .iter() + .map(|link| { + serde_json::json!({ + "label": link.label, + "href": link.href, + }) }) - }) - .collect(); - ( - status, - Json(serde_json::json!({ - "title": title, - "message": message, - "links": links, - })), - ) - .into_response() + .collect(); + + body["links"] = serde_json::json!(links); + } + + (status, Json(body)).into_response() } } } From 06ada29aca1399abeb652fa1f382cc8f1012af90 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:52:05 +0300 Subject: [PATCH 6/9] Simplify Redirect match arm formatting Refactor the match arm for AxumNope::Redirect in crates/bin/docs_rs_web/src/error.rs to a single-line expression calling redirect_with_policy(target, cache_policy). This is a formatting/clarity change only and does not alter behavior. --- crates/bin/docs_rs_web/src/error.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index 5d790f22ec..1bc46db092 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -271,9 +271,7 @@ impl IntoResponse for JsonAxumNope { // User searched without providing search terms. StatusCode::NOT_FOUND.into_response() } - AxumNope::Redirect(target, cache_policy) => { - redirect_with_policy(target, cache_policy) - } + AxumNope::Redirect(target, cache_policy) => redirect_with_policy(target, cache_policy), _ => { let recovery = self.0.recovery_links(); let ErrorInfo { From 2137e121a86ad1a3493273112cc441c5080218a2 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:27:30 +0000 Subject: [PATCH 7/9] improve 404 wording and style recovery links as buttons Address review feedback on #2568: clarify the contextual missing-page message and render the recovery links as pure-css action buttons instead of a plain bullet list. JSON error body structure is unchanged. --- crates/bin/docs_rs_web/src/error.rs | 14 +++++++------- crates/bin/docs_rs_web/templates/error.html | 10 ++++------ crates/bin/docs_rs_web/templates/style/style.scss | 11 +++++++++++ 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/bin/docs_rs_web/src/error.rs b/crates/bin/docs_rs_web/src/error.rs index 1bc46db092..bf67bf714b 100644 --- a/crates/bin/docs_rs_web/src/error.rs +++ b/crates/bin/docs_rs_web/src/error.rs @@ -112,16 +112,16 @@ impl AxumNope { // new release reached via `/latest/`). Acknowledge the version // and offer recovery links instead of a bare 404 (issue #2568). // The recovery links themselves are built in `recovery_links`. - let version_label = if is_latest_url { - format!("the latest version ({version})") + let existing = if is_latest_url { + format!("The latest version of `{name}` ({version})") } else { - format!("version {version}") + format!("Version {version} of `{name}`") }; ErrorInfo { - title: "The requested resource does not exist", + title: "This page does not exist", message: format!( - "`{name}` {version_label} exists, but this page within it does not. \ - It may have been moved or removed in this release." + "{existing} exists, but this page inside it could not be found. \ + It may have been moved or removed." ) .into(), status: StatusCode::NOT_FOUND, @@ -519,7 +519,7 @@ mod tests { .next() .unwrap() .text_contents(), - "The requested resource does not exist", + "This page does not exist", ); // Two recovery links, preserving the `/latest/` alias. diff --git a/crates/bin/docs_rs_web/templates/error.html b/crates/bin/docs_rs_web/templates/error.html index c213487fdd..e9c9a3fa50 100644 --- a/crates/bin/docs_rs_web/templates/error.html +++ b/crates/bin/docs_rs_web/templates/error.html @@ -8,12 +8,10 @@

{{ title }}

{{ message }}
{%- if !recovery.is_empty() -%} -
- + {%- endif -%} {%- endblock header -%} diff --git a/crates/bin/docs_rs_web/templates/style/style.scss b/crates/bin/docs_rs_web/templates/style/style.scss index 036de8b82b..48d0e93d74 100644 --- a/crates/bin/docs_rs_web/templates/style/style.scss +++ b/crates/bin/docs_rs_web/templates/style/style.scss @@ -884,6 +884,17 @@ div.search-page-search-form { display: inline-block; } +#recovery-links { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 0 14px 14px; + + .pure-button { + text-decoration: none; + } +} + #clipboard { cursor: pointer; } From db1e00b8751c37f15420f8ad58c64678ede52191 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:55:35 +0000 Subject: [PATCH 8/9] Center contextual 404 recovery actions The error page title lives in a `div.container`, which is only centered on the page when the body sets the `centered` class. The error page doesn't, so the title was centered inside a box pinned to the left edge while the message and recovery links below centered against the full page width. Scope the container centering to the error page with an `error-header` class so the title lines up with the text below it, center the recovery links, and give them a small top gap so they read as part of the message rather than running into it. --- crates/bin/docs_rs_web/templates/error.html | 2 +- crates/bin/docs_rs_web/templates/style/style.scss | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/bin/docs_rs_web/templates/error.html b/crates/bin/docs_rs_web/templates/error.html index e9c9a3fa50..f170419100 100644 --- a/crates/bin/docs_rs_web/templates/error.html +++ b/crates/bin/docs_rs_web/templates/error.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {%- block header -%} -
+

{{ title }}

diff --git a/crates/bin/docs_rs_web/templates/style/style.scss b/crates/bin/docs_rs_web/templates/style/style.scss index 48d0e93d74..531946e216 100644 --- a/crates/bin/docs_rs_web/templates/style/style.scss +++ b/crates/bin/docs_rs_web/templates/style/style.scss @@ -704,6 +704,12 @@ div.docsrs-package-container { border-bottom: 1px solid var(--color-border); margin-bottom: 20px; + // On the error page the body text below is centered against the full page + // width, so the title bar has to be centered too or the two disagree. + &.error-header .container { + margin: 0 auto; + } + .container { display: flex; align-items: center; @@ -887,8 +893,9 @@ div.search-page-search-form { #recovery-links { display: flex; flex-wrap: wrap; + justify-content: center; gap: 10px; - padding: 0 14px 14px; + padding: 10px 14px 14px; .pure-button { text-decoration: none; From 8f780a186dda4cad5cbced8a66500a8aadff3822 Mon Sep 17 00:00:00 2001 From: kivancgnlp <67621783+kivancgnlp@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:36:43 +0000 Subject: [PATCH 9/9] Add GUI test for contextual 404 page --- gui-tests/404.goml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gui-tests/404.goml b/gui-tests/404.goml index 546e09a803..4a941e3644 100644 --- a/gui-tests/404.goml +++ b/gui-tests/404.goml @@ -1,3 +1,13 @@ // Checks the content of the 404 page. go-to: |DOC_PATH| + "/non-existing-crate" assert-text: ("#crate-title", "The requested crate does not exist") + +// Checks the 404 page of a page missing inside an existing crate version. +go-to: |DOC_PATH| + "/sysinfo/latest/sysinfo/removed_module/index.html" +assert-text: ("#crate-title", "This page does not exist") +assert-text: (".description", "The latest version of `sysinfo` (0.23.5) exists", CONTAINS) +assert-count: ("#recovery-links a", 2) +assert-text: ("#recovery-links a:nth-of-type(1)", "Documentation home for this version") +assert-attribute: ("#recovery-links a:nth-of-type(1)", {"href": "/sysinfo/latest/sysinfo/"}) +assert-text: ("#recovery-links a:nth-of-type(2)", "All versions of sysinfo") +assert-attribute: ("#recovery-links a:nth-of-type(2)", {"href": "/crate/sysinfo/latest"})