rustio_admin/templates.rs
1//! Template rendering. Rust code passes typed context; this module
2//! owns everything about HTML generation.
3//!
4//! # Loader contract
5//!
6//! Per-request lookup via [`minijinja::Environment::set_loader`]. On
7//! every `render` call the cache is cleared, forcing the loader closure
8//! to re-resolve from disk so a developer can edit a template under
9//! `RUSTIO_TEMPLATE_DIR` and see the change on the next request without
10//! restarting the process.
11//!
12//! Lookup order, by template name `<path>`:
13//!
14//! 1. `<RUSTIO_TEMPLATE_DIR>/<path>` — project disk override.
15//! 2. Embedded default — compiled into the binary via `include_str!`.
16//!
17//! Per-model lookup hook: callers that pass a model context can use
18//! [`Templates::render_for_model`] to add a third tier:
19//!
20//! 1. `<RUSTIO_TEMPLATE_DIR>/admin/<model>/<page>.html`
21//! 2. `<RUSTIO_TEMPLATE_DIR>/<path>`
22//! 3. Embedded default
23
24use std::path::PathBuf;
25use std::sync::{Arc, Mutex};
26
27use minijinja::{Environment, ErrorKind};
28use serde::Serialize;
29
30use crate::error::{Error, Result};
31
32// The embedded template bytes live in the std-only `rustio-admin-assets`
33// leaf crate so the CLI can reach them without linking this runtime.
34// This module owns the *lookup* (disk override → embedded default); the
35// table of defaults is imported here.
36use rustio_admin_assets::EMBEDDED_TEMPLATES;
37
38// public:
39/// Re-exported from `rustio-admin-assets` so the framework's public API
40/// (`rustio_admin::embedded_template_names` /
41/// `rustio_admin::embedded_template_source`, via `lib.rs`) is unchanged
42/// after the assets split. See that crate for the definitions.
43pub use rustio_admin_assets::{embedded_template_names, embedded_template_source};
44
45// public:
46pub struct Templates {
47 env: Mutex<Environment<'static>>,
48}
49
50impl Templates {
51 // public:
52 /// Build the environment.
53 ///
54 /// `project_templates_dir = None` → embedded templates only.
55 /// `project_templates_dir = Some(path)` → disk overrides win at
56 /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
57 /// own resolved path) here.
58 ///
59 /// When a disk root is supplied, the constructor scans it once for
60 /// overrides of embedded templates. Each match is logged at INFO;
61 /// an override that looks structurally incomplete (no
62 /// `{% extends %}`, no `{% block %}`, no `<html>` tag) is logged at
63 /// WARN so a one-line stub of an admin template stops being a
64 /// silent failure. Non-fatal: the override is still served — the
65 /// scan exists only to make the failure mode visible.
66 pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
67 let disk_root = project_templates_dir;
68 if let Some(root) = disk_root.as_deref() {
69 for v in validate_overrides(root) {
70 match v {
71 OverrideValidation::Loaded { name, bytes } => {
72 log::info!(
73 "templates: project override loaded for `{name}` ({bytes} bytes)"
74 );
75 }
76 OverrideValidation::Suspicious { name, bytes } => {
77 log::warn!(
78 "templates: project override for `{name}` looks incomplete \
79 ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
80 `<html>` tag) — the admin UI may render incorrectly. Either \
81 copy the framework default in full or remove the override."
82 );
83 }
84 OverrideValidation::Unreadable { name, error } => {
85 log::warn!(
86 "templates: project override `{name}` exists but cannot be read: {error}"
87 );
88 }
89 OverrideValidation::OrphanAdminFile { path } => {
90 log::warn!(
91 "templates: `{path}` is in the admin namespace but does not \
92 override any embedded template (typo? framework default \
93 will be served unchanged). Project-specific admin pages \
94 belong outside `templates/admin/`."
95 );
96 }
97 }
98 }
99 }
100 let mut env = Environment::new();
101 env.set_loader(move |name| load_template(disk_root.as_deref(), name));
102
103 // `icon(name, class="…")` returns inline SVG for one of the
104 // lucide stroke icons baked at compile time. Templates use it
105 // to render sidebar nav icons, button icons, and alert glyphs
106 // without an extra HTTP round trip. See `admin/icons.rs` for
107 // the catalogue.
108 env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
109 let class: String = kwargs.get("class").unwrap_or_default();
110 kwargs.assert_all_used().ok();
111 // The output is HTML — minijinja's autoescape would
112 // mangle it. Wrap in `safe()` so it renders as markup.
113 minijinja::value::Value::from_safe_string(crate::admin::icons::render_inline(
114 name, &class,
115 ))
116 });
117
118 Ok(Arc::new(Self {
119 env: Mutex::new(env),
120 }))
121 }
122
123 // public:
124 /// Render a template by name.
125 pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
126 let mut env = self
127 .env
128 .lock()
129 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
130 // Clear cache so the loader runs again — restart-free dev edits.
131 env.clear_templates();
132 let tmpl = env
133 .get_template(name)
134 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
135 tmpl.render(ctx).map_err(|e| {
136 log::error!("template render failed for {name}: {e:?}");
137 Error::Internal(format!("render {name}: {e}"))
138 })
139 }
140
141 // public:
142 /// Render with a per-model override hook.
143 ///
144 /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
145 /// stripped of any leading `admin/`), falling back to `name`.
146 ///
147 /// Consumed by every generic-CRUD render in `admin::handlers` so
148 /// a project can drop `templates/admin/<admin_name>/list.html`,
149 /// `…/form.html`, `…/confirm_delete.html`, or
150 /// `…/object_history.html` to override just that one page for
151 /// just that one model. The per-model file wins; absent that the
152 /// loader falls back to the framework-wide override (the
153 /// path-without-model-prefix), then the embedded default.
154 pub fn render_for_model<S: Serialize>(
155 &self,
156 model: &str,
157 name: &str,
158 ctx: &S,
159 ) -> Result<String> {
160 let page = name.strip_prefix("admin/").unwrap_or(name);
161 let per_model = format!("admin/{model}/{page}");
162 let mut env = self
163 .env
164 .lock()
165 .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
166 env.clear_templates();
167 if let Ok(tmpl) = env.get_template(&per_model) {
168 return tmpl
169 .render(ctx)
170 .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
171 }
172 let tmpl = env
173 .get_template(name)
174 .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
175 tmpl.render(ctx)
176 .map_err(|e| Error::Internal(format!("render {name}: {e}")))
177 }
178}
179
180/// Outcome of inspecting one project override file at startup.
181/// Per-file, not per-render: the cost is paid once when the `Templates`
182/// arc is built, not on every request.
183///
184/// Pure data — `Templates::new` translates each variant to a log line.
185/// Returned as a `Vec` so unit tests can assert on the structural
186/// classification without scraping log output.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub(crate) enum OverrideValidation {
189 /// File loaded and contains at least one of `{% extends %}`,
190 /// `{% block %}`, or `<html`.
191 Loaded { name: &'static str, bytes: usize },
192 /// File loaded but contains none of the structural markers.
193 Suspicious { name: &'static str, bytes: usize },
194 /// File exists on disk but `read_to_string` failed.
195 Unreadable { name: &'static str, error: String },
196 /// File in `templates/admin/` whose name does NOT match any
197 /// embedded template — usually a typo or a misplaced project
198 /// admin page.
199 OrphanAdminFile { path: String },
200}
201
202/// Walk `EMBEDDED_TEMPLATES`, classify any project override of one of
203/// those names, return the per-file results.
204///
205/// Files in `disk_root` that do NOT shadow an embedded name are
206/// ignored: those are project-only templates and have no framework
207/// default to compare against.
208pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
209 let mut results = Vec::new();
210 for (name, _embedded) in EMBEDDED_TEMPLATES {
211 let path = disk_root.join(name);
212 if !path.is_file() {
213 continue;
214 }
215 match std::fs::read_to_string(&path) {
216 Ok(body) => {
217 let bytes = body.len();
218 let has_structure = body.contains("{% extends")
219 || body.contains("{% block")
220 || body.contains("<html");
221 if has_structure {
222 results.push(OverrideValidation::Loaded { name, bytes });
223 } else {
224 results.push(OverrideValidation::Suspicious { name, bytes });
225 }
226 }
227 Err(e) => {
228 results.push(OverrideValidation::Unreadable {
229 name,
230 error: e.to_string(),
231 });
232 }
233 }
234 }
235
236 // Orphan-admin-file scan. The framework reserves `templates/admin/`
237 // for overrides of embedded admin templates. A file in that
238 // namespace whose name doesn't match any embedded template
239 // overrides nothing — usually a typo or misunderstanding. Either
240 // way the developer's intent and the runtime's behaviour disagree
241 // silently; this scan logs a WARN so the disagreement becomes
242 // observable.
243 let admin_dir = disk_root.join("admin");
244 if admin_dir.is_dir() {
245 let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
246 .iter()
247 .filter_map(|(n, _)| n.strip_prefix("admin/"))
248 .collect();
249 if let Ok(entries) = std::fs::read_dir(&admin_dir) {
250 // Sort for deterministic ordering — the loop visits files in
251 // arbitrary FS order otherwise, which makes log lines and
252 // tests non-deterministic.
253 let mut files: Vec<_> = entries
254 .filter_map(|e| e.ok())
255 .filter(|e| {
256 e.path()
257 .extension()
258 .and_then(|s| s.to_str())
259 .map(|s| s.eq_ignore_ascii_case("html"))
260 .unwrap_or(false)
261 })
262 .collect();
263 files.sort_by_key(|e| e.file_name());
264 for entry in files {
265 let file_name = entry.file_name();
266 let Some(stem_html) = file_name.to_str() else {
267 continue;
268 };
269 if known.contains(stem_html) {
270 continue;
271 }
272 results.push(OverrideValidation::OrphanAdminFile {
273 path: format!("admin/{stem_html}"),
274 });
275 }
276 }
277 }
278
279 results
280}
281
282fn load_template(
283 disk_root: Option<&std::path::Path>,
284 name: &str,
285) -> std::result::Result<Option<String>, minijinja::Error> {
286 if let Some(root) = disk_root {
287 let path = root.join(name);
288 if path.exists() {
289 return std::fs::read_to_string(&path).map(Some).map_err(|e| {
290 minijinja::Error::new(
291 ErrorKind::InvalidOperation,
292 format!("read template {}: {e}", path.display()),
293 )
294 });
295 }
296 }
297 Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
298 if *n == name {
299 Some((*b).to_string())
300 } else {
301 None
302 }
303 }))
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use serde::Serialize;
310 use std::io::Write;
311
312 #[derive(Serialize)]
313 struct Empty {}
314
315 fn tempdir() -> std::path::PathBuf {
316 let dir = std::env::temp_dir().join(format!(
317 "rustio-admin-test-{}",
318 std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .unwrap()
321 .as_nanos()
322 ));
323 std::fs::create_dir_all(&dir).unwrap();
324 dir
325 }
326
327 #[test]
328 fn missing_template_errors_cleanly() {
329 let t = Templates::new(None).unwrap();
330 let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
331 assert_eq!(err.status(), 500);
332 }
333
334 #[test]
335 fn disk_loader_finds_project_template() {
336 let dir = tempdir();
337 let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
338 f.write_all(b"hi from disk").unwrap();
339 drop(f);
340
341 let t = Templates::new(Some(dir.clone())).unwrap();
342 let body = t.render("hello.html", &Empty {}).unwrap();
343 assert_eq!(body, "hi from disk");
344
345 let _ = std::fs::remove_dir_all(&dir);
346 }
347
348 /// Per-model template override: a file at
349 /// `<disk_root>/admin/<model>/list.html` wins over a same-named
350 /// disk override AND over the embedded default, but only for
351 /// `render_for_model(model, ...)` calls. Other models still see
352 /// the framework default (or whatever shadow they have).
353 #[test]
354 fn render_for_model_prefers_per_model_override() {
355 let dir = tempdir();
356 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
357 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
358 f.write_all(b"books-specific list").unwrap();
359 drop(f);
360
361 let t = Templates::new(Some(dir.clone())).unwrap();
362 // Books model sees the override.
363 let body = t
364 .render_for_model("books", "admin/list.html", &Empty {})
365 .unwrap();
366 assert_eq!(body, "books-specific list");
367 let _ = std::fs::remove_dir_all(&dir);
368 }
369
370 /// A model with no per-model file falls through to the
371 /// framework-default lookup chain. Other models with overrides
372 /// don't bleed into this one.
373 #[test]
374 fn render_for_model_falls_through_to_framework_default() {
375 let dir = tempdir();
376 // Drop a books-only override; query for a different model.
377 std::fs::create_dir_all(dir.join("admin/books")).unwrap();
378 let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
379 f.write_all(b"books override").unwrap();
380 drop(f);
381 // Drop a framework-wide override too, to confirm the
382 // fall-through actually reaches it (and isn't accidentally
383 // picking up the books override for the other model).
384 std::fs::create_dir_all(dir.join("admin")).unwrap();
385 let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
386 f.write_all(b"framework-wide list").unwrap();
387 drop(f);
388
389 let t = Templates::new(Some(dir.clone())).unwrap();
390 // "authors" has no per-model file — falls through to
391 // framework-wide override.
392 let body = t
393 .render_for_model("authors", "admin/list.html", &Empty {})
394 .unwrap();
395 assert_eq!(body, "framework-wide list");
396 // "books" still sees its own override.
397 let body = t
398 .render_for_model("books", "admin/list.html", &Empty {})
399 .unwrap();
400 assert_eq!(body, "books override");
401 let _ = std::fs::remove_dir_all(&dir);
402 }
403
404 /// Every embedded template is registered. Catches typos in
405 /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
406 #[test]
407 fn every_embedded_template_loads() {
408 let t = Templates::new(None).unwrap();
409 for (name, _) in EMBEDDED_TEMPLATES {
410 // Render with an empty serializable; minijinja's
411 // strict-undefined fails on missing variables, so most
412 // pages will Err — but parsing happens before evaluation.
413 // We accept any Err whose underlying minijinja error is a
414 // template-evaluation problem; an Error::Internal that
415 // says "template <name> not found" would mean the loader
416 // failed entirely (regression).
417 let result = t.render(name, &Empty {});
418 if let Err(e) = result {
419 let msg = e.to_string();
420 assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
421 }
422 }
423 }
424
425 /// The command rail marks exactly the current page's entry with
426 /// `aria-current="page"`, driven by the `nav_active` key the page
427 /// contexts set via `BaseContext::with_nav_active`. Guards against the
428 /// rail silently losing its active-state highlighting again (the key
429 /// was unset for a long time, so the comparison was always false).
430 #[test]
431 fn sidebar_marks_active_nav_item() {
432 let t = Templates::new(None).unwrap();
433 let render_with = |active: &str| {
434 t.render(
435 "admin/_sidebar.html",
436 &minijinja::context! {
437 app_name => "Test Admin",
438 nav_active => active,
439 identity => minijinja::context! { is_admin => true, is_developer => true },
440 entries => vec![
441 minijinja::context! { admin_name => "customer", display_name => "Customers" },
442 ],
443 },
444 )
445 .unwrap()
446 };
447
448 // A built-in section key (Users) highlights its own link and nothing else.
449 let users = render_with("users");
450 assert!(
451 users.contains(r#"href="/admin/users" aria-current="page""#),
452 "Users link should be active when nav_active=users"
453 );
454 assert_eq!(
455 users.matches(r#"aria-current="page""#).count(),
456 1,
457 "exactly one rail item is active"
458 );
459
460 // A model's admin_name highlights that model's link.
461 let model = render_with("customer");
462 assert!(
463 model.contains(r#"href="/admin/customer" aria-current="page""#),
464 "model link should be active when nav_active matches its admin_name"
465 );
466
467 // The new developer entry highlights when active and is always present.
468 let designer = render_with("view-designer");
469 assert!(designer.contains(r#"href="/admin/dev/view-designer""#));
470 assert!(
471 designer.contains(r#"href="/admin/dev/view-designer" aria-current="page""#),
472 "View designer link should be active when nav_active=view-designer"
473 );
474
475 // An unrelated key leaves the rail with no active item.
476 let none = render_with("");
477 assert_eq!(none.matches(r#"aria-current="page""#).count(), 0);
478 }
479
480 /// Regression gate for the 0.7.0 → 0.7.1 fix.
481 ///
482 /// Scans every `.rs` file under `src/admin/` for string
483 /// literals of the shape `"admin/<name>.html"` and asserts each
484 /// one resolves via `Templates::new(None)?`. If a handler is
485 /// added that renders a new template and the author forgets to
486 /// extend `EMBEDDED_TEMPLATES`, this test fails before the
487 /// release ships rather than after the first user clicks the
488 /// new page.
489 ///
490 /// The 0.6.0 R2 + 0.7.0 R3 cycles both shipped with this
491 /// shape of bug — the disk template files were committed, the
492 /// handlers rendered them, the bug was invisible to unit tests
493 /// (no integration test boots a real HTTP stack against the
494 /// affected routes), and the regression surfaced only when the
495 /// flagship downstream walked the surface against a live DB.
496 /// This test makes the discipline mechanical.
497 #[test]
498 fn every_handler_rendered_template_resolves() {
499 let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
500 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
501 // Bare-literal scan — the framework only uses
502 // `"admin/<...>.html"` strings as template names, so
503 // finding every literal of that shape catches the entire
504 // surface without needing AST parsing or a regex
505 // dependency.
506 walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
507 let content = std::fs::read_to_string(path)
508 .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
509 extract_template_names(&content, &mut names);
510 });
511 assert!(
512 !names.is_empty(),
513 "no template names found — scan regression?"
514 );
515
516 let t = Templates::new(None).unwrap();
517 let mut missing: Vec<String> = Vec::new();
518 for name in &names {
519 let result = t.render(name, &Empty {});
520 if let Err(e) = result {
521 let msg = e.to_string();
522 // `not found` is minijinja's "template not in
523 // loader" error. Other errors (strict-undefined,
524 // type mismatches) are tolerated — the test cares
525 // only about loader resolution, not full render
526 // success against an empty context.
527 if msg.contains("not found") {
528 missing.push(format!("{name}: {msg}"));
529 }
530 }
531 }
532 assert!(
533 missing.is_empty(),
534 "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n {}",
535 missing.join("\n ")
536 );
537 }
538
539 /// Pull every `"admin/<...>.html"` literal out of `content` and
540 /// stuff it into `out`. Bare-string scan; tolerates string
541 /// literals that span multiple lines because the closing
542 /// `.html"` must appear before the next double-quote.
543 fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
544 let needle = "\"admin/";
545 let mut cursor = 0;
546 while let Some(idx) = content[cursor..].find(needle) {
547 let start = cursor + idx + 1; // past the opening quote
548 let after = &content[start..];
549 // The literal ends at the next `"`. If `.html` does
550 // not appear before that quote, this isn't a template
551 // reference (it could be e.g. a Permission action_name
552 // that happens to start with `admin/`).
553 if let Some(end_rel) = after.find('"') {
554 let literal = &after[..end_rel];
555 if literal.ends_with(".html") {
556 out.insert(literal.to_string());
557 }
558 cursor = start + end_rel + 1;
559 } else {
560 break;
561 }
562 }
563 }
564
565 /// Recursively walk every `.rs` file under `root`, calling
566 /// `visit` for each. Std-only — no `walkdir` dep needed for a
567 /// single test.
568 fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
569 let entries = match std::fs::read_dir(root) {
570 Ok(e) => e,
571 Err(_) => return,
572 };
573 for entry in entries.flatten() {
574 let path = entry.path();
575 let file_type = match entry.file_type() {
576 Ok(ft) => ft,
577 Err(_) => continue,
578 };
579 if file_type.is_dir() {
580 walk_rs_files(&path, visit);
581 } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
582 visit(&path);
583 }
584 }
585 }
586}