use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use minijinja::{Environment, ErrorKind};
use serde::Serialize;
use crate::error::{Error, Result};
pub struct Templates {
env: Mutex<Environment<'static>>,
}
impl Templates {
pub fn new(project_templates_dir: Option<PathBuf>) -> Result<Arc<Self>> {
let disk_root = project_templates_dir;
let mut env = Environment::new();
env.set_loader(move |name| load_template(disk_root.as_deref(), name));
env.add_function("icon", |name: &str, kwargs: minijinja::value::Kwargs| {
let class: String = kwargs.get("class").unwrap_or_default();
kwargs.assert_all_used().ok();
minijinja::value::Value::from_safe_string(
crate::admin::icons::render_inline(name, &class),
)
});
Ok(Arc::new(Self {
env: Mutex::new(env),
}))
}
pub fn render<S: Serialize>(&self, name: &str, ctx: &S) -> Result<String> {
let mut env = self
.env
.lock()
.map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
env.clear_templates();
let tmpl = env
.get_template(name)
.map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
tmpl.render(ctx)
.map_err(|e| Error::Internal(format!("render {name}: {e}")))
}
#[allow(dead_code)]
pub fn render_for_model<S: Serialize>(
&self,
model: &str,
name: &str,
ctx: &S,
) -> Result<String> {
let page = name.strip_prefix("admin/").unwrap_or(name);
let per_model = format!("admin/{model}/{page}");
let mut env = self
.env
.lock()
.map_err(|e| Error::Internal(format!("template env poisoned: {e}")))?;
env.clear_templates();
if let Ok(tmpl) = env.get_template(&per_model) {
return tmpl
.render(ctx)
.map_err(|e| Error::Internal(format!("render {per_model}: {e}")));
}
let tmpl = env
.get_template(name)
.map_err(|e| Error::Internal(format!("template {name} not found: {e}")))?;
tmpl.render(ctx)
.map_err(|e| Error::Internal(format!("render {name}: {e}")))
}
}
fn load_template(
disk_root: Option<&std::path::Path>,
name: &str,
) -> std::result::Result<Option<String>, minijinja::Error> {
if let Some(root) = disk_root {
let path = root.join(name);
if path.exists() {
return std::fs::read_to_string(&path).map(Some).map_err(|e| {
minijinja::Error::new(
ErrorKind::InvalidOperation,
format!("read template {}: {e}", path.display()),
)
});
}
}
Ok(EMBEDDED_TEMPLATES
.iter()
.find_map(|(n, b)| if *n == name { Some((*b).to_string()) } else { None }))
}
const EMBEDDED_TEMPLATES: &[(&str, &str)] = &[
("base.html", include_str!("../assets/templates/base.html")),
("admin/base.html", include_str!("../assets/templates/admin/base.html")),
("admin/login.html", include_str!("../assets/templates/admin/login.html")),
("admin/index.html", include_str!("../assets/templates/admin/index.html")),
("admin/list.html", include_str!("../assets/templates/admin/list.html")),
("admin/form.html", include_str!("../assets/templates/admin/form.html")),
("admin/confirm_delete.html", include_str!("../assets/templates/admin/confirm_delete.html")),
("admin/object_history.html", include_str!("../assets/templates/admin/object_history.html")),
("admin/log_entries.html", include_str!("../assets/templates/admin/log_entries.html")),
("admin/password_change.html", include_str!("../assets/templates/admin/password_change.html")),
("admin/users_list.html", include_str!("../assets/templates/admin/users_list.html")),
("admin/user_edit.html", include_str!("../assets/templates/admin/user_edit.html")),
("admin/user_new.html", include_str!("../assets/templates/admin/user_new.html")),
("admin/user_view.html", include_str!("../assets/templates/admin/user_view.html")),
("admin/user_confirm_delete.html", include_str!("../assets/templates/admin/user_confirm_delete.html")),
("admin/groups_list.html", include_str!("../assets/templates/admin/groups_list.html")),
("admin/group_edit.html", include_str!("../assets/templates/admin/group_edit.html")),
("admin/group_new.html", include_str!("../assets/templates/admin/group_new.html")),
("admin/group_confirm_delete.html", include_str!("../assets/templates/admin/group_confirm_delete.html")),
("admin/forbidden.html", include_str!("../assets/templates/admin/forbidden.html")),
("admin/error.html", include_str!("../assets/templates/admin/error.html")),
("admin/coming_soon.html", include_str!("../assets/templates/admin/coming_soon.html")),
("admin/includes/_field_errors.html", include_str!("../assets/templates/admin/includes/_field_errors.html")),
("admin/includes/_form_field.html", include_str!("../assets/templates/admin/includes/_form_field.html")),
("search.html", include_str!("../assets/templates/search.html")),
];
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
use std::io::Write;
#[derive(Serialize)]
struct Empty {}
#[test]
fn loader_registers_all_embedded_templates() {
let t = Templates::new(None).unwrap();
assert!(t.render("base.html", &Empty {}).is_ok());
}
#[test]
fn missing_template_errors_cleanly() {
let t = Templates::new(None).unwrap();
let err = t.render("does/not/exist.html", &Empty {}).unwrap_err();
assert_eq!(err.status(), 500);
}
#[test]
fn disk_override_wins_over_embedded() {
let dir = tempdir();
let admin_dir = dir.join("admin");
std::fs::create_dir_all(&admin_dir).unwrap();
let mut f = std::fs::File::create(admin_dir.join("login.html")).unwrap();
f.write_all(b"OVERRIDDEN-BODY").unwrap();
drop(f);
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t.render("admin/login.html", &Empty {}).unwrap();
assert_eq!(body, "OVERRIDDEN-BODY");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn embedded_fallback_when_disk_missing() {
let dir = tempdir();
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t.render("admin/login.html", &Empty {}).unwrap();
assert!(!body.is_empty());
assert!(!body.contains("OVERRIDDEN-BODY"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn live_edit_visible_on_next_render_without_restart() {
let dir = tempdir();
let admin_dir = dir.join("admin");
std::fs::create_dir_all(&admin_dir).unwrap();
let target = admin_dir.join("login.html");
std::fs::write(&target, b"V1").unwrap();
let t = Templates::new(Some(dir.clone())).unwrap();
assert_eq!(t.render("admin/login.html", &Empty {}).unwrap(), "V1");
std::fs::write(&target, b"V2").unwrap();
assert_eq!(
t.render("admin/login.html", &Empty {}).unwrap(),
"V2",
"loader must re-resolve from disk on every render"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn user_confirm_delete_renders_with_last_developer_banner() {
let t = Templates::new(None).unwrap();
let ctx = serde_json::json!({
"user_id": 122,
"email": "backup@example.com",
"role": "developer",
"group_count": 0,
"session_count": 1,
"direct_perm_count": 0,
"is_self": false,
"is_last_developer": true,
"csrf_token": "test-csrf",
});
let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
assert!(
body.contains("is the last active developer"),
"last-dev banner must mention the orphan condition"
);
assert!(
body.contains("rustio-cli user role set"),
"last-dev banner must point operators at the CLI escape hatch"
);
assert!(
body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
"submit must be disabled when target is the last active developer"
);
let ctx_self = serde_json::json!({
"user_id": 7,
"email": "me@rustio.local",
"role": "administrator",
"group_count": 2,
"session_count": 1,
"direct_perm_count": 0,
"is_self": true,
"is_last_developer": false,
"csrf_token": "test-csrf",
});
let body = t.render("admin/user_confirm_delete.html", &ctx_self).unwrap();
assert!(
body.contains("your own account"),
"self-delete banner must call out the self-action"
);
assert!(
body.contains(r#"<button type="submit" class="btn-danger" disabled>"#),
"submit must be disabled on self-delete"
);
}
fn view_ctx_base() -> serde_json::Value {
serde_json::json!({
"csrf_token": "test-csrf",
"user": {
"id": 42,
"email": "alice@example.com",
"full_name": "Alice",
"full_name_value": null,
"role": "Staff",
"is_admin": false,
"is_developer": false,
"is_active": true,
"is_demo": false,
"demo_label": null,
"locale": null,
"timezone": null,
"created_at_iso": "2026-04-25 12:00 UTC",
"last_seen_relative": "just now",
"last_login_iso": "2026-04-25 11:50 UTC",
"groups": [],
},
"users": [],
"total": 1,
"activity_count": 0,
"permission_count": 0,
"session_count": 0,
"tab": "overview",
"recent_events": [],
"activity_page": 1,
"activity_total_pages": 1,
"permissions": [],
"sessions": [],
"project_fields": [],
"can_edit": true,
})
}
#[test]
fn icon_function_emits_inline_svg() {
let dir = tempdir();
let admin_dir = dir.join("admin");
std::fs::create_dir_all(&admin_dir).unwrap();
std::fs::write(
admin_dir.join("icon_test.html"),
r#"<div>{{ icon("home", class="sidebar-icon") }}</div>"#,
)
.unwrap();
let t = Templates::new(Some(dir.clone())).unwrap();
let body = t.render("admin/icon_test.html", &Empty {}).unwrap();
assert!(
body.contains("<svg"),
"icon() must emit raw <svg> markup, not escape it"
);
assert!(body.contains(r#"class="sidebar-icon""#));
assert!(body.contains(r#"viewBox="0 0 24 24""#));
assert!(body.contains(r#"stroke="currentColor""#));
std::fs::write(
admin_dir.join("icon_missing.html"),
r#"<span>{{ icon("not-real") }}</span>"#,
)
.unwrap();
let body = t.render("admin/icon_missing.html", &Empty {}).unwrap();
assert_eq!(body.trim(), "<span></span>", "missing icon must be silent");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn user_view_overview_renders_with_groups() {
let t = Templates::new(None).unwrap();
let mut ctx = view_ctx_base();
ctx["user"]["groups"] = serde_json::json!(["Auditors", "Content Editors"]);
let body = t.render("admin/user_view.html", &ctx).unwrap();
assert!(body.contains("class=\"splitview\""), "must render the splitview shell");
assert!(body.contains("class=\"show-grid\""), "Overview must render the show-grid");
assert!(body.contains("class=\"stat-strip\""), "Overview must render the stat-strip");
assert!(body.contains("<code>Auditors</code>"));
assert!(body.contains("<code>Content Editors</code>"));
}
#[test]
fn user_view_overview_without_groups_shows_empty_marker() {
let t = Templates::new(None).unwrap();
let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
assert!(
body.contains("No groups"),
"empty-groups copy must appear in the show-grid Groups row"
);
}
#[test]
fn user_view_activity_tab_renders_pager() {
let t = Templates::new(None).unwrap();
let mut ctx = view_ctx_base();
ctx["tab"] = serde_json::json!("activity");
ctx["activity_count"] = serde_json::json!(120);
ctx["activity_page"] = serde_json::json!(2);
ctx["activity_total_pages"] = serde_json::json!(3);
ctx["recent_events"] = serde_json::json!([
{ "id": 1, "kind": "info", "message": "Updated <strong>posts</strong> #5",
"timestamp_relative": "1h ago", "actor": "user:42" },
{ "id": 2, "kind": "success", "message": "Created <strong>posts</strong> #5",
"timestamp_relative": "2h ago", "actor": "user:42" },
]);
let body = t.render("admin/user_view.html", &ctx).unwrap();
assert!(body.contains("class=\"timeline\""), "Activity must render the timeline component");
assert!(body.contains("tl-info"), "event kind must drive the dot color class");
assert!(body.contains("class=\"pager\""), "Activity must render the pager when total_pages > 1");
assert!(body.contains("?tab=activity&page=1"), "pager must link Prev to page-1");
assert!(body.contains("?tab=activity&page=3"), "pager must link Next to page+1");
assert!(!body.contains("?tab=overview&page="), "Overview tab link must strip page param");
assert!(!body.contains("?tab=permissions&page="), "Permissions tab link must strip page param");
assert!(!body.contains("?tab=sessions&page="), "Sessions tab link must strip page param");
}
#[test]
fn user_view_permissions_tab_renders_with_sources() {
let t = Templates::new(None).unwrap();
let mut ctx = view_ctx_base();
ctx["tab"] = serde_json::json!("permissions");
ctx["permissions"] = serde_json::json!([
{ "name": "posts.add_post", "source": "direct" },
{ "name": "posts.change_post", "source": "via Editors" },
]);
let body = t.render("admin/user_view.html", &ctx).unwrap();
assert!(body.contains("class=\"perm-grid\""), "Permissions must render perm-grid");
assert!(body.contains("posts.add_post"));
assert!(body.contains("posts.change_post"));
assert!(body.contains("direct"), "direct grant must show the 'direct' source chip");
assert!(body.contains("via Editors"), "inherited grant must show the source group");
}
#[test]
fn user_view_sessions_tab_truncates_token_and_handles_nulls() {
let t = Templates::new(None).unwrap();
let mut ctx = view_ctx_base();
ctx["tab"] = serde_json::json!("sessions");
ctx["sessions"] = serde_json::json!([
{
"token_short": "abc1234",
"created_at_iso": "2026-05-01 10:00 UTC",
"last_seen_relative": "5m ago",
"ip": null,
"user_agent": null,
},
]);
let body = t.render("admin/user_view.html", &ctx).unwrap();
assert!(body.contains("<table class=\"table\""), "Sessions must render the table");
assert!(body.contains("<code>abc1234</code>"), "token must render truncated, never full-length");
assert!(body.contains("rio-cell-empty"), "absent IP / UA must render as the empty marker");
}
#[test]
fn users_list_renders_row_clickable_links() {
let t = Templates::new(None).unwrap();
let ctx = serde_json::json!({
"page_title": "Users",
"users": [
{ "id": 7, "email": "alice@example.com", "role": "staff",
"is_active": true, "created_at": "2026-04-01" },
{ "id": 9, "email": "bob@example.com", "role": "developer",
"is_active": false, "created_at": "2026-04-02" },
],
"csrf_token": "x",
});
let body = t.render("admin/users_list.html", &ctx).unwrap();
assert!(body.contains(r#"class="results row-clickable""#));
let count_for = |needle: &str| body.matches(needle).count();
assert_eq!(
count_for(r#"href="/admin/users/7/""#),
4,
"every cell in row 7 must link to the profile view (4 anchors)"
);
assert_eq!(
count_for(r#"href="/admin/users/9/""#),
4,
"every cell in row 9 must link to the profile view (4 anchors)"
);
assert_eq!(
count_for(r#"href="/admin/users/7/edit""#),
0,
"list rows must NOT link to /edit anymore — that lives behind the view"
);
}
#[test]
fn user_view_overview_renders_project_fields_section() {
let t = Templates::new(None).unwrap();
let mut ctx = view_ctx_base();
ctx["project_fields"] = serde_json::json!([
{
"label": "Halal certification",
"rows": [
{ "label": "Certified by", "value": "ICCV Halal Authority" },
{ "label": "License #", "value": "HC-2025-0042" },
],
},
]);
let body = t.render("admin/user_view.html", &ctx).unwrap();
assert!(body.contains("Halal certification"), "section label must render");
assert!(body.contains("Certified by"));
assert!(body.contains("ICCV Halal Authority"));
assert!(body.contains("License #"));
assert!(body.contains("HC-2025-0042"));
}
#[test]
fn user_view_overview_omits_extension_when_project_fields_empty() {
let t = Templates::new(None).unwrap();
let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
assert!(
!body.contains("Halal certification"),
"no extension means no project section heading"
);
}
#[test]
fn user_view_demo_badge_renders_only_for_demo_users() {
let t = Templates::new(None).unwrap();
let body = t.render("admin/user_view.html", &view_ctx_base()).unwrap();
assert!(
!body.contains(">DEMO"),
"DEMO badge must NOT render for a real user"
);
let mut demo_ctx = view_ctx_base();
demo_ctx["user"]["is_demo"] = serde_json::Value::Bool(true);
demo_ctx["user"]["demo_label"] = serde_json::Value::String("staff @ rustio.local".into());
let demo_body = t.render("admin/user_view.html", &demo_ctx).unwrap();
assert!(
demo_body.contains(">DEMO"),
"DEMO badge must render for a demo user"
);
assert!(
demo_body.contains("staff @ rustio.local"),
"demo label must appear in the badge"
);
}
#[test]
fn user_confirm_delete_submit_enabled_for_normal_user() {
let t = Templates::new(None).unwrap();
let ctx = serde_json::json!({
"user_id": 99,
"email": "throwaway@example.com",
"role": "staff",
"group_count": 0,
"session_count": 0,
"direct_perm_count": 0,
"is_self": false,
"is_last_developer": false,
"csrf_token": "test-csrf",
});
let body = t.render("admin/user_confirm_delete.html", &ctx).unwrap();
assert!(
body.contains(r#"<button type="submit" class="btn-danger">"#),
"submit button must render with btn-danger class"
);
assert!(
!body.contains(r#"<button type="submit" class="deletelink-button" disabled>"#),
"submit button must NOT render with `disabled` for a deletable user"
);
assert!(!body.contains("is the last active developer"));
assert!(!body.contains("your own account"));
}
fn tempdir() -> PathBuf {
let pid = std::process::id();
let nonce: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let path = std::env::temp_dir().join(format!("rustio-tpl-{pid}-{nonce}"));
std::fs::create_dir_all(&path).unwrap();
path
}
}