Skip to main content

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// public:
33pub struct Templates {
34    env: Mutex<Environment<'static>>,
35}
36
37impl Templates {
38    // public:
39    /// Build the environment.
40    ///
41    /// `project_templates_dir = None` → embedded templates only.
42    /// `project_templates_dir = Some(path)` → disk overrides win at
43    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
44    /// own resolved path) here.
45    ///
46    /// When a disk root is supplied, the constructor scans it once for
47    /// overrides of embedded templates. Each match is logged at INFO;
48    /// an override that looks structurally incomplete (no
49    /// `{% extends %}`, no `{% block %}`, no `<html>` tag) is logged at
50    /// WARN so a one-line stub of an admin template stops being a
51    /// silent failure. Non-fatal: the override is still served — the
52    /// scan exists only to make the failure mode visible.
53    pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
54        let disk_root = project_templates_dir;
55        if let Some(root) = disk_root.as_deref() {
56            for v in validate_overrides(root) {
57                match v {
58                    OverrideValidation::Loaded { name, bytes } => {
59                        log::info!(
60                            "templates: project override loaded for `{name}` ({bytes} bytes)"
61                        );
62                    }
63                    OverrideValidation::Suspicious { name, bytes } => {
64                        log::warn!(
65                            "templates: project override for `{name}` looks incomplete \
66                             ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
67                             `<html>` tag) — the admin UI may render incorrectly. Either \
68                             copy the framework default in full or remove the override."
69                        );
70                    }
71                    OverrideValidation::Unreadable { name, error } => {
72                        log::warn!(
73                            "templates: project override `{name}` exists but cannot be read: {error}"
74                        );
75                    }
76                    OverrideValidation::OrphanAdminFile { path } => {
77                        log::warn!(
78                            "templates: `{path}` is in the admin namespace but does not \
79                             override any embedded template (typo? framework default \
80                             will be served unchanged). Project-specific admin pages \
81                             belong outside `templates/admin/`."
82                        );
83                    }
84                }
85            }
86        }
87        let mut env = Environment::new();
88        env.set_loader(move |name| load_template(disk_root.as_deref(), name));
89
90        // `icon(name, class="…")` returns inline SVG for one of the
91        // lucide stroke icons baked at compile time. Templates use it
92        // to render sidebar nav icons, button icons, and alert glyphs
93        // without an extra HTTP round trip. See `admin/icons.rs` for
94        // the catalogue.
95        env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
96            let class: String = kwargs.get("class").unwrap_or_default();
97            kwargs.assert_all_used().ok();
98            // The output is HTML — minijinja's autoescape would
99            // mangle it. Wrap in `safe()` so it renders as markup.
100            minijinja::value::Value::from_safe_string(crate::admin::icons::render_inline(
101                name, &class,
102            ))
103        });
104
105        Ok(Arc::new(Self {
106            env: Mutex::new(env),
107        }))
108    }
109
110    // public:
111    /// Render a template by name.
112    pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
113        let mut env = self
114            .env
115            .lock()
116            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
117        // Clear cache so the loader runs again — restart-free dev edits.
118        env.clear_templates();
119        let tmpl = env
120            .get_template(name)
121            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
122        tmpl.render(ctx).map_err(|e| {
123            log::error!("template render failed for {name}: {e:?}");
124            Error::Internal(format!("render {name}: {e}"))
125        })
126    }
127
128    // public:
129    /// Render with a per-model override hook.
130    ///
131    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
132    /// stripped of any leading `admin/`), falling back to `name`.
133    ///
134    /// Consumed by every generic-CRUD render in `admin::handlers` so
135    /// a project can drop `templates/admin/<admin_name>/list.html`,
136    /// `…/form.html`, `…/confirm_delete.html`, or
137    /// `…/object_history.html` to override just that one page for
138    /// just that one model. The per-model file wins; absent that the
139    /// loader falls back to the framework-wide override (the
140    /// path-without-model-prefix), then the embedded default.
141    pub fn render_for_model<S: Serialize>(
142        &self,
143        model: &str,
144        name: &str,
145        ctx: &S,
146    ) -> Result<String> {
147        let page = name.strip_prefix("admin/").unwrap_or(name);
148        let per_model = format!("admin/{model}/{page}");
149        let mut env = self
150            .env
151            .lock()
152            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
153        env.clear_templates();
154        if let Ok(tmpl) = env.get_template(&per_model) {
155            return tmpl
156                .render(ctx)
157                .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
158        }
159        let tmpl = env
160            .get_template(name)
161            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
162        tmpl.render(ctx)
163            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
164    }
165}
166
167/// Outcome of inspecting one project override file at startup.
168/// Per-file, not per-render: the cost is paid once when the `Templates`
169/// arc is built, not on every request.
170///
171/// Pure data — `Templates::new` translates each variant to a log line.
172/// Returned as a `Vec` so unit tests can assert on the structural
173/// classification without scraping log output.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub(crate) enum OverrideValidation {
176    /// File loaded and contains at least one of `{% extends %}`,
177    /// `{% block %}`, or `<html`.
178    Loaded { name: &'static str, bytes: usize },
179    /// File loaded but contains none of the structural markers.
180    Suspicious { name: &'static str, bytes: usize },
181    /// File exists on disk but `read_to_string` failed.
182    Unreadable { name: &'static str, error: String },
183    /// File in `templates/admin/` whose name does NOT match any
184    /// embedded template — usually a typo or a misplaced project
185    /// admin page.
186    OrphanAdminFile { path: String },
187}
188
189/// Walk `EMBEDDED_TEMPLATES`, classify any project override of one of
190/// those names, return the per-file results.
191///
192/// Files in `disk_root` that do NOT shadow an embedded name are
193/// ignored: those are project-only templates and have no framework
194/// default to compare against.
195pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
196    let mut results = Vec::new();
197    for (name, _embedded) in EMBEDDED_TEMPLATES {
198        let path = disk_root.join(name);
199        if !path.is_file() {
200            continue;
201        }
202        match std::fs::read_to_string(&path) {
203            Ok(body) => {
204                let bytes = body.len();
205                let has_structure = body.contains("{% extends")
206                    || body.contains("{% block")
207                    || body.contains("<html");
208                if has_structure {
209                    results.push(OverrideValidation::Loaded { name, bytes });
210                } else {
211                    results.push(OverrideValidation::Suspicious { name, bytes });
212                }
213            }
214            Err(e) => {
215                results.push(OverrideValidation::Unreadable {
216                    name,
217                    error: e.to_string(),
218                });
219            }
220        }
221    }
222
223    // Orphan-admin-file scan. The framework reserves `templates/admin/`
224    // for overrides of embedded admin templates. A file in that
225    // namespace whose name doesn't match any embedded template
226    // overrides nothing — usually a typo or misunderstanding. Either
227    // way the developer's intent and the runtime's behaviour disagree
228    // silently; this scan logs a WARN so the disagreement becomes
229    // observable.
230    let admin_dir = disk_root.join("admin");
231    if admin_dir.is_dir() {
232        let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
233            .iter()
234            .filter_map(|(n, _)| n.strip_prefix("admin/"))
235            .collect();
236        if let Ok(entries) = std::fs::read_dir(&admin_dir) {
237            // Sort for deterministic ordering — the loop visits files in
238            // arbitrary FS order otherwise, which makes log lines and
239            // tests non-deterministic.
240            let mut files: Vec<_> = entries
241                .filter_map(|e| e.ok())
242                .filter(|e| {
243                    e.path()
244                        .extension()
245                        .and_then(|s| s.to_str())
246                        .map(|s| s.eq_ignore_ascii_case("html"))
247                        .unwrap_or(false)
248                })
249                .collect();
250            files.sort_by_key(|e| e.file_name());
251            for entry in files {
252                let file_name = entry.file_name();
253                let Some(stem_html) = file_name.to_str() else {
254                    continue;
255                };
256                if known.contains(stem_html) {
257                    continue;
258                }
259                results.push(OverrideValidation::OrphanAdminFile {
260                    path: format!("admin/{stem_html}"),
261                });
262            }
263        }
264    }
265
266    results
267}
268
269fn load_template(
270    disk_root: Option<&std::path::Path>,
271    name: &str,
272) -> std::result::Result<Option<String>, minijinja::Error> {
273    if let Some(root) = disk_root {
274        let path = root.join(name);
275        if path.exists() {
276            return std::fs::read_to_string(&path).map(Some).map_err(|e| {
277                minijinja::Error::new(
278                    ErrorKind::InvalidOperation,
279                    format!("read template {}: {e}", path.display()),
280                )
281            });
282        }
283    }
284    Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
285        if *n == name {
286            Some((*b).to_string())
287        } else {
288            None
289        }
290    }))
291}
292
293// public:
294/// Every template baked into the framework binary, by canonical name
295/// (e.g. `"admin/list.html"`). Order is stable across builds so a
296/// CLI verb that lists them produces deterministic output. Used by
297/// `rustio override` to enumerate copy candidates; project code can
298/// also iterate this to build documentation pages.
299pub fn embedded_template_names() -> Vec<&'static str> {
300    EMBEDDED_TEMPLATES.iter().map(|(n, _)| *n).collect()
301}
302
303// public:
304/// Return the byte-for-byte source of an embedded template by name,
305/// or `None` when no such template exists. Used by `rustio override`
306/// to materialise a copy at `<RUSTIO_TEMPLATE_DIR>/<name>` so the
307/// operator can start editing without first having to find the
308/// framework source.
309pub fn embedded_template_source(name: &str) -> Option<&'static str> {
310    EMBEDDED_TEMPLATES
311        .iter()
312        .find_map(|(n, body)| if *n == name { Some(*body) } else { None })
313}
314
315/// Baked into the binary. Single-binary deploy is a hard constraint.
316const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
317    // Shell + partials
318    (
319        "admin/_base.html",
320        include_str!("../assets/templates/admin/_base.html"),
321    ),
322    (
323        "admin/_topbar.html",
324        include_str!("../assets/templates/admin/_topbar.html"),
325    ),
326    (
327        "admin/_sidebar.html",
328        include_str!("../assets/templates/admin/_sidebar.html"),
329    ),
330    (
331        "admin/_theme.html",
332        include_str!("../assets/templates/admin/_theme.html"),
333    ),
334    (
335        "admin/includes/_form_field.html",
336        include_str!("../assets/templates/admin/includes/_form_field.html"),
337    ),
338    (
339        "admin/includes/_field_errors.html",
340        include_str!("../assets/templates/admin/includes/_field_errors.html"),
341    ),
342    // Generic pages
343    (
344        "admin/login.html",
345        include_str!("../assets/templates/admin/login.html"),
346    ),
347    (
348        "admin/index.html",
349        include_str!("../assets/templates/admin/index.html"),
350    ),
351    (
352        "admin/list.html",
353        include_str!("../assets/templates/admin/list.html"),
354    ),
355    (
356        "admin/form.html",
357        include_str!("../assets/templates/admin/form.html"),
358    ),
359    (
360        "admin/confirm_delete.html",
361        include_str!("../assets/templates/admin/confirm_delete.html"),
362    ),
363    (
364        "admin/bulk_confirm_delete.html",
365        include_str!("../assets/templates/admin/bulk_confirm_delete.html"),
366    ),
367    (
368        "admin/db_browser.html",
369        include_str!("../assets/templates/admin/db_browser.html"),
370    ),
371    (
372        "admin/bulk_confirm_action.html",
373        include_str!("../assets/templates/admin/bulk_confirm_action.html"),
374    ),
375    (
376        "admin/error.html",
377        include_str!("../assets/templates/admin/error.html"),
378    ),
379    (
380        "admin/forbidden.html",
381        include_str!("../assets/templates/admin/forbidden.html"),
382    ),
383    // Audit / password change
384    (
385        "admin/object_history.html",
386        include_str!("../assets/templates/admin/object_history.html"),
387    ),
388    (
389        "admin/log_entries.html",
390        include_str!("../assets/templates/admin/log_entries.html"),
391    ),
392    (
393        "admin/apis_index.html",
394        include_str!("../assets/templates/admin/apis_index.html"),
395    ),
396    (
397        "admin/apis_playground.html",
398        include_str!("../assets/templates/admin/apis_playground.html"),
399    ),
400    (
401        "admin/health.html",
402        include_str!("../assets/templates/admin/health.html"),
403    ),
404    (
405        "admin/feature_flags.html",
406        include_str!("../assets/templates/admin/feature_flags.html"),
407    ),
408    (
409        "admin/notifications.html",
410        include_str!("../assets/templates/admin/notifications.html"),
411    ),
412    (
413        "admin/csv_import_result.html",
414        include_str!("../assets/templates/admin/csv_import_result.html"),
415    ),
416    (
417        "admin/docs_index.html",
418        include_str!("../assets/templates/admin/docs_index.html"),
419    ),
420    (
421        "admin/doc_page.html",
422        include_str!("../assets/templates/admin/doc_page.html"),
423    ),
424    (
425        "admin/password_change.html",
426        include_str!("../assets/templates/admin/password_change.html"),
427    ),
428    // Built-in user pages
429    (
430        "admin/users_list.html",
431        include_str!("../assets/templates/admin/users_list.html"),
432    ),
433    (
434        "admin/user_new.html",
435        include_str!("../assets/templates/admin/user_new.html"),
436    ),
437    (
438        "admin/user_edit.html",
439        include_str!("../assets/templates/admin/user_edit.html"),
440    ),
441    (
442        "admin/user_view.html",
443        include_str!("../assets/templates/admin/user_view.html"),
444    ),
445    (
446        "admin/user_confirm_delete.html",
447        include_str!("../assets/templates/admin/user_confirm_delete.html"),
448    ),
449    // Built-in group pages
450    (
451        "admin/groups_list.html",
452        include_str!("../assets/templates/admin/groups_list.html"),
453    ),
454    (
455        "admin/group_new.html",
456        include_str!("../assets/templates/admin/group_new.html"),
457    ),
458    (
459        "admin/group_edit.html",
460        include_str!("../assets/templates/admin/group_edit.html"),
461    ),
462    (
463        "admin/group_confirm_delete.html",
464        include_str!("../assets/templates/admin/group_confirm_delete.html"),
465    ),
466    // Self-service account pages (R0+)
467    (
468        "admin/account_sessions.html",
469        include_str!("../assets/templates/admin/account_sessions.html"),
470    ),
471    // Self-service password recovery (R1)
472    (
473        "admin/forgot_password.html",
474        include_str!("../assets/templates/admin/forgot_password.html"),
475    ),
476    (
477        "admin/forgot_password_sent.html",
478        include_str!("../assets/templates/admin/forgot_password_sent.html"),
479    ),
480    (
481        "admin/reset_password.html",
482        include_str!("../assets/templates/admin/reset_password.html"),
483    ),
484    // Organisational recovery (R2)
485    //
486    // These pages are rendered by `admin/admin_recovery_handlers.rs`
487    // — the admin-driven reset / lock / re-auth / forced-rotation
488    // surface. They were inadvertently omitted from this list when
489    // R2 shipped in 0.6.0; the disk files were committed but never
490    // hooked into the embedded set. Without them every
491    // `/admin/reauth`, `/admin/users/:id/reset-password`,
492    // `/admin/users/:id/lock`, and forced-password-change request
493    // returns the framework's generic 500 page. The
494    // `every_handler_rendered_template_resolves` test in this file
495    // is the regression gate that catches this shape of omission.
496    (
497        "admin/reauth.html",
498        include_str!("../assets/templates/admin/reauth.html"),
499    ),
500    (
501        "admin/admin_reset_password.html",
502        include_str!("../assets/templates/admin/admin_reset_password.html"),
503    ),
504    (
505        "admin/lock_user.html",
506        include_str!("../assets/templates/admin/lock_user.html"),
507    ),
508    (
509        "admin/confirm_admin_action.html",
510        include_str!("../assets/templates/admin/confirm_admin_action.html"),
511    ),
512    (
513        "admin/must_change_password.html",
514        include_str!("../assets/templates/admin/must_change_password.html"),
515    ),
516    // TOTP MFA (R3)
517    //
518    // Rendered by `admin/mfa_handlers.rs` (enrol / verify /
519    // regenerate / disable). Same shape of omission as the R2 set
520    // above; same regression-gate test covers them.
521    (
522        "admin/mfa_enroll.html",
523        include_str!("../assets/templates/admin/mfa_enroll.html"),
524    ),
525    (
526        "admin/mfa_enroll_complete.html",
527        include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
528    ),
529    (
530        "admin/mfa_verify.html",
531        include_str!("../assets/templates/admin/mfa_verify.html"),
532    ),
533    (
534        "admin/mfa_disable.html",
535        include_str!("../assets/templates/admin/mfa_disable.html"),
536    ),
537    (
538        "admin/mfa_regenerate.html",
539        include_str!("../assets/templates/admin/mfa_regenerate.html"),
540    ),
541    (
542        "admin/mfa_regenerate_complete.html",
543        include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
544    ),
545];
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use serde::Serialize;
551    use std::io::Write;
552
553    #[derive(Serialize)]
554    struct Empty {}
555
556    fn tempdir() -> std::path::PathBuf {
557        let dir = std::env::temp_dir().join(format!(
558            "rustio-admin-test-{}",
559            std::time::SystemTime::now()
560                .duration_since(std::time::UNIX_EPOCH)
561                .unwrap()
562                .as_nanos()
563        ));
564        std::fs::create_dir_all(&dir).unwrap();
565        dir
566    }
567
568    #[test]
569    fn missing_template_errors_cleanly() {
570        let t = Templates::new(None).unwrap();
571        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
572        assert_eq!(err.status(), 500);
573    }
574
575    #[test]
576    fn disk_loader_finds_project_template() {
577        let dir = tempdir();
578        let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
579        f.write_all(b"hi from disk").unwrap();
580        drop(f);
581
582        let t = Templates::new(Some(dir.clone())).unwrap();
583        let body = t.render("hello.html", &Empty {}).unwrap();
584        assert_eq!(body, "hi from disk");
585
586        let _ = std::fs::remove_dir_all(&dir);
587    }
588
589    /// Per-model template override: a file at
590    /// `<disk_root>/admin/<model>/list.html` wins over a same-named
591    /// disk override AND over the embedded default, but only for
592    /// `render_for_model(model, ...)` calls. Other models still see
593    /// the framework default (or whatever shadow they have).
594    #[test]
595    fn render_for_model_prefers_per_model_override() {
596        let dir = tempdir();
597        std::fs::create_dir_all(dir.join("admin/books")).unwrap();
598        let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
599        f.write_all(b"books-specific list").unwrap();
600        drop(f);
601
602        let t = Templates::new(Some(dir.clone())).unwrap();
603        // Books model sees the override.
604        let body = t
605            .render_for_model("books", "admin/list.html", &Empty {})
606            .unwrap();
607        assert_eq!(body, "books-specific list");
608        let _ = std::fs::remove_dir_all(&dir);
609    }
610
611    /// A model with no per-model file falls through to the
612    /// framework-default lookup chain. Other models with overrides
613    /// don't bleed into this one.
614    #[test]
615    fn render_for_model_falls_through_to_framework_default() {
616        let dir = tempdir();
617        // Drop a books-only override; query for a different model.
618        std::fs::create_dir_all(dir.join("admin/books")).unwrap();
619        let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
620        f.write_all(b"books override").unwrap();
621        drop(f);
622        // Drop a framework-wide override too, to confirm the
623        // fall-through actually reaches it (and isn't accidentally
624        // picking up the books override for the other model).
625        std::fs::create_dir_all(dir.join("admin")).unwrap();
626        let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
627        f.write_all(b"framework-wide list").unwrap();
628        drop(f);
629
630        let t = Templates::new(Some(dir.clone())).unwrap();
631        // "authors" has no per-model file — falls through to
632        // framework-wide override.
633        let body = t
634            .render_for_model("authors", "admin/list.html", &Empty {})
635            .unwrap();
636        assert_eq!(body, "framework-wide list");
637        // "books" still sees its own override.
638        let body = t
639            .render_for_model("books", "admin/list.html", &Empty {})
640            .unwrap();
641        assert_eq!(body, "books override");
642        let _ = std::fs::remove_dir_all(&dir);
643    }
644
645    /// Every embedded template is registered. Catches typos in
646    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
647    #[test]
648    fn every_embedded_template_loads() {
649        let t = Templates::new(None).unwrap();
650        for (name, _) in EMBEDDED_TEMPLATES {
651            // Render with an empty serializable; minijinja's
652            // strict-undefined fails on missing variables, so most
653            // pages will Err — but parsing happens before evaluation.
654            // We accept any Err whose underlying minijinja error is a
655            // template-evaluation problem; an Error::Internal that
656            // says "template <name> not found" would mean the loader
657            // failed entirely (regression).
658            let result = t.render(name, &Empty {});
659            if let Err(e) = result {
660                let msg = e.to_string();
661                assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
662            }
663        }
664    }
665
666    /// Regression gate for the 0.7.0 → 0.7.1 fix.
667    ///
668    /// Scans every `.rs` file under `src/admin/` for string
669    /// literals of the shape `"admin/<name>.html"` and asserts each
670    /// one resolves via `Templates::new(None)?`. If a handler is
671    /// added that renders a new template and the author forgets to
672    /// extend `EMBEDDED_TEMPLATES`, this test fails before the
673    /// release ships rather than after the first user clicks the
674    /// new page.
675    ///
676    /// The 0.6.0 R2 + 0.7.0 R3 cycles both shipped with this
677    /// shape of bug — the disk template files were committed, the
678    /// handlers rendered them, the bug was invisible to unit tests
679    /// (no integration test boots a real HTTP stack against the
680    /// affected routes), and the regression surfaced only when the
681    /// flagship downstream walked the surface against a live DB.
682    /// This test makes the discipline mechanical.
683    #[test]
684    fn every_handler_rendered_template_resolves() {
685        let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
686        let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
687        // Bare-literal scan — the framework only uses
688        // `"admin/<...>.html"` strings as template names, so
689        // finding every literal of that shape catches the entire
690        // surface without needing AST parsing or a regex
691        // dependency.
692        walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
693            let content = std::fs::read_to_string(path)
694                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
695            extract_template_names(&content, &mut names);
696        });
697        assert!(
698            !names.is_empty(),
699            "no template names found — scan regression?"
700        );
701
702        let t = Templates::new(None).unwrap();
703        let mut missing: Vec<String> = Vec::new();
704        for name in &names {
705            let result = t.render(name, &Empty {});
706            if let Err(e) = result {
707                let msg = e.to_string();
708                // `not found` is minijinja's "template not in
709                // loader" error. Other errors (strict-undefined,
710                // type mismatches) are tolerated — the test cares
711                // only about loader resolution, not full render
712                // success against an empty context.
713                if msg.contains("not found") {
714                    missing.push(format!("{name}: {msg}"));
715                }
716            }
717        }
718        assert!(
719            missing.is_empty(),
720            "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n  {}",
721            missing.join("\n  ")
722        );
723    }
724
725    /// Pull every `"admin/<...>.html"` literal out of `content` and
726    /// stuff it into `out`. Bare-string scan; tolerates string
727    /// literals that span multiple lines because the closing
728    /// `.html"` must appear before the next double-quote.
729    fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
730        let needle = "\"admin/";
731        let mut cursor = 0;
732        while let Some(idx) = content[cursor..].find(needle) {
733            let start = cursor + idx + 1; // past the opening quote
734            let after = &content[start..];
735            // The literal ends at the next `"`. If `.html` does
736            // not appear before that quote, this isn't a template
737            // reference (it could be e.g. a Permission action_name
738            // that happens to start with `admin/`).
739            if let Some(end_rel) = after.find('"') {
740                let literal = &after[..end_rel];
741                if literal.ends_with(".html") {
742                    out.insert(literal.to_string());
743                }
744                cursor = start + end_rel + 1;
745            } else {
746                break;
747            }
748        }
749    }
750
751    /// Recursively walk every `.rs` file under `root`, calling
752    /// `visit` for each. Std-only — no `walkdir` dep needed for a
753    /// single test.
754    fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
755        let entries = match std::fs::read_dir(root) {
756            Ok(e) => e,
757            Err(_) => return,
758        };
759        for entry in entries.flatten() {
760            let path = entry.path();
761            let file_type = match entry.file_type() {
762                Ok(ft) => ft,
763                Err(_) => continue,
764            };
765            if file_type.is_dir() {
766                walk_rs_files(&path, visit);
767            } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
768                visit(&path);
769            }
770        }
771    }
772}