Skip to main content

rustio_core/
templates.rs

1//! Template rendering. Rust code passes typed context; this module
2//! owns everything about HTML generation.
3//!
4//! # Loader contract (Phase 6a)
5//!
6//! Per-request lookup via [`minijinja::Environment::set_loader`].
7//! On every `render` call the cache is cleared, forcing the loader
8//! closure to re-resolve from disk so a developer can edit a
9//! template under `RUSTIO_TEMPLATE_DIR` and see the change on the
10//! next request without 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 (Phase 7 hook): callers that pass a model context
18//! can use [`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//!
24//! No handler in Phase 6a calls `render_for_model`; the path is
25//! exercised only by tests so the wiring is ready when a project
26//! needs a per-model override.
27
28use std::path::PathBuf;
29use std::sync::{Arc, Mutex};
30
31use minijinja::{Environment, ErrorKind};
32use serde::Serialize;
33
34use crate::error::{Error, Result};
35
36pub struct Templates {
37    env: Mutex<Environment<'static>>,
38}
39
40impl Templates {
41    /// Build the environment.
42    ///
43    /// `project_templates_dir = None` → embedded templates only.
44    /// `project_templates_dir = Some(path)` → disk overrides win at
45    /// render time. Pass the value of `RUSTIO_TEMPLATE_DIR` (or your
46    /// own resolved path) here.
47    pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
48        let disk_root = project_templates_dir;
49        let mut env = Environment::new();
50        env.set_loader(move |name| load_template(disk_root.as_deref(), name));
51
52        // Phase 7a/2 — `icon(name, class="...")` returns inline SVG
53        // for one of the lucide stroke icons baked at compile time.
54        // Templates use this to render sidebar nav icons, button
55        // icons, alert-banner glyphs without an extra HTTP round
56        // trip. See `admin/icons.rs` for the catalogue.
57        env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
58            let class: String = kwargs.get("class").unwrap_or_default();
59            kwargs.assert_all_used().ok();
60            // The output is HTML — minijinja's autoescape would mangle
61            // it. Wrap in `safe()` so it renders as markup.
62            minijinja::value::Value::from_safe_string(
63                crate::admin::icons::render_inline(name, &class),
64            )
65        });
66
67        Ok(Arc::new(Self {
68            env: Mutex::new(env),
69        }))
70    }
71
72    /// Render a template by name.
73    pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
74        let mut env = self
75            .env
76            .lock()
77            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
78        // Clear cache so the loader runs again — restart-free dev edits.
79        env.clear_templates();
80        let tmpl = env
81            .get_template(name)
82            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
83        tmpl.render(ctx)
84            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
85    }
86
87    /// Render with a per-model override hook.
88    ///
89    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
90    /// stripped of any leading `admin/`), falling back to `name`.
91    /// Phase 6a wires the API but no handler calls it yet — the
92    /// existing Phase 6a admin pages all call [`Self::render`].
93    #[allow(dead_code)]
94    pub fn render_for_model<S: Serialize>(
95        &self,
96        model: &str,
97        name: &str,
98        ctx: &S,
99    ) -> Result<String> {
100        let page = name.strip_prefix("admin/").unwrap_or(name);
101        let per_model = format!("admin/{model}/{page}");
102        let mut env = self
103            .env
104            .lock()
105            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
106        env.clear_templates();
107        // Try per-model first; fall through if loader returns None.
108        if let Ok(tmpl) = env.get_template(&per_model) {
109            return tmpl
110                .render(ctx)
111                .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
112        }
113        let tmpl = env
114            .get_template(name)
115            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
116        tmpl.render(ctx)
117            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
118    }
119}
120
121fn load_template(
122    disk_root: Option<&std::path::Path>,
123    name: &str,
124) -> std::result::Result<Option<String>, minijinja::Error> {
125    if let Some(root) = disk_root {
126        let path = root.join(name);
127        if path.exists() {
128            return std::fs::read_to_string(&path).map(Some).map_err(|e| {
129                minijinja::Error::new(
130                    ErrorKind::InvalidOperation,
131                    format!("read template {}: {e}", path.display()),
132                )
133            });
134        }
135    }
136    Ok(EMBEDDED_TEMPLATES
137        .iter()
138        .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
139}
140
141// Baked into the binary. Single-binary deploy is a hard constraint.
142const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
143    ("base.html", include_str!("../assets/templates/base.html")),
144    ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
145    ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
146    ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
147    ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
148    ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
149    ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
150    ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
151    ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
152    ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
153    ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
154    ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
155    ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
156    ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
157    ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
158    ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
159    ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
160    ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
161    ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
162    ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
163    ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
164    ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
165    ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
166    ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
167    ("search.html", include_str!("../assets/templates/search.html")),
168];
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use serde::Serialize;
174    use std::io::Write;
175
176    #[derive(Serialize)]
177    struct Empty {}
178
179    #[test]
180    fn loader_registers_all_embedded_templates() {
181        let t = Templates::new(None).unwrap();
182        assert!(t.render("base.html", &Empty {}).is_ok());
183    }
184
185    #[test]
186    fn missing_template_errors_cleanly() {
187        let t = Templates::new(None).unwrap();
188        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
189        assert_eq!(err.status(), 500);
190    }
191
192    #[test]
193    fn disk_override_wins_over_embedded() {
194        let dir = tempdir();
195        let admin_dir = dir.join("admin");
196        std::fs::create_dir_all(&admin_dir).unwrap();
197        let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
198        f.write_all(b"OVERRIDDEN-BODY").unwrap();
199        drop(f);
200
201        let t = Templates::new(Some(dir.clone())).unwrap();
202        let body = t.render("admin/login.html", &Empty {}).unwrap();
203        assert_eq!(body, "OVERRIDDEN-BODY");
204
205        // Cleanup.
206        let _ = std::fs::remove_dir_all(&dir);
207    }
208
209    #[test]
210    fn embedded_fallback_when_disk_missing() {
211        let dir = tempdir();
212        // dir exists but contains no admin/login.html — embedded must win.
213        let t = Templates::new(Some(dir.clone())).unwrap();
214        let body = t.render("admin/login.html", &Empty {}).unwrap();
215        // Embedded login.html is never empty; reject if it returned the
216        // disk-override sentinel.
217        assert!(!body.is_empty());
218        assert!(!body.contains("OVERRIDDEN-BODY"));
219
220        let _ = std::fs::remove_dir_all(&dir);
221    }
222
223    #[test]
224    fn live_edit_visible_on_next_render_without_restart() {
225        // The win of the loader refactor: edit a template on disk, the
226        // next render reflects it — no Templates rebuild, no restart.
227        let dir = tempdir();
228        let admin_dir = dir.join("admin");
229        std::fs::create_dir_all(&admin_dir).unwrap();
230        let target = admin_dir.join("login.html");
231
232        std::fs::write(&target, b"V1").unwrap();
233        let t = Templates::new(Some(dir.clone())).unwrap();
234        assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
235
236        // Edit in place.
237        std::fs::write(&target, b"V2").unwrap();
238        assert_eq!(
239            t.render("admin/login.html", &Empty {}).unwrap(),
240            "V2",
241            "loader must re-resolve from disk on every render"
242        );
243
244        let _ = std::fs::remove_dir_all(&dir);
245    }
246
247    /// Phase 7a/0.5/f-fix regression: the embedded loader must know
248    /// about `admin/user_confirm_delete.html`. The browser smoke run
249    /// of /f hit a 500 because the template existed on disk but the
250    /// EMBEDDED_TEMPLATES const didn't list it. This test renders the
251    /// template with two distinct contexts and asserts the
252    /// last-developer banner + the submit-button disabled/enabled
253    /// invariant — so any future delete-handler refactor that adds a
254    /// new template can't ship the same gap unnoticed.
255    #[test]
256    fn user_confirm_delete_renders_with_last_developer_banner() {
257        let t = Templates::new(None).unwrap();
258
259        // Last-developer case → blocking banner + disabled submit.
260        let ctx = serde_json::json!({
261            "user_id": 122,
262            "email": "backup@example.com",
263            "role": "developer",
264            "group_count": 0,
265            "session_count": 1,
266            "direct_perm_count": 0,
267            "is_self": false,
268            "is_last_developer": true,
269            "csrf_token": "test-csrf",
270        });
271        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
272        assert!(
273            body.contains("is the last active developer"),
274            "last-dev banner must mention the orphan condition"
275        );
276        assert!(
277            body.contains("rustio-cli user role set"),
278            "last-dev banner must point operators at the CLI escape hatch"
279        );
280        assert!(
281            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
282            "submit must be disabled when target is the last active developer"
283        );
284
285        // Self-delete case → different errornote, also disabled.
286        let ctx_self = serde_json::json!({
287            "user_id": 7,
288            "email": "me@rustio.local",
289            "role": "administrator",
290            "group_count": 2,
291            "session_count": 1,
292            "direct_perm_count": 0,
293            "is_self": true,
294            "is_last_developer": false,
295            "csrf_token": "test-csrf",
296        });
297        let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
298        assert!(
299            body.contains("your own account"),
300            "self-delete banner must call out the self-action"
301        );
302        assert!(
303            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
304            "submit must be disabled on self-delete"
305        );
306    }
307
308    // ------------------------------------------------------------------
309    // Phase 7a/0.5/h — admin/user_view.html
310    //
311    // Five render tests covering the load-bearing template branches:
312    // - groups list populated vs empty
313    // - is_self / is_last_developer disable the Delete button
314    // - is_demo absent → no demo row
315    //
316    // All run as sandbox tests (no DB) because the template only
317    // depends on the context dict — perfect for catching template
318    // typos + missing variables without needing postgres.
319    // ------------------------------------------------------------------
320
321    fn view_ctx_base() -> serde_json::Value {
322        serde_json::json!({
323            "target_id": 42,
324            "target_email": "alice@example.com",
325            "target_role": "staff",
326            "target_is_active": true,
327            "target_is_demo": false,
328            "target_demo_label": null,
329            "target_created_at": "2026-04-25 12:00 UTC",
330            "target_updated_at": "2026-04-25 12:30 UTC",
331            "groups": [],
332            "direct_perms": [],
333            "is_self": false,
334            "is_last_developer": false,
335            "can_edit": true,
336            "can_delete": true,
337            "csrf_token": "test-csrf",
338        })
339    }
340
341    /// Phase 7a/2 — the `icon()` minijinja function is registered in
342    /// `Templates::new`. A template can call `{{ icon("home", class="w-4 h-4") }}`
343    /// and the inline SVG is emitted unescaped (because we wrap it in
344    /// `Value::from_safe_string`). Lock that contract.
345    #[test]
346    fn icon_function_emits_inline_svg() {
347        // Write a temp template that exercises icon() and render it.
348        let dir = tempdir();
349        let admin_dir = dir.join("admin");
350        std::fs::create_dir_all(&admin_dir).unwrap();
351        std::fs::write(
352            admin_dir.join("icon_test.html"),
353            r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
354        )
355        .unwrap();
356
357        let t = Templates::new(Some(dir.clone())).unwrap();
358        let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
359        assert!(
360            body.contains("<svg"),
361            "icon() must emit raw <svg> markup, not escape it"
362        );
363        assert!(body.contains(r#"class="sidebar-icon""#));
364        assert!(body.contains(r#"viewBox="0 0 24 24""#));
365        assert!(body.contains(r#"stroke="currentColor""#));
366
367        // Unknown icon name → empty string, page still renders.
368        std::fs::write(
369            admin_dir.join("icon_missing.html"),
370            r#"<span>{{ icon("not-real") }}</span>"#,
371        )
372        .unwrap();
373        let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
374        assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
375
376        let _ = std::fs::remove_dir_all(&dir);
377    }
378
379    #[test]
380    fn user_view_renders_with_groups() {
381        let t = Templates::new(None).unwrap();
382        let mut ctx = view_ctx_base();
383        ctx["groups"] = serde_json::json!([
384            { "name": "Auditors", "description": "read-only audit access" },
385            { "name": "Content Editors", "description": "" },
386        ]);
387        let body = t.render("admin/user_view.html", &ctx).unwrap();
388        assert!(
389            body.contains("Group memberships (2)"),
390            "membership count must reflect the groups list length"
391        );
392        assert!(body.contains("Auditors"));
393        assert!(body.contains("Content Editors"));
394        // Description-empty branch: the `<span class=\"help\">` must
395        // NOT render for the second group.
396        assert!(body.contains("read-only audit access"));
397    }
398
399    #[test]
400    fn user_view_without_groups_shows_empty_message() {
401        let t = Templates::new(None).unwrap();
402        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
403        assert!(
404            body.contains("No group memberships"),
405            "empty-state copy must appear when the groups list is empty"
406        );
407        assert!(body.contains("Group memberships (0)"));
408    }
409
410    /// Semantic invariant: when `can_delete` is false (whatever
411    /// reason), the Delete element must render as a `<span>` (not an
412    /// `<a href>`) so a misclick can't hit the destructive endpoint.
413    /// The exact CSS classes used to disable it are styling, not
414    /// contract.
415    fn assert_delete_is_disabled_span(body: &str, expected_tooltip: &str) {
416        assert!(
417            body.contains(r#"<span class="btn-danger"#),
418            "Delete must render as a <span> with btn-danger class when guarded"
419        );
420        assert!(
421            !body.contains(r#"<a href="/admin/users/42/delete""#),
422            "Delete must NOT render as an <a href=…/delete> when guarded"
423        );
424        assert!(
425            body.contains(expected_tooltip),
426            "tooltip must contain {expected_tooltip:?} so the operator knows why"
427        );
428    }
429
430    #[test]
431    fn user_view_is_self_disables_delete_as_span() {
432        let t = Templates::new(None).unwrap();
433        let mut ctx = view_ctx_base();
434        ctx["is_self"] = serde_json::Value::Bool(true);
435        ctx["can_delete"] = serde_json::Value::Bool(false);
436        let body = t.render("admin/user_view.html", &ctx).unwrap();
437        assert_delete_is_disabled_span(&body, "Cannot delete your own account");
438        // Edit must still render as an anchor — administrators can
439        // edit themselves, just not delete.
440        assert!(
441            body.contains(r#"<a href="/admin/users/42/edit""#),
442            "Edit button stays clickable on a self-view"
443        );
444    }
445
446    #[test]
447    fn user_view_last_developer_disables_delete() {
448        let t = Templates::new(None).unwrap();
449        let mut ctx = view_ctx_base();
450        ctx["is_last_developer"] = serde_json::Value::Bool(true);
451        ctx["can_delete"] = serde_json::Value::Bool(false);
452        let body = t.render("admin/user_view.html", &ctx).unwrap();
453        assert_delete_is_disabled_span(&body, "Cannot delete the last active developer");
454    }
455
456    /// The users list now navigates to the profile view (not edit).
457    /// Lock the invariant: every cell in every row carries a `.row-link`
458    /// anchor pointing to `/admin/users/:id/` — never to `/edit`. A
459    /// regression here would silently revert /h's UX shift.
460    #[test]
461    fn users_list_renders_row_clickable_links() {
462        let t = Templates::new(None).unwrap();
463        let ctx = serde_json::json!({
464            "page_title": "Users",
465            "users": [
466                { "id": 7, "email": "alice@example.com", "role": "staff",
467                  "is_active": true, "created_at": "2026-04-01" },
468                { "id": 9, "email": "bob@example.com", "role": "developer",
469                  "is_active": false, "created_at": "2026-04-02" },
470            ],
471            "csrf_token": "x",
472        });
473        let body = t.render("admin/users_list.html", &ctx).unwrap();
474
475        // The table must declare itself row-clickable (CSS hook).
476        assert!(body.contains(r#"class="results row-clickable""#));
477
478        // Each row → 4 anchors pointing at the profile, none at /edit.
479        let count_for = |needle: &str| body.matches(needle).count();
480        assert_eq!(
481            count_for(r#"href="/admin/users/7/""#),
482            4,
483            "every cell in row 7 must link to the profile view (4 anchors)"
484        );
485        assert_eq!(
486            count_for(r#"href="/admin/users/9/""#),
487            4,
488            "every cell in row 9 must link to the profile view (4 anchors)"
489        );
490        // Defensive: no /edit links should leak through from the
491        // pre-/h pattern.
492        assert_eq!(
493            count_for(r#"href="/admin/users/7/edit""#),
494            0,
495            "list rows must NOT link to /edit anymore — that lives behind the view"
496        );
497    }
498
499    #[test]
500    fn user_view_real_user_omits_demo_row() {
501        let t = Templates::new(None).unwrap();
502        // is_demo defaults to false in view_ctx_base.
503        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
504        // The demo label string only appears when target_is_demo=true.
505        // Looking for the demo-account marker that's only in the
506        // gated `{% if target_is_demo %}` block.
507        assert!(
508            !body.contains("badge-warning\">staff @"),
509            "demo badge must NOT render for a real (non-demo) user"
510        );
511        assert!(
512            !body.contains("Demo account"),
513            "demo label must NOT appear for a real user"
514        );
515
516        // Demo case: badge renders with the label.
517        let mut demo_ctx = view_ctx_base();
518        demo_ctx["target_is_demo"] = serde_json::Value::Bool(true);
519        demo_ctx["target_demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
520        let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
521        assert!(
522            demo_body.contains("staff @ rustio.local"),
523            "demo label must render in the badge for a demo user"
524        );
525    }
526
527    /// Companion to `…with_last_developer_banner`: when neither guard
528    /// fires, the submit button MUST be enabled (no `disabled`
529    /// attribute). Lock that invariant in too — a stray `{% if %}`
530    /// edit could otherwise quietly disable every confirm button.
531    #[test]
532    fn user_confirm_delete_submit_enabled_for_normal_user() {
533        let t = Templates::new(None).unwrap();
534        let ctx = serde_json::json!({
535            "user_id": 99,
536            "email": "throwaway@example.com",
537            "role": "staff",
538            "group_count": 0,
539            "session_count": 0,
540            "direct_perm_count": 0,
541            "is_self": false,
542            "is_last_developer": false,
543            "csrf_token": "test-csrf",
544        });
545        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
546        // Submit must render without `disabled`. The exact button
547        // markup includes an icon child (Phase 7a/2), so we assert
548        // on the contract — `<button type="submit" class="btn-danger">`
549        // present, and `disabled` NOT present anywhere on it.
550        assert!(
551            body.contains(r#"<button type="submit" class="btn-danger">"#),
552            "submit button must render with btn-danger class"
553        );
554        // Find the button's opening tag and assert no disabled attr
555        // sneaks in via a different code path.
556        assert!(
557            !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
558            "submit button must NOT render with `disabled` for a deletable user"
559        );
560        // And the warning banners must NOT appear.
561        assert!(!body.contains("is the last active developer"));
562        assert!(!body.contains("your own account"));
563    }
564
565    fn tempdir() -> PathBuf {
566        let pid = std::process::id();
567        let nonce: u64 = std::time::SystemTime::now()
568            .duration_since(std::time::UNIX_EPOCH)
569            .unwrap()
570            .as_nanos() as u64;
571        let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
572        std::fs::create_dir_all(&path).unwrap();
573        path
574    }
575}