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-admin 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-admin 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/_row_actions.html",
336        include_str!("../assets/templates/admin/_row_actions.html"),
337    ),
338    (
339        "admin/includes/_form_field.html",
340        include_str!("../assets/templates/admin/includes/_form_field.html"),
341    ),
342    (
343        "admin/includes/_field_errors.html",
344        include_str!("../assets/templates/admin/includes/_field_errors.html"),
345    ),
346    // Generic pages
347    (
348        "admin/login.html",
349        include_str!("../assets/templates/admin/login.html"),
350    ),
351    (
352        "admin/index.html",
353        include_str!("../assets/templates/admin/index.html"),
354    ),
355    (
356        "admin/list.html",
357        include_str!("../assets/templates/admin/list.html"),
358    ),
359    (
360        "admin/form.html",
361        include_str!("../assets/templates/admin/form.html"),
362    ),
363    (
364        "admin/confirm_delete.html",
365        include_str!("../assets/templates/admin/confirm_delete.html"),
366    ),
367    (
368        "admin/bulk_confirm_delete.html",
369        include_str!("../assets/templates/admin/bulk_confirm_delete.html"),
370    ),
371    (
372        "admin/db_browser.html",
373        include_str!("../assets/templates/admin/db_browser.html"),
374    ),
375    (
376        "admin/bulk_confirm_action.html",
377        include_str!("../assets/templates/admin/bulk_confirm_action.html"),
378    ),
379    (
380        "admin/error.html",
381        include_str!("../assets/templates/admin/error.html"),
382    ),
383    (
384        "admin/forbidden.html",
385        include_str!("../assets/templates/admin/forbidden.html"),
386    ),
387    // Audit / password change
388    (
389        "admin/object_history.html",
390        include_str!("../assets/templates/admin/object_history.html"),
391    ),
392    (
393        "admin/log_entries.html",
394        include_str!("../assets/templates/admin/log_entries.html"),
395    ),
396    (
397        "admin/apis_index.html",
398        include_str!("../assets/templates/admin/apis_index.html"),
399    ),
400    (
401        "admin/apis_playground.html",
402        include_str!("../assets/templates/admin/apis_playground.html"),
403    ),
404    (
405        "admin/health.html",
406        include_str!("../assets/templates/admin/health.html"),
407    ),
408    (
409        "admin/feature_flags.html",
410        include_str!("../assets/templates/admin/feature_flags.html"),
411    ),
412    (
413        "admin/_list_adaptive.html",
414        include_str!("../assets/templates/admin/_list_adaptive.html"),
415    ),
416    (
417        "admin/view_designer.html",
418        include_str!("../assets/templates/admin/view_designer.html"),
419    ),
420    (
421        "admin/view_designer_model.html",
422        include_str!("../assets/templates/admin/view_designer_model.html"),
423    ),
424    (
425        "admin/branding.html",
426        include_str!("../assets/templates/admin/branding.html"),
427    ),
428    (
429        "admin/schema.html",
430        include_str!("../assets/templates/admin/schema.html"),
431    ),
432    (
433        "admin/view_layer/_cell.html",
434        include_str!("../assets/templates/admin/view_layer/_cell.html"),
435    ),
436    (
437        "admin/view_layer/_row.html",
438        include_str!("../assets/templates/admin/view_layer/_row.html"),
439    ),
440    (
441        "admin/notifications.html",
442        include_str!("../assets/templates/admin/notifications.html"),
443    ),
444    (
445        "admin/csv_import_result.html",
446        include_str!("../assets/templates/admin/csv_import_result.html"),
447    ),
448    (
449        "admin/docs_index.html",
450        include_str!("../assets/templates/admin/docs_index.html"),
451    ),
452    (
453        "admin/doc_page.html",
454        include_str!("../assets/templates/admin/doc_page.html"),
455    ),
456    (
457        "admin/password_change.html",
458        include_str!("../assets/templates/admin/password_change.html"),
459    ),
460    // Built-in user pages
461    (
462        "admin/users_list.html",
463        include_str!("../assets/templates/admin/users_list.html"),
464    ),
465    (
466        "admin/user_new.html",
467        include_str!("../assets/templates/admin/user_new.html"),
468    ),
469    (
470        "admin/user_edit.html",
471        include_str!("../assets/templates/admin/user_edit.html"),
472    ),
473    (
474        "admin/user_view.html",
475        include_str!("../assets/templates/admin/user_view.html"),
476    ),
477    (
478        "admin/user_confirm_delete.html",
479        include_str!("../assets/templates/admin/user_confirm_delete.html"),
480    ),
481    // Built-in group pages
482    (
483        "admin/groups_list.html",
484        include_str!("../assets/templates/admin/groups_list.html"),
485    ),
486    (
487        "admin/group_new.html",
488        include_str!("../assets/templates/admin/group_new.html"),
489    ),
490    (
491        "admin/group_edit.html",
492        include_str!("../assets/templates/admin/group_edit.html"),
493    ),
494    (
495        "admin/group_confirm_delete.html",
496        include_str!("../assets/templates/admin/group_confirm_delete.html"),
497    ),
498    // Self-service account pages (R0+)
499    (
500        "admin/account_sessions.html",
501        include_str!("../assets/templates/admin/account_sessions.html"),
502    ),
503    // Self-service password recovery (R1)
504    (
505        "admin/forgot_password.html",
506        include_str!("../assets/templates/admin/forgot_password.html"),
507    ),
508    (
509        "admin/forgot_password_sent.html",
510        include_str!("../assets/templates/admin/forgot_password_sent.html"),
511    ),
512    (
513        "admin/reset_password.html",
514        include_str!("../assets/templates/admin/reset_password.html"),
515    ),
516    // Organisational recovery (R2)
517    //
518    // These pages are rendered by `admin/admin_recovery_handlers.rs`
519    // — the admin-driven reset / lock / re-auth / forced-rotation
520    // surface. They were inadvertently omitted from this list when
521    // R2 shipped in 0.6.0; the disk files were committed but never
522    // hooked into the embedded set. Without them every
523    // `/admin/reauth`, `/admin/users/:id/reset-password`,
524    // `/admin/users/:id/lock`, and forced-password-change request
525    // returns the framework's generic 500 page. The
526    // `every_handler_rendered_template_resolves` test in this file
527    // is the regression gate that catches this shape of omission.
528    (
529        "admin/reauth.html",
530        include_str!("../assets/templates/admin/reauth.html"),
531    ),
532    (
533        "admin/admin_reset_password.html",
534        include_str!("../assets/templates/admin/admin_reset_password.html"),
535    ),
536    (
537        "admin/lock_user.html",
538        include_str!("../assets/templates/admin/lock_user.html"),
539    ),
540    (
541        "admin/confirm_admin_action.html",
542        include_str!("../assets/templates/admin/confirm_admin_action.html"),
543    ),
544    (
545        "admin/must_change_password.html",
546        include_str!("../assets/templates/admin/must_change_password.html"),
547    ),
548    // TOTP MFA (R3)
549    //
550    // Rendered by `admin/mfa_handlers.rs` (enrol / verify /
551    // regenerate / disable). Same shape of omission as the R2 set
552    // above; same regression-gate test covers them.
553    (
554        "admin/mfa_enroll.html",
555        include_str!("../assets/templates/admin/mfa_enroll.html"),
556    ),
557    (
558        "admin/mfa_enroll_complete.html",
559        include_str!("../assets/templates/admin/mfa_enroll_complete.html"),
560    ),
561    (
562        "admin/mfa_verify.html",
563        include_str!("../assets/templates/admin/mfa_verify.html"),
564    ),
565    (
566        "admin/mfa_disable.html",
567        include_str!("../assets/templates/admin/mfa_disable.html"),
568    ),
569    (
570        "admin/mfa_regenerate.html",
571        include_str!("../assets/templates/admin/mfa_regenerate.html"),
572    ),
573    (
574        "admin/mfa_regenerate_complete.html",
575        include_str!("../assets/templates/admin/mfa_regenerate_complete.html"),
576    ),
577];
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use serde::Serialize;
583    use std::io::Write;
584
585    #[derive(Serialize)]
586    struct Empty {}
587
588    fn tempdir() -> std::path::PathBuf {
589        let dir = std::env::temp_dir().join(format!(
590            "rustio-admin-test-{}",
591            std::time::SystemTime::now()
592                .duration_since(std::time::UNIX_EPOCH)
593                .unwrap()
594                .as_nanos()
595        ));
596        std::fs::create_dir_all(&dir).unwrap();
597        dir
598    }
599
600    #[test]
601    fn missing_template_errors_cleanly() {
602        let t = Templates::new(None).unwrap();
603        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
604        assert_eq!(err.status(), 500);
605    }
606
607    #[test]
608    fn disk_loader_finds_project_template() {
609        let dir = tempdir();
610        let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
611        f.write_all(b"hi from disk").unwrap();
612        drop(f);
613
614        let t = Templates::new(Some(dir.clone())).unwrap();
615        let body = t.render("hello.html", &Empty {}).unwrap();
616        assert_eq!(body, "hi from disk");
617
618        let _ = std::fs::remove_dir_all(&dir);
619    }
620
621    /// Per-model template override: a file at
622    /// `<disk_root>/admin/<model>/list.html` wins over a same-named
623    /// disk override AND over the embedded default, but only for
624    /// `render_for_model(model, ...)` calls. Other models still see
625    /// the framework default (or whatever shadow they have).
626    #[test]
627    fn render_for_model_prefers_per_model_override() {
628        let dir = tempdir();
629        std::fs::create_dir_all(dir.join("admin/books")).unwrap();
630        let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
631        f.write_all(b"books-specific list").unwrap();
632        drop(f);
633
634        let t = Templates::new(Some(dir.clone())).unwrap();
635        // Books model sees the override.
636        let body = t
637            .render_for_model("books", "admin/list.html", &Empty {})
638            .unwrap();
639        assert_eq!(body, "books-specific list");
640        let _ = std::fs::remove_dir_all(&dir);
641    }
642
643    /// A model with no per-model file falls through to the
644    /// framework-default lookup chain. Other models with overrides
645    /// don't bleed into this one.
646    #[test]
647    fn render_for_model_falls_through_to_framework_default() {
648        let dir = tempdir();
649        // Drop a books-only override; query for a different model.
650        std::fs::create_dir_all(dir.join("admin/books")).unwrap();
651        let mut f = std::fs::File::create(dir.join("admin/books/list.html")).unwrap();
652        f.write_all(b"books override").unwrap();
653        drop(f);
654        // Drop a framework-wide override too, to confirm the
655        // fall-through actually reaches it (and isn't accidentally
656        // picking up the books override for the other model).
657        std::fs::create_dir_all(dir.join("admin")).unwrap();
658        let mut f = std::fs::File::create(dir.join("admin/list.html")).unwrap();
659        f.write_all(b"framework-wide list").unwrap();
660        drop(f);
661
662        let t = Templates::new(Some(dir.clone())).unwrap();
663        // "authors" has no per-model file — falls through to
664        // framework-wide override.
665        let body = t
666            .render_for_model("authors", "admin/list.html", &Empty {})
667            .unwrap();
668        assert_eq!(body, "framework-wide list");
669        // "books" still sees its own override.
670        let body = t
671            .render_for_model("books", "admin/list.html", &Empty {})
672            .unwrap();
673        assert_eq!(body, "books override");
674        let _ = std::fs::remove_dir_all(&dir);
675    }
676
677    /// Every embedded template is registered. Catches typos in
678    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
679    #[test]
680    fn every_embedded_template_loads() {
681        let t = Templates::new(None).unwrap();
682        for (name, _) in EMBEDDED_TEMPLATES {
683            // Render with an empty serializable; minijinja's
684            // strict-undefined fails on missing variables, so most
685            // pages will Err — but parsing happens before evaluation.
686            // We accept any Err whose underlying minijinja error is a
687            // template-evaluation problem; an Error::Internal that
688            // says "template <name> not found" would mean the loader
689            // failed entirely (regression).
690            let result = t.render(name, &Empty {});
691            if let Err(e) = result {
692                let msg = e.to_string();
693                assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
694            }
695        }
696    }
697
698    /// The command rail marks exactly the current page's entry with
699    /// `aria-current="page"`, driven by the `nav_active` key the page
700    /// contexts set via `BaseContext::with_nav_active`. Guards against the
701    /// rail silently losing its active-state highlighting again (the key
702    /// was unset for a long time, so the comparison was always false).
703    #[test]
704    fn sidebar_marks_active_nav_item() {
705        let t = Templates::new(None).unwrap();
706        let render_with = |active: &str| {
707            t.render(
708                "admin/_sidebar.html",
709                &minijinja::context! {
710                    app_name => "Test Admin",
711                    nav_active => active,
712                    identity => minijinja::context! { is_admin => true, is_developer => true },
713                    entries => vec![
714                        minijinja::context! { admin_name => "customer", display_name => "Customers" },
715                    ],
716                },
717            )
718            .unwrap()
719        };
720
721        // A built-in section key (Users) highlights its own link and nothing else.
722        let users = render_with("users");
723        assert!(
724            users.contains(r#"href="/admin/users" aria-current="page""#),
725            "Users link should be active when nav_active=users"
726        );
727        assert_eq!(
728            users.matches(r#"aria-current="page""#).count(),
729            1,
730            "exactly one rail item is active"
731        );
732
733        // A model's admin_name highlights that model's link.
734        let model = render_with("customer");
735        assert!(
736            model.contains(r#"href="/admin/customer" aria-current="page""#),
737            "model link should be active when nav_active matches its admin_name"
738        );
739
740        // The new developer entry highlights when active and is always present.
741        let designer = render_with("view-designer");
742        assert!(designer.contains(r#"href="/admin/dev/view-designer""#));
743        assert!(
744            designer.contains(r#"href="/admin/dev/view-designer" aria-current="page""#),
745            "View designer link should be active when nav_active=view-designer"
746        );
747
748        // An unrelated key leaves the rail with no active item.
749        let none = render_with("");
750        assert_eq!(none.matches(r#"aria-current="page""#).count(), 0);
751    }
752
753    /// Regression gate for the 0.7.0 → 0.7.1 fix.
754    ///
755    /// Scans every `.rs` file under `src/admin/` for string
756    /// literals of the shape `"admin/<name>.html"` and asserts each
757    /// one resolves via `Templates::new(None)?`. If a handler is
758    /// added that renders a new template and the author forgets to
759    /// extend `EMBEDDED_TEMPLATES`, this test fails before the
760    /// release ships rather than after the first user clicks the
761    /// new page.
762    ///
763    /// The 0.6.0 R2 + 0.7.0 R3 cycles both shipped with this
764    /// shape of bug — the disk template files were committed, the
765    /// handlers rendered them, the bug was invisible to unit tests
766    /// (no integration test boots a real HTTP stack against the
767    /// affected routes), and the regression surfaced only when the
768    /// flagship downstream walked the surface against a live DB.
769    /// This test makes the discipline mechanical.
770    #[test]
771    fn every_handler_rendered_template_resolves() {
772        let admin_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
773        let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
774        // Bare-literal scan — the framework only uses
775        // `"admin/<...>.html"` strings as template names, so
776        // finding every literal of that shape catches the entire
777        // surface without needing AST parsing or a regex
778        // dependency.
779        walk_rs_files(&admin_src, &mut |path: &std::path::Path| {
780            let content = std::fs::read_to_string(path)
781                .unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
782            extract_template_names(&content, &mut names);
783        });
784        assert!(
785            !names.is_empty(),
786            "no template names found — scan regression?"
787        );
788
789        let t = Templates::new(None).unwrap();
790        let mut missing: Vec<String> = Vec::new();
791        for name in &names {
792            let result = t.render(name, &Empty {});
793            if let Err(e) = result {
794                let msg = e.to_string();
795                // `not found` is minijinja's "template not in
796                // loader" error. Other errors (strict-undefined,
797                // type mismatches) are tolerated — the test cares
798                // only about loader resolution, not full render
799                // success against an empty context.
800                if msg.contains("not found") {
801                    missing.push(format!("{name}: {msg}"));
802                }
803            }
804        }
805        assert!(
806            missing.is_empty(),
807            "templates referenced by handlers but not in EMBEDDED_TEMPLATES:\n  {}",
808            missing.join("\n  ")
809        );
810    }
811
812    /// Pull every `"admin/<...>.html"` literal out of `content` and
813    /// stuff it into `out`. Bare-string scan; tolerates string
814    /// literals that span multiple lines because the closing
815    /// `.html"` must appear before the next double-quote.
816    fn extract_template_names(content: &str, out: &mut std::collections::BTreeSet<String>) {
817        let needle = "\"admin/";
818        let mut cursor = 0;
819        while let Some(idx) = content[cursor..].find(needle) {
820            let start = cursor + idx + 1; // past the opening quote
821            let after = &content[start..];
822            // The literal ends at the next `"`. If `.html` does
823            // not appear before that quote, this isn't a template
824            // reference (it could be e.g. a Permission action_name
825            // that happens to start with `admin/`).
826            if let Some(end_rel) = after.find('"') {
827                let literal = &after[..end_rel];
828                if literal.ends_with(".html") {
829                    out.insert(literal.to_string());
830                }
831                cursor = start + end_rel + 1;
832            } else {
833                break;
834            }
835        }
836    }
837
838    /// Recursively walk every `.rs` file under `root`, calling
839    /// `visit` for each. Std-only — no `walkdir` dep needed for a
840    /// single test.
841    fn walk_rs_files(root: &std::path::Path, visit: &mut dyn FnMut(&std::path::Path)) {
842        let entries = match std::fs::read_dir(root) {
843            Ok(e) => e,
844            Err(_) => return,
845        };
846        for entry in entries.flatten() {
847            let path = entry.path();
848            let file_type = match entry.file_type() {
849                Ok(ft) => ft,
850                Err(_) => continue,
851            };
852            if file_type.is_dir() {
853                walk_rs_files(&path, visit);
854            } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
855                visit(&path);
856            }
857        }
858    }
859}