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    let rows: Vec<RowView> = rows_raw
1205        .iter()
1206        .map(|row| {
1207            let id = row.get(pk).cloned().unwrap_or_default();
1208            let cells = columns
1209                .iter()
1210                .enumerate()
1211                .map(|(col_idx, col)| {
1212                    let raw = row.get(&col.name).cloned().unwrap_or_default();
1213                    if let Some(fk) = fk_lookups.iter().find(|f| f.column_index == col_idx) {
1214                        // FK column: render as a clickable link to the
1215                        // target row (or `#<id>` if the target is gone).
1216                        if raw.is_empty() {
1217                            return String::new();
1218                        }
1219                        match fk.id_to_label.get(&raw) {
1220                            Some(label) => format!(
1221                                r#"<a href="/admin/{slug}/{id}">{label}</a>"#,
1222                                slug = html_escape(&fk.target_admin_name),
1223                                id = html_escape(&raw),
1224                                label = html_escape(label),
1225                            ),
1226                            None => format!("#{}", html_escape(&raw)),
1227                        }
1228                    } else if is_status_field_name(&col.name) {
1229                        // Status-shaped column: wrap in a pill badge that
1230                        // admin.css colours via `[data-status="<value>"]`.
1231                        // SQLite boolean columns return "0"/"1"; we
1232                        // normalise to "Active"/"Inactive" so the label
1233                        // reads cleanly AND admin.css can colour the
1234                        // pill via `[data-status="active|inactive"]`.
1235                        // String statuses (todo / in_progress / done /
1236                        // pending …) pass through with the raw value
1237                        // lowercased for the data-status attribute.
1238                        // Empty values render as empty cells, not pills.
1239                        if raw.is_empty() {
1240                            return String::new();
1241                        }
1242                        let (data_value, label) = normalize_status_pill(&raw);
1243                        format!(
1244                            r#"<span class="badge-status" data-status="{value}">{label}</span>"#,
1245                            value = html_escape(&data_value),
1246                            label = html_escape(&label),
1247                        )
1248                    } else {
1249                        html_escape(&raw)
1250                    }
1251                })
1252                .collect();
1253            RowView {
1254                id: id.clone(),
1255                cells,
1256                edit_url: format!("/admin/{slug}/{id}/edit"),
1257                delete_url: format!("/admin/{slug}/{id}/delete"),
1258            }
1259        })
1260        .collect();
1261
1262    let pagination = build_pagination_view(
1263        slug,
1264        query,
1265        current_page,
1266        total_pages,
1267        total,
1268        &validated_sort,
1269        &validated_dir,
1270    );
1271
1272    let model_view = ModelView {
1273        display_name: format!("{}s", model.model_name()),
1274        singular_name: model.model_name().to_string(),
1275        new_url: format!("/admin/{slug}/new"),
1276    };
1277
1278    // Stage 4f-b: full CRUD wired. Gate each action on "user is
1279    // signed in" for now; per-model RBAC resolution lands in a
1280    // follow-up once the Role is surfaced in the request context.
1281    let signed_in = identity.is_some();
1282    let permissions = ListPermissionsView {
1283        view: true,
1284        create: signed_in,
1285        edit: signed_in,
1286        delete: signed_in,
1287    };
1288
1289    let design = design_view();
1290    let user = user_view(identity);
1291
1292    let env = crate::admin::templating::env();
1293    match env.get_template("admin/list.html").and_then(|tmpl| {
1294        tmpl.render(minijinja::context! {
1295            design => design,
1296            current_user => user,
1297            sidebar_entries => sidebar,
1298            model => model_view,
1299            columns => columns,
1300            rows => rows,
1301            total => total,
1302            pagination => pagination,
1303            permissions => permissions,
1304            page_title => format!("{}s", model.model_name()),
1305            query => query.unwrap_or(""),
1306            csrf_token => csrf_token.unwrap_or(""),
1307            rustio_version => env!("CARGO_PKG_VERSION"),
1308        })
1309    }) {
1310        Ok(html) => html,
1311        Err(err) => {
1312            eprintln!("admin list template render failed: {err}");
1313            list_fallback(model, &rows_raw, &columns)
1314        }
1315    }
1316}
1317
1318fn build_pagination_view(
1319    slug: &str,
1320    query: Option<&str>,
1321    current: i64,
1322    pages: i64,
1323    total: i64,
1324    sort: &Option<String>,
1325    dir: &Option<String>,
1326) -> PaginationView {
1327    // `fetch_users_table_state` uses PAGE_SIZE = 20; keep that here. If the
1328    // page-size constant ever moves, thread it through instead of copying.
1329    let per_page: i64 = 20;
1330    let from = if total == 0 {
1331        0
1332    } else {
1333        (current - 1) * per_page + 1
1334    };
1335    let to = (current * per_page).min(total).max(from);
1336    if pages <= 1 {
1337        return PaginationView {
1338            pages,
1339            current,
1340            per_page,
1341            total,
1342            from,
1343            to,
1344            links: Vec::new(),
1345        };
1346    }
1347    let q_param = query.unwrap_or("");
1348    let sort_param = sort.as_deref().unwrap_or("");
1349    let dir_param = dir.as_deref().unwrap_or("");
1350    let base_href = |p: i64| -> String {
1351        let mut parts = vec![format!("page={p}")];
1352        if !q_param.is_empty() {
1353            parts.push(format!("q={}", urlencode(q_param)));
1354        }
1355        if !sort_param.is_empty() {
1356            parts.push(format!("sort={sort_param}"));
1357        }
1358        if !dir_param.is_empty() {
1359            parts.push(format!("dir={dir_param}"));
1360        }
1361        format!("/admin/{slug}?{}", parts.join("&"))
1362    };
1363
1364    let mut links = Vec::with_capacity(pages as usize + 2);
1365    links.push(PageLinkView {
1366        label: "‹ Prev".into(),
1367        href: if current > 1 {
1368            base_href(current - 1)
1369        } else {
1370            "#".into()
1371        },
1372        active: false,
1373        disabled: current <= 1,
1374    });
1375    for p in 1..=pages {
1376        links.push(PageLinkView {
1377            label: p.to_string(),
1378            href: base_href(p),
1379            active: p == current,
1380            disabled: false,
1381        });
1382    }
1383    links.push(PageLinkView {
1384        label: "Next ›".into(),
1385        href: if current < pages {
1386            base_href(current + 1)
1387        } else {
1388            "#".into()
1389        },
1390        active: false,
1391        disabled: current >= pages,
1392    });
1393
1394    PaginationView {
1395        pages,
1396        current,
1397        per_page,
1398        total,
1399        from,
1400        to,
1401        links,
1402    }
1403}
1404
1405/// Minimal percent-encoding for pagination query params. Only covers
1406/// the subset of ASCII that needs escaping in a URL query value —
1407/// enough for search strings. Not a general-purpose encoder.
1408fn urlencode(s: &str) -> String {
1409    let mut out = String::with_capacity(s.len());
1410    for b in s.bytes() {
1411        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1412            out.push(b as char);
1413        } else {
1414            out.push_str(&format!("%{b:02X}"));
1415        }
1416    }
1417    out
1418}
1419
1420fn list_fallback(
1421    model: &dyn AdminUiModel,
1422    rows: &[HashMap<String, String>],
1423    columns: &[ColumnView],
1424) -> String {
1425    let mut out = format!(
1426        "<!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>",
1427        html_escape(model.model_name()),
1428        html_escape(model.model_name()),
1429    );
1430    for c in columns {
1431        out.push_str(&format!("<th>{}</th>", html_escape(&c.label)));
1432    }
1433    out.push_str("</tr>");
1434    for row in rows {
1435        out.push_str("<tr>");
1436        for c in columns {
1437            let v = row.get(&c.name).cloned().unwrap_or_default();
1438            out.push_str(&format!("<td>{}</td>", html_escape(&v)));
1439        }
1440        out.push_str("</tr>");
1441    }
1442    out.push_str("</table></body></html>");
1443    out
1444}
1445
1446#[derive(serde::Serialize)]
1447struct ProfileView {
1448    email: String,
1449    user_id: i64,
1450    role: String,
1451    is_active: bool,
1452}
1453
1454/// 0.10+ renderer for `GET /admin/profile`. Builds the merged
1455/// sidebar (same as dashboard / list) and renders
1456/// `admin/profile.html`.
1457pub async fn profile_render(
1458    db: &Db,
1459    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1460    legacy_entries: &[crate::admin::AdminEntry],
1461    identity: Option<&crate::auth::Identity>,
1462    user: Option<&crate::auth::User>,
1463    csrf_token: Option<&str>,
1464) -> String {
1465    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1466    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1467
1468    let profile = match user {
1469        Some(u) => ProfileView {
1470            email: u.email.clone(),
1471            user_id: u.id,
1472            role: u.role.clone(),
1473            is_active: u.is_active,
1474        },
1475        None => ProfileView {
1476            email: "unknown".into(),
1477            user_id: 0,
1478            role: "?".into(),
1479            is_active: false,
1480        },
1481    };
1482
1483    let design = design_view();
1484    let user_v = user_view(identity);
1485
1486    let env = crate::admin::templating::env();
1487    match env.get_template("admin/profile.html").and_then(|tmpl| {
1488        tmpl.render(minijinja::context! {
1489            design => design,
1490            current_user => user_v,
1491            sidebar_entries => sidebar,
1492            profile => profile,
1493            page_title => "Your account",
1494            csrf_token => csrf_token.unwrap_or(""),
1495            rustio_version => env!("CARGO_PKG_VERSION"),
1496        })
1497    }) {
1498        Ok(html) => html,
1499        Err(err) => {
1500            eprintln!("admin profile template render failed: {err}");
1501            format!(
1502                "<!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>",
1503                html_escape(&profile.email),
1504            )
1505        }
1506    }
1507}
1508
1509#[derive(serde::Serialize)]
1510struct ActionRowView {
1511    timestamp: String,
1512    user_email: Option<String>,
1513    action_type: String,
1514    model_name: String,
1515    object_id: i64,
1516    object_url: Option<String>,
1517    summary: String,
1518}
1519
1520#[derive(serde::Serialize)]
1521struct OptionView {
1522    value: String,
1523    label: String,
1524    selected: bool,
1525}
1526
1527/// Render `admin/actions.html` — the project-wide audit timeline.
1528#[allow(clippy::too_many_arguments)]
1529pub async fn actions_render(
1530    db: &Db,
1531    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1532    legacy_entries: &[crate::admin::AdminEntry],
1533    identity: Option<&crate::auth::Identity>,
1534    csrf_token: Option<&str>,
1535    actions: &[crate::admin::audit::AdminAction],
1536    model_filter: Option<&str>,
1537    action_filter: Option<&str>,
1538) -> String {
1539    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1540    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1541    let design = design_view();
1542    let user_v = user_view(identity);
1543
1544    let model_options: Vec<OptionView> = legacy_entries
1545        .iter()
1546        .filter(|e| !e.core)
1547        .map(|e| OptionView {
1548            value: e.admin_name.to_string(),
1549            label: e.display_name.to_string(),
1550            selected: model_filter == Some(e.admin_name),
1551        })
1552        .collect();
1553
1554    let action_options: Vec<OptionView> = [
1555        ("", "All actions"),
1556        ("create", "Created"),
1557        ("update", "Updated"),
1558        ("delete", "Deleted"),
1559    ]
1560    .into_iter()
1561    .map(|(v, l)| OptionView {
1562        value: v.to_string(),
1563        label: l.to_string(),
1564        selected: match v {
1565            "" => action_filter.is_none(),
1566            other => action_filter == Some(other),
1567        },
1568    })
1569    .collect();
1570
1571    let action_rows: Vec<ActionRowView> = actions
1572        .iter()
1573        .map(|a| {
1574            let object_url = legacy_entries
1575                .iter()
1576                .find(|e| e.singular_name == a.model_name || e.display_name == a.model_name)
1577                .map(|e| format!("/admin/{}/{}/edit", e.admin_name, a.object_id));
1578            ActionRowView {
1579                timestamp: a.timestamp.format("%Y-%m-%d %H:%M UTC").to_string(),
1580                user_email: a.user_email.clone(),
1581                action_type: a.action_type.clone(),
1582                model_name: a.model_name.clone(),
1583                object_id: a.object_id,
1584                object_url,
1585                summary: a.summary.clone(),
1586            }
1587        })
1588        .collect();
1589
1590    let count_label = if actions.len() == 1 {
1591        "1 action".to_string()
1592    } else {
1593        format!("{} actions", actions.len())
1594    };
1595    let filters_active = model_filter.is_some() || action_filter.is_some();
1596
1597    let env = crate::admin::templating::env();
1598    match env.get_template("admin/actions.html").and_then(|tmpl| {
1599        tmpl.render(minijinja::context! {
1600            design => design,
1601            current_user => user_v,
1602            sidebar_entries => sidebar,
1603            page_title => "Recent actions",
1604            csrf_token => csrf_token.unwrap_or(""),
1605            rustio_version => env!("CARGO_PKG_VERSION"),
1606            actions => action_rows,
1607            model_options => model_options,
1608            action_options => action_options,
1609            filters_active => filters_active,
1610            count_label => count_label,
1611        })
1612    }) {
1613        Ok(html) => html,
1614        Err(err) => {
1615            eprintln!("admin actions template render failed: {err}");
1616            "<!doctype html><html><body><h1>Recent actions</h1><p>Template failed.</p></body></html>".into()
1617        }
1618    }
1619}
1620
1621#[derive(serde::Serialize)]
1622pub struct SuggestionReviewView {
1623    pub model: String,
1624    pub field: String,
1625    pub industry: String,
1626    pub confidence_label: String,
1627    pub confidence_class: String,
1628    pub apply_url: String,
1629    pub can_apply: bool,
1630    pub step_descriptions: Vec<String>,
1631    pub schema_diff_html: String,
1632    pub explanation: String,
1633    pub risk_label: String,
1634    pub risk_class: String,
1635    pub adds_fields: u32,
1636    pub destructive: bool,
1637    pub validation_ok: bool,
1638    pub validation_message: Option<String>,
1639    pub warnings: Vec<String>,
1640    pub error: Option<String>,
1641}
1642
1643#[derive(serde::Serialize)]
1644pub struct AppliedFileView {
1645    pub kind: String,
1646    pub path: String,
1647}
1648
1649#[derive(serde::Serialize)]
1650pub struct SuggestionAppliedView {
1651    pub change_lines: Vec<String>,
1652    pub files: Vec<AppliedFileView>,
1653}
1654
1655/// Render `admin/suggestion_review.html`. All AI-pipeline work
1656/// (planner, reviewer, diff, confidence) happens in the caller;
1657/// this function only lays out the values.
1658pub async fn suggestion_review_render(
1659    db: &Db,
1660    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1661    legacy_entries: &[crate::admin::AdminEntry],
1662    identity: Option<&crate::auth::Identity>,
1663    csrf_token: Option<&str>,
1664    view: SuggestionReviewView,
1665) -> String {
1666    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1667    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1668    let design = design_view();
1669    let user_v = user_view(identity);
1670    let env = crate::admin::templating::env();
1671    match env
1672        .get_template("admin/suggestion_review.html")
1673        .and_then(|tmpl| {
1674            tmpl.render(minijinja::context! {
1675                design => design,
1676                current_user => user_v,
1677                sidebar_entries => sidebar,
1678                page_title => format!("Review: add {} to {}", view.field, view.model),
1679                csrf_token => csrf_token.unwrap_or(""),
1680                rustio_version => env!("CARGO_PKG_VERSION"),
1681                view => view,
1682            })
1683        }) {
1684        Ok(html) => html,
1685        Err(err) => {
1686            eprintln!("admin suggestion_review template render failed: {err}");
1687            "<!doctype html><html><body><h1>Review suggestion</h1><p>Template failed.</p></body></html>".into()
1688        }
1689    }
1690}
1691
1692/// Render `admin/suggestion_applied.html` — success page after a
1693/// suggestion is applied.
1694pub async fn suggestion_applied_render(
1695    db: &Db,
1696    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1697    legacy_entries: &[crate::admin::AdminEntry],
1698    identity: Option<&crate::auth::Identity>,
1699    csrf_token: Option<&str>,
1700    applied: SuggestionAppliedView,
1701) -> String {
1702    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1703    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1704    let design = design_view();
1705    let user_v = user_view(identity);
1706    let env = crate::admin::templating::env();
1707    match env
1708        .get_template("admin/suggestion_applied.html")
1709        .and_then(|tmpl| {
1710            tmpl.render(minijinja::context! {
1711                design => design,
1712                current_user => user_v,
1713                sidebar_entries => sidebar,
1714                page_title => "Changes applied",
1715                csrf_token => csrf_token.unwrap_or(""),
1716                rustio_version => env!("CARGO_PKG_VERSION"),
1717                applied => applied,
1718            })
1719        }) {
1720        Ok(html) => html,
1721        Err(err) => {
1722            eprintln!("admin suggestion_applied template render failed: {err}");
1723            "<!doctype html><html><body><h1>Changes applied</h1><p>Template failed.</p></body></html>".into()
1724        }
1725    }
1726}
1727
1728/// Render `admin/password_change.html`. `error` shows as an alert
1729/// banner on top when the previous submit failed.
1730pub async fn password_change_render(
1731    db: &Db,
1732    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1733    legacy_entries: &[crate::admin::AdminEntry],
1734    identity: Option<&crate::auth::Identity>,
1735    csrf_token: Option<&str>,
1736    error: Option<&str>,
1737) -> String {
1738    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1739    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1740    let design = design_view();
1741    let user_v = user_view(identity);
1742    let env = crate::admin::templating::env();
1743    match env
1744        .get_template("admin/password_change.html")
1745        .and_then(|tmpl| {
1746            tmpl.render(minijinja::context! {
1747                design => design,
1748                current_user => user_v,
1749                sidebar_entries => sidebar,
1750                page_title => "Change password",
1751                csrf_token => csrf_token.unwrap_or(""),
1752                error => error,
1753                rustio_version => env!("CARGO_PKG_VERSION"),
1754            })
1755        }) {
1756        Ok(html) => html,
1757        Err(err) => {
1758            eprintln!("admin password_change template render failed: {err}");
1759            "<!doctype html><html><body><h1>Change password</h1><p>Template failed.</p></body></html>".into()
1760        }
1761    }
1762}
1763
1764/// Render `admin/password_change_done.html`.
1765pub async fn password_change_done_render(
1766    db: &Db,
1767    registry: &crate::admin::admin_form_bridge::AdminRegistry,
1768    legacy_entries: &[crate::admin::AdminEntry],
1769    identity: Option<&crate::auth::Identity>,
1770    csrf_token: Option<&str>,
1771) -> String {
1772    let dashboard_entries = collect_dashboard_entries(db, registry).await;
1773    let sidebar = sidebar_merged(&dashboard_entries, legacy_entries, None);
1774    let design = design_view();
1775    let user_v = user_view(identity);
1776    let env = crate::admin::templating::env();
1777    match env
1778        .get_template("admin/password_change_done.html")
1779        .and_then(|tmpl| {
1780            tmpl.render(minijinja::context! {
1781                design => design,
1782                current_user => user_v,
1783                sidebar_entries => sidebar,
1784                page_title => "Password changed",
1785                csrf_token => csrf_token.unwrap_or(""),
1786                rustio_version => env!("CARGO_PKG_VERSION"),
1787            })
1788        }) {
1789        Ok(html) => html,
1790        Err(err) => {
1791            eprintln!("admin password_change_done template render failed: {err}");
1792            "<!doctype html><html><body><h1>Password changed</h1><p><a href=\"/admin\">Back</a></p></body></html>".into()
1793        }
1794    }
1795}
1796
1797/// Heuristic for "does this column look like a status?" — used by
1798/// the list-page cell renderer to opt into the `badge-status` pill
1799/// styling via a `data-status=<value>` attribute. Name-based only;
1800/// the type check is implicit (`bool`s typically carry `is_` /
1801/// `has_` prefixes already).
1802///
1803/// Examples that match: `status`, `state`, `task_status`,
1804/// `is_active`, `is_published`, `has_paid`, `published`, `active`.
1805/// Examples that don't: `title`, `description`, `priority`,
1806/// `created_at`, `project_id`.
1807fn is_status_field_name(name: &str) -> bool {
1808    let n = name.to_lowercase();
1809    n == "status"
1810        || n == "state"
1811        || n == "active"
1812        || n == "published"
1813        || n.ends_with("_status")
1814        || n.ends_with("_state")
1815        || n.starts_with("is_")
1816        || n.starts_with("has_")
1817}
1818
1819/// Normalise a raw cell value for status rendering.
1820///
1821/// Returns `(data_status_value, display_label)`:
1822/// - `data_status_value` is the lowercased value placed in the
1823///   `data-status` attribute. The 0.10.x design system renders every
1824///   status uniformly in `--text-secondary` regardless of the value,
1825///   but the attribute is retained so a project can re-introduce
1826///   colour-coding via its own `templates/static/admin.css` override.
1827/// - `display_label` is the sentence-case text shown in the cell. The
1828///   visual spec mandates sentence case everywhere — never `TODO`,
1829///   never `In_Progress`. Underscores in the raw value are replaced
1830///   with spaces and only the first letter is upper-cased.
1831///
1832/// SQLite booleans round-trip as `"0"` / `"1"` strings through the
1833/// persistence layer; both are mapped to the readable `Active` /
1834/// `Inactive` labels.
1835fn normalize_status_pill(raw: &str) -> (String, String) {
1836    let lc = raw.trim().to_lowercase();
1837    match lc.as_str() {
1838        "1" | "true" | "yes" | "on" => ("active".to_string(), "Active".to_string()),
1839        "0" | "false" | "no" | "off" => ("inactive".to_string(), "Inactive".to_string()),
1840        _ => (lc.clone(), humanize_status_label(raw)),
1841    }
1842}
1843
1844/// Turn a raw status value into a sentence-case display label:
1845/// `"in_progress"` → `"In progress"`, `"TODO"` → `"Todo"`,
1846/// `"done"` → `"Done"`. Underscores become spaces. Only the first
1847/// character is upper-cased; the rest stay lower-case (sentence case,
1848/// not Title Case).
1849fn humanize_status_label(raw: &str) -> String {
1850    let spaced = raw.trim().replace('_', " ").to_lowercase();
1851    let mut chars = spaced.chars();
1852    match chars.next() {
1853        None => String::new(),
1854        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1855    }
1856}
1857
1858/// Convert a database column name into a sentence-cased display label.
1859///
1860/// Rules (in order):
1861///   1. Empty input returns empty.
1862///   2. Bare `"id"` becomes `"ID"` — the conventional case for an
1863///      identifier column.
1864///   3. A label with no underscores whose first char is already
1865///      uppercase is treated as user-set (e.g. `"Username"` from
1866///      `AdminUiField { label: "Username", … }`) and passed through
1867///      unchanged so explicit labels aren't lowercased.
1868///   4. A trailing `"_id"` is stripped (`"project_id"` → `"project"`)
1869///      so foreign-key columns show the model name rather than the
1870///      column name.
1871///   5. Underscores become spaces, the whole label is lowercased,
1872///      then the first character is uppercased: `"due_at"` →
1873///      `"Due at"`, `"first_name"` → `"First name"`.
1874///
1875/// Idempotent — `humanize_field_label("Title") == "Title"`.
1876fn humanize_field_label(raw: &str) -> String {
1877    if raw == "id" {
1878        return "ID".to_string();
1879    }
1880    if !raw.contains('_') && raw.chars().next().is_some_and(|c| c.is_uppercase()) {
1881        return raw.to_string();
1882    }
1883    let stripped = raw.strip_suffix("_id").unwrap_or(raw);
1884    let spaced = stripped.replace('_', " ").to_lowercase();
1885    let mut chars = spaced.chars();
1886    match chars.next() {
1887        None => String::new(),
1888        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1889    }
1890}
1891
1892fn dashboard_fallback(entries: &[DashboardEntry]) -> String {
1893    let mut out = String::from(
1894        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Dashboard</title></head><body style=\"font-family:system-ui\"><h1>Dashboard</h1><ul>",
1895    );
1896    for e in entries {
1897        out.push_str(&format!(
1898            "<li><a href=\"/admin/{}\">{}</a> ({})</li>",
1899            html_escape(e.slug),
1900            html_escape(e.model_name),
1901            e.count
1902        ));
1903    }
1904    out.push_str("</ul></body></html>");
1905    out
1906}
1907
1908#[cfg(test)]
1909mod tests {
1910    use super::*;
1911    use crate::admin::{AdminEntry, AdminField, FieldType};
1912
1913    /// Helper: build a minimal `AdminEntry` for the dashboard walker
1914    /// to chew on. Only the fields the walker actually reads are
1915    /// populated; the rest fall back to empty slices / defaults.
1916    fn entry(
1917        admin: &'static str,
1918        singular: &'static str,
1919        table: &'static str,
1920        core: bool,
1921    ) -> AdminEntry {
1922        const NO_FIELDS: &[AdminField] = &[AdminField {
1923            name: "id",
1924            ty: FieldType::I64,
1925            editable: false,
1926            nullable: false,
1927            relation: None,
1928        }];
1929        AdminEntry {
1930            admin_name: admin,
1931            display_name: singular,
1932            singular_name: singular,
1933            table,
1934            fields: NO_FIELDS,
1935            core,
1936        }
1937    }
1938
1939    #[tokio::test]
1940    async fn legacy_dashboard_walk_returns_one_entry_per_non_core_model() {
1941        let db = Db::memory().await.unwrap();
1942        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1943            .execute(db.pool())
1944            .await
1945            .unwrap();
1946        sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
1947            .execute(db.pool())
1948            .await
1949            .unwrap();
1950        sqlx::query("INSERT INTO projects DEFAULT VALUES")
1951            .execute(db.pool())
1952            .await
1953            .unwrap();
1954        sqlx::query("INSERT INTO projects DEFAULT VALUES")
1955            .execute(db.pool())
1956            .await
1957            .unwrap();
1958        sqlx::query("INSERT INTO tasks DEFAULT VALUES")
1959            .execute(db.pool())
1960            .await
1961            .unwrap();
1962
1963        let legacy = [
1964            entry("projects", "Project", "projects", false),
1965            entry("tasks", "Task", "tasks", false),
1966        ];
1967        let known = std::collections::HashSet::new();
1968
1969        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1970
1971        assert_eq!(got.len(), 2);
1972        assert_eq!(got[0].slug, "projects");
1973        assert_eq!(got[0].count, 2);
1974        assert_eq!(got[1].slug, "tasks");
1975        assert_eq!(got[1].count, 1);
1976    }
1977
1978    #[tokio::test]
1979    async fn legacy_dashboard_walk_skips_core_entries() {
1980        let db = Db::memory().await.unwrap();
1981        // No table needed — `core` filter should bail out before any SQL runs.
1982        let legacy = [
1983            entry("rustio_users", "User", "rustio_users", true),
1984            entry("projects", "Project", "projects", false),
1985        ];
1986        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
1987            .execute(db.pool())
1988            .await
1989            .unwrap();
1990
1991        let known = std::collections::HashSet::new();
1992        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
1993
1994        assert_eq!(got.len(), 1, "core entry should be skipped");
1995        assert_eq!(got[0].slug, "projects");
1996    }
1997
1998    #[tokio::test]
1999    async fn legacy_dashboard_walk_dedupes_against_already_listed_slugs() {
2000        let db = Db::memory().await.unwrap();
2001        sqlx::query("CREATE TABLE projects (id INTEGER PRIMARY KEY)")
2002            .execute(db.pool())
2003            .await
2004            .unwrap();
2005        sqlx::query("CREATE TABLE tasks (id INTEGER PRIMARY KEY)")
2006            .execute(db.pool())
2007            .await
2008            .unwrap();
2009
2010        // Imagine the new-engine registry already covers `projects`.
2011        let mut known = std::collections::HashSet::new();
2012        known.insert("projects");
2013
2014        let legacy = [
2015            entry("projects", "Project", "projects", false),
2016            entry("tasks", "Task", "tasks", false),
2017        ];
2018
2019        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2020
2021        assert_eq!(got.len(), 1, "already-listed slug should be skipped");
2022        assert_eq!(got[0].slug, "tasks");
2023    }
2024
2025    #[tokio::test]
2026    async fn legacy_dashboard_walk_falls_back_to_zero_when_table_missing() {
2027        let db = Db::memory().await.unwrap();
2028        // No `CREATE TABLE` — the COUNT(*) will fail; the walker
2029        // should degrade to count=0 instead of erroring or panicking.
2030        let legacy = [entry("ghosts", "Ghost", "ghosts", false)];
2031        let known = std::collections::HashSet::new();
2032
2033        let got = collect_legacy_dashboard_entries(&db, &legacy, &known).await;
2034
2035        assert_eq!(got.len(), 1);
2036        assert_eq!(got[0].count, 0);
2037    }
2038
2039    #[test]
2040    fn status_field_name_matches_known_patterns() {
2041        // Bare names
2042        assert!(is_status_field_name("status"));
2043        assert!(is_status_field_name("state"));
2044        assert!(is_status_field_name("active"));
2045        assert!(is_status_field_name("published"));
2046        // Case-insensitive
2047        assert!(is_status_field_name("Status"));
2048        assert!(is_status_field_name("STATE"));
2049        // Suffix patterns
2050        assert!(is_status_field_name("task_status"));
2051        assert!(is_status_field_name("order_state"));
2052        // Prefix patterns (booleans typically)
2053        assert!(is_status_field_name("is_active"));
2054        assert!(is_status_field_name("is_published"));
2055        assert!(is_status_field_name("has_paid"));
2056    }
2057
2058    #[test]
2059    fn status_field_name_rejects_non_status_columns() {
2060        // Title-like text columns
2061        assert!(!is_status_field_name("title"));
2062        assert!(!is_status_field_name("description"));
2063        assert!(!is_status_field_name("name"));
2064        // Numerics
2065        assert!(!is_status_field_name("priority"));
2066        assert!(!is_status_field_name("count"));
2067        // Timestamps
2068        assert!(!is_status_field_name("created_at"));
2069        assert!(!is_status_field_name("due_at"));
2070        // FK columns
2071        assert!(!is_status_field_name("project_id"));
2072        assert!(!is_status_field_name("user_id"));
2073        // Edge case: substring "status" inside a word should NOT match
2074        assert!(!is_status_field_name("statustown"));
2075        assert!(!is_status_field_name("estatus_id"));
2076    }
2077
2078    #[test]
2079    fn normalize_status_pill_maps_boolean_encodings() {
2080        // Truthy → active + "Active" label
2081        for raw in ["1", "true", "TRUE", " True ", "yes", "on"] {
2082            let (data, label) = normalize_status_pill(raw);
2083            assert_eq!(
2084                data, "active",
2085                "truthy raw {raw:?} should map to data=active"
2086            );
2087            assert_eq!(label, "Active", "truthy raw {raw:?} should label as Active");
2088        }
2089        // Falsy → inactive + "Inactive" label
2090        for raw in ["0", "false", "FALSE", "no", "off"] {
2091            let (data, label) = normalize_status_pill(raw);
2092            assert_eq!(
2093                data, "inactive",
2094                "falsy raw {raw:?} should map to data=inactive"
2095            );
2096            assert_eq!(
2097                label, "Inactive",
2098                "falsy raw {raw:?} should label as Inactive"
2099            );
2100        }
2101    }
2102
2103    #[test]
2104    fn normalize_status_pill_humanizes_string_statuses() {
2105        // String statuses get sentence-case labels — never SCREAMING,
2106        // never Title_Case. `data-status` stays lowercased for CSS
2107        // matchers in projects that re-introduce colour coding.
2108        let (data, label) = normalize_status_pill("In_Progress");
2109        assert_eq!(data, "in_progress");
2110        assert_eq!(label, "In progress");
2111
2112        let (data, label) = normalize_status_pill("DONE");
2113        assert_eq!(data, "done");
2114        assert_eq!(label, "Done");
2115
2116        let (data, label) = normalize_status_pill("todo");
2117        assert_eq!(data, "todo");
2118        assert_eq!(label, "Todo");
2119
2120        let (data, label) = normalize_status_pill("review");
2121        assert_eq!(data, "review");
2122        assert_eq!(label, "Review");
2123
2124        // Unknown value: still humanised the same way.
2125        let (data, label) = normalize_status_pill("custom_state");
2126        assert_eq!(data, "custom_state");
2127        assert_eq!(label, "Custom state");
2128    }
2129
2130    #[test]
2131    fn humanize_status_label_handles_edges() {
2132        assert_eq!(humanize_status_label(""), "");
2133        assert_eq!(humanize_status_label("a"), "A");
2134        assert_eq!(humanize_status_label(" trim "), "Trim");
2135        assert_eq!(
2136            humanize_status_label("multi_word_status"),
2137            "Multi word status"
2138        );
2139    }
2140
2141    #[test]
2142    fn humanize_field_label_cases() {
2143        assert_eq!(humanize_field_label(""), "");
2144        assert_eq!(humanize_field_label("id"), "ID");
2145        assert_eq!(humanize_field_label("title"), "Title");
2146        assert_eq!(humanize_field_label("project_id"), "Project");
2147        assert_eq!(humanize_field_label("user_id"), "User");
2148        assert_eq!(humanize_field_label("due_at"), "Due at");
2149        assert_eq!(humanize_field_label("created_at"), "Created at");
2150        assert_eq!(humanize_field_label("first_name"), "First name");
2151        // Already-cased labels (e.g. user-set via AdminUiField.label =
2152        // "Username") pass through unchanged.
2153        assert_eq!(humanize_field_label("Username"), "Username");
2154        assert_eq!(humanize_field_label("User ID"), "User ID");
2155        // Idempotent on a previous humanize output.
2156        assert_eq!(
2157            humanize_field_label(&humanize_field_label("due_at")),
2158            "Due at"
2159        );
2160    }
2161}