Skip to main content

rustio_core/admin/
layout.rs

1//! Admin page assembler.
2//!
3//! Every admin page is rendered by `minijinja` against the templates
4//! bundled under `rustio-core/assets/templates/`. The functions here
5//! build the typed context dicts the templates consume — no HTML is
6//! concatenated in Rust. Bootstrap 5 CSS/JS and `admin.css`/`app.js`
7//! ship from `rustio-core/assets/static/` and are served under
8//! `/admin/static/…` by the core (see `admin::templating`).
9
10use std::collections::HashMap;
11
12use crate::admin::admin_form_bridge::{
13    resolve_filter_type, AdminDataType, AdminUiField, AdminUiModel, FilterType,
14};
15use crate::admin::persistence;
16use crate::admin::ui::html_escape;
17use crate::orm::Db;
18
19// ---------------------------------------------------------------
20// Dashboard (admin index — GET /admin)
21// ---------------------------------------------------------------
22
23/// One card + one sidebar entry per registered model.
24struct DashboardEntry {
25    slug: &'static str,
26    model_name: &'static str,
27    count: i64,
28}
29
30/// Walk the registry, count rows per table, return a sorted list.
31/// Extracted so both the dashboard and the per-model pages share one
32/// source of truth for the sidebar contents.
33async fn collect_dashboard_entries(
34    db: &Db,
35    registry: &crate::admin::admin_form_bridge::AdminRegistry,
36) -> Vec<DashboardEntry> {
37    use sqlx::Row;
38    let mut slugs: Vec<&'static str> = registry.slugs().copied().collect();
39    slugs.sort();
40    let mut out = Vec::with_capacity(slugs.len());
41    for slug in slugs {
42        let Some(model) = registry.get(slug) else {
43            continue;
44        };
45        if let Some(sql) = model.ensure_table_sql() {
46            let _ = persistence::ensure_table(db, sql).await;
47        }
48        let table = model.table_name();
49        let count: i64 = {
50            let sql = format!(
51                "SELECT COUNT(*) AS c FROM \"{}\"",
52                table.replace('"', "\"\"")
53            );
54            match sqlx::query(&sql).fetch_one(db.pool()).await {
55                Ok(row) => row.try_get::<i64, _>("c").unwrap_or(0),
56                Err(_) => 0,
57            }
58        };
59        out.push(DashboardEntry {
60            slug: model.slug(),
61            model_name: model.model_name(),
62            count,
63        });
64    }
65    out
66}
67
68/// Walk legacy `AdminEntry`s, skipping framework-internal (`core`)
69/// entries and any slugs already covered by the new-engine registry,
70/// and return one `DashboardEntry` per remaining model. Mirrors the
71/// shape of [`collect_dashboard_entries`] so both lists are
72/// interchangeable downstream.
73///
74/// This is what makes `Admin::new().model::<T>()`-registered models
75/// appear on the `/admin` dashboard, not just in the sidebar. Without
76/// this walk, the cards listed only what the new `AdminUiModel`
77/// registry knew about — projects scaffolded via the standard
78/// `rustio new app` path were invisible on the overview.
79async fn collect_legacy_dashboard_entries(
80    db: &Db,
81    legacy_entries: &[crate::admin::AdminEntry],
82    already_listed: &std::collections::HashSet<&str>,
83) -> Vec<DashboardEntry> {
84    use sqlx::Row;
85    let mut out = Vec::new();
86    for entry in legacy_entries {
87        if entry.core || already_listed.contains(entry.admin_name) {
88            continue;
89        }
90        let count: i64 = {
91            let sql = format!(
92                "SELECT COUNT(*) AS c FROM \"{}\"",
93                entry.table.replace('"', "\"\""),
94            );
95            match sqlx::query(&sql).fetch_one(db.pool()).await {
96                Ok(row) => row.try_get::<i64, _>("c").unwrap_or(0),
97                Err(_) => 0,
98            }
99        };
100        out.push(DashboardEntry {
101            slug: entry.admin_name,
102            // `singular_name` is "Task" / "Project"; the card template
103            // pluralizes via `format!("{}s", …)` for the label.
104            model_name: entry.singular_name,
105            count,
106        });
107    }
108    // Sort by slug for deterministic card order — matches what
109    // `collect_dashboard_entries` does for the new-engine half.
110    out.sort_by_key(|e| e.slug);
111    out
112}
113
114/// Pull the users-table window + count from the demo table. When a
115/// non-empty `query` is supplied, dispatches to
116/// [`persistence::search_records`] / [`persistence::count_search_records`]
117/// so the same (rows, total) shape works for both list and search
118/// modes. Failures degrade silently to `(empty Vec, 0)` so the page
119/// chrome can still render — a transient DB error must not 500 a
120/// page that is mostly chrome.
121/// Returns `(rows, total, current_page, total_pages)`. Filters are
122/// classified by metadata (`resolve_filter_type`) into equality vs
123/// `LIKE` clauses, then handed to [`persistence::filter_records`] /
124/// [`persistence::count_filtered_records`] alongside the search
125/// query. Total is fetched first so the page can be clamped to a
126/// valid range before the windowed query runs — `?page=999` against
127/// a 30-row table snaps to the last real page instead of returning
128/// nothing. `total_pages` is always at least `1` so the chrome can
129/// render `(Page 1 of 1)` even on an empty DB.
130#[allow(clippy::too_many_arguments)]
131async fn fetch_users_table_state(
132    db: &Db,
133    model: &dyn AdminUiModel,
134    query: Option<&str>,
135    filters: &HashMap<String, String>,
136    page: i64,
137    sort: Option<&str>,
138    dir: Option<&str>,
139) -> (
140    Vec<HashMap<String, String>>,
141    i64,
142    i64,
143    i64,
144    Option<String>,
145    Option<String>,
146) {
147    const PAGE_SIZE: i64 = 20;
148    let table = model.table_name();
149    let searchable: Vec<&str> = model.searchable_fields();
150    let (eq_filters, like_filters) = classify_filters(model, filters);
151    let (validated_sort, validated_dir) = validate_sort_state(model, sort, dir);
152
153    let total = persistence::count_filtered_records(
154        db,
155        table,
156        &eq_filters,
157        &like_filters,
158        query,
159        &searchable,
160    )
161    .await
162    .unwrap_or(0);
163
164    let total_pages = if total > 0 {
165        ((total as u64).div_ceil(PAGE_SIZE as u64) as i64).max(1)
166    } else {
167        1
168    };
169    let current_page = page.clamp(1, total_pages);
170    let offset = (current_page - 1) * PAGE_SIZE;
171
172    let rows = persistence::filter_records(
173        db,
174        table,
175        &eq_filters,
176        &like_filters,
177        query,
178        &searchable,
179        validated_sort.as_deref(),
180        validated_dir.as_deref(),
181        PAGE_SIZE,
182        offset,
183    )
184    .await
185    .unwrap_or_default();
186
187    (
188        rows,
189        total,
190        current_page,
191        total_pages,
192        validated_sort,
193        validated_dir,
194    )
195}
196
197/// Validate the URL's `sort` + `dir` against `UserAdmin` metadata.
198/// `sort` must name a field that's both declared and `sortable`;
199/// `dir` is normalised to `"asc"` or `"desc"` (any other value
200/// becomes `"asc"`). An invalid sort drops both to `None` so
201/// persistence falls back to `ORDER BY "id" DESC`. All validation
202/// happens here so persistence stays a simple SQL emitter that
203/// trusts its inputs.
204fn validate_sort_state(
205    model: &dyn AdminUiModel,
206    sort: Option<&str>,
207    dir: Option<&str>,
208) -> (Option<String>, Option<String>) {
209    let valid_sort = sort.filter(|s| model.fields().iter().any(|f| f.name == *s && f.sortable));
210    match valid_sort {
211        Some(s) => {
212            let d = if matches!(dir, Some("desc")) {
213                "desc"
214            } else {
215                "asc"
216            };
217            (Some(s.to_string()), Some(d.to_string()))
218        }
219        None => (None, None),
220    }
221}
222
223/// Walk `model.fields()` and split the raw URL filter map into two
224/// buckets keyed off [`resolve_filter_type`]: equality filters
225/// (Boolean, Select) and `LIKE` filters (Exact text). Any URL key
226/// that doesn't correspond to a declared `AdminUiField` is silently
227/// dropped — this is the security boundary that stops an attacker
228/// from injecting `?random_column=x` to query columns that admin
229/// metadata never exposed as filterable.
230fn classify_filters(
231    model: &dyn AdminUiModel,
232    raw: &HashMap<String, String>,
233) -> (HashMap<String, String>, HashMap<String, String>) {
234    let fields = model.fields();
235    let mut eq = HashMap::new();
236    let mut like = HashMap::new();
237    for (k, v) in raw {
238        let Some(field) = fields.iter().find(|f| f.name == k.as_str()) else {
239            continue;
240        };
241        // Don't filter on a column the admin model didn't mark
242        // filterable — same idea as the unknown-key drop above,
243        // just for declared-but-unfilterable columns.
244        if !field.filterable && !field.advanced_filter {
245            continue;
246        }
247        match resolve_filter_type(field) {
248            FilterType::Boolean | FilterType::Select => {
249                eq.insert(k.clone(), v.clone());
250            }
251            FilterType::Exact => {
252                like.insert(k.clone(), v.clone());
253            }
254        }
255    }
256    (eq, like)
257}
258
259// ---------------------------------------------------------------
260// Built-in demo model: User
261// ---------------------------------------------------------------
262
263/// Demo `AdminUiModel` registered as `"users"`. Backs the
264/// `/admin-new/users` route. The struct is unit; all metadata lives
265/// in the trait impl.
266pub struct UserAdmin;
267
268/// Factory used by the registry to produce a fresh boxed model per
269/// request. `UserAdmin` is zero-sized so the allocation is free.
270pub fn new_user_admin() -> Box<dyn AdminUiModel> {
271    Box::new(UserAdmin)
272}
273
274impl AdminUiModel for UserAdmin {
275    fn slug(&self) -> &'static str {
276        "users"
277    }
278    fn model_name(&self) -> &'static str {
279        "User"
280    }
281    fn table_name(&self) -> &'static str {
282        "admin_new_demo_users"
283    }
284    fn primary_key(&self) -> &'static str {
285        "id"
286    }
287    fn searchable_fields(&self) -> Vec<&'static str> {
288        vec!["username", "email", "doctor_id"]
289    }
290    fn primary_status_field(&self) -> Option<&'static str> {
291        Some("is_active")
292    }
293    fn ensure_table_sql(&self) -> Option<&'static str> {
294        Some(
295            "CREATE TABLE IF NOT EXISTS admin_new_demo_users (
296                id INTEGER PRIMARY KEY AUTOINCREMENT,
297                username TEXT NOT NULL,
298                email TEXT NOT NULL,
299                is_active TEXT NOT NULL DEFAULT 'false',
300                doctor_id TEXT,
301                salary_amount TEXT
302            )",
303        )
304    }
305
306    fn fields(&self) -> Vec<AdminUiField> {
307        vec![
308            AdminUiField {
309                name: "username",
310                label: "Username",
311                data_type: AdminDataType::String,
312                required: true,
313                readonly: false,
314                is_relation: false,
315                options: vec![],
316                filterable: true,
317                advanced_filter: false,
318                sortable: true,
319                visible_in_table: true,
320            },
321            AdminUiField {
322                name: "email",
323                label: "Email",
324                data_type: AdminDataType::Email,
325                required: true,
326                readonly: false,
327                is_relation: false,
328                options: vec![],
329                filterable: false,
330                advanced_filter: true,
331                sortable: true,
332                visible_in_table: true,
333            },
334            AdminUiField {
335                name: "is_active",
336                label: "Active",
337                data_type: AdminDataType::Boolean,
338                required: false,
339                readonly: false,
340                is_relation: false,
341                options: vec![],
342                filterable: true,
343                advanced_filter: false,
344                sortable: true,
345                visible_in_table: true,
346            },
347            AdminUiField {
348                name: "doctor_id",
349                label: "Doctor",
350                data_type: AdminDataType::Integer,
351                required: true,
352                readonly: false,
353                is_relation: true,
354                options: vec![
355                    ("1".into(), "Dr. Erik".into()),
356                    ("2".into(), "Dr. Sara".into()),
357                ],
358                filterable: true,
359                advanced_filter: false,
360                sortable: true,
361                visible_in_table: true,
362            },
363            AdminUiField {
364                name: "salary_amount",
365                label: "Salary",
366                data_type: AdminDataType::Float,
367                required: false,
368                readonly: false,
369                is_relation: false,
370                options: vec![],
371                filterable: false,
372                advanced_filter: true,
373                sortable: true,
374                visible_in_table: true,
375            },
376        ]
377    }
378}
379
380// ---------------------------------------------------------------
381// Template-based renderers (0.10.0+)
382//
383// Every admin page is rendered by `minijinja`. The sidebar is built
384// from the registered `AdminUiModel` registry (plus any legacy
385// `AdminEntry` models, merged in by `sidebar_merged`) — no
386// placeholder groups.
387// ---------------------------------------------------------------
388
389#[derive(serde::Serialize)]
390struct DesignView<'a> {
391    project_name: &'a str,
392    logo_initial: &'a str,
393    primary_color: &'a str,
394    accent_color: &'a str,
395}
396
397#[derive(serde::Serialize)]
398struct UserView {
399    email: String,
400    display_name: String,
401}
402
403#[derive(serde::Serialize)]
404struct SidebarEntryView {
405    label: String,
406    href: String,
407    active: bool,
408    visible: bool,
409    /// Row count for the underlying table. -1 means "no count
410    /// available" (used for legacy `AdminEntry`s); the template
411    /// hides the badge in that case. Otherwise the count renders
412    /// in `.rio-sidebar__count` to the right of the label.
413    count: i64,
414}
415
416#[derive(serde::Serialize)]
417struct DashboardCardView {
418    label: String,
419    value: i64,
420}
421
422fn design_view() -> DesignView<'static> {
423    let d = crate::admin::design::Design::global();
424    // `Design::global()` returns `&'static Self`, so field borrows
425    // satisfy the `'static` lifetime without any allocation.
426    DesignView {
427        project_name: d.project_name.as_str(),
428        logo_initial: d.logo_initial.as_str(),
429        primary_color: d.primary_color.as_str(),
430        accent_color: d.accent_color.as_str(),
431    }
432}
433
434fn user_view(identity: Option<&crate::auth::Identity>) -> Option<UserView> {
435    identity.map(|id| UserView {
436        email: id.email.clone(),
437        display_name: id.email.clone(),
438    })
439}
440
441fn sidebar_from_entries(
442    entries: &[DashboardEntry],
443    active_slug: Option<&str>,
444) -> Vec<SidebarEntryView> {
445    entries
446        .iter()
447        .map(|e| SidebarEntryView {
448            label: format!("{}s", e.model_name),
449            href: format!("/admin/{}", e.slug),
450            active: active_slug == Some(e.slug),
451            visible: true,
452            count: e.count,
453        })
454        .collect()
455}
456
457/// Merge sidebar sources: the "new" registry (DashboardEntry list
458/// from `admin_new_registry`) first, then any legacy `AdminEntry`
459/// registered via `Admin::model::<T>()` that isn't already in the
460/// new registry. Dedup is by slug (`entry.admin_name`), so a model
461/// registered in both surfaces keeps its new-registry entry. Core
462/// entries (framework-synthetic) are omitted.
463fn sidebar_merged(
464    dashboard_entries: &[DashboardEntry],
465    legacy_entries: &[crate::admin::AdminEntry],
466    active_slug: Option<&str>,
467) -> Vec<SidebarEntryView> {
468    let mut merged = sidebar_from_entries(dashboard_entries, active_slug);
469    let known: std::collections::HashSet<&str> = dashboard_entries.iter().map(|e| e.slug).collect();
470    for entry in legacy_entries {
471        if entry.core || known.contains(entry.admin_name) {
472            continue;
473        }
474        merged.push(SidebarEntryView {
475            label: entry.display_name.to_string(),
476            href: format!("/admin/{}", entry.admin_name),
477            active: active_slug == Some(entry.admin_name),
478            visible: true,
479            count: -1,
480        });
481    }
482    merged
483}
484
485/// Adapter that implements [`AdminUiModel`] for a legacy
486/// [`crate::admin::AdminEntry`] so the template-based `list_render`
487/// can serve its rows without a separate rendering path. No form /
488/// mutation behaviour — legacy create / edit / delete still flow
489/// through `mount_model`'s literal routes; this adapter only needs
490/// to describe the table shape well enough for the list view.
491pub struct LegacyEntryModel {
492    entry: crate::admin::AdminEntry,
493}
494
495impl LegacyEntryModel {
496    /// Clone-construct from an `AdminEntry` ref. Cheap — every field
497    /// inside `AdminEntry` is either a `&'static str`, `&'static
498    /// [AdminField]`, or a `bool`, so `Clone` is effectively a shallow
499    /// pointer copy.
500    pub fn new(entry: &crate::admin::AdminEntry) -> Self {
501        Self {
502            entry: entry.clone(),
503        }
504    }
505
506    /// The underlying `AdminEntry`. Exposed so the form / list
507    /// enrichment helpers can read the original `AdminField.relation`
508    /// info (which `AdminUiField` doesn't currently carry).
509    pub fn source_entry(&self) -> &crate::admin::AdminEntry {
510        &self.entry
511    }
512}
513
514/// Fetch `(id, display)` pairs from the table pointed at by an
515/// `AdminRelation`. Used to populate a FK field's `<select>`.
516///
517/// The chosen display column is, in priority order:
518/// 1. `relation.display_field` if set,
519/// 2. otherwise the first non-id `FieldType::String` column on the
520///    target entry,
521/// 3. otherwise the id itself ("#123").
522///
523/// Cap is 500 rows — matches `RELATION_FILTER_DROPDOWN_CAP` so a
524/// project with a huge target table doesn't render a form that
525/// takes minutes to parse. Larger tables should move to a typeahead
526/// control, which is a separate stage.
527async fn fk_options(
528    db: &Db,
529    relation: crate::admin::AdminRelation,
530    legacy_entries: &[crate::admin::AdminEntry],
531) -> Vec<(String, String)> {
532    use sqlx::Row as _;
533
534    let Some(target_entry) = legacy_entries
535        .iter()
536        .find(|e| e.singular_name == relation.model)
537    else {
538        return Vec::new();
539    };
540    let display_col = relation
541        .display_field
542        .or_else(|| {
543            target_entry
544                .fields
545                .iter()
546                .filter(|f| f.name != "id" && matches!(f.ty, crate::admin::FieldType::String))
547                .map(|f| f.name)
548                .next()
549        })
550        .unwrap_or("id");
551
552    let sql = format!(
553        r#"SELECT "id", "{display}" FROM "{table}" ORDER BY "{display}" LIMIT 500"#,
554        display = display_col.replace('"', "\"\""),
555        table = target_entry.table.replace('"', "\"\""),
556    );
557    let Ok(rows) = sqlx::query(&sql).fetch_all(db.pool()).await else {
558        return Vec::new();
559    };
560    rows.into_iter()
561        .filter_map(|row| {
562            let id: Option<i64> = row.try_get(0).ok();
563            // Display column may be integer (when we fell back to
564            // "id") or string. Try string first, then stringify the
565            // integer.
566            let display: Option<String> = row
567                .try_get::<Option<String>, _>(1)
568                .ok()
569                .flatten()
570                .or_else(|| {
571                    row.try_get::<Option<i64>, _>(1)
572                        .ok()
573                        .flatten()
574                        .map(|n| n.to_string())
575                });
576            match (id, display) {
577                (Some(i), Some(d)) => Some((i.to_string(), d)),
578                (Some(i), None) => Some((i.to_string(), format!("#{i}"))),
579                _ => None,
580            }
581        })
582        .collect()
583}
584
585/// Resolve `(id → label)` for one FK column across every row
586/// currently on the page. One SQL `SELECT id, <display> FROM <target>
587/// WHERE id IN (?, ?, …)` — so a list with 20 rows and 3 FK columns
588/// costs 3 queries, not 60.
589async fn fk_lookup_batch(
590    db: &Db,
591    target_entry: &crate::admin::AdminEntry,
592    display_field: Option<&'static str>,
593    ids: &[String],
594) -> std::collections::HashMap<String, String> {
595    use sqlx::Row as _;
596
597    let mut out = std::collections::HashMap::new();
598    if ids.is_empty() {
599        return out;
600    }
601    let display_col = display_field
602        .or_else(|| {
603            target_entry
604                .fields
605                .iter()
606                .filter(|f| f.name != "id" && matches!(f.ty, crate::admin::FieldType::String))
607                .map(|f| f.name)
608                .next()
609        })
610        .unwrap_or("id");
611
612    let placeholders = vec!["?"; ids.len()].join(",");
613    let sql = format!(
614        r#"SELECT "id", "{display}" FROM "{table}" WHERE "id" IN ({placeholders})"#,
615        display = display_col.replace('"', "\"\""),
616        table = target_entry.table.replace('"', "\"\""),
617    );
618    let mut q = sqlx::query(&sql);
619    for id in ids {
620        q = q.bind(id);
621    }
622    let Ok(rows) = q.fetch_all(db.pool()).await else {
623        return out;
624    };
625    for row in rows {
626        let Ok(id) = row.try_get::<i64, _>(0) else {
627            continue;
628        };
629        let label: Option<String> =
630            row.try_get::<Option<String>, _>(1)
631                .ok()
632                .flatten()
633                .or_else(|| {
634                    row.try_get::<Option<i64>, _>(1)
635                        .ok()
636                        .flatten()
637                        .map(|n| n.to_string())
638                });
639        if let Some(l) = label {
640            out.insert(id.to_string(), l);
641        }
642    }
643    out
644}
645
646/// Per-column FK resolution data used to rewrite list cells.
647struct FkColumnInfo {
648    column_index: usize,
649    target_admin_name: String,
650    id_to_label: std::collections::HashMap<String, String>,
651}
652
653/// For each visible column on the list page that points at another
654/// model, batch-resolve the FK values displayed in the current page
655/// of rows. One SQL query per FK column; callers render
656/// `<a href="/admin/<target>/<id>">label</a>` in each matching cell.
657async fn build_fk_lookups(
658    db: &Db,
659    source_entry: Option<&crate::admin::AdminEntry>,
660    columns: &[ColumnView],
661    rows_raw: &[HashMap<String, String>],
662    legacy_entries: &[crate::admin::AdminEntry],
663) -> Vec<FkColumnInfo> {
664    let mut out = Vec::new();
665    let Some(source) = source_entry else {
666        return out;
667    };
668    for (idx, col) in columns.iter().enumerate() {
669        let Some(source_field) = source.fields.iter().find(|f| f.name == col.name) else {
670            continue;
671        };
672        let Some(relation) = source_field.relation else {
673            continue;
674        };
675        let Some(target_entry) = legacy_entries
676            .iter()
677            .find(|e| e.singular_name == relation.model)
678        else {
679            continue;
680        };
681        let ids: Vec<String> = {
682            let mut seen = std::collections::HashSet::new();
683            let mut v = Vec::new();
684            for row in rows_raw {
685                if let Some(id) = row.get(&col.name) {
686                    if !id.is_empty() && seen.insert(id.clone()) {
687                        v.push(id.clone());
688                    }
689                }
690            }
691            v
692        };
693        if ids.is_empty() {
694            continue;
695        }
696        let id_to_label = fk_lookup_batch(db, target_entry, relation.display_field, &ids).await;
697        out.push(FkColumnInfo {
698            column_index: idx,
699            target_admin_name: target_entry.admin_name.to_string(),
700            id_to_label,
701        });
702    }
703    out
704}
705
706/// Produce `model.fields()` with FK options populated when the
707/// source is a legacy `AdminEntry`. For new-engine models the
708/// registration code is responsible for its own options — we pass
709/// those through unchanged.
710pub async fn enrich_fields_for_form(
711    db: &Db,
712    model: &dyn AdminUiModel,
713    legacy_source: Option<&crate::admin::AdminEntry>,
714    legacy_entries: &[crate::admin::AdminEntry],
715) -> Vec<AdminUiField> {
716    let mut fields = model.fields();
717    let Some(source) = legacy_source else {
718        return fields;
719    };
720    for field in fields.iter_mut() {
721        let Some(source_field) = source.fields.iter().find(|f| f.name == field.name) else {
722            continue;
723        };
724        let Some(relation) = source_field.relation else {
725            continue;
726        };
727        field.is_relation = true;
728        field.options = fk_options(db, relation, legacy_entries).await;
729    }
730    fields
731}
732
733fn admin_field_to_ui_field(field: &crate::admin::AdminField) -> AdminUiField {
734    use crate::admin::FieldType;
735    // FieldType is `#[non_exhaustive]`. Exhaustive matching inside
736    // this crate is required — the compiler will flag a new variant
737    // here if one lands without updating this mapping.
738    let data_type = match field.ty {
739        FieldType::String => AdminDataType::String,
740        FieldType::I32 | FieldType::I64 => AdminDataType::Integer,
741        FieldType::Bool => AdminDataType::Boolean,
742        FieldType::DateTime => AdminDataType::DateTime,
743    };
744    AdminUiField {
745        name: field.name,
746        // Legacy `AdminField` carries no display label; synthesise
747        // the column name itself. Stage 5 may capitalise / prettify
748        // this, but the bare name is readable and unambiguous.
749        label: field.name,
750        data_type,
751        required: !field.nullable,
752        readonly: !field.editable,
753        is_relation: field.relation.is_some(),
754        options: Vec::new(),
755        filterable: false,
756        advanced_filter: false,
757        sortable: matches!(
758            data_type,
759            AdminDataType::Integer
760                | AdminDataType::Float
761                | AdminDataType::DateTime
762                | AdminDataType::String
763                | AdminDataType::Email
764        ),
765        visible_in_table: true,
766    }
767}
768
769// ---------------------------------------------------------------
770// 0.10 form rendering (stage 4f-a)
771//
772// GET /admin/:model/new and GET /admin/:model/:id/edit both flow
773// through `form_render`. POST submission, validation, and mutation
774// land in stage 4f-b.
775// ---------------------------------------------------------------
776
777#[derive(serde::Serialize)]
778struct FormFieldView {
779    id: String,
780    name: String,
781    label: String,
782    required: bool,
783    readonly: bool,
784    control: String,
785    help: Option<String>,
786    error: Option<String>,
787}
788
789#[derive(serde::Serialize)]
790struct FormView {
791    title: String,
792    action: String,
793    cancel_url: String,
794    submit_label: String,
795    error: Option<String>,
796    fields: Vec<FormFieldView>,
797}
798
799fn render_field_control(field: &AdminUiField, value: &str) -> String {
800    let id = format!("field_{}", field.name);
801    let name = field.name;
802    let val = html_escape(value);
803    let readonly = if field.readonly { " readonly" } else { "" };
804    let required = if field.required && !field.readonly {
805        " required"
806    } else {
807        ""
808    };
809
810    // FK fields render as a `<select>` regardless of the underlying
811    // data_type (FKs stored in `i64` columns would otherwise fall
812    // into the Integer branch and show a raw number input).
813    if field.is_relation && !field.options.is_empty() {
814        let mut options = String::from(r#"<option value="">— choose —</option>"#);
815        for (ov, ol) in &field.options {
816            let sel = if ov == value { " selected" } else { "" };
817            options.push_str(&format!(
818                r#"<option value="{v}"{sel}>{l}</option>"#,
819                v = html_escape(ov),
820                l = html_escape(ol),
821            ));
822        }
823        return format!(
824            r#"<select class="rio-form__input" id="{id}" name="{name}"{readonly}{required}>{options}</select>"#,
825        );
826    }
827    if field.is_relation {
828        // FK without options (target table missing, query failed,
829        // or 0 rows) — fall back to a plain number input so the
830        // form still submits. This matches the 0.9 relation-layer
831        // rule: "never guess, never hide".
832        return format!(
833            r#"<input type="number" step="1" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required} placeholder="id">"#,
834        );
835    }
836
837    match field.data_type {
838        AdminDataType::Text => format!(
839            r#"<textarea class="rio-form__input rio-form__input--textarea" id="{id}" name="{name}"{readonly}{required} rows="4">{val}</textarea>"#,
840        ),
841        AdminDataType::Email => format!(
842            r#"<input type="email" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required} autocomplete="off">"#,
843        ),
844        AdminDataType::Integer => format!(
845            r#"<input type="number" step="1" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
846        ),
847        AdminDataType::Float => format!(
848            r#"<input type="number" step="any" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
849        ),
850        AdminDataType::Boolean => {
851            let checked = if value == "1" || value.eq_ignore_ascii_case("true") {
852                " checked"
853            } else {
854                ""
855            };
856            // Hidden input keeps the field in the POST body when the
857            // box is unchecked, so "unchecked" means "false" rather
858            // than "omitted".
859            format!(
860                r#"<input type="hidden" name="{name}" value="0"><input type="checkbox" class="rio-form__check" id="{id}" name="{name}" value="1"{checked}{readonly}>"#,
861            )
862        }
863        AdminDataType::DateTime => format!(
864            r#"<input type="datetime-local" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
865        ),
866        AdminDataType::String => format!(
867            r#"<input type="text" class="rio-form__input" id="{id}" name="{name}" value="{val}"{readonly}{required}>"#,
868        ),
869    }
870}
871
872/// Render the form page for `GET /admin/:model/new` (when
873/// `editing_id = None`) or `GET /admin/:model/:id/edit` (when
874/// `editing_id = Some(id)`).
875///
876/// `legacy_source` is `Some(&entry)` when the model came from the
877/// legacy `AdminEntry` path — this unlocks FK options enrichment
878/// (the legacy field type doesn't carry pre-populated
879/// `AdminUiField.options`). For new-engine models this is `None`
880/// and their own registration code is responsible for options.
881#[allow(clippy::too_many_arguments)]
882pub async fn form_render(
883    db: &Db,
884    registry: &crate::admin::admin_form_bridge::AdminRegistry,
885    legacy_entries: &[crate::admin::AdminEntry],
886    model: &dyn AdminUiModel,
887    legacy_source: Option<&crate::admin::AdminEntry>,
888    editing_id: Option<&str>,
889    identity: Option<&crate::auth::Identity>,
890    csrf_token: Option<&str>,
891    form_error: Option<&str>,
892) -> String {
893    if let Some(sql) = model.ensure_table_sql() {
894        let _ = persistence::ensure_table(db, sql).await;
895    }
896
897    let dashboard_entries = collect_dashboard_entries(db, registry).await;
898    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, Some(model.slug()));
899
900    let is_edit = editing_id.is_some();
901    let prefill = if let Some(id) = editing_id {
902        persistence::get_record_by_id(db, model.table_name(), id)
903            .await
904            .unwrap_or_default()
905    } else {
906        HashMap::new()
907    };
908
909    let pk = model.primary_key();
910    let slug = model.slug();
911    let enriched = enrich_fields_for_form(db, model, legacy_source, legacy_entries).await;
912    let fields: Vec<FormFieldView> = enriched
913        .into_iter()
914        .filter(|f| {
915            // Create form skips the PK (DB auto-assigns). Edit form
916            // keeps it visible + readonly so the reader sees which
917            // row they're editing.
918            if !is_edit && f.name == pk {
919                return false;
920            }
921            true
922        })
923        .map(|mut f| {
924            if f.name == pk {
925                f.readonly = true;
926            }
927            let raw_value = prefill.get(f.name).cloned().unwrap_or_default();
928            let control = render_field_control(&f, &raw_value);
929            FormFieldView {
930                id: format!("field_{}", f.name),
931                name: f.name.to_string(),
932                label: humanize_field_label(f.label),
933                required: f.required && !f.readonly,
934                readonly: f.readonly,
935                control,
936                help: None,
937                error: None,
938            }
939        })
940        .collect();
941
942    let (title, action, submit_label) = match editing_id {
943        Some(id) => (
944            format!("Edit {}", model.model_name()),
945            format!("/admin/{slug}/{id}/edit"),
946            "Save changes".to_string(),
947        ),
948        None => (
949            format!("New {}", model.model_name()),
950            format!("/admin/{slug}/new"),
951            format!("Create {}", model.model_name()),
952        ),
953    };
954
955    let form = FormView {
956        title,
957        action,
958        cancel_url: format!("/admin/{slug}"),
959        submit_label,
960        error: form_error.map(str::to_string),
961        fields,
962    };
963
964    let design = design_view();
965    let user = user_view(identity);
966
967    let env = crate::admin::templating::env();
968    match env.get_template("admin/form.html").and_then(|tmpl| {
969        tmpl.render(minijinja::context! {
970            design => design,
971            current_user => user,
972            sidebar_entries => sidebar,
973            form => form,
974            page_title => format!(
975                "{} · {}s",
976                if is_edit { "Edit" } else { "New" },
977                model.model_name()
978            ),
979            csrf_token => csrf_token.unwrap_or(""),
980            rustio_version => env!("CARGO_PKG_VERSION"),
981        })
982    }) {
983        Ok(html) => html,
984        Err(err) => {
985            eprintln!("admin form template render failed: {err}");
986            form_fallback(model, editing_id)
987        }
988    }
989}
990
991fn form_fallback(model: &dyn AdminUiModel, editing_id: Option<&str>) -> String {
992    let kind = if editing_id.is_some() { "Edit" } else { "New" };
993    format!(
994        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{kind} {mn}</title></head><body style=\"font-family:system-ui\"><h1>{kind} {mn}</h1><p>The form template failed to render. Check the server log.</p><p><a href=\"/admin/{slug}\">Back to list</a></p></body></html>",
995        mn = html_escape(model.model_name()),
996        slug = html_escape(model.slug()),
997    )
998}
999
1000impl AdminUiModel for LegacyEntryModel {
1001    fn slug(&self) -> &'static str {
1002        self.entry.admin_name
1003    }
1004    fn model_name(&self) -> &'static str {
1005        self.entry.singular_name
1006    }
1007    fn table_name(&self) -> &'static str {
1008        self.entry.table
1009    }
1010    fn primary_key(&self) -> &'static str {
1011        // Convention: the first non-editable field is the PK ("id").
1012        // Falls back to "id" if no match.
1013        self.entry
1014            .fields
1015            .iter()
1016            .find(|f| !f.editable && f.name == "id")
1017            .map(|f| f.name)
1018            .unwrap_or("id")
1019    }
1020    fn fields(&self) -> Vec<AdminUiField> {
1021        self.entry
1022            .fields
1023            .iter()
1024            .map(admin_field_to_ui_field)
1025            .collect()
1026    }
1027    fn searchable_fields(&self) -> Vec<&'static str> {
1028        self.entry
1029            .fields
1030            .iter()
1031            .filter(|f| matches!(f.ty, crate::admin::FieldType::String))
1032            .map(|f| f.name)
1033            .collect()
1034    }
1035    fn primary_status_field(&self) -> Option<&'static str> {
1036        None
1037    }
1038    fn ensure_table_sql(&self) -> Option<&'static str> {
1039        None
1040    }
1041}
1042
1043/// 0.10+ dashboard renderer. Collects the registry-driven entry list,
1044/// builds a typed context, and lets `minijinja` render
1045/// `admin/dashboard.html`.
1046///
1047/// `csrf_token` is rendered as a hidden input inside the header's
1048/// logout form (the only state-changing form on the dashboard). If
1049/// the template fails to render, falls back to a minimal inline
1050/// shell so the server never crashes on a bad override.
1051pub async fn dashboard_render(
1052    db: &Db,
1053    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1054    legacy_entries: &[crate::admin::AdminEntry],
1055    identity: Option<&crate::auth::Identity>,
1056    csrf_token: Option<&str>,
1057) -> String {
1058    // Cards come from two sources, in priority order:
1059    //   1. the new `AdminUiModel` registry (one entry per registered slug)
1060    //   2. the legacy `Admin::new().model::<T>()` path, filtered to
1061    //      non-`core` entries not already covered by source 1
1062    // Same dedup rule as `sidebar_merged` keeps a model registered
1063    // through both paths from appearing twice. Before this dual-source
1064    // build, the dashboard cards only reflected source 1 — every
1065    // `rustio new app`-scaffolded model was invisible at /admin.
1066    let new_entries = collect_dashboard_entries(db, registry).await;
1067    let known: std::collections::HashSet<&str> = new_entries.iter().map(|e| e.slug).collect();
1068    let legacy_dash = collect_legacy_dashboard_entries(db, legacy_entries, &known).await;
1069    let all_entries: Vec<&DashboardEntry> = new_entries.iter().chain(legacy_dash.iter()).collect();
1070
1071    let sidebar = sidebar_merged(&new_entries, legacy_entries, None);
1072    let cards: Vec<DashboardCardView> = all_entries
1073        .iter()
1074        .map(|e| DashboardCardView {
1075            label: format!("{}s", e.model_name),
1076            value: e.count,
1077        })
1078        .collect();
1079    let design = design_view();
1080    let user = user_view(identity);
1081
1082    let env = crate::admin::templating::env();
1083    match env.get_template("admin/dashboard.html").and_then(|tmpl| {
1084        tmpl.render(minijinja::context! {
1085            design => design,
1086            current_user => user,
1087            sidebar_entries => sidebar,
1088            dashboard_cards => cards,
1089            page_title => "Dashboard",
1090            csrf_token => csrf_token.unwrap_or(""),
1091            rustio_version => env!("CARGO_PKG_VERSION"),
1092        })
1093    }) {
1094        Ok(html) => html,
1095        Err(err) => {
1096            eprintln!("admin dashboard template render failed: {err}");
1097            // Fallback path also gets the combined list so a template
1098            // failure doesn't silently regress the bug we just fixed.
1099            let combined: Vec<DashboardEntry> =
1100                new_entries.into_iter().chain(legacy_dash).collect();
1101            dashboard_fallback(&combined)
1102        }
1103    }
1104}
1105
1106#[derive(serde::Serialize)]
1107struct ModelView {
1108    display_name: String,
1109    singular_name: String,
1110    new_url: String,
1111}
1112
1113#[derive(serde::Serialize)]
1114struct ColumnView {
1115    name: String,
1116    label: String,
1117    sortable: bool,
1118}
1119
1120#[derive(serde::Serialize)]
1121struct RowView {
1122    id: String,
1123    cells: Vec<String>,
1124    edit_url: String,
1125    delete_url: String,
1126}
1127
1128#[derive(serde::Serialize)]
1129struct PageLinkView {
1130    label: String,
1131    href: String,
1132    active: bool,
1133    disabled: bool,
1134}
1135
1136#[derive(serde::Serialize)]
1137struct PaginationView {
1138    pages: i64,
1139    current: i64,
1140    per_page: i64,
1141    total: i64,
1142    from: i64,
1143    to: i64,
1144    links: Vec<PageLinkView>,
1145}
1146
1147#[derive(serde::Serialize)]
1148struct ListPermissionsView {
1149    view: bool,
1150    create: bool,
1151    edit: bool,
1152    delete: bool,
1153}
1154
1155/// 0.10+ list-page renderer. Searchable / filter / sort / paginate
1156/// query runs through `fetch_users_table_state`; the page renders via
1157/// `minijinja`. Create / edit / delete actions are RBAC-gated by the
1158/// caller.
1159#[allow(clippy::too_many_arguments)]
1160pub async fn list_render(
1161    db: &Db,
1162    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1163    legacy_entries: &[crate::admin::AdminEntry],
1164    model: &dyn AdminUiModel,
1165    legacy_source: Option<&crate::admin::AdminEntry>,
1166    query: Option<&str>,
1167    page: i64,
1168    filters: &HashMap<String, String>,
1169    sort: Option<&str>,
1170    dir: Option<&str>,
1171    identity: Option<&crate::auth::Identity>,
1172    csrf_token: Option<&str>,
1173) -> String {
1174    if let Some(sql) = model.ensure_table_sql() {
1175        let _ = persistence::ensure_table(db, sql).await;
1176    }
1177
1178    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1179    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, Some(model.slug()));
1180
1181    let (rows_raw, total, current_page, total_pages, validated_sort, validated_dir) =
1182        fetch_users_table_state(db, model, query, filters, page, sort, dir).await;
1183
1184    let fields = model.fields();
1185    let columns: Vec<ColumnView> = fields
1186        .iter()
1187        .filter(|f| f.visible_in_table)
1188        .map(|f| ColumnView {
1189            name: f.name.to_string(),
1190            label: humanize_field_label(f.label),
1191            sortable: f.sortable,
1192        })
1193        .collect();
1194
1195    // One batch `SELECT … WHERE id IN (…)` per FK column visible on
1196    // this page of rows. Cells for matching FK values are rewritten
1197    // into `<a href="/admin/<target>/<id>">display</a>`. Unresolved
1198    // ids (stale, deleted, target wiped) render as `#<id>` — never
1199    // the raw integer with no context.
1200    let fk_lookups = build_fk_lookups(db, legacy_source, &columns, &rows_raw, legacy_entries).await;
1201
1202    let pk = model.primary_key();
1203    let slug = model.slug();
1204    // §4.8 — the first non-id column is the "primary" cell (bold name).
1205    let primary_col = columns
1206        .iter()
1207        .find(|c| c.name.as_str() != pk)
1208        .map(|c| c.name.clone());
1209    let rows: Vec<RowView> = rows_raw
1210        .iter()
1211        .map(|row| {
1212            let id = row.get(pk).cloned().unwrap_or_default();
1213            let cells = columns
1214                .iter()
1215                .enumerate()
1216                .map(|(col_idx, col)| {
1217                    let raw = row.get(&col.name).cloned().unwrap_or_default();
1218                    if col.name.as_str() == pk {
1219                        // §3.4 — ID column: rust mono `#<id>`.
1220                        if raw.is_empty() {
1221                            return String::new();
1222                        }
1223                        return format!(
1224                            r#"<span class="rio-cell-id">#{}</span>"#,
1225                            html_escape(&raw)
1226                        );
1227                    }
1228                    if let Some(fk) = fk_lookups.iter().find(|f| f.column_index == col_idx) {
1229                        // FK column: render as a clickable link to the
1230                        // target row (or `#<id>` if the target is gone).
1231                        if raw.is_empty() {
1232                            return String::new();
1233                        }
1234                        match fk.id_to_label.get(&raw) {
1235                            Some(label) => format!(
1236                                r#"<a href="/admin/{slug}/{id}">{label}</a>"#,
1237                                slug = html_escape(&fk.target_admin_name),
1238                                id = html_escape(&raw),
1239                                label = html_escape(label),
1240                            ),
1241                            None => format!("#{}", html_escape(&raw)),
1242                        }
1243                    } else if is_status_field_name(&col.name) {
1244                        // §3.5 — status-shaped column: vivid colour pill.
1245                        // Booleans ("0"/"1") normalise to Active/Inactive;
1246                        // string statuses keep their label. Empty → empty.
1247                        if raw.is_empty() {
1248                            return String::new();
1249                        }
1250                        let (data_value, label) = normalize_status_pill(&raw);
1251                        format!(
1252                            r#"<span class="{cls}">{label}</span>"#,
1253                            cls = status_pill_color(&data_value),
1254                            label = html_escape(&label),
1255                        )
1256                    } else if primary_col.as_deref() == Some(col.name.as_str()) {
1257                        // §4.8 — primary-name cell (bold).
1258                        format!(
1259                            r#"<span class="rio-cell-primary">{}</span>"#,
1260                            html_escape(&raw)
1261                        )
1262                    } else {
1263                        html_escape(&raw)
1264                    }
1265                })
1266                .collect();
1267            RowView {
1268                id: id.clone(),
1269                cells,
1270                edit_url: format!("/admin/{slug}/{id}/edit"),
1271                delete_url: format!("/admin/{slug}/{id}/delete"),
1272            }
1273        })
1274        .collect();
1275
1276    let pagination = build_pagination_view(
1277        slug,
1278        query,
1279        current_page,
1280        total_pages,
1281        total,
1282        &validated_sort,
1283        &validated_dir,
1284    );
1285
1286    let model_view = ModelView {
1287        display_name: format!("{}s", model.model_name()),
1288        singular_name: model.model_name().to_string(),
1289        new_url: format!("/admin/{slug}/new"),
1290    };
1291
1292    // Stage 4f-b: full CRUD wired. Gate each action on "user is
1293    // signed in" for now; per-model RBAC resolution lands in a
1294    // follow-up once the Role is surfaced in the request context.
1295    let signed_in = identity.is_some();
1296    let permissions = ListPermissionsView {
1297        view: true,
1298        create: signed_in,
1299        edit: signed_in,
1300        delete: signed_in,
1301    };
1302
1303    let design = design_view();
1304    let user = user_view(identity);
1305
1306    let env = crate::admin::templating::env();
1307    match env.get_template("admin/list.html").and_then(|tmpl| {
1308        tmpl.render(minijinja::context! {
1309            design => design,
1310            current_user => user,
1311            sidebar_entries => sidebar,
1312            model => model_view,
1313            columns => columns,
1314            rows => rows,
1315            total => total,
1316            pagination => pagination,
1317            permissions => permissions,
1318            page_title => format!("{}s", model.model_name()),
1319            query => query.unwrap_or(""),
1320            csrf_token => csrf_token.unwrap_or(""),
1321            rustio_version => env!("CARGO_PKG_VERSION"),
1322        })
1323    }) {
1324        Ok(html) => html,
1325        Err(err) => {
1326            eprintln!("admin list template render failed: {err}");
1327            list_fallback(model, &rows_raw, &columns)
1328        }
1329    }
1330}
1331
1332fn build_pagination_view(
1333    slug: &str,
1334    query: Option<&str>,
1335    current: i64,
1336    pages: i64,
1337    total: i64,
1338    sort: &Option<String>,
1339    dir: &Option<String>,
1340) -> PaginationView {
1341    // `fetch_users_table_state` uses PAGE_SIZE = 20; keep that here. If the
1342    // page-size constant ever moves, thread it through instead of copying.
1343    let per_page: i64 = 20;
1344    let from = if total == 0 {
1345        0
1346    } else {
1347        (current - 1) * per_page + 1
1348    };
1349    let to = (current * per_page).min(total).max(from);
1350    if pages <= 1 {
1351        return PaginationView {
1352            pages,
1353            current,
1354            per_page,
1355            total,
1356            from,
1357            to,
1358            links: Vec::new(),
1359        };
1360    }
1361    let q_param = query.unwrap_or("");
1362    let sort_param = sort.as_deref().unwrap_or("");
1363    let dir_param = dir.as_deref().unwrap_or("");
1364    let base_href = |p: i64| -> String {
1365        let mut parts = vec![format!("page={p}")];
1366        if !q_param.is_empty() {
1367            parts.push(format!("q={}", urlencode(q_param)));
1368        }
1369        if !sort_param.is_empty() {
1370            parts.push(format!("sort={sort_param}"));
1371        }
1372        if !dir_param.is_empty() {
1373            parts.push(format!("dir={dir_param}"));
1374        }
1375        format!("/admin/{slug}?{}", parts.join("&"))
1376    };
1377
1378    let mut links = Vec::with_capacity(pages as usize + 2);
1379    links.push(PageLinkView {
1380        label: "‹ Prev".into(),
1381        href: if current > 1 {
1382            base_href(current - 1)
1383        } else {
1384            "#".into()
1385        },
1386        active: false,
1387        disabled: current <= 1,
1388    });
1389    for p in 1..=pages {
1390        links.push(PageLinkView {
1391            label: p.to_string(),
1392            href: base_href(p),
1393            active: p == current,
1394            disabled: false,
1395        });
1396    }
1397    links.push(PageLinkView {
1398        label: "Next ›".into(),
1399        href: if current < pages {
1400            base_href(current + 1)
1401        } else {
1402            "#".into()
1403        },
1404        active: false,
1405        disabled: current >= pages,
1406    });
1407
1408    PaginationView {
1409        pages,
1410        current,
1411        per_page,
1412        total,
1413        from,
1414        to,
1415        links,
1416    }
1417}
1418
1419/// Minimal percent-encoding for pagination query params. Only covers
1420/// the subset of ASCII that needs escaping in a URL query value —
1421/// enough for search strings. Not a general-purpose encoder.
1422fn urlencode(s: &str) -> String {
1423    let mut out = String::with_capacity(s.len());
1424    for b in s.bytes() {
1425        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1426            out.push(b as char);
1427        } else {
1428            out.push_str(&format!("%{b:02X}"));
1429        }
1430    }
1431    out
1432}
1433
1434fn list_fallback(
1435    model: &dyn AdminUiModel,
1436    rows: &[HashMap<String, String>],
1437    columns: &[ColumnView],
1438) -> String {
1439    let mut out = format!(
1440        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{} - list</title></head><body style=\"font-family:system-ui\"><h1>{}s</h1><table border=\"1\" cellpadding=\"6\"><tr>",
1441        html_escape(model.model_name()),
1442        html_escape(model.model_name()),
1443    );
1444    for c in columns {
1445        out.push_str(&format!("<th>{}</th>", html_escape(&c.label)));
1446    }
1447    out.push_str("</tr>");
1448    for row in rows {
1449        out.push_str("<tr>");
1450        for c in columns {
1451            let v = row.get(&c.name).cloned().unwrap_or_default();
1452            out.push_str(&format!("<td>{}</td>", html_escape(&v)));
1453        }
1454        out.push_str("</tr>");
1455    }
1456    out.push_str("</table></body></html>");
1457    out
1458}
1459
1460#[derive(serde::Serialize)]
1461struct ProfileView {
1462    email: String,
1463    user_id: i64,
1464    role: String,
1465    is_active: bool,
1466}
1467
1468/// 0.10+ renderer for `GET /admin/profile`. Builds the merged
1469/// sidebar (same as dashboard / list) and renders
1470/// `admin/profile.html`.
1471pub async fn profile_render(
1472    db: &Db,
1473    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1474    legacy_entries: &[crate::admin::AdminEntry],
1475    identity: Option<&crate::auth::Identity>,
1476    user: Option<&crate::auth::User>,
1477    csrf_token: Option<&str>,
1478) -> String {
1479    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1480    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1481
1482    let profile = match user {
1483        Some(u) => ProfileView {
1484            email: u.email.clone(),
1485            user_id: u.id,
1486            role: u.role.clone(),
1487            is_active: u.is_active,
1488        },
1489        None => ProfileView {
1490            email: "unknown".into(),
1491            user_id: 0,
1492            role: "?".into(),
1493            is_active: false,
1494        },
1495    };
1496
1497    let design = design_view();
1498    let user_v = user_view(identity);
1499
1500    let env = crate::admin::templating::env();
1501    match env.get_template("admin/profile.html").and_then(|tmpl| {
1502        tmpl.render(minijinja::context! {
1503            design => design,
1504            current_user => user_v,
1505            sidebar_entries => sidebar,
1506            profile => profile,
1507            page_title => "Your account",
1508            csrf_token => csrf_token.unwrap_or(""),
1509            rustio_version => env!("CARGO_PKG_VERSION"),
1510        })
1511    }) {
1512        Ok(html) => html,
1513        Err(err) => {
1514            eprintln!("admin profile template render failed: {err}");
1515            format!(
1516                "<!doctype html><html><head><meta charset=\"utf-8\"><title>Your account</title></head><body><h1>Your account</h1><p>Email: {}</p><p><a href=\"/admin\">Back</a></p></body></html>",
1517                html_escape(&profile.email),
1518            )
1519        }
1520    }
1521}
1522
1523#[derive(serde::Serialize)]
1524struct ActionRowView {
1525    timestamp: String,
1526    user_email: Option<String>,
1527    action_type: String,
1528    model_name: String,
1529    object_id: i64,
1530    object_url: Option<String>,
1531    summary: String,
1532}
1533
1534#[derive(serde::Serialize)]
1535struct OptionView {
1536    value: String,
1537    label: String,
1538    selected: bool,
1539}
1540
1541/// Render `admin/actions.html` — the project-wide audit timeline.
1542#[allow(clippy::too_many_arguments)]
1543pub async fn actions_render(
1544    db: &Db,
1545    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1546    legacy_entries: &[crate::admin::AdminEntry],
1547    identity: Option<&crate::auth::Identity>,
1548    csrf_token: Option<&str>,
1549    actions: &[crate::admin::audit::AdminAction],
1550    model_filter: Option<&str>,
1551    action_filter: Option<&str>,
1552) -> String {
1553    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1554    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1555    let design = design_view();
1556    let user_v = user_view(identity);
1557
1558    let model_options: Vec<OptionView> = legacy_entries
1559        .iter()
1560        .filter(|e| !e.core)
1561        .map(|e| OptionView {
1562            value: e.admin_name.to_string(),
1563            label: e.display_name.to_string(),
1564            selected: model_filter == Some(e.admin_name),
1565        })
1566        .collect();
1567
1568    let action_options: Vec<OptionView> = [
1569        ("", "All actions"),
1570        ("create", "Created"),
1571        ("update", "Updated"),
1572        ("delete", "Deleted"),
1573    ]
1574    .into_iter()
1575    .map(|(v, l)| OptionView {
1576        value: v.to_string(),
1577        label: l.to_string(),
1578        selected: match v {
1579            "" => action_filter.is_none(),
1580            other => action_filter == Some(other),
1581        },
1582    })
1583    .collect();
1584
1585    let action_rows: Vec<ActionRowView> = actions
1586        .iter()
1587        .map(|a| {
1588            let object_url = legacy_entries
1589                .iter()
1590                .find(|e| e.singular_name == a.model_name || e.display_name == a.model_name)
1591                .map(|e| format!("/admin/{}/{}/edit", e.admin_name, a.object_id));
1592            ActionRowView {
1593                timestamp: a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string(),
1594                user_email: a.user_email.clone(),
1595                action_type: a.action_type.clone(),
1596                model_name: a.model_name.clone(),
1597                object_id: a.object_id,
1598                object_url,
1599                summary: a.summary.clone(),
1600            }
1601        })
1602        .collect();
1603
1604    let count_label = if actions.len() == 1 {
1605        "1 action".to_string()
1606    } else {
1607        format!("{} actions", actions.len())
1608    };
1609    let filters_active = model_filter.is_some() || action_filter.is_some();
1610
1611    let env = crate::admin::templating::env();
1612    match env.get_template("admin/actions.html").and_then(|tmpl| {
1613        tmpl.render(minijinja::context! {
1614            design => design,
1615            current_user => user_v,
1616            sidebar_entries => sidebar,
1617            page_title => "Recent actions",
1618            csrf_token => csrf_token.unwrap_or(""),
1619            rustio_version => env!("CARGO_PKG_VERSION"),
1620            actions => action_rows,
1621            model_options => model_options,
1622            action_options => action_options,
1623            filters_active => filters_active,
1624            count_label => count_label,
1625        })
1626    }) {
1627        Ok(html) => html,
1628        Err(err) => {
1629            eprintln!("admin actions template render failed: {err}");
1630            "<!doctype html><html><body><h1>Recent actions</h1><p>Template failed.</p></body></html>".into()
1631        }
1632    }
1633}
1634
1635#[derive(serde::Serialize)]
1636pub struct SuggestionReviewView {
1637    pub model: String,
1638    pub field: String,
1639    pub industry: String,
1640    pub confidence_label: String,
1641    pub confidence_class: String,
1642    pub apply_url: String,
1643    pub can_apply: bool,
1644    pub step_descriptions: Vec<String>,
1645    pub schema_diff_html: String,
1646    pub explanation: String,
1647    pub risk_label: String,
1648    pub risk_class: String,
1649    pub adds_fields: u32,
1650    pub destructive: bool,
1651    pub validation_ok: bool,
1652    pub validation_message: Option<String>,
1653    pub warnings: Vec<String>,
1654    pub error: Option<String>,
1655}
1656
1657#[derive(serde::Serialize)]
1658pub struct AppliedFileView {
1659    pub kind: String,
1660    pub path: String,
1661}
1662
1663#[derive(serde::Serialize)]
1664pub struct SuggestionAppliedView {
1665    pub change_lines: Vec<String>,
1666    pub files: Vec<AppliedFileView>,
1667}
1668
1669/// Render `admin/suggestion_review.html`. All AI-pipeline work
1670/// (planner, reviewer, diff, confidence) happens in the caller;
1671/// this function only lays out the values.
1672pub async fn suggestion_review_render(
1673    db: &Db,
1674    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1675    legacy_entries: &[crate::admin::AdminEntry],
1676    identity: Option<&crate::auth::Identity>,
1677    csrf_token: Option<&str>,
1678    view: SuggestionReviewView,
1679) -> String {
1680    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1681    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1682    let design = design_view();
1683    let user_v = user_view(identity);
1684    let env = crate::admin::templating::env();
1685    match env
1686        .get_template("admin/suggestion_review.html")
1687        .and_then(|tmpl| {
1688            tmpl.render(minijinja::context! {
1689                design => design,
1690                current_user => user_v,
1691                sidebar_entries => sidebar,
1692                page_title => format!("Review: add {} to {}", view.field, view.model),
1693                csrf_token => csrf_token.unwrap_or(""),
1694                rustio_version => env!("CARGO_PKG_VERSION"),
1695                view => view,
1696            })
1697        }) {
1698        Ok(html) => html,
1699        Err(err) => {
1700            eprintln!("admin suggestion_review template render failed: {err}");
1701            "<!doctype html><html><body><h1>Review suggestion</h1><p>Template failed.</p></body></html>".into()
1702        }
1703    }
1704}
1705
1706/// Render `admin/suggestion_applied.html` — success page after a
1707/// suggestion is applied.
1708pub async fn suggestion_applied_render(
1709    db: &Db,
1710    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1711    legacy_entries: &[crate::admin::AdminEntry],
1712    identity: Option<&crate::auth::Identity>,
1713    csrf_token: Option<&str>,
1714    applied: SuggestionAppliedView,
1715) -> String {
1716    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1717    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1718    let design = design_view();
1719    let user_v = user_view(identity);
1720    let env = crate::admin::templating::env();
1721    match env
1722        .get_template("admin/suggestion_applied.html")
1723        .and_then(|tmpl| {
1724            tmpl.render(minijinja::context! {
1725                design => design,
1726                current_user => user_v,
1727                sidebar_entries => sidebar,
1728                page_title => "Changes applied",
1729                csrf_token => csrf_token.unwrap_or(""),
1730                rustio_version => env!("CARGO_PKG_VERSION"),
1731                applied => applied,
1732            })
1733        }) {
1734        Ok(html) => html,
1735        Err(err) => {
1736            eprintln!("admin suggestion_applied template render failed: {err}");
1737            "<!doctype html><html><body><h1>Changes applied</h1><p>Template failed.</p></body></html>".into()
1738        }
1739    }
1740}
1741
1742/// Render `admin/password_change.html`. `error` shows as an alert
1743/// banner on top when the previous submit failed.
1744pub async fn password_change_render(
1745    db: &Db,
1746    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1747    legacy_entries: &[crate::admin::AdminEntry],
1748    identity: Option<&crate::auth::Identity>,
1749    csrf_token: Option<&str>,
1750    error: Option<&str>,
1751) -> String {
1752    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1753    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1754    let design = design_view();
1755    let user_v = user_view(identity);
1756    let env = crate::admin::templating::env();
1757    match env
1758        .get_template("admin/password_change.html")
1759        .and_then(|tmpl| {
1760            tmpl.render(minijinja::context! {
1761                design => design,
1762                current_user => user_v,
1763                sidebar_entries => sidebar,
1764                page_title => "Change password",
1765                csrf_token => csrf_token.unwrap_or(""),
1766                error => error,
1767                rustio_version => env!("CARGO_PKG_VERSION"),
1768            })
1769        }) {
1770        Ok(html) => html,
1771        Err(err) => {
1772            eprintln!("admin password_change template render failed: {err}");
1773            "<!doctype html><html><body><h1>Change password</h1><p>Template failed.</p></body></html>".into()
1774        }
1775    }
1776}
1777
1778/// Render `admin/password_change_done.html`.
1779pub async fn password_change_done_render(
1780    db: &Db,
1781    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1782    legacy_entries: &[crate::admin::AdminEntry],
1783    identity: Option<&crate::auth::Identity>,
1784    csrf_token: Option<&str>,
1785) -> String {
1786    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1787    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1788    let design = design_view();
1789    let user_v = user_view(identity);
1790    let env = crate::admin::templating::env();
1791    match env
1792        .get_template("admin/password_change_done.html")
1793        .and_then(|tmpl| {
1794            tmpl.render(minijinja::context! {
1795                design => design,
1796                current_user => user_v,
1797                sidebar_entries => sidebar,
1798                page_title => "Password changed",
1799                csrf_token => csrf_token.unwrap_or(""),
1800                rustio_version => env!("CARGO_PKG_VERSION"),
1801            })
1802        }) {
1803        Ok(html) => html,
1804        Err(err) => {
1805            eprintln!("admin password_change_done template render failed: {err}");
1806            "<!doctype html><html><body><h1>Password changed</h1><p><a href=\"/admin\">Back</a></p></body></html>".into()
1807        }
1808    }
1809}
1810
1811/// Heuristic for "does this column look like a status?" — used by
1812/// the list-page cell renderer to opt into the `badge-status` pill
1813/// styling via a `data-status=<value>` attribute. Name-based only;
1814/// the type check is implicit (`bool`s typically carry `is_` /
1815/// `has_` prefixes already).
1816///
1817/// Examples that match: `status`, `state`, `task_status`,
1818/// `is_active`, `is_published`, `has_paid`, `published`, `active`.
1819/// Examples that don't: `title`, `description`, `priority`,
1820/// `created_at`, `project_id`.
1821fn is_status_field_name(name: &str) -> bool {
1822    let n = name.to_lowercase();
1823    n == "status"
1824        || n == "state"
1825        || n == "active"
1826        || n == "published"
1827        || n.ends_with("_status")
1828        || n.ends_with("_state")
1829        || n.starts_with("is_")
1830        || n.starts_with("has_")
1831}
1832
1833/// Normalise a raw cell value for status rendering.
1834///
1835/// Returns `(data_status_value, display_label)`:
1836/// - `data_status_value` is the lowercased value placed in the
1837///   `data-status` attribute. The 0.10.x design system renders every
1838///   status uniformly in `--text-secondary` regardless of the value,
1839///   but the attribute is retained so a project can re-introduce
1840///   colour-coding via its own `templates/static/admin.css` override.
1841/// - `display_label` is the sentence-case text shown in the cell. The
1842///   visual spec mandates sentence case everywhere — never `TODO`,
1843///   never `In_Progress`. Underscores in the raw value are replaced
1844///   with spaces and only the first letter is upper-cased.
1845///
1846/// SQLite booleans round-trip as `"0"` / `"1"` strings through the
1847/// persistence layer; both are mapped to the readable `Active` /
1848/// `Inactive` labels.
1849/// Map a lowercased status value to a vivid pill class (v8 §3.5).
1850/// Three buckets: emerald (good / done), amber (pending / attention),
1851/// slate (inactive / closed). Unknown values fall back to slate.
1852fn status_pill_color(data_value: &str) -> &'static str {
1853    match data_value.trim() {
1854        "active" | "approved" | "published" | "live" | "completed" | "complete" | "done"
1855        | "finished" | "resolved" | "paid" => "rio-pill rio-pill-emerald",
1856        "referred" | "pending" | "todo" | "queued" | "open" | "new" | "scheduled" | "draft"
1857        | "sent" | "in progress" | "in review" | "review" | "overdue" | "on leave" => {
1858            "rio-pill rio-pill-amber"
1859        }
1860        _ => "rio-pill rio-pill-slate",
1861    }
1862}
1863
1864fn normalize_status_pill(raw: &str) -> (String, String) {
1865    let lc = raw.trim().to_lowercase();
1866    match lc.as_str() {
1867        "1" | "true" | "yes" | "on" => ("active".to_string(), "Active".to_string()),
1868        "0" | "false" | "no" | "off" => ("inactive".to_string(), "Inactive".to_string()),
1869        _ => (lc.clone(), humanize_status_label(raw)),
1870    }
1871}
1872
1873/// Turn a raw status value into a sentence-case display label:
1874/// `"in_progress"` → `"In progress"`, `"TODO"` → `"Todo"`,
1875/// `"done"` → `"Done"`. Underscores become spaces. Only the first
1876/// character is upper-cased; the rest stay lower-case (sentence case,
1877/// not Title Case).
1878fn humanize_status_label(raw: &str) -> String {
1879    let spaced = raw.trim().replace('_', " ").to_lowercase();
1880    let mut chars = spaced.chars();
1881    match chars.next() {
1882        None => String::new(),
1883        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1884    }
1885}
1886
1887/// Convert a database column name into a sentence-cased display label.
1888///
1889/// Rules (in order):
1890///   1. Empty input returns empty.
1891///   2. Bare `"id"` becomes `"ID"` — the conventional case for an
1892///      identifier column.
1893///   3. A label with no underscores whose first char is already
1894///      uppercase is treated as user-set (e.g. `"Username"` from
1895///      `AdminUiField { label: "Username", … }`) and passed through
1896///      unchanged so explicit labels aren't lowercased.
1897///   4. A trailing `"_id"` is stripped (`"project_id"` → `"project"`)
1898///      so foreign-key columns show the model name rather than the
1899///      column name.
1900///   5. Underscores become spaces, the whole label is lowercased,
1901///      then the first character is uppercased: `"due_at"` →
1902///      `"Due at"`, `"first_name"` → `"First name"`.
1903///
1904/// Idempotent — `humanize_field_label("Title") == "Title"`.
1905fn humanize_field_label(raw: &str) -> String {
1906    if raw == "id" {
1907        return "ID".to_string();
1908    }
1909    if !raw.contains('_') && raw.chars().next().is_some_and(|c| c.is_uppercase()) {
1910        return raw.to_string();
1911    }
1912    let stripped = raw.strip_suffix("_id").unwrap_or(raw);
1913    let spaced = stripped.replace('_', " ").to_lowercase();
1914    let mut chars = spaced.chars();
1915    match chars.next() {
1916        None => String::new(),
1917        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1918    }
1919}
1920
1921fn dashboard_fallback(entries: &[DashboardEntry]) -> String {
1922    let mut out = String::from(
1923        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Dashboard</title></head><body style=\"font-family:system-ui\"><h1>Dashboard</h1><ul>",
1924    );
1925    for e in entries {
1926        out.push_str(&format!(
1927            "<li><a href=\"/admin/{}\">{}</a> ({})</li>",
1928            html_escape(e.slug),
1929            html_escape(e.model_name),
1930            e.count
1931        ));
1932    }
1933    out.push_str("</ul></body></html>");
1934    out
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939    use super::*;
1940    use crate::admin::{AdminEntry, AdminField, FieldType};
1941
1942    /// Helper: build a minimal `AdminEntry` for the dashboard walker
1943    /// to chew on. Only the fields the walker actually reads are
1944    /// populated; the rest fall back to empty slices / defaults.
1945    fn entry(
1946        admin: &'static str,
1947        singular: &'static str,
1948        table: &'static str,
1949        core: bool,
1950    ) -> AdminEntry {
1951        const NO_FIELDS: &[AdminField] = &[AdminField {
1952            name: "id",
1953            ty: FieldType::I64,
1954            editable: false,
1955            nullable: false,
1956            relation: None,
1957        }];
1958        AdminEntry {
1959            admin_name: admin,
1960            display_name: singular,
1961            singular_name: singular,
1962            table,
1963            fields: NO_FIELDS,
1964            core,
1965        }
1966    }
1967
1968    #[tokio::test]
1969    async fn legacy_dashboard_walk_returns_one_entry_per_non_core_model() {
1970        let db = Db::memory().await.unwrap();
1971        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1972            .execute(db.pool())
1973            .await
1974            .unwrap();
1975        sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
1976            .execute(db.pool())
1977            .await
1978            .unwrap();
1979        sqlx::query("INSERT INTO projects DEFAULT VALUES")
1980            .execute(db.pool())
1981            .await
1982            .unwrap();
1983        sqlx::query("INSERT INTO projects DEFAULT VALUES")
1984            .execute(db.pool())
1985            .await
1986            .unwrap();
1987        sqlx::query("INSERT INTO tasks DEFAULT VALUES")
1988            .execute(db.pool())
1989            .await
1990            .unwrap();
1991
1992        let legacy = [
1993            entry("projects", "Project", "projects", false),
1994            entry("tasks", "Task", "tasks", false),
1995        ];
1996        let known = std::collections::HashSet::new();
1997
1998        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1999
2000        assert_eq!(got.len(), 2);
2001        assert_eq!(got[0].slug, "projects");
2002        assert_eq!(got[0].count, 2);
2003        assert_eq!(got[1].slug, "tasks");
2004        assert_eq!(got[1].count, 1);
2005    }
2006
2007    #[tokio::test]
2008    async fn legacy_dashboard_walk_skips_core_entries() {
2009        let db = Db::memory().await.unwrap();
2010        // No table needed — `core` filter should bail out before any SQL runs.
2011        let legacy = [
2012            entry("rustio_users", "User", "rustio_users", true),
2013            entry("projects", "Project", "projects", false),
2014        ];
2015        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2016            .execute(db.pool())
2017            .await
2018            .unwrap();
2019
2020        let known = std::collections::HashSet::new();
2021        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2022
2023        assert_eq!(got.len(), 1, "core entry should be skipped");
2024        assert_eq!(got[0].slug, "projects");
2025    }
2026
2027    #[tokio::test]
2028    async fn legacy_dashboard_walk_dedupes_against_already_listed_slugs() {
2029        let db = Db::memory().await.unwrap();
2030        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2031            .execute(db.pool())
2032            .await
2033            .unwrap();
2034        sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
2035            .execute(db.pool())
2036            .await
2037            .unwrap();
2038
2039        // Imagine the new-engine registry already covers `projects`.
2040        let mut known = std::collections::HashSet::new();
2041        known.insert("projects");
2042
2043        let legacy = [
2044            entry("projects", "Project", "projects", false),
2045            entry("tasks", "Task", "tasks", false),
2046        ];
2047
2048        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2049
2050        assert_eq!(got.len(), 1, "already-listed slug should be skipped");
2051        assert_eq!(got[0].slug, "tasks");
2052    }
2053
2054    #[tokio::test]
2055    async fn legacy_dashboard_walk_falls_back_to_zero_when_table_missing() {
2056        let db = Db::memory().await.unwrap();
2057        // No `CREATE TABLE` — the COUNT(*) will fail; the walker
2058        // should degrade to count=0 instead of erroring or panicking.
2059        let legacy = [entry("ghosts", "Ghost", "ghosts", false)];
2060        let known = std::collections::HashSet::new();
2061
2062        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2063
2064        assert_eq!(got.len(), 1);
2065        assert_eq!(got[0].count, 0);
2066    }
2067
2068    #[test]
2069    fn status_field_name_matches_known_patterns() {
2070        // Bare names
2071        assert!(is_status_field_name("status"));
2072        assert!(is_status_field_name("state"));
2073        assert!(is_status_field_name("active"));
2074        assert!(is_status_field_name("published"));
2075        // Case-insensitive
2076        assert!(is_status_field_name("Status"));
2077        assert!(is_status_field_name("STATE"));
2078        // Suffix patterns
2079        assert!(is_status_field_name("task_status"));
2080        assert!(is_status_field_name("order_state"));
2081        // Prefix patterns (booleans typically)
2082        assert!(is_status_field_name("is_active"));
2083        assert!(is_status_field_name("is_published"));
2084        assert!(is_status_field_name("has_paid"));
2085    }
2086
2087    #[test]
2088    fn status_field_name_rejects_non_status_columns() {
2089        // Title-like text columns
2090        assert!(!is_status_field_name("title"));
2091        assert!(!is_status_field_name("description"));
2092        assert!(!is_status_field_name("name"));
2093        // Numerics
2094        assert!(!is_status_field_name("priority"));
2095        assert!(!is_status_field_name("count"));
2096        // Timestamps
2097        assert!(!is_status_field_name("created_at"));
2098        assert!(!is_status_field_name("due_at"));
2099        // FK columns
2100        assert!(!is_status_field_name("project_id"));
2101        assert!(!is_status_field_name("user_id"));
2102        // Edge case: substring "status" inside a word should NOT match
2103        assert!(!is_status_field_name("statustown"));
2104        assert!(!is_status_field_name("estatus_id"));
2105    }
2106
2107    #[test]
2108    fn normalize_status_pill_maps_boolean_encodings() {
2109        // Truthy → active + "Active" label
2110        for raw in ["1", "true", "TRUE", " True ", "yes", "on"] {
2111            let (data, label) = normalize_status_pill(raw);
2112            assert_eq!(
2113                data, "active",
2114                "truthy raw {raw:?} should map to data=active"
2115            );
2116            assert_eq!(label, "Active", "truthy raw {raw:?} should label as Active");
2117        }
2118        // Falsy → inactive + "Inactive" label
2119        for raw in ["0", "false", "FALSE", "no", "off"] {
2120            let (data, label) = normalize_status_pill(raw);
2121            assert_eq!(
2122                data, "inactive",
2123                "falsy raw {raw:?} should map to data=inactive"
2124            );
2125            assert_eq!(
2126                label, "Inactive",
2127                "falsy raw {raw:?} should label as Inactive"
2128            );
2129        }
2130    }
2131
2132    #[test]
2133    fn normalize_status_pill_humanizes_string_statuses() {
2134        // String statuses get sentence-case labels — never SCREAMING,
2135        // never Title_Case. `data-status` stays lowercased for CSS
2136        // matchers in projects that re-introduce colour coding.
2137        let (data, label) = normalize_status_pill("In_Progress");
2138        assert_eq!(data, "in_progress");
2139        assert_eq!(label, "In progress");
2140
2141        let (data, label) = normalize_status_pill("DONE");
2142        assert_eq!(data, "done");
2143        assert_eq!(label, "Done");
2144
2145        let (data, label) = normalize_status_pill("todo");
2146        assert_eq!(data, "todo");
2147        assert_eq!(label, "Todo");
2148
2149        let (data, label) = normalize_status_pill("review");
2150        assert_eq!(data, "review");
2151        assert_eq!(label, "Review");
2152
2153        // Unknown value: still humanised the same way.
2154        let (data, label) = normalize_status_pill("custom_state");
2155        assert_eq!(data, "custom_state");
2156        assert_eq!(label, "Custom state");
2157    }
2158
2159    #[test]
2160    fn humanize_status_label_handles_edges() {
2161        assert_eq!(humanize_status_label(""), "");
2162        assert_eq!(humanize_status_label("a"), "A");
2163        assert_eq!(humanize_status_label(" trim "), "Trim");
2164        assert_eq!(
2165            humanize_status_label("multi_word_status"),
2166            "Multi word status"
2167        );
2168    }
2169
2170    #[test]
2171    fn humanize_field_label_cases() {
2172        assert_eq!(humanize_field_label(""), "");
2173        assert_eq!(humanize_field_label("id"), "ID");
2174        assert_eq!(humanize_field_label("title"), "Title");
2175        assert_eq!(humanize_field_label("project_id"), "Project");
2176        assert_eq!(humanize_field_label("user_id"), "User");
2177        assert_eq!(humanize_field_label("due_at"), "Due at");
2178        assert_eq!(humanize_field_label("created_at"), "Created at");
2179        assert_eq!(humanize_field_label("first_name"), "First name");
2180        // Already-cased labels (e.g. user-set via AdminUiField.label =
2181        // "Username") pass through unchanged.
2182        assert_eq!(humanize_field_label("Username"), "Username");
2183        assert_eq!(humanize_field_label("User ID"), "User ID");
2184        // Idempotent on a previous humanize output.
2185        assert_eq!(
2186            humanize_field_label(&humanize_field_label("due_at")),
2187            "Due at"
2188        );
2189    }
2190}