Skip to main content

rustio_core/
admin.rs

1//! Auto-generated CRUD admin backed by [`crate::orm`].
2//!
3//! Build an [`Admin`] by chaining `.model::<T>()` calls, then mount it with
4//! [`Admin::register`]. This attaches list / create / edit / delete routes
5//! at `/admin/<admin_name>` for each model and an index page at `/admin`
6//! listing every registered model.
7//!
8//! ## UI ownership
9//!
10//! The admin HTML shell, layout, forms, tables, auth pages, and error
11//! states are **framework-owned**: there is no hook for a project to
12//! replace these templates. Visual customisation — logo mark, project
13//! display name, primary/accent colour, density — flows through the
14//! [`design::Design`] config (loaded from `rustio.design.json` if
15//! present). The public `templates/` + `static/` directories of a
16//! generated project are for public site pages, **not** for admin
17//! overrides.
18//!
19//! For a single-model app, [`register`] is a convenience wrapper.
20
21use std::sync::Arc;
22
23use bytes::Bytes;
24use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
25use http_body_util::{BodyExt, Full};
26
27use crate::error::Error;
28use crate::http::{Request, Response};
29use crate::orm::{Db, Model};
30use crate::router::Router;
31
32// `FormData` lives in `http` and is re-exported here so that the
33// `#[derive(RustioAdmin)]`-generated code referencing
34// `::rustio_core::admin::FormData` continues to work.
35pub use crate::http::FormData;
36
37pub mod admin_form_bridge;
38pub mod admin_generator;
39pub mod audit;
40pub mod auto_form;
41pub mod design;
42pub mod entry_builder;
43pub mod form;
44pub mod intelligence;
45pub mod layout;
46pub mod persistence;
47pub mod rbac;
48pub mod relations;
49pub mod schema_cache;
50pub mod schema_introspect;
51pub mod suggestions;
52pub mod templating;
53pub mod ui;
54
55#[cfg(test)]
56mod admin_intelligence_tests;
57#[cfg(test)]
58mod relations_tests;
59#[cfg(test)]
60mod suggestions_tests;
61
62/// The primitive types the admin + schema layers know how to render,
63/// validate, and serialise.
64///
65/// Marked `#[non_exhaustive]` because we expect to add variants (`Uuid`,
66/// `Json`, `Decimal`, `Bytes`) in later minor releases. Callers matching
67/// on this enum must include a wildcard arm.
68#[non_exhaustive]
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum FieldType {
71    I32,
72    I64,
73    String,
74    Bool,
75    /// `chrono::DateTime<Utc>`, stored as ISO-8601 text in SQLite.
76    DateTime,
77}
78
79/// Metadata about one struct field, generated by `#[derive(RustioAdmin)]`
80/// and consumed by the admin renderer + the schema exporter.
81///
82/// `nullable` tracks whether the source field is `Option<T>`. When true:
83///   - the DB column is allowed to be `NULL`;
84///   - the admin form accepts an empty value and stores it as `NULL`;
85///   - the schema exports it as `"nullable": true`.
86///
87/// `relation` carries compile-time relation metadata when the source
88/// field is annotated with `#[rustio(belongs_to = "...")]`. `None`
89/// means the field is a plain column; the admin renders it using the
90/// `ty` rules. When `Some`, the [`crate::admin::relations`] registry
91/// resolves and renders the foreign key.
92#[derive(Debug, Clone, Copy)]
93pub struct AdminField {
94    pub name: &'static str,
95    pub ty: FieldType,
96    pub editable: bool,
97    pub nullable: bool,
98    pub relation: Option<AdminRelation>,
99}
100
101/// Compile-time mirror of [`crate::schema::Relation`]. Uses `&'static
102/// str` so the whole thing is `const`-constructible by the
103/// `#[derive(RustioAdmin)]` macro and can live inside the
104/// `AdminField`-typed `const FIELDS` slice without a runtime allocation.
105///
106/// Converted into the owned-string [`crate::schema::Relation`] by
107/// [`crate::schema::SchemaField::from_admin_field`] so every downstream
108/// consumer (schema export, AI layer, relation registry) reads the same
109/// declarative shape.
110#[derive(Debug, Clone, Copy)]
111pub struct AdminRelation {
112    /// `RelationKind::BelongsTo` is the only variant emitted by the
113    /// macro today. `HasMany` is computed inversely at runtime by
114    /// [`crate::schema::Schema::incoming_relations`] and the registry.
115    pub kind: crate::schema::RelationKind,
116    /// Target model's `singular_name` — e.g. `"Patient"`, matching the
117    /// type name the user wrote in `#[rustio(belongs_to = "Patient")]`.
118    pub model: &'static str,
119    /// Optional column name on the target whose value should be shown
120    /// in place of the raw FK ID. `None` ⇒ the admin renders `#<id>`.
121    /// Never inferred — opt-in only.
122    pub display_field: Option<&'static str>,
123}
124
125pub trait AdminModel: Model {
126    const ADMIN_NAME: &'static str;
127    const DISPLAY_NAME: &'static str;
128    const FIELDS: &'static [AdminField];
129
130    fn field_display(&self, name: &str) -> Option<String>;
131    fn from_form(form: &FormData, id: Option<i64>) -> Result<Self, Error>;
132
133    /// Singular form of the display name. Used for labels like "New X" and
134    /// "Edit X". Defaults to [`DISPLAY_NAME`]; the `#[derive(RustioAdmin)]`
135    /// macro generates a proper singular form.
136    fn singular_name() -> &'static str {
137        Self::DISPLAY_NAME
138    }
139}
140
141/// Metadata about one registered admin model.
142///
143/// Carries everything the admin index page, the router registration,
144/// and the schema exporter need — collected by [`Admin::model`] from the
145/// type's `AdminModel` / `Model` impls at registration time.
146///
147/// `core` marks infrastructure models (currently just `User`) that the
148/// schema needs to describe but that the admin router does **not**
149/// expose as CRUD pages. Core models appear in `rustio.schema.json`
150/// with `"core": true` but have no `/admin/<name>` routes.
151#[derive(Debug, Clone)]
152pub struct AdminEntry {
153    pub admin_name: &'static str,
154    pub display_name: &'static str,
155    pub singular_name: &'static str,
156    pub table: &'static str,
157    pub fields: &'static [AdminField],
158    pub core: bool,
159}
160
161/// Fields the built-in `User` model exposes via the schema. Kept as a
162/// `const` so tests and the schema exporter reference the same source
163/// of truth. `password_hash` is marked `editable: false` so no future
164/// UI layer accidentally surfaces the hash in a form; `created_at`
165/// mirrors the `rustio_users.created_at` column so the schema doesn't
166/// under-describe the actual table shape.
167pub const USER_FIELDS: &[AdminField] = &[
168    AdminField {
169        name: "id",
170        ty: FieldType::I64,
171        editable: false,
172        nullable: false,
173        relation: None,
174    },
175    AdminField {
176        name: "email",
177        ty: FieldType::String,
178        editable: true,
179        nullable: false,
180        relation: None,
181    },
182    AdminField {
183        name: "password_hash",
184        ty: FieldType::String,
185        editable: false,
186        nullable: false,
187        relation: None,
188    },
189    AdminField {
190        name: "is_active",
191        ty: FieldType::Bool,
192        editable: true,
193        nullable: false,
194        relation: None,
195    },
196    AdminField {
197        name: "role",
198        ty: FieldType::String,
199        editable: true,
200        nullable: false,
201        relation: None,
202    },
203    AdminField {
204        name: "created_at",
205        ty: FieldType::DateTime,
206        editable: false,
207        nullable: false,
208        relation: None,
209    },
210];
211
212/// Core `AdminEntry` for the built-in `User` model. Always present in
213/// every project's schema; not routed by the admin in 0.4.0.
214pub(crate) const USER_ENTRY: AdminEntry = AdminEntry {
215    admin_name: "users",
216    display_name: "Users",
217    singular_name: "User",
218    table: "rustio_users",
219    fields: USER_FIELDS,
220    core: true,
221};
222
223// ---------------------------------------------------------------------------
224// Bundled CSS + icon assets
225// ---------------------------------------------------------------------------
226
227/// Framework-owned stylesheet. Bundled into the binary via `include_str!`
228/// and served at `/admin/assets/admin.css` so the admin pages reference
229/// it with a single `<link>`. Projects must not edit this.
230const ADMIN_CSS_BUNDLE: &str = include_str!("../assets/admin.css");
231
232/// Cache-buster appended to the stylesheet `<link>` URL as `?v=…`.
233/// Uses the CSS byte length — changes on every content edit, so the
234/// browser fetches a fresh copy without relying on the etag /
235/// must-revalidate dance (which some browsers treat softly). Served
236/// bytes are still cached at the `/admin/assets/admin.css` route;
237/// the query string is ignored by the handler but re-keys the HTTP
238/// cache entry.
239const ADMIN_CSS_VER: usize = ADMIN_CSS_BUNDLE.len();
240
241/// Framework-owned favicon: a compact SVG rust-coloured square with a
242/// white "R". Scalable, CSP-friendly, zero network dependency. Served
243/// at `/admin/assets/favicon.svg` and referenced from every page
244/// shell's `<head>`. Kept inline (as a string) rather than
245/// `include_bytes!` so the brand colour can be swapped at build time
246/// via `rustio.design.json` without a second-pass binary edit.
247const ADMIN_FAVICON_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#B84318"/><text x="16" y="22" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="20" font-weight="700" fill="#ffffff" text-anchor="middle">R</text></svg>"##;
248
249// Inline Lucide SVG icon markup. Each function returns a complete
250// `<svg>` element sized by the caller's CSS (16px for toolbar,
251// 18px for nav, etc.). `currentColor` lets the surrounding class
252// (`.rio-btn-primary`, `.rio-icon-btn.rio-danger`) drive the stroke.
253fn svg(path: &str) -> String {
254    format!(
255        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{path}</svg>"#
256    )
257}
258
259fn icon_layers() -> String {
260    svg(
261        r#"<path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/>"#,
262    )
263}
264
265fn icon_dashboard() -> String {
266    svg(
267        r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#,
268    )
269}
270
271fn icon_plus() -> String {
272    svg(r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#)
273}
274
275fn icon_search() -> String {
276    svg(r#"<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>"#)
277}
278
279fn icon_pencil() -> String {
280    svg(
281        r#"<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/>"#,
282    )
283}
284
285fn icon_trash() -> String {
286    svg(
287        r#"<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>"#,
288    )
289}
290
291fn icon_chevron_right() -> String {
292    svg(r#"<polyline points="9 18 15 12 9 6"/>"#)
293}
294
295fn icon_logout() -> String {
296    svg(
297        r#"<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/>"#,
298    )
299}
300
301fn icon_shield_alert() -> String {
302    svg(
303        r#"<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="M12 8v4"/><path d="M12 16h.01"/>"#,
304    )
305}
306
307fn icon_triangle_alert() -> String {
308    svg(
309        r#"<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>"#,
310    )
311}
312
313fn icon_inbox() -> String {
314    svg(
315        r#"<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>"#,
316    )
317}
318
319fn icon_arrow_left() -> String {
320    svg(r#"<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>"#)
321}
322
323fn icon_activity() -> String {
324    svg(
325        r#"<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.5.5 0 0 1-.95 0L9.24 2.18a.5.5 0 0 0-.95 0L5.94 10.54A2 2 0 0 1 4.01 12H2"/>"#,
326    )
327}
328
329fn icon_home() -> String {
330    svg(
331        r#"<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>"#,
332    )
333}
334
335fn icon_bell() -> String {
336    svg(
337        r#"<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>"#,
338    )
339}
340
341fn icon_mail() -> String {
342    svg(
343        r#"<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>"#,
344    )
345}
346
347// ---------------------------------------------------------------------------
348// Admin builder + route registration
349// ---------------------------------------------------------------------------
350
351type ModelRegistrar = Box<dyn FnOnce(Router, &Db, Arc<Vec<AdminEntry>>) -> Router + Send + Sync>;
352
353/// Builder that collects admin models and mounts them with a shared
354/// `/admin` index page.
355pub struct Admin {
356    entries: Vec<AdminEntry>,
357    registrars: Vec<ModelRegistrar>,
358}
359
360impl Admin {
361    pub fn new() -> Self {
362        Self {
363            entries: vec![USER_ENTRY.clone()],
364            registrars: Vec::new(),
365        }
366    }
367
368    /// Register a user-facing model on this admin.
369    pub fn model<T: AdminModel>(mut self) -> Self {
370        self.entries.push(AdminEntry {
371            admin_name: T::ADMIN_NAME,
372            display_name: T::DISPLAY_NAME,
373            singular_name: T::singular_name(),
374            table: T::TABLE,
375            fields: T::FIELDS,
376            core: false,
377        });
378        self.registrars.push(Box::new(|router, db, entries| {
379            mount_model::<T>(router, db, entries)
380        }));
381        self
382    }
383
384    /// Number of user-registered models (not counting core models).
385    pub fn len(&self) -> usize {
386        self.entries.iter().filter(|e| !e.core).count()
387    }
388
389    pub fn is_empty(&self) -> bool {
390        self.len() == 0
391    }
392
393    /// All entries, including core models. Used by the schema exporter;
394    /// iterate with `.iter().filter(|e| !e.core)` when you want only
395    /// user-facing models.
396    pub fn entries(&self) -> &[AdminEntry] {
397        &self.entries
398    }
399
400    /// Mount the admin onto a router.
401    pub fn register(self, mut router: Router, db: &Db) -> Router {
402        let entries = Arc::new(self.entries);
403
404        // Error shell: catch unhandled NotFound / Internal errors that
405        // come out of `/admin/*` handlers and render them in-shell.
406        // Public (non-admin) paths fall through to the router default.
407        let err_entries = entries.clone();
408        router = router.wrap(move |req, next| {
409            let entries = err_entries.clone();
410            async move {
411                let is_admin = req.uri().path().starts_with("/admin");
412                if !is_admin {
413                    return next.run(req).await;
414                }
415                // Skip the stylesheet and favicon — neither is HTML
416                // and neither should ever render a shell page on a
417                // 404 / 500 from an upstream handler.
418                let path = req.uri().path();
419                if path == "/admin/assets/admin.css" || path == "/admin/assets/favicon.svg" {
420                    return next.run(req).await;
421                }
422                let user_email = req
423                    .ctx()
424                    .get::<crate::auth::Identity>()
425                    .map(|i| i.email.clone());
426                let csrf = req
427                    .ctx()
428                    .get::<crate::auth::CsrfToken>()
429                    .map(|t| t.0.clone());
430
431                let res = next.run(req).await;
432                match res {
433                    Err(Error::NotFound) => Ok(admin_not_found_response(
434                        &entries,
435                        user_email.as_deref(),
436                        csrf.as_deref(),
437                    )),
438                    Err(Error::Internal(msg)) => {
439                        let req_id = new_request_id();
440                        eprintln!("admin 500 [{req_id}]: {msg}");
441                        Ok(admin_server_error_response(
442                            &entries,
443                            user_email.as_deref(),
444                            csrf.as_deref(),
445                            &req_id,
446                        ))
447                    }
448                    other => other,
449                }
450            }
451        });
452
453        // Static asset: framework-owned stylesheet. Cached for an hour
454        // because the bytes are pinned to the compiled binary — the
455        // content can only change with a redeploy.
456        router = router.get("/admin/assets/admin.css", |_req, _params| async move {
457            Ok::<Response, Error>(admin_css_response())
458        });
459        // Favicon — scalable SVG served from the same /admin/assets
460        // namespace. No binary asset, no external dependency, full
461        // brand colouring. Requested by every browser tab.
462        router = router.get("/admin/assets/favicon.svg", |_req, _params| async move {
463            Ok::<Response, Error>(admin_favicon_response())
464        });
465
466        // 0.10+ bundled assets for the template-based admin. Served
467        // under the new `/admin/static/…` namespace (the legacy
468        // `/admin/assets/` routes above keep working for the
469        // pre-template pages until stage 5 removes them).
470        for &(path, content_type, bytes) in crate::admin::templating::BUNDLED_ASSETS {
471            let full_path = format!("/admin/static/{path}");
472            router = router.get(&full_path, move |_req, _params| async move {
473                Ok::<Response, Error>(bundled_asset_response(bytes, content_type))
474            });
475        }
476
477        // Build the AdminUiModel registry first — both the dashboard
478        // (GET /admin) and the per-model routes (/admin/:model and
479        // its create/edit/delete variants) read from it.
480        let admin_new_registry = std::sync::Arc::new({
481            let mut reg = crate::admin::admin_form_bridge::AdminRegistry::new();
482            reg.register("users", crate::admin::layout::new_user_admin);
483            reg
484        });
485
486        // GET /admin — dashboard rendered by the new admin engine
487        // (one card per registered AdminUiModel). Uses the same shell
488        // / topbar / sidebar / CSS as the per-model pages so the
489        // visual identity stays consistent across the admin surface.
490        let index_db = db.clone();
491        let index_registry = admin_new_registry.clone();
492        let index_entries = entries.clone();
493        router = router.get("/admin", move |req, _params| {
494            let db = index_db.clone();
495            let registry = index_registry.clone();
496            let legacy_entries = index_entries.clone();
497            async move {
498                if let Err(resp) = admin_guard(req.ctx()) {
499                    return Ok(resp);
500                }
501                // 0.10+ template render. Identity is cloned out of the
502                // request context so the borrow ends before the async
503                // `dashboard_render` awaits on DB queries.
504                let identity = crate::auth::identity(req.ctx()).cloned();
505                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
506                let html = crate::admin::layout::dashboard_render(
507                    &db,
508                    &registry,
509                    legacy_entries.as_slice(),
510                    identity.as_ref(),
511                    csrf.as_deref(),
512                )
513                .await;
514                Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
515            }
516        });
517        // Login + logout. Unauthenticated users *need* to reach
518        // /admin/login; POST /admin/logout is CSRF-protected inside
519        // the handler. GET /admin/logout is **public** — Django uses
520        // it both for the "confirm sign out" prompt (when a session
521        // is live) and for the post-logout "you have signed out"
522        // page (after POST clears the session).
523        let login_db = db.clone();
524        router = router.post("/admin/login", move |req, _params| {
525            let db = login_db.clone();
526            async move { handle_login(req, &db).await }
527        });
528        // Direct GET to /admin/login: bookmarks, browser history, dev
529        // probes, or any link that hits the login URL straight (instead
530        // of bouncing through the unauthenticated /admin redirect).
531        // Renders the same login form as the 401 fallback so the page
532        // never dead-ends with a 405. If the request is already
533        // authenticated, hand them the standard login page anyway —
534        // they can ignore it or sign out from there.
535        router = router.get("/admin/login", |_req, _params| async move {
536            Ok::<Response, Error>(login_page(200, None, None))
537        });
538        let logout_db = db.clone();
539        router = router.post("/admin/logout", move |req, _params| {
540            let db = logout_db.clone();
541            async move { handle_logout(req, &db).await }
542        });
543        router = router.get("/admin/logout", move |req, _params| async move {
544            let signed_in = req.ctx().get::<crate::auth::Identity>().is_some();
545            let csrf = ctx_csrf(req.ctx()).map(str::to_string);
546            Ok::<Response, Error>(logout_confirmation_response(signed_in, csrf.as_deref()))
547        });
548
549        // Password change (self-service). GET renders the form, POST
550        // validates + rotates the hash + re-issues the session cookie.
551        let pw_get_entries = entries.clone();
552        let pw_get_db = db.clone();
553        let pw_get_registry = admin_new_registry.clone();
554        router = router.get("/admin/password_change", move |req, _params| {
555            let legacy_entries = pw_get_entries.clone();
556            let db = pw_get_db.clone();
557            let registry = pw_get_registry.clone();
558            async move {
559                if let Err(resp) = admin_guard(req.ctx()) {
560                    return Ok(resp);
561                }
562                let identity = crate::auth::identity(req.ctx()).cloned();
563                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
564                let html = crate::admin::layout::password_change_render(
565                    &db,
566                    &registry,
567                    &legacy_entries,
568                    identity.as_ref(),
569                    csrf.as_deref(),
570                    None,
571                )
572                .await;
573                Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
574            }
575        });
576        let pw_post_entries = entries.clone();
577        let pw_post_db = db.clone();
578        let pw_post_registry = admin_new_registry.clone();
579        router = router.post("/admin/password_change", move |req, _params| {
580            let legacy_entries = pw_post_entries.clone();
581            let db = pw_post_db.clone();
582            let registry = pw_post_registry.clone();
583            async move {
584                if let Err(resp) = admin_guard(req.ctx()) {
585                    return Ok(resp);
586                }
587                handle_password_change_post(req, &db, &registry, &legacy_entries).await
588            }
589        });
590        let pw_done_entries = entries.clone();
591        let pw_done_db = db.clone();
592        let pw_done_registry = admin_new_registry.clone();
593        router = router.get("/admin/password_change/done", move |req, _params| {
594            let legacy_entries = pw_done_entries.clone();
595            let db = pw_done_db.clone();
596            let registry = pw_done_registry.clone();
597            async move {
598                if let Err(resp) = admin_guard(req.ctx()) {
599                    return Ok(resp);
600                }
601                let identity = crate::auth::identity(req.ctx()).cloned();
602                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
603                let html = crate::admin::layout::password_change_done_render(
604                    &db,
605                    &registry,
606                    &legacy_entries,
607                    identity.as_ref(),
608                    csrf.as_deref(),
609                )
610                .await;
611                Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
612            }
613        });
614
615        // Profile (self-service identity card).
616        let profile_entries = entries.clone();
617        let profile_db = db.clone();
618        let profile_registry = admin_new_registry.clone();
619        router = router.get("/admin/profile", move |req, _params| {
620            let legacy_entries = profile_entries.clone();
621            let db = profile_db.clone();
622            let registry = profile_registry.clone();
623            async move {
624                if let Err(resp) = admin_guard(req.ctx()) {
625                    return Ok(resp);
626                }
627                let identity = crate::auth::identity(req.ctx()).cloned();
628                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
629                let user = match identity.as_ref() {
630                    Some(id) => crate::auth::user::find_by_id(&db, id.user_id).await?,
631                    None => None,
632                };
633                let html = crate::admin::layout::profile_render(
634                    &db,
635                    &registry,
636                    &legacy_entries,
637                    identity.as_ref(),
638                    user.as_ref(),
639                    csrf.as_deref(),
640                )
641                .await;
642                Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
643            }
644        });
645
646        // Recent actions (project-wide audit timeline).
647        let actions_entries = entries.clone();
648        let actions_db = db.clone();
649        let actions_registry = admin_new_registry.clone();
650        router = router.get("/admin/actions", move |req, _params| {
651            let legacy_entries = actions_entries.clone();
652            let db = actions_db.clone();
653            let registry = actions_registry.clone();
654            async move {
655                if let Err(resp) = admin_guard(req.ctx()) {
656                    return Ok(resp);
657                }
658                let query = req.query();
659                let model_filter = query
660                    .get("model")
661                    .map(str::trim)
662                    .filter(|s| !s.is_empty())
663                    .map(String::from);
664                let action_filter = query
665                    .get("action")
666                    .map(str::trim)
667                    .filter(|s| !s.is_empty())
668                    .map(String::from);
669                let actions =
670                    audit::recent(&db, 200, model_filter.as_deref(), action_filter.as_deref())
671                        .await?;
672                let identity = crate::auth::identity(req.ctx()).cloned();
673                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
674                let html = crate::admin::layout::actions_render(
675                    &db,
676                    &registry,
677                    &legacy_entries,
678                    identity.as_ref(),
679                    csrf.as_deref(),
680                    &actions,
681                    model_filter.as_deref(),
682                    action_filter.as_deref(),
683                )
684                .await;
685                Ok::<Response, Error>(with_admin_headers(crate::http::html(html)))
686            }
687        });
688
689        // 0.7.1 Actionable Intelligence — suggestion → review → apply.
690        // GET shows the proposed plan + review; POST (CSRF) commits
691        // through the executor. No bypass of planner/review/executor.
692        let sugg_get_entries = entries.clone();
693        let sugg_get_db = db.clone();
694        let sugg_get_registry = admin_new_registry.clone();
695        router = router.get("/admin/suggestions/:admin/:field", move |req, params| {
696            let legacy_entries = sugg_get_entries.clone();
697            let db = sugg_get_db.clone();
698            let registry = sugg_get_registry.clone();
699            async move {
700                if let Err(resp) = admin_guard(req.ctx()) {
701                    return Ok(resp);
702                }
703                let admin_name = params.get("admin").unwrap_or("").to_string();
704                let field = params.get("field").unwrap_or("").to_string();
705                let identity = crate::auth::identity(req.ctx()).cloned();
706                let csrf = ctx_csrf(req.ctx()).map(str::to_string);
707                Ok::<Response, Error>(
708                    suggestion_review_response(
709                        &db,
710                        &registry,
711                        &legacy_entries,
712                        identity.as_ref(),
713                        csrf.as_deref(),
714                        &admin_name,
715                        &field,
716                        None,
717                    )
718                    .await,
719                )
720            }
721        });
722        let sugg_post_entries = entries.clone();
723        let sugg_post_db = db.clone();
724        let sugg_post_registry = admin_new_registry.clone();
725        router = router.post("/admin/suggestions/:admin/:field", move |req, params| {
726            let legacy_entries = sugg_post_entries.clone();
727            let db = sugg_post_db.clone();
728            let registry = sugg_post_registry.clone();
729            async move {
730                if let Err(resp) = admin_guard(req.ctx()) {
731                    return Ok(resp);
732                }
733                let admin_name = params.get("admin").unwrap_or("").to_string();
734                let field = params.get("field").unwrap_or("").to_string();
735                let (_, body, ctx) = req.into_parts();
736                let form = read_form_from_parts(body).await?;
737                require_csrf(&ctx, &form)?;
738                let identity = crate::auth::identity(&ctx).cloned();
739                let csrf = ctx_csrf(&ctx).map(str::to_string);
740                Ok::<Response, Error>(
741                    suggestion_apply_response(
742                        &db,
743                        &registry,
744                        &legacy_entries,
745                        identity.as_ref(),
746                        csrf.as_deref(),
747                        &admin_name,
748                        &field,
749                    )
750                    .await,
751                )
752            }
753        });
754
755        // 0.7.2 — runtime schema reload. POST refreshes the cache,
756        // returns to the dashboard. No restart, no recompile.
757        router = router.post("/admin/schema/reload", move |req, _params| async move {
758            if let Err(resp) = admin_guard(req.ctx()) {
759                return Ok(resp);
760            }
761            let (_, body, ctx) = req.into_parts();
762            let form = read_form_from_parts(body).await?;
763            require_csrf(&ctx, &form)?;
764            // Attempt the reload; on failure redirect with an error
765            // flag in the query string. The dashboard renders a
766            // banner for `?schema_reload=ok|err`.
767            let redirect_url = match schema_cache::refresh() {
768                Ok(_) => "/admin?schema_reload=ok",
769                Err(_) => "/admin?schema_reload=err",
770            };
771            Ok::<Response, Error>(with_admin_headers(redirect(redirect_url)))
772        });
773
774        // /admin/:model is the canonical model path. Registered
775        // *after* every literal /admin/<word> route above so the
776        // router's first-match-wins lookup keeps reserved literals
777        // (login, logout, profile, password_change, actions,
778        // suggestions, schema, assets) bound to their dedicated
779        // handlers. Anything else under /admin/<slug> resolves into
780        // the template-based list page. Form submits go to the
781        // dedicated /admin/:model/new and /admin/:model/:id/edit
782        // routes mounted below — there is no catch-all POST.
783        {
784            let db = db.clone();
785            let registry = admin_new_registry.clone();
786            let model_entries = entries.clone();
787            router =
788                router.get("/admin/:model", move |req, params| {
789                    let db = db.clone();
790                    let registry = registry.clone();
791                    let legacy_entries = model_entries.clone();
792                    async move {
793                        admin_model_index_get(&db, &registry, &legacy_entries, req, params).await
794                    }
795                });
796        }
797
798        // 0.10 stage 4f-a: GET /admin/:model/new and
799        // /admin/:model/:id/edit route through the template-based
800        // `form_render`. Registered BEFORE the `for registrar` loop
801        // below so the generic routes shadow any legacy literals like
802        // `/admin/patients/:id/edit` — unified template styling for
803        // both registration surfaces.
804        {
805            let db = db.clone();
806            let registry = admin_new_registry.clone();
807            let form_new_entries = entries.clone();
808            router = router.get("/admin/:model/new", move |req, params| {
809                let db = db.clone();
810                let registry = registry.clone();
811                let legacy_entries = form_new_entries.clone();
812                async move {
813                    admin_model_form_get(&db, &registry, &legacy_entries, req, params, None).await
814                }
815            });
816        }
817        {
818            let db = db.clone();
819            let registry = admin_new_registry.clone();
820            let form_edit_entries = entries.clone();
821            router = router.get("/admin/:model/:id/edit", move |req, params| {
822                let db = db.clone();
823                let registry = registry.clone();
824                let legacy_entries = form_edit_entries.clone();
825                async move {
826                    let id = params.get("id").map(str::to_string);
827                    admin_model_form_get(
828                        &db,
829                        &registry,
830                        &legacy_entries,
831                        req,
832                        params,
833                        id.as_deref(),
834                    )
835                    .await
836                }
837            });
838        }
839
840        // Stage 4f-b: matching POST handlers.
841        {
842            let db = db.clone();
843            let registry = admin_new_registry.clone();
844            let create_entries = entries.clone();
845            router = router.post("/admin/:model/new", move |req, params| {
846                let db = db.clone();
847                let registry = registry.clone();
848                let legacy_entries = create_entries.clone();
849                async move {
850                    admin_model_create_post(&db, &registry, &legacy_entries, req, params).await
851                }
852            });
853        }
854        {
855            let db = db.clone();
856            let registry = admin_new_registry.clone();
857            let update_entries = entries.clone();
858            router = router.post("/admin/:model/:id/edit", move |req, params| {
859                let db = db.clone();
860                let registry = registry.clone();
861                let legacy_entries = update_entries.clone();
862                async move {
863                    admin_model_update_post(&db, &registry, &legacy_entries, req, params).await
864                }
865            });
866        }
867        {
868            let db = db.clone();
869            let registry = admin_new_registry.clone();
870            let delete_entries = entries.clone();
871            router = router.post("/admin/:model/:id/delete", move |req, params| {
872                let db = db.clone();
873                let registry = registry.clone();
874                let legacy_entries = delete_entries.clone();
875                async move {
876                    admin_model_delete_post(&db, &registry, &legacy_entries, req, params).await
877                }
878            });
879        }
880
881        for registrar in self.registrars {
882            router = registrar(router, db, entries.clone());
883        }
884        router
885    }
886}
887
888impl Default for Admin {
889    fn default() -> Self {
890        Self::new()
891    }
892}
893
894/// Convenience: mount CRUD routes and an `/admin` index for a single model.
895pub fn register<T>(router: Router, db: &Db) -> Router
896where
897    T: AdminModel + Model,
898{
899    Admin::new().model::<T>().register(router, db)
900}
901
902fn mount_model<T>(mut router: Router, db: &Db, entries: Arc<Vec<AdminEntry>>) -> Router
903where
904    T: AdminModel + Model,
905{
906    let base = format!("/admin/{}", T::ADMIN_NAME);
907    let create_path = format!("{base}/create");
908    let edit_path = format!("{base}/:id/edit");
909    let delete_path = format!("{base}/:id/delete");
910    let history_path = format!("{base}/:id/history");
911    let bulk_path = format!("{base}/bulk_action");
912
913    // --- list (with search + filter via ?q=&status=&priority=) ---
914    let list_db = db.clone();
915    let list_entries = entries.clone();
916    router = router.get(&base, move |req, _params| {
917        let db = list_db.clone();
918        let entries = list_entries.clone();
919        async move {
920            if let Err(resp) = admin_guard(req.ctx()) {
921                return Ok(resp);
922            }
923            let query = req.query();
924            let q = query
925                .get("q")
926                .map(str::trim)
927                .filter(|s| !s.is_empty())
928                .map(String::from);
929            let status = query
930                .get("status")
931                .map(str::trim)
932                .filter(|s| !s.is_empty())
933                .map(String::from);
934            let priority = query
935                .get("priority")
936                .map(str::trim)
937                .filter(|s| !s.is_empty())
938                .map(String::from);
939            // Only accept sort values from the closed vocabulary —
940            // anything else falls back to default so a crafted URL
941            // can't surface a panic or reveal unknown ordering.
942            let sort = query
943                .get("sort")
944                .map(str::trim)
945                .filter(|s| !s.is_empty())
946                .filter(|s| SORT_OPTIONS.iter().any(|(v, _)| *v == *s))
947                .map(String::from);
948
949            // Column visibility — rule-based default set computed
950            // from the model's field shape via
951            // [`default_list_columns`].
952            let visible_columns: Vec<&'static str> = default_list_columns::<T>();
953
954            let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
955            let all_items = T::all(&db).await?;
956            let total = all_items.len();
957
958            // Gather distinct filter-values from the unfiltered set so
959            // the dropdown options reflect what actually exists in the
960            // data, not a hard-coded list.
961            let status_options = distinct_values::<T>(&all_items, "status");
962            let priority_options = distinct_values::<T>(&all_items, "priority");
963
964            // Relation-aware filters (Phase 5): one entry per FK
965            // column on the model. Each either renders a dropdown
966            // (target ≤ 500 rows AND declares display_field) or a
967            // numeric fallback input with a clear hint.
968            let registry = current_registry();
969            let relation_filter_states = if registry.is_empty() {
970                Vec::new()
971            } else {
972                build_relation_filters::<T>(&db, &registry, &query).await
973            };
974
975            // In-memory filter. Fine for the dev admin's typical row
976            // counts; graduates to DB WHERE clauses when a project adds
977            // an index or pagination layer.
978            let mut filtered: Vec<&T> = all_items
979                .iter()
980                .filter(|item| {
981                    if let Some(qs) = &q {
982                        if !matches_query::<T>(item, qs) {
983                            return false;
984                        }
985                    }
986                    if let Some(s) = &status {
987                        let v = item.field_display("status").unwrap_or_default();
988                        if &v != s {
989                            return false;
990                        }
991                    }
992                    // Relation-aware equality filter: if the user
993                    // asked for ?patient_id=7, drop rows whose FK
994                    // value doesn't match. Applied before sort so the
995                    // result count reflects the filtered set.
996                    for rel in &relation_filter_states {
997                        if let Some(wanted) = rel.current_value {
998                            let actual = item
999                                .field_display(&rel.field_name)
1000                                .and_then(|s| s.parse::<i64>().ok());
1001                            if actual != Some(wanted) {
1002                                return false;
1003                            }
1004                        }
1005                    }
1006                    if let Some(p) = &priority {
1007                        let v = item.field_display("priority").unwrap_or_default();
1008                        if &v != p {
1009                            return false;
1010                        }
1011                    }
1012                    true
1013                })
1014                .collect();
1015
1016            // Sort. Default (when `sort` is absent) is newest-first —
1017            // matches the natural operator expectation of "most recent
1018            // at the top" for admin lists.
1019            match sort.as_deref() {
1020                Some("oldest") | Some("id_asc") => filtered.sort_by_key(|i| i.id()),
1021                Some("id_desc") => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1022                Some("newest") | None => filtered.sort_by_key(|i| std::cmp::Reverse(i.id())),
1023                _ => {}
1024            }
1025
1026            let filters = ListFilters {
1027                q: q.as_deref(),
1028                status: status.as_deref(),
1029                status_options: &status_options,
1030                priority: priority.as_deref(),
1031                priority_options: &priority_options,
1032                sort: sort.as_deref(),
1033                relation_filters: &relation_filter_states,
1034                visible_columns: &visible_columns,
1035            };
1036
1037            // Relation layer (0.9.0): FK label prefetch uses the same
1038            // registry we built above for filters. Missing schema
1039            // file or no declared relations → empty registry → zero
1040            // extra queries and the page renders exactly like
1041            // pre-0.9.0.
1042            let fk_labels = if registry.is_empty() {
1043                FkLabels::new()
1044            } else {
1045                fetch_fk_labels::<T>(&db, &filtered, &registry).await
1046            };
1047            let cell_ctx = CellCtx {
1048                registry: &registry,
1049                fk_labels: &fk_labels,
1050            };
1051            Ok::<Response, Error>(list_response::<T>(
1052                shell, &filtered, total, filters, &cell_ctx,
1053            ))
1054        }
1055    });
1056
1057    // --- create (GET + POST) ---
1058    let create_entries = entries.clone();
1059    let create_form_db = db.clone();
1060    router = router.get(&create_path, move |req, _params| {
1061        let entries = create_entries.clone();
1062        let db = create_form_db.clone();
1063        async move {
1064            if let Err(resp) = admin_guard(req.ctx()) {
1065                return Ok(resp);
1066            }
1067            let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1068            // No item yet → relation ctx is empty; create form has
1069            // nothing to resolve and no inverse relations to count.
1070            let cell_ctx = CellCtx::empty();
1071            let inverse_counts = std::collections::HashMap::new();
1072            let registry = current_registry();
1073            let form_options = if registry.is_empty() {
1074                FormRelationOptions::new()
1075            } else {
1076                fetch_form_relation_options::<T>(&db, &registry).await
1077            };
1078            Ok::<Response, Error>(form_response::<T>(
1079                shell,
1080                FormMode::Create,
1081                &cell_ctx,
1082                &inverse_counts,
1083                &form_options,
1084            ))
1085        }
1086    });
1087
1088    let create_db = db.clone();
1089    router = router.post(&create_path, move |req, _params| {
1090        let db = create_db.clone();
1091        async move {
1092            if let Err(resp) = admin_guard(req.ctx()) {
1093                return Ok(resp);
1094            }
1095            // Peer IP + user id must be captured before `req.into_parts()`
1096            // consumes the request.
1097            let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1098            let (_, body, ctx) = req.into_parts();
1099            let form = read_form_from_parts(body).await?;
1100            require_csrf(&ctx, &form)?;
1101            let user_id = ctx
1102                .get::<crate::auth::Identity>()
1103                .map(|i| i.user_id)
1104                .unwrap_or(0);
1105
1106            let item = T::from_form(&form, None)?;
1107            let primary = primary_string_value::<T>(&item);
1108            let new_id = item.create(&db).await?;
1109
1110            audit::record(
1111                &db,
1112                audit::LogEntry {
1113                    user_id,
1114                    action_type: audit::ActionType::Create,
1115                    model_name: T::ADMIN_NAME,
1116                    object_id: new_id,
1117                    ip_address: peer_ip.as_deref(),
1118                    summary: audit_summary(
1119                        audit::ActionType::Create,
1120                        T::singular_name(),
1121                        new_id,
1122                        &primary,
1123                    ),
1124                },
1125            )
1126            .await?;
1127
1128            Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1129                "/admin/{}",
1130                T::ADMIN_NAME
1131            ))))
1132        }
1133    });
1134
1135    // --- edit (GET + POST) ---
1136    let edit_db = db.clone();
1137    let edit_entries = entries.clone();
1138    router = router.get(&edit_path, move |req, params| {
1139        let db = edit_db.clone();
1140        let entries = edit_entries.clone();
1141        async move {
1142            if let Err(resp) = admin_guard(req.ctx()) {
1143                return Ok(resp);
1144            }
1145            let id = parse_id_param(&params)?;
1146            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1147            let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1148            // Prefetch FK labels for this single row so the edit-
1149            // page "linked: <Name>" hint and any inverse-panel
1150            // counts (Phase 4) render without N+1 queries.
1151            let registry = current_registry();
1152            let items_ref: Vec<&T> = vec![&item];
1153            let fk_labels = if registry.is_empty() {
1154                FkLabels::new()
1155            } else {
1156                fetch_fk_labels::<T>(&db, &items_ref, &registry).await
1157            };
1158            let inverse_counts = if registry.is_empty() {
1159                std::collections::HashMap::new()
1160            } else {
1161                fetch_inverse_counts(&db, T::singular_name(), id, &registry).await
1162            };
1163            let cell_ctx = CellCtx {
1164                registry: &registry,
1165                fk_labels: &fk_labels,
1166            };
1167            let form_options = if registry.is_empty() {
1168                FormRelationOptions::new()
1169            } else {
1170                fetch_form_relation_options::<T>(&db, &registry).await
1171            };
1172            Ok::<Response, Error>(form_response::<T>(
1173                shell,
1174                FormMode::Edit { id, item: &item },
1175                &cell_ctx,
1176                &inverse_counts,
1177                &form_options,
1178            ))
1179        }
1180    });
1181
1182    let update_db = db.clone();
1183    router = router.post(&edit_path, move |req, params| {
1184        let db = update_db.clone();
1185        async move {
1186            if let Err(resp) = admin_guard(req.ctx()) {
1187                return Ok(resp);
1188            }
1189            let id = parse_id_param(&params)?;
1190            let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1191            let (_, body, ctx) = req.into_parts();
1192            let form = read_form_from_parts(body).await?;
1193            require_csrf(&ctx, &form)?;
1194            let user_id = ctx
1195                .get::<crate::auth::Identity>()
1196                .map(|i| i.user_id)
1197                .unwrap_or(0);
1198
1199            let item = T::from_form(&form, Some(id))?;
1200            let primary = primary_string_value::<T>(&item);
1201            item.update(&db).await?;
1202
1203            audit::record(
1204                &db,
1205                audit::LogEntry {
1206                    user_id,
1207                    action_type: audit::ActionType::Update,
1208                    model_name: T::ADMIN_NAME,
1209                    object_id: id,
1210                    ip_address: peer_ip.as_deref(),
1211                    summary: audit_summary(
1212                        audit::ActionType::Update,
1213                        T::singular_name(),
1214                        id,
1215                        &primary,
1216                    ),
1217                },
1218            )
1219            .await?;
1220
1221            Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1222                "/admin/{}",
1223                T::ADMIN_NAME
1224            ))))
1225        }
1226    });
1227
1228    // --- delete: GET = confirmation page, POST = perform delete ---
1229    let delete_confirm_db = db.clone();
1230    let delete_confirm_entries = entries.clone();
1231    router = router.get(&delete_path, move |req, params| {
1232        let db = delete_confirm_db.clone();
1233        let entries = delete_confirm_entries.clone();
1234        async move {
1235            if let Err(resp) = admin_guard(req.ctx()) {
1236                return Ok(resp);
1237            }
1238            let id = parse_id_param(&params)?;
1239            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1240            let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1241            Ok::<Response, Error>(delete_confirmation_response::<T>(shell, id, &item))
1242        }
1243    });
1244
1245    let delete_db = db.clone();
1246    let delete_entries = entries.clone();
1247    router = router.post(&delete_path, move |req, params| {
1248        let db = delete_db.clone();
1249        let entries = delete_entries.clone();
1250        async move {
1251            if let Err(resp) = admin_guard(req.ctx()) {
1252                return Ok(resp);
1253            }
1254            let id = parse_id_param(&params)?;
1255            let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1256            let (_, body, ctx) = req.into_parts();
1257            let form = read_form_from_parts(body).await?;
1258            require_csrf(&ctx, &form)?;
1259            let user_id = ctx
1260                .get::<crate::auth::Identity>()
1261                .map(|i| i.user_id)
1262                .unwrap_or(0);
1263
1264            // Fetch the record first so the audit summary can describe
1265            // what was deleted; the row is gone after `T::delete`.
1266            let primary = match T::find(&db, id).await? {
1267                Some(item) => primary_string_value::<T>(&item),
1268                None => String::new(),
1269            };
1270
1271            // Phase 6 — delete guard. For every `has_many` inverse
1272            // pointing at this row, count blockers. If any exist,
1273            // refuse the delete and render a 409 page with a
1274            // per-blocker breakdown + links to filtered lists.
1275            let registry = current_registry();
1276            if !registry.is_empty() {
1277                let counts = fetch_inverse_counts(&db, T::singular_name(), id, &registry).await;
1278                let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1279                    .has_many(T::singular_name())
1280                    .iter()
1281                    .filter_map(|inv| {
1282                        let key = format!("{}.{}", inv.source_model, inv.source_field);
1283                        counts
1284                            .get(&key)
1285                            .copied()
1286                            .filter(|n| *n > 0)
1287                            .map(|n| (inv, n))
1288                    })
1289                    .collect();
1290                if !blockers.is_empty() {
1291                    let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1292                    return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1293                        &shell, id, &primary, &blockers,
1294                    ));
1295                }
1296            }
1297
1298            // Execute the delete. Defence in depth: SQLite's FK
1299            // enforcement still runs (a new reference could have
1300            // landed between the pre-check and this line); catch
1301            // that specific failure and render the same 409 page
1302            // instead of a generic 500.
1303            if let Err(e) = T::delete(&db, id).await {
1304                if is_foreign_key_violation(&e) {
1305                    let registry = current_registry();
1306                    let counts = fetch_inverse_counts(&db, T::singular_name(), id, &registry).await;
1307                    let blockers: Vec<(&relations::InverseRelation, i64)> = registry
1308                        .has_many(T::singular_name())
1309                        .iter()
1310                        .filter_map(|inv| {
1311                            let key = format!("{}.{}", inv.source_model, inv.source_field);
1312                            counts
1313                                .get(&key)
1314                                .copied()
1315                                .filter(|n| *n > 0)
1316                                .map(|n| (inv, n))
1317                        })
1318                        .collect();
1319                    let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1320                    return Ok::<Response, Error>(render_delete_blocked_page::<T>(
1321                        &shell, id, &primary, &blockers,
1322                    ));
1323                }
1324                return Err(e);
1325            }
1326
1327            audit::record(
1328                &db,
1329                audit::LogEntry {
1330                    user_id,
1331                    action_type: audit::ActionType::Delete,
1332                    model_name: T::ADMIN_NAME,
1333                    object_id: id,
1334                    ip_address: peer_ip.as_deref(),
1335                    summary: audit_summary(
1336                        audit::ActionType::Delete,
1337                        T::singular_name(),
1338                        id,
1339                        &primary,
1340                    ),
1341                },
1342            )
1343            .await?;
1344
1345            Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1346                "/admin/{}",
1347                T::ADMIN_NAME
1348            ))))
1349        }
1350    });
1351
1352    // --- per-object history (Django parity). Reads from
1353    // `rustio_admin_actions` so every add / change / delete that went
1354    // through the admin is visible, newest first.
1355    let history_db = db.clone();
1356    let history_entries = entries.clone();
1357    router = router.get(&history_path, move |req, params| {
1358        let db = history_db.clone();
1359        let entries = history_entries.clone();
1360        async move {
1361            if let Err(resp) = admin_guard(req.ctx()) {
1362                return Ok(resp);
1363            }
1364            let id = parse_id_param(&params)?;
1365            let item = T::find(&db, id).await?.ok_or(Error::NotFound)?;
1366            let actions = audit::for_object(&db, T::ADMIN_NAME, id).await?;
1367            let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), req.ctx());
1368            Ok::<Response, Error>(object_history_response::<T>(shell, id, &item, &actions))
1369        }
1370    });
1371
1372    // --- bulk actions (Django parity). POST with `_selected` + `action`.
1373    // First POST → render a confirmation page listing the records that
1374    // will be touched. Second POST (with `_confirm=yes`) → perform it.
1375    // Individual audit entries are written per record so per-object
1376    // history stays complete even for bulk operations.
1377    let bulk_db = db.clone();
1378    let bulk_entries = entries.clone();
1379    router = router.post(&bulk_path, move |req, _params| {
1380        let db = bulk_db.clone();
1381        let entries = bulk_entries.clone();
1382        async move {
1383            if let Err(resp) = admin_guard(req.ctx()) {
1384                return Ok(resp);
1385            }
1386            let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
1387            let (_, body, ctx) = req.into_parts();
1388            let form = read_form_from_parts(body).await?;
1389            require_csrf(&ctx, &form)?;
1390            let user_id = ctx
1391                .get::<crate::auth::Identity>()
1392                .map(|i| i.user_id)
1393                .unwrap_or(0);
1394
1395            let action = form.get("action").unwrap_or("").trim().to_string();
1396            let selected_raw = form.get("_selected").unwrap_or("").to_string();
1397            let ids: Vec<i64> = selected_raw
1398                .split(',')
1399                .map(str::trim)
1400                .filter(|s| !s.is_empty())
1401                .filter_map(|s| s.parse::<i64>().ok())
1402                .collect();
1403            let confirmed = form.get("_confirm").map(|v| v == "yes").unwrap_or(false);
1404
1405            if ids.is_empty() || action.is_empty() {
1406                return Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1407                    "/admin/{}",
1408                    T::ADMIN_NAME
1409                ))));
1410            }
1411
1412            if action != "delete" {
1413                return Err(Error::BadRequest(
1414                    format!("Unknown bulk action `{action}`",),
1415                ));
1416            }
1417
1418            // First POST (no `_confirm=yes`): render the intermediate
1419            // confirmation page. User reviews the items and posts again.
1420            if !confirmed {
1421                let mut items: Vec<(i64, String)> = Vec::with_capacity(ids.len());
1422                for id in &ids {
1423                    if let Some(item) = T::find(&db, *id).await? {
1424                        let primary = primary_string_value::<T>(&item);
1425                        items.push((*id, primary));
1426                    }
1427                }
1428                let shell = Shell::from_ctx(&entries, Some(T::ADMIN_NAME), &ctx);
1429                return Ok::<Response, Error>(bulk_delete_confirmation_response::<T>(
1430                    &shell, &items,
1431                ));
1432            }
1433
1434            // Confirmed: delete each record, record an audit entry per
1435            // row so object history remains per-record complete.
1436            for id in &ids {
1437                let primary = match T::find(&db, *id).await? {
1438                    Some(item) => primary_string_value::<T>(&item),
1439                    None => continue, // already gone; skip silently
1440                };
1441                T::delete(&db, *id).await?;
1442
1443                let mut summary =
1444                    audit_summary(audit::ActionType::Delete, T::singular_name(), *id, &primary);
1445                summary.push_str(" (via bulk action)");
1446
1447                audit::record(
1448                    &db,
1449                    audit::LogEntry {
1450                        user_id,
1451                        action_type: audit::ActionType::Delete,
1452                        model_name: T::ADMIN_NAME,
1453                        object_id: *id,
1454                        ip_address: peer_ip.as_deref(),
1455                        summary,
1456                    },
1457                )
1458                .await?;
1459            }
1460
1461            Ok::<Response, Error>(with_admin_headers(redirect(&format!(
1462                "/admin/{}",
1463                T::ADMIN_NAME
1464            ))))
1465        }
1466    });
1467
1468    router
1469}
1470
1471fn parse_id_param(params: &crate::router::Params) -> Result<i64, Error> {
1472    params
1473        .get("id")
1474        .and_then(|s| s.parse::<i64>().ok())
1475        .ok_or_else(|| Error::BadRequest(String::from("invalid id")))
1476}
1477
1478/// Best-effort display string for an item's primary identifier, used in
1479/// audit summaries. Returns the first editable `String` field's value,
1480/// or an empty string when none is available.
1481fn primary_string_value<T: AdminModel>(item: &T) -> String {
1482    T::FIELDS
1483        .iter()
1484        .find(|f| f.editable && matches!(f.ty, FieldType::String))
1485        .and_then(|f| item.field_display(f.name))
1486        .filter(|s| !s.is_empty())
1487        .unwrap_or_default()
1488}
1489
1490/// Compose a human-readable audit line, e.g.
1491/// `Created Task #5: Ship 0.4.0` or `Deleted User #12`.
1492fn audit_summary(action: audit::ActionType, singular: &str, id: i64, primary: &str) -> String {
1493    let verb = action.label();
1494    if primary.is_empty() {
1495        format!("{verb} {singular} #{id}")
1496    } else {
1497        format!("{verb} {singular} #{id}: {primary}")
1498    }
1499}
1500
1501// ---------------------------------------------------------------------------
1502// Constants + context helpers
1503// ---------------------------------------------------------------------------
1504
1505/// Maximum size of an admin form body.
1506pub const MAX_FORM_BODY_BYTES: usize = crate::http::MAX_REQUEST_BODY_BYTES;
1507
1508/// Name of the hidden form field that carries the per-session CSRF token.
1509pub const CSRF_FIELD: &str = "_csrf";
1510
1511fn ctx_csrf(ctx: &crate::context::Context) -> Option<&str> {
1512    ctx.get::<crate::auth::CsrfToken>().map(|t| t.0.as_str())
1513}
1514
1515fn ctx_user_email(ctx: &crate::context::Context) -> Option<&str> {
1516    ctx.get::<crate::auth::Identity>().map(|i| i.email.as_str())
1517}
1518
1519fn csrf_input(csrf: Option<&str>) -> String {
1520    match csrf {
1521        Some(token) if !token.is_empty() => format!(
1522            r#"<input type="hidden" name="{name}" value="{value}">"#,
1523            name = CSRF_FIELD,
1524            value = escape_html(token),
1525        ),
1526        _ => String::new(),
1527    }
1528}
1529
1530fn require_csrf(ctx: &crate::context::Context, form: &FormData) -> Result<(), Error> {
1531    let expected = ctx
1532        .get::<crate::auth::CsrfToken>()
1533        .map(|t| t.0.as_str())
1534        .unwrap_or("");
1535    let provided = form.get(CSRF_FIELD).unwrap_or("");
1536    if !crate::auth::csrf::verify_token(expected, provided) {
1537        return Err(Error::Forbidden);
1538    }
1539    Ok(())
1540}
1541
1542fn with_admin_headers(mut resp: Response) -> Response {
1543    use hyper::header::HeaderValue;
1544    let h = resp.headers_mut();
1545    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
1546    h.insert(
1547        "x-content-type-options",
1548        HeaderValue::from_static("nosniff"),
1549    );
1550    h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
1551    if crate::auth::in_production() {
1552        h.insert(
1553            "strict-transport-security",
1554            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1555        );
1556    }
1557    resp
1558}
1559
1560async fn read_form(req: Request) -> Result<FormData, Error> {
1561    let (_, body, _) = req.into_parts();
1562    read_form_from_parts(body).await
1563}
1564
1565async fn read_form_from_parts(body: hyper::body::Incoming) -> Result<FormData, Error> {
1566    let limited = http_body_util::Limited::new(body, MAX_FORM_BODY_BYTES);
1567    let collected = limited.collect().await.map_err(|e| {
1568        if e.downcast_ref::<http_body_util::LengthLimitError>()
1569            .is_some()
1570        {
1571            Error::PayloadTooLarge
1572        } else {
1573            Error::BadRequest(e.to_string())
1574        }
1575    })?;
1576    let bytes = collected.to_bytes();
1577    let body_str = std::str::from_utf8(&bytes).map_err(|e| Error::BadRequest(e.to_string()))?;
1578    Ok(FormData::parse(body_str))
1579}
1580
1581fn redirect(to: &str) -> Response {
1582    hyper::Response::builder()
1583        .status(303)
1584        .header("location", to)
1585        .body(Full::new(Bytes::new()))
1586        .expect("valid redirect")
1587}
1588
1589/// Serve the bundled admin stylesheet.
1590///
1591/// We use `no-cache, must-revalidate` rather than a long-lived
1592/// `max-age` because the CSS is baked into the binary via
1593/// `include_str!` — when a project redeploys, the CSS may have
1594/// changed, and a stale browser cache would silently render the old
1595/// UI for up to the cache lifetime. `no-cache` forces the browser to
1596/// re-send the request every time, but the 304 short-circuit on the
1597/// cheap ETag keeps the wire cost near-zero for repeat loads.
1598///
1599/// The ETag fingerprints the compiled CSS bytes so it changes every
1600/// time the binary is rebuilt with a different stylesheet. Browsers
1601/// sending `If-None-Match` get 304 Not Modified.
1602fn admin_css_response() -> Response {
1603    use hyper::header::HeaderValue;
1604    let body = ADMIN_CSS_BUNDLE.as_bytes();
1605    // Cheap stable fingerprint: length + first/last 4 bytes. Good
1606    // enough to change whenever the CSS content changes; avoids
1607    // pulling a hash crate. `'W/'` prefix marks it as a weak ETag
1608    // (byte-for-byte equivalence not guaranteed across CDNs).
1609    let etag = {
1610        let len = body.len();
1611        let head = u32::from_le_bytes([
1612            *body.first().unwrap_or(&0),
1613            *body.get(1).unwrap_or(&0),
1614            *body.get(2).unwrap_or(&0),
1615            *body.get(3).unwrap_or(&0),
1616        ]);
1617        let tail = u32::from_le_bytes([
1618            *body.get(len.saturating_sub(4)).unwrap_or(&0),
1619            *body.get(len.saturating_sub(3)).unwrap_or(&0),
1620            *body.get(len.saturating_sub(2)).unwrap_or(&0),
1621            *body.get(len.saturating_sub(1)).unwrap_or(&0),
1622        ]);
1623        format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1624    };
1625    let mut resp = hyper::Response::builder()
1626        .status(200)
1627        .header("content-type", "text/css; charset=utf-8")
1628        .header("cache-control", "no-cache, must-revalidate")
1629        .header("etag", etag)
1630        .body(Full::new(Bytes::from_static(ADMIN_CSS_BUNDLE.as_bytes())))
1631        .expect("valid css response");
1632    let h = resp.headers_mut();
1633    // Nosniff on the stylesheet too — an attacker who can upload to an
1634    // admin-adjacent endpoint mustn't get the browser to execute it.
1635    h.insert(
1636        "x-content-type-options",
1637        HeaderValue::from_static("nosniff"),
1638    );
1639    resp
1640}
1641
1642/// Serve the admin favicon — a scalable SVG. Cached for a day because
1643/// the brand mark changes rarely (and only with a redeploy), and the
1644/// bytes are tiny. `nosniff` for the same reason the CSS has it: an
1645/// attacker must not trick the browser into executing it as script.
1646/// `<span class="rio-env-chip">` markup, rendered consistently
1647/// everywhere the admin shell appears (main pages *and* the auth
1648/// shell). Signed-out operators still see whether they're logging
1649/// into dev or prod, which closes a small phishing surface — an
1650/// attacker who puts up a lookalike dev admin can't hide the chip.
1651fn env_chip_html() -> String {
1652    if crate::auth::in_production() {
1653        r#"<span class="rio-env-chip is-prod">production</span>"#.to_string()
1654    } else {
1655        r#"<span class="rio-env-chip">development</span>"#.to_string()
1656    }
1657}
1658
1659/// Generic static-asset response for the 0.10+ `/admin/static/…` bundle.
1660/// The bytes are pinned to the compiled binary, so a long-ish cache is
1661/// safe — when the binary redeploys, the content-length changes and
1662/// the etag changes with it. `nosniff` for the same reason every other
1663/// admin asset has it.
1664fn bundled_asset_response(bytes: &'static [u8], content_type: &'static str) -> Response {
1665    use hyper::header::HeaderValue;
1666    let etag = {
1667        let len = bytes.len();
1668        let head = u32::from_le_bytes([
1669            *bytes.first().unwrap_or(&0),
1670            *bytes.get(1).unwrap_or(&0),
1671            *bytes.get(2).unwrap_or(&0),
1672            *bytes.get(3).unwrap_or(&0),
1673        ]);
1674        let tail = u32::from_le_bytes([
1675            *bytes.get(len.saturating_sub(4)).unwrap_or(&0),
1676            *bytes.get(len.saturating_sub(3)).unwrap_or(&0),
1677            *bytes.get(len.saturating_sub(2)).unwrap_or(&0),
1678            *bytes.get(len.saturating_sub(1)).unwrap_or(&0),
1679        ]);
1680        format!("W/\"rio-{len}-{head:x}-{tail:x}\"")
1681    };
1682    let mut resp = hyper::Response::builder()
1683        .status(200)
1684        .header("content-type", content_type)
1685        .header("cache-control", "public, max-age=3600")
1686        .header("etag", etag)
1687        .body(Full::new(Bytes::from_static(bytes)))
1688        .expect("valid static asset response");
1689    resp.headers_mut().insert(
1690        "x-content-type-options",
1691        HeaderValue::from_static("nosniff"),
1692    );
1693    resp
1694}
1695
1696fn admin_favicon_response() -> Response {
1697    use hyper::header::HeaderValue;
1698    let mut resp = hyper::Response::builder()
1699        .status(200)
1700        .header("content-type", "image/svg+xml")
1701        .header("cache-control", "public, max-age=86400")
1702        .body(Full::new(Bytes::from_static(ADMIN_FAVICON_SVG.as_bytes())))
1703        .expect("valid favicon response");
1704    resp.headers_mut().insert(
1705        "x-content-type-options",
1706        HeaderValue::from_static("nosniff"),
1707    );
1708    resp
1709}
1710
1711// ---------------------------------------------------------------------------
1712// Shell (sidebar + topbar) — wraps every authenticated page
1713// ---------------------------------------------------------------------------
1714
1715/// Per-request data the shell needs: registered models (for the sidebar),
1716/// which one is active (for highlight), the signed-in user email (for
1717/// the sidebar footer), and the session's CSRF token (for the logout
1718/// form). Cheaply constructed from a request context.
1719struct Shell<'a> {
1720    entries: &'a [AdminEntry],
1721    active: Option<&'a str>,
1722    user_email: Option<&'a str>,
1723    csrf: Option<&'a str>,
1724}
1725
1726impl<'a> Shell<'a> {
1727    fn from_ctx(
1728        entries: &'a [AdminEntry],
1729        active: Option<&'a str>,
1730        ctx: &'a crate::context::Context,
1731    ) -> Self {
1732        Self {
1733            entries,
1734            active,
1735            user_email: ctx_user_email(ctx),
1736            csrf: ctx_csrf(ctx),
1737        }
1738    }
1739}
1740
1741/// One breadcrumb segment: `(label, optional href)`. The last segment
1742/// is rendered as the current page (no link).
1743type Crumb<'a> = (&'a str, Option<&'a str>);
1744
1745fn render_breadcrumbs(crumbs: &[Crumb<'_>]) -> String {
1746    if crumbs.is_empty() {
1747        return String::new();
1748    }
1749    let sep = format!(
1750        r#"<span class="rio-crumb-sep">{}</span>"#,
1751        icon_chevron_right()
1752    );
1753    let mut out = String::from(r#"<nav class="rio-breadcrumbs" aria-label="Breadcrumb">"#);
1754    for (i, (label, href)) in crumbs.iter().enumerate() {
1755        let is_last = i == crumbs.len() - 1;
1756        if i > 0 {
1757            out.push_str(&sep);
1758        }
1759        match (is_last, href) {
1760            (true, _) => {
1761                out.push_str(&format!(
1762                    r#"<span class="rio-crumb-current" aria-current="page">{}</span>"#,
1763                    escape_html(label),
1764                ));
1765            }
1766            (false, Some(h)) => {
1767                out.push_str(&format!(
1768                    r#"<a href="{}">{}</a>"#,
1769                    escape_html(h),
1770                    escape_html(label),
1771                ));
1772            }
1773            (false, None) => {
1774                out.push_str(&escape_html(label));
1775            }
1776        }
1777    }
1778    out.push_str("</nav>");
1779    out
1780}
1781
1782/// Sentinel passed to `Shell::active` for the built-in "Recent
1783/// actions" page. Prefixed with `__` so it cannot collide with any
1784/// user model's `admin_name` (which is lowercased, alphanumeric).
1785const NAV_ACTIONS: &str = "__actions";
1786
1787/// Humanise a macro-generated `DISPLAY_NAME` for sidebar/dashboard
1788/// display. The macro outputs Rust's camel/pascal plural (e.g.
1789/// `VitalSigns`, `AppointmentEvents`, `Staffs`, `CheckIns`) which
1790/// reads awkwardly as a nav label. This helper:
1791///
1792///   * inserts a space before every internal uppercase letter, so
1793///     `VitalSigns` → `Vital Signs`, `AppointmentEvents` → `Appointment Events`,
1794///     `MedicalRecords` → `Medical Records`, `CheckIns` → `Check Ins`;
1795///   * fixes the two English pluralisations the macro gets wrong:
1796///     `Staffs` → `Staff` (already a mass noun), `Diagnosis` →
1797///     `Diagnoses`.
1798///
1799/// Returns `String` because some transformations (the two special
1800/// cases) substitute; we avoid returning `Cow<str>` to keep the
1801/// call site mono-morphic.
1802fn humanise_model_label(name: &str) -> String {
1803    // Two hard-coded fixes first — English plural edge-cases the
1804    // macro cannot infer.
1805    if name == "Staffs" {
1806        return "Staff".to_string();
1807    }
1808    if name == "Diagnosis" {
1809        return "Diagnoses".to_string();
1810    }
1811    // Insert a space before each internal uppercase letter.
1812    let mut out = String::with_capacity(name.len() + 4);
1813    for (i, ch) in name.chars().enumerate() {
1814        if i > 0 && ch.is_ascii_uppercase() {
1815            out.push(' ');
1816        }
1817        out.push(ch);
1818    }
1819    out
1820}
1821
1822fn render_sidebar(shell: &Shell<'_>) -> String {
1823    let design = design::Design::global();
1824    let user_facing: Vec<&AdminEntry> = shell.entries.iter().filter(|e| !e.core).collect();
1825
1826    let mut models_html = String::new();
1827    if !user_facing.is_empty() {
1828        models_html.push_str(r#"<div class="rio-nav">"#);
1829        models_html.push_str(r#"<div class="rio-nav-section">Models</div>"#);
1830        for e in &user_facing {
1831            let active_cls = if shell.active == Some(e.admin_name) {
1832                "rio-nav-link is-active"
1833            } else {
1834                "rio-nav-link"
1835            };
1836            models_html.push_str(&format!(
1837                r#"<a class="{cls}" href="/admin/{name}">{icon}<span>{label}</span></a>"#,
1838                cls = active_cls,
1839                name = escape_html(e.admin_name),
1840                icon = icon_layers(),
1841                label = escape_html(&humanise_model_label(e.display_name)),
1842            ));
1843        }
1844        models_html.push_str("</div>");
1845    }
1846
1847    // Dashboard is active by default (no model page, no built-in
1848    // sentinel). Recent-actions and the other built-ins set explicit
1849    // sentinel strings so they can claim their own highlight without
1850    // this falling through and lighting up Dashboard on their pages.
1851    let dashboard_active = if shell.active.is_none() {
1852        "rio-nav-link is-active"
1853    } else {
1854        "rio-nav-link"
1855    };
1856    let actions_active = if shell.active == Some(NAV_ACTIONS) {
1857        "rio-nav-link is-active"
1858    } else {
1859        "rio-nav-link"
1860    };
1861
1862    let logout_form = if shell.csrf.is_some() {
1863        format!(
1864            r#"<form class="rio-sidebar-logout" method="post" action="/admin/logout">
1865{csrf}
1866<button type="submit">{icon}<span>Sign out</span></button>
1867</form>"#,
1868            csrf = csrf_input(shell.csrf),
1869            icon = icon_logout(),
1870        )
1871    } else {
1872        String::new()
1873    };
1874
1875    let email = shell.user_email.unwrap_or("");
1876    let avatar_initial = email
1877        .chars()
1878        .next()
1879        .map(|c| c.to_ascii_uppercase().to_string())
1880        .unwrap_or_else(|| String::from("·"));
1881
1882    let user_block = if shell.user_email.is_some() {
1883        // The user chip is clickable → /admin/profile. Matches
1884        // Django's "Welcome, name" dropdown in the header, but fits
1885        // our sidebar footer.
1886        format!(
1887            r#"<a class="rio-sidebar-user" href="/admin/profile" title="Your profile">
1888<span class="rio-avatar">{avatar}</span>
1889<span class="rio-user-email">{email}</span>
1890</a>"#,
1891            avatar = escape_html(&avatar_initial),
1892            email = escape_html(email),
1893        )
1894    } else {
1895        String::new()
1896    };
1897
1898    format!(
1899        r#"<aside class="rio-sidebar">
1900<div class="rio-sidebar-inner">
1901<a class="rio-brand" href="/admin">
1902<span class="rio-brand-mark">{logo}</span>
1903<span class="rio-brand-meta">
1904<span class="rio-brand-name">{project}</span>
1905<span class="rio-brand-label">Admin</span>
1906</span>
1907</a>
1908<nav class="rio-nav">
1909<a class="{dash}" href="/admin">{dash_icon}<span>Dashboard</span></a>
1910<a class="{actions}" href="/admin/actions">{actions_icon}<span>Recent actions</span></a>
1911</nav>
1912{models}
1913<div class="rio-sidebar-footer">
1914{user}
1915{logout}
1916</div>
1917</div>
1918</aside>"#,
1919        logo = escape_html(&design.logo_initial),
1920        project = escape_html(&design.project_name),
1921        dash = dashboard_active,
1922        dash_icon = icon_dashboard(),
1923        actions = actions_active,
1924        actions_icon = icon_activity(),
1925        models = models_html,
1926        user = user_block,
1927        logout = logout_form,
1928    )
1929}
1930
1931/// Render an authenticated shell page. Used for dashboard, list, form,
1932/// and delete-confirmation responses.
1933#[allow(clippy::too_many_arguments)]
1934fn render_shell_page(
1935    shell: &Shell<'_>,
1936    status: u16,
1937    document_title: &str,
1938    page_title: &str,
1939    page_subtitle: Option<&str>,
1940    breadcrumbs: &[Crumb<'_>],
1941    actions: &str,
1942    body: &str,
1943) -> Response {
1944    let design = design::Design::global();
1945
1946    let sidebar = render_sidebar(shell);
1947    let crumbs = render_breadcrumbs(breadcrumbs);
1948
1949    let env_chip = env_chip_html();
1950
1951    // Topbar action cluster: env-chip + Home + Notifications bell +
1952    // Messages + Logout button with a visible "Logout" label.
1953    // Rendered only when we have an identity (i.e. a CSRF token to
1954    // back the logout form). On unauthenticated error pages the
1955    // cluster collapses to just the env-chip.
1956    let topbar_actions = match shell.csrf {
1957        Some(csrf) => format!(
1958            r#"<div class="rio-topbar-actions">
1959{env}
1960<a class="rio-topbar-icon" href="/admin" title="Home" aria-label="Home">{home}</a>
1961<button class="rio-topbar-icon" type="button" title="Notifications" aria-label="Notifications">{bell}<span class="rio-topbar-dot"></span></button>
1962<button class="rio-topbar-icon" type="button" title="Messages" aria-label="Messages">{mail}</button>
1963<form class="rio-topbar-logout" method="post" action="/admin/logout">
1964<input type="hidden" name="_csrf" value="{csrf_val}">
1965<button type="submit" title="Sign out">{logout}<span>Logout</span></button>
1966</form>
1967</div>"#,
1968            env = env_chip,
1969            home = icon_home(),
1970            bell = icon_bell(),
1971            mail = icon_mail(),
1972            logout = icon_logout(),
1973            csrf_val = escape_html(csrf),
1974        ),
1975        None => format!(
1976            r#"<div class="rio-topbar-actions">{env}</div>"#,
1977            env = env_chip
1978        ),
1979    };
1980
1981    let subtitle_html = page_subtitle
1982        .map(|s| format!(r#"<p class="rio-page-subtitle">{}</p>"#, escape_html(s)))
1983        .unwrap_or_default();
1984
1985    let actions_block = if actions.is_empty() {
1986        String::new()
1987    } else {
1988        format!(r#"<div class="rio-page-actions">{actions}</div>"#)
1989    };
1990
1991    let theme_style = format!(
1992        "\n:root {{\n  --rio-primary: {p};\n  --rio-primary-hover: {ph};\n  --rio-accent: {a};\n  --rio-accent-hover: {ah};\n}}\n",
1993        p = escape_css_color(&design.primary_color),
1994        ph = escape_css_color(&design.primary_color),
1995        a = escape_css_color(&design.accent_color),
1996        ah = escape_css_color(&design.accent_color),
1997    );
1998
1999    let density_class = match design.density {
2000        design::Density::Comfortable => "",
2001        design::Density::Compact => " rio-density-compact",
2002    };
2003
2004    let body_html = format!(
2005        r#"<!doctype html>
2006<html lang="en">
2007<head>
2008<meta charset="utf-8">
2009<meta name="viewport" content="width=device-width, initial-scale=1">
2010<title>{doc_title} · {project}</title>
2011<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
2012<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
2013<style>{theme}</style>
2014</head>
2015<body class="rio-body{density}">
2016<div class="rio-app">
2017{sidebar}
2018<main class="rio-main">
2019<div class="rio-container">
2020<header class="rio-topbar">
2021{crumbs}
2022{topbar_actions}
2023</header>
2024<div class="rio-page-header">
2025<div>
2026<h1 class="rio-page-title">{page_title}</h1>
2027{subtitle}
2028</div>
2029{actions}
2030</div>
2031{body}
2032</div>
2033</main>
2034</div>
2035<script>
2036// Admin Intelligence Layer (0.7.0) — minimal JS for PII toggle.
2037// Click a .rio-pii-toggle to reveal / hide the adjacent masked value.
2038document.addEventListener("click", function(e){{
2039  var btn = e.target.closest ? e.target.closest(".rio-pii-toggle") : null;
2040  if(!btn) return;
2041  // The masked <span> is the button's previous sibling by construction.
2042  var span = btn.previousElementSibling;
2043  if(!span || !span.classList.contains("rio-pii")) return;
2044  if(span.getAttribute("data-hidden") === "1"){{
2045    span.textContent = span.getAttribute("data-value") || "";
2046    span.setAttribute("data-hidden","0");
2047    btn.textContent = "hide";
2048  }} else {{
2049    span.textContent = span.getAttribute("data-mask") || "";
2050    span.setAttribute("data-hidden","1");
2051    btn.textContent = "show";
2052  }}
2053}});
2054</script>
2055</body>
2056</html>"#,
2057        doc_title = escape_html(document_title),
2058        project = escape_html(&design.project_name),
2059        theme = theme_style,
2060        density = density_class,
2061        sidebar = sidebar,
2062        crumbs = crumbs,
2063        topbar_actions = topbar_actions,
2064        page_title = escape_html(page_title),
2065        subtitle = subtitle_html,
2066        actions = actions_block,
2067        body = body,
2068        css_ver = ADMIN_CSS_VER,
2069    );
2070
2071    let resp = hyper::Response::builder()
2072        .status(status)
2073        .header("content-type", "text/html; charset=utf-8")
2074        // Kill all HTML caching so the browser always fetches a fresh
2075        // admin page and never paints a stale shell. The CSS bundle
2076        // has its own `?v=<len>` cache-buster; these headers close
2077        // the remaining gap on the HTML itself.
2078        .header(
2079            "cache-control",
2080            "no-store, no-cache, must-revalidate, max-age=0",
2081        )
2082        .header("pragma", "no-cache")
2083        .header("expires", "0")
2084        .body(Full::new(Bytes::from(body_html)))
2085        .expect("valid response");
2086    with_admin_headers(resp)
2087}
2088
2089/// Minimal sanitiser for CSS colour tokens emitted inline in a
2090/// `<style>` block. We reject any input containing `;`, `{`, `}`, `<`,
2091/// or backslash to prevent a breakout from the CSS context into another
2092/// attribute or the HTML parser. Returns the original string when safe,
2093/// or a fallback token when suspicious.
2094fn escape_css_color(s: &str) -> &str {
2095    if s.contains([';', '{', '}', '<', '\\']) {
2096        // Fall back to the default primary colour.
2097        "#0f172a"
2098    } else {
2099        s
2100    }
2101}
2102
2103// ---------------------------------------------------------------------------
2104// Dashboard (admin index)
2105// ---------------------------------------------------------------------------
2106
2107/// Register a config-driven model under its own slug. The closure
2108/// captures `cfg` and clones it on every lookup, so the registry's
2109/// `Fn` factory can build a fresh `Box<dyn AdminUiModel>` per
2110/// request without any global state.
2111pub fn register_generated(
2112    registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2113    cfg: crate::admin::admin_generator::AdminModelConfig,
2114) {
2115    let slug = cfg.slug;
2116    registry.register(slug, move || {
2117        crate::admin::admin_generator::from_config(cfg.clone())
2118    });
2119}
2120
2121/// Schema-driven counterpart to [`register_generated`]. Reads the
2122/// table's columns via `PRAGMA table_info`, builds an
2123/// [`AdminModelConfig`](crate::admin::admin_generator::AdminModelConfig)
2124/// from them, and registers it under the derived slug. The table
2125/// must already exist — this helper does **not** issue
2126/// `CREATE TABLE`.
2127pub async fn register_from_table(
2128    db: &Db,
2129    registry: &mut crate::admin::admin_form_bridge::AdminRegistry,
2130    table: &str,
2131) -> Result<(), Error> {
2132    let cfg = crate::admin::schema_introspect::generate_from_table(db, table).await?;
2133    register_generated(registry, cfg);
2134    Ok(())
2135}
2136
2137/// Context-aware hint shown on an empty list page. Combines the
2138/// country (e.g. "In Sweden…") with the active industry conventions
2139/// (e.g. "…usually include personnummer and income"). Returns `None`
2140/// when the project has no context or the current model doesn't
2141/// intersect any known convention — better to stay silent than
2142/// generic.
2143fn empty_state_hint<T: AdminModel>(context: Option<&crate::ai::ContextConfig>) -> Option<String> {
2144    let ctx = context?;
2145    let schema = ctx.industry_schema()?;
2146    // Only emit the hint when the current model actually overlaps the
2147    // industry's required fields — a hint about personnummer on a
2148    // `Widget` model is noise.
2149    let model_has_convention = schema
2150        .required_fields
2151        .iter()
2152        .any(|f| T::FIELDS.iter().any(|af| af.name == f.as_str()));
2153    if !model_has_convention {
2154        return None;
2155    }
2156    let country_phrase = match ctx.country.as_deref() {
2157        Some(cc) if cc.eq_ignore_ascii_case("SE") => "In Sweden, ",
2158        Some(cc) if cc.eq_ignore_ascii_case("NO") => "In Norway, ",
2159        _ => "",
2160    };
2161    let industry = ctx.industry.as_deref().unwrap_or("");
2162    let singular_lower = T::singular_name().to_lowercase();
2163    let fields_list = schema.required_fields.join(", ");
2164    Some(format!(
2165        "{country}{industry} {singular}s usually include {fields}.",
2166        country = country_phrase,
2167        industry = industry,
2168        singular = singular_lower,
2169        fields = fields_list,
2170    ))
2171}
2172
2173// ---------------------------------------------------------------------------
2174// List page
2175// ---------------------------------------------------------------------------
2176
2177/// All filter state for a list page in one bundle — lets the handler
2178/// pass params cleanly and lets the renderer reflect them back in the
2179/// form (so the search box keeps its value after submission).
2180struct ListFilters<'a> {
2181    q: Option<&'a str>,
2182    status: Option<&'a str>,
2183    status_options: &'a [String],
2184    priority: Option<&'a str>,
2185    priority_options: &'a [String],
2186    /// Current sort mode (`newest` | `oldest` | `id_asc` | `id_desc`).
2187    /// `None` → default (newest first).
2188    sort: Option<&'a str>,
2189    /// Phase 5 — one entry per `belongs_to` relation owned by the
2190    /// model, carrying either a dropdown option list or a signal to
2191    /// render a numeric fallback input.
2192    relation_filters: &'a [RelationFilterState],
2193    /// Column-visibility set used by [`list_response`] to filter
2194    /// `T::FIELDS` when rendering the `<thead>` and row cells. Fed
2195    /// from [`default_list_columns`].
2196    visible_columns: &'a [&'static str],
2197}
2198
2199/// Phase 5 — how one relation-aware filter should render. Assembled
2200/// by the list handler per request and consumed by the toolbar.
2201struct RelationFilterState {
2202    /// FK column name on the source model (e.g. `"patient_id"`).
2203    field_name: String,
2204    /// Human-readable label shown above the control (e.g. `"Patient"`).
2205    label: String,
2206    /// Currently-selected FK value pulled from the query string.
2207    current_value: Option<i64>,
2208    /// Rendering mode — determined by the row count of the target.
2209    mode: RelationFilterMode,
2210}
2211
2212enum RelationFilterMode {
2213    /// Target has at most [`relations::RELATION_FILTER_DROPDOWN_CAP`]
2214    /// rows AND declares a `display_field`. The dropdown lists
2215    /// `(id, display)` pairs.
2216    Dropdown { options: Vec<(i64, String)> },
2217    /// Either too many rows or no `display_field` declared — the
2218    /// admin renders a numeric input instead. `too_many` toggles the
2219    /// hint copy so an operator knows *why* they got the fallback.
2220    Numeric { too_many: bool },
2221}
2222
2223impl ListFilters<'_> {
2224    fn is_active(&self) -> bool {
2225        self.q.is_some()
2226            || self.status.is_some()
2227            || self.priority.is_some()
2228            || self.sort.is_some()
2229            || self
2230                .relation_filters
2231                .iter()
2232                .any(|r| r.current_value.is_some())
2233    }
2234}
2235
2236/// The fixed sort vocabulary the toolbar exposes. Kept as
2237/// `(value, label)` tuples so the renderer can build a `<select>`
2238/// with the correct `<option selected>` mark from this single source.
2239const SORT_OPTIONS: &[(&str, &str)] = &[
2240    ("newest", "Newest first"),
2241    ("oldest", "Oldest first"),
2242    ("id_asc", "ID ↑"),
2243    ("id_desc", "ID ↓"),
2244];
2245
2246// ---------------------------------------------------------------------------
2247// Relation resolution helpers
2248// ---------------------------------------------------------------------------
2249
2250/// Map keyed by FK field name → (referenced id → display label).
2251/// Built once per list/detail render by [`fetch_fk_labels`]. An empty
2252/// outer map, or a missing inner entry, is the "fall back to #<id>"
2253/// signal to the renderer — no display_field was declared, or the
2254/// target row has been deleted, or the prefetch query failed. The
2255/// renderer never panics on a missing lookup.
2256type FkLabels = std::collections::HashMap<String, std::collections::HashMap<i64, String>>;
2257
2258/// Per-form dropdown option sets: FK field name → ordered list of
2259/// `(id, label)` pairs fetched from the target table. Populated by
2260/// [`fetch_form_relation_options`] and consumed by [`render_field`].
2261///
2262/// A field is present in the map only when:
2263///   * the field carries `#[rustio(belongs_to = "…")]`,
2264///   * the target declares a `display_field`,
2265///   * the target has `<= RELATION_FILTER_DROPDOWN_CAP` rows.
2266///
2267/// Absence from the map means the form renderer falls back to a raw
2268/// `<input type="number">` — same UX as the list-page filter's
2269/// `Numeric { too_many }` mode.
2270type FormRelationOptions = std::collections::HashMap<String, Vec<(i64, String)>>;
2271
2272/// Bundle passed into [`render_cell`] so it can resolve foreign-key
2273/// columns without further DB work. Construction sites own the
2274/// registry and the prefetched labels.
2275struct CellCtx<'a> {
2276    registry: &'a relations::RelationRegistry,
2277    fk_labels: &'a FkLabels,
2278}
2279
2280impl CellCtx<'_> {
2281    /// An empty context — registry with no relations, no labels.
2282    /// Used by tests and by call sites that haven't yet plumbed the
2283    /// registry through. Renders behave exactly like the pre-0.9.0
2284    /// admin (every FK column is a raw number).
2285    fn empty() -> CellCtx<'static> {
2286        static EMPTY_REG: std::sync::OnceLock<relations::RelationRegistry> =
2287            std::sync::OnceLock::new();
2288        static EMPTY_LABELS: std::sync::OnceLock<FkLabels> = std::sync::OnceLock::new();
2289        CellCtx {
2290            registry: EMPTY_REG.get_or_init(relations::RelationRegistry::empty),
2291            fk_labels: EMPTY_LABELS.get_or_init(std::collections::HashMap::new),
2292        }
2293    }
2294}
2295
2296/// Snapshot the current registry from the in-memory schema cache.
2297/// Cheap: HashMap construction over a handful of models. Called on
2298/// every admin list / detail / delete handler that needs relation
2299/// awareness; no background task, no persistent cache.
2300fn current_registry() -> relations::RelationRegistry {
2301    match schema_cache::snapshot() {
2302        Some(c) => relations::RelationRegistry::from_schema(&c.schema),
2303        None => relations::RelationRegistry::empty(),
2304    }
2305}
2306
2307/// Prefetch display labels for every `belongs_to` relation owned by
2308/// `T` whose target declares a `display_field`. Emits one `SELECT`
2309/// per FK column; IDs are collected from the current result set,
2310/// deduplicated, and batched into an `IN (…)` clause.
2311///
2312/// ## v1-NOTE — query strategy
2313///
2314/// This is a 1-plus-K strategy: one list query plus K single-column
2315/// FK lookups, where K is the number of FK columns on the model that
2316/// carry a `display_field`. Fine for realistic row counts and avoids
2317/// N+1. The first future optimisation is to rewrite the list query as
2318/// a `LEFT JOIN` onto every target carrying a display_field,
2319/// projecting `display_field` as an aliased column and collapsing
2320/// every round-trip into one. That change requires reworking the
2321/// list query builder, which is out of scope for this pass.
2322/// Dependent-reference counts per inverse relation for one target row.
2323/// Keyed by source-field qualified id: `"<source_model>.<source_field>"`.
2324/// Used by both the Phase 4 inverse panel renderer and the Phase 6
2325/// delete guard — they ask the same question ("how many rows point
2326/// at this one?") and share the same map.
2327type InverseCounts = std::collections::HashMap<String, i64>;
2328
2329/// Count every row that holds a foreign key into `(target_model,
2330/// target_id)`. Issues **one `SELECT COUNT(*)` per incoming edge**;
2331/// the result is small (a handful of i64s) and cached into
2332/// [`InverseCounts`] for the rest of the request.
2333///
2334/// v1-NOTE: per-edge counting is the simplest correct shape. A
2335/// future version could batch these into a single UNION query or
2336/// push the counts into the existing list query as aggregated
2337/// sub-selects — same optimisation space as FK label prefetch.
2338async fn fetch_inverse_counts(
2339    db: &Db,
2340    target_model: &str,
2341    target_id: i64,
2342    registry: &relations::RelationRegistry,
2343) -> InverseCounts {
2344    use sqlx::Row;
2345    let mut out: InverseCounts = std::collections::HashMap::new();
2346    for inv in registry.has_many(target_model) {
2347        let sql = format!(
2348            "SELECT COUNT(*) AS rio_count FROM \"{table}\" WHERE \"{col}\" = ?",
2349            table = inv.source_table,
2350            col = inv.source_field,
2351        );
2352        let row = match sqlx::query(&sql).bind(target_id).fetch_one(db.pool()).await {
2353            Ok(r) => r,
2354            Err(_) => continue,
2355        };
2356        let count: i64 = row.try_get::<i64, _>("rio_count").unwrap_or_default();
2357        out.insert(format!("{}.{}", inv.source_model, inv.source_field), count);
2358    }
2359    out
2360}
2361
2362/// Build one [`RelationFilterState`] per `belongs_to` relation on `T`.
2363/// Issues **one `SELECT id, <display> FROM <target> LIMIT cap+1` per
2364/// relation**. When the result hits `cap+1` rows the state falls
2365/// back to numeric-input mode and the admin renders the hint
2366/// explaining why. When the target has no declared `display_field`,
2367/// we also fall back to numeric input because there is no safe label
2368/// to render.
2369async fn build_relation_filters<T: AdminModel>(
2370    db: &Db,
2371    registry: &relations::RelationRegistry,
2372    query: &FormData,
2373) -> Vec<RelationFilterState> {
2374    use sqlx::Row;
2375    let mut out: Vec<RelationFilterState> = Vec::new();
2376    let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2377    for resolved in registry.belongs_to_of(T::singular_name()) {
2378        // Pull the currently-selected id from the query string.
2379        let current_value = query
2380            .get(&resolved.source_field)
2381            .and_then(|v| v.parse::<i64>().ok());
2382
2383        // Determine the mode.
2384        let mode = match &resolved.target_display_field {
2385            None => RelationFilterMode::Numeric { too_many: false },
2386            Some(display_col) => {
2387                // LIMIT cap+1 so we can detect "above cap" without a
2388                // separate COUNT query.
2389                let sql = format!(
2390                    "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2391                    col = display_col,
2392                    table = resolved.target_table,
2393                    lim = cap + 1,
2394                );
2395                let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2396                    Ok(r) => r,
2397                    Err(_) => {
2398                        // Degrade: SQL failure → numeric fallback.
2399                        out.push(RelationFilterState {
2400                            field_name: resolved.source_field.clone(),
2401                            label: resolved.target_model.clone(),
2402                            current_value,
2403                            mode: RelationFilterMode::Numeric { too_many: false },
2404                        });
2405                        continue;
2406                    }
2407                };
2408                if rows.len() > cap {
2409                    RelationFilterMode::Numeric { too_many: true }
2410                } else {
2411                    let options: Vec<(i64, String)> = rows
2412                        .into_iter()
2413                        .map(|row| {
2414                            let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2415                            let label: String = row
2416                                .try_get::<String, _>("rio_label")
2417                                .or_else(|_| {
2418                                    row.try_get::<i64, _>("rio_label").map(|n| n.to_string())
2419                                })
2420                                .or_else(|_| {
2421                                    row.try_get::<i32, _>("rio_label").map(|n| n.to_string())
2422                                })
2423                                .unwrap_or_default();
2424                            (id, label)
2425                        })
2426                        .collect();
2427                    RelationFilterMode::Dropdown { options }
2428                }
2429            }
2430        };
2431
2432        out.push(RelationFilterState {
2433            field_name: resolved.source_field.clone(),
2434            label: resolved.target_model.clone(),
2435            current_value,
2436            mode,
2437        });
2438    }
2439    out
2440}
2441
2442/// Build a FK-options map for use by admin create / edit forms.
2443/// Mirrors [`build_relation_filters`] in structure — same cap, same
2444/// SQL shape — but returns a flat per-field map so the form renderer
2445/// can look up options by field name without threading the richer
2446/// filter-state type through.
2447async fn fetch_form_relation_options<T: AdminModel>(
2448    db: &Db,
2449    registry: &relations::RelationRegistry,
2450) -> FormRelationOptions {
2451    use sqlx::Row;
2452    let mut out: FormRelationOptions = std::collections::HashMap::new();
2453    let cap = relations::RELATION_FILTER_DROPDOWN_CAP;
2454    for resolved in registry.belongs_to_of(T::singular_name()) {
2455        let Some(display_col) = &resolved.target_display_field else {
2456            continue;
2457        };
2458        let sql = format!(
2459            "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" ORDER BY \"{col}\" ASC LIMIT {lim}",
2460            col = display_col,
2461            table = resolved.target_table,
2462            lim = cap + 1,
2463        );
2464        let rows = match sqlx::query(&sql).fetch_all(db.pool()).await {
2465            Ok(r) => r,
2466            Err(_) => continue,
2467        };
2468        if rows.len() > cap {
2469            // Too many options for a dropdown — fall back to numeric
2470            // input by omitting this field from the map.
2471            continue;
2472        }
2473        let options: Vec<(i64, String)> = rows
2474            .into_iter()
2475            .map(|row| {
2476                let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2477                let label: String = row
2478                    .try_get::<String, _>("rio_label")
2479                    .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2480                    .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2481                    .unwrap_or_default();
2482                (id, label)
2483            })
2484            .collect();
2485        out.insert(resolved.source_field.clone(), options);
2486    }
2487    out
2488}
2489
2490async fn fetch_fk_labels<T: AdminModel>(
2491    db: &Db,
2492    items: &[&T],
2493    registry: &relations::RelationRegistry,
2494) -> FkLabels {
2495    use sqlx::Row;
2496    let mut out: FkLabels = std::collections::HashMap::new();
2497    let source_model = T::singular_name();
2498    for f in T::FIELDS {
2499        let Some(resolved) = registry.belongs_to(source_model, f.name) else {
2500            continue;
2501        };
2502        let Some(display_col) = &resolved.target_display_field else {
2503            // No display_field declared for this relation. Don't
2504            // query — the renderer will fall back to #<id> and
2505            // explicit-is-better-than-implicit stops us from guessing
2506            // a column name.
2507            continue;
2508        };
2509        // Collect distinct ids; drop empty / unparseable values.
2510        let mut ids: Vec<i64> = items
2511            .iter()
2512            .filter_map(|it| it.field_display(f.name))
2513            .filter_map(|s| s.parse::<i64>().ok())
2514            .collect();
2515        ids.sort_unstable();
2516        ids.dedup();
2517        if ids.is_empty() {
2518            continue;
2519        }
2520        // SQLite's default max parameter count is 32766, so batching
2521        // any realistic list of ids fits in a single query.
2522        let placeholders: Vec<&'static str> = ids.iter().map(|_| "?").collect();
2523        let sql = format!(
2524            "SELECT id AS rio_id, \"{col}\" AS rio_label FROM \"{table}\" WHERE id IN ({ph})",
2525            col = display_col,
2526            table = resolved.target_table,
2527            ph = placeholders.join(","),
2528        );
2529        let mut q = sqlx::query(&sql);
2530        for id in &ids {
2531            q = q.bind(id);
2532        }
2533        let rows = match q.fetch_all(db.pool()).await {
2534            Ok(r) => r,
2535            // Degrade silently on any SQL error. The renderer falls
2536            // back to #<id>; failing a whole list page over one bad
2537            // display-field would be a net regression.
2538            Err(_) => continue,
2539        };
2540        let mut map: std::collections::HashMap<i64, String> = std::collections::HashMap::new();
2541        for row in rows {
2542            let id: i64 = row.try_get::<i64, _>("rio_id").unwrap_or_default();
2543            let label: String = row
2544                .try_get::<String, _>("rio_label")
2545                .or_else(|_| row.try_get::<i64, _>("rio_label").map(|n| n.to_string()))
2546                .or_else(|_| row.try_get::<i32, _>("rio_label").map(|n| n.to_string()))
2547                .or_else(|_| row.try_get::<bool, _>("rio_label").map(|b| b.to_string()))
2548                .unwrap_or_default();
2549            map.insert(id, label);
2550        }
2551        out.insert(f.name.to_string(), map);
2552    }
2553    out
2554}
2555
2556fn list_response<T: AdminModel>(
2557    shell: Shell<'_>,
2558    items: &[&T],
2559    total: usize,
2560    filters: ListFilters<'_>,
2561    cell_ctx: &CellCtx<'_>,
2562) -> Response {
2563    let count = items.len();
2564    let singular = T::singular_name();
2565    let plural = T::DISPLAY_NAME;
2566    let admin_name = T::ADMIN_NAME;
2567
2568    let page_actions = format!(
2569        r#"<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{icon}<span>Add {singular}</span></a>"#,
2570        name = escape_html(admin_name),
2571        singular = escape_html(singular),
2572        icon = icon_plus(),
2573    );
2574
2575    // --- Empty states: two flavours ---
2576    // 1. Table has zero rows, period → encourage creation + show
2577    //    context-aware hint when the project has declared one.
2578    // 2. Filter returned zero of N → explain the filter is the reason.
2579    let body = if total == 0 {
2580        let hint_html = match empty_state_hint::<T>(intelligence::context_global()) {
2581            Some(h) => format!(r#"<p class="rio-empty-hint">{}</p>"#, escape_html(&h)),
2582            None => String::new(),
2583        };
2584        format!(
2585            r#"<div class="rio-card">
2586<div class="rio-empty">
2587<div class="rio-empty-icon">{icon}</div>
2588<h3>Start by adding your first {singular_lower}</h3>
2589<p>This table is empty. Create the first record to get started.</p>
2590{hint}
2591<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2592</div>
2593</div>"#,
2594            icon = icon_inbox(),
2595            name = escape_html(admin_name),
2596            plus = icon_plus(),
2597            singular_lower = escape_html(&singular.to_lowercase()),
2598            hint = hint_html,
2599        )
2600    } else {
2601        let toolbar = render_list_toolbar::<T>(&filters, count, total);
2602        // Change 4 — active filter chip row. Rendered OUTSIDE the
2603        // toolbar form and BEFORE the bulk-action form so neither
2604        // form owns it; each chip's `×` is a plain link that rebuilds
2605        // the URL without that one filter key.
2606        let chips = render_active_filter_chips(&filters, admin_name);
2607
2608        if items.is_empty() {
2609            format!(
2610                r#"<div class="rio-table-wrap">
2611{toolbar}
2612{chips}
2613<div class="rio-empty">
2614<div class="rio-empty-icon">{icon}</div>
2615<h3>No records match these filters</h3>
2616<p>Try a different search term, clear the filters, or add a new {singular_lower}.</p>
2617<div class="rio-empty-actions">
2618<a class="rio-btn" href="/admin/{name}">{reset}<span>Clear filters</span></a>
2619<a class="rio-btn rio-btn-primary" href="/admin/{name}/create">{plus}<span>Add {singular_lower}</span></a>
2620</div>
2621</div>
2622</div>"#,
2623                icon = icon_search(),
2624                singular_lower = escape_html(&singular.to_lowercase()),
2625                name = escape_html(admin_name),
2626                reset = icon_arrow_left(),
2627                plus = icon_plus(),
2628            )
2629        } else {
2630            // Filter `T::FIELDS` through the visible-columns set
2631            // computed by the list handler. Declaration order is
2632            // preserved; hidden columns are simply not rendered in
2633            // this pass. (Row-expansion / hide-column UI is a later
2634            // change.)
2635            let visible_fields: Vec<&AdminField> = T::FIELDS
2636                .iter()
2637                .filter(|f| filters.visible_columns.contains(&f.name))
2638                .collect();
2639            // Change 5 — row expansion. Enabled only when the model
2640            // has fields outside the primary column set (Change 1);
2641            // otherwise there is nothing to reveal and the whole
2642            // expand UI is omitted.
2643            let has_hidden_fields = T::FIELDS.len() > visible_fields.len();
2644            let hidden_fields: Vec<&AdminField> = T::FIELDS
2645                .iter()
2646                .filter(|f| !filters.visible_columns.contains(&f.name))
2647                .collect();
2648            // Total column count drives the expand-row colspan. Based
2649            // on server-visible columns only; client-side display:none
2650            // from the Change 2 columns toggle is intentionally
2651            // ignored (browsers stretch the spanned cell to the
2652            // remaining visible width automatically).
2653            let colspan_total = visible_fields.len() + 3;
2654            // Each <th> and <td> carries `data-col="<field_name>"`
2655            // so the Columns toggle (Change 2) can match cells by
2656            // column name via `querySelectorAll('[data-col="…"]')`.
2657            // Checkbox and actions columns have no data-col and
2658            // are never touched by the toggle.
2659            let headers: String = visible_fields
2660                .iter()
2661                .map(|f| {
2662                    format!(
2663                        r#"<th data-col="{name}">{label}</th>"#,
2664                        name = escape_html(f.name),
2665                        label = escape_html(&humanise(f.name)),
2666                    )
2667                })
2668                .collect();
2669            let expand_header = if has_hidden_fields {
2670                r#"<th class="rio-cell-expand" aria-label="Expand"></th>"#.to_string()
2671            } else {
2672                String::new()
2673            };
2674            let rows: String = items
2675                .iter()
2676                .map(|item| {
2677                    let cells: String = visible_fields
2678                        .iter()
2679                        .map(|f| {
2680                            let cell = render_cell::<T>(f, *item, cell_ctx);
2681                            inject_data_col(&cell, f.name)
2682                        })
2683                        .collect();
2684                    let id = item.id();
2685                    // Icon + label actions — obvious to non-technical
2686                    // users. Delete uses a danger-ghost style so the
2687                    // destructive path reads but doesn't scream.
2688                    let row_actions = format!(
2689                        r#"<td class="rio-cell-actions">
2690<div class="rio-row-actions">
2691<a class="rio-btn rio-btn-sm" href="/admin/{name}/{id}/edit">{pencil}<span>Edit</span></a>
2692<a class="rio-btn rio-btn-sm rio-btn-danger-ghost" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete</span></a>
2693</div>
2694</td>"#,
2695                        name = escape_html(admin_name),
2696                        id = id,
2697                        pencil = icon_pencil(),
2698                        trash = icon_trash(),
2699                    );
2700                    let checkbox = format!(
2701                        r#"<td class="rio-cell-check"><input type="checkbox" class="rio-bulk-row" value="{id}" aria-label="Select row {id}"></td>"#,
2702                    );
2703                    if !has_hidden_fields {
2704                        return format!("<tr>{checkbox}{cells}{row_actions}</tr>");
2705                    }
2706                    // Change 5 — build the paired main+expand rows.
2707                    // The expand row is always present in the DOM and
2708                    // carries the `hidden` attribute; the inline IIFE
2709                    // at the bottom of the page flips it on click.
2710                    let expand_cell = format!(
2711                        r#"<td class="rio-cell-expand"><button type="button" class="rio-expand-btn" data-expand-toggle aria-expanded="false" aria-label="Expand row {id}">&#9656;</button></td>"#,
2712                    );
2713                    let detail_fields: String = hidden_fields
2714                        .iter()
2715                        .map(|f| {
2716                            format!(
2717                                r#"<div class="rio-expand-field"><dt>{label}</dt><dd>{value}</dd></div>"#,
2718                                label = escape_html(&humanise(f.name)),
2719                                value = render_cell_inner::<T>(f, *item, cell_ctx),
2720                            )
2721                        })
2722                        .collect();
2723                    let expand_row = format!(
2724                        r#"<tr class="rio-row-expand" data-row-id="{id}" hidden><td colspan="{colspan}" class="rio-cell-expand-panel"><dl class="rio-expand-details">{fields}</dl></td></tr>"#,
2725                        id = id,
2726                        colspan = colspan_total,
2727                        fields = detail_fields,
2728                    );
2729                    format!(
2730                        r#"<tr class="rio-row-main" data-row-id="{id}">{expand_cell}{checkbox}{cells}{row_actions}</tr>{expand_row}"#,
2731                    )
2732                })
2733                .collect();
2734
2735            let csrf = csrf_input(shell.csrf);
2736            let bulk_bar = format!(
2737                r#"<div class="rio-bulk-bar">
2738<label class="rio-bulk-label" for="rio-bulk-action">Action</label>
2739<select class="rio-select" id="rio-bulk-action" name="action">
2740<option value="">-- Select an action --</option>
2741<option value="delete">Delete selected {plural_lower}</option>
2742</select>
2743<button type="submit" class="rio-btn">Go</button>
2744<span class="rio-bulk-count" data-rio-bulk-count>0 selected</span>
2745</div>"#,
2746                plural_lower = escape_html(&plural.to_lowercase()),
2747            );
2748
2749            format!(
2750                r#"<div class="rio-table-wrap">
2751{toolbar}
2752{chips}
2753<form method="post" action="/admin/{name}/bulk_action" class="rio-bulk-form">
2754{csrf}
2755<input type="hidden" name="_selected" value="">
2756{bulk_bar}
2757<table class="rio-table">
2758<thead><tr>{expand_header}<th class="rio-cell-check"><input type="checkbox" class="rio-bulk-all" aria-label="Select all"></th>{headers}<th aria-label="Actions"></th></tr></thead>
2759<tbody>{rows}</tbody>
2760</table>
2761</form>
2762<script>
2763(function(){{
2764var form=document.querySelector('.rio-bulk-form');
2765if(form){{
2766  var all=form.querySelector('.rio-bulk-all');
2767  var rows=form.querySelectorAll('.rio-bulk-row');
2768  var count=form.querySelector('[data-rio-bulk-count]');
2769  var hidden=form.querySelector('input[name="_selected"]');
2770  function collect(){{var ids=[];rows.forEach(function(cb){{if(cb.checked)ids.push(cb.value);}});return ids;}}
2771  function update(){{var ids=collect();if(hidden)hidden.value=ids.join(',');if(count)count.textContent=ids.length+' selected';}}
2772  if(all)all.addEventListener('change',function(){{rows.forEach(function(cb){{cb.checked=all.checked;}});update();}});
2773  rows.forEach(function(cb){{cb.addEventListener('change',update);}});
2774  form.addEventListener('submit',function(e){{update();var ids=collect();var act=form.querySelector('[name="action"]');if(!ids.length||!act.value){{e.preventDefault();alert('Select one or more rows and an action, then click Go.');}}}});
2775  update();
2776}}
2777// Columns toggle (Change 2) — outside-click closes the <details>,
2778// and checkbox changes flip `display: none` on the matching <th>
2779// and every matching <td> via `data-col` attribute. Checkbox and
2780// actions columns carry no `data-col`, so they're never touched.
2781document.addEventListener('click',function(e){{
2782  var d=document.querySelector('details.rio-cols-ctl[open]');
2783  if(!d)return;
2784  if(d.contains(e.target))return;
2785  d.open=false;
2786}});
2787document.addEventListener('change',function(e){{
2788  var cb=e.target&&e.target.closest?e.target.closest('.rio-cols-check'):null;
2789  if(!cb)return;
2790  var col=cb.getAttribute('data-col');
2791  if(!col)return;
2792  var esc=(window.CSS&&CSS.escape)?CSS.escape(col):col;
2793  document.querySelectorAll('[data-col="'+esc+'"]').forEach(function(cell){{
2794    cell.style.display=cb.checked?'':'none';
2795  }});
2796}});
2797// More filters panel toggle (Change 3) — plain hidden-attribute
2798// flip. Button carries `data-more-filters-toggle` and an
2799// `aria-controls` pointing at the panel id. No outside-click
2800// handler, no animation.
2801document.addEventListener('click',function(e){{
2802  var btn=e.target&&e.target.closest?e.target.closest('[data-more-filters-toggle]'):null;
2803  if(!btn)return;
2804  var id=btn.getAttribute('aria-controls');
2805  var panel=id?document.getElementById(id):null;
2806  if(!panel)return;
2807  var open=!panel.hasAttribute('hidden');
2808  if(open){{
2809    panel.setAttribute('hidden','');
2810    btn.setAttribute('aria-expanded','false');
2811  }}else{{
2812    panel.removeAttribute('hidden');
2813    btn.setAttribute('aria-expanded','true');
2814  }}
2815}});
2816// Row expansion toggle (Change 5) — the button lives in the first
2817// column of each `.rio-row-main`, the paired `.rio-row-expand` is
2818// its `nextElementSibling`. Flip the `hidden` attribute + chevron
2819// glyph + aria-expanded; nothing else.
2820document.addEventListener('click',function(e){{
2821  var btn=e.target&&e.target.closest?e.target.closest('[data-expand-toggle]'):null;
2822  if(!btn)return;
2823  var main=btn.closest('tr');
2824  if(!main)return;
2825  var panel=main.nextElementSibling;
2826  if(!panel||!panel.classList.contains('rio-row-expand'))return;
2827  var open=!panel.hasAttribute('hidden');
2828  if(open){{
2829    panel.setAttribute('hidden','');
2830    btn.setAttribute('aria-expanded','false');
2831    btn.textContent='\u25B8';
2832  }}else{{
2833    panel.removeAttribute('hidden');
2834    btn.setAttribute('aria-expanded','true');
2835    btn.textContent='\u25BE';
2836  }}
2837}});
2838}})();
2839</script>
2840</div>"#,
2841                name = escape_html(admin_name),
2842                expand_header = expand_header,
2843            )
2844        }
2845    };
2846
2847    let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), (plural, None)];
2848
2849    render_shell_page(
2850        &shell,
2851        200,
2852        plural,
2853        plural,
2854        Some(&format!(
2855            "Browse, search, and manage {}.",
2856            plural.to_lowercase()
2857        )),
2858        crumbs,
2859        &page_actions,
2860        &body,
2861    )
2862}
2863
2864/// Render the search + filters + submit/reset toolbar. Posts as a GET
2865/// to the same list URL, so the current filter state is carried in the
2866/// URL and bookmarkable.
2867/// Render one relation-aware filter control. Dropdown when the
2868/// target carries a `display_field` and fits under the cap; numeric
2869/// input otherwise (with a hint explaining why).
2870fn render_relation_filter_control(state: &RelationFilterState) -> String {
2871    let field = escape_html(&state.field_name);
2872    let label = escape_html(&state.label);
2873    match &state.mode {
2874        RelationFilterMode::Dropdown { options } => {
2875            // "All patients", "All doctors" — pluralise the singular
2876            // model name so the placeholder reads like English.
2877            let placeholder = format!("All {}", pluralise_label(&state.label));
2878            let options_html: String = std::iter::once(format!(
2879                r#"<option value="">{}</option>"#,
2880                escape_html(&placeholder),
2881            ))
2882            .chain(options.iter().map(|(id, display)| {
2883                let selected = state.current_value == Some(*id);
2884                let mark = if selected { " selected" } else { "" };
2885                format!(
2886                    r#"<option value="{id}"{mark}>{display}</option>"#,
2887                    id = id,
2888                    mark = mark,
2889                    display = escape_html(display),
2890                )
2891            }))
2892            .collect();
2893            format!(
2894                r#"<select class="rio-select" name="{field}" aria-label="Filter by {label}">{options_html}</select>"#,
2895            )
2896        }
2897        RelationFilterMode::Numeric { too_many } => {
2898            let current = state
2899                .current_value
2900                .map(|v| v.to_string())
2901                .unwrap_or_default();
2902            // Two different hints so the operator knows the reason
2903            // for the numeric fallback: too many target rows to
2904            // dropdown, vs. no display_field declared on the target.
2905            let hint = if *too_many {
2906                format!(
2907                    r#"<span class="rio-field-hint">Too many options for a dropdown — enter the {label} ID directly.</span>"#,
2908                    label = label,
2909                )
2910            } else {
2911                format!(
2912                    r#"<span class="rio-field-hint">No display field declared for {label} — enter the ID directly.</span>"#,
2913                    label = label,
2914                )
2915            };
2916            format!(
2917                r#"<label class="rio-field" style="display:inline-flex; gap:var(--rio-s-1); align-items:center; margin:0">\
2918<span class="rio-field-label">{label} ID</span>\
2919<input class="rio-input" type="number" name="{field}" value="{current}" style="width:140px" aria-label="Filter by {label} id">\
2920{hint}\
2921</label>"#,
2922                label = label,
2923                field = field,
2924                current = escape_html(&current),
2925                hint = hint,
2926            )
2927        }
2928    }
2929}
2930
2931/// Render the "Columns" control — a `<details>` summary button that
2932/// opens a checkbox panel listing every field on the model (Change
2933/// 2/5). Primary columns from [`default_list_columns`] start
2934/// checked; non-primary start unchecked. Toggling a primary
2935/// checkbox hides or shows that column client-side via a small
2936/// script at the bottom of the list page. Non-primary checkboxes
2937/// render in the panel for visibility but flipping them has no
2938/// effect in Change 2 — Change 1's server-side `visible_columns`
2939/// filter excludes those columns from the DOM entirely. Revealing
2940/// a non-primary column is a separate feature deferred to a later
2941/// change.
2942fn render_columns_control<T: AdminModel>(filters: &ListFilters<'_>) -> String {
2943    let rows: String = T::FIELDS
2944        .iter()
2945        .map(|f| {
2946            let is_visible = filters.visible_columns.contains(&f.name);
2947            let is_id = f.name == "id";
2948            let checked = if is_visible { " checked" } else { "" };
2949            let disabled = if is_id { " disabled" } else { "" };
2950            let mut tags: Vec<&'static str> = Vec::new();
2951            if is_visible {
2952                tags.push("primary");
2953            }
2954            if f.relation.is_some() {
2955                tags.push("relation");
2956            }
2957            let tag_html = if tags.is_empty() {
2958                String::new()
2959            } else {
2960                format!(" <small>{}</small>", tags.join(" · "))
2961            };
2962            format!(
2963                r#"<label class="rio-cols-panel-row"><input type="checkbox" class="rio-cols-check" data-col="{name}"{checked}{disabled}><span>{label}{tags}</span></label>"#,
2964                name = escape_html(f.name),
2965                label = escape_html(&humanise(f.name)),
2966                tags = tag_html,
2967            )
2968        })
2969        .collect();
2970
2971    format!(
2972        r#"<details class="rio-cols-ctl"><summary class="rio-btn">Columns</summary><div class="rio-cols-panel">{rows}</div></details>"#,
2973    )
2974}
2975
2976/// Build an admin list URL from a slice of (key, value) parameter
2977/// pairs (Change 4/5). No URL encoding is performed — values are
2978/// round-tripped raw, exactly as they entered the filter state from
2979/// the query string. Empty params → bare `/admin/<name>`; otherwise
2980/// `?k=v&k=v…`. Key ordering is preserved from the caller.
2981fn build_list_url(admin_name: &str, params: &[(String, String)]) -> String {
2982    if params.is_empty() {
2983        return format!("/admin/{}", admin_name);
2984    }
2985    let query: String = params
2986        .iter()
2987        .map(|(k, v)| format!("{}={}", k, v))
2988        .collect::<Vec<_>>()
2989        .join("&");
2990    format!("/admin/{}?{}", admin_name, query)
2991}
2992
2993/// Collect the currently-active filter parameters as (key, value)
2994/// pairs in the canonical order `q, status, priority, <relations…>,
2995/// sort` (Change 4/5). Skips empty `q`. Each active relation filter
2996/// contributes one entry keyed by its FK column name. Sort is
2997/// included so chip removal preserves the user's sort choice.
2998fn current_filter_params(filters: &ListFilters<'_>) -> Vec<(String, String)> {
2999    let mut params: Vec<(String, String)> = Vec::new();
3000    if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3001        params.push(("q".to_string(), q.to_string()));
3002    }
3003    if let Some(s) = filters.status {
3004        params.push(("status".to_string(), s.to_string()));
3005    }
3006    if let Some(p) = filters.priority {
3007        params.push(("priority".to_string(), p.to_string()));
3008    }
3009    for r in filters.relation_filters {
3010        if let Some(id) = r.current_value {
3011            params.push((r.field_name.clone(), id.to_string()));
3012        }
3013    }
3014    if let Some(sort) = filters.sort {
3015        params.push(("sort".to_string(), sort.to_string()));
3016    }
3017    params
3018}
3019
3020/// Render the "active filter chips" row (Change 4/5) — one chip per
3021/// chippable filter (search, status, priority, each active relation).
3022/// Sort is NOT chippable. Each chip's `×` link rebuilds the list URL
3023/// with that one key removed, preserving every other filter including
3024/// sort. A trailing "Clear all" link strips everything back to
3025/// `/admin/<name>`. Returns `""` when no chippable filter is active,
3026/// so the caller can drop the empty wrapper from the DOM.
3027fn render_active_filter_chips(filters: &ListFilters<'_>, admin_name: &str) -> String {
3028    let all_params = current_filter_params(filters);
3029    let remove_url = |exclude_key: &str| -> String {
3030        let kept: Vec<(String, String)> = all_params
3031            .iter()
3032            .filter(|(k, _)| k != exclude_key)
3033            .cloned()
3034            .collect();
3035        build_list_url(admin_name, &kept)
3036    };
3037
3038    let mut chips: Vec<String> = Vec::new();
3039
3040    if let Some(q) = filters.q.filter(|s| !s.is_empty()) {
3041        chips.push(format!(
3042            r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Search:</span> <span class="admin-filter-chip-value">&quot;{value}&quot;</span> <a href="{href}" aria-label="Remove search filter">&times;</a></span>"#,
3043            value = escape_html(q),
3044            href = escape_html(&remove_url("q")),
3045        ));
3046    }
3047    if let Some(s) = filters.status {
3048        chips.push(format!(
3049            r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Status:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove status filter">&times;</a></span>"#,
3050            value = escape_html(&humanise_enum_value(s)),
3051            href = escape_html(&remove_url("status")),
3052        ));
3053    }
3054    if let Some(p) = filters.priority {
3055        chips.push(format!(
3056            r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">Priority:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove priority filter">&times;</a></span>"#,
3057            value = escape_html(p),
3058            href = escape_html(&remove_url("priority")),
3059        ));
3060    }
3061    for r in filters.relation_filters {
3062        if let Some(id) = r.current_value {
3063            let display = match &r.mode {
3064                RelationFilterMode::Dropdown { options } => options
3065                    .iter()
3066                    .find(|(opt_id, _)| *opt_id == id)
3067                    .map(|(_, name)| name.clone())
3068                    .unwrap_or_else(|| format!("#{}", id)),
3069                RelationFilterMode::Numeric { .. } => format!("#{}", id),
3070            };
3071            chips.push(format!(
3072                r#"<span class="admin-filter-chip"><span class="admin-filter-chip-label">{label}:</span> <span class="admin-filter-chip-value">{value}</span> <a href="{href}" aria-label="Remove {label} filter">&times;</a></span>"#,
3073                label = escape_html(&r.label),
3074                value = escape_html(&display),
3075                href = escape_html(&remove_url(&r.field_name)),
3076            ));
3077        }
3078    }
3079
3080    if chips.is_empty() {
3081        return String::new();
3082    }
3083
3084    format!(
3085        r#"<div class="admin-filter-chips">{chips}<a class="admin-filter-clear-all" href="/admin/{name}">Clear all</a></div>"#,
3086        chips = chips.join(""),
3087        name = escape_html(admin_name),
3088    )
3089}
3090
3091/// Render the "More filters" secondary panel (Change 3). Returns
3092/// `""` when no secondary filter control is available on the model
3093/// — in which case the toolbar also omits the "More filters"
3094/// button, keeping the DOM minimal on filter-less models. Otherwise
3095/// returns a `hidden` `<div>` containing a 3-column grid of secondary
3096/// filter controls. Spec: panel defaults to hidden; JS toggles it.
3097fn render_more_filters_panel(
3098    status_select: &str,
3099    priority_select: &str,
3100    secondary_relations_html: &str,
3101) -> String {
3102    if status_select.is_empty() && priority_select.is_empty() && secondary_relations_html.is_empty()
3103    {
3104        return String::new();
3105    }
3106    format!(
3107        r#"<div class="admin-list-more-filters" id="more-filters-panel" hidden><div class="admin-filter-grid">{status}{priority}{relations}</div></div>"#,
3108        status = status_select,
3109        priority = priority_select,
3110        relations = secondary_relations_html,
3111    )
3112}
3113
3114fn render_list_toolbar<T: AdminModel>(
3115    filters: &ListFilters<'_>,
3116    shown: usize,
3117    total: usize,
3118) -> String {
3119    let admin_name = T::ADMIN_NAME;
3120    let plural = T::DISPLAY_NAME;
3121
3122    let q_value = filters.q.map(escape_html).unwrap_or_default();
3123
3124    // Status filter dropdown — only rendered when the model has a
3125    // String field named "status" (detected by non-empty options).
3126    // Values stay raw (`checked_in`, `no_show`) so form round-trip
3127    // works; labels are humanised ("Checked in", "No show") so the
3128    // dropdown reads like English rather than database fields.
3129    let status_select = if !filters.status_options.is_empty() {
3130        let options: String =
3131            std::iter::once(r#"<option value="">All statuses</option>"#.to_string())
3132                .chain(filters.status_options.iter().map(|v| {
3133                    let selected = if filters.status.map(|s| s == v).unwrap_or(false) {
3134                        " selected"
3135                    } else {
3136                        ""
3137                    };
3138                    format!(
3139                        r#"<option value="{v}"{selected}>{label}</option>"#,
3140                        v = escape_html(v),
3141                        label = escape_html(&humanise_enum_value(v)),
3142                    )
3143                }))
3144                .collect();
3145        format!(
3146            r#"<select class="rio-select" name="status" aria-label="Filter by status">{options}</select>"#,
3147        )
3148    } else {
3149        String::new()
3150    };
3151
3152    // Priority filter — numeric. Sort the options numerically so the
3153    // dropdown reads 1, 2, …, 10 (not 1, 10, 2 — the default string
3154    // sort from `distinct_values`).
3155    let priority_select = if !filters.priority_options.is_empty() {
3156        let mut sorted_priorities: Vec<&String> = filters.priority_options.iter().collect();
3157        sorted_priorities.sort_by(|a, b| {
3158            let na: Option<i64> = a.parse().ok();
3159            let nb: Option<i64> = b.parse().ok();
3160            match (na, nb) {
3161                (Some(x), Some(y)) => x.cmp(&y),
3162                _ => a.cmp(b),
3163            }
3164        });
3165        let options: String =
3166            std::iter::once(r#"<option value="">All priorities</option>"#.to_string())
3167                .chain(sorted_priorities.iter().map(|v| {
3168                    let selected = if filters.priority.map(|p| p == v.as_str()).unwrap_or(false) {
3169                        " selected"
3170                    } else {
3171                        ""
3172                    };
3173                    format!(
3174                        r#"<option value="{v}"{selected}>Priority {v}</option>"#,
3175                        v = escape_html(v),
3176                    )
3177                }))
3178                .collect();
3179        format!(
3180            r#"<select class="rio-select" name="priority" aria-label="Filter by priority">{options}</select>"#,
3181        )
3182    } else {
3183        String::new()
3184    };
3185
3186    // Phase 5 — relation-aware filters. The FIRST belongs_to on the
3187    // model is promoted to the primary toolbar; the rest drop into
3188    // the "More filters" panel below (Change 3).
3189    let (primary_relation_html, secondary_relations_html): (String, String) = {
3190        let mut iter = filters.relation_filters.iter();
3191        let first = iter
3192            .next()
3193            .map(render_relation_filter_control)
3194            .unwrap_or_default();
3195        let rest: String = iter.map(render_relation_filter_control).collect();
3196        (first, rest)
3197    };
3198
3199    // Count active SECONDARY filters for the "More filters (N)"
3200    // button label. Excludes search, the primary relation filter,
3201    // and sort per spec §5.4.
3202    let secondary_active_count: usize = filters.status.is_some() as usize
3203        + filters.priority.is_some() as usize
3204        + filters
3205            .relation_filters
3206            .iter()
3207            .skip(1)
3208            .filter(|r| r.current_value.is_some())
3209            .count();
3210
3211    // Render the "More filters" panel + button only when at least
3212    // one secondary filter control exists on this model. If the
3213    // panel would be empty, both the button and the <div> are
3214    // omitted from the DOM entirely (spec §Q3 decision).
3215    let more_filters_panel_html =
3216        render_more_filters_panel(&status_select, &priority_select, &secondary_relations_html);
3217    let more_filters_btn = if more_filters_panel_html.is_empty() {
3218        String::new()
3219    } else {
3220        let label = if secondary_active_count == 0 {
3221            "More filters".to_string()
3222        } else {
3223            format!("More filters ({secondary_active_count})")
3224        };
3225        format!(
3226            r#"<button type="button" class="rio-btn" data-more-filters-toggle aria-controls="more-filters-panel" aria-expanded="false">{label}</button>"#,
3227        )
3228    };
3229
3230    let reset_btn = if filters.is_active() {
3231        format!(
3232            r#"<a class="rio-btn rio-btn-ghost" href="/admin/{name}">Reset</a>"#,
3233            name = escape_html(admin_name),
3234        )
3235    } else {
3236        String::new()
3237    };
3238
3239    // Sort dropdown — always rendered. Pinned vocabulary in
3240    // `SORT_OPTIONS` keeps the options identical across every admin
3241    // list in every project, so the toolbar shape is predictable for
3242    // operators moving between models.
3243    let sort_select = {
3244        let current = filters.sort.unwrap_or("newest");
3245        let options: String = SORT_OPTIONS
3246            .iter()
3247            .map(|(value, label)| {
3248                let sel = if *value == current { " selected" } else { "" };
3249                format!(
3250                    r#"<option value="{v}"{sel}>{l}</option>"#,
3251                    v = escape_html(value),
3252                    l = escape_html(label),
3253                )
3254            })
3255            .collect();
3256        format!(
3257            r#"<select class="rio-select rio-select-sort" name="sort" aria-label="Sort records">{options}</select>"#,
3258        )
3259    };
3260
3261    let count_label = if filters.is_active() {
3262        format!("Showing {shown} of {total}")
3263    } else if total == 1 {
3264        "1 record".to_string()
3265    } else {
3266        format!("{total} records")
3267    };
3268
3269    // Search-intent badge. Only render when the user has typed
3270    // something that the classifier routes away from plain text —
3271    // "Interpreted as ID", "Interpreted as personnummer", etc. —
3272    // so the operator sees what the list handler is actually doing.
3273    let intent_badge = filters
3274        .q
3275        .filter(|q| !q.is_empty())
3276        .map(|q| match intelligence::classify_search(q) {
3277            intelligence::SearchIntent::Text(_) => String::new(),
3278            other => format!(
3279                r#"<span class="rio-search-intent">Interpreted as: {}</span>"#,
3280                escape_html(other.label()),
3281            ),
3282        })
3283        .unwrap_or_default();
3284
3285    // Columns control — sits inside .rio-toolbar-actions, right
3286    // after the "More filters" button (Change 3). Native <details>
3287    // whose panel is positioned below the summary; open/close is
3288    // browser-default, outside-click closes it via a small JS
3289    // listener at the bottom of the list page.
3290    let columns_control = render_columns_control::<T>(filters);
3291
3292    format!(
3293        r#"<form class="rio-table-toolbar" method="get" action="/admin/{name}" role="search" aria-label="Search {plural}">
3294<div class="rio-search">
3295{search_icon}
3296<input type="search" name="q" value="{q}" placeholder="Search {plural_lower}…" aria-label="Search text">
3297{intent}
3298</div>
3299{primary_relation}
3300{sort}
3301<div class="rio-toolbar-actions">
3302<button type="submit" class="rio-btn rio-btn-primary">{submit_icon}<span>Search</span></button>
3303{more_filters_btn}
3304{reset}
3305{columns}
3306</div>
3307<div class="rio-count">{count}</div>
3308{more_filters_panel}
3309</form>"#,
3310        name = escape_html(admin_name),
3311        plural = escape_html(plural),
3312        plural_lower = escape_html(&plural.to_lowercase()),
3313        search_icon = icon_search(),
3314        q = q_value,
3315        intent = intent_badge,
3316        primary_relation = primary_relation_html,
3317        sort = sort_select,
3318        submit_icon = icon_search(),
3319        more_filters_btn = more_filters_btn,
3320        reset = reset_btn,
3321        columns = columns_control,
3322        count = escape_html(&count_label),
3323        more_filters_panel = more_filters_panel_html,
3324    )
3325}
3326
3327/// In-memory substring match — scans every String field, plus the
3328/// id, for `needle` (case-insensitive). Good enough for the dev
3329/// admin's typical row counts; DB-backed LIKE comes once a project
3330/// adds pagination.
3331fn matches_query<T: AdminModel>(item: &T, needle: &str) -> bool {
3332    let needle = needle.to_lowercase();
3333    if item.id().to_string().contains(&needle) {
3334        return true;
3335    }
3336    for f in T::FIELDS.iter() {
3337        if matches!(f.ty, FieldType::String) {
3338            if let Some(v) = item.field_display(f.name) {
3339                if v.to_lowercase().contains(&needle) {
3340                    return true;
3341                }
3342            }
3343        }
3344    }
3345    false
3346}
3347
3348/// Collect the distinct non-empty values for a named field, sorted.
3349/// Returns empty when the field doesn't exist on the model — which
3350/// lets the toolbar renderer skip the filter entirely.
3351fn distinct_values<T: AdminModel>(items: &[T], field_name: &str) -> Vec<String> {
3352    if !T::FIELDS.iter().any(|f| f.name == field_name) {
3353        return Vec::new();
3354    }
3355    let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3356    for item in items {
3357        if let Some(v) = item.field_display(field_name) {
3358            if !v.is_empty() {
3359                set.insert(v);
3360            }
3361        }
3362    }
3363    set.into_iter().collect()
3364}
3365
3366/// Map common status / lifecycle strings to a semantic pill colour.
3367/// Unknown values fall through to neutral slate. Keeping this small
3368/// and known is deliberate — no ad-hoc colour explosions.
3369fn status_pill_class(value: &str) -> &'static str {
3370    match value {
3371        "done" | "complete" | "completed" | "finished" | "resolved" => "rio-pill rio-pill-emerald",
3372        "active" | "approved" | "published" | "live" => "rio-pill rio-pill-emerald",
3373        "pending" | "todo" | "queued" | "open" | "new" => "rio-pill rio-pill-amber",
3374        "in_progress" | "doing" | "working" | "review" | "in_review" => "rio-pill rio-pill-indigo",
3375        "archived" | "inactive" | "closed" | "cancelled" | "canceled" => "rio-pill rio-pill-slate",
3376        "blocked" | "failed" | "rejected" | "error" => "rio-pill rio-pill-rose",
3377        _ => "rio-pill rio-pill-slate",
3378    }
3379}
3380
3381/// Render one table cell with styling driven by the field's role and
3382/// type. Designed for scanning: id monospace-muted, primary column
3383/// bolded, bools + status strings become pills, nullable empties
3384/// render as a subtle em-dash.
3385///
3386/// Relation-aware: when the field carries `#[rustio(belongs_to =
3387/// "…")]` metadata and the registry has prefetched a display label,
3388/// the cell renders as a link to the target plus the muted `#<id>`.
3389/// When `display_field` is not declared, the cell renders `#<id>` as
3390/// a link. No label inference is ever attempted.
3391///
3392/// Insert `data-col="{col}"` into the opening `<td` tag of a
3393/// cell-rendering so the Columns-toggle JS can target the cell by
3394/// column name (Change 2/5). Non-invasive — preserves the rest of
3395/// the cell verbatim; returns the original string if the shape is
3396/// unexpected (e.g. a future render_cell variant that doesn't start
3397/// with `<td`).
3398fn inject_data_col(cell: &str, col: &str) -> String {
3399    let trimmed_offset = cell.len() - cell.trim_start().len();
3400    let rest = &cell[trimmed_offset..];
3401    if !rest.starts_with("<td") {
3402        return cell.to_string();
3403    }
3404    let (leading_ws, after_ws) = cell.split_at(trimmed_offset);
3405    // after_ws starts with "<td"; splice ` data-col="..."` after `<td`.
3406    let after_td = &after_ws[3..];
3407    format!(
3408        r#"{leading_ws}<td data-col="{col}"{after_td}"#,
3409        col = escape_html(col),
3410    )
3411}
3412
3413fn render_cell<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3414    let value = item.field_display(f.name).unwrap_or_default();
3415    if f.name == "id" {
3416        return format!(r#"<td class="rio-cell-id">#{}</td>"#, escape_html(&value));
3417    }
3418    if value.is_empty() && f.nullable {
3419        return r#"<td class="rio-cell-muted">—</td>"#.to_string();
3420    }
3421    // Relation branch: if this field is a FK to another model, render
3422    // a link to the target admin page plus the id. Uses the prefetched
3423    // label map; never re-queries the DB here.
3424    if let Some(resolved) = ctx.registry.belongs_to(T::singular_name(), f.name) {
3425        if let Ok(id) = value.parse::<i64>() {
3426            let label = ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3427            let admin = escape_html(&resolved.target_admin_name);
3428            return match (label, &resolved.target_display_field) {
3429                // Best case: label resolved, render name + muted id.
3430                (Some(name), _) => format!(
3431                    r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></td>"#,
3432                    admin = admin,
3433                    id = id,
3434                    name = escape_html(name),
3435                ),
3436                // display_field declared but target missing (deleted
3437                // row, stale schema). Link still works — the target
3438                // admin will produce a 404.
3439                (None, Some(_)) => format!(
3440                    r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3441                    admin = admin,
3442                    id = id,
3443                ),
3444                // No display_field declared → explicit #<id> with
3445                // link. We never guess a column.
3446                (None, None) => format!(
3447                    r#"<td class="rio-cell-muted"><a href="/admin/{admin}/{id}">#{id}</a></td>"#,
3448                    admin = admin,
3449                    id = id,
3450                ),
3451            };
3452        }
3453        // FK column but the value didn't parse as an i64. Fall
3454        // through to the default numeric render.
3455    }
3456    // Sensitive fields (personnummer, email under GDPR, patient_id under
3457    // healthcare, …) are masked by default; a tiny inline toggle
3458    // restores the real value when the reviewer clicks "show".
3459    let ctx = intelligence::context_global();
3460    let ui = intelligence::field_ui_metadata(f, ctx);
3461    if ui.sensitive && !value.is_empty() {
3462        let masked = intelligence::mask_pii(&value);
3463        return format!(
3464            r#"<td class="rio-cell-muted">\
3465<span class="rio-pii" data-value="{real}" data-mask="{mask}" data-hidden="1">{mask}</span>\
3466<button class="rio-pii-toggle" type="button" aria-label="Reveal value">show</button>\
3467</td>"#,
3468            real = escape_html(&value),
3469            mask = escape_html(&masked),
3470        );
3471    }
3472    if matches!(f.ty, FieldType::Bool) {
3473        let (cls, label) = match value.as_str() {
3474            "true" => ("rio-pill rio-pill-emerald", "active"),
3475            "false" => ("rio-pill rio-pill-slate", "inactive"),
3476            other => ("rio-pill rio-pill-slate", other),
3477        };
3478        return format!(
3479            r#"<td><span class="{cls}">{}</span></td>"#,
3480            escape_html(label)
3481        );
3482    }
3483    // Status-like String fields get a coloured pill. Keeps tables
3484    // scannable at a glance without requiring per-project config.
3485    if f.name == "status" && matches!(f.ty, FieldType::String) {
3486        let cls = status_pill_class(&value);
3487        let label = value.replace('_', " ");
3488        return format!(
3489            r#"<td><span class="{cls}">{}</span></td>"#,
3490            escape_html(&label)
3491        );
3492    }
3493    // Numeric fields get tabular numerics + muted colour.
3494    if matches!(f.ty, FieldType::I32 | FieldType::I64) {
3495        return format!(r#"<td class="rio-cell-num">{}</td>"#, escape_html(&value));
3496    }
3497    // First non-id field becomes the primary-weight cell.
3498    let is_primary = f.name != "id"
3499        && T::FIELDS
3500            .iter()
3501            .find(|x| x.name != "id")
3502            .map(|first| first.name == f.name)
3503            .unwrap_or(false);
3504    let cls = if is_primary {
3505        "rio-cell-primary"
3506    } else {
3507        "rio-cell-muted"
3508    };
3509    format!(r#"<td class="{cls}">{}</td>"#, escape_html(&value))
3510}
3511
3512/// Strip the `<td …>` wrapper from a [`render_cell`] output so the
3513/// inner value can be reused inside the row-expansion panel
3514/// (Change 5/5). Relies on the invariant that `render_cell` always
3515/// returns a single `<td …>INNER</td>` string — do not change that
3516/// shape without updating this helper.
3517fn render_cell_inner<T: AdminModel>(f: &AdminField, item: &T, ctx: &CellCtx<'_>) -> String {
3518    let cell = render_cell::<T>(f, item, ctx);
3519    let start = cell.find('>').map(|i| i + 1).unwrap_or(0);
3520    let end = cell.rfind("</td>").unwrap_or(cell.len());
3521    cell[start..end].to_string()
3522}
3523
3524// ---------------------------------------------------------------------------
3525// Form page (create / edit)
3526// ---------------------------------------------------------------------------
3527
3528enum FormMode<'a, T: AdminModel> {
3529    Create,
3530    Edit { id: i64, item: &'a T },
3531}
3532
3533fn form_response<T: AdminModel>(
3534    shell: Shell<'_>,
3535    mode: FormMode<'_, T>,
3536    cell_ctx: &CellCtx<'_>,
3537    inverse_counts: &InverseCounts,
3538    form_options: &FormRelationOptions,
3539) -> Response {
3540    let plural = T::DISPLAY_NAME;
3541    let singular = T::singular_name();
3542    let admin_name = T::ADMIN_NAME;
3543
3544    let (heading, doc_title, subtitle, action, back_label) = match &mode {
3545        FormMode::Create => (
3546            format!("New {singular}"),
3547            format!("New {singular}"),
3548            format!("Create a new {} record.", singular.to_lowercase()),
3549            format!("/admin/{admin_name}/create"),
3550            format!("Back to {}", plural.to_lowercase()),
3551        ),
3552        FormMode::Edit { id, .. } => (
3553            format!("Edit {singular}"),
3554            format!("Edit {singular} #{id}"),
3555            format!("Update this {} record.", singular.to_lowercase()),
3556            format!("/admin/{admin_name}/{id}/edit"),
3557            format!("Back to {}", plural.to_lowercase()),
3558        ),
3559    };
3560
3561    let fields: String = T::FIELDS
3562        .iter()
3563        .filter(|f| f.editable)
3564        .map(|f| {
3565            render_field_block::<T>(
3566                f,
3567                match &mode {
3568                    FormMode::Create => None,
3569                    FormMode::Edit { item, .. } => Some(*item),
3570                },
3571                cell_ctx,
3572                form_options,
3573            )
3574        })
3575        .collect();
3576
3577    let meta_block = match &mode {
3578        FormMode::Create => String::new(),
3579        FormMode::Edit { id, item } => render_meta::<T>(*id, item),
3580    };
3581
3582    // Phase 4 — inverse relation panels. Shown only on the Edit page
3583    // (Create has nothing to count against). Each `has_many` pointing
3584    // at this model becomes a "<plural> (N)" card linking to the
3585    // filtered list on that source model. v1 renders counts only; a
3586    // future pass can add preview rows and in-page navigation.
3587    let inverse_panel = match &mode {
3588        FormMode::Create => String::new(),
3589        FormMode::Edit { id, .. } => {
3590            render_inverse_panel::<T>(cell_ctx.registry, inverse_counts, *id)
3591        }
3592    };
3593
3594    let danger_zone = match &mode {
3595        FormMode::Create => String::new(),
3596        FormMode::Edit { id, .. } => format!(
3597            r#"<section class="rio-danger-zone">
3598<div class="rio-danger-copy">
3599<h3 class="rio-danger-title">{warn}<span>Delete this {singular}</span></h3>
3600<p class="rio-danger-hint">Permanently removes this record. Rows that reference it with <code>ON DELETE CASCADE</code> will also be deleted.</p>
3601</div>
3602<a class="rio-btn rio-btn-danger" href="/admin/{name}/{id}/delete" rel="nofollow">{trash}<span>Delete record</span></a>
3603</section>"#,
3604            warn = icon_triangle_alert(),
3605            singular = escape_html(&singular.to_lowercase()),
3606            name = escape_html(admin_name),
3607            id = id,
3608            trash = icon_trash(),
3609        ),
3610    };
3611
3612    let csrf_hidden = csrf_input(shell.csrf);
3613
3614    let body = format!(
3615        r#"{meta}
3616<form class="rio-card rio-form" method="post" action="{action}" autocomplete="off">
3617{csrf}
3618<div class="rio-form-section">
3619<h2 class="rio-form-section-title">Details</h2>
3620<p class="rio-form-section-hint">Fields marked optional accept an empty value.</p>
3621{fields}
3622</div>
3623<div class="rio-form-footer">
3624<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back_icon}<span>{back_label}</span></a>
3625<div class="rio-footer-actions">
3626<a class="rio-btn" href="/admin/{name}">Cancel</a>
3627<button class="rio-btn rio-btn-primary" type="submit">Save</button>
3628</div>
3629</div>
3630</form>
3631{inverse}
3632{danger}"#,
3633        meta = meta_block,
3634        action = escape_html(&action),
3635        csrf = csrf_hidden,
3636        fields = fields,
3637        name = escape_html(admin_name),
3638        back_icon = icon_arrow_left(),
3639        back_label = escape_html(&back_label),
3640        inverse = inverse_panel,
3641        danger = danger_zone,
3642    );
3643
3644    // Bind the plural href to a local `String` so the breadcrumbs
3645    // array can borrow into it. Without this, `format!` creates a
3646    // temporary that's dropped before the render call.
3647    let plural_href = format!("/admin/{admin_name}");
3648    let crumbs: Vec<Crumb<'_>> = match &mode {
3649        FormMode::Create => vec![
3650            ("Admin", Some("/admin")),
3651            (plural, Some(plural_href.as_str())),
3652            ("New", None),
3653        ],
3654        FormMode::Edit { .. } => vec![
3655            ("Admin", Some("/admin")),
3656            (plural, Some(plural_href.as_str())),
3657            ("Edit", None),
3658        ],
3659    };
3660
3661    // On edit mode, surface the "History" action in the page header
3662    // so operators can navigate to the per-object history page
3663    // without scrolling down to the metadata block.
3664    let page_actions = match &mode {
3665        FormMode::Create => String::new(),
3666        FormMode::Edit { id, .. } => format!(
3667            r#"<a class="rio-btn" href="/admin/{name}/{id}/history">History</a>"#,
3668            name = escape_html(admin_name),
3669            id = id,
3670        ),
3671    };
3672
3673    render_shell_page(
3674        &shell,
3675        200,
3676        &doc_title,
3677        &heading,
3678        Some(&subtitle),
3679        &crumbs,
3680        &page_actions,
3681        &body,
3682    )
3683}
3684
3685/// Render a `(label, input)` block for one editable admin field.
3686///
3687/// Relation-aware: when the field carries `#[rustio(belongs_to =
3688/// "…")]` and we're rendering an existing row (Edit mode), a small
3689/// "linked: <Name> (#<id>)" hint appears under the input. The input
3690/// itself stays numeric in v1 — a proper relation picker is future
3691/// work.
3692fn render_field_block<T: AdminModel>(
3693    f: &AdminField,
3694    item: Option<&T>,
3695    cell_ctx: &CellCtx<'_>,
3696    form_options: &FormRelationOptions,
3697) -> String {
3698    let name = escape_html(f.name);
3699    let mut ui = intelligence::field_ui_metadata(f, intelligence::context_global());
3700    // When the field carries a declared relation, our own richer
3701    // `Linked: <Name>` hint replaces the generic "Foreign-key id"
3702    // hint from the intelligence layer. The relation label also
3703    // drops its trailing " Id" so the field reads as "Patient"
3704    // not "Patient Id".
3705    if f.relation.is_some() {
3706        ui.hint = None;
3707        ui.label = field_label(f);
3708    }
3709    let input = render_field::<T>(f, item, ui.placeholder.as_deref(), form_options);
3710
3711    // Bool fields render as a single checkbox row for compactness.
3712    if matches!(f.ty, FieldType::Bool) {
3713        return format!(
3714            r#"<div class="rio-field rio-field-row-checkbox">
3715{input}
3716<label for="_{name}">{label}</label>
3717</div>"#,
3718            input = input,
3719            name = name,
3720            label = escape_html(&ui.label),
3721        );
3722    }
3723
3724    let optional_mark = if f.nullable {
3725        r#"<span class="rio-field-optional">optional</span>"#.to_string()
3726    } else {
3727        String::new()
3728    };
3729    let sensitive_mark = if ui.sensitive {
3730        let note = ui
3731            .sensitivity_note
3732            .as_deref()
3733            .unwrap_or("Personal data — handle with care.");
3734        format!(
3735            r#"<span class="rio-field-sensitive" title="{note}">🔒 PII</span>"#,
3736            note = escape_html(note),
3737        )
3738    } else {
3739        String::new()
3740    };
3741    let hint_html = match ui.hint.as_deref() {
3742        Some(h) => format!(r#"<p class="rio-field-hint">{}</p>"#, escape_html(h),),
3743        None => String::new(),
3744    };
3745
3746    // Relation hint: "Linked: <Name> (#<id>)" under the numeric input.
3747    // Rendered only when the field has `#[rustio(belongs_to = …)]`,
3748    // we're editing an existing row, and the FK value parses. Stays
3749    // silent when the id is empty, unparseable, or the label
3750    // prefetch returned nothing.
3751    let relation_hint = render_relation_hint::<T>(f, item, cell_ctx);
3752
3753    format!(
3754        r#"<div class="rio-field">
3755<label for="_{name}">{label}{optional}{sensitive}</label>
3756{input}
3757{rel}
3758{hint}
3759</div>"#,
3760        name = name,
3761        label = escape_html(&ui.label),
3762        optional = optional_mark,
3763        sensitive = sensitive_mark,
3764        rel = relation_hint,
3765        hint = hint_html,
3766    )
3767}
3768
3769/// Render a small `<p>` hint below a FK input showing the currently
3770/// linked target. Empty string when:
3771///   - the field has no `belongs_to` annotation,
3772///   - the registry doesn't know about the model / field,
3773///   - there is no current item (Create mode),
3774///   - the value doesn't parse as an i64.
3775fn render_relation_hint<T: AdminModel>(
3776    f: &AdminField,
3777    item: Option<&T>,
3778    cell_ctx: &CellCtx<'_>,
3779) -> String {
3780    let Some(item) = item else {
3781        return String::new();
3782    };
3783    if f.relation.is_none() {
3784        return String::new();
3785    }
3786    let Some(resolved) = cell_ctx.registry.belongs_to(T::singular_name(), f.name) else {
3787        return String::new();
3788    };
3789    let Some(value) = item.field_display(f.name) else {
3790        return String::new();
3791    };
3792    let Ok(id) = value.parse::<i64>() else {
3793        return String::new();
3794    };
3795    let label = cell_ctx.fk_labels.get(f.name).and_then(|m| m.get(&id));
3796    let admin = escape_html(&resolved.target_admin_name);
3797    match (label, &resolved.target_display_field) {
3798        (Some(name), _) => format!(
3799            r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">{name}</a> <span class="rio-cell-id">#{id}</span></p>"#,
3800            admin = admin,
3801            id = id,
3802            name = escape_html(name),
3803        ),
3804        (None, _) => format!(
3805            r#"<p class="rio-field-hint">Linked: <a href="/admin/{admin}/{id}">#{id}</a></p>"#,
3806            admin = admin,
3807            id = id,
3808        ),
3809    }
3810}
3811
3812/// Substring-match the well-known SQLite error message that fires
3813/// on a FK constraint violation. Stable across SQLite 3.x and
3814/// surfaced identically by every sqlx version we support. Fragile
3815/// against localisation, but SQLite does not localise driver error
3816/// messages.
3817fn is_foreign_key_violation(e: &Error) -> bool {
3818    matches!(e, Error::Internal(msg) if msg.contains("FOREIGN KEY constraint failed"))
3819}
3820
3821/// Phase 6 — 409 Conflict page rendered when a delete is refused
3822/// because other rows reference this one. Lists every blocking
3823/// inverse with its count and a link to the filtered list on the
3824/// source model.
3825fn render_delete_blocked_page<T: AdminModel>(
3826    shell: &Shell<'_>,
3827    target_id: i64,
3828    target_primary: &str,
3829    blockers: &[(&relations::InverseRelation, i64)],
3830) -> Response {
3831    let singular = T::singular_name();
3832    let admin_name = T::ADMIN_NAME;
3833    let plural = T::DISPLAY_NAME;
3834    let subject = if target_primary.is_empty() {
3835        format!("{singular} #{target_id}")
3836    } else {
3837        format!("{target_primary} (#{target_id})")
3838    };
3839
3840    let rows: String = blockers
3841        .iter()
3842        .map(|(inv, count)| {
3843            let filter_url = format!(
3844                "/admin/{}?{}={}",
3845                inv.source_admin_name,
3846                urlencoding_light(&inv.source_field),
3847                target_id,
3848            );
3849            format!(
3850                r#"<li class="rio-dashboard-alert"><div><strong>{label}</strong> — referenced by <strong>{count}</strong> row{plural_s} via <code>{field}</code></div><a class="rio-btn rio-btn-sm" href="{url}">Open {label_lower}</a></li>"#,
3851                label = escape_html(&inv.source_display_name),
3852                label_lower = escape_html(&inv.source_display_name.to_lowercase()),
3853                field = escape_html(&inv.source_field),
3854                count = count,
3855                plural_s = if *count == 1 { "" } else { "s" },
3856                url = escape_html(&filter_url),
3857            )
3858        })
3859        .collect();
3860
3861    let back_href = format!("/admin/{}", admin_name);
3862    let body = format!(
3863        r#"<section class="rio-card">
3864<div class="rio-card-header">
3865<h2 class="rio-card-title">Cannot delete {subject}</h2>
3866<p class="rio-card-subtitle">Other records reference this one. Remove or reassign them first, then retry the delete.</p>
3867</div>
3868<ul class="rio-dashboard-alerts" style="list-style:none; margin:0; padding:var(--rio-card-pad)">
3869{rows}
3870</ul>
3871<div class="rio-form-footer">
3872<a class="rio-btn" href="{back}">Back to {plural_lower}</a>
3873</div>
3874</section>"#,
3875        subject = escape_html(&subject),
3876        rows = rows,
3877        back = escape_html(&back_href),
3878        plural_lower = escape_html(&plural.to_lowercase()),
3879    );
3880
3881    let plural_href = back_href.clone();
3882    let crumbs: Vec<Crumb<'_>> = vec![
3883        ("Admin", Some("/admin")),
3884        (plural, Some(plural_href.as_str())),
3885        ("Delete blocked", None),
3886    ];
3887    let doc_title = format!("Cannot delete {subject}");
3888    render_shell_page(
3889        shell,
3890        409,
3891        &doc_title,
3892        "Delete blocked",
3893        Some("Remove the dependent references first, then retry."),
3894        &crumbs,
3895        "",
3896        &body,
3897    )
3898}
3899
3900/// Phase 4 — render a "Related" block listing every `has_many`
3901/// inverse of the current model with its row count and a link to
3902/// the filtered list on the source model.
3903///
3904/// v1 shows counts only. Future evolution (per the module header in
3905/// `admin/relations.rs`):
3906///   - preview rows (top-N newest),
3907///   - in-page expand rather than link-out,
3908///   - per-panel search.
3909fn render_inverse_panel<T: AdminModel>(
3910    registry: &relations::RelationRegistry,
3911    counts: &InverseCounts,
3912    target_id: i64,
3913) -> String {
3914    let inverses = registry.has_many(T::singular_name());
3915    if inverses.is_empty() {
3916        return String::new();
3917    }
3918    let cards: String = inverses
3919        .iter()
3920        .map(|inv| {
3921            let key = format!("{}.{}", inv.source_model, inv.source_field);
3922            let count = counts.get(&key).copied().unwrap_or(0);
3923            let label = inv.source_display_name.to_string();
3924            // `source_field` is guaranteed snake_case by the Rust
3925            // identifier rules, so URL-encoding is moot. Kept as a
3926            // helper call for future-proofing.
3927            let filter_url = format!(
3928                "/admin/{}?{}={}",
3929                inv.source_admin_name,
3930                urlencoding_light(&inv.source_field),
3931                target_id,
3932            );
3933            format!(
3934                r#"<li><a href="{url}" class="rio-suggestion-card"><div><strong>{label}</strong> <span class="rio-cell-id">({count})</span></div><div class="rio-cell-muted">via {field}</div></a></li>"#,
3935                url = escape_html(&filter_url),
3936                label = escape_html(&label),
3937                count = count,
3938                field = escape_html(&inv.source_field),
3939            )
3940        })
3941        .collect();
3942    format!(
3943        r#"<section class="rio-card rio-related">
3944<div class="rio-card-header">
3945<h2 class="rio-card-title">Related</h2>
3946<p class="rio-card-subtitle">Incoming references to this record.</p>
3947</div>
3948<ul class="rio-related-grid">
3949{cards}
3950</ul>
3951</section>"#,
3952        cards = cards,
3953    )
3954}
3955
3956/// Minimal URL-path encoder. Doesn't depend on the `url` crate —
3957/// only replaces the handful of characters that would break an
3958/// unquoted query-string key or value. All our source_field names
3959/// are snake_case idents (already safe) so this is really just a
3960/// belt-and-suspenders measure for future schemas.
3961fn urlencoding_light(s: &str) -> String {
3962    let mut out = String::with_capacity(s.len());
3963    for ch in s.chars() {
3964        match ch {
3965            ' ' => out.push_str("%20"),
3966            '&' => out.push_str("%26"),
3967            '=' => out.push_str("%3D"),
3968            '#' => out.push_str("%23"),
3969            '?' => out.push_str("%3F"),
3970            _ => out.push(ch),
3971        }
3972    }
3973    out
3974}
3975
3976/// Convert snake_case to Title Case for humans.
3977fn humanise(s: &str) -> String {
3978    let mut out = String::with_capacity(s.len());
3979    let mut next_upper = true;
3980    for ch in s.chars() {
3981        if ch == '_' {
3982            out.push(' ');
3983            next_upper = true;
3984        } else if next_upper {
3985            out.push(ch.to_ascii_uppercase());
3986            next_upper = false;
3987        } else {
3988            out.push(ch);
3989        }
3990    }
3991    out
3992}
3993
3994/// Humanise an enum-like `String` value for display. `in_progress` →
3995/// `"In progress"` (sentence case, not Title Case — an enum value in
3996/// a dropdown reads better as a label than as a book title). Raw
3997/// value stays untouched for round-trip via form POST.
3998fn humanise_enum_value(s: &str) -> String {
3999    let mut out = String::with_capacity(s.len());
4000    let mut first = true;
4001    for ch in s.chars() {
4002        if ch == '_' {
4003            out.push(' ');
4004        } else if first {
4005            out.push(ch.to_ascii_uppercase());
4006            first = false;
4007        } else {
4008            out.push(ch.to_ascii_lowercase());
4009        }
4010    }
4011    out
4012}
4013
4014/// Naive pluralise for filter placeholders. `"patient"` → `"patients"`,
4015/// `"status"` → `"statuses"`, `"category"` → `"categories"`. Good
4016/// enough for our display labels; the macro's plural for model names
4017/// is a separate codepath.
4018fn pluralise_label(s: &str) -> String {
4019    let lower = s.to_lowercase();
4020    if lower.ends_with('s')
4021        || lower.ends_with('x')
4022        || lower.ends_with("ch")
4023        || lower.ends_with("sh")
4024    {
4025        format!("{lower}es")
4026    } else if lower.ends_with('y')
4027        && !lower.ends_with("ay")
4028        && !lower.ends_with("ey")
4029        && !lower.ends_with("iy")
4030        && !lower.ends_with("oy")
4031        && !lower.ends_with("uy")
4032    {
4033        format!("{}ies", &lower[..lower.len() - 1])
4034    } else {
4035        format!("{lower}s")
4036    }
4037}
4038
4039/// Default column cap for the list-page visible set. Above this the
4040/// table starts to feel wide; users opt into more via the Columns
4041/// control. Caps apply after the rule-set + fill step below.
4042const DEFAULT_VISIBLE_COLUMNS: usize = 5;
4043
4044/// Name-like columns that rule 3 in [`is_primary_column`] treats as
4045/// the "first match" name field. A single model may have several
4046/// of these (e.g. both `full_name` and `email`); only the one that
4047/// appears first in declaration order gets primary status, handled
4048/// by the caller since the decision needs cross-field context.
4049const NAME_LIKE_FIELDS: &[&str] = &["name", "full_name", "title", "email"];
4050
4051/// Field-local rules for primary-column membership. Returns `true`
4052/// if the column qualifies based on properties of the field alone;
4053/// rule 3 (first name-like match) requires model-wide context and is
4054/// applied in [`default_list_columns`].
4055///
4056/// Rules applied here (spec order, excluding rule 3):
4057///   1. `name == "id"`              → primary
4058///   2. model's `primary_display_field` attribute matches — RustIO
4059///      has no such attribute today; placeholder for a future macro
4060///      addition. Always `false` in this pass.
4061///   4. `relation.is_some()` AND    → primary
4062///      name ends with `_id`
4063///   5. `Bool` type AND             → primary
4064///      name starts with `is_`
4065///   6. name ∈ {status, state, priority} → primary
4066///   7. otherwise                   → not primary
4067///
4068/// Rule 3 lives in [`default_list_columns`] because "first match on
4069/// the model" can't be decided from a single `&AdminField`.
4070pub(crate) fn is_primary_column(f: &AdminField) -> bool {
4071    // Rule 1.
4072    if f.name == "id" {
4073        return true;
4074    }
4075    // Rule 2 intentionally omitted — no such attribute yet.
4076    // Rule 4.
4077    if f.relation.is_some() && f.name.ends_with("_id") {
4078        return true;
4079    }
4080    // Rule 5.
4081    if matches!(f.ty, FieldType::Bool) && f.name.starts_with("is_") {
4082        return true;
4083    }
4084    // Rule 6.
4085    if matches!(f.name, "status" | "state" | "priority") {
4086        return true;
4087    }
4088    // Rule 7.
4089    false
4090}
4091
4092/// Compute the default set of visible columns for a model's list
4093/// page. Generic across every rustio project — relies only on
4094/// [`AdminField`] shape (type, name, relation). Algorithm:
4095///
4096/// 1. Collect every field that matches [`is_primary_column`].
4097/// 2. Apply rule 3: if any field name appears in [`NAME_LIKE_FIELDS`],
4098///    include only the first-in-declaration-order match.
4099/// 3. Cap at [`DEFAULT_VISIBLE_COLUMNS`] (5), keeping declaration
4100///    order.
4101///
4102/// If fewer than 5 fields match, the return value has fewer than 5
4103/// entries — no padding. Users can reveal hidden columns via the
4104/// Columns control.
4105///
4106/// Output preserves declaration order, which is how the list
4107/// renderer expects it.
4108pub(crate) fn default_list_columns<T: AdminModel>() -> Vec<&'static str> {
4109    let fields = T::FIELDS;
4110    let mut picked: Vec<&'static str> = Vec::with_capacity(DEFAULT_VISIBLE_COLUMNS);
4111
4112    // Rule-based picks, with rule 3 applied as "first name-like
4113    // match wins". Scan in declaration order; a later name-like
4114    // field can't override an earlier one. Cap at
4115    // DEFAULT_VISIBLE_COLUMNS; no fill step.
4116    let mut name_rule_used = false;
4117    for f in fields {
4118        if picked.len() >= DEFAULT_VISIBLE_COLUMNS {
4119            break;
4120        }
4121        let hits_name_rule = !name_rule_used && NAME_LIKE_FIELDS.contains(&f.name);
4122        if is_primary_column(f) || hits_name_rule {
4123            picked.push(f.name);
4124            if hits_name_rule {
4125                name_rule_used = true;
4126            }
4127        }
4128    }
4129
4130    picked
4131}
4132
4133/// Drop a trailing ` Id` from a humanised field name when the column
4134/// is declared as a FK relation. `"Patient Id"` → `"Patient"` so the
4135/// edit form reads naturally. The underlying form field name stays
4136/// the raw column (`patient_id`) so POST still round-trips.
4137fn field_label(f: &AdminField) -> String {
4138    let base = humanise(f.name);
4139    if f.relation.is_some() {
4140        base.strip_suffix(" Id").map(str::to_string).unwrap_or(base)
4141    } else {
4142        base
4143    }
4144}
4145
4146/// Render a metadata block for the edit page: id + immutable
4147/// (non-editable) field values.
4148fn render_meta<T: AdminModel>(id: i64, item: &T) -> String {
4149    let mut items = vec![format!(
4150        r#"<div class="rio-meta-item">
4151<span class="rio-meta-label">ID</span>
4152<span class="rio-meta-value">#{id}</span>
4153</div>"#,
4154    )];
4155
4156    for f in T::FIELDS.iter() {
4157        if f.editable || f.name == "id" {
4158            continue;
4159        }
4160        let value = item.field_display(f.name).unwrap_or_default();
4161        let shown = if value.is_empty() {
4162            "—".to_string()
4163        } else {
4164            value
4165        };
4166        items.push(format!(
4167            r#"<div class="rio-meta-item">
4168<span class="rio-meta-label">{label}</span>
4169<span class="rio-meta-value">{value}</span>
4170</div>"#,
4171            label = escape_html(&humanise(f.name)),
4172            value = escape_html(&shown),
4173        ));
4174    }
4175
4176    format!(r#"<div class="rio-meta">{}</div>"#, items.join(""))
4177}
4178
4179/// Render the raw input widget for an admin field. Signature preserved
4180/// from earlier releases because tests and downstream code depend on
4181/// the exact output shape (presence/absence of `required`, the
4182/// `type="datetime-local"` hook, `value=""` for None).
4183fn render_field<T: AdminModel>(
4184    f: &AdminField,
4185    item: Option<&T>,
4186    placeholder: Option<&str>,
4187    form_options: &FormRelationOptions,
4188) -> String {
4189    let current = item
4190        .and_then(|i| i.field_display(f.name))
4191        .unwrap_or_default();
4192    let n = escape_html(f.name);
4193    let v = escape_html(&current);
4194
4195    let required = if !f.nullable && !matches!(f.ty, FieldType::Bool) {
4196        " required"
4197    } else {
4198        ""
4199    };
4200
4201    let placeholder_attr = match placeholder {
4202        Some(p) if !p.is_empty() => format!(r#" placeholder="{}""#, escape_html(p)),
4203        _ => String::new(),
4204    };
4205
4206    // FK dropdown — preferred presentation for i32/i64 fields that
4207    // carry a `belongs_to` AND have a pre-fetched option list (see
4208    // [`fetch_form_relation_options`]). Falls through to the raw
4209    // number input when no options are present (no relation, no
4210    // `display_field`, or row count over the cap).
4211    if matches!(f.ty, FieldType::I32 | FieldType::I64) {
4212        if let Some(options) = form_options.get(f.name) {
4213            let none_opt = if f.nullable {
4214                r#"<option value="">— none —</option>"#
4215            } else {
4216                r#"<option value="" disabled selected>Select…</option>"#
4217            };
4218            let options_html: String = options
4219                .iter()
4220                .map(|(id, label)| {
4221                    let selected = if current == id.to_string() {
4222                        " selected"
4223                    } else {
4224                        ""
4225                    };
4226                    format!(
4227                        r#"<option value="{id}"{selected}>{label}</option>"#,
4228                        id = id,
4229                        selected = selected,
4230                        label = escape_html(label),
4231                    )
4232                })
4233                .collect();
4234            // Suppress the default-selected "Select…" when editing a
4235            // row whose FK already has a value — the matching option
4236            // already carries `selected`.
4237            let none_opt = if !current.is_empty() && !f.nullable {
4238                r#"<option value="" disabled>Select…</option>"#
4239            } else {
4240                none_opt
4241            };
4242            return format!(
4243                r#"<select class="rio-input rio-select" id="_{n}" name="{n}"{required}>{none}{opts}</select>"#,
4244                n = n,
4245                required = required,
4246                none = none_opt,
4247                opts = options_html,
4248            );
4249        }
4250    }
4251
4252    match f.ty {
4253        FieldType::Bool => format!(
4254            r#"<input class="rio-checkbox" id="_{n}" type="checkbox" name="{n}" {checked}>"#,
4255            checked = if current == "true" { "checked" } else { "" },
4256        ),
4257        FieldType::I32 | FieldType::I64 => {
4258            format!(
4259                r#"<input class="rio-input" id="_{n}" type="number" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4260            )
4261        }
4262        FieldType::String => {
4263            format!(
4264                r#"<input class="rio-input" id="_{n}" type="text" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4265            )
4266        }
4267        FieldType::DateTime => {
4268            format!(
4269                r#"<input class="rio-input" id="_{n}" type="datetime-local" name="{n}" value="{v}"{required}{placeholder_attr}>"#
4270            )
4271        }
4272    }
4273}
4274
4275// ---------------------------------------------------------------------------
4276// Delete confirmation page
4277// ---------------------------------------------------------------------------
4278
4279fn delete_confirmation_response<T: AdminModel>(shell: Shell<'_>, id: i64, item: &T) -> Response {
4280    let singular = T::singular_name();
4281    let plural = T::DISPLAY_NAME;
4282    let admin_name = T::ADMIN_NAME;
4283
4284    // Pick a display value: the first editable String field, or fall
4285    // back to the id. Gives the confirmation page some context beyond
4286    // a bare number.
4287    let summary = T::FIELDS
4288        .iter()
4289        .find(|f| f.editable && matches!(f.ty, FieldType::String))
4290        .and_then(|f| item.field_display(f.name))
4291        .filter(|s| !s.is_empty())
4292        .unwrap_or_else(|| format!("#{id}"));
4293
4294    let csrf_hidden = csrf_input(shell.csrf);
4295
4296    // Context-aware GDPR / PII banner. Fires when this record has at
4297    // least one field the intelligence layer classifies as sensitive
4298    // under the project's `rustio.context.json`.
4299    let ctx = intelligence::context_global();
4300    let has_pii = T::FIELDS
4301        .iter()
4302        .any(|f| intelligence::field_ui_metadata(f, ctx).sensitive);
4303    let pii_banner = if has_pii {
4304        let note = if ctx.is_some_and(|c| c.requires_gdpr()) {
4305            "This record contains personal data (GDPR). Deletion is typically irreversible — verify you have the right to erase."
4306        } else {
4307            "This record contains fields flagged as personal data. Review before proceeding."
4308        };
4309        format!(
4310            r#"<div class="rio-alert rio-alert-error">{icon}<div><strong>Sensitive data.</strong> {note}</div></div>"#,
4311            icon = icon_shield_alert(),
4312            note = escape_html(note),
4313        )
4314    } else {
4315        String::new()
4316    };
4317
4318    let body = format!(
4319        r#"<div class="rio-card">
4320<div class="rio-card-body">
4321{pii_banner}
4322<div class="rio-alert rio-alert-warn">
4323{warn}
4324<div>
4325<strong>This action cannot be undone.</strong>
4326Deleting this record removes it permanently. Rows that reference it via a foreign key with <code>ON DELETE CASCADE</code> will be deleted too.
4327</div>
4328</div>
4329<p>You are about to delete <strong>{singular}</strong>:</p>
4330<div class="rio-meta">
4331<div class="rio-meta-item">
4332<span class="rio-meta-label">ID</span>
4333<span class="rio-meta-value">#{id}</span>
4334</div>
4335<div class="rio-meta-item">
4336<span class="rio-meta-label">Summary</span>
4337<span class="rio-meta-value">{summary}</span>
4338</div>
4339</div>
4340</div>
4341<div class="rio-form-footer">
4342<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Back to {plural_lower}</span></a>
4343<div class="rio-footer-actions">
4344<a class="rio-btn" href="/admin/{name}/{id}/edit">Cancel</a>
4345<form class="rio-inline-form" method="post" action="/admin/{name}/{id}/delete">
4346{csrf}
4347<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Delete {singular}</span></button>
4348</form>
4349</div>
4350</div>
4351</div>"#,
4352        warn = icon_triangle_alert(),
4353        singular = escape_html(singular),
4354        id = id,
4355        summary = escape_html(&summary),
4356        name = escape_html(admin_name),
4357        back = icon_arrow_left(),
4358        plural_lower = escape_html(&plural.to_lowercase()),
4359        csrf = csrf_hidden,
4360        trash = icon_trash(),
4361    );
4362
4363    let plural_href = format!("/admin/{admin_name}");
4364    let crumbs: &[Crumb<'_>] = &[
4365        ("Admin", Some("/admin")),
4366        (plural, Some(&plural_href)),
4367        ("Delete", None),
4368    ];
4369
4370    render_shell_page(
4371        &shell,
4372        200,
4373        &format!("Delete {singular}"),
4374        &format!("Delete {singular}?"),
4375        Some("Confirm you want to remove this record."),
4376        crumbs,
4377        "",
4378        &body,
4379    )
4380}
4381
4382/// Intermediate confirmation page for a bulk delete. Shows the list of
4383/// records that will be deleted and posts back to the same URL with
4384/// `_confirm=yes` when the operator commits. Mirrors Django's "Are
4385/// you sure?" step.
4386fn bulk_delete_confirmation_response<T: AdminModel>(
4387    shell: &Shell<'_>,
4388    items: &[(i64, String)],
4389) -> Response {
4390    let singular = T::singular_name();
4391    let plural = T::DISPLAY_NAME;
4392    let admin_name = T::ADMIN_NAME;
4393    let csrf_hidden = csrf_input(shell.csrf);
4394
4395    let count = items.len();
4396    let count_label = if count == 1 {
4397        format!("1 {}", singular.to_lowercase())
4398    } else {
4399        format!("{count} {}", plural.to_lowercase())
4400    };
4401
4402    // Hidden `_selected` carries the IDs forward. Each row shows the
4403    // id + the primary string value so the operator can verify.
4404    let selected_csv: String = items
4405        .iter()
4406        .map(|(id, _)| id.to_string())
4407        .collect::<Vec<_>>()
4408        .join(",");
4409    let rows: String = items
4410        .iter()
4411        .map(|(id, primary)| {
4412            let label = if primary.is_empty() {
4413                format!("#{id}")
4414            } else {
4415                format!("#{id} · {primary}")
4416            };
4417            format!(
4418                r#"<li class="rio-bulk-item">{label}</li>"#,
4419                label = escape_html(&label),
4420            )
4421        })
4422        .collect();
4423
4424    let body = format!(
4425        r#"<div class="rio-card">
4426<div class="rio-card-body">
4427<div class="rio-alert rio-alert-warn">
4428{warn}
4429<div>
4430<strong>This action cannot be undone.</strong>
4431You are about to delete <strong>{count_label}</strong>. Each record removed here is logged individually in <a href="/admin/actions">Recent actions</a>.
4432</div>
4433</div>
4434<p>Review the list, then confirm:</p>
4435<ul class="rio-bulk-list">{rows}</ul>
4436</div>
4437<form method="post" action="/admin/{name}/bulk_action" class="rio-form-footer">
4438{csrf}
4439<input type="hidden" name="action" value="delete">
4440<input type="hidden" name="_selected" value="{selected}">
4441<input type="hidden" name="_confirm" value="yes">
4442<a class="rio-btn rio-btn-ghost" href="/admin/{name}">{back}<span>Cancel</span></a>
4443<div class="rio-footer-actions">
4444<button class="rio-btn rio-btn-danger" type="submit">{trash}<span>Yes, delete {count_label}</span></button>
4445</div>
4446</form>
4447</div>"#,
4448        warn = icon_triangle_alert(),
4449        count_label = escape_html(&count_label),
4450        rows = rows,
4451        name = escape_html(admin_name),
4452        csrf = csrf_hidden,
4453        selected = escape_html(&selected_csv),
4454        back = icon_arrow_left(),
4455        trash = icon_trash(),
4456    );
4457
4458    let plural_href = format!("/admin/{admin_name}");
4459    let crumbs: &[Crumb<'_>] = &[
4460        ("Admin", Some("/admin")),
4461        (plural, Some(&plural_href)),
4462        ("Delete selected", None),
4463    ];
4464
4465    render_shell_page(
4466        shell,
4467        200,
4468        &format!("Delete selected {}", plural.to_lowercase()),
4469        &format!("Delete selected {}?", plural.to_lowercase()),
4470        Some("Confirm you want to remove these records."),
4471        crumbs,
4472        "",
4473        &body,
4474    )
4475}
4476
4477// ---------------------------------------------------------------------------
4478// Styled 404 + 500 (rendered inside the admin shell)
4479// ---------------------------------------------------------------------------
4480
4481fn new_request_id() -> String {
4482    use rand::RngCore as _;
4483    let mut buf = [0u8; 6];
4484    rand::rngs::OsRng.fill_bytes(&mut buf);
4485    let mut s = String::with_capacity(12);
4486    for b in buf {
4487        s.push_str(&format!("{b:02x}"));
4488    }
4489    s
4490}
4491
4492/// Minimal shell built without a live `Context`. Used by the admin
4493/// error middleware when the request has already been consumed.
4494fn error_shell<'a>(
4495    entries: &'a [AdminEntry],
4496    email: Option<&'a str>,
4497    csrf: Option<&'a str>,
4498) -> Shell<'a> {
4499    Shell {
4500        entries,
4501        active: None,
4502        user_email: email,
4503        csrf,
4504    }
4505}
4506
4507/// 404 page rendered via `minijinja` against `auth/not_found.html`.
4508/// Called by the admin error middleware when any `/admin/*` handler
4509/// returns `Err(Error::NotFound)`. Unused callbacks (`entries`,
4510/// `email`) are kept in the signature to match the existing call
4511/// site — stage 5 cleanup retires the argument list.
4512fn admin_not_found_response(
4513    _entries: &[AdminEntry],
4514    _email: Option<&str>,
4515    csrf: Option<&str>,
4516) -> Response {
4517    let design = design::Design::global();
4518    let env = crate::admin::templating::env();
4519    let body = match env.get_template("auth/not_found.html").and_then(|tmpl| {
4520        tmpl.render(minijinja::context! {
4521            design => minijinja::context! {
4522                project_name => design.project_name.as_str(),
4523                logo_initial => design.logo_initial.as_str(),
4524            },
4525            csrf_token => csrf.unwrap_or(""),
4526        })
4527    }) {
4528        Ok(html) => html,
4529        Err(err) => {
4530            eprintln!("admin not-found template render failed: {err}");
4531            format!(
4532                "<!doctype html><html><head><meta charset=\"utf-8\"><title>404 Not Found · {p}</title></head><body style=\"font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center\"><p>404 Not Found</p><h1>We couldn't find that page.</h1><p><a href=\"/admin\">Back to dashboard</a></p></body></html>",
4533                p = escape_html(&design.project_name),
4534            )
4535        }
4536    };
4537    let resp = hyper::Response::builder()
4538        .status(404)
4539        .header("content-type", "text/html; charset=utf-8")
4540        .body(Full::new(Bytes::from(body)))
4541        .expect("valid response");
4542    with_admin_headers(resp)
4543}
4544
4545fn admin_server_error_response(
4546    entries: &[AdminEntry],
4547    email: Option<&str>,
4548    csrf: Option<&str>,
4549    request_id: &str,
4550) -> Response {
4551    let shell = error_shell(entries, email, csrf);
4552    let when = Utc::now().format("%Y-%m-%d %H:%M UTC").to_string();
4553    let body = format!(
4554        r#"<div class="rio-card">
4555<div class="rio-card-body">
4556<div class="rio-alert rio-alert-error">
4557{icon}
4558<div>
4559<strong>Something went wrong.</strong>
4560The admin could not complete your request. The detail has been logged server-side; the summary below is what to share when reporting.
4561</div>
4562</div>
4563<div class="rio-meta">
4564<div class="rio-meta-item">
4565<span class="rio-meta-label">Request ID</span>
4566<span class="rio-meta-value"><code>{rid}</code></span>
4567</div>
4568<div class="rio-meta-item">
4569<span class="rio-meta-label">Timestamp</span>
4570<span class="rio-meta-value">{when}</span>
4571</div>
4572</div>
4573<div class="rio-error-actions">
4574<a class="rio-btn" href="/admin">{back}<span>Back to dashboard</span></a>
4575</div>
4576</div>
4577</div>"#,
4578        icon = icon_triangle_alert(),
4579        rid = escape_html(request_id),
4580        when = escape_html(&when),
4581        back = icon_arrow_left(),
4582    );
4583    let crumbs: &[Crumb<'_>] = &[("Admin", Some("/admin")), ("Server error", None)];
4584    render_shell_page(
4585        &shell,
4586        500,
4587        "500 Server Error",
4588        "500 · Server error",
4589        Some("The admin could not complete your request."),
4590        crumbs,
4591        "",
4592        &body,
4593    )
4594}
4595
4596// ---------------------------------------------------------------------------
4597// Shared admin-engine model handlers
4598// ---------------------------------------------------------------------------
4599//
4600// `/admin/:model` and `/admin-new/:model` both delegate here so the
4601// engine has exactly one implementation. Routing is the only thing
4602// that differs between the two surfaces. After parity is confirmed
4603// the alias `/admin-new/:model` will be removed; this file stays
4604// untouched at that point.
4605
4606async fn admin_model_index_get(
4607    db: &Db,
4608    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4609    legacy_entries: &[AdminEntry],
4610    req: Request,
4611    params: crate::router::Params,
4612) -> Result<Response, Error> {
4613    if let Err(resp) = admin_guard(req.ctx()) {
4614        return Ok(resp);
4615    }
4616    let model_slug = params.get("model").unwrap_or("").to_string();
4617
4618    // Resolve the model: new registry first, then fall back to
4619    // a legacy `AdminEntry` (wrapped in a `LegacyEntryModel` adapter
4620    // so the template-based renderer doesn't care which source it
4621    // came from). If neither source knows the slug, 404.
4622    enum ResolvedModel {
4623        New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4624        Legacy(crate::admin::layout::LegacyEntryModel),
4625    }
4626    let resolved = if let Some(model) = registry.get(&model_slug) {
4627        ResolvedModel::New(model)
4628    } else if let Some(entry) = legacy_entries
4629        .iter()
4630        .find(|e| !e.core && e.admin_name == model_slug)
4631    {
4632        ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4633    } else {
4634        return Err(Error::NotFound);
4635    };
4636    let q_map = req.query().into_map();
4637    let id = q_map.get("id").filter(|s| !s.is_empty()).cloned();
4638    let query = q_map
4639        .get("q")
4640        .map(|s| s.trim())
4641        .filter(|s| !s.is_empty())
4642        .map(String::from);
4643    let page = q_map
4644        .get("page")
4645        .and_then(|p| p.parse::<i64>().ok())
4646        .filter(|p| *p > 0)
4647        .unwrap_or(1);
4648    let sort = q_map.get("sort").filter(|s| !s.is_empty()).cloned();
4649    let dir = q_map.get("dir").filter(|s| !s.is_empty()).cloned();
4650    let filters: std::collections::HashMap<String, String> = q_map
4651        .iter()
4652        .filter(|(k, v)| {
4653            !v.is_empty()
4654                && k.as_str() != "q"
4655                && k.as_str() != "page"
4656                && k.as_str() != "id"
4657                && k.as_str() != "sort"
4658                && k.as_str() != "dir"
4659                && k.as_str() != "advanced"
4660        })
4661        .map(|(k, v)| (k.clone(), v.clone()))
4662        .collect();
4663    let _ = q_map
4664        .get("advanced")
4665        .map(|s| !s.is_empty())
4666        .unwrap_or(false);
4667    // Stage 4e: route to the template-based list renderer. The `id`
4668    // query param (drawer-edit in the legacy flow) is intentionally
4669    // ignored here; stage 4f adds dedicated /admin/:model/:id/edit
4670    // routes as the replacement.
4671    let _ = id;
4672    let identity = crate::auth::identity(req.ctx()).cloned();
4673    let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4674    let html = match &resolved {
4675        ResolvedModel::New(model) => {
4676            crate::admin::layout::list_render(
4677                db,
4678                registry,
4679                legacy_entries,
4680                &**model,
4681                None,
4682                query.as_deref(),
4683                page,
4684                &filters,
4685                sort.as_deref(),
4686                dir.as_deref(),
4687                identity.as_ref(),
4688                csrf.as_deref(),
4689            )
4690            .await
4691        }
4692        ResolvedModel::Legacy(model) => {
4693            let source = model.source_entry().clone();
4694            crate::admin::layout::list_render(
4695                db,
4696                registry,
4697                legacy_entries,
4698                model,
4699                Some(&source),
4700                query.as_deref(),
4701                page,
4702                &filters,
4703                sort.as_deref(),
4704                dir.as_deref(),
4705                identity.as_ref(),
4706                csrf.as_deref(),
4707            )
4708            .await
4709        }
4710    };
4711    Ok(with_admin_headers(crate::http::html(html)))
4712}
4713
4714/// GET handler for both `/admin/:model/new` and
4715/// `/admin/:model/:id/edit`. Resolves the slug through the same
4716/// new-registry → legacy-entries fallback that
4717/// `admin_model_index_get` uses, then delegates to
4718/// `layout::form_render`. No mutation — stage 4f-b wires POST.
4719async fn admin_model_form_get(
4720    db: &Db,
4721    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4722    legacy_entries: &[AdminEntry],
4723    req: Request,
4724    params: crate::router::Params,
4725    editing_id: Option<&str>,
4726) -> Result<Response, Error> {
4727    if let Err(resp) = admin_guard(req.ctx()) {
4728        return Ok(resp);
4729    }
4730    let model_slug = params.get("model").unwrap_or("").to_string();
4731
4732    enum ResolvedModel {
4733        New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4734        Legacy(crate::admin::layout::LegacyEntryModel),
4735    }
4736    let resolved = if let Some(model) = registry.get(&model_slug) {
4737        ResolvedModel::New(model)
4738    } else if let Some(entry) = legacy_entries
4739        .iter()
4740        .find(|e| !e.core && e.admin_name == model_slug)
4741    {
4742        ResolvedModel::Legacy(crate::admin::layout::LegacyEntryModel::new(entry))
4743    } else {
4744        return Err(Error::NotFound);
4745    };
4746
4747    let identity = crate::auth::identity(req.ctx()).cloned();
4748    let csrf = ctx_csrf(req.ctx()).map(str::to_string);
4749    let html = match &resolved {
4750        ResolvedModel::New(model) => {
4751            crate::admin::layout::form_render(
4752                db,
4753                registry,
4754                legacy_entries,
4755                &**model,
4756                None,
4757                editing_id,
4758                identity.as_ref(),
4759                csrf.as_deref(),
4760                None,
4761            )
4762            .await
4763        }
4764        ResolvedModel::Legacy(model) => {
4765            let source = model.source_entry().clone();
4766            crate::admin::layout::form_render(
4767                db,
4768                registry,
4769                legacy_entries,
4770                model,
4771                Some(&source),
4772                editing_id,
4773                identity.as_ref(),
4774                csrf.as_deref(),
4775                None,
4776            )
4777            .await
4778        }
4779    };
4780    Ok(with_admin_headers(crate::http::html(html)))
4781}
4782
4783// 0.10 stage 4f-b: POST handlers for the new form routes.
4784//
4785// Each handler resolves the `:model` slug through the same
4786// new-registry → legacy-entries fallback the GET handlers use,
4787// validates CSRF against the session, then mutates via
4788// `admin::persistence`. Success → 303 redirect to the list page
4789// (Post/Redirect/Get). Failure → re-render the form with an error
4790// banner (no input preservation yet — see TODO in `form_render`).
4791
4792fn resolve_form_model(
4793    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4794    legacy_entries: &[AdminEntry],
4795    slug: &str,
4796) -> Result<FormResolvedModel, Error> {
4797    if let Some(model) = registry.get(slug) {
4798        return Ok(FormResolvedModel::New(model));
4799    }
4800    if let Some(entry) = legacy_entries
4801        .iter()
4802        .find(|e| !e.core && e.admin_name == slug)
4803    {
4804        return Ok(FormResolvedModel::Legacy(
4805            crate::admin::layout::LegacyEntryModel::new(entry),
4806        ));
4807    }
4808    Err(Error::NotFound)
4809}
4810
4811enum FormResolvedModel {
4812    New(Box<dyn crate::admin::admin_form_bridge::AdminUiModel>),
4813    Legacy(crate::admin::layout::LegacyEntryModel),
4814}
4815
4816impl FormResolvedModel {
4817    fn as_ui_model(&self) -> &dyn crate::admin::admin_form_bridge::AdminUiModel {
4818        match self {
4819            FormResolvedModel::New(m) => &**m,
4820            FormResolvedModel::Legacy(m) => m,
4821        }
4822    }
4823
4824    /// `Some(entry)` when the model came from a legacy registration,
4825    /// so the form layer can populate FK `<select>` options from the
4826    /// entry's relation metadata. `None` for new-engine models —
4827    /// they pre-populate `AdminUiField.options` at registration time.
4828    fn legacy_source(&self) -> Option<&AdminEntry> {
4829        match self {
4830            FormResolvedModel::New(_) => None,
4831            FormResolvedModel::Legacy(m) => Some(m.source_entry()),
4832        }
4833    }
4834}
4835
4836/// Build a `column → value` map from a submitted form body, scoped
4837/// to the model's declared fields. The PK is excluded so a client
4838/// can't overwrite it. Readonly fields are excluded too — they
4839/// display on the edit form but must not be accepted as inputs.
4840fn build_mutation_data(
4841    model: &dyn crate::admin::admin_form_bridge::AdminUiModel,
4842    form: &FormData,
4843) -> std::collections::HashMap<String, String> {
4844    let pk = model.primary_key();
4845    let mut out = std::collections::HashMap::new();
4846    for field in model.fields() {
4847        if field.name == pk || field.readonly {
4848            continue;
4849        }
4850        let value = form.get(field.name).unwrap_or("");
4851        out.insert(field.name.to_string(), value.to_string());
4852    }
4853    out
4854}
4855
4856async fn admin_model_create_post(
4857    db: &Db,
4858    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4859    legacy_entries: &[AdminEntry],
4860    req: Request,
4861    params: crate::router::Params,
4862) -> Result<Response, Error> {
4863    if let Err(resp) = admin_guard(req.ctx()) {
4864        return Ok(resp);
4865    }
4866    let model_slug = params.get("model").unwrap_or("").to_string();
4867    let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4868
4869    let (_, body, ctx) = req.into_parts();
4870    let form = read_form_from_parts(body).await?;
4871    require_csrf(&ctx, &form)?;
4872
4873    let data = build_mutation_data(resolved.as_ui_model(), &form);
4874    match crate::admin::persistence::insert_record(db, resolved.as_ui_model().table_name(), &data)
4875        .await
4876    {
4877        Ok(_) => Ok(with_admin_headers(redirect(&format!(
4878            "/admin/{model_slug}"
4879        )))),
4880        Err(e) => {
4881            let identity = crate::auth::identity(&ctx).cloned();
4882            let csrf = ctx_csrf(&ctx).map(str::to_string);
4883            let error_msg = format!("Could not create: {e}");
4884            let source = resolved.legacy_source().cloned();
4885            let html = crate::admin::layout::form_render(
4886                db,
4887                registry,
4888                legacy_entries,
4889                resolved.as_ui_model(),
4890                source.as_ref(),
4891                None,
4892                identity.as_ref(),
4893                csrf.as_deref(),
4894                Some(&error_msg),
4895            )
4896            .await;
4897            Ok(with_admin_headers(crate::http::html(html)))
4898        }
4899    }
4900}
4901
4902async fn admin_model_update_post(
4903    db: &Db,
4904    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4905    legacy_entries: &[AdminEntry],
4906    req: Request,
4907    params: crate::router::Params,
4908) -> Result<Response, Error> {
4909    if let Err(resp) = admin_guard(req.ctx()) {
4910        return Ok(resp);
4911    }
4912    let model_slug = params.get("model").unwrap_or("").to_string();
4913    let id = params.get("id").unwrap_or("").to_string();
4914    if id.is_empty() {
4915        return Err(Error::BadRequest("missing id".into()));
4916    }
4917    let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4918
4919    let (_, body, ctx) = req.into_parts();
4920    let form = read_form_from_parts(body).await?;
4921    require_csrf(&ctx, &form)?;
4922
4923    let data = build_mutation_data(resolved.as_ui_model(), &form);
4924    match crate::admin::persistence::update_record(
4925        db,
4926        resolved.as_ui_model().table_name(),
4927        &id,
4928        &data,
4929    )
4930    .await
4931    {
4932        Ok(_) => Ok(with_admin_headers(redirect(&format!(
4933            "/admin/{model_slug}"
4934        )))),
4935        Err(e) => {
4936            let identity = crate::auth::identity(&ctx).cloned();
4937            let csrf = ctx_csrf(&ctx).map(str::to_string);
4938            let error_msg = format!("Could not update: {e}");
4939            let source = resolved.legacy_source().cloned();
4940            let html = crate::admin::layout::form_render(
4941                db,
4942                registry,
4943                legacy_entries,
4944                resolved.as_ui_model(),
4945                source.as_ref(),
4946                Some(&id),
4947                identity.as_ref(),
4948                csrf.as_deref(),
4949                Some(&error_msg),
4950            )
4951            .await;
4952            Ok(with_admin_headers(crate::http::html(html)))
4953        }
4954    }
4955}
4956
4957async fn admin_model_delete_post(
4958    db: &Db,
4959    registry: &crate::admin::admin_form_bridge::AdminRegistry,
4960    legacy_entries: &[AdminEntry],
4961    req: Request,
4962    params: crate::router::Params,
4963) -> Result<Response, Error> {
4964    if let Err(resp) = admin_guard(req.ctx()) {
4965        return Ok(resp);
4966    }
4967    let model_slug = params.get("model").unwrap_or("").to_string();
4968    let id = params.get("id").unwrap_or("").to_string();
4969    if id.is_empty() {
4970        return Err(Error::BadRequest("missing id".into()));
4971    }
4972    let resolved = resolve_form_model(registry, legacy_entries, &model_slug)?;
4973
4974    let (_, body, ctx) = req.into_parts();
4975    let form = read_form_from_parts(body).await?;
4976    require_csrf(&ctx, &form)?;
4977
4978    crate::admin::persistence::bulk_delete(
4979        db,
4980        resolved.as_ui_model().table_name(),
4981        std::slice::from_ref(&id),
4982    )
4983    .await?;
4984    Ok(with_admin_headers(redirect(&format!(
4985        "/admin/{model_slug}"
4986    ))))
4987}
4988
4989// ---------------------------------------------------------------------------
4990// Admin guard + login/forbidden auth pages
4991// ---------------------------------------------------------------------------
4992
4993#[allow(clippy::result_large_err)]
4994fn admin_guard(ctx: &crate::context::Context) -> Result<(), Response> {
4995    match crate::auth::require_admin(ctx) {
4996        Ok(_) => Ok(()),
4997        Err(Error::Unauthorized) => Err(login_page(401, None, None)),
4998        Err(Error::Forbidden) => Err(forbidden_page(ctx_csrf(ctx))),
4999        Err(other) => Err(other.into_response()),
5000    }
5001}
5002
5003/// Render the login page. Status 401 on a pure auth-gate hit; 400 on
5004/// missing fields; 403 on inactive account; 429 on rate-limit trip.
5005///
5006/// Rendered via `minijinja` against `auth/login.html` (bundled in
5007/// `rustio-core/assets/templates/`; user projects can override by
5008/// placing a same-named file under their own `templates/auth/`). If
5009/// the template fails to render for any reason — parse error, missing
5010/// include, IO error on an override — the renderer falls back to a
5011/// minimal inline HTML shell that still serves the same form. The
5012/// server never crashes on a bad template.
5013fn login_page(status: u16, email: Option<&str>, error: Option<&str>) -> Response {
5014    let design = design::Design::global();
5015    let env = crate::admin::templating::env();
5016    let body = match env.get_template("auth/login.html").and_then(|tmpl| {
5017        tmpl.render(minijinja::context! {
5018            design => minijinja::context! {
5019                project_name => design.project_name.as_str(),
5020                logo_initial => design.logo_initial.as_str(),
5021            },
5022            email => email.unwrap_or(""),
5023            error => error,
5024        })
5025    }) {
5026        Ok(html) => html,
5027        Err(err) => {
5028            eprintln!("admin login template render failed: {err}");
5029            login_page_fallback(&design.project_name, email, error)
5030        }
5031    };
5032
5033    let resp = hyper::Response::builder()
5034        .status(status)
5035        .header("content-type", "text/html; charset=utf-8")
5036        .body(Full::new(Bytes::from(body)))
5037        .expect("valid response");
5038    with_admin_headers(resp)
5039}
5040
5041/// Emergency login page used when the template engine can't render
5042/// `auth/login.html`. Deliberately self-contained — no CSS links, no
5043/// includes, no `env()` call — so a broken template environment still
5044/// lets an operator sign in and fix it.
5045fn login_page_fallback(project_name: &str, email: Option<&str>, error: Option<&str>) -> String {
5046    let project = escape_html(project_name);
5047    let email = email.map(escape_html).unwrap_or_default();
5048    let error_block = match error {
5049        Some(msg) => format!(r#"<p style="color:#b91c1c">{}</p>"#, escape_html(msg)),
5050        None => String::new(),
5051    };
5052    format!(
5053        r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sign in · {project}</title></head><body style="font-family:system-ui;max-width:20rem;margin:4rem auto;padding:0 1rem">
5054<h1>Sign in</h1><p>{project}</p>{error_block}
5055<form method="post" action="/admin/login">
5056<p><label>Email<br><input type="email" name="email" value="{email}" autofocus required></label></p>
5057<p><label>Password<br><input type="password" name="password" required></label></p>
5058<p><button type="submit">Sign in</button></p>
5059</form></body></html>"#,
5060    )
5061}
5062
5063/// Render the 403 page. Rendered via `minijinja` against
5064/// `auth/forbidden.html`; falls back to a minimal inline shell with
5065/// the same sign-out form if the template render fails.
5066fn forbidden_page(csrf: Option<&str>) -> Response {
5067    let design = design::Design::global();
5068    let env = crate::admin::templating::env();
5069    let body = match env.get_template("auth/forbidden.html").and_then(|tmpl| {
5070        tmpl.render(minijinja::context! {
5071            design => minijinja::context! {
5072                project_name => design.project_name.as_str(),
5073                logo_initial => design.logo_initial.as_str(),
5074            },
5075            csrf_token => csrf.unwrap_or(""),
5076        })
5077    }) {
5078        Ok(html) => html,
5079        Err(err) => {
5080            eprintln!("admin forbidden template render failed: {err}");
5081            forbidden_page_fallback(&design.project_name, csrf)
5082        }
5083    };
5084    let resp = hyper::Response::builder()
5085        .status(403)
5086        .header("content-type", "text/html; charset=utf-8")
5087        .body(Full::new(Bytes::from(body)))
5088        .expect("valid response");
5089    with_admin_headers(resp)
5090}
5091
5092/// Emergency 403 shell used when `auth/forbidden.html` can't render.
5093/// Self-contained — no CSS link, no includes, so a broken template
5094/// still lets the operator sign out.
5095fn forbidden_page_fallback(project_name: &str, csrf: Option<&str>) -> String {
5096    let project = escape_html(project_name);
5097    let csrf_input_html = match csrf {
5098        Some(token) if !token.is_empty() => format!(
5099            r#"<input type="hidden" name="_csrf" value="{}">"#,
5100            escape_html(token)
5101        ),
5102        _ => String::new(),
5103    };
5104    format!(
5105        r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><title>403 Forbidden · {project}</title></head><body style="font-family:system-ui;max-width:28rem;margin:4rem auto;padding:0 1rem;text-align:center">
5106<p>403 Forbidden</p><h1>You're signed in, but you don't have admin access.</h1>
5107<form method="post" action="/admin/logout">{csrf_input_html}<button type="submit">Sign out</button></form>
5108</body></html>"#,
5109    )
5110}
5111
5112// ---------------------------------------------------------------------------
5113// Password change (self-service) — Django parity
5114// ---------------------------------------------------------------------------
5115
5116// Password-change pages: ported to `admin::layout::password_change_render`
5117// and `admin::layout::password_change_done_render` in stage 4h-ii.
5118
5119// ---------------------------------------------------------------------------
5120// Profile: ported to `admin::layout::profile_render` in stage 4h.
5121// The legacy string-concat renderer that lived here is gone.
5122// ---------------------------------------------------------------------------
5123
5124// ---------------------------------------------------------------------------
5125// Logout confirmation (GET /admin/logout) — Django parity
5126// ---------------------------------------------------------------------------
5127
5128/// Render the logout page. Two branches:
5129///
5130/// - Signed-in (session cookie valid) → show "Sign out" confirmation
5131///   with a POST form. The user confirms; POST /admin/logout then
5132///   deletes the session and redirects back here where the other
5133///   branch renders.
5134/// - Signed-out → show the Django-style "Thanks for your time" page
5135///   with a link back to the login screen.
5136fn logout_confirmation_response(signed_in: bool, csrf: Option<&str>) -> Response {
5137    let d = design::Design::global();
5138
5139    let theme_style = format!(
5140        "\n:root {{\n  --rio-primary: {p};\n  --rio-accent: {a};\n}}\n",
5141        p = escape_css_color(&d.primary_color),
5142        a = escape_css_color(&d.accent_color),
5143    );
5144
5145    let card_body = if signed_in {
5146        let csrf_hidden = csrf_input(csrf);
5147        format!(
5148            r#"<h1 class="rio-auth-title">Sign out</h1>
5149<p class="rio-auth-subtitle">You're about to sign out of the admin.</p>
5150<form method="post" action="/admin/logout">
5151{csrf}
5152<button class="rio-btn rio-btn-primary rio-btn-block" type="submit">Sign out</button>
5153</form>
5154<p class="rio-auth-footer"><a href="/admin">Cancel and return to the admin</a></p>"#,
5155            csrf = csrf_hidden,
5156        )
5157    } else {
5158        String::from(
5159            r#"<h1 class="rio-auth-title">You have signed out</h1>
5160<p class="rio-auth-subtitle">Thanks for your time. Sessions are already revoked server-side.</p>
5161<a class="rio-btn rio-btn-primary rio-btn-block" href="/admin">Sign in again</a>"#,
5162        )
5163    };
5164
5165    let body = format!(
5166        r#"<!doctype html>
5167<html lang="en">
5168<head>
5169<meta charset="utf-8">
5170<meta name="viewport" content="width=device-width, initial-scale=1">
5171<title>Sign out · {project}</title>
5172<link rel="stylesheet" href="/admin/assets/admin.css?v={css_ver}">
5173<link rel="icon" type="image/svg+xml" href="/admin/assets/favicon.svg">
5174<style>{theme}</style>
5175</head>
5176<body>
5177<div class="rio-auth-shell">
5178<div class="rio-auth-card">
5179<div class="rio-auth-logo">
5180<span class="rio-brand-mark">{logo}</span>
5181<span class="rio-brand-meta">
5182<span class="rio-brand-name">{project}</span>
5183<span class="rio-brand-label">Admin</span>
5184</span>
5185</div>
5186{card_body}
5187</div>
5188</div>
5189</body>
5190</html>"#,
5191        project = escape_html(&d.project_name),
5192        theme = theme_style,
5193        logo = escape_html(&d.logo_initial),
5194        card_body = card_body,
5195        css_ver = ADMIN_CSS_VER,
5196    );
5197
5198    let resp = hyper::Response::builder()
5199        .status(200)
5200        .header("content-type", "text/html; charset=utf-8")
5201        .body(Full::new(Bytes::from(body)))
5202        .expect("valid response");
5203    with_admin_headers(resp)
5204}
5205
5206// ---------------------------------------------------------------------------
5207// Per-object history (GET /admin/<model>/<id>/history) — Django parity
5208// ---------------------------------------------------------------------------
5209
5210/// Stub history page. In 0.4 we don't persist an admin `LogEntry`
5211/// table, so the page confirms the record exists and explains the
5212/// feature isn't live yet. The route exists so the edit page's
5213/// "History" link has somewhere to land, and so projects that layer
5214/// their own audit table can drop in the rendering later.
5215fn object_history_response<T: AdminModel>(
5216    shell: Shell<'_>,
5217    id: i64,
5218    item: &T,
5219    actions: &[audit::AdminAction],
5220) -> Response {
5221    let plural = T::DISPLAY_NAME;
5222    let singular = T::singular_name();
5223    let admin_name = T::ADMIN_NAME;
5224
5225    let summary = T::FIELDS
5226        .iter()
5227        .find(|f| f.editable && matches!(f.ty, FieldType::String))
5228        .and_then(|f| item.field_display(f.name))
5229        .filter(|s| !s.is_empty())
5230        .unwrap_or_else(|| format!("#{id}"));
5231
5232    let inner = if actions.is_empty() {
5233        format!(
5234            r#"<div class="rio-empty">
5235<div class="rio-empty-icon">{icon}</div>
5236<h3>No change history yet</h3>
5237<p>Every add, change, or delete made through the admin will appear here. This record has no entries yet — the most likely reason is that it predates the audit log, or no one has edited it through the admin.</p>
5238</div>"#,
5239            icon = icon_inbox(),
5240        )
5241    } else {
5242        render_actions_timeline(actions, /* show_object_link = */ false)
5243    };
5244
5245    let body = format!(
5246        r#"<div class="rio-card">
5247<div class="rio-card-header">
5248<div>
5249<h2 class="rio-card-title">Change history — {singular_hdr} {summary}</h2>
5250<p class="rio-card-subtitle">Every add / change / delete that happened to this record, newest first.</p>
5251</div>
5252<a class="rio-btn" href="/admin/{name}/{id}/edit">Back to record</a>
5253</div>
5254{inner}
5255</div>"#,
5256        singular_hdr = escape_html(singular),
5257        summary = escape_html(&summary),
5258        name = escape_html(admin_name),
5259        id = id,
5260        inner = inner,
5261    );
5262
5263    let plural_href = format!("/admin/{admin_name}");
5264    let edit_href = format!("/admin/{admin_name}/{id}/edit");
5265    let crumbs: &[Crumb<'_>] = &[
5266        ("Admin", Some("/admin")),
5267        (plural, Some(&plural_href)),
5268        (singular, Some(&edit_href)),
5269        ("History", None),
5270    ];
5271
5272    render_shell_page(
5273        &shell,
5274        200,
5275        &format!("History — {singular} {summary}"),
5276        "Change history",
5277        Some("Every add / change / delete that happened to this record."),
5278        crumbs,
5279        "",
5280        &body,
5281    )
5282}
5283
5284// ---------------------------------------------------------------------------
5285// Suggestion review + apply (0.7.1 Actionable Intelligence Layer)
5286// ---------------------------------------------------------------------------
5287
5288/// Render the review page for `/admin/suggestions/<admin>/<field>`.
5289///
5290/// Flow: re-derive the suggestion (refuse crafted URLs), run the
5291/// planner, run the review layer, render the plan + risk + warnings
5292/// with an Approve button. The button is **disabled** when risk is
5293/// Critical or when validation failed — the safety spec demands it
5294/// and the POST handler refuses anyway.
5295///
5296/// `error` is populated on POST-flow re-renders (executor refusal,
5297/// policy violation, etc.) so the operator sees the reason beside
5298/// the plan rather than on a separate page.
5299/// 0.10+ template-based suggestion review renderer.
5300///
5301/// Runs the exact same planner / reviewer chain as the legacy
5302/// version; differences are confined to the final HTML assembly,
5303/// which now flows through `admin::layout::suggestion_review_render`
5304/// + `admin/suggestion_review.html`.
5305#[allow(clippy::too_many_arguments)]
5306async fn suggestion_review_response(
5307    db: &Db,
5308    registry: &crate::admin::admin_form_bridge::AdminRegistry,
5309    legacy_entries: &[AdminEntry],
5310    identity: Option<&crate::auth::Identity>,
5311    csrf: Option<&str>,
5312    admin_name: &str,
5313    field: &str,
5314    error: Option<&str>,
5315) -> Response {
5316    let ctx = intelligence::context_global();
5317    let effective = entry_builder::entries_effective(legacy_entries);
5318    let Some(suggestion) =
5319        suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5320    else {
5321        return admin_not_found_response(legacy_entries, None, csrf);
5322    };
5323
5324    let plan_result = match run_planner(&suggestion.prompt, ctx) {
5325        Ok(pr) => pr,
5326        Err(msg) => {
5327            return suggestion_error_response(
5328                db,
5329                registry,
5330                legacy_entries,
5331                identity,
5332                csrf,
5333                &suggestion,
5334                &msg,
5335            )
5336            .await;
5337        }
5338    };
5339    let review = match crate::ai::review_plan(
5340        plan_result.schema_ref(),
5341        &plan_result.plan_result.plan,
5342        ctx,
5343    ) {
5344        Ok(r) => r,
5345        Err(e) => {
5346            return suggestion_error_response(
5347                db,
5348                registry,
5349                legacy_entries,
5350                identity,
5351                csrf,
5352                &suggestion,
5353                &format!("review layer refused: {e}"),
5354            )
5355            .await;
5356        }
5357    };
5358
5359    let can_apply = matches!(review.validation, crate::ai::ValidationOutcome::Valid)
5360        && review.risk != crate::ai::RiskLevel::Critical;
5361
5362    let step_descriptions: Vec<String> = plan_result
5363        .plan_result
5364        .plan
5365        .steps
5366        .iter()
5367        .map(|p| match p {
5368            crate::ai::Primitive::AddField(a) => format!(
5369                "+ Add field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5370                escape_html(&a.field.name),
5371                escape_html(&a.field.ty),
5372                if a.field.nullable { ", nullable" } else { "" },
5373                escape_html(&a.model),
5374            ),
5375            other => escape_html(&format!("{other:?}")),
5376        })
5377        .collect();
5378
5379    let schema_diff_html =
5380        render_schema_diff(plan_result.schema_ref(), &plan_result.plan_result.plan);
5381
5382    let (risk_label, risk_class) = match review.risk {
5383        crate::ai::RiskLevel::Low => ("Low", "success"),
5384        crate::ai::RiskLevel::Medium => ("Medium", "warning"),
5385        crate::ai::RiskLevel::High => ("High", "danger"),
5386        crate::ai::RiskLevel::Critical => ("Critical", "danger"),
5387    };
5388
5389    let (validation_ok, validation_message) = match &review.validation {
5390        crate::ai::ValidationOutcome::Valid => (true, None),
5391        crate::ai::ValidationOutcome::Invalid { step, reason } => (
5392            false,
5393            Some(format!(
5394                "Plan fails at step {step}: {reason}. Regenerate the schema or adjust the plan before applying."
5395            )),
5396        ),
5397    };
5398
5399    let confidence_class = match suggestion.confidence.as_str() {
5400        "High" => "success",
5401        "Medium" => "warning",
5402        _ => "secondary",
5403    };
5404
5405    let view = crate::admin::layout::SuggestionReviewView {
5406        model: suggestion.model_display.clone(),
5407        field: suggestion.field.clone(),
5408        industry: ctx
5409            .and_then(|c| c.industry.as_deref())
5410            .unwrap_or("")
5411            .to_string(),
5412        confidence_label: suggestion.confidence.as_str().to_string(),
5413        confidence_class: confidence_class.to_string(),
5414        apply_url: suggestion.url_path(),
5415        can_apply,
5416        step_descriptions,
5417        schema_diff_html,
5418        explanation: plan_result.plan_result.explanation.clone(),
5419        risk_label: risk_label.to_string(),
5420        risk_class: risk_class.to_string(),
5421        adds_fields: review.impact.adds_fields as u32,
5422        destructive: review.impact.destructive,
5423        validation_ok,
5424        validation_message,
5425        warnings: review.warnings.clone(),
5426        error: error.map(str::to_string),
5427    };
5428
5429    let html = crate::admin::layout::suggestion_review_render(
5430        db,
5431        registry,
5432        legacy_entries,
5433        identity,
5434        csrf,
5435        view,
5436    )
5437    .await;
5438    with_admin_headers(crate::http::html(html))
5439}
5440
5441/// POST handler for `/admin/suggestions/<admin>/<field>`.
5442///
5443/// Re-runs the planner, runs the review layer, runs the executor.
5444/// Any refusal at any step re-renders the review page (template
5445/// form) with an inline error banner — the executor never writes
5446/// unless every gate returned `Ok`.
5447#[allow(clippy::too_many_arguments)]
5448async fn suggestion_apply_response(
5449    db: &Db,
5450    registry: &crate::admin::admin_form_bridge::AdminRegistry,
5451    legacy_entries: &[AdminEntry],
5452    identity: Option<&crate::auth::Identity>,
5453    csrf: Option<&str>,
5454    admin_name: &str,
5455    field: &str,
5456) -> Response {
5457    let ctx = intelligence::context_global();
5458    let effective = entry_builder::entries_effective(legacy_entries);
5459    let Some(suggestion) =
5460        suggestions::find_suggestion_from_entries(&effective, ctx, admin_name, field)
5461    else {
5462        return admin_not_found_response(legacy_entries, None, csrf);
5463    };
5464    let plan_result = match run_planner(&suggestion.prompt, ctx) {
5465        Ok(pr) => pr,
5466        Err(msg) => {
5467            return suggestion_review_response(
5468                db,
5469                registry,
5470                legacy_entries,
5471                identity,
5472                csrf,
5473                admin_name,
5474                field,
5475                Some(&msg),
5476            )
5477            .await;
5478        }
5479    };
5480    let doc = match crate::ai::build_plan_document(
5481        plan_result.schema_ref(),
5482        &suggestion.prompt,
5483        &plan_result.plan_result,
5484        ctx,
5485    ) {
5486        Ok(d) => d,
5487        Err(e) => {
5488            return suggestion_review_response(
5489                db,
5490                registry,
5491                legacy_entries,
5492                identity,
5493                csrf,
5494                admin_name,
5495                field,
5496                Some(&format!("plan document rejected: {e}")),
5497            )
5498            .await;
5499        }
5500    };
5501    if doc.risk == crate::ai::RiskLevel::Critical {
5502        return suggestion_review_response(
5503            db,
5504            registry,
5505            legacy_entries,
5506            identity,
5507            csrf,
5508            admin_name,
5509            field,
5510            Some("Plan risk is Critical — the safe executor refuses to apply it."),
5511        )
5512        .await;
5513    }
5514    let options = crate::ai::ExecuteOptions::default();
5515    let result =
5516        match crate::ai::execute_plan_document(std::path::Path::new("."), &doc, &options, ctx) {
5517            Ok(r) => r,
5518            Err(e) => {
5519                return suggestion_review_response(
5520                    db,
5521                    registry,
5522                    legacy_entries,
5523                    identity,
5524                    csrf,
5525                    admin_name,
5526                    field,
5527                    Some(&format!("executor refused: {e}")),
5528                )
5529                .await;
5530            }
5531        };
5532
5533    schema_cache::refresh_best_effort();
5534
5535    let change_lines: Vec<String> = doc.plan.steps.iter().map(describe_applied_step).collect();
5536
5537    let files: Vec<crate::admin::layout::AppliedFileView> = result
5538        .generated_files
5539        .iter()
5540        .map(|f| {
5541            let kind = if f.ends_with(".sql") {
5542                "Created migration"
5543            } else if f.ends_with(".rs") {
5544                "Updated"
5545            } else {
5546                "Wrote"
5547            };
5548            crate::admin::layout::AppliedFileView {
5549                kind: kind.to_string(),
5550                path: f.clone(),
5551            }
5552        })
5553        .collect();
5554
5555    let applied = crate::admin::layout::SuggestionAppliedView {
5556        change_lines,
5557        files,
5558    };
5559    let html = crate::admin::layout::suggestion_applied_render(
5560        db,
5561        registry,
5562        legacy_entries,
5563        identity,
5564        csrf,
5565        applied,
5566    )
5567    .await;
5568    with_admin_headers(crate::http::html(html))
5569}
5570
5571/// Short-circuit renderer for the rare case where we can't even
5572/// build a plan (planner or reviewer itself failed). Renders the
5573/// review page with just the suggestion metadata + the error
5574/// banner — no plan steps, no schema diff, no risk. Deliberately
5575/// *doesn't* re-enter `suggestion_review_response` (that would
5576/// create a recursive async call and also risk looping through the
5577/// planner on every retry).
5578#[allow(clippy::too_many_arguments)]
5579async fn suggestion_error_response(
5580    db: &Db,
5581    registry: &crate::admin::admin_form_bridge::AdminRegistry,
5582    legacy_entries: &[AdminEntry],
5583    identity: Option<&crate::auth::Identity>,
5584    csrf: Option<&str>,
5585    suggestion: &suggestions::Suggestion,
5586    msg: &str,
5587) -> Response {
5588    let ctx = intelligence::context_global();
5589    let confidence_class = match suggestion.confidence.as_str() {
5590        "High" => "success",
5591        "Medium" => "warning",
5592        _ => "secondary",
5593    };
5594    let view = crate::admin::layout::SuggestionReviewView {
5595        model: suggestion.model_display.clone(),
5596        field: suggestion.field.clone(),
5597        industry: ctx
5598            .and_then(|c| c.industry.as_deref())
5599            .unwrap_or("")
5600            .to_string(),
5601        confidence_label: suggestion.confidence.as_str().to_string(),
5602        confidence_class: confidence_class.to_string(),
5603        apply_url: suggestion.url_path(),
5604        can_apply: false,
5605        step_descriptions: Vec::new(),
5606        schema_diff_html: String::new(),
5607        explanation: String::new(),
5608        risk_label: "?".into(),
5609        risk_class: "secondary".into(),
5610        adds_fields: 0,
5611        destructive: false,
5612        validation_ok: false,
5613        validation_message: None,
5614        warnings: Vec::new(),
5615        error: Some(msg.to_string()),
5616    };
5617    let html = crate::admin::layout::suggestion_review_render(
5618        db,
5619        registry,
5620        legacy_entries,
5621        identity,
5622        csrf,
5623        view,
5624    )
5625    .await;
5626    with_admin_headers(crate::http::html(html))
5627}
5628
5629/// Coloured risk pill reusing the existing pill classes.
5630/// Render a compact before/after diff of the target model's fields.
5631/// The "before" column is the current schema shape; the "after"
5632/// column is the schema with the plan's primitives applied in
5633/// sequence. Returns an empty string when no model in the plan can
5634/// be resolved (keeps the review page clean when the diff would be
5635/// meaningless).
5636fn render_schema_diff(schema: &crate::schema::Schema, plan: &crate::ai::Plan) -> String {
5637    use std::collections::BTreeSet;
5638
5639    // Collect the set of model names the plan touches.
5640    let mut touched: Vec<String> = Vec::new();
5641    for step in &plan.steps {
5642        if let crate::ai::Primitive::AddField(a) = step {
5643            if !touched.contains(&a.model) {
5644                touched.push(a.model.clone());
5645            }
5646        }
5647    }
5648    if touched.is_empty() {
5649        return String::new();
5650    }
5651
5652    let mut out = String::new();
5653    for model_name in &touched {
5654        let Some(model) = schema.models.iter().find(|m| &m.name == model_name) else {
5655            continue;
5656        };
5657        // Build "after" set: start from current, apply plan steps.
5658        let before: Vec<(String, String)> = model
5659            .fields
5660            .iter()
5661            .map(|f| {
5662                let ty = if f.nullable {
5663                    format!("Option<{}>", f.ty)
5664                } else {
5665                    f.ty.clone()
5666                };
5667                (f.name.clone(), ty)
5668            })
5669            .collect();
5670        let before_names: BTreeSet<&str> = before.iter().map(|(n, _)| n.as_str()).collect();
5671        let mut added: Vec<(String, String)> = Vec::new();
5672        for step in &plan.steps {
5673            if let crate::ai::Primitive::AddField(a) = step {
5674                if a.model == *model_name && !before_names.contains(a.field.name.as_str()) {
5675                    let ty = if a.field.nullable {
5676                        format!("Option<{}>", a.field.ty)
5677                    } else {
5678                        a.field.ty.clone()
5679                    };
5680                    added.push((a.field.name.clone(), ty));
5681                }
5682            }
5683        }
5684        out.push_str(&format!(
5685            r#"<div class="rio-schema-diff"><h3>Model <code>{}</code></h3><pre>"#,
5686            escape_html(model_name),
5687        ));
5688        for (name, ty) in &before {
5689            out.push_str(&format!("  {}: {}\n", escape_html(name), escape_html(ty),));
5690        }
5691        for (name, ty) in &added {
5692            out.push_str(&format!(
5693                "<span class=\"rio-schema-diff-add\">+ {}: {}</span>\n",
5694                escape_html(name),
5695                escape_html(ty),
5696            ));
5697        }
5698        out.push_str("</pre></div>");
5699    }
5700    out
5701}
5702
5703/// Human description of one primitive after it was applied. Used by
5704/// the "Changes applied" summary on the success page. Mirrors the
5705/// executor's log shape but tuned for operator-speak.
5706fn describe_applied_step(p: &crate::ai::Primitive) -> String {
5707    match p {
5708        crate::ai::Primitive::AddField(a) => format!(
5709            "Added field <code>{}</code> (<code>{}</code>{}) to <code>{}</code>",
5710            escape_html(&a.field.name),
5711            escape_html(&a.field.ty),
5712            if a.field.nullable { ", nullable" } else { "" },
5713            escape_html(&a.model),
5714        ),
5715        crate::ai::Primitive::RenameField(r) => format!(
5716            "Renamed <code>{}.{}</code> to <code>{}</code>",
5717            escape_html(&r.model),
5718            escape_html(&r.from),
5719            escape_html(&r.to),
5720        ),
5721        crate::ai::Primitive::RenameModel(r) => format!(
5722            "Renamed model <code>{}</code> to <code>{}</code>",
5723            escape_html(&r.from),
5724            escape_html(&r.to),
5725        ),
5726        other => escape_html(&format!("{:?}", other)),
5727    }
5728}
5729
5730/// Wrap the planner call with the surrounding project I/O so the
5731/// suggestion review + apply handlers read the schema / context the
5732/// same way.
5733struct PlannerCallResult {
5734    plan_result: crate::ai::PlanResult,
5735    schema: crate::schema::Schema,
5736}
5737impl PlannerCallResult {
5738    fn schema_ref(&self) -> &crate::schema::Schema {
5739        &self.schema
5740    }
5741}
5742
5743fn run_planner(
5744    prompt: &str,
5745    context: Option<&crate::ai::ContextConfig>,
5746) -> Result<PlannerCallResult, String> {
5747    // The admin handler runs in the project's CWD; the schema file
5748    // is always at `rustio.schema.json` there. A missing schema is
5749    // a legitimate project-setup error — tell the operator and
5750    // stop.
5751    let schema_path = std::path::Path::new("rustio.schema.json");
5752    let schema_json = std::fs::read_to_string(schema_path)
5753        .map_err(|e| format!("could not read rustio.schema.json: {e}"))?;
5754    let schema = crate::schema::Schema::parse(&schema_json)
5755        .map_err(|e| format!("rustio.schema.json parse error: {e}"))?;
5756    let plan_result = crate::ai::generate_plan(
5757        &schema,
5758        context,
5759        crate::ai::PlanRequest::new(prompt.to_string()),
5760    )
5761    .map_err(|e| format!("planner refused: {e}"))?;
5762    Ok(PlannerCallResult {
5763        plan_result,
5764        schema,
5765    })
5766}
5767
5768// `/admin/actions` audit timeline: ported to
5769// `admin::layout::actions_render` in stage 4h-iii. The per-object
5770// `render_actions_timeline` helper below is still used by the
5771// per-object history page.
5772
5773/// Render a list of audit actions as a compact timeline. Used by both
5774/// the per-object history page and the project-wide `/admin/actions`
5775/// page. When `show_object_link` is true, each entry links to
5776/// `/admin/<model>/<id>/history` so the operator can drill into one
5777/// record; when false (already on that page) the link is omitted.
5778fn render_actions_timeline(actions: &[audit::AdminAction], show_object_link: bool) -> String {
5779    if actions.is_empty() {
5780        return String::new();
5781    }
5782    let rows: String = actions
5783        .iter()
5784        .map(|a| {
5785            let action = audit::ActionType::parse(&a.action_type);
5786            let (pill_class, label) = match action {
5787                Some(at) => (at.pill_class(), at.label()),
5788                None => ("rio-pill rio-pill-slate", "Action"),
5789            };
5790            let when = a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
5791            let who = a
5792                .user_email
5793                .clone()
5794                .unwrap_or_else(|| format!("user #{}", a.user_id));
5795            let ip = match &a.ip_address {
5796                Some(ip) if !ip.is_empty() => {
5797                    format!(r#"<span class="rio-audit-ip">{}</span>"#, escape_html(ip))
5798                }
5799                _ => String::new(),
5800            };
5801            let object_link = if show_object_link {
5802                format!(
5803                    r#"<a class="rio-audit-object" href="/admin/{name}/{id}/history">{name} #{id}</a>"#,
5804                    name = escape_html(&a.model_name),
5805                    id = a.object_id,
5806                )
5807            } else {
5808                String::new()
5809            };
5810            format!(
5811                r#"<li class="rio-audit-item">
5812<div class="rio-audit-head">
5813<span class="{pill}">{label}</span>
5814{object_link}
5815<span class="rio-audit-when">{when}</span>
5816</div>
5817<p class="rio-audit-summary">{summary}</p>
5818<div class="rio-audit-meta">
5819<span class="rio-audit-who">{who}</span>
5820{ip}
5821</div>
5822</li>"#,
5823                pill = pill_class,
5824                label = label,
5825                object_link = object_link,
5826                when = escape_html(&when),
5827                summary = escape_html(&a.summary),
5828                who = escape_html(&who),
5829                ip = ip,
5830            )
5831        })
5832        .collect();
5833    format!(r#"<ul class="rio-audit-timeline">{rows}</ul>"#)
5834}
5835
5836// ---------------------------------------------------------------------------
5837// Session cookie builder
5838// ---------------------------------------------------------------------------
5839
5840fn build_session_cookie(name: &str, token: &str, max_age: i64) -> String {
5841    build_session_cookie_impl(name, token, max_age, crate::auth::in_production())
5842}
5843
5844fn build_session_cookie_impl(name: &str, token: &str, max_age: i64, secure: bool) -> String {
5845    let secure = if secure { "; Secure" } else { "" };
5846    format!("{name}={token}; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age={max_age}")
5847}
5848
5849// ---------------------------------------------------------------------------
5850// Login / logout handlers
5851// ---------------------------------------------------------------------------
5852
5853async fn handle_login(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5854    use crate::auth;
5855
5856    let peer_ip = req.peer_addr().map(|a| a.ip().to_string());
5857
5858    let form = read_form(req).await?;
5859    let email = form.get("email").unwrap_or("").trim().to_string();
5860    let password = form.get("password").unwrap_or("").to_string();
5861
5862    if email.is_empty() || password.is_empty() {
5863        return Ok(login_page(
5864            400,
5865            Some(&email),
5866            Some("Email and password are both required."),
5867        ));
5868    }
5869
5870    let email_key = auth::normalise_email(&email);
5871    let rate_key = auth::LoginRateLimiter::compose_key(&email_key, peer_ip.as_deref());
5872    if let Err(remaining) = auth::LoginRateLimiter::global().check(&rate_key) {
5873        return Ok(login_page(
5874            429,
5875            Some(&email),
5876            Some(&format!(
5877                "Too many failed attempts. Try again in {}s.",
5878                remaining.as_secs().max(1),
5879            )),
5880        ));
5881    }
5882
5883    let generic = "Invalid email or password.";
5884
5885    let user = auth::user::find_by_email(db, &email).await?;
5886    let valid = match &user {
5887        Some(u) => auth::password::verify(&password, &u.password_hash),
5888        None => {
5889            let _ = auth::password::verify(&password, auth::dummy_password_hash());
5890            false
5891        }
5892    };
5893
5894    if !valid {
5895        auth::LoginRateLimiter::global().record_failure(&rate_key);
5896        return Ok(login_page(401, Some(&email), Some(generic)));
5897    }
5898
5899    let user = user.expect("valid credentials imply a found user");
5900    if !user.is_active {
5901        return Ok(login_page(
5902            403,
5903            Some(&email),
5904            Some("This account is inactive. Contact an administrator."),
5905        ));
5906    }
5907
5908    auth::LoginRateLimiter::global().record_success(&rate_key);
5909    let _ = auth::session::sweep_expired(db).await;
5910
5911    let session = auth::session::create(db, user.id).await?;
5912
5913    let mut resp = redirect("/admin");
5914    let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
5915    crate::http::set_cookie(
5916        &mut resp,
5917        &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
5918    );
5919    Ok(with_admin_headers(resp))
5920}
5921
5922async fn handle_logout(req: Request, db: &crate::orm::Db) -> Result<Response, Error> {
5923    use crate::auth;
5924
5925    let cookie_token = req.cookie(auth::SESSION_COOKIE);
5926    let (_, body, ctx) = req.into_parts();
5927    let form = read_form_from_parts(body).await?;
5928    require_csrf(&ctx, &form)?;
5929
5930    if let Some(token) = cookie_token {
5931        let _ = auth::session::delete(db, &token).await;
5932    }
5933
5934    // Redirect to GET /admin/logout which renders the Django-style
5935    // "You have been signed out" confirmation page (no session now,
5936    // so the page shows the signed-out branch, not the confirm-sign-out
5937    // form).
5938    let mut resp = redirect("/admin/logout");
5939    crate::http::set_cookie(
5940        &mut resp,
5941        &build_session_cookie(auth::SESSION_COOKIE, "", 0),
5942    );
5943    Ok(with_admin_headers(resp))
5944}
5945
5946/// `POST /admin/password_change` — self-service password change.
5947///
5948/// Flow (Django-parity):
5949/// 1. Read the form + verify CSRF (must match current session).
5950/// 2. Pull `Identity` out of ctx to find the user id — the admin
5951///    guard in the GET already confirmed the user is admin, the POST
5952///    reaches this point the same way.
5953/// 3. Fetch the user fresh, verify the submitted old password with
5954///    argon2 (constant-time, never panics on bad hash).
5955/// 4. Enforce matching new-password fields and an 8-char minimum.
5956/// 5. Call `auth::user::set_password` — this wipes every session for
5957///    this user, including the caller's.
5958/// 6. Create a fresh session for the same user and set a new cookie
5959///    so the caller stays logged in; redirect to /done.
5960async fn handle_password_change_post(
5961    req: Request,
5962    db: &crate::orm::Db,
5963    registry: &crate::admin::admin_form_bridge::AdminRegistry,
5964    legacy_entries: &[AdminEntry],
5965) -> Result<Response, Error> {
5966    use crate::auth;
5967
5968    let (_, body, ctx) = req.into_parts();
5969    let form = read_form_from_parts(body).await?;
5970    require_csrf(&ctx, &form)?;
5971
5972    // Must be authenticated — reuse the same guard logic so the
5973    // error rendering stays consistent with other admin routes.
5974    let user_id = match ctx.get::<auth::Identity>() {
5975        Some(i) => i.user_id,
5976        None => return Ok(login_page(401, None, None)),
5977    };
5978    let identity = crate::auth::identity(&ctx).cloned();
5979    let csrf = ctx_csrf(&ctx).map(str::to_string);
5980
5981    let old = form.get("old_password").unwrap_or("").to_string();
5982    let new1 = form.get("new_password1").unwrap_or("").to_string();
5983    let new2 = form.get("new_password2").unwrap_or("").to_string();
5984
5985    // Validation. All re-renders return the form with an inline alert.
5986    async fn render_err(
5987        db: &crate::orm::Db,
5988        registry: &crate::admin::admin_form_bridge::AdminRegistry,
5989        legacy_entries: &[AdminEntry],
5990        identity: Option<&crate::auth::Identity>,
5991        csrf: Option<&str>,
5992        msg: &str,
5993    ) -> Response {
5994        let html = crate::admin::layout::password_change_render(
5995            db,
5996            registry,
5997            legacy_entries,
5998            identity,
5999            csrf,
6000            Some(msg),
6001        )
6002        .await;
6003        with_admin_headers(crate::http::html(html))
6004    }
6005
6006    if old.is_empty() || new1.is_empty() || new2.is_empty() {
6007        return Ok(render_err(
6008            db,
6009            registry,
6010            legacy_entries,
6011            identity.as_ref(),
6012            csrf.as_deref(),
6013            "All three fields are required.",
6014        )
6015        .await);
6016    }
6017    if new1 != new2 {
6018        return Ok(render_err(
6019            db,
6020            registry,
6021            legacy_entries,
6022            identity.as_ref(),
6023            csrf.as_deref(),
6024            "The two new password fields did not match. Try again.",
6025        )
6026        .await);
6027    }
6028    if new1.len() < 8 {
6029        return Ok(render_err(
6030            db,
6031            registry,
6032            legacy_entries,
6033            identity.as_ref(),
6034            csrf.as_deref(),
6035            "Your new password must be at least 8 characters.",
6036        )
6037        .await);
6038    }
6039
6040    // Verify old password against the current hash. Fetch fresh
6041    // because the session-attached `Identity` doesn't carry the hash.
6042    let user = match auth::user::find_by_id(db, user_id).await? {
6043        Some(u) => u,
6044        None => return Ok(login_page(401, None, None)),
6045    };
6046    if !auth::password::verify(&old, &user.password_hash) {
6047        return Ok(render_err(
6048            db,
6049            registry,
6050            legacy_entries,
6051            identity.as_ref(),
6052            csrf.as_deref(),
6053            "Your old password was entered incorrectly. Please try again.",
6054        )
6055        .await);
6056    }
6057
6058    // Rotate the hash + wipe every session for this user. Then
6059    // re-issue a new session so the caller doesn't get booted out
6060    // of their own password-change form.
6061    auth::user::set_password(db, user.id, &new1).await?;
6062    let session = auth::session::create(db, user.id).await?;
6063    let max_age = auth::SESSION_TTL_DAYS * 24 * 3600;
6064    let mut resp = redirect("/admin/password_change/done");
6065    crate::http::set_cookie(
6066        &mut resp,
6067        &build_session_cookie(auth::SESSION_COOKIE, &session.id, max_age),
6068    );
6069    Ok(with_admin_headers(resp))
6070}
6071
6072// ---------------------------------------------------------------------------
6073// DateTime parsing
6074// ---------------------------------------------------------------------------
6075
6076/// Parse the `YYYY-MM-DDTHH:MM[:SS]` value emitted by the browser's
6077/// `<input type="datetime-local">` widget into a `DateTime<Utc>`.
6078pub fn parse_datetime_local(raw: &str) -> Result<DateTime<Utc>, String> {
6079    if raw.is_empty() {
6080        return Err(String::from("date-time value is empty"));
6081    }
6082    if raw.trim_matches(|c: char| c.is_ascii_whitespace()) != raw {
6083        return Err(format!("`{raw}` has surrounding whitespace"));
6084    }
6085    if raw.ends_with('Z') || raw.contains('+') || (raw.matches('-').count() > 2) {
6086        return Err(format!(
6087            "`{raw}` looks like a timezoned date-time; expected YYYY-MM-DDTHH:MM"
6088        ));
6089    }
6090
6091    let parsed = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
6092        .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
6093        .map_err(|_| format!("`{raw}` is not a valid date-time"))?;
6094    match Utc.from_local_datetime(&parsed) {
6095        chrono::LocalResult::Single(dt) => Ok(dt),
6096        _ => Err(format!("`{raw}` could not be interpreted as UTC")),
6097    }
6098}
6099
6100fn escape_html(s: &str) -> String {
6101    let mut out = String::with_capacity(s.len());
6102    for ch in s.chars() {
6103        match ch {
6104            '&' => out.push_str("&amp;"),
6105            '<' => out.push_str("&lt;"),
6106            '>' => out.push_str("&gt;"),
6107            '"' => out.push_str("&quot;"),
6108            '\'' => out.push_str("&#39;"),
6109            c => out.push(c),
6110        }
6111    }
6112    out
6113}
6114
6115#[cfg(test)]
6116mod tests {
6117    use super::*;
6118    use chrono::Timelike;
6119
6120    // --- session cookie builder -----------------------------------------
6121    //
6122    // Drive both branches by passing `secure` explicitly. Avoids
6123    // mutating RUSTIO_ENV, which is read by tests in other modules
6124    // and would introduce non-determinism under parallel test runs.
6125
6126    #[test]
6127    fn session_cookie_dev_has_no_secure_flag() {
6128        let c = build_session_cookie_impl("rustio_session", "TOK", 600, false);
6129        assert_eq!(
6130            c,
6131            "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Max-Age=600"
6132        );
6133        assert!(!c.contains("Secure"));
6134    }
6135
6136    #[test]
6137    fn session_cookie_production_has_secure_flag() {
6138        let c = build_session_cookie_impl("rustio_session", "TOK", 600, true);
6139        assert_eq!(
6140            c,
6141            "rustio_session=TOK; Path=/; HttpOnly; SameSite=Strict; Secure; Max-Age=600"
6142        );
6143    }
6144
6145    #[test]
6146    fn session_cookie_expiration_shape_is_stable() {
6147        let c = build_session_cookie_impl("rustio_session", "", 0, true);
6148        assert!(c.contains("rustio_session=; "));
6149        assert!(c.contains("HttpOnly"));
6150        assert!(c.contains("SameSite=Strict"));
6151        assert!(c.contains("Secure"));
6152        assert!(c.contains("Max-Age=0"));
6153    }
6154
6155    #[test]
6156    fn escape_html_escapes_dangerous_chars() {
6157        assert_eq!(
6158            escape_html("<script>alert(\"xss\")</script>"),
6159            "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;"
6160        );
6161        assert_eq!(escape_html("a & b"), "a &amp; b");
6162        assert_eq!(escape_html("it's"), "it&#39;s");
6163    }
6164
6165    // --- CSS colour escaper -------------------------------------------
6166
6167    #[test]
6168    fn escape_css_color_accepts_hex_tokens() {
6169        assert_eq!(escape_css_color("#0f172a"), "#0f172a");
6170        assert_eq!(escape_css_color("#4f46e5"), "#4f46e5");
6171    }
6172
6173    #[test]
6174    fn escape_css_color_rejects_injection_attempts() {
6175        // Any character that could break out of the CSS value context
6176        // falls back to the safe default.
6177        assert_eq!(escape_css_color("red; } body { display:none"), "#0f172a");
6178        assert_eq!(escape_css_color("}</style><script>"), "#0f172a");
6179        assert_eq!(escape_css_color("red\\0A "), "#0f172a");
6180    }
6181
6182    // --- DateTime parsing edge cases ------------------------------------
6183
6184    #[test]
6185    fn parse_datetime_local_accepts_minute_precision() {
6186        let dt = parse_datetime_local("2026-04-18T10:12").unwrap();
6187        assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:00+00:00");
6188        assert!(dt.to_rfc3339().ends_with("+00:00"));
6189    }
6190
6191    #[test]
6192    fn parse_datetime_local_accepts_second_precision() {
6193        let dt = parse_datetime_local("2026-04-18T10:12:33").unwrap();
6194        assert_eq!(dt.to_rfc3339(), "2026-04-18T10:12:33+00:00");
6195    }
6196
6197    #[test]
6198    fn parse_datetime_local_rejects_empty_string() {
6199        assert!(parse_datetime_local("").is_err());
6200    }
6201
6202    #[test]
6203    fn parse_datetime_local_rejects_free_text() {
6204        assert!(parse_datetime_local("tomorrow at noon").is_err());
6205    }
6206
6207    #[test]
6208    fn parse_datetime_local_rejects_partial_date() {
6209        assert!(parse_datetime_local("2026-04-18").is_err());
6210    }
6211
6212    #[test]
6213    fn parse_datetime_local_rejects_out_of_range_date() {
6214        assert!(parse_datetime_local("2026-13-01T00:00").is_err());
6215        assert!(parse_datetime_local("2026-04-31T00:00").is_err());
6216    }
6217
6218    #[test]
6219    fn parse_datetime_local_rejects_out_of_range_time() {
6220        assert!(parse_datetime_local("2026-04-18T25:00").is_err());
6221        assert!(parse_datetime_local("2026-04-18T10:99").is_err());
6222    }
6223
6224    #[test]
6225    fn parse_datetime_local_rejects_surrounding_whitespace() {
6226        assert!(parse_datetime_local(" 2026-04-18T10:12").is_err());
6227        assert!(parse_datetime_local("2026-04-18T10:12 ").is_err());
6228    }
6229
6230    #[test]
6231    fn parse_datetime_local_rejects_timezone_suffix() {
6232        assert!(parse_datetime_local("2026-04-18T10:12Z").is_err());
6233        assert!(parse_datetime_local("2026-04-18T10:12:00+00:00").is_err());
6234    }
6235
6236    // --- Admin rendering of field widgets --------------------------------
6237
6238    struct Widgety;
6239    impl crate::orm::Model for Widgety {
6240        const TABLE: &'static str = "widgety";
6241        const COLUMNS: &'static [&'static str] = &["id"];
6242        const INSERT_COLUMNS: &'static [&'static str] = &[];
6243        fn id(&self) -> i64 {
6244            0
6245        }
6246        fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6247            unimplemented!()
6248        }
6249        fn insert_values(&self) -> Vec<crate::orm::Value> {
6250            Vec::new()
6251        }
6252    }
6253    impl AdminModel for Widgety {
6254        const ADMIN_NAME: &'static str = "widgety";
6255        const DISPLAY_NAME: &'static str = "Widgety";
6256        const FIELDS: &'static [AdminField] = &[];
6257        fn field_display(&self, name: &str) -> Option<String> {
6258            match name {
6259                "filled" => Some(String::from("2026-04-18T10:12")),
6260                "empty" => Some(String::new()),
6261                _ => None,
6262            }
6263        }
6264        fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6265            unimplemented!()
6266        }
6267    }
6268
6269    fn string_field(name: &'static str, nullable: bool) -> AdminField {
6270        AdminField {
6271            name,
6272            ty: FieldType::String,
6273            editable: true,
6274            nullable,
6275            relation: None,
6276        }
6277    }
6278
6279    fn datetime_field(name: &'static str, nullable: bool) -> AdminField {
6280        AdminField {
6281            name,
6282            ty: FieldType::DateTime,
6283            editable: true,
6284            nullable,
6285            relation: None,
6286        }
6287    }
6288
6289    #[test]
6290    fn nullable_string_field_omits_required_attribute() {
6291        let f = string_field("note", true);
6292        let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6293        assert!(!html.contains("required"), "html was: {html}");
6294    }
6295
6296    #[test]
6297    fn non_nullable_string_field_marks_required() {
6298        let f = string_field("title", false);
6299        let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6300        assert!(html.contains("required"), "html was: {html}");
6301    }
6302
6303    #[test]
6304    fn bool_field_never_marks_required() {
6305        let f = AdminField {
6306            name: "flag",
6307            ty: FieldType::Bool,
6308            editable: true,
6309            nullable: false,
6310            relation: None,
6311        };
6312        let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6313        assert!(!html.contains("required"), "html was: {html}");
6314    }
6315
6316    #[test]
6317    fn datetime_field_uses_datetime_local_input() {
6318        let f = datetime_field("starts_at", false);
6319        let html = render_field::<Widgety>(&f, None, None, &FormRelationOptions::new());
6320        assert!(
6321            html.contains(r#"type="datetime-local""#),
6322            "html was: {html}"
6323        );
6324    }
6325
6326    #[test]
6327    fn datetime_field_renders_existing_value() {
6328        let f = datetime_field("filled", true);
6329        let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6330        assert!(
6331            html.contains(r#"value="2026-04-18T10:12""#),
6332            "html was: {html}"
6333        );
6334    }
6335
6336    #[test]
6337    fn nullable_field_with_none_value_does_not_panic() {
6338        let f = string_field("empty", true);
6339        let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6340        assert!(html.contains(r#"value="""#));
6341        assert!(!html.contains("required"));
6342    }
6343
6344    #[test]
6345    fn field_display_returning_none_renders_empty_value() {
6346        let f = string_field("unknown_field", false);
6347        let html = render_field::<Widgety>(&f, Some(&Widgety), None, &FormRelationOptions::new());
6348        assert!(html.contains(r#"value="""#));
6349    }
6350
6351    #[test]
6352    fn parse_datetime_local_enforces_utc_for_every_valid_input() {
6353        let inputs = [
6354            "2000-01-01T00:00",
6355            "2026-04-18T10:12",
6356            "2026-04-18T10:12:33",
6357            "2099-12-31T23:59",
6358        ];
6359        for raw in inputs {
6360            let dt = parse_datetime_local(raw).unwrap_or_else(|e| panic!("`{raw}`: {e}"));
6361            assert!(
6362                dt.to_rfc3339().ends_with("+00:00"),
6363                "non-UTC offset in output for `{raw}`: {}",
6364                dt.to_rfc3339(),
6365            );
6366            assert!(
6367                dt.nanosecond() == 0,
6368                "unexpected sub-second part for `{raw}`"
6369            );
6370        }
6371    }
6372
6373    // --- humanise helper ---
6374
6375    #[test]
6376    fn humanise_converts_snake_case_to_title_case() {
6377        assert_eq!(humanise("title"), "Title");
6378        assert_eq!(humanise("is_active"), "Is Active");
6379        assert_eq!(humanise("created_at"), "Created At");
6380        assert_eq!(humanise("assigned_to"), "Assigned To");
6381    }
6382
6383    // --- is_primary_column / default_list_columns ---
6384    //
6385    // Field fixtures. Small helpers to assemble `AdminField` values
6386    // that exercise each rule without dragging in a full model.
6387
6388    fn make_field(
6389        name: &'static str,
6390        ty: FieldType,
6391        relation: Option<AdminRelation>,
6392    ) -> AdminField {
6393        AdminField {
6394            name,
6395            ty,
6396            editable: true,
6397            nullable: false,
6398            relation,
6399        }
6400    }
6401
6402    fn fk(target: &'static str) -> AdminRelation {
6403        AdminRelation {
6404            kind: crate::schema::RelationKind::BelongsTo,
6405            model: target,
6406            display_field: None,
6407        }
6408    }
6409
6410    #[test]
6411    fn primary_rule_1_id_always_primary() {
6412        assert!(is_primary_column(&make_field("id", FieldType::I64, None)));
6413    }
6414
6415    #[test]
6416    fn primary_rule_4_fk_ending_in_id() {
6417        assert!(is_primary_column(&make_field(
6418            "department_id",
6419            FieldType::I64,
6420            Some(fk("Department")),
6421        )));
6422        // No relation → not primary via rule 4 (rule 7 takes over).
6423        assert!(!is_primary_column(&make_field(
6424            "department_id",
6425            FieldType::I64,
6426            None,
6427        )));
6428        // Relation but doesn't end in _id → not primary.
6429        assert!(!is_primary_column(&make_field(
6430            "department",
6431            FieldType::I64,
6432            Some(fk("Department")),
6433        )));
6434    }
6435
6436    #[test]
6437    fn primary_rule_5_is_prefix_bool() {
6438        assert!(is_primary_column(&make_field(
6439            "is_active",
6440            FieldType::Bool,
6441            None,
6442        )));
6443        assert!(is_primary_column(&make_field(
6444            "is_admin",
6445            FieldType::Bool,
6446            None,
6447        )));
6448        // Bool without `is_` prefix → not primary.
6449        assert!(!is_primary_column(&make_field(
6450            "active",
6451            FieldType::Bool,
6452            None,
6453        )));
6454        // `is_*` but not a bool → not primary.
6455        assert!(!is_primary_column(&make_field(
6456            "is_active",
6457            FieldType::String,
6458            None,
6459        )));
6460    }
6461
6462    #[test]
6463    fn primary_rule_6_status_state_priority() {
6464        assert!(is_primary_column(&make_field(
6465            "status",
6466            FieldType::String,
6467            None,
6468        )));
6469        assert!(is_primary_column(&make_field(
6470            "state",
6471            FieldType::String,
6472            None,
6473        )));
6474        assert!(is_primary_column(&make_field(
6475            "priority",
6476            FieldType::I32,
6477            None,
6478        )));
6479        // Near-miss — not an exact match.
6480        assert!(!is_primary_column(&make_field(
6481            "priorities",
6482            FieldType::I32,
6483            None,
6484        )));
6485    }
6486
6487    #[test]
6488    fn primary_rule_7_plain_fields_not_primary() {
6489        assert!(!is_primary_column(&make_field(
6490            "specialty",
6491            FieldType::String,
6492            None,
6493        )));
6494        assert!(!is_primary_column(&make_field(
6495            "license_no",
6496            FieldType::String,
6497            None,
6498        )));
6499        assert!(!is_primary_column(&make_field(
6500            "years_experience",
6501            FieldType::I32,
6502            None,
6503        )));
6504        assert!(!is_primary_column(&make_field(
6505            "created_at",
6506            FieldType::DateTime,
6507            None,
6508        )));
6509    }
6510
6511    // `default_list_columns` is generic over `AdminModel`. The test
6512    // fixtures below supply three small synthetic models to verify
6513    // (a) rule 3's "first name-like match" behaviour, (b) the cap at
6514    // 5, and (c) the fill step that lands `years_experience` in the
6515    // Doctor model's default set.
6516
6517    struct DoctorFixture;
6518    impl crate::orm::Model for DoctorFixture {
6519        const TABLE: &'static str = "doctors";
6520        const COLUMNS: &'static [&'static str] = &[
6521            "id",
6522            "full_name",
6523            "specialty",
6524            "department_id",
6525            "license_no",
6526            "email",
6527            "phone",
6528            "years_experience",
6529            "is_active",
6530            "created_at",
6531        ];
6532        const INSERT_COLUMNS: &'static [&'static str] = &[];
6533        fn id(&self) -> i64 {
6534            0
6535        }
6536        fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6537            unimplemented!()
6538        }
6539        fn insert_values(&self) -> Vec<crate::orm::Value> {
6540            Vec::new()
6541        }
6542    }
6543    impl AdminModel for DoctorFixture {
6544        const ADMIN_NAME: &'static str = "doctors";
6545        const DISPLAY_NAME: &'static str = "Doctors";
6546        const FIELDS: &'static [AdminField] = &[
6547            AdminField {
6548                name: "id",
6549                ty: FieldType::I64,
6550                editable: false,
6551                nullable: false,
6552                relation: None,
6553            },
6554            AdminField {
6555                name: "full_name",
6556                ty: FieldType::String,
6557                editable: true,
6558                nullable: false,
6559                relation: None,
6560            },
6561            AdminField {
6562                name: "specialty",
6563                ty: FieldType::String,
6564                editable: true,
6565                nullable: false,
6566                relation: None,
6567            },
6568            AdminField {
6569                name: "department_id",
6570                ty: FieldType::I64,
6571                editable: true,
6572                nullable: false,
6573                relation: Some(AdminRelation {
6574                    kind: crate::schema::RelationKind::BelongsTo,
6575                    model: "Department",
6576                    display_field: None,
6577                }),
6578            },
6579            AdminField {
6580                name: "license_no",
6581                ty: FieldType::String,
6582                editable: true,
6583                nullable: false,
6584                relation: None,
6585            },
6586            AdminField {
6587                name: "email",
6588                ty: FieldType::String,
6589                editable: true,
6590                nullable: false,
6591                relation: None,
6592            },
6593            AdminField {
6594                name: "phone",
6595                ty: FieldType::String,
6596                editable: true,
6597                nullable: false,
6598                relation: None,
6599            },
6600            AdminField {
6601                name: "years_experience",
6602                ty: FieldType::I32,
6603                editable: true,
6604                nullable: false,
6605                relation: None,
6606            },
6607            AdminField {
6608                name: "is_active",
6609                ty: FieldType::Bool,
6610                editable: true,
6611                nullable: false,
6612                relation: None,
6613            },
6614            AdminField {
6615                name: "created_at",
6616                ty: FieldType::DateTime,
6617                editable: false,
6618                nullable: false,
6619                relation: None,
6620            },
6621        ];
6622        fn singular_name() -> &'static str {
6623            "Doctor"
6624        }
6625        fn field_display(&self, _: &str) -> Option<String> {
6626            None
6627        }
6628        fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6629            unimplemented!()
6630        }
6631    }
6632
6633    #[test]
6634    fn default_columns_doctor_yields_four_rule_matches() {
6635        // Rules 1 (id) + 3 (full_name — first name-like) +
6636        // 4 (department_id — FK with _id) + 5 (is_active) match
6637        // exactly four fields on Doctor. `specialty`, `license_no`,
6638        // `email`, `phone`, `years_experience`, `created_at` do not
6639        // match any rule, so they are NOT included. No fill step
6640        // means the return is exactly those four.
6641        let cols = default_list_columns::<DoctorFixture>();
6642        assert_eq!(cols, vec!["id", "full_name", "department_id", "is_active",]);
6643    }
6644
6645    #[test]
6646    fn default_columns_returns_fewer_than_five_when_rules_match_fewer() {
6647        // Synthetic 3-field model: only `id` (rule 1), `name`
6648        // (rule 3 first name-like), and `is_active` (rule 5) match.
6649        // Return is exactly those three — not padded to 5.
6650        struct Tiny;
6651        impl crate::orm::Model for Tiny {
6652            const TABLE: &'static str = "tinies";
6653            const COLUMNS: &'static [&'static str] = &["id", "name", "is_active"];
6654            const INSERT_COLUMNS: &'static [&'static str] = &[];
6655            fn id(&self) -> i64 {
6656                0
6657            }
6658            fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6659                unimplemented!()
6660            }
6661            fn insert_values(&self) -> Vec<crate::orm::Value> {
6662                Vec::new()
6663            }
6664        }
6665        impl AdminModel for Tiny {
6666            const ADMIN_NAME: &'static str = "tinies";
6667            const DISPLAY_NAME: &'static str = "Tinies";
6668            const FIELDS: &'static [AdminField] = &[
6669                AdminField {
6670                    name: "id",
6671                    ty: FieldType::I64,
6672                    editable: false,
6673                    nullable: false,
6674                    relation: None,
6675                },
6676                AdminField {
6677                    name: "name",
6678                    ty: FieldType::String,
6679                    editable: true,
6680                    nullable: false,
6681                    relation: None,
6682                },
6683                AdminField {
6684                    name: "is_active",
6685                    ty: FieldType::Bool,
6686                    editable: true,
6687                    nullable: false,
6688                    relation: None,
6689                },
6690            ];
6691            fn singular_name() -> &'static str {
6692                "Tiny"
6693            }
6694            fn field_display(&self, _: &str) -> Option<String> {
6695                None
6696            }
6697            fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6698                unimplemented!()
6699            }
6700        }
6701        let cols = default_list_columns::<Tiny>();
6702        assert_eq!(cols, vec!["id", "name", "is_active"]);
6703    }
6704
6705    #[test]
6706    fn default_list_columns_caps_at_five() {
6707        // Synthetic model with MORE than 5 rule-matching fields to
6708        // confirm the cap applies and declaration order is kept.
6709        struct Stuffed;
6710        impl crate::orm::Model for Stuffed {
6711            const TABLE: &'static str = "stuffed";
6712            const COLUMNS: &'static [&'static str] = &[
6713                "id",
6714                "name",
6715                "status",
6716                "state",
6717                "priority",
6718                "is_active",
6719                "is_admin",
6720            ];
6721            const INSERT_COLUMNS: &'static [&'static str] = &[];
6722            fn id(&self) -> i64 {
6723                0
6724            }
6725            fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6726                unimplemented!()
6727            }
6728            fn insert_values(&self) -> Vec<crate::orm::Value> {
6729                Vec::new()
6730            }
6731        }
6732        impl AdminModel for Stuffed {
6733            const ADMIN_NAME: &'static str = "stuffed";
6734            const DISPLAY_NAME: &'static str = "Stuffed";
6735            const FIELDS: &'static [AdminField] = &[
6736                AdminField {
6737                    name: "id",
6738                    ty: FieldType::I64,
6739                    editable: false,
6740                    nullable: false,
6741                    relation: None,
6742                },
6743                AdminField {
6744                    name: "name",
6745                    ty: FieldType::String,
6746                    editable: true,
6747                    nullable: false,
6748                    relation: None,
6749                },
6750                AdminField {
6751                    name: "status",
6752                    ty: FieldType::String,
6753                    editable: true,
6754                    nullable: false,
6755                    relation: None,
6756                },
6757                AdminField {
6758                    name: "state",
6759                    ty: FieldType::String,
6760                    editable: true,
6761                    nullable: false,
6762                    relation: None,
6763                },
6764                AdminField {
6765                    name: "priority",
6766                    ty: FieldType::I32,
6767                    editable: true,
6768                    nullable: false,
6769                    relation: None,
6770                },
6771                AdminField {
6772                    name: "is_active",
6773                    ty: FieldType::Bool,
6774                    editable: true,
6775                    nullable: false,
6776                    relation: None,
6777                },
6778                AdminField {
6779                    name: "is_admin",
6780                    ty: FieldType::Bool,
6781                    editable: true,
6782                    nullable: false,
6783                    relation: None,
6784                },
6785            ];
6786            fn singular_name() -> &'static str {
6787                "Stuffed"
6788            }
6789            fn field_display(&self, _: &str) -> Option<String> {
6790                None
6791            }
6792            fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6793                unimplemented!()
6794            }
6795        }
6796        let cols = default_list_columns::<Stuffed>();
6797        assert_eq!(cols.len(), 5, "cap must hold at 5");
6798        // First 5 in declaration order.
6799        assert_eq!(cols, vec!["id", "name", "status", "state", "priority"]);
6800    }
6801
6802    #[test]
6803    fn default_list_columns_rule_3_first_name_like_wins() {
6804        // Model with two name-like fields (`full_name` + `email`).
6805        // Only `full_name` — the earlier one — should be in the
6806        // default set via rule 3; `email` must NOT be picked unless
6807        // fill-step promotes it (it doesn't — non-FK int fill skips
6808        // Strings).
6809        struct TwoNames;
6810        impl crate::orm::Model for TwoNames {
6811            const TABLE: &'static str = "two_names";
6812            const COLUMNS: &'static [&'static str] = &["id", "full_name", "email"];
6813            const INSERT_COLUMNS: &'static [&'static str] = &[];
6814            fn id(&self) -> i64 {
6815                0
6816            }
6817            fn from_row(_: crate::orm::Row<'_>) -> Result<Self, Error> {
6818                unimplemented!()
6819            }
6820            fn insert_values(&self) -> Vec<crate::orm::Value> {
6821                Vec::new()
6822            }
6823        }
6824        impl AdminModel for TwoNames {
6825            const ADMIN_NAME: &'static str = "two_names";
6826            const DISPLAY_NAME: &'static str = "TwoNames";
6827            const FIELDS: &'static [AdminField] = &[
6828                AdminField {
6829                    name: "id",
6830                    ty: FieldType::I64,
6831                    editable: false,
6832                    nullable: false,
6833                    relation: None,
6834                },
6835                AdminField {
6836                    name: "full_name",
6837                    ty: FieldType::String,
6838                    editable: true,
6839                    nullable: false,
6840                    relation: None,
6841                },
6842                AdminField {
6843                    name: "email",
6844                    ty: FieldType::String,
6845                    editable: true,
6846                    nullable: false,
6847                    relation: None,
6848                },
6849            ];
6850            fn singular_name() -> &'static str {
6851                "TwoName"
6852            }
6853            fn field_display(&self, _: &str) -> Option<String> {
6854                None
6855            }
6856            fn from_form(_: &FormData, _: Option<i64>) -> Result<Self, Error> {
6857                unimplemented!()
6858            }
6859        }
6860        let cols = default_list_columns::<TwoNames>();
6861        // Rule 3 fires exactly once: `full_name` is the first
6862        // name-like field in declaration order. `email` also
6863        // appears in NAME_LIKE_FIELDS but must NOT be promoted —
6864        // rule 3 is "first match wins". With no fill step, email
6865        // does not reappear via some later pass either. Return is
6866        // exactly {id, full_name}.
6867        assert_eq!(cols, vec!["id", "full_name"]);
6868    }
6869}