Skip to main content

larust_core/
error.rs

1use crate::{debug, error_pages};
2use axum::http::header::CONTENT_TYPE;
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use thiserror::Error;
6
7/// The framework's primary error type.
8///
9/// HTTP responses for `Config`/`Internal` only ever expose a generic
10/// message - *unless* `APP_DEBUG=true` (see the `debug` module), in which
11/// case the full message and source chain are rendered as an HTML page
12/// instead. The wrapped source error (with full detail) is always logged
13/// via `tracing` regardless of debug mode. Never enable `APP_DEBUG` outside
14/// local development - see `docs/GOTCHAS.md`.
15#[derive(Debug, Error)]
16pub enum AppError {
17    #[error("configuration error: {0}")]
18    Config(#[source] Box<dyn std::error::Error + Send + Sync>),
19
20    #[error("not found")]
21    NotFound,
22
23    #[error("internal server error: {0}")]
24    Internal(#[source] Box<dyn std::error::Error + Send + Sync>),
25
26    /// A specific HTTP status with a message safe to show clients (Laravel's
27    /// `abort()`). Unlike `Config`/`Internal`, this message is sent as-is -
28    /// callers are responsible for not putting sensitive detail in it.
29    #[error("{message}")]
30    Http { status: StatusCode, message: String },
31}
32
33impl IntoResponse for AppError {
34    fn into_response(self) -> Response {
35        // Computed once, up front, from thiserror's own derived `Display`
36        // (`#[error("configuration error: {0}")]` / `#[error("internal
37        // server error: {0}")]`) - the single source of truth for the
38        // top-level message, used both for the log line below and (for
39        // `Config`/`Internal`) as the debug-page's own first "cause" card,
40        // so the two can never silently drift apart the way two separately
41        // hand-written literals could.
42        let message = self.to_string();
43        if matches!(self, AppError::Config(_) | AppError::Internal(_)) {
44            tracing::error!(error = %message, "unhandled application error");
45        }
46
47        match self {
48            AppError::NotFound => {
49                if debug::is_enabled() {
50                    debug_page(
51                        StatusCode::NOT_FOUND,
52                        "Not Found",
53                        &["No route matched this request.".to_string()],
54                        None,
55                    )
56                } else {
57                    html_response(StatusCode::NOT_FOUND, error_pages::not_found_html())
58                }
59            }
60            AppError::Http { status, message } => (status, message).into_response(),
61            AppError::Config(source) | AppError::Internal(source) => {
62                internal_response(&message, source.as_ref())
63            }
64        }
65    }
66}
67
68/// Shared by both `AppError` variants that carry a boxed source error -
69/// walks the full `source()` chain (each wrapped error, one level at a
70/// time) so a debug-mode page shows e.g. the actual SQL driver error, not
71/// just "internal server error". Capped so a pathological (e.g. cyclic)
72/// third-party `source()` implementation can't hang the request or grow
73/// the page unbounded - every error source in this codebase today
74/// terminates in a handful of levels, so the cap is generous, not tight.
75const MAX_SOURCE_CHAIN_DEPTH: u8 = 20;
76
77fn internal_response(top_message: &str, source: &(dyn std::error::Error + 'static)) -> Response {
78    if debug::is_enabled() {
79        // One message per card (see `debug_page`'s own doc comment for why
80        // this changed from one big concatenated string) rather than
81        // building a single blob - each `source()` level renders as its
82        // own distinct, readable card instead of a wall of text with
83        // hand-inserted "Caused by:" separators.
84        let mut messages = vec![top_message.to_string()];
85        let mut cause = source.source();
86        let mut depth = 0;
87        while let Some(err) = cause {
88            depth += 1;
89            if depth > MAX_SOURCE_CHAIN_DEPTH {
90                messages.push("… source chain truncated …".to_string());
91                break;
92            }
93            messages.push(err.to_string());
94            cause = err.source();
95        }
96        debug_page(
97            StatusCode::INTERNAL_SERVER_ERROR,
98            "Internal Server Error",
99            &messages,
100            None,
101        )
102    } else {
103        html_response(
104            StatusCode::INTERNAL_SERVER_ERROR,
105            error_pages::internal_html(),
106        )
107    }
108}
109
110/// What capturing a backtrace for a given panic turned up.
111/// `Application::serve()` installs a custom panic hook (alongside its
112/// `CatchPanicLayer`) that captures this at the exact moment a panic
113/// happens, since capturing *after* `catch_unwind` returns would be
114/// useless - the stack has already unwound back up to the catching layer
115/// by then, so a fresh capture there would just show tower-http's own
116/// frames, not the panic site's.
117pub(crate) enum PanicBacktrace {
118    /// `RUST_BACKTRACE`/`RUST_LIB_BACKTRACE` wasn't set at the moment this
119    /// exact panic happened - `std::backtrace::Backtrace::capture()`'s own
120    /// documented behavior, the same env var Rust's own default panic hook
121    /// already reads. `xr dev --debug` sets `RUST_BACKTRACE=full`
122    /// automatically for exactly this reason - see `larust_cli::dev::
123    /// set_debug_env_vars`.
124    Disabled,
125    /// This platform/build doesn't support capturing one at all -
126    /// `std::backtrace::BacktraceStatus` can report this; rare, but real.
127    Unsupported,
128    /// The real, captured backtrace - `Backtrace`'s own `Display` output,
129    /// parsed into individual frames purely for rendering (grouping
130    /// consecutive Rust-runtime-internal frames into a collapsible region
131    /// instead of showing every single one at full prominence).
132    Captured(String),
133}
134
135/// Same debug/production branching `AppError::Internal` uses, for a panic
136/// caught by `Application::serve()`'s `CatchPanicLayer`. There's no
137/// `AppError`/`std::error::Error` value for a panic - just its payload's
138/// message, plus whatever `backtrace` the panic hook managed to capture -
139/// so this is a distinct entry point rather than routed through
140/// `internal_response`, with no synthetic `source()` chain to walk.
141pub(crate) fn render_panic(message: &str, backtrace: PanicBacktrace) -> Response {
142    tracing::error!(error = %message, "panic in request handler");
143    if debug::is_enabled() {
144        debug_page(
145            StatusCode::INTERNAL_SERVER_ERROR,
146            "Internal Server Error (panic)",
147            &[message.to_string()],
148            Some(backtrace),
149        )
150    } else {
151        html_response(
152            StatusCode::INTERNAL_SERVER_ERROR,
153            error_pages::internal_html(),
154        )
155    }
156}
157
158/// Shared by every production-mode branch above - builds the same
159/// `text/html; charset=utf-8` response shape `debug_page` uses, just for
160/// an already-fully-rendered page instead of one this module formats
161/// itself.
162fn html_response(status: StatusCode, html: String) -> Response {
163    (status, [(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
164}
165
166/// One frame of a parsed backtrace - `text` is the frame's own full,
167/// possibly-multi-line block (the `N: symbol` header line, plus any
168/// `at FILE:LINE` location line(s) `Backtrace`'s own `Display` impl prints
169/// under it), kept together as one unit so collapsing/rendering never
170/// splits a frame's header from its own location.
171struct Frame {
172    text: String,
173    is_runtime_noise: bool,
174}
175
176/// A line starting a new frame in `Backtrace`'s own `Display` output looks
177/// like (leading whitespace varies, but always) `<digits>: <symbol>` -
178/// e.g. `  12: core::ops::function::FnOnce::call_once`. Any other line
179/// (typically an indented `at /path/to/file.rs:10:5` location line, or a
180/// continuation for an inlined frame) belongs to whichever frame most
181/// recently started.
182fn is_frame_header_line(line: &str) -> bool {
183    let trimmed = line.trim_start();
184    let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
185    digit_count > 0 && trimmed[digit_count..].starts_with(':')
186}
187
188/// Rust-standard-library/runtime frames that appear, unchanged, in the
189/// backtrace of *every* panic, regardless of what the application actually
190/// did - panic machinery unwinding itself, allocator internals, the
191/// backtrace-capturing code's own frames, and (since every Larust app runs
192/// on it) tokio's own task-polling machinery. Collapsing these by default
193/// is the same spirit as Laravel/Ignition's own "N vendor frames
194/// collapsed", applied to what a Rust backtrace's own equivalent of
195/// "vendor" actually is - there's no portable way to also detect "this
196/// frame belongs to some *other* third-party crate the app depends on" at
197/// this generic a layer, so this intentionally stays conservative: it only
198/// ever hides frames that are *never* the app's own code, rather than
199/// guessing more broadly and risking hiding something relevant.
200const RUNTIME_NOISE_PREFIXES: &[&str] = &[
201    "std::",
202    "core::",
203    "alloc::",
204    "__rust_",
205    "rust_begin_unwind",
206    "backtrace::",
207    "backtrace_rs::",
208    "tokio::",
209    "<unknown>",
210];
211
212fn classify_as_runtime_noise(frame_text: &str) -> bool {
213    let first_line = frame_text.lines().next().unwrap_or("");
214    let symbol = first_line
215        .split_once(':')
216        .map(|(_, rest)| rest.trim())
217        .unwrap_or(first_line);
218    RUNTIME_NOISE_PREFIXES
219        .iter()
220        .any(|prefix| symbol.starts_with(prefix))
221}
222
223/// Parses `Backtrace`'s own `Display` output into individual frames - there
224/// is no stable, structured per-frame API on `std::backtrace::Backtrace`
225/// itself (only `Display`/`Debug`, producing pre-formatted text), so this
226/// has to work from that text directly rather than real frame objects.
227fn parse_backtrace_frames(text: &str) -> Vec<Frame> {
228    let mut frames: Vec<Frame> = Vec::new();
229    for line in text.lines() {
230        if is_frame_header_line(line) {
231            frames.push(Frame {
232                text: line.to_string(),
233                is_runtime_noise: false,
234            });
235        } else if let Some(last) = frames.last_mut() {
236            last.text.push('\n');
237            last.text.push_str(line);
238        }
239        // A line before any frame header at all (shouldn't happen given
240        // `Backtrace`'s own fixed format, but not worth panicking the
241        // error page itself over) is simply dropped.
242    }
243    for frame in &mut frames {
244        frame.is_runtime_noise = classify_as_runtime_noise(&frame.text);
245    }
246    frames
247}
248
249/// Renders `frames` as HTML, grouping any *run* of consecutive
250/// runtime-noise frames into one collapsed `<details>` region (native,
251/// keyboard-accessible, needs no JavaScript) rather than showing every
252/// single one at full prominence - noise can cluster at both the start of
253/// a backtrace (panic/unwind machinery) and the end (tokio's own task-
254/// polling loop, `main`), so this groups each *contiguous* run
255/// independently instead of assuming there's only ever one.
256fn render_backtrace_frames_html(frames: &[Frame]) -> String {
257    let mut html = String::new();
258    let mut i = 0;
259    while i < frames.len() {
260        if frames[i].is_runtime_noise {
261            let start = i;
262            while i < frames.len() && frames[i].is_runtime_noise {
263                i += 1;
264            }
265            let hidden = &frames[start..i];
266            let joined = hidden
267                .iter()
268                .map(|f| f.text.as_str())
269                .collect::<Vec<_>>()
270                .join("\n");
271            html.push_str(&format!(
272                "<details class=\"bt-noise\"><summary>{} runtime frame{} hidden</summary><pre>{}</pre></details>",
273                hidden.len(),
274                if hidden.len() == 1 { "" } else { "s" },
275                escape_html(&joined),
276            ));
277        } else {
278            html.push_str(&format!(
279                "<pre class=\"bt-frame\">{}</pre>",
280                escape_html(&frames[i].text)
281            ));
282            i += 1;
283        }
284    }
285    html
286}
287
288/// Renders the "Stack trace" section for a panic - absent entirely for
289/// every other `AppError` variant, which has a `source()` chain instead
290/// (already rendered as its own cause cards by the caller).
291fn render_backtrace_section(backtrace: &PanicBacktrace) -> String {
292    match backtrace {
293        PanicBacktrace::Captured(text) => {
294            let frames = parse_backtrace_frames(text);
295            format!(
296                r#"<div class="section">
297  <h2>Stack trace</h2>
298  <div class="backtrace">{}</div>
299</div>"#,
300                render_backtrace_frames_html(&frames)
301            )
302        }
303        PanicBacktrace::Disabled => r#"<div class="section">
304  <h2>Stack trace</h2>
305  <p class="hint">No backtrace was captured. Set <code>RUST_BACKTRACE=1</code> (or run <code>xr dev --debug</code>, which sets it automatically) to see one here next time.</p>
306</div>"#
307            .to_string(),
308        PanicBacktrace::Unsupported => r#"<div class="section">
309  <h2>Stack trace</h2>
310  <p class="hint">Backtraces aren't supported on this platform/build.</p>
311</div>"#
312            .to_string(),
313    }
314}
315
316/// Self-contained (no external CSS/JS, no build step) - this has to render
317/// standalone even as the very first response a broken app ever produces.
318///
319/// `messages` is the ordered list of "what went wrong" entries: the
320/// top-level error first, then each `source()` cause in order (or just one
321/// entry, for `NotFound`/a panic, which have no chain at all) - rendered as
322/// its own distinct card rather than one big concatenated string with
323/// hand-inserted "Caused by:" text, both for readability on a long chain
324/// and so a single very long message doesn't force the *entire* page to
325/// scroll instead of just its own card (reported directly, from a real
326/// overflow bug in `xr dev`'s own build-failure placeholder page - see
327/// `docs/GOTCHAS.md` - that this page's design deliberately avoids
328/// repeating).
329///
330/// `backtrace` is only ever `Some` for a panic (see `render_panic`) - every
331/// other `AppError` variant has a real `source()` chain instead, which
332/// `messages` already captures.
333fn debug_page(
334    status: StatusCode,
335    title: &str,
336    messages: &[String],
337    backtrace: Option<PanicBacktrace>,
338) -> Response {
339    let (first, rest) = messages.split_first().expect("messages is never empty");
340    let causes_html: String = rest
341        .iter()
342        .map(|message| {
343            format!(
344                "<div class=\"cause\"><span class=\"cause-label\">Caused by</span><pre>{}</pre></div>",
345                escape_html(message)
346            )
347        })
348        .collect();
349    let backtrace_html = backtrace
350        .as_ref()
351        .map(render_backtrace_section)
352        .unwrap_or_default();
353
354    let html = format!(
355        r##"<!doctype html>
356<html lang="en">
357<head>
358<meta charset="utf-8">
359<meta name="viewport" content="width=device-width, initial-scale=1">
360<title>{status} {title}</title>
361<style>
362{style}
363</style>
364</head>
365<body>
366<div class="page">
367  <header>
368    <div class="brand">
369      <svg viewBox="0 0 48 48" role="img" aria-hidden="true"><path fill="#ff735f" d="M12 0h24c6.63 0 12 5.37 12 12v24c0 6.63-5.37 12-12 12H0V12C0 5.37 5.37 0 12 0Z"/><path fill="#fff" d="M13.25 30.59a1 1 0 0 1-.76-1.64l4.7-5.59-4.69-5.42a1 1 0 1 1 1.51-1.31l5.25 6.07a1 1 0 0 1 0 1.3l-5.25 6.24a1 1 0 0 1-.76.35Z"/><path fill="#fff" d="M32.75 34.73h-12a1 1 0 1 1 0-2h12a1 1 0 1 1 0 2Z"/></svg>
370      <span>larust</span>
371    </div>
372    {theme_switch}
373  </header>
374  <main>
375    <div class="badges">
376      <span class="badge badge-status">{status}</span>
377      <span class="badge badge-title">{title}</span>
378    </div>
379    <div class="cause cause-primary"><pre>{first}</pre></div>
380    {causes_html}
381    {backtrace_html}
382  </main>
383  <footer>Shown because <code>APP_DEBUG=true</code>. Never enable this outside local development.</footer>
384</div>
385{theme_script}
386</body>
387</html>"##,
388        status = status.as_u16(),
389        title = escape_html(title),
390        first = escape_html(first),
391        style = DEBUG_PAGE_STYLE,
392        theme_switch = THEME_SWITCH_HTML,
393        theme_script = THEME_SWITCH_SCRIPT,
394    );
395
396    (status, [(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
397}
398
399/// Color tokens reuse this framework's own established brand palette
400/// (`#f4513d`/`#ff735f`, already used by `larust_cli::dev_placeholder` and
401/// every generated app's own scaffolded CSS) and the exact light-mode
402/// values `demo`'s own stylesheet already defines (`--ink`/`--muted`/
403/// `--line`/`--paper`) - this page should look like a deliberate part of
404/// Larust, not a generic reskin of Laravel's own debug page, even though
405/// the *idea* (a real full-page error view instead of a bare `<pre>` dump)
406/// is the same one Laravel/Ignition already proved out well.
407const DEBUG_PAGE_STYLE: &str = r#"
408:root {
409  color-scheme: dark;
410  --bg: #171513; --panel: #211e1b; --panel-2: #272522; --border: #3a352e;
411  --ink: #f8f3eb; --muted: #a99d90; --accent: #ff735f; --accent-soft: #3a2420;
412  font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
413}
414@media (prefers-color-scheme: light) {
415  :root:not([data-theme="dark"]) {
416    --bg: #f4f0e8; --panel: #fffdf9; --panel-2: #f4f0e8; --border: #e4ddd2;
417    --ink: #202124; --muted: #6b6d73; --accent: #cf3628; --accent-soft: #fbe6e3;
418  }
419}
420:root[data-theme="light"] {
421  --bg: #f4f0e8; --panel: #fffdf9; --panel-2: #f4f0e8; --border: #e4ddd2;
422  --ink: #202124; --muted: #6b6d73; --accent: #cf3628; --accent-soft: #fbe6e3;
423}
424* { box-sizing: border-box; }
425body { margin: 0; background: var(--bg); color: var(--ink); }
426.page { max-width: 64rem; margin: 0 auto; padding: 1.5rem clamp(1rem, 4vw, 2.5rem) 3rem; }
427header { display: flex; align-items: center; justify-content: space-between; padding: .5rem 0 1.5rem; }
428.brand { display: inline-flex; align-items: center; gap: .6rem; font-size: 1.05rem; font-weight: 800; letter-spacing: -.03em; }
429.brand svg { width: 1.75rem; height: 1.75rem; flex: none; }
430.badges { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; }
431.badge { display: inline-flex; align-items: center; padding: .3rem .7rem; border-radius: 999px; font-size: .78rem; font-weight: 700; }
432.badge-status { background: var(--accent-soft); color: var(--accent); }
433.badge-title { background: var(--panel-2); color: var(--muted); border: 1px solid var(--border); }
434.cause { margin-bottom: .9rem; background: var(--panel); border: 1px solid var(--border); border-radius: .8rem; padding: 1rem 1.1rem; }
435.cause-primary { border-color: var(--accent); }
436.cause-label { display: block; margin-bottom: .4rem; color: var(--muted); font-size: .7rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
437.cause pre, .section pre { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; font: .85rem/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
438.section { margin-top: 1.5rem; }
439.section h2 { font-size: .85rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin: 0 0 .7rem; }
440.hint { color: var(--muted); font-size: .9rem; }
441.backtrace { display: flex; flex-direction: column; gap: .4rem; }
442.bt-frame { background: var(--panel); border: 1px solid var(--border); border-radius: .6rem; padding: .6rem .8rem; }
443.bt-noise { background: var(--panel-2); border: 1px dashed var(--border); border-radius: .6rem; }
444.bt-noise summary { cursor: pointer; padding: .5rem .8rem; color: var(--muted); font-size: .82rem; font-weight: 650; }
445.bt-noise[open] summary { border-bottom: 1px dashed var(--border); }
446.bt-noise pre { padding: .6rem .8rem; }
447footer { margin-top: 2rem; color: var(--muted); font-size: .82rem; }
448footer code { background: var(--panel-2); border-radius: .3rem; padding: .1rem .35rem; }
449.theme-switch { position: relative; }
450.theme-switch button.toggle { display: inline-flex; align-items: center; justify-content: center; width: 2.2rem; height: 2.2rem; border-radius: .6rem; border: 1px solid var(--border); background: var(--panel); color: var(--muted); cursor: pointer; }
451.theme-switch button.toggle svg { width: 1.1rem; height: 1.1rem; }
452.theme-menu { position: absolute; right: 0; top: calc(100% + .4rem); min-width: 8rem; background: var(--panel); border: 1px solid var(--border); border-radius: .6rem; padding: .3rem; box-shadow: 0 10px 30px rgba(0,0,0,.25); z-index: 10; }
453.theme-menu button { display: block; width: 100%; text-align: left; padding: .45rem .6rem; border: 0; background: none; color: var(--ink); font: inherit; font-size: .85rem; border-radius: .4rem; cursor: pointer; }
454.theme-menu button:hover { background: var(--panel-2); }
455"#;
456
457const THEME_SWITCH_HTML: &str = r#"<div class="theme-switch">
458      <button type="button" class="toggle" id="larust-theme-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Change color theme">
459        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></svg>
460      </button>
461      <div class="theme-menu" id="larust-theme-menu" hidden>
462        <button type="button" data-theme-choice="light">Light</button>
463        <button type="button" data-theme-choice="dark">Dark</button>
464        <button type="button" data-theme-choice="system">System</button>
465      </div>
466    </div>"#;
467
468/// Persists to `localStorage`, wrapped in `try`/`catch` throughout -
469/// private browsing or a blocked-storage policy can make any call throw,
470/// and this page has to keep working (just without persistence) even then,
471/// not break the one interactive thing on an already-broken-app's error
472/// page.
473const THEME_SWITCH_SCRIPT: &str = r#"<script>
474(function () {
475  var root = document.documentElement;
476  var STORAGE_KEY = 'larust-debug-theme';
477  function apply(theme) {
478    if (theme === 'dark') { root.setAttribute('data-theme', 'dark'); }
479    else if (theme === 'light') { root.setAttribute('data-theme', 'light'); }
480    else { root.removeAttribute('data-theme'); }
481  }
482  var saved = null;
483  try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
484  apply(saved || 'system');
485
486  var button = document.getElementById('larust-theme-toggle');
487  var menu = document.getElementById('larust-theme-menu');
488  button.addEventListener('click', function () {
489    var opening = menu.hidden;
490    menu.hidden = !opening;
491    button.setAttribute('aria-expanded', String(opening));
492  });
493  Array.prototype.forEach.call(menu.querySelectorAll('[data-theme-choice]'), function (option) {
494    option.addEventListener('click', function () {
495      var theme = option.getAttribute('data-theme-choice');
496      apply(theme);
497      try { localStorage.setItem(STORAGE_KEY, theme); } catch (e) {}
498      menu.hidden = true;
499      button.setAttribute('aria-expanded', 'false');
500    });
501  });
502  document.addEventListener('click', function (event) {
503    if (!menu.hidden && !event.target.closest('.theme-switch')) {
504      menu.hidden = true;
505      button.setAttribute('aria-expanded', 'false');
506    }
507  });
508})();
509</script>"#;
510
511fn escape_html(s: &str) -> String {
512    let mut out = String::with_capacity(s.len());
513    for c in s.chars() {
514        match c {
515            '&' => out.push_str("&amp;"),
516            '<' => out.push_str("&lt;"),
517            '>' => out.push_str("&gt;"),
518            '"' => out.push_str("&quot;"),
519            '\'' => out.push_str("&#x27;"),
520            _ => out.push(c),
521        }
522    }
523    out
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn is_frame_header_line_matches_a_real_backtrace_frame_header() {
532        assert!(is_frame_header_line("   0: rust_begin_unwind"));
533        assert!(is_frame_header_line("  12: core::panicking::panic_fmt"));
534        assert!(!is_frame_header_line(
535            "             at /rustc/abc/library/std/src/panicking.rs:665:5"
536        ));
537        assert!(!is_frame_header_line(""));
538    }
539
540    #[test]
541    fn classify_as_runtime_noise_flags_known_stdlib_and_runtime_prefixes() {
542        assert!(classify_as_runtime_noise(
543            "   0: rust_begin_unwind\n             at /rustc/abc/library/std/src/panicking.rs:665:5"
544        ));
545        assert!(classify_as_runtime_noise(
546            "   1: core::panicking::panic_fmt"
547        ));
548        assert!(classify_as_runtime_noise(
549            "   2: tokio::runtime::task::core::Core<T,S>::poll"
550        ));
551        assert!(!classify_as_runtime_noise("   3: demo::handlers::broken"));
552    }
553
554    #[test]
555    fn parse_backtrace_frames_groups_a_multi_line_frame_together() {
556        let text = "   0: rust_begin_unwind\n             at /rustc/abc/library/std/src/panicking.rs:665:5\n   1: demo::handlers::broken\n             at ./src/handlers.rs:10:5";
557        let frames = parse_backtrace_frames(text);
558        assert_eq!(frames.len(), 2);
559        assert!(frames[0].text.contains("rust_begin_unwind"));
560        assert!(frames[0].text.contains("panicking.rs:665:5"));
561        assert!(frames[1].text.contains("demo::handlers::broken"));
562    }
563
564    /// Uses a *real* captured backtrace (`force_capture`, which ignores
565    /// `RUST_BACKTRACE` entirely - unlike `capture()`, whose env-var check
566    /// is cached process-wide after the first call, making it unreliable
567    /// to toggle from within a shared test binary; see `std::backtrace`'s
568    /// own doc comment) so this proves the parser against Rust's own real
569    /// output format, not a hand-constructed approximation of it that could
570    /// drift from what `Backtrace::capture()` actually produces.
571    #[test]
572    fn parse_backtrace_frames_handles_a_real_captured_backtrace() {
573        let backtrace = std::backtrace::Backtrace::force_capture();
574        let frames = parse_backtrace_frames(&backtrace.to_string());
575        assert!(
576            !frames.is_empty(),
577            "a force-captured backtrace must parse into at least one frame"
578        );
579        // Every real backtrace from inside this test binary's own process
580        // includes genuine Rust-runtime frames (test harness setup, if
581        // nothing else) - this doesn't assert on any *specific* frame
582        // (fragile against toolchain/std version drift), just that the
583        // classifier isn't a no-op that flags nothing at all.
584        assert!(
585            frames.iter().any(|f| f.is_runtime_noise),
586            "a real backtrace must contain at least one recognizable runtime frame"
587        );
588    }
589
590    #[test]
591    fn render_backtrace_frames_html_collapses_consecutive_noisy_frames_into_one_region() {
592        let frames = vec![
593            Frame {
594                text: "   0: rust_begin_unwind".to_string(),
595                is_runtime_noise: true,
596            },
597            Frame {
598                text: "   1: core::panicking::panic_fmt".to_string(),
599                is_runtime_noise: true,
600            },
601            Frame {
602                text: "   2: demo::handlers::broken".to_string(),
603                is_runtime_noise: false,
604            },
605            Frame {
606                text: "   3: tokio::runtime::task::core::Core<T,S>::poll".to_string(),
607                is_runtime_noise: true,
608            },
609        ];
610        let html = render_backtrace_frames_html(&frames);
611        assert_eq!(
612            html.matches("<details class=\"bt-noise\">").count(),
613            2,
614            "two separate noisy runs (leading and trailing) must become two separate collapsed regions, not one"
615        );
616        assert!(html.contains("2 runtime frames hidden"));
617        assert!(html.contains("1 runtime frame hidden"));
618        assert!(html.contains("demo::handlers::broken"));
619    }
620
621    /// `debug_page` is called directly here, not through `render_panic`/
622    /// `AppError::into_response` - both of those gate on the process-wide
623    /// `debug::is_enabled()` `OnceLock`, which stays `false` for this
624    /// entire shared unit-test binary (nothing in *this* module ever calls
625    /// `Application::new()` to flip it, and flipping it directly would
626    /// permanently commit that choice for every other unit test sharing
627    /// this same process - the exact conflict `application.rs`'s own test
628    /// module doc comment already documents avoiding). `debug_page` itself
629    /// has no such gate - only its callers do - so calling it directly
630    /// exercises the real rendering logic in full isolation from that
631    /// global flag. The gate itself (does `APP_DEBUG=true` actually route
632    /// here at all) is covered separately, in a real separate process, by
633    /// `tests/error_response_debug_mode.rs`.
634    async fn body_text(response: Response) -> String {
635        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
636            .await
637            .unwrap();
638        String::from_utf8(bytes.to_vec()).unwrap()
639    }
640
641    #[tokio::test]
642    async fn debug_page_renders_each_cause_as_its_own_card() {
643        let response = debug_page(
644            StatusCode::INTERNAL_SERVER_ERROR,
645            "Internal Server Error",
646            &["top-level failure".to_string(), "root cause".to_string()],
647            None,
648        );
649        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
650        let body = body_text(response).await;
651        assert!(body.contains("top-level failure"));
652        assert!(body.contains("root cause"));
653        assert!(body.contains("Caused by"));
654        // The theme switcher is always present, regardless of which
655        // `AppError` variant produced the page.
656        assert!(body.contains("larust-theme-toggle"));
657    }
658
659    #[tokio::test]
660    async fn debug_page_shows_a_hint_when_backtraces_are_disabled() {
661        let response = debug_page(
662            StatusCode::INTERNAL_SERVER_ERROR,
663            "Internal Server Error (panic)",
664            &["boom".to_string()],
665            Some(PanicBacktrace::Disabled),
666        );
667        let body = body_text(response).await;
668        assert!(body.contains("RUST_BACKTRACE=1"));
669        assert!(body.contains("xr dev --debug"));
670        // No `source()` chain for a panic - must never show a stray
671        // "Caused by" card with nothing behind it.
672        assert!(!body.contains("Caused by"));
673    }
674
675    #[tokio::test]
676    async fn debug_page_shows_a_hint_when_backtraces_are_unsupported() {
677        let response = debug_page(
678            StatusCode::INTERNAL_SERVER_ERROR,
679            "Internal Server Error (panic)",
680            &["boom".to_string()],
681            Some(PanicBacktrace::Unsupported),
682        );
683        let body = body_text(response).await;
684        assert!(body.contains("aren't supported on this platform"));
685    }
686
687    #[tokio::test]
688    async fn debug_page_renders_a_captured_backtrace_with_noisy_frames_collapsed() {
689        let backtrace = concat!(
690            "   0: rust_begin_unwind\n",
691            "             at /rustc/abc/library/std/src/panicking.rs:665:5\n",
692            "   1: demo::handlers::broken\n",
693            "             at ./src/handlers.rs:10:5\n",
694            "   2: tokio::runtime::task::core::Core<T,S>::poll\n",
695        );
696        let response = debug_page(
697            StatusCode::INTERNAL_SERVER_ERROR,
698            "Internal Server Error (panic)",
699            &["boom".to_string()],
700            Some(PanicBacktrace::Captured(backtrace.to_string())),
701        );
702        let body = body_text(response).await;
703        assert!(body.contains("Stack trace"));
704        assert!(body.contains("demo::handlers::broken"));
705        assert!(body.contains("bt-noise"));
706        assert!(!body.contains("RUST_BACKTRACE=1"));
707    }
708}