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
32pub struct Templates {
33    env: Mutex<Environment<'static>>,
34}
35
36impl Templates {
37    /// Build the environment.
38    ///
39    /// `project_templates_dir = None` → embedded templates only.
40    /// `project_templates_dir = Some(path)` → disk overrides win at
41    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
42    /// own resolved path) here.
43    ///
44    /// When a disk root is supplied, the constructor scans it once for
45    /// overrides of embedded templates. Each match is logged at INFO;
46    /// an override that looks structurally incomplete (no
47    /// `{% extends %}`, no `{% block %}`, no `<html>` tag) is logged at
48    /// WARN so a one-line stub of an admin template stops being a
49    /// silent failure. Non-fatal: the override is still served — the
50    /// scan exists only to make the failure mode visible.
51    pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
52        let disk_root = project_templates_dir;
53        if let Some(root) = disk_root.as_deref() {
54            for v in validate_overrides(root) {
55                match v {
56                    OverrideValidation::Loaded { name, bytes } => {
57                        log::info!(
58                            "templates: project override loaded for `{name}` ({bytes} bytes)"
59                        );
60                    }
61                    OverrideValidation::Suspicious { name, bytes } => {
62                        log::warn!(
63                            "templates: project override for `{name}` looks incomplete \
64                             ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
65                             `<html>` tag) — the admin UI may render incorrectly. Either \
66                             copy the framework default in full or remove the override."
67                        );
68                    }
69                    OverrideValidation::Unreadable { name, error } => {
70                        log::warn!(
71                            "templates: project override `{name}` exists but cannot be read: {error}"
72                        );
73                    }
74                    OverrideValidation::OrphanAdminFile { path } => {
75                        log::warn!(
76                            "templates: `{path}` is in the admin namespace but does not \
77                             override any embedded template (typo? framework default \
78                             will be served unchanged). Project-specific admin pages \
79                             belong outside `templates/admin/`."
80                        );
81                    }
82                }
83            }
84        }
85        let mut env = Environment::new();
86        env.set_loader(move |name| load_template(disk_root.as_deref(), name));
87
88        // `icon(name, class="…")` returns inline SVG for one of the
89        // lucide stroke icons baked at compile time. Templates use it
90        // to render sidebar nav icons, button icons, and alert glyphs
91        // without an extra HTTP round trip. See `admin/icons.rs` for
92        // the catalogue.
93        env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
94            let class: String = kwargs.get("class").unwrap_or_default();
95            kwargs.assert_all_used().ok();
96            // The output is HTML — minijinja's autoescape would
97            // mangle it. Wrap in `safe()` so it renders as markup.
98            minijinja::value::Value::from_safe_string(crate::admin::icons::render_inline(
99                name, &class,
100            ))
101        });
102
103        Ok(Arc::new(Self {
104            env: Mutex::new(env),
105        }))
106    }
107
108    /// Render a template by name.
109    pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
110        let mut env = self
111            .env
112            .lock()
113            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
114        // Clear cache so the loader runs again — restart-free dev edits.
115        env.clear_templates();
116        let tmpl = env
117            .get_template(name)
118            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
119        tmpl.render(ctx)
120            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
121    }
122
123    /// Render with a per-model override hook.
124    ///
125    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
126    /// stripped of any leading `admin/`), falling back to `name`.
127    #[allow(dead_code)]
128    pub fn render_for_model<S: Serialize>(
129        &self,
130        model: &str,
131        name: &str,
132        ctx: &S,
133    ) -> Result<String> {
134        let page = name.strip_prefix("admin/").unwrap_or(name);
135        let per_model = format!("admin/{model}/{page}");
136        let mut env = self
137            .env
138            .lock()
139            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
140        env.clear_templates();
141        if let Ok(tmpl) = env.get_template(&per_model) {
142            return tmpl
143                .render(ctx)
144                .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
145        }
146        let tmpl = env
147            .get_template(name)
148            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
149        tmpl.render(ctx)
150            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
151    }
152}
153
154/// Outcome of inspecting one project override file at startup.
155/// Per-file, not per-render: the cost is paid once when the `Templates`
156/// arc is built, not on every request.
157///
158/// Pure data — `Templates::new` translates each variant to a log line.
159/// Returned as a `Vec` so unit tests can assert on the structural
160/// classification without scraping log output.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub(crate) enum OverrideValidation {
163    /// File loaded and contains at least one of `{% extends %}`,
164    /// `{% block %}`, or `<html`.
165    Loaded { name: &'static str, bytes: usize },
166    /// File loaded but contains none of the structural markers.
167    Suspicious { name: &'static str, bytes: usize },
168    /// File exists on disk but `read_to_string` failed.
169    Unreadable { name: &'static str, error: String },
170    /// File in `templates/admin/` whose name does NOT match any
171    /// embedded template — usually a typo or a misplaced project
172    /// admin page.
173    OrphanAdminFile { path: String },
174}
175
176/// Walk `EMBEDDED_TEMPLATES`, classify any project override of one of
177/// those names, return the per-file results.
178///
179/// Files in `disk_root` that do NOT shadow an embedded name are
180/// ignored: those are project-only templates and have no framework
181/// default to compare against.
182pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
183    let mut results = Vec::new();
184    for (name, _embedded) in EMBEDDED_TEMPLATES {
185        let path = disk_root.join(name);
186        if !path.is_file() {
187            continue;
188        }
189        match std::fs::read_to_string(&path) {
190            Ok(body) => {
191                let bytes = body.len();
192                let has_structure = body.contains("{% extends")
193                    || body.contains("{% block")
194                    || body.contains("<html");
195                if has_structure {
196                    results.push(OverrideValidation::Loaded { name, bytes });
197                } else {
198                    results.push(OverrideValidation::Suspicious { name, bytes });
199                }
200            }
201            Err(e) => {
202                results.push(OverrideValidation::Unreadable {
203                    name,
204                    error: e.to_string(),
205                });
206            }
207        }
208    }
209
210    // Orphan-admin-file scan. The framework reserves `templates/admin/`
211    // for overrides of embedded admin templates. A file in that
212    // namespace whose name doesn't match any embedded template
213    // overrides nothing — usually a typo or misunderstanding. Either
214    // way the developer's intent and the runtime's behaviour disagree
215    // silently; this scan logs a WARN so the disagreement becomes
216    // observable.
217    let admin_dir = disk_root.join("admin");
218    if admin_dir.is_dir() {
219        let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
220            .iter()
221            .filter_map(|(n, _)| n.strip_prefix("admin/"))
222            .collect();
223        if let Ok(entries) = std::fs::read_dir(&admin_dir) {
224            // Sort for deterministic ordering — the loop visits files in
225            // arbitrary FS order otherwise, which makes log lines and
226            // tests non-deterministic.
227            let mut files: Vec<_> = entries
228                .filter_map(|e| e.ok())
229                .filter(|e| {
230                    e.path()
231                        .extension()
232                        .and_then(|s| s.to_str())
233                        .map(|s| s.eq_ignore_ascii_case("html"))
234                        .unwrap_or(false)
235                })
236                .collect();
237            files.sort_by_key(|e| e.file_name());
238            for entry in files {
239                let file_name = entry.file_name();
240                let Some(stem_html) = file_name.to_str() else {
241                    continue;
242                };
243                if known.contains(stem_html) {
244                    continue;
245                }
246                results.push(OverrideValidation::OrphanAdminFile {
247                    path: format!("admin/{stem_html}"),
248                });
249            }
250        }
251    }
252
253    results
254}
255
256fn load_template(
257    disk_root: Option<&std::path::Path>,
258    name: &str,
259) -> std::result::Result<Option<String>, minijinja::Error> {
260    if let Some(root) = disk_root {
261        let path = root.join(name);
262        if path.exists() {
263            return std::fs::read_to_string(&path).map(Some).map_err(|e| {
264                minijinja::Error::new(
265                    ErrorKind::InvalidOperation,
266                    format!("read template {}: {e}", path.display()),
267                )
268            });
269        }
270    }
271    Ok(EMBEDDED_TEMPLATES.iter().find_map(|(n, b)| {
272        if *n == name {
273            Some((*b).to_string())
274        } else {
275            None
276        }
277    }))
278}
279
280/// Baked into the binary. Single-binary deploy is a hard constraint.
281const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
282    // Shell + partials
283    (
284        "admin/_base.html",
285        include_str!("../assets/templates/admin/_base.html"),
286    ),
287    (
288        "admin/_topbar.html",
289        include_str!("../assets/templates/admin/_topbar.html"),
290    ),
291    (
292        "admin/_sidebar.html",
293        include_str!("../assets/templates/admin/_sidebar.html"),
294    ),
295    (
296        "admin/_theme.html",
297        include_str!("../assets/templates/admin/_theme.html"),
298    ),
299    (
300        "admin/includes/_form_field.html",
301        include_str!("../assets/templates/admin/includes/_form_field.html"),
302    ),
303    (
304        "admin/includes/_field_errors.html",
305        include_str!("../assets/templates/admin/includes/_field_errors.html"),
306    ),
307    // Generic pages
308    (
309        "admin/login.html",
310        include_str!("../assets/templates/admin/login.html"),
311    ),
312    (
313        "admin/index.html",
314        include_str!("../assets/templates/admin/index.html"),
315    ),
316    (
317        "admin/list.html",
318        include_str!("../assets/templates/admin/list.html"),
319    ),
320    (
321        "admin/form.html",
322        include_str!("../assets/templates/admin/form.html"),
323    ),
324    (
325        "admin/confirm_delete.html",
326        include_str!("../assets/templates/admin/confirm_delete.html"),
327    ),
328    (
329        "admin/error.html",
330        include_str!("../assets/templates/admin/error.html"),
331    ),
332    (
333        "admin/forbidden.html",
334        include_str!("../assets/templates/admin/forbidden.html"),
335    ),
336    // Audit / password change
337    (
338        "admin/object_history.html",
339        include_str!("../assets/templates/admin/object_history.html"),
340    ),
341    (
342        "admin/log_entries.html",
343        include_str!("../assets/templates/admin/log_entries.html"),
344    ),
345    (
346        "admin/password_change.html",
347        include_str!("../assets/templates/admin/password_change.html"),
348    ),
349    // Built-in user pages
350    (
351        "admin/users_list.html",
352        include_str!("../assets/templates/admin/users_list.html"),
353    ),
354    (
355        "admin/user_new.html",
356        include_str!("../assets/templates/admin/user_new.html"),
357    ),
358    (
359        "admin/user_edit.html",
360        include_str!("../assets/templates/admin/user_edit.html"),
361    ),
362    (
363        "admin/user_view.html",
364        include_str!("../assets/templates/admin/user_view.html"),
365    ),
366    (
367        "admin/user_confirm_delete.html",
368        include_str!("../assets/templates/admin/user_confirm_delete.html"),
369    ),
370    // Built-in group pages
371    (
372        "admin/groups_list.html",
373        include_str!("../assets/templates/admin/groups_list.html"),
374    ),
375    (
376        "admin/group_new.html",
377        include_str!("../assets/templates/admin/group_new.html"),
378    ),
379    (
380        "admin/group_edit.html",
381        include_str!("../assets/templates/admin/group_edit.html"),
382    ),
383    (
384        "admin/group_confirm_delete.html",
385        include_str!("../assets/templates/admin/group_confirm_delete.html"),
386    ),
387];
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use serde::Serialize;
393    use std::io::Write;
394
395    #[derive(Serialize)]
396    struct Empty {}
397
398    fn tempdir() -> std::path::PathBuf {
399        let dir = std::env::temp_dir().join(format!(
400            "rustio-admin-test-{}",
401            std::time::SystemTime::now()
402                .duration_since(std::time::UNIX_EPOCH)
403                .unwrap()
404                .as_nanos()
405        ));
406        std::fs::create_dir_all(&dir).unwrap();
407        dir
408    }
409
410    #[test]
411    fn missing_template_errors_cleanly() {
412        let t = Templates::new(None).unwrap();
413        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
414        assert_eq!(err.status(), 500);
415    }
416
417    #[test]
418    fn disk_loader_finds_project_template() {
419        let dir = tempdir();
420        let mut f = std::fs::File::create(dir.join("hello.html")).unwrap();
421        f.write_all(b"hi from disk").unwrap();
422        drop(f);
423
424        let t = Templates::new(Some(dir.clone())).unwrap();
425        let body = t.render("hello.html", &Empty {}).unwrap();
426        assert_eq!(body, "hi from disk");
427
428        let _ = std::fs::remove_dir_all(&dir);
429    }
430
431    /// Every embedded template is registered. Catches typos in
432    /// `EMBEDDED_TEMPLATES` (e.g. wrong path, missing entry).
433    #[test]
434    fn every_embedded_template_loads() {
435        let t = Templates::new(None).unwrap();
436        for (name, _) in EMBEDDED_TEMPLATES {
437            // Render with an empty serializable; minijinja's
438            // strict-undefined fails on missing variables, so most
439            // pages will Err — but parsing happens before evaluation.
440            // We accept any Err whose underlying minijinja error is a
441            // template-evaluation problem; an Error::Internal that
442            // says "template <name> not found" would mean the loader
443            // failed entirely (regression).
444            let result = t.render(name, &Empty {});
445            if let Err(e) = result {
446                let msg = e.to_string();
447                assert!(!msg.contains("not found"), "{name} failed to load: {msg}");
448            }
449        }
450    }
451}