1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
//! Django-shape DEBUG template-error overlay — issue #386.
//!
//! When Tera fails to render a template at request time, the default
//! `template_views::render` fallback emits a 500 with a plain-text
//! "template render error: {err}" body — fine for production where
//! the operator pulls the full error from the tracing log, but
//! hostile to local dev where the developer wants to *see* what
//! broke without leaving the browser.
//!
//! This module provides the inverse path. [`enabled`] decides whether
//! the current process should serve debug overlays based on the
//! tiered-settings convention ([`RUSTANGO_ENV`] != `"prod"`, or the
//! explicit `RUSTANGO_TEMPLATE_DEBUG=1` override). [`error_page_html`]
//! renders a styled HTML page from a Tera error.
//!
//! The page intentionally pulls every field [`tera::Error`] exposes
//! — `Display`, kind discriminator, source chain — so the dev sees
//! the same diagnostic the tracing log gets. The styling is
//! inline-CSS so it works in any vanilla browser without a static
//! asset round-trip (which might fail for the same reason the
//! template did).
//!
//! ## Wiring
//!
//! Anywhere a caller invokes `Tera::render` and turns the result into
//! an HTTP response, replace
//!
//! ```ignore
//! match tera.render(name, ctx) {
//! Ok(html) => axum::response::Html(html).into_response(),
//! Err(e) => /* plain-text 500 */,
//! }
//! ```
//!
//! with:
//!
//! ```ignore
//! match tera.render(name, ctx) {
//! Ok(html) => axum::response::Html(html).into_response(),
//! Err(e) if rustango::template_debug::enabled() => (
//! axum::http::StatusCode::INTERNAL_SERVER_ERROR,
//! axum::response::Html(rustango::template_debug::error_page_html(&e, name)),
//! ).into_response(),
//! Err(_) => /* plain-text 500 */,
//! }
//! ```
//!
//! The framework's own `template_views::render` is updated for this
//! in the same slice as the helper — see the call site for the
//! reference wiring.
/// `true` when the current process should serve debug overlays on
/// template render errors. Resolution order:
///
/// 1. `RUSTANGO_TEMPLATE_DEBUG` env var, when set to a truthy value
/// (`1` / `true` / `yes` / `on` — case-insensitive) — forces ON.
/// Setting it to a falsy value (`0` / `false` / `no` / `off`)
/// forces OFF.
/// 2. Otherwise, `RUSTANGO_ENV` — `prod` (or `production`) → OFF,
/// anything else (including absent) → ON.
///
/// Reading the env on every call is cheap (single `std::env::var`)
/// and avoids a startup-time vs. config-load-time ordering hazard.
/// Callers that want compile-time control can wrap their call site
/// in `#[cfg(debug_assertions)]`.
#[must_use]
pub fn enabled() -> bool {
if let Ok(raw) = std::env::var("RUSTANGO_TEMPLATE_DEBUG") {
return match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
// Any other value — ignore and fall through to env-tier.
_ => env_tier_is_dev(),
};
}
env_tier_is_dev()
}
fn env_tier_is_dev() -> bool {
let env = std::env::var("RUSTANGO_ENV").unwrap_or_default();
!matches!(
env.trim().to_ascii_lowercase().as_str(),
"prod" | "production"
)
}
/// Render a styled HTML page describing a template render failure.
/// Layout: red header banner, monospace error body, template-name
/// + error-kind discriminator, full source-chain walk. Inline CSS
/// so it works without an external stylesheet (the same template
/// system that failed isn't trustworthy for re-rendering its own
/// error page).
#[must_use]
pub fn error_page_html(err: &tera::Error, template_name: &str) -> String {
use std::error::Error as _;
use std::fmt::Write as _;
let mut buf = String::with_capacity(2_048);
let _ = write!(
buf,
r#"<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<title>Template error: {name}</title>
<style>
body {{
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0; padding: 0; background: #fafafa; color: #1a1a1a;
}}
.banner {{
background: #b00020; color: #fff; padding: 1.2rem 2rem;
}}
.banner h1 {{ margin: 0; font-size: 1.5rem; }}
.banner small {{ display: block; opacity: 0.8; margin-top: 0.2rem; }}
main {{ padding: 1.5rem 2rem; max-width: 64rem; }}
section {{ margin-bottom: 1.5rem; }}
h2 {{ font-size: 1rem; text-transform: uppercase;
letter-spacing: 0.05em; color: #777; margin: 0 0 0.4rem 0; }}
pre, code {{ font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 13px; }}
pre {{ background: #fff; border: 1px solid #ddd;
border-radius: 4px; padding: 0.8rem 1rem; overflow-x: auto;
white-space: pre-wrap; word-break: break-word; }}
.footer {{ color: #888; font-size: 12px; padding: 1rem 2rem; }}
</style>
</head><body>
<div class="banner">
<h1>Template error</h1>
<small>Rendering <code>{name}</code></small>
</div>
<main>
"#,
name = escape_html(template_name),
);
let _ = write!(
buf,
"<section><h2>Error</h2><pre>{}</pre></section>\n",
escape_html(&err.to_string()),
);
// `tera::Error::kind` is a private field; `Debug` on the error
// value itself surfaces the kind variant + any wrapped payload
// for the same diagnostic value.
let _ = write!(
buf,
"<section><h2>Debug</h2><pre>{}</pre></section>\n",
escape_html(&format!("{err:?}")),
);
// Walk the source chain to pick up parse-error line numbers and
// any underlying I/O failure that Tera wraps.
let mut chain: Vec<String> = Vec::new();
let mut current: Option<&dyn std::error::Error> = err.source();
while let Some(e) = current {
chain.push(e.to_string());
current = e.source();
}
if !chain.is_empty() {
let _ = write!(buf, "<section><h2>Caused by</h2><pre>");
for (i, line) in chain.iter().enumerate() {
let _ = write!(buf, "{i}. {}\n", escape_html(line));
}
let _ = write!(buf, "</pre></section>\n");
}
buf.push_str(
r#"<section><h2>Why am I seeing this?</h2>
<p>This page is rendered when <code>rustango::template_debug::enabled()</code>
is true. Set <code>RUSTANGO_ENV=prod</code> or
<code>RUSTANGO_TEMPLATE_DEBUG=0</code> to switch back to the
plain-text 500 response.</p>
</section>
"#,
);
buf.push_str("</main>\n<div class=\"footer\">rustango template debug</div>\n</body></html>");
buf
}
/// Minimal HTML-escape — enough for showing raw error strings on
/// the debug page without ever crossing back into a Tera render
/// (which is what failed in the first place).
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// Guard against parallel-test env mutation — env is process-global.
/// All `enabled()` tests acquire this mutex so they can mutate the
/// two env vars deterministically.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::{Mutex, OnceLock};
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
/// Helper for env-mutating tests. Edition 2021 still permits
/// bare `set_var`/`remove_var`; the workspace `unsafe_code =
/// "forbid"` lint blocks the edition-2024 unsafe form, so this
/// keeps the calls bare.
fn with_env<F: FnOnce()>(key: &str, val: Option<&str>, f: F) {
let prev = std::env::var(key).ok();
match val {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
f();
match prev {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
#[test]
fn enabled_defaults_to_true_when_no_env_set() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", None, || {
assert!(enabled(), "missing both env vars → dev tier → debug on");
});
});
}
#[test]
fn enabled_is_false_in_prod_tier() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", Some("prod"), || {
assert!(!enabled(), "prod tier → debug off");
});
with_env("RUSTANGO_ENV", Some("production"), || {
assert!(!enabled(), "`production` long form → debug off");
});
});
}
#[test]
fn enabled_is_true_in_staging_and_dev_tiers() {
let _g = env_lock();
with_env("RUSTANGO_TEMPLATE_DEBUG", None, || {
with_env("RUSTANGO_ENV", Some("dev"), || {
assert!(enabled());
});
with_env("RUSTANGO_ENV", Some("staging"), || {
assert!(enabled());
});
});
}
#[test]
fn explicit_template_debug_override_wins_over_env_tier() {
let _g = env_lock();
// prod tier + explicit "1" → forced ON.
with_env("RUSTANGO_ENV", Some("prod"), || {
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("1"), || {
assert!(enabled(), "explicit `1` forces debug on in prod");
});
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("true"), || {
assert!(enabled());
});
});
// dev tier + explicit "0" → forced OFF.
with_env("RUSTANGO_ENV", Some("dev"), || {
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("0"), || {
assert!(!enabled(), "explicit `0` forces debug off in dev");
});
with_env("RUSTANGO_TEMPLATE_DEBUG", Some("off"), || {
assert!(!enabled());
});
});
}
#[test]
fn error_page_html_contains_template_name_and_error() {
// Build a real `tera::Error` by triggering a parse failure.
let mut tera = tera::Tera::default();
let err = tera
.add_raw_template("broken.html", "{% if %}{% endif %}")
.expect_err("intentionally bad template");
let page = error_page_html(&err, "broken.html");
assert!(page.contains("Template error"), "needs banner header");
assert!(page.contains("broken.html"), "must echo template name");
// The error's Display includes the parser diagnostic.
assert!(
page.contains("Failed to parse") || page.contains("parse"),
"should surface the parse failure text, got: {page}"
);
// Inline CSS so the page works without a static-asset hop.
assert!(page.contains("<style>"));
// No raw < / > leak through from the error message.
assert!(
!page.contains("<%"),
"any `<` in error text must be escaped"
);
}
#[test]
fn error_page_escapes_html_in_template_name() {
// Defensive — a malicious or accidental template name with
// angle brackets shouldn't break out of the page.
let mut tera = tera::Tera::default();
let err = tera
.add_raw_template("x", "{% if %}{% endif %}")
.expect_err("intentionally bad template");
let page = error_page_html(&err, "<script>alert(1)</script>");
assert!(!page.contains("<script>alert(1)</script>"));
assert!(page.contains("<script>alert(1)</script>"));
}
#[test]
fn escape_html_handles_all_five_entities() {
assert_eq!(escape_html("&"), "&");
assert_eq!(escape_html("<"), "<");
assert_eq!(escape_html(">"), ">");
assert_eq!(escape_html("\""), """);
assert_eq!(escape_html("'"), "'");
assert_eq!(escape_html("abc"), "abc");
}
}