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