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// 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}