use crate::request::Request;
use crate::response::Response;
use crate::status::Status;
use rustlavel_core::{Error, Json};
use std::sync::atomic::{AtomicBool, Ordering};
static DEBUG: AtomicBool = AtomicBool::new(false);
pub fn set_debug(enabled: bool) {
DEBUG.store(enabled, Ordering::Relaxed);
}
pub fn debug_enabled() -> bool {
DEBUG.load(Ordering::Relaxed)
}
pub struct Diagnostic {
pub title: String,
pub message: String,
pub hint: Option<String>,
pub location: Option<(String, u32)>,
pub status: Status,
}
impl Diagnostic {
pub fn from_error(error: &Error) -> Self {
Diagnostic {
title: error.title().to_string(),
message: error.to_string(),
hint: error.hint(),
location: None,
status: Status(error.status()),
}
}
pub fn from_panic(message: String, location: Option<(String, u32)>) -> Self {
Diagnostic {
title: "Unhandled Panic".to_string(),
message,
hint: Some(
"A handler panicked. Prefer returning `Result` so the failure is part of the \
type, rather than unwrapping a value that can be absent."
.to_string(),
),
location,
status: Status::INTERNAL_ERROR,
}
}
}
pub fn response_for(error: &Error) -> Response {
render(&Diagnostic::from_error(error), None)
}
pub fn render(diagnostic: &Diagnostic, request: Option<&Request>) -> Response {
let wants_json = request.is_some_and(Request::wants_json);
if !debug_enabled() {
rustlavel_core::log::log(
rustlavel_core::log::Level::Error,
format!("{}: {}", diagnostic.title, diagnostic.message),
);
return if wants_json {
Response::new(diagnostic.status).with_json(Json::object([(
"message",
Json::from("Server Error"),
)]))
} else {
Response::new(diagnostic.status).with_html(PRODUCTION_PAGE)
};
}
if wants_json {
let mut fields = vec![
("message", Json::from(diagnostic.message.as_str())),
("exception", Json::from(diagnostic.title.as_str())),
];
if let Some(hint) = &diagnostic.hint {
fields.push(("hint", Json::from(hint.as_str())));
}
if let Some((file, line)) = &diagnostic.location {
fields.push(("file", Json::from(file.as_str())));
fields.push(("line", Json::from(*line)));
}
return Response::new(diagnostic.status).with_json(Json::object(fields));
}
Response::new(diagnostic.status).with_html(html(diagnostic, request))
}
fn html(diagnostic: &Diagnostic, request: Option<&Request>) -> String {
let mut sections = String::new();
if let Some(hint) = &diagnostic.hint {
sections.push_str(&format!(
r#"<div class="hint"><span class="hint-label">Suggestion</span><p>{}</p></div>"#,
escape(hint)
));
}
if let Some((file, line)) = &diagnostic.location {
sections.push_str(&format!(
r#"<div class="location">{}<span class="line">:{}</span></div>"#,
escape(file),
line
));
if let Some(snippet) = source_snippet(file, *line) {
sections.push_str(&snippet);
}
}
if let Some(request) = request {
sections.push_str(&request_panel(request));
}
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} — Rustlavel</title>
<style>{CSS}</style>
</head>
<body>
<main>
<header>
<div class="badge">{status}</div>
<h1>{title}</h1>
<p class="message">{message}</p>
</header>
{sections}
<footer>Rustlavel debug page — hidden automatically when <code>APP_DEBUG=false</code>.</footer>
</main>
</body>
</html>"#,
title = escape(&diagnostic.title),
status = diagnostic.status.code(),
message = escape(&diagnostic.message),
)
}
fn source_snippet(file: &str, line: u32) -> Option<String> {
let source = std::fs::read_to_string(file).ok()?;
let lines: Vec<&str> = source.lines().collect();
let target = line as usize;
let start = target.saturating_sub(4).max(1);
let end = (target + 3).min(lines.len());
let mut rows = String::new();
for number in start..=end {
let content = lines.get(number - 1).copied().unwrap_or_default();
let class = if number == target { "row highlight" } else { "row" };
rows.push_str(&format!(
r#"<div class="{class}"><span class="num">{number}</span><code>{}</code></div>"#,
escape(content)
));
}
Some(format!(r#"<div class="snippet">{rows}</div>"#))
}
fn request_panel(request: &Request) -> String {
let mut rows = format!(
r#"<tr><th>Method</th><td>{}</td></tr><tr><th>Path</th><td>{}</td></tr>"#,
request.method(),
escape(request.target())
);
if let Some(route) = request.route() {
rows.push_str(&format!(r#"<tr><th>Route</th><td>{}</td></tr>"#, escape(route)));
}
for (name, value) in request.headers().iter() {
let shown = if is_sensitive(name) { "[hidden]" } else { value };
rows.push_str(&format!(
r#"<tr><th>{}</th><td>{}</td></tr>"#,
escape(name),
escape(shown)
));
}
format!(r#"<div class="panel"><h2>Request</h2><table>{rows}</table></div>"#)
}
fn is_sensitive(header: &str) -> bool {
matches!(header, "authorization" | "cookie" | "proxy-authorization" | "x-api-key")
}
fn escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c => out.push(c),
}
}
out
}
const PRODUCTION_PAGE: &str = r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Server Error</title>
<style>body{font:16px/1.6 system-ui,sans-serif;display:grid;place-content:center;height:100vh;margin:0;color:#334}</style>
</head><body><div><h1>500</h1><p>Something went wrong. Please try again.</p></div></body></html>"#;
const CSS: &str = r#"
:root { color-scheme: light dark; --bg:#faf9f7; --fg:#1c1b1a; --muted:#6b6864; --line:#e5e2dd;
--accent:#b4483c; --panel:#fff; --code:#f4f2ef; }
@media (prefers-color-scheme: dark) {
:root { --bg:#181716; --fg:#eceae7; --muted:#9a958e; --line:#2e2c29; --accent:#e0796c;
--panel:#201f1d; --code:#252321; }
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:15px/1.6 ui-sans-serif,-apple-system,'Segoe UI',sans-serif; }
main { max-width: 900px; margin: 0 auto; padding: 48px 24px 80px; }
header { border-left: 3px solid var(--accent); padding-left: 20px; margin-bottom: 32px; }
.badge { display:inline-block; background:var(--accent); color:#fff; font-size:12px; font-weight:600;
letter-spacing:.06em; padding:3px 9px; border-radius:4px; }
h1 { font-size: 26px; margin: 12px 0 8px; font-weight: 650; }
.message { font-size: 17px; margin:0; color:var(--fg); }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing:.08em; color: var(--muted);
margin: 0 0 12px; font-weight: 600; }
.hint { background:var(--panel); border:1px solid var(--line); border-radius:8px;
padding:16px 20px; margin-bottom:24px; }
.hint-label { display:block; font-size:12px; text-transform:uppercase; letter-spacing:.08em;
color:var(--accent); font-weight:600; margin-bottom:4px; }
.hint p { margin:0; }
.location { font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px;
color:var(--muted); margin-bottom:8px; }
.location .line { color: var(--accent); }
.snippet { background:var(--code); border:1px solid var(--line); border-radius:8px;
overflow-x:auto; margin-bottom:24px; padding:12px 0; }
.row { display:flex; gap:16px; padding:1px 20px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
font-size:13px; white-space:pre; }
.row.highlight { background:color-mix(in srgb, var(--accent) 14%, transparent); }
.num { color:var(--muted); min-width:3ch; text-align:right; user-select:none; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; color:var(--muted); font-weight:500; width:180px; vertical-align:top;
padding:4px 12px 4px 0; word-break:break-word; }
td { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; padding:4px 0; word-break:break-all; }
footer { margin-top:40px; font-size:12px; color:var(--muted); }
code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::method::Method;
static DEBUG_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn exclusive() -> std::sync::MutexGuard<'static, ()> {
DEBUG_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn production_hides_the_details() {
let _guard = exclusive();
set_debug(false);
let response = response_for(&Error::msg("connection string leaked"));
assert_eq!(response.status, Status::INTERNAL_ERROR);
assert!(!response.body_string().contains("connection string leaked"));
}
#[test]
fn debug_shows_the_message_and_hint() {
let _guard = exclusive();
set_debug(true);
let error = Error::Config { file: ".env".into(), line: 3, message: "bad line".into() };
let response = response_for(&error);
let body = response.body_string();
assert!(body.contains("bad line"));
assert!(body.contains("Suggestion"));
set_debug(false);
}
#[test]
fn json_clients_get_json_errors() {
let _guard = exclusive();
set_debug(true);
let request = Request::new(Method::Get, "/api/users").with_header("accept", "application/json");
let response = render(&Diagnostic::from_error(&Error::msg("nope")), Some(&request));
assert_eq!(response.headers.content_type(), Some("application/json"));
assert!(response.body_string().contains("\"message\":\"nope\""));
set_debug(false);
}
#[test]
fn credentials_are_not_echoed_onto_the_page() {
let _guard = exclusive();
set_debug(true);
let request = Request::new(Method::Get, "/")
.with_header("authorization", "Bearer super-secret")
.with_header("x-trace", "abc");
let response = render(&Diagnostic::from_error(&Error::msg("boom")), Some(&request));
let body = response.body_string();
assert!(!body.contains("super-secret"));
assert!(body.contains("[hidden]"));
assert!(body.contains("abc"));
set_debug(false);
}
#[test]
fn markup_in_a_message_is_escaped() {
let _guard = exclusive();
set_debug(true);
let response = response_for(&Error::msg("<script>alert(1)</script>"));
assert!(!response.body_string().contains("<script>"));
assert!(response.body_string().contains("<script>"));
set_debug(false);
}
}