Skip to main content

autumn_admin_plugin/
traits.rs

1//! Core traits that models implement to participate in the admin panel.
2
3use std::future::Future;
4use std::pin::Pin;
5
6use chrono::{DateTime, Utc};
7use serde::Deserialize;
8use serde_json::Value;
9
10// ── Field metadata ──────────────────────────────────────────────────
11
12/// The kind of a model field, used to select the appropriate form widget.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum AdminFieldKind {
15    /// Single-line text input.
16    Text,
17    /// Multi-line textarea.
18    TextArea,
19    /// Integer input.
20    Integer,
21    /// Floating-point input.
22    Float,
23    /// Boolean checkbox.
24    Boolean,
25    /// Date picker (no time).
26    Date,
27    /// Date + time picker.
28    DateTime,
29    /// Select dropdown with fixed choices.
30    Select(Vec<SelectOption>),
31    /// Hidden field (shown in detail, not editable).
32    Hidden,
33    /// Password field (write-only, never displayed).
34    Password,
35    /// JSON editor.
36    Json,
37}
38
39impl AdminFieldKind {
40    /// Whether an empty submitted string on a **nullable** field of this kind
41    /// should be coerced to `NULL`.
42    ///
43    /// Text-routed columns (`String`/`Uuid`/`Enum`/`Decimal` all map to
44    /// [`AdminFieldKind::Text`]) join the numeric/date kinds here so that a
45    /// blank submission on a nullable column clears to `NULL` instead of
46    /// storing an empty string (which fails validation for nullable
47    /// Uuid/decimal columns and silently stores `""` for strings).
48    ///
49    /// `Json` joins them: an empty submission can't be parsed by
50    /// `serde_json::from_str`, so without this it would silently persist the
51    /// raw `""` rather than clearing a nullable JSON column to `NULL`.
52    ///
53    /// `Boolean`/`Hidden`/`Password` are excluded: their empty submissions are
54    /// handled elsewhere or are not meaningfully "blank".
55    #[must_use]
56    pub const fn blank_submission_is_null(&self) -> bool {
57        matches!(
58            self,
59            Self::Integer
60                | Self::Float
61                | Self::Date
62                | Self::DateTime
63                | Self::Text
64                | Self::TextArea
65                | Self::Select(_)
66                | Self::Json
67        )
68    }
69}
70
71/// A single option in a [`AdminFieldKind::Select`] dropdown.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct SelectOption {
74    pub value: String,
75    pub label: String,
76}
77
78/// Metadata for a single model field.
79#[derive(Debug, Clone)]
80#[allow(clippy::struct_excessive_bools)] // orthogonal flags on a plain config record
81#[non_exhaustive]
82pub struct AdminField {
83    /// Column name in the database / struct field name.
84    pub name: &'static str,
85    /// Human-readable label for the UI.
86    pub label: String,
87    /// Widget type.
88    pub kind: AdminFieldKind,
89    /// Whether this field appears in the list view table.
90    pub list_display: bool,
91    /// Whether this field is searchable (included in text search).
92    pub searchable: bool,
93    /// Whether this field can be used as a filter.
94    pub filterable: bool,
95    /// Whether this field is required on create/edit forms.
96    pub required: bool,
97    /// Whether this field is editable (false for IDs, timestamps, etc.).
98    pub editable: bool,
99    /// Editable only on create; shown as read-only on edit (e.g. `principal_id`, `expires_at`).
100    /// `strip_meta_fields` drops these on update submissions so the model never sees them.
101    pub create_only: bool,
102    /// Sort priority in list view (None = not sortable).
103    pub sortable: bool,
104    /// Whether this column is encrypted at rest (#805). When set, the field is
105    /// rendered as a disabled, redacted, unsubmitted control in forms (so its
106    /// plaintext is never placed into the HTML and a save never overwrites the
107    /// stored ciphertext), and — unless [`Self::encrypted_visible`] is also set —
108    /// redacted (`••••••••`) in list and detail views.
109    ///
110    /// This is a per-field flag rather than a global column-name lookup so that an
111    /// unrelated resource with a same-named plaintext field stays fully editable.
112    pub encrypted: bool,
113    /// For an [`Self::encrypted`] column, show its decrypted plaintext in list and
114    /// detail (read) views — the `#[encrypted(admin_visible)]` opt-in. Edit forms
115    /// still never pre-fill the plaintext. Has no effect unless `encrypted` is set.
116    pub encrypted_visible: bool,
117}
118
119impl AdminField {
120    /// Create a new field with sensible defaults.
121    ///
122    /// By default: displayed in list, not searchable, not filterable,
123    /// required, and sortable. Editable defaults to `true` except for
124    /// [`AdminFieldKind::Hidden`], which is read-only by contract
125    /// (and is therefore excluded from `strip_meta_fields` acceptance
126    /// even if a caller later flips `editable` back to `true`).
127    #[must_use]
128    pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
129        let editable = !matches!(kind, AdminFieldKind::Hidden);
130        Self {
131            name,
132            label: humanize_field_name(name),
133            kind,
134            list_display: true,
135            searchable: false,
136            filterable: false,
137            required: true,
138            editable,
139            create_only: false,
140            sortable: true,
141            encrypted: false,
142            encrypted_visible: false,
143        }
144    }
145
146    /// Mark this column as encrypted at rest (#805): redacted in read views and
147    /// rendered as a disabled, unsubmitted control in forms.
148    #[must_use]
149    pub const fn encrypted(mut self) -> Self {
150        self.encrypted = true;
151        self
152    }
153
154    /// Mark this column as encrypted at rest but show its decrypted plaintext in
155    /// read views (the `#[encrypted(admin_visible)]` opt-in). Implies
156    /// [`Self::encrypted`]; edit forms still never pre-fill the plaintext.
157    #[must_use]
158    pub const fn encrypted_visible(mut self) -> Self {
159        self.encrypted = true;
160        self.encrypted_visible = true;
161        self
162    }
163
164    /// Set the human-readable label.
165    #[must_use]
166    pub fn label(mut self, label: impl Into<String>) -> Self {
167        self.label = label.into();
168        self
169    }
170
171    /// Mark this field as searchable.
172    #[must_use]
173    pub const fn searchable(mut self) -> Self {
174        self.searchable = true;
175        self
176    }
177
178    /// Mark this field as filterable.
179    #[must_use]
180    pub const fn filterable(mut self) -> Self {
181        self.filterable = true;
182        self
183    }
184
185    /// Mark this field as optional.
186    #[must_use]
187    pub const fn optional(mut self) -> Self {
188        self.required = false;
189        self
190    }
191
192    /// Mark this field as read-only.
193    #[must_use]
194    pub const fn readonly(mut self) -> Self {
195        self.editable = false;
196        self
197    }
198
199    /// Editable on create, read-only on edit (e.g. `principal_id`, `expires_at`).
200    /// The field renders normally in the create form but as a disabled display
201    /// in the edit form; update submissions never receive its value.
202    #[must_use]
203    pub const fn create_only(mut self) -> Self {
204        self.create_only = true;
205        self
206    }
207
208    /// Hide this field from the list view.
209    #[must_use]
210    pub const fn hide_from_list(mut self) -> Self {
211        self.list_display = false;
212        self
213    }
214}
215
216// ── Bulk actions ────────────────────────────────────────────────────
217
218/// A named bulk action that can be performed on selected records.
219pub struct AdminAction {
220    /// Machine name (used in form values).
221    pub name: &'static str,
222    /// Human-readable label for the button.
223    pub label: String,
224    /// CSS class for styling (e.g., "danger" for destructive actions).
225    pub style: ActionStyle,
226    /// Whether a confirmation dialog is shown before executing.
227    pub confirm: bool,
228}
229
230/// Visual style for an admin action button.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ActionStyle {
233    /// Default/neutral style.
234    Default,
235    /// Primary/positive action.
236    Primary,
237    /// Destructive/dangerous action (red).
238    Danger,
239}
240
241// ── Version history ──────────────────────────────────────────────────
242
243/// A single entry in the admin History pane for an opted-in model.
244///
245/// Mirrors [`autumn_web::VersionEntry`] but is decoupled from the runtime
246/// type so the admin plugin has no compile-time dependency on the DB feature.
247#[derive(Debug, Clone)]
248pub struct AdminHistoryEntry {
249    /// Auto-incrementing PK in the history table.
250    pub id: i64,
251    /// Actor identifier (`user_id` or `"system"`).
252    pub actor: String,
253    /// Operation: `"insert"`, `"update"`, or `"delete"`.
254    pub op: String,
255    /// Request / trace correlation ID.
256    pub request_id: Option<String>,
257    /// Column-level changes, serialized as JSON for template rendering.
258    pub changes: Vec<Value>,
259    /// When this entry was recorded.
260    pub recorded_at: DateTime<Utc>,
261}
262
263/// Paginated history result for the admin History pane.
264#[derive(Debug, Clone)]
265pub struct AdminHistoryPage {
266    pub entries: Vec<AdminHistoryEntry>,
267    pub total: u64,
268    pub page: u64,
269    pub per_page: u64,
270}
271
272impl AdminHistoryPage {
273    /// Total number of pages.
274    #[must_use]
275    pub const fn total_pages(&self) -> u64 {
276        if self.per_page == 0 {
277            return 0;
278        }
279        self.total.div_ceil(self.per_page)
280    }
281
282    /// Whether there is a next page.
283    #[must_use]
284    pub const fn has_next_page(&self) -> bool {
285        self.page < self.total_pages()
286    }
287}
288
289// ── The core trait ──────────────────────────────────────────────────
290
291/// Type alias for the boxed future returned by async `AdminModel` methods.
292pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
293
294/// Error type for admin operations.
295#[derive(Debug, thiserror::Error)]
296pub enum AdminError {
297    #[error("Record not found")]
298    NotFound,
299
300    #[error("Validation failed: {0}")]
301    Validation(String),
302
303    #[error("Database error: {0}")]
304    Database(String),
305
306    #[error("{0}")]
307    Other(String),
308}
309
310// ── CSV import types ────────────────────────────────────────────────
311
312/// Mode for an admin CSV import operation.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum CsvImportMode {
315    /// Insert every row as a new record.
316    #[default]
317    Insert,
318    /// Dry-run: validate rows but do not write anything.
319    DryRun,
320}
321
322impl CsvImportMode {
323    /// Parse from a form field value.
324    ///
325    /// Returns `None` for unrecognised values so callers can reject them with a
326    /// 400 instead of silently falling back to the destructive `Insert` path.
327    /// An *absent* mode field should default to `Insert` without calling this.
328    #[must_use]
329    pub fn from_form_value(s: &str) -> Option<Self> {
330        match s {
331            "insert" | "Insert" => Some(Self::Insert),
332            "dry_run" | "DryRun" | "dry-run" => Some(Self::DryRun),
333            _ => None,
334        }
335    }
336}
337
338/// The outcome of processing a single imported CSV row.
339#[derive(Debug)]
340pub enum AdminImportRowResult {
341    /// The row was inserted as a new record.
342    Inserted,
343    /// The row updated an existing record.
344    Updated,
345    /// The row was intentionally skipped.
346    Skipped,
347    /// A row-level error (no specific column).
348    RowError(String),
349    /// A field-level error with a column name.
350    FieldError { column: String, message: String },
351}
352
353/// Summary of a completed (or dry-run) admin CSV import.
354#[derive(Debug, Default, Clone)]
355pub struct AdminImportReport {
356    pub inserted: u64,
357    pub updated: u64,
358    pub skipped: u64,
359    pub errors: Vec<AdminImportError>,
360}
361
362/// A single parse/validation error from an admin CSV import.
363#[derive(Debug, Clone)]
364pub struct AdminImportError {
365    /// 1-based CSV line number (header = line 1).
366    pub line: u64,
367    /// Column name, if known.
368    pub column: Option<String>,
369    /// Human-readable description.
370    pub message: String,
371}
372
373/// The core trait that enables a model to be managed in the admin panel.
374///
375/// Implementors provide field metadata, CRUD operations, and display
376/// configuration. The admin plugin uses this trait to generate all views
377/// dynamically at runtime.
378///
379/// # Design notes
380///
381/// All data flows through `serde_json::Value` to keep the trait object-safe.
382/// The admin panel doesn't need to know concrete types — it renders fields
383/// based on [`AdminField`] metadata and passes values as JSON.
384pub trait AdminModel: Send + Sync + 'static {
385    /// URL-safe slug for this model (e.g., "projects", "tickets").
386    /// Used in admin URLs: `/admin/projects/`, `/admin/projects/42/`.
387    fn slug(&self) -> &'static str;
388
389    /// Human-readable singular name (e.g., "Project").
390    fn display_name(&self) -> &'static str;
391
392    /// Human-readable plural name (e.g., "Projects").
393    fn display_name_plural(&self) -> &'static str;
394
395    /// Field metadata for this model.
396    fn fields(&self) -> Vec<AdminField>;
397
398    /// Available bulk actions.
399    ///
400    /// Defaults to "Delete selected". When `supports_soft_delete()` returns
401    /// `true`, also includes "Restore selected" and "Purge selected" so that
402    /// the admin route validator can dispatch those action names.
403    fn actions(&self) -> Vec<AdminAction> {
404        let mut acts = vec![AdminAction {
405            name: "delete",
406            label: "Delete selected".to_owned(),
407            style: ActionStyle::Danger,
408            confirm: true,
409        }];
410        if self.supports_soft_delete() {
411            acts.push(AdminAction {
412                name: "restore",
413                label: "Restore selected".to_owned(),
414                style: ActionStyle::Default,
415                confirm: false,
416            });
417            acts.push(AdminAction {
418                name: "purge",
419                label: "Purge selected".to_owned(),
420                style: ActionStyle::Danger,
421                confirm: true,
422            });
423        }
424        acts
425    }
426
427    // ── CRUD operations ─────────────────────────────────────────
428
429    /// List records with pagination, search, sort, and filters.
430    fn list(
431        &self,
432        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
433        params: ListParams,
434    ) -> AdminFuture<'_, ListResult>;
435
436    /// Get a single record by ID.
437    fn get(
438        &self,
439        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
440        id: i64,
441    ) -> AdminFuture<'_, Option<Value>>;
442
443    /// Create a new record from form data.
444    fn create(
445        &self,
446        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
447        data: Value,
448    ) -> AdminFuture<'_, Value>;
449
450    /// Update an existing record.
451    fn update(
452        &self,
453        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
454        id: i64,
455        data: Value,
456    ) -> AdminFuture<'_, Value>;
457
458    /// Delete a record by ID.
459    fn delete(
460        &self,
461        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
462        id: i64,
463    ) -> AdminFuture<'_, ()>;
464
465    /// Whether this model supports soft-delete (defaults to `false`).
466    ///
467    /// When `true`, the admin panel shows a Trash tab with restore/purge.
468    fn supports_soft_delete(&self) -> bool {
469        false
470    }
471
472    /// Restore a soft-deleted record (set `deleted_at = NULL`).
473    ///
474    /// The default returns `AdminError::Other` when `supports_soft_delete()` is
475    /// `false`, so models that opt in must override this method.
476    fn restore<'a>(
477        &'a self,
478        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
479        _id: i64,
480    ) -> AdminFuture<'a, ()> {
481        Box::pin(async move {
482            Err(AdminError::Other(
483                "this model does not support soft delete; \
484                 override supports_soft_delete() to return true and implement restore()"
485                    .to_owned(),
486            ))
487        })
488    }
489
490    /// Permanently delete (purge) a soft-deleted record.
491    ///
492    /// The default returns `AdminError::Other` when `supports_soft_delete()` is
493    /// `false`.
494    fn purge<'a>(
495        &'a self,
496        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
497        _id: i64,
498    ) -> AdminFuture<'a, ()> {
499        Box::pin(async move {
500            Err(AdminError::Other(
501                "this model does not support soft delete; \
502                 override supports_soft_delete() to return true and implement purge()"
503                    .to_owned(),
504            ))
505        })
506    }
507
508    /// List soft-deleted records (where `deleted_at IS NOT NULL`).
509    ///
510    /// The default returns `AdminError::Other` when `supports_soft_delete()` is
511    /// `false`.
512    fn list_deleted<'a>(
513        &'a self,
514        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
515        _params: ListParams,
516    ) -> AdminFuture<'a, ListResult> {
517        Box::pin(async move {
518            Err(AdminError::Other(
519                "this model does not support soft delete; \
520                 override supports_soft_delete() to return true and implement list_deleted()"
521                    .to_owned(),
522            ))
523        })
524    }
525
526    /// Execute a bulk action on the given IDs.
527    fn execute_action(
528        &self,
529        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
530        action: &str,
531        ids: Vec<i64>,
532    ) -> AdminFuture<'_, u64> {
533        // Default implementation: dispatch the built-in `"delete"`, `"restore"`,
534        // and `"purge"` actions. Any other action name returns an error so it
535        // doesn't silently no-op — overriders that declare custom actions must
536        // implement them here.
537        //
538        // We clone the pool (deadpool::Pool is Arc-backed, cheap) so the
539        // returned future only borrows from `&self` and avoids the
540        // lifetime mismatch between `&self` and `&pool` that would
541        // otherwise show up in the trait's elided `'_` return signature.
542        let action = action.to_owned();
543        let pool = pool.clone();
544        Box::pin(async move {
545            match action.as_str() {
546                "delete" => {
547                    let mut count: u64 = 0;
548                    for id in ids {
549                        self.delete(&pool, id).await?;
550                        count += 1;
551                    }
552                    Ok(count)
553                }
554                "restore" => {
555                    let mut count: u64 = 0;
556                    for id in ids {
557                        self.restore(&pool, id).await?;
558                        count += 1;
559                    }
560                    Ok(count)
561                }
562                "purge" => {
563                    let mut count: u64 = 0;
564                    for id in ids {
565                        self.purge(&pool, id).await?;
566                        count += 1;
567                    }
568                    Ok(count)
569                }
570                other => Err(AdminError::Other(format!(
571                    "unhandled bulk action '{other}'; \
572                     override AdminModel::execute_action to support it"
573                ))),
574            }
575        })
576    }
577
578    /// Return a display string for a record (used in breadcrumbs, titles).
579    ///
580    /// Defaults to `"ModelName #id"` (or `"ModelName <no id>"` when the
581    /// record has no numeric `id`).
582    fn record_display(&self, record: &Value) -> String {
583        record_id(record).map_or_else(
584            || format!("{} <no id>", self.display_name()),
585            |id| format!("{} #{id}", self.display_name()),
586        )
587    }
588
589    /// Records per page in the list view. Override to taste.
590    fn per_page(&self) -> u64 {
591        25
592    }
593
594    /// Count records matching a list query (defaults to `list(..., per_page: 0).total`).
595    ///
596    /// Override if the backend can count without materializing records.
597    fn count(
598        &self,
599        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
600    ) -> AdminFuture<'_, u64> {
601        let params = ListParams {
602            page: 1,
603            per_page: 0,
604            ..Default::default()
605        };
606        let fut = self.list(pool, params);
607        Box::pin(async move { fut.await.map(|r| r.total) })
608    }
609
610    // ── CSV import / export ─────────────────────────────────────────
611
612    /// Whether this model exposes a `GET /admin/{slug}/export.csv` link.
613    ///
614    /// Defaults to `false` — export must be explicitly opted into to avoid
615    /// silently exposing model data on upgrade. Override to `true` to enable
616    /// the CSV export button and route.
617    fn supports_csv_export(&self) -> bool {
618        false
619    }
620
621    /// Column names written to the CSV header row during export.
622    ///
623    /// Defaults to the ordered names of all non-password, non-hidden fields
624    /// declared in [`fields`]. Override to add computed columns (e.g. a
625    /// joined display value) or to omit sensitive columns (PII redaction).
626    ///
627    /// # PII redaction strategy
628    ///
629    /// To redact a column: remove it from this list. To include a placeholder
630    /// instead of the real value, add the column here and override
631    /// [`AdminModel::csv_export_row`] to return `"[REDACTED]"` for that key.
632    ///
633    /// [`fields`]: AdminModel::fields
634    fn csv_export_columns(&self) -> Vec<&'static str> {
635        self.fields()
636            .into_iter()
637            .filter(|f| {
638                !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden) && !f.encrypted
639            })
640            .map(|f| f.name)
641            .collect()
642    }
643
644    /// Serialize a single record (as returned by [`list`]) into an ordered
645    /// list of string values for CSV export.
646    ///
647    /// The default implementation extracts values for each column in
648    /// [`csv_export_columns`] from the JSON record. Override to add computed
649    /// columns (joined values, formatted timestamps, etc.).
650    ///
651    /// [`list`]: AdminModel::list
652    /// [`csv_export_columns`]: AdminModel::csv_export_columns
653    fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
654        columns
655            .iter()
656            .map(|col| {
657                record
658                    .get(*col)
659                    .map(|v| match v {
660                        Value::String(s) => escape_csv_formula(s),
661                        Value::Null => String::new(),
662                        other => other.to_string(),
663                    })
664                    .unwrap_or_default()
665            })
666            .collect()
667    }
668
669    /// Whether this model accepts `POST /admin/{slug}/import` CSV uploads.
670    ///
671    /// Defaults to `false` — import must be explicitly opted into because it
672    /// performs bulk writes. Override to `true` and implement
673    /// [`import_csv_row`] to enable the import UI.
674    ///
675    /// [`import_csv_row`]: AdminModel::import_csv_row
676    fn supports_csv_import(&self) -> bool {
677        false
678    }
679
680    /// Process a single CSV row during an admin import.
681    ///
682    /// Receives the **1-based line number** in the CSV file and a map of
683    /// `column_name → value` (all strings; coerce as needed). Return the
684    /// appropriate [`AdminImportRowResult`].
685    ///
686    /// The default always returns `AdminImportRowResult::Skipped` — models
687    /// that set [`supports_csv_import`] to `true` should override this.
688    ///
689    /// [`supports_csv_import`]: AdminModel::supports_csv_import
690    fn import_csv_row<'a>(
691        &'a self,
692        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
693        _line: u64,
694        _row: std::collections::HashMap<String, String>,
695        _mode: CsvImportMode,
696    ) -> AdminFuture<'a, AdminImportRowResult> {
697        Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
698    }
699
700    /// Whether this model has automatic record version history enabled.
701    ///
702    /// When `true`, the admin panel renders a **History** affordance on the
703    /// detail page and serves `/{slug}/{id}/history`.
704    fn has_history(&self) -> bool {
705        false
706    }
707
708    /// Retrieve a paginated page of version history entries for a record.
709    ///
710    /// The default implementation returns [`AdminError::Other`] so models
711    /// that do not opt in get a clear error instead of a silent no-op.
712    fn get_history<'a>(
713        &'a self,
714        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
715        _record_id: i64,
716        _page: u64,
717        _per_page: u64,
718    ) -> AdminFuture<'a, AdminHistoryPage> {
719        Box::pin(async move {
720            Err(AdminError::Other(
721                "this model does not have version history enabled; \
722                 use #[repository(Model, versioned = true)] to opt in"
723                    .to_owned(),
724            ))
725        })
726    }
727}
728
729// ── VersionPage → AdminHistoryPage conversion ──────────────────────
730
731impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
732    fn from(e: autumn_web::version_history::VersionEntry) -> Self {
733        Self {
734            id: e.id,
735            actor: e.actor,
736            op: e.op.to_string(),
737            request_id: e.request_id,
738            changes: e
739                .changes
740                .into_iter()
741                .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
742                .collect(),
743            recorded_at: e.recorded_at,
744        }
745    }
746}
747
748impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
749    /// Convert a [`autumn_web::version_history::VersionPage`] returned by a
750    /// versioned repository's `version_history()` method into an
751    /// [`AdminHistoryPage`] for the admin panel.
752    ///
753    /// ```rust,ignore
754    /// fn get_history<'a>(
755    ///     &'a self, pool: &'a Pool<AsyncPgConnection>,
756    ///     record_id: i64, page: u64, per_page: u64,
757    /// ) -> AdminFuture<'a, AdminHistoryPage> {
758    ///     let pool = pool.clone();
759    ///     Box::pin(async move {
760    ///         let repo = PgPostRepository::from_pool(pool);
761    ///         let filter = autumn_web::VersionFilter { page, per_page, ..Default::default() };
762    ///         repo.version_history(record_id, filter).await
763    ///             .map(AdminHistoryPage::from)
764    ///             .map_err(|e| AdminError::Database(e.to_string()))
765    ///     })
766    /// }
767    /// ```
768    fn from(vp: autumn_web::version_history::VersionPage) -> Self {
769        Self {
770            entries: vp
771                .entries
772                .into_iter()
773                .map(AdminHistoryEntry::from)
774                .collect(),
775            total: vp.total,
776            page: vp.page,
777            per_page: vp.per_page,
778        }
779    }
780}
781
782/// Extract the `"id"` field of a record as `i64`.
783///
784/// Returns `None` when the field is missing or non-numeric. Callers in
785/// mutation paths should treat `None` as an error (the model returned a
786/// payload without a routable identifier); display contexts may fall back
787/// to a placeholder like `"#?"`.
788#[must_use]
789pub fn record_id(record: &Value) -> Option<i64> {
790    record.get("id").and_then(Value::as_i64)
791}
792
793// ── Query parameters ────────────────────────────────────────────────
794
795/// Parameters for a list query.
796#[derive(Debug, Clone, Default)]
797pub struct ListParams {
798    /// Page number (1-indexed).
799    pub page: u64,
800    /// Records per page.
801    pub per_page: u64,
802    /// Full-text search query.
803    pub search: Option<String>,
804    /// Column to sort by.
805    pub sort_by: Option<String>,
806    /// Sort direction.
807    pub sort_dir: SortDirection,
808    /// Active filters (`field_name` → value).
809    pub filters: Vec<(String, String)>,
810}
811
812/// Sort direction for list queries.
813#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
814#[serde(rename_all = "lowercase")]
815pub enum SortDirection {
816    #[default]
817    Asc,
818    Desc,
819}
820
821impl SortDirection {
822    /// URL-friendly representation (`"asc"` / `"desc"`).
823    #[must_use]
824    pub const fn as_str(self) -> &'static str {
825        match self {
826            Self::Asc => "asc",
827            Self::Desc => "desc",
828        }
829    }
830
831    /// The opposite direction (used to flip sort on re-click).
832    #[must_use]
833    pub const fn flipped(self) -> Self {
834        match self {
835            Self::Asc => Self::Desc,
836            Self::Desc => Self::Asc,
837        }
838    }
839}
840
841/// Result of a list query, containing records and pagination metadata.
842#[derive(Debug, Clone)]
843pub struct ListResult {
844    /// The records for the current page (as JSON objects).
845    pub records: Vec<Value>,
846    /// Total number of records matching the query (for pagination).
847    pub total: u64,
848    /// Current page number.
849    pub page: u64,
850    /// Records per page.
851    pub per_page: u64,
852}
853
854impl ListResult {
855    /// Total number of pages.
856    #[must_use]
857    pub const fn total_pages(&self) -> u64 {
858        if self.per_page == 0 {
859            return 0;
860        }
861        self.total.div_ceil(self.per_page)
862    }
863}
864
865// ── Helpers ─────────────────────────────────────────────────────────
866
867/// Prefix a CSV cell value with `'` when it starts with a formula-triggering
868/// character (`=`, `+`, `-`, `@`, tab, or CR) to prevent spreadsheet formula
869/// injection.
870fn escape_csv_formula(s: &str) -> String {
871    match s.bytes().next() {
872        Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
873            let mut out = String::with_capacity(s.len() + 1);
874            out.push('\'');
875            out.push_str(s);
876            out
877        }
878        _ => s.to_owned(),
879    }
880}
881
882/// Convert a `snake_case` field name to a human-readable label.
883///
884/// `"created_at"` → `"Created At"`, `"user_id"` → `"User Id"`.
885fn humanize_field_name(name: &str) -> String {
886    let mut s = String::with_capacity(name.len());
887    for (i, word) in name.split('_').enumerate() {
888        if i > 0 {
889            s.push(' ');
890        }
891        let mut chars = word.chars();
892        if let Some(c) = chars.next() {
893            s.extend(c.to_uppercase());
894            s.extend(chars);
895        }
896    }
897    s
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use std::sync::Mutex;
904
905    /// Test fixture: an `AdminModel` whose `delete` records the id it was
906    /// asked to delete. Doesn't override `execute_action` — that's the
907    /// behaviour under test.
908    struct DeletingModel {
909        deleted: Mutex<Vec<i64>>,
910        fail_on: Option<i64>,
911    }
912
913    impl AdminModel for DeletingModel {
914        fn slug(&self) -> &'static str {
915            "tracked"
916        }
917        fn display_name(&self) -> &'static str {
918            "Tracked"
919        }
920        fn display_name_plural(&self) -> &'static str {
921            "Tracked"
922        }
923        fn fields(&self) -> Vec<AdminField> {
924            vec![]
925        }
926        fn list(
927            &self,
928            _pool: &diesel_async::pooled_connection::deadpool::Pool<
929                diesel_async::AsyncPgConnection,
930            >,
931            _params: ListParams,
932        ) -> AdminFuture<'_, ListResult> {
933            Box::pin(async {
934                Ok(ListResult {
935                    records: vec![],
936                    total: 0,
937                    page: 1,
938                    per_page: 25,
939                })
940            })
941        }
942        fn get(
943            &self,
944            _pool: &diesel_async::pooled_connection::deadpool::Pool<
945                diesel_async::AsyncPgConnection,
946            >,
947            _id: i64,
948        ) -> AdminFuture<'_, Option<Value>> {
949            Box::pin(async { Ok(None) })
950        }
951        fn create(
952            &self,
953            _pool: &diesel_async::pooled_connection::deadpool::Pool<
954                diesel_async::AsyncPgConnection,
955            >,
956            data: Value,
957        ) -> AdminFuture<'_, Value> {
958            Box::pin(async move { Ok(data) })
959        }
960        fn update(
961            &self,
962            _pool: &diesel_async::pooled_connection::deadpool::Pool<
963                diesel_async::AsyncPgConnection,
964            >,
965            _id: i64,
966            data: Value,
967        ) -> AdminFuture<'_, Value> {
968            Box::pin(async move { Ok(data) })
969        }
970        fn delete(
971            &self,
972            _pool: &diesel_async::pooled_connection::deadpool::Pool<
973                diesel_async::AsyncPgConnection,
974            >,
975            id: i64,
976        ) -> AdminFuture<'_, ()> {
977            let deleted = &self.deleted;
978            let fail_on = self.fail_on;
979            Box::pin(async move {
980                if Some(id) == fail_on {
981                    return Err(AdminError::Database("simulated failure".into()));
982                }
983                deleted.lock().unwrap().push(id);
984                Ok(())
985            })
986        }
987    }
988
989    /// Test fixture: an `AdminModel` that supports soft delete and records
990    /// which ids were restored/purged. Overrides `supports_soft_delete()` and
991    /// the three soft-delete methods.
992    #[derive(Default)]
993    struct SoftDeleteModel {
994        restored: Mutex<Vec<i64>>,
995        purged: Mutex<Vec<i64>>,
996    }
997
998    impl AdminModel for SoftDeleteModel {
999        fn slug(&self) -> &'static str {
1000            "soft"
1001        }
1002        fn display_name(&self) -> &'static str {
1003            "Soft"
1004        }
1005        fn display_name_plural(&self) -> &'static str {
1006            "Softs"
1007        }
1008        fn fields(&self) -> Vec<AdminField> {
1009            vec![]
1010        }
1011        fn list(
1012            &self,
1013            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1014                diesel_async::AsyncPgConnection,
1015            >,
1016            _params: ListParams,
1017        ) -> AdminFuture<'_, ListResult> {
1018            Box::pin(async {
1019                Ok(ListResult {
1020                    records: vec![],
1021                    total: 0,
1022                    page: 1,
1023                    per_page: 25,
1024                })
1025            })
1026        }
1027        fn get(
1028            &self,
1029            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1030                diesel_async::AsyncPgConnection,
1031            >,
1032            _id: i64,
1033        ) -> AdminFuture<'_, Option<Value>> {
1034            Box::pin(async { Ok(None) })
1035        }
1036        fn create(
1037            &self,
1038            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1039                diesel_async::AsyncPgConnection,
1040            >,
1041            data: Value,
1042        ) -> AdminFuture<'_, Value> {
1043            Box::pin(async move { Ok(data) })
1044        }
1045        fn update(
1046            &self,
1047            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1048                diesel_async::AsyncPgConnection,
1049            >,
1050            _id: i64,
1051            data: Value,
1052        ) -> AdminFuture<'_, Value> {
1053            Box::pin(async move { Ok(data) })
1054        }
1055        fn delete(
1056            &self,
1057            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1058                diesel_async::AsyncPgConnection,
1059            >,
1060            _id: i64,
1061        ) -> AdminFuture<'_, ()> {
1062            Box::pin(async { Ok(()) })
1063        }
1064        fn supports_soft_delete(&self) -> bool {
1065            true
1066        }
1067        fn restore<'a>(
1068            &'a self,
1069            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1070                diesel_async::AsyncPgConnection,
1071            >,
1072            id: i64,
1073        ) -> AdminFuture<'a, ()> {
1074            Box::pin(async move {
1075                self.restored.lock().unwrap().push(id);
1076                Ok(())
1077            })
1078        }
1079        fn purge<'a>(
1080            &'a self,
1081            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1082                diesel_async::AsyncPgConnection,
1083            >,
1084            id: i64,
1085        ) -> AdminFuture<'a, ()> {
1086            Box::pin(async move {
1087                self.purged.lock().unwrap().push(id);
1088                Ok(())
1089            })
1090        }
1091        fn list_deleted<'a>(
1092            &'a self,
1093            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1094                diesel_async::AsyncPgConnection,
1095            >,
1096            _params: ListParams,
1097        ) -> AdminFuture<'a, ListResult> {
1098            Box::pin(async {
1099                Ok(ListResult {
1100                    records: vec![],
1101                    total: 0,
1102                    page: 1,
1103                    per_page: 25,
1104                })
1105            })
1106        }
1107    }
1108
1109    /// Build a `Pool` whose manager would fail to connect — the test models
1110    /// never call `pool.get()`, so the pool itself just sits unused.
1111    fn dummy_pool()
1112    -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
1113        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1114        use diesel_async::pooled_connection::deadpool::Pool;
1115        let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
1116            "postgresql://test",
1117        );
1118        Pool::builder(mgr).build().expect("build pool")
1119    }
1120
1121    #[tokio::test]
1122    async fn default_execute_action_delete_invokes_delete_for_each_id() {
1123        let model = DeletingModel {
1124            deleted: Mutex::new(vec![]),
1125            fail_on: None,
1126        };
1127        let pool = dummy_pool();
1128        let count = model
1129            .execute_action(&pool, "delete", vec![10, 20, 30])
1130            .await
1131            .expect("default delete should succeed");
1132        assert_eq!(count, 3);
1133        assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
1134    }
1135
1136    #[tokio::test]
1137    async fn default_execute_action_delete_aborts_on_first_failure() {
1138        let model = DeletingModel {
1139            deleted: Mutex::new(vec![]),
1140            fail_on: Some(20),
1141        };
1142        let pool = dummy_pool();
1143        let err = model
1144            .execute_action(&pool, "delete", vec![10, 20, 30])
1145            .await
1146            .expect_err("delete should propagate failure");
1147        assert!(matches!(err, AdminError::Database(_)));
1148        // Only the pre-failure id was committed.
1149        assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
1150    }
1151
1152    #[tokio::test]
1153    async fn default_execute_action_rejects_unknown_action() {
1154        let model = DeletingModel {
1155            deleted: Mutex::new(vec![]),
1156            fail_on: None,
1157        };
1158        let pool = dummy_pool();
1159        let err = model
1160            .execute_action(&pool, "promote", vec![1])
1161            .await
1162            .expect_err("unknown actions must error, not silently no-op");
1163        assert!(
1164            matches!(err, AdminError::Other(msg) if msg.contains("promote")),
1165            "error should name the unhandled action"
1166        );
1167        assert!(model.deleted.lock().unwrap().is_empty());
1168    }
1169
1170    #[test]
1171    fn humanize_converts_snake_case() {
1172        assert_eq!(humanize_field_name("created_at"), "Created At");
1173        assert_eq!(humanize_field_name("user_id"), "User Id");
1174        assert_eq!(humanize_field_name("name"), "Name");
1175        assert_eq!(humanize_field_name(""), "");
1176    }
1177
1178    #[test]
1179    fn list_result_total_pages() {
1180        let result = ListResult {
1181            records: vec![],
1182            total: 25,
1183            page: 1,
1184            per_page: 10,
1185        };
1186        assert_eq!(result.total_pages(), 3);
1187    }
1188
1189    #[test]
1190    fn list_result_total_pages_exact() {
1191        let result = ListResult {
1192            records: vec![],
1193            total: 20,
1194            page: 1,
1195            per_page: 10,
1196        };
1197        assert_eq!(result.total_pages(), 2);
1198    }
1199
1200    #[test]
1201    fn list_result_total_pages_zero_per_page() {
1202        let result = ListResult {
1203            records: vec![],
1204            total: 20,
1205            page: 1,
1206            per_page: 0,
1207        };
1208        assert_eq!(result.total_pages(), 0);
1209    }
1210
1211    #[test]
1212    fn admin_field_builder() {
1213        let field = AdminField::new("email", AdminFieldKind::Text)
1214            .label("Email Address")
1215            .searchable()
1216            .filterable()
1217            .optional();
1218
1219        assert_eq!(field.name, "email");
1220        assert_eq!(field.label, "Email Address");
1221        assert!(field.searchable);
1222        assert!(field.filterable);
1223        assert!(!field.required);
1224        assert!(field.editable);
1225    }
1226
1227    #[test]
1228    fn record_id_extracts_numeric_id() {
1229        assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
1230    }
1231
1232    #[test]
1233    fn record_id_returns_none_for_missing_or_non_numeric() {
1234        assert_eq!(record_id(&serde_json::json!({})), None);
1235        assert_eq!(record_id(&serde_json::json!({"id": null})), None);
1236        assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
1237        // Floats aren't valid IDs either — only integers.
1238        assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
1239    }
1240
1241    #[test]
1242    fn hidden_fields_default_to_not_editable() {
1243        // AdminFieldKind::Hidden is documented as "not editable". Ensure the
1244        // default matches the contract so admins who skip `.readonly()` still
1245        // get safe behaviour.
1246        let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
1247        assert!(
1248            !hidden.editable,
1249            "Hidden fields must default to editable=false"
1250        );
1251
1252        // Other kinds remain editable by default.
1253        let text = AdminField::new("name", AdminFieldKind::Text);
1254        assert!(text.editable);
1255    }
1256
1257    // ── Soft-delete admin support (issue #689) ────────────────────
1258
1259    #[test]
1260    fn admin_model_supports_soft_delete_defaults_to_false() {
1261        let model = DeletingModel {
1262            deleted: Mutex::new(vec![]),
1263            fail_on: None,
1264        };
1265        assert!(
1266            !model.supports_soft_delete(),
1267            "AdminModel::supports_soft_delete() must default to false"
1268        );
1269    }
1270
1271    #[tokio::test]
1272    async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
1273        let model = DeletingModel {
1274            deleted: Mutex::new(vec![]),
1275            fail_on: None,
1276        };
1277        let pool = dummy_pool();
1278        let err = model
1279            .restore(&pool, 1)
1280            .await
1281            .expect_err("restore must error when supports_soft_delete() is false");
1282        assert!(
1283            matches!(err, AdminError::Other(_)),
1284            "restore on non-soft-delete model must return AdminError::Other: {err:?}"
1285        );
1286    }
1287
1288    #[tokio::test]
1289    async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
1290        let model = DeletingModel {
1291            deleted: Mutex::new(vec![]),
1292            fail_on: None,
1293        };
1294        let pool = dummy_pool();
1295        let err = model
1296            .purge(&pool, 1)
1297            .await
1298            .expect_err("purge must error when supports_soft_delete() is false");
1299        assert!(
1300            matches!(err, AdminError::Other(_)),
1301            "purge on non-soft-delete model must return AdminError::Other: {err:?}"
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
1307        let model = DeletingModel {
1308            deleted: Mutex::new(vec![]),
1309            fail_on: None,
1310        };
1311        let pool = dummy_pool();
1312        let params = ListParams {
1313            page: 1,
1314            per_page: 25,
1315            ..Default::default()
1316        };
1317        let err = model
1318            .list_deleted(&pool, params)
1319            .await
1320            .expect_err("list_deleted must error when supports_soft_delete() is false");
1321        assert!(
1322            matches!(err, AdminError::Other(_)),
1323            "list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
1324        );
1325    }
1326
1327    #[test]
1328    fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
1329        let model = DeletingModel {
1330            deleted: Mutex::new(vec![]),
1331            fail_on: None,
1332        };
1333        let acts = model.actions();
1334        assert_eq!(
1335            acts.len(),
1336            1,
1337            "default model must advertise exactly one action"
1338        );
1339        assert_eq!(acts[0].name, "delete");
1340    }
1341
1342    #[test]
1343    fn actions_includes_restore_and_purge_when_soft_delete_supported() {
1344        let model = SoftDeleteModel::default();
1345        let acts = model.actions();
1346        let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
1347        assert!(
1348            names.contains(&"restore"),
1349            "soft-delete model must advertise restore action; got: {names:?}"
1350        );
1351        assert!(
1352            names.contains(&"purge"),
1353            "soft-delete model must advertise purge action; got: {names:?}"
1354        );
1355    }
1356
1357    #[tokio::test]
1358    async fn execute_action_restore_dispatches_to_restore_method() {
1359        let model = SoftDeleteModel::default();
1360        let pool = dummy_pool();
1361        let count = model
1362            .execute_action(&pool, "restore", vec![10, 20])
1363            .await
1364            .expect("restore action should succeed on soft-delete model");
1365        assert_eq!(
1366            count, 2,
1367            "restore action must return count of restored records"
1368        );
1369        assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
1370    }
1371
1372    #[tokio::test]
1373    async fn execute_action_purge_dispatches_to_purge_method() {
1374        let model = SoftDeleteModel::default();
1375        let pool = dummy_pool();
1376        let count = model
1377            .execute_action(&pool, "purge", vec![5])
1378            .await
1379            .expect("purge action should succeed on soft-delete model");
1380        assert_eq!(count, 1, "purge action must return count of purged records");
1381        assert_eq!(*model.purged.lock().unwrap(), vec![5]);
1382    }
1383
1384    // ── Version history tests (issue #700) ────────────────────────
1385
1386    #[test]
1387    fn admin_model_has_history_defaults_to_false() {
1388        let model = DeletingModel {
1389            deleted: Mutex::new(vec![]),
1390            fail_on: None,
1391        };
1392        assert!(
1393            !model.has_history(),
1394            "AdminModel::has_history() must default to false"
1395        );
1396    }
1397
1398    #[tokio::test]
1399    async fn admin_model_get_history_returns_error_when_not_opted_in() {
1400        let model = DeletingModel {
1401            deleted: Mutex::new(vec![]),
1402            fail_on: None,
1403        };
1404        let pool = dummy_pool();
1405        let err = model
1406            .get_history(&pool, 42, 1, 25)
1407            .await
1408            .expect_err("get_history must error when has_history() is false");
1409        assert!(
1410            matches!(err, AdminError::Other(_)),
1411            "get_history on non-versioned model must return AdminError::Other: {err:?}"
1412        );
1413    }
1414
1415    #[test]
1416    fn admin_history_page_total_pages() {
1417        let page = AdminHistoryPage {
1418            entries: vec![],
1419            total: 51,
1420            page: 1,
1421            per_page: 25,
1422        };
1423        assert_eq!(page.total_pages(), 3);
1424    }
1425
1426    #[test]
1427    fn admin_history_page_has_next_page() {
1428        let page = AdminHistoryPage {
1429            entries: vec![],
1430            total: 50,
1431            page: 1,
1432            per_page: 25,
1433        };
1434        assert!(page.has_next_page());
1435    }
1436
1437    #[test]
1438    fn admin_history_page_no_next_on_last() {
1439        let page = AdminHistoryPage {
1440            entries: vec![],
1441            total: 50,
1442            page: 2,
1443            per_page: 25,
1444        };
1445        assert!(!page.has_next_page());
1446    }
1447
1448    #[test]
1449    fn admin_history_page_zero_per_page() {
1450        let page = AdminHistoryPage {
1451            entries: vec![],
1452            total: 10,
1453            page: 1,
1454            per_page: 0,
1455        };
1456        assert_eq!(page.total_pages(), 0);
1457    }
1458
1459    // ── SortDirection and AdminField builder coverage ─────────────
1460
1461    #[test]
1462    fn sort_direction_as_str_returns_correct_values() {
1463        assert_eq!(SortDirection::Asc.as_str(), "asc");
1464        assert_eq!(SortDirection::Desc.as_str(), "desc");
1465    }
1466
1467    #[test]
1468    fn sort_direction_flipped_returns_opposite() {
1469        assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
1470        assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
1471    }
1472
1473    #[test]
1474    fn admin_field_readonly_sets_editable_false() {
1475        let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
1476        assert!(!field.editable, "readonly() must set editable = false");
1477    }
1478
1479    #[test]
1480    fn admin_field_hide_from_list_sets_list_display_false() {
1481        let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
1482        assert!(
1483            !field.list_display,
1484            "hide_from_list() must set list_display = false"
1485        );
1486    }
1487
1488    #[test]
1489    fn admin_model_record_display_includes_display_name_and_id() {
1490        let model = DeletingModel {
1491            deleted: Mutex::new(vec![]),
1492            fail_on: None,
1493        };
1494        let record = serde_json::json!({"id": 7, "name": "foo"});
1495        assert_eq!(model.record_display(&record), "Tracked #7");
1496    }
1497
1498    #[test]
1499    fn admin_model_record_display_placeholder_when_no_id() {
1500        let model = DeletingModel {
1501            deleted: Mutex::new(vec![]),
1502            fail_on: None,
1503        };
1504        let record = serde_json::json!({"name": "bar"});
1505        assert_eq!(model.record_display(&record), "Tracked <no id>");
1506    }
1507
1508    #[test]
1509    fn admin_model_per_page_default_is_25() {
1510        let model = DeletingModel {
1511            deleted: Mutex::new(vec![]),
1512            fail_on: None,
1513        };
1514        assert_eq!(model.per_page(), 25);
1515    }
1516
1517    #[test]
1518    fn version_page_converts_to_admin_history_page() {
1519        use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
1520        use chrono::Utc;
1521
1522        let entry = VersionEntry {
1523            id: 1,
1524            table_name: "posts".to_owned(),
1525            record_id: 42,
1526            op: VersionOp::Update,
1527            actor: "admin".to_owned(),
1528            request_id: Some("req-1".to_owned()),
1529            changes: vec![ColumnChange::new(
1530                "title",
1531                Some(serde_json::json!("old")),
1532                Some(serde_json::json!("new")),
1533            )],
1534            recorded_at: Utc::now(),
1535        };
1536        let vp = VersionPage {
1537            entries: vec![entry],
1538            total: 1,
1539            page: 1,
1540            per_page: 25,
1541        };
1542
1543        let ap = AdminHistoryPage::from(vp);
1544        assert_eq!(ap.total, 1);
1545        assert_eq!(ap.page, 1);
1546        assert_eq!(ap.per_page, 25);
1547        assert_eq!(ap.entries.len(), 1);
1548        let e = &ap.entries[0];
1549        assert_eq!(e.id, 1);
1550        assert_eq!(e.actor, "admin");
1551        assert_eq!(e.op, "update");
1552        assert_eq!(e.request_id.as_deref(), Some("req-1"));
1553        assert_eq!(e.changes.len(), 1);
1554    }
1555
1556    #[test]
1557    fn version_entry_converts_to_admin_history_entry() {
1558        use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
1559        use chrono::Utc;
1560
1561        let entry = VersionEntry {
1562            id: 7,
1563            table_name: "users".to_owned(),
1564            record_id: 3,
1565            op: VersionOp::Delete,
1566            actor: "system".to_owned(),
1567            request_id: None,
1568            changes: vec![ColumnChange::sensitive("password_digest")],
1569            recorded_at: Utc::now(),
1570        };
1571
1572        let admin_entry = AdminHistoryEntry::from(entry);
1573        assert_eq!(admin_entry.id, 7);
1574        assert_eq!(admin_entry.actor, "system");
1575        assert_eq!(admin_entry.op, "delete");
1576        assert!(admin_entry.request_id.is_none());
1577        assert_eq!(admin_entry.changes.len(), 1);
1578    }
1579
1580    // ── CsvImportMode ────────────────────────────────────────────────
1581
1582    #[test]
1583    fn csv_import_mode_from_form_value_recognises_insert() {
1584        assert_eq!(
1585            CsvImportMode::from_form_value("insert"),
1586            Some(CsvImportMode::Insert)
1587        );
1588        assert_eq!(
1589            CsvImportMode::from_form_value("Insert"),
1590            Some(CsvImportMode::Insert)
1591        );
1592    }
1593
1594    #[test]
1595    fn csv_import_mode_from_form_value_recognises_dry_run() {
1596        assert_eq!(
1597            CsvImportMode::from_form_value("dry_run"),
1598            Some(CsvImportMode::DryRun)
1599        );
1600        assert_eq!(
1601            CsvImportMode::from_form_value("DryRun"),
1602            Some(CsvImportMode::DryRun)
1603        );
1604        assert_eq!(
1605            CsvImportMode::from_form_value("dry-run"),
1606            Some(CsvImportMode::DryRun)
1607        );
1608    }
1609
1610    #[test]
1611    fn csv_import_mode_from_form_value_rejects_unknown() {
1612        assert_eq!(CsvImportMode::from_form_value("upsert"), None);
1613        assert_eq!(CsvImportMode::from_form_value(""), None);
1614        assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
1615        assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
1616    }
1617
1618    #[test]
1619    fn csv_import_mode_default_is_insert() {
1620        assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
1621    }
1622
1623    // ── escape_csv_formula ──────────────────────────────────────────
1624
1625    #[test]
1626    fn escape_csv_formula_prefixes_equals_sign() {
1627        assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
1628    }
1629
1630    #[test]
1631    fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
1632        assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
1633        assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
1634        assert_eq!(escape_csv_formula("@A1"), "'@A1");
1635    }
1636
1637    #[test]
1638    fn escape_csv_formula_prefixes_tab_and_cr() {
1639        assert_eq!(escape_csv_formula("\thello"), "'\thello");
1640        assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
1641    }
1642
1643    #[test]
1644    fn escape_csv_formula_leaves_normal_strings_unchanged() {
1645        assert_eq!(escape_csv_formula("hello world"), "hello world");
1646        assert_eq!(escape_csv_formula("123"), "123");
1647        assert_eq!(escape_csv_formula(""), "");
1648        assert_eq!(escape_csv_formula("normal,value"), "normal,value");
1649    }
1650
1651    // ── AdminModel CSV defaults ──────────────────────────────────────
1652
1653    #[test]
1654    fn admin_model_supports_csv_export_defaults_to_false() {
1655        let model = DeletingModel {
1656            deleted: Mutex::new(vec![]),
1657            fail_on: None,
1658        };
1659        assert!(
1660            !model.supports_csv_export(),
1661            "supports_csv_export must default to false to require explicit opt-in"
1662        );
1663    }
1664
1665    #[test]
1666    fn admin_model_supports_csv_import_defaults_to_false() {
1667        let model = DeletingModel {
1668            deleted: Mutex::new(vec![]),
1669            fail_on: None,
1670        };
1671        assert!(
1672            !model.supports_csv_import(),
1673            "supports_csv_import must default to false"
1674        );
1675    }
1676
1677    #[test]
1678    fn csv_export_row_extracts_columns_and_escapes_formulas() {
1679        let model = DeletingModel {
1680            deleted: Mutex::new(vec![]),
1681            fail_on: None,
1682        };
1683        let record = serde_json::json!({
1684            "id": 1,
1685            "name": "Alice",
1686            "formula": "=EVIL()",
1687            "amount": 42.5,
1688            "active": true,
1689            "notes": null,
1690        });
1691        let columns = &[
1692            "id", "name", "formula", "amount", "active", "notes", "missing",
1693        ];
1694        let row = model.csv_export_row(columns, &record);
1695        assert_eq!(row[0], "1");
1696        assert_eq!(row[1], "Alice");
1697        assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
1698        assert_eq!(row[3], "42.5");
1699        assert_eq!(row[4], "true");
1700        assert_eq!(row[5], "", "null becomes empty string");
1701        assert_eq!(row[6], "", "missing column becomes empty string");
1702    }
1703}