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    ///
48    /// Phase 12/c-fix — when a disk root is supplied, the constructor
49    /// scans it once for overrides of embedded templates. Each match
50    /// is logged at INFO; an override that looks structurally
51    /// incomplete (no `{% extends %}`, no `{% block %}`, no `<html>`
52    /// tag) is logged at WARN so a one-line stub of `admin/base.html`
53    /// stops being a silent failure. Non-fatal: the override is still
54    /// served — the scan exists only to make the failure mode visible.
55    pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
56        let disk_root = project_templates_dir;
57        if let Some(root) = disk_root.as_deref() {
58            for v in validate_overrides(root) {
59                match v {
60                    OverrideValidation::Loaded { name, bytes } => {
61                        log::info!(
62                            "templates: project override loaded for `{name}` ({bytes} bytes)"
63                        );
64                    }
65                    OverrideValidation::Suspicious { name, bytes } => {
66                        log::warn!(
67                            "templates: project override for `{name}` looks incomplete \
68                             ({bytes} bytes, no `{{% extends %}}`, no `{{% block %}}`, no \
69                             `<html>` tag) — the admin UI may render incorrectly. Either \
70                             copy the framework default in full or remove the override."
71                        );
72                    }
73                    OverrideValidation::Unreadable { name, error } => {
74                        log::warn!(
75                            "templates: project override `{name}` exists but cannot be read: {error}"
76                        );
77                    }
78                    OverrideValidation::OrphanAdminFile { path } => {
79                        log::warn!(
80                            "templates: `{path}` is in the admin namespace but does not \
81                             override any embedded template (typo? framework default \
82                             will be served unchanged). Project-specific admin pages \
83                             belong outside `templates/admin/`."
84                        );
85                    }
86                }
87            }
88        }
89        let mut env = Environment::new();
90        env.set_loader(move |name| load_template(disk_root.as_deref(), name));
91
92        // Phase 7a/2 — `icon(name, class="...")` returns inline SVG
93        // for one of the lucide stroke icons baked at compile time.
94        // Templates use this to render sidebar nav icons, button
95        // icons, alert-banner glyphs without an extra HTTP round
96        // trip. See `admin/icons.rs` for the catalogue.
97        env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
98            let class: String = kwargs.get("class").unwrap_or_default();
99            kwargs.assert_all_used().ok();
100            // The output is HTML — minijinja's autoescape would mangle
101            // it. Wrap in `safe()` so it renders as markup.
102            minijinja::value::Value::from_safe_string(
103                crate::admin::icons::render_inline(name, &class),
104            )
105        });
106
107        Ok(Arc::new(Self {
108            env: Mutex::new(env),
109        }))
110    }
111
112    /// Render a template by name.
113    pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
114        let mut env = self
115            .env
116            .lock()
117            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
118        // Clear cache so the loader runs again — restart-free dev edits.
119        env.clear_templates();
120        let tmpl = env
121            .get_template(name)
122            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
123        tmpl.render(ctx)
124            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
125    }
126
127    /// Render with a per-model override hook.
128    ///
129    /// Tries `admin/<model>/<page>` first (where `<page>` is `name`
130    /// stripped of any leading `admin/`), falling back to `name`.
131    /// Phase 6a wires the API but no handler calls it yet — the
132    /// existing Phase 6a admin pages all call [`Self::render`].
133    #[allow(dead_code)]
134    pub fn render_for_model<S: Serialize>(
135        &self,
136        model: &str,
137        name: &str,
138        ctx: &S,
139    ) -> Result<String> {
140        let page = name.strip_prefix("admin/").unwrap_or(name);
141        let per_model = format!("admin/{model}/{page}");
142        let mut env = self
143            .env
144            .lock()
145            .map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
146        env.clear_templates();
147        // Try per-model first; fall through if loader returns None.
148        if let Ok(tmpl) = env.get_template(&per_model) {
149            return tmpl
150                .render(ctx)
151                .map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
152        }
153        let tmpl = env
154            .get_template(name)
155            .map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
156        tmpl.render(ctx)
157            .map_err(|e| Error::Internal(format!("render {name}: {e}")))
158    }
159}
160
161/// Phase 12/c-fix — outcome of inspecting one project override file at
162/// startup. Per-file, not per-render: the cost is paid once when the
163/// `Templates` arc is built, not on every request.
164///
165/// Pure data — `Templates::new` translates each variant to a log line.
166/// Returned as a `Vec` so unit tests can assert on the structural
167/// classification without scraping log output.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub(crate) enum OverrideValidation {
170    /// File loaded and contains at least one of `{% extends %}`,
171    /// `{% block %}`, or `<html`. Heuristic for "this looks like a
172    /// real template body".
173    Loaded { name: &'static str, bytes: usize },
174    /// File loaded but contains none of the structural markers.
175    /// Most likely a stub or a placeholder; the framework default is
176    /// silently being replaced by something that will not render the
177    /// admin UI correctly.
178    Suspicious { name: &'static str, bytes: usize },
179    /// File exists on disk but `read_to_string` failed
180    /// (permissions, encoding, IO error).
181    Unreadable { name: &'static str, error: String },
182    /// 1.8.1 — file in `templates/admin/` whose name does NOT match any
183    /// embedded template, i.e. it overrides nothing. Most likely a typo
184    /// (`baes.html` instead of `base.html`) — the developer thinks they
185    /// have an override but the framework just serves the embedded
186    /// default. This variant catches the inverted version of the
187    /// silent-failure bug `Suspicious` was added for.
188    OrphanAdminFile { path: String },
189}
190
191/// Phase 12/c-fix — walk `EMBEDDED_TEMPLATES`, classify any project
192/// override of one of those names, return the per-file results.
193///
194/// Files in `disk_root` that do NOT shadow an embedded name are
195/// ignored: those are project-only templates (e.g. `home.html`) and
196/// have no framework default to compare against. Only shadowing files
197/// can cause the silent-replacement failure mode this scan exists for.
198pub(crate) fn validate_overrides(disk_root: &std::path::Path) -> Vec<OverrideValidation> {
199    let mut results = Vec::new();
200    for (name, _embedded) in EMBEDDED_TEMPLATES {
201        let path = disk_root.join(name);
202        if !path.is_file() {
203            continue;
204        }
205        match std::fs::read_to_string(&path) {
206            Ok(body) => {
207                let bytes = body.len();
208                let has_structure = body.contains("{% extends")
209                    || body.contains("{% block")
210                    || body.contains("<html");
211                if has_structure {
212                    results.push(OverrideValidation::Loaded { name, bytes });
213                } else {
214                    results.push(OverrideValidation::Suspicious { name, bytes });
215                }
216            }
217            Err(e) => {
218                results.push(OverrideValidation::Unreadable {
219                    name,
220                    error: e.to_string(),
221                });
222            }
223        }
224    }
225
226    // 1.8.1 — orphan-admin-file scan. The framework reserves
227    // `templates/admin/*.html` for overrides of embedded admin
228    // templates. A file in that namespace whose name doesn't match
229    // any embedded template overrides nothing — usually a typo
230    // (`baes.html` for `base.html`) or a misunderstanding (project
231    // admin UI should live under the project's namespace, not under
232    // `templates/admin/`). Either way the developer's intent and the
233    // runtime's behaviour disagree silently; this scan logs a WARN
234    // so the disagreement becomes observable.
235    let admin_dir = disk_root.join("admin");
236    if admin_dir.is_dir() {
237        let known: std::collections::HashSet<&'static str> = EMBEDDED_TEMPLATES
238            .iter()
239            .filter_map(|(n, _)| n.strip_prefix("admin/"))
240            .collect();
241        if let Ok(entries) = std::fs::read_dir(&admin_dir) {
242            // Sort for deterministic ordering — the loop visits files in
243            // arbitrary FS order otherwise, which makes log lines and
244            // tests non-deterministic.
245            let mut files: Vec<_> = entries
246                .filter_map(|e| e.ok())
247                .filter(|e| {
248                    e.path()
249                        .extension()
250                        .and_then(|s| s.to_str())
251                        .map(|s| s.eq_ignore_ascii_case("html"))
252                        .unwrap_or(false)
253                })
254                .collect();
255            files.sort_by_key(|e| e.file_name());
256            for entry in files {
257                let file_name = entry.file_name();
258                let Some(stem_html) = file_name.to_str() else {
259                    continue;
260                };
261                if known.contains(stem_html) {
262                    continue;
263                }
264                results.push(OverrideValidation::OrphanAdminFile {
265                    path: format!("admin/{stem_html}"),
266                });
267            }
268        }
269    }
270
271    results
272}
273
274fn load_template(
275    disk_root: Option<&std::path::Path>,
276    name: &str,
277) -> std::result::Result<Option<String>, minijinja::Error> {
278    if let Some(root) = disk_root {
279        let path = root.join(name);
280        if path.exists() {
281            return std::fs::read_to_string(&path).map(Some).map_err(|e| {
282                minijinja::Error::new(
283                    ErrorKind::InvalidOperation,
284                    format!("read template {}: {e}", path.display()),
285                )
286            });
287        }
288    }
289    Ok(EMBEDDED_TEMPLATES
290        .iter()
291        .find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
292}
293
294// Baked into the binary. Single-binary deploy is a hard constraint.
295const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
296    ("base.html", include_str!("../assets/templates/base.html")),
297    ("admin/base.html", include_str!("../assets/templates/admin/base.html")),
298    ("admin/login.html", include_str!("../assets/templates/admin/login.html")),
299    ("admin/index.html", include_str!("../assets/templates/admin/index.html")),
300    ("admin/list.html", include_str!("../assets/templates/admin/list.html")),
301    ("admin/form.html", include_str!("../assets/templates/admin/form.html")),
302    ("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
303    ("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
304    ("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
305    ("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
306    ("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
307    ("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
308    ("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
309    ("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
310    ("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
311    ("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
312    ("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
313    ("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
314    ("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
315    ("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
316    ("admin/error.html", include_str!("../assets/templates/admin/error.html")),
317    ("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
318    ("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
319    ("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
320    ("search.html", include_str!("../assets/templates/search.html")),
321];
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use serde::Serialize;
327    use std::io::Write;
328
329    #[derive(Serialize)]
330    struct Empty {}
331
332    #[test]
333    fn loader_registers_all_embedded_templates() {
334        let t = Templates::new(None).unwrap();
335        assert!(t.render("base.html", &Empty {}).is_ok());
336    }
337
338    #[test]
339    fn missing_template_errors_cleanly() {
340        let t = Templates::new(None).unwrap();
341        let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
342        assert_eq!(err.status(), 500);
343    }
344
345    #[test]
346    fn disk_override_wins_over_embedded() {
347        let dir = tempdir();
348        let admin_dir = dir.join("admin");
349        std::fs::create_dir_all(&admin_dir).unwrap();
350        let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
351        f.write_all(b"OVERRIDDEN-BODY").unwrap();
352        drop(f);
353
354        let t = Templates::new(Some(dir.clone())).unwrap();
355        let body = t.render("admin/login.html", &Empty {}).unwrap();
356        assert_eq!(body, "OVERRIDDEN-BODY");
357
358        // Cleanup.
359        let _ = std::fs::remove_dir_all(&dir);
360    }
361
362    #[test]
363    fn embedded_fallback_when_disk_missing() {
364        let dir = tempdir();
365        // dir exists but contains no admin/login.html — embedded must win.
366        let t = Templates::new(Some(dir.clone())).unwrap();
367        let body = t.render("admin/login.html", &Empty {}).unwrap();
368        // Embedded login.html is never empty; reject if it returned the
369        // disk-override sentinel.
370        assert!(!body.is_empty());
371        assert!(!body.contains("OVERRIDDEN-BODY"));
372
373        let _ = std::fs::remove_dir_all(&dir);
374    }
375
376    // ----- Phase 12/c-fix — validate_overrides ---------------------------
377
378    /// Phase 12/c-fix — a faithful copy of an embedded admin template
379    /// (here, just "contains `{% extends %}`") classifies as Loaded.
380    /// The structural marker is enough; the content doesn't have to
381    /// match the upstream template byte-for-byte.
382    #[test]
383    fn validate_overrides_classifies_real_template_as_loaded() {
384        let dir = tempdir();
385        let admin_dir = dir.join("admin");
386        std::fs::create_dir_all(&admin_dir).unwrap();
387        std::fs::write(
388            admin_dir.join("login.html"),
389            "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
390        )
391        .unwrap();
392        let v = validate_overrides(&dir);
393        assert_eq!(v.len(), 1);
394        match &v[0] {
395            OverrideValidation::Loaded { name, bytes } => {
396                assert_eq!(*name, "admin/login.html");
397                assert!(*bytes > 0);
398            }
399            other => panic!("expected Loaded, got: {other:?}"),
400        }
401        let _ = std::fs::remove_dir_all(&dir);
402    }
403
404    /// Phase 12/c-fix — the bug we shipped pre-fix: a one-line stub of
405    /// admin/base.html silently destroys the admin UI. Validation must
406    /// flag it as Suspicious so the operator sees a WARN in the log.
407    #[test]
408    fn validate_overrides_flags_stub_admin_base_as_suspicious() {
409        let dir = tempdir();
410        let admin_dir = dir.join("admin");
411        std::fs::create_dir_all(&admin_dir).unwrap();
412        std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
413        let v = validate_overrides(&dir);
414        assert_eq!(v.len(), 1);
415        match &v[0] {
416            OverrideValidation::Suspicious { name, bytes } => {
417                assert_eq!(*name, "admin/base.html");
418                assert_eq!(*bytes, 13);
419            }
420            other => panic!("expected Suspicious, got: {other:?}"),
421        }
422        let _ = std::fs::remove_dir_all(&dir);
423    }
424
425    /// Phase 12/c-fix — a project-only template like `home.html` is NOT
426    /// in EMBEDDED_TEMPLATES, so it doesn't shadow a framework default
427    /// and must be silently ignored by the validator. Otherwise every
428    /// scaffolded project would emit warnings about its own home page.
429    #[test]
430    fn validate_overrides_ignores_project_only_templates() {
431        let dir = tempdir();
432        std::fs::write(dir.join("home.html"), "<h1>welcome</h1>").unwrap();
433        let v = validate_overrides(&dir);
434        assert!(
435            v.is_empty(),
436            "home.html shadows nothing, must not appear in validation: {v:?}"
437        );
438        let _ = std::fs::remove_dir_all(&dir);
439    }
440
441    /// Phase 12/c-fix — empty disk root (the common case for projects
442    /// that ship no overrides) returns an empty Vec without touching
443    /// the filesystem beyond `is_file` checks.
444    #[test]
445    fn validate_overrides_empty_dir_returns_empty_vec() {
446        let dir = tempdir();
447        let v = validate_overrides(&dir);
448        assert!(v.is_empty(), "no files ⇒ no validations: {v:?}");
449        let _ = std::fs::remove_dir_all(&dir);
450    }
451
452    /// Phase 12/c-fix — multiple overrides classify independently.
453    /// Order is the order of EMBEDDED_TEMPLATES (deterministic); test
454    /// asserts on the multi-set rather than the sequence.
455    #[test]
456    fn validate_overrides_handles_mixed_loaded_and_suspicious() {
457        let dir = tempdir();
458        let admin_dir = dir.join("admin");
459        std::fs::create_dir_all(&admin_dir).unwrap();
460        // A real-looking override of login.html.
461        std::fs::write(
462            admin_dir.join("login.html"),
463            "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
464        )
465        .unwrap();
466        // And a stub of base.html.
467        std::fs::write(admin_dir.join("base.html"), "<h1>TEST</h1>").unwrap();
468        let v = validate_overrides(&dir);
469        assert_eq!(v.len(), 2, "both files must be classified: {v:?}");
470        let suspicious_count = v
471            .iter()
472            .filter(|x| matches!(x, OverrideValidation::Suspicious { .. }))
473            .count();
474        let loaded_count = v
475            .iter()
476            .filter(|x| matches!(x, OverrideValidation::Loaded { .. }))
477            .count();
478        assert_eq!(suspicious_count, 1, "exactly one Suspicious: {v:?}");
479        assert_eq!(loaded_count, 1, "exactly one Loaded: {v:?}");
480        let _ = std::fs::remove_dir_all(&dir);
481    }
482
483    /// 1.8.1 — a file in `templates/admin/` whose name doesn't match
484    /// any embedded template (e.g. typo `baes.html` for `base.html`)
485    /// produces no override but the developer thinks it does. The
486    /// orphan-admin-file variant catches this inverted-typo case.
487    #[test]
488    fn validate_overrides_flags_orphan_admin_file() {
489        let dir = tempdir();
490        let admin_dir = dir.join("admin");
491        std::fs::create_dir_all(&admin_dir).unwrap();
492        // Typo for "base.html". Looks like an override, isn't.
493        std::fs::write(
494            admin_dir.join("baes.html"),
495            "{% extends \"admin/base.html\" %}\n{% block content %}hi{% endblock %}",
496        )
497        .unwrap();
498        let v = validate_overrides(&dir);
499        assert_eq!(v.len(), 1, "exactly one orphan: {v:?}");
500        match &v[0] {
501            OverrideValidation::OrphanAdminFile { path } => {
502                assert_eq!(path, "admin/baes.html");
503            }
504            other => panic!("expected OrphanAdminFile, got: {other:?}"),
505        }
506        let _ = std::fs::remove_dir_all(&dir);
507    }
508
509    /// 1.8.1 — a real override and an orphan in the same admin/ dir
510    /// classify independently. Real override gets Loaded, typo gets
511    /// OrphanAdminFile. Order of results is sorted by file name for
512    /// determinism, so we can index into the Vec.
513    #[test]
514    fn validate_overrides_handles_real_and_orphan_in_same_admin_dir() {
515        let dir = tempdir();
516        let admin_dir = dir.join("admin");
517        std::fs::create_dir_all(&admin_dir).unwrap();
518        std::fs::write(
519            admin_dir.join("base.html"),
520            "{% block content %}real override{% endblock %}",
521        )
522        .unwrap();
523        std::fs::write(admin_dir.join("typo.html"), "<h1>oops</h1>").unwrap();
524        let v = validate_overrides(&dir);
525        let loaded_count = v
526            .iter()
527            .filter(|x| matches!(x, OverrideValidation::Loaded { name, .. } if *name == "admin/base.html"))
528            .count();
529        let orphan_count = v
530            .iter()
531            .filter(|x| matches!(x, OverrideValidation::OrphanAdminFile { path } if path == "admin/typo.html"))
532            .count();
533        assert_eq!(loaded_count, 1, "real override must be Loaded: {v:?}");
534        assert_eq!(orphan_count, 1, "typo must be OrphanAdminFile: {v:?}");
535        let _ = std::fs::remove_dir_all(&dir);
536    }
537
538    /// 1.8.1 — non-html files in `templates/admin/` are skipped (so a
539    /// `.gitkeep` or `.DS_Store` doesn't produce an orphan warning).
540    #[test]
541    fn validate_overrides_orphan_scan_skips_non_html() {
542        let dir = tempdir();
543        let admin_dir = dir.join("admin");
544        std::fs::create_dir_all(&admin_dir).unwrap();
545        std::fs::write(admin_dir.join(".gitkeep"), "").unwrap();
546        std::fs::write(admin_dir.join("notes.txt"), "draft").unwrap();
547        let v = validate_overrides(&dir);
548        assert!(
549            v.is_empty(),
550            "non-html files in admin/ must not produce orphans: {v:?}"
551        );
552        let _ = std::fs::remove_dir_all(&dir);
553    }
554
555    #[test]
556    fn live_edit_visible_on_next_render_without_restart() {
557        // The win of the loader refactor: edit a template on disk, the
558        // next render reflects it — no Templates rebuild, no restart.
559        let dir = tempdir();
560        let admin_dir = dir.join("admin");
561        std::fs::create_dir_all(&admin_dir).unwrap();
562        let target = admin_dir.join("login.html");
563
564        std::fs::write(&target, b"V1").unwrap();
565        let t = Templates::new(Some(dir.clone())).unwrap();
566        assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
567
568        // Edit in place.
569        std::fs::write(&target, b"V2").unwrap();
570        assert_eq!(
571            t.render("admin/login.html", &Empty {}).unwrap(),
572            "V2",
573            "loader must re-resolve from disk on every render"
574        );
575
576        let _ = std::fs::remove_dir_all(&dir);
577    }
578
579    /// Phase 7a/0.5/f-fix regression: the embedded loader must know
580    /// about `admin/user_confirm_delete.html`. The browser smoke run
581    /// of /f hit a 500 because the template existed on disk but the
582    /// EMBEDDED_TEMPLATES const didn't list it. This test renders the
583    /// template with two distinct contexts and asserts the
584    /// last-developer banner + the submit-button disabled/enabled
585    /// invariant — so any future delete-handler refactor that adds a
586    /// new template can't ship the same gap unnoticed.
587    #[test]
588    fn user_confirm_delete_renders_with_last_developer_banner() {
589        let t = Templates::new(None).unwrap();
590
591        // Last-developer case → blocking banner + disabled submit.
592        let ctx = serde_json::json!({
593            "user_id": 122,
594            "email": "backup@example.com",
595            "role": "developer",
596            "group_count": 0,
597            "session_count": 1,
598            "direct_perm_count": 0,
599            "is_self": false,
600            "is_last_developer": true,
601            "csrf_token": "test-csrf",
602        });
603        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
604        assert!(
605            body.contains("is the last active developer"),
606            "last-dev banner must mention the orphan condition"
607        );
608        assert!(
609            body.contains("rustio-cli user role set"),
610            "last-dev banner must point operators at the CLI escape hatch"
611        );
612        assert!(
613            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
614            "submit must be disabled when target is the last active developer"
615        );
616
617        // Self-delete case → different errornote, also disabled.
618        let ctx_self = serde_json::json!({
619            "user_id": 7,
620            "email": "me@rustio.local",
621            "role": "administrator",
622            "group_count": 2,
623            "session_count": 1,
624            "direct_perm_count": 0,
625            "is_self": true,
626            "is_last_developer": false,
627            "csrf_token": "test-csrf",
628        });
629        let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
630        assert!(
631            body.contains("your own account"),
632            "self-delete banner must call out the self-action"
633        );
634        assert!(
635            body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
636            "submit must be disabled on self-delete"
637        );
638    }
639
640    // ------------------------------------------------------------------
641    // Phase 7a/0.5/h — admin/user_view.html
642    //
643    // Five render tests covering the load-bearing template branches:
644    // - groups list populated vs empty
645    // - is_self / is_last_developer disable the Delete button
646    // - is_demo absent → no demo row
647    //
648    // All run as sandbox tests (no DB) because the template only
649    // depends on the context dict — perfect for catching template
650    // typos + missing variables without needing postgres.
651    // ------------------------------------------------------------------
652
653    /// Phase 10/b — base context fixture for `admin/user_view.html`.
654    /// Matches the `UserViewCtx` shape in `admin/builtin.rs`. Tests
655    /// override individual fields via `ctx[...] = …`.
656    fn view_ctx_base() -> serde_json::Value {
657        serde_json::json!({
658            "csrf_token": "test-csrf",
659            "user": {
660                "id": 42,
661                "email": "alice@example.com",
662                "full_name": "Alice",
663                "full_name_value": null,
664                "role": "Staff",
665                "is_admin": false,
666                "is_developer": false,
667                "is_active": true,
668                "is_demo": false,
669                "demo_label": null,
670                "locale": null,
671                "timezone": null,
672                "created_at_iso": "2026-04-25 12:00 UTC",
673                "last_seen_relative": "just now",
674                "last_login_iso": "2026-04-25 11:50 UTC",
675                "groups": [],
676            },
677            "users": [],
678            "total": 1,
679            "activity_count": 0,
680            "permission_count": 0,
681            "session_count": 0,
682            "tab": "overview",
683            "recent_events": [],
684            "activity_page": 1,
685            "activity_total_pages": 1,
686            "permissions": [],
687            "sessions": [],
688            "project_fields": [],
689            "can_edit": true,
690        })
691    }
692
693    /// Phase 7a/2 — the `icon()` minijinja function is registered in
694    /// `Templates::new`. A template can call `{{ icon("home", class="w-4 h-4") }}`
695    /// and the inline SVG is emitted unescaped (because we wrap it in
696    /// `Value::from_safe_string`). Lock that contract.
697    #[test]
698    fn icon_function_emits_inline_svg() {
699        // Write a temp template that exercises icon() and render it.
700        let dir = tempdir();
701        let admin_dir = dir.join("admin");
702        std::fs::create_dir_all(&admin_dir).unwrap();
703        std::fs::write(
704            admin_dir.join("icon_test.html"),
705            r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
706        )
707        .unwrap();
708
709        let t = Templates::new(Some(dir.clone())).unwrap();
710        let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
711        assert!(
712            body.contains("<svg"),
713            "icon() must emit raw <svg> markup, not escape it"
714        );
715        assert!(body.contains(r#"class="sidebar-icon""#));
716        assert!(body.contains(r#"viewBox="0 0 24 24""#));
717        assert!(body.contains(r#"stroke="currentColor""#));
718
719        // Unknown icon name → empty string, page still renders.
720        std::fs::write(
721            admin_dir.join("icon_missing.html"),
722            r#"<span>{{ icon("not-real") }}</span>"#,
723        )
724        .unwrap();
725        let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
726        assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
727
728        let _ = std::fs::remove_dir_all(&dir);
729    }
730
731    /// Phase 10/b — Overview tab renders the splitview shell + show-grid
732    /// profile + recent-activity timeline. Groups appear as `<code>` chips
733    /// in the show-grid Groups row.
734    #[test]
735    fn user_view_overview_renders_with_groups() {
736        let t = Templates::new(None).unwrap();
737        let mut ctx = view_ctx_base();
738        ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
739        let body = t.render("admin/user_view.html", &ctx).unwrap();
740
741        // Splitview shell + Overview branch markers.
742        assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
743        assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
744        assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
745
746        // Groups row content — chips appear inside the show-grid.
747        assert!(body.contains("<code>Auditors</code>"));
748        assert!(body.contains("<code>Content Editors</code>"));
749    }
750
751    /// Phase 10/b — empty groups render the muted "No groups" inline
752    /// in the Groups row of the show-grid.
753    #[test]
754    fn user_view_overview_without_groups_shows_empty_marker() {
755        let t = Templates::new(None).unwrap();
756        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
757        assert!(
758            body.contains("No groups"),
759            "empty-groups copy must appear in the show-grid Groups row"
760        );
761    }
762
763    /// Phase 10/b — Activity tab renders the timeline component and a
764    /// pager when there's more than one page. Inline-Delete contract
765    /// from the pre-10/b template is intentionally gone; the destructive
766    /// path lives behind `/admin/users/:id/delete` exclusively.
767    #[test]
768    fn user_view_activity_tab_renders_pager() {
769        let t = Templates::new(None).unwrap();
770        let mut ctx = view_ctx_base();
771        ctx["tab"] = serde_json::json!("activity");
772        ctx["activity_count"] = serde_json::json!(120);
773        ctx["activity_page"] = serde_json::json!(2);
774        ctx["activity_total_pages"] = serde_json::json!(3);
775        ctx["recent_events"] = serde_json::json!([
776            { "id": 1, "kind": "info",    "message": "Updated <strong>posts</strong> #5",
777              "timestamp_relative": "1h ago", "actor": "user:42" },
778            { "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
779              "timestamp_relative": "2h ago", "actor": "user:42" },
780        ]);
781        let body = t.render("admin/user_view.html", &ctx).unwrap();
782
783        assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
784        assert!(body.contains("tl-info"), "event kind must drive the dot color class");
785        assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
786        assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
787        assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
788        // Tab links must NOT carry &page=… (per Phase 10/b spec — tab clicks reset page).
789        assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
790        assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
791        assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
792    }
793
794    /// Phase 10/b — Permissions tab renders direct + inherited grants
795    /// with a source chip on each tile.
796    #[test]
797    fn user_view_permissions_tab_renders_with_sources() {
798        let t = Templates::new(None).unwrap();
799        let mut ctx = view_ctx_base();
800        ctx["tab"] = serde_json::json!("permissions");
801        ctx["permissions"] = serde_json::json!([
802            { "name": "posts.add_post",    "source": "direct" },
803            { "name": "posts.change_post", "source": "via Editors" },
804        ]);
805        let body = t.render("admin/user_view.html", &ctx).unwrap();
806
807        assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
808        assert!(body.contains("posts.add_post"));
809        assert!(body.contains("posts.change_post"));
810        assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
811        assert!(body.contains("via Editors"), "inherited grant must show the source group");
812    }
813
814    /// Phase 10/b — Sessions tab renders the table; absent IP / UA
815    /// columns render as the framework's "—" cell-empty marker. The
816    /// session token is truncated to its first 7 chars.
817    #[test]
818    fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
819        let t = Templates::new(None).unwrap();
820        let mut ctx = view_ctx_base();
821        ctx["tab"] = serde_json::json!("sessions");
822        ctx["sessions"] = serde_json::json!([
823            {
824                "token_short": "abc1234",
825                "created_at_iso": "2026-05-01 10:00 UTC",
826                "last_seen_relative": "5m ago",
827                "ip": null,
828                "user_agent": null,
829            },
830        ]);
831        let body = t.render("admin/user_view.html", &ctx).unwrap();
832
833        assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
834        assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
835        // Null IP / UA fall back to the framework's empty-cell marker.
836        assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
837    }
838
839    /// The users list now navigates to the profile view (not edit).
840    /// Lock the invariant: every cell in every row carries a `.row-link`
841    /// anchor pointing to `/admin/users/:id/` — never to `/edit`. A
842    /// regression here would silently revert /h's UX shift.
843    #[test]
844    fn users_list_renders_row_clickable_links() {
845        let t = Templates::new(None).unwrap();
846        let ctx = serde_json::json!({
847            "page_title": "Users",
848            "users": [
849                { "id": 7, "email": "alice@example.com", "role": "staff",
850                  "is_active": true, "created_at": "2026-04-01" },
851                { "id": 9, "email": "bob@example.com", "role": "developer",
852                  "is_active": false, "created_at": "2026-04-02" },
853            ],
854            "csrf_token": "x",
855        });
856        let body = t.render("admin/users_list.html", &ctx).unwrap();
857
858        // The table must declare itself row-clickable (CSS hook).
859        assert!(body.contains(r#"class="results row-clickable""#));
860
861        // Each row → 4 anchors pointing at the profile, none at /edit.
862        let count_for = |needle: &str| body.matches(needle).count();
863        assert_eq!(
864            count_for(r#"href="/admin/users/7/""#),
865            4,
866            "every cell in row 7 must link to the profile view (4 anchors)"
867        );
868        assert_eq!(
869            count_for(r#"href="/admin/users/9/""#),
870            4,
871            "every cell in row 9 must link to the profile view (4 anchors)"
872        );
873        // Defensive: no /edit links should leak through from the
874        // pre-/h pattern.
875        assert_eq!(
876            count_for(r#"href="/admin/users/7/edit""#),
877            0,
878            "list rows must NOT link to /edit anymore — that lives behind the view"
879        );
880    }
881
882    /// Phase 10/c — when the project registers a `user_profile_extension`,
883    /// the closure's `Vec<UserProfileSection>` lands in `project_fields`
884    /// and the default `{% block project_user_fields %}` renders each
885    /// section as a labeled show-grid. Empty `project_fields` must
886    /// produce no extra markup — no orphan headings, no empty divs.
887    #[test]
888    fn user_view_overview_renders_project_fields_section() {
889        let t = Templates::new(None).unwrap();
890        let mut ctx = view_ctx_base();
891        ctx["project_fields"] = serde_json::json!([
892            {
893                "label": "Halal certification",
894                "rows": [
895                    { "label": "Certified by", "value": "ICCV Halal Authority" },
896                    { "label": "License #",    "value": "HC-2025-0042" },
897                ],
898            },
899        ]);
900        let body = t.render("admin/user_view.html", &ctx).unwrap();
901
902        assert!(body.contains("Halal certification"), "section label must render");
903        assert!(body.contains("Certified by"));
904        assert!(body.contains("ICCV Halal Authority"));
905        assert!(body.contains("License #"));
906        assert!(body.contains("HC-2025-0042"));
907    }
908
909    /// Phase 10/c — empty project_fields must produce no extra section
910    /// markup. Zero-config projects (no extension registered) get a
911    /// clean Overview tab with no orphan headings.
912    #[test]
913    fn user_view_overview_omits_extension_when_project_fields_empty() {
914        let t = Templates::new(None).unwrap();
915        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
916        // Fixture has project_fields=[] — no project label should appear.
917        // Spot-check a few labels that appear only if any section rendered.
918        assert!(
919            !body.contains("Halal certification"),
920            "no extension means no project section heading"
921        );
922    }
923
924    /// Phase 10/b — demo flag drives a yellow badge in the identity
925    /// strip of the detail-head; the badge must NOT render for a real
926    /// user, and MUST include the demo_label when one is set.
927    #[test]
928    fn user_view_demo_badge_renders_only_for_demo_users() {
929        let t = Templates::new(None).unwrap();
930        // Real (non-demo) user: no DEMO badge.
931        let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
932        assert!(
933            !body.contains(">DEMO"),
934            "DEMO badge must NOT render for a real user"
935        );
936
937        // Demo case: badge renders with the label.
938        let mut demo_ctx = view_ctx_base();
939        demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
940        demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
941        let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
942        assert!(
943            demo_body.contains(">DEMO"),
944            "DEMO badge must render for a demo user"
945        );
946        assert!(
947            demo_body.contains("staff @ rustio.local"),
948            "demo label must appear in the badge"
949        );
950    }
951
952    /// Companion to `…with_last_developer_banner`: when neither guard
953    /// fires, the submit button MUST be enabled (no `disabled`
954    /// attribute). Lock that invariant in too — a stray `{% if %}`
955    /// edit could otherwise quietly disable every confirm button.
956    #[test]
957    fn user_confirm_delete_submit_enabled_for_normal_user() {
958        let t = Templates::new(None).unwrap();
959        let ctx = serde_json::json!({
960            "user_id": 99,
961            "email": "throwaway@example.com",
962            "role": "staff",
963            "group_count": 0,
964            "session_count": 0,
965            "direct_perm_count": 0,
966            "is_self": false,
967            "is_last_developer": false,
968            "csrf_token": "test-csrf",
969        });
970        let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
971        // Submit must render without `disabled`. The exact button
972        // markup includes an icon child (Phase 7a/2), so we assert
973        // on the contract — `<button type="submit" class="btn-danger">`
974        // present, and `disabled` NOT present anywhere on it.
975        assert!(
976            body.contains(r#"<button type="submit" class="btn-danger">"#),
977            "submit button must render with btn-danger class"
978        );
979        // Find the button's opening tag and assert no disabled attr
980        // sneaks in via a different code path.
981        assert!(
982            !body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
983            "submit button must NOT render with `disabled` for a deletable user"
984        );
985        // And the warning banners must NOT appear.
986        assert!(!body.contains("is the last active developer"));
987        assert!(!body.contains("your own account"));
988    }
989
990    fn tempdir() -> PathBuf {
991        let pid = std::process::id();
992        let nonce: u64 = std::time::SystemTime::now()
993            .duration_since(std::time::UNIX_EPOCH)
994            .unwrap()
995            .as_nanos() as u64;
996        let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
997        std::fs::create_dir_all(&path).unwrap();
998        path
999    }
1000}