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"` action
534        // here; `"restore"`, `"purge"`, and anything else go through
535        // `dispatch_restore_purge_or_unhandled`, shared with models (e.g.
536        // `TokenAdminModel`, `FeatureFlagAdminModel`) that override this
537        // method to batch `"delete"` into one query but still need the same
538        // restore/purge/unhandled-action fallback.
539        if action == "delete" {
540            // Clone the pool (deadpool::Pool is Arc-backed, cheap) so the
541            // returned future only borrows from `&self` and avoids the
542            // lifetime mismatch between `&self` and `&pool` that would
543            // otherwise show up in the trait's elided `'_` return signature.
544            let pool = pool.clone();
545            return Box::pin(async move {
546                let mut count: u64 = 0;
547                for id in ids {
548                    self.delete(&pool, id).await?;
549                    count += 1;
550                }
551                Ok(count)
552            });
553        }
554        dispatch_restore_purge_or_unhandled(self, pool, action, ids)
555    }
556
557    /// Return a display string for a record (used in breadcrumbs, titles).
558    ///
559    /// Defaults to `"ModelName #id"` (or `"ModelName <no id>"` when the
560    /// record has no numeric `id`).
561    fn record_display(&self, record: &Value) -> String {
562        record_id(record).map_or_else(
563            || format!("{} <no id>", self.display_name()),
564            |id| format!("{} #{id}", self.display_name()),
565        )
566    }
567
568    /// Records per page in the list view. Override to taste.
569    fn per_page(&self) -> u64 {
570        25
571    }
572
573    /// Count records matching a list query (defaults to `list(..., per_page: 0).total`).
574    ///
575    /// Override if the backend can count without materializing records.
576    fn count(
577        &self,
578        pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
579    ) -> AdminFuture<'_, u64> {
580        let params = ListParams {
581            page: 1,
582            per_page: 0,
583            ..Default::default()
584        };
585        let fut = self.list(pool, params);
586        Box::pin(async move { fut.await.map(|r| r.total) })
587    }
588
589    // ── CSV import / export ─────────────────────────────────────────
590
591    /// Whether this model exposes a `GET /admin/{slug}/export.csv` link.
592    ///
593    /// Defaults to `false` — export must be explicitly opted into to avoid
594    /// silently exposing model data on upgrade. Override to `true` to enable
595    /// the CSV export button and route.
596    fn supports_csv_export(&self) -> bool {
597        false
598    }
599
600    /// Column names written to the CSV header row during export.
601    ///
602    /// Defaults to the ordered names of all non-password, non-hidden fields
603    /// declared in [`fields`]. Override to add computed columns (e.g. a
604    /// joined display value) or to omit sensitive columns (PII redaction).
605    ///
606    /// # PII redaction strategy
607    ///
608    /// To redact a column: remove it from this list. To include a placeholder
609    /// instead of the real value, add the column here and override
610    /// [`AdminModel::csv_export_row`] to return `"[REDACTED]"` for that key.
611    ///
612    /// [`fields`]: AdminModel::fields
613    fn csv_export_columns(&self) -> Vec<&'static str> {
614        self.fields()
615            .into_iter()
616            .filter(|f| {
617                // #1771: a confidential column, and its blind-index companion,
618                // leave the database in a file built for sharing. The envelope
619                // and the token are both per-owner values, so an exported file
620                // is a portable correlation handle.
621                !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
622                    && !f.encrypted
623                    && !::autumn_web::confidential::is_confidential_column_name(f.name)
624            })
625            .map(|f| f.name)
626            .collect()
627    }
628
629    /// Serialize a single record (as returned by [`list`]) into an ordered
630    /// list of string values for CSV export.
631    ///
632    /// The default implementation extracts values for each column in
633    /// [`csv_export_columns`] from the JSON record. Override to add computed
634    /// columns (joined values, formatted timestamps, etc.).
635    ///
636    /// [`list`]: AdminModel::list
637    /// [`csv_export_columns`]: AdminModel::csv_export_columns
638    fn csv_export_row(&self, columns: &[&str], record: &Value) -> Vec<String> {
639        columns
640            .iter()
641            .map(|col| {
642                record
643                    .get(*col)
644                    .map(|v| match v {
645                        Value::String(s) => escape_csv_formula(s),
646                        Value::Null => String::new(),
647                        other => other.to_string(),
648                    })
649                    .unwrap_or_default()
650            })
651            .collect()
652    }
653
654    /// Whether this model accepts `POST /admin/{slug}/import` CSV uploads.
655    ///
656    /// Defaults to `false` — import must be explicitly opted into because it
657    /// performs bulk writes. Override to `true` and implement
658    /// [`import_csv_row`] to enable the import UI.
659    ///
660    /// [`import_csv_row`]: AdminModel::import_csv_row
661    fn supports_csv_import(&self) -> bool {
662        false
663    }
664
665    /// Process a single CSV row during an admin import.
666    ///
667    /// Receives the **1-based line number** in the CSV file and a map of
668    /// `column_name → value` (all strings; coerce as needed). Return the
669    /// appropriate [`AdminImportRowResult`].
670    ///
671    /// The default always returns `AdminImportRowResult::Skipped` — models
672    /// that set [`supports_csv_import`] to `true` should override this.
673    ///
674    /// [`supports_csv_import`]: AdminModel::supports_csv_import
675    fn import_csv_row<'a>(
676        &'a self,
677        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
678        _line: u64,
679        _row: std::collections::HashMap<String, String>,
680        _mode: CsvImportMode,
681    ) -> AdminFuture<'a, AdminImportRowResult> {
682        Box::pin(async move { Ok(AdminImportRowResult::Skipped) })
683    }
684
685    /// Whether this model has automatic record version history enabled.
686    ///
687    /// When `true`, the admin panel renders a **History** affordance on the
688    /// detail page and serves `/{slug}/{id}/history`.
689    fn has_history(&self) -> bool {
690        false
691    }
692
693    /// Retrieve a paginated page of version history entries for a record.
694    ///
695    /// The default implementation returns [`AdminError::Other`] so models
696    /// that do not opt in get a clear error instead of a silent no-op.
697    fn get_history<'a>(
698        &'a self,
699        _pool: &'a diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
700        _record_id: i64,
701        _page: u64,
702        _per_page: u64,
703    ) -> AdminFuture<'a, AdminHistoryPage> {
704        Box::pin(async move {
705            Err(AdminError::Other(
706                "this model does not have version history enabled; \
707                 use #[repository(Model, versioned = true)] to opt in"
708                    .to_owned(),
709            ))
710        })
711    }
712}
713
714/// Refuse a call on the `SQLite` backend, for a model that needs Postgres.
715///
716/// The three built-in models (`tokens`, `experiments`, `feature_flags`) read
717/// Postgres-only tables with Postgres-only SQL: `ILIKE`, `::type` casts,
718/// `NOW()` and writable CTEs. Since issue #2108 the crate COMPILES under
719/// `autumn-web/sqlite`, so registering one on `SQLite` is now a run-time
720/// mistake instead of a build error. Without this guard the operator sees a
721/// raw driver message such as `near "ILIKE": syntax error`.
722///
723/// Call it first in every method of a Postgres-only model. On Postgres it is a
724/// compile-time `Ok(())`: `backend_select!` drops the other arm.
725#[allow(
726    clippy::missing_const_for_fn,
727    clippy::unnecessary_wraps,
728    reason = "the Postgres arm is a trivial Ok(()); the SQLite arm formats an error"
729)]
730pub fn require_postgres(model: &str) -> Result<(), AdminError> {
731    ::autumn_web::backend_select! {
732        pg => {{
733            let _ = model;
734            Ok(())
735        }},
736        sqlite => {{
737            Err(AdminError::Other(format!(
738                "{model} needs the Postgres backend: it reads a Postgres-only table \
739                 with Postgres-only SQL. This app runs on SQLite. Register your own \
740                 AdminModel instead — see the autumn-admin-plugin README, \
741                 \"Database Backends\" (issue #2108)."
742            )))
743        }},
744    }
745}
746
747/// Dispatch the built-in `"restore"`/`"purge"` bulk actions and the fallback
748/// error for anything else.
749///
750/// Shared by `AdminModel::execute_action`'s default and by models (or
751/// generated `#[model]` admin adapters) that override it to special-case
752/// `"delete"` with a batched query — see `TokenAdminModel` and
753/// `FeatureFlagAdminModel` — so that fallthrough behaves identically without
754/// copying the loop.
755pub fn dispatch_restore_purge_or_unhandled<'a>(
756    model: &'a (impl AdminModel + ?Sized),
757    pool: &diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection>,
758    action: &str,
759    ids: Vec<i64>,
760) -> AdminFuture<'a, u64> {
761    let action = action.to_owned();
762    let pool = pool.clone();
763    Box::pin(async move {
764        match action.as_str() {
765            "restore" => {
766                let mut count: u64 = 0;
767                for id in ids {
768                    model.restore(&pool, id).await?;
769                    count += 1;
770                }
771                Ok(count)
772            }
773            "purge" => {
774                let mut count: u64 = 0;
775                for id in ids {
776                    model.purge(&pool, id).await?;
777                    count += 1;
778                }
779                Ok(count)
780            }
781            other => Err(AdminError::Other(format!(
782                "unhandled bulk action '{other}'; \
783                 override AdminModel::execute_action to support it"
784            ))),
785        }
786    })
787}
788
789// ── VersionPage → AdminHistoryPage conversion ──────────────────────
790
791impl From<autumn_web::version_history::VersionEntry> for AdminHistoryEntry {
792    fn from(e: autumn_web::version_history::VersionEntry) -> Self {
793        Self {
794            id: e.id,
795            actor: e.actor,
796            op: e.op.to_string(),
797            request_id: e.request_id,
798            changes: e
799                .changes
800                .into_iter()
801                .map(|c| serde_json::to_value(&c).unwrap_or(serde_json::Value::Null))
802                .collect(),
803            recorded_at: e.recorded_at,
804        }
805    }
806}
807
808impl From<autumn_web::version_history::VersionPage> for AdminHistoryPage {
809    /// Convert a [`autumn_web::version_history::VersionPage`] returned by a
810    /// versioned repository's `version_history()` method into an
811    /// [`AdminHistoryPage`] for the admin panel.
812    ///
813    /// ```rust,ignore
814    /// fn get_history<'a>(
815    ///     &'a self, pool: &'a Pool<RuntimeConnection>,
816    ///     record_id: i64, page: u64, per_page: u64,
817    /// ) -> AdminFuture<'a, AdminHistoryPage> {
818    ///     let pool = pool.clone();
819    ///     Box::pin(async move {
820    ///         let repo = PgPostRepository::from_pool(pool);
821    ///         let filter = autumn_web::VersionFilter { page, per_page, ..Default::default() };
822    ///         repo.version_history(record_id, filter).await
823    ///             .map(AdminHistoryPage::from)
824    ///             .map_err(|e| AdminError::Database(e.to_string()))
825    ///     })
826    /// }
827    /// ```
828    fn from(vp: autumn_web::version_history::VersionPage) -> Self {
829        Self {
830            entries: vp
831                .entries
832                .into_iter()
833                .map(AdminHistoryEntry::from)
834                .collect(),
835            total: vp.total,
836            page: vp.page,
837            per_page: vp.per_page,
838        }
839    }
840}
841
842/// Extract the `"id"` field of a record as `i64`.
843///
844/// Returns `None` when the field is missing or non-numeric. Callers in
845/// mutation paths should treat `None` as an error (the model returned a
846/// payload without a routable identifier); display contexts may fall back
847/// to a placeholder like `"#?"`.
848#[must_use]
849pub fn record_id(record: &Value) -> Option<i64> {
850    record.get("id").and_then(Value::as_i64)
851}
852
853// ── Query parameters ────────────────────────────────────────────────
854
855/// Parameters for a list query.
856#[derive(Debug, Clone, Default)]
857pub struct ListParams {
858    /// Page number (1-indexed).
859    pub page: u64,
860    /// Records per page.
861    pub per_page: u64,
862    /// Full-text search query.
863    pub search: Option<String>,
864    /// Column to sort by.
865    pub sort_by: Option<String>,
866    /// Sort direction.
867    pub sort_dir: SortDirection,
868    /// Active filters (`field_name` → value).
869    pub filters: Vec<(String, String)>,
870}
871
872impl ListParams {
873    /// SQL `OFFSET`/`LIMIT` for this page, ready to bind directly.
874    ///
875    /// `per_page == 0` means "no limit": offset 0, limit `i64::MAX`. Every
876    /// built-in `AdminModel::list()` shares this convention.
877    #[must_use]
878    pub fn sql_offset_limit(&self) -> (i64, i64) {
879        if self.per_page == 0 {
880            return (0, i64::MAX);
881        }
882        let offset = self.page.saturating_sub(1) * self.per_page;
883        (
884            i64::try_from(offset).unwrap_or(0),
885            i64::try_from(self.per_page).unwrap_or(i64::MAX),
886        )
887    }
888}
889
890/// Sort direction for list queries.
891#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
892#[serde(rename_all = "lowercase")]
893pub enum SortDirection {
894    #[default]
895    Asc,
896    Desc,
897}
898
899impl SortDirection {
900    /// URL-friendly representation (`"asc"` / `"desc"`).
901    #[must_use]
902    pub const fn as_str(self) -> &'static str {
903        match self {
904            Self::Asc => "asc",
905            Self::Desc => "desc",
906        }
907    }
908
909    /// The opposite direction (used to flip sort on re-click).
910    #[must_use]
911    pub const fn flipped(self) -> Self {
912        match self {
913            Self::Asc => Self::Desc,
914            Self::Desc => Self::Asc,
915        }
916    }
917}
918
919/// Result of a list query, containing records and pagination metadata.
920#[derive(Debug, Clone)]
921pub struct ListResult {
922    /// The records for the current page (as JSON objects).
923    pub records: Vec<Value>,
924    /// Total number of records matching the query (for pagination).
925    pub total: u64,
926    /// Current page number.
927    pub page: u64,
928    /// Records per page.
929    pub per_page: u64,
930}
931
932impl ListResult {
933    /// Total number of pages.
934    #[must_use]
935    pub const fn total_pages(&self) -> u64 {
936        if self.per_page == 0 {
937            return 0;
938        }
939        self.total.div_ceil(self.per_page)
940    }
941}
942
943// ── Helpers ─────────────────────────────────────────────────────────
944
945/// Prefix a CSV cell value with `'` when it starts with a formula-triggering
946/// character (`=`, `+`, `-`, `@`, tab, or CR) to prevent spreadsheet formula
947/// injection.
948fn escape_csv_formula(s: &str) -> String {
949    match s.bytes().next() {
950        Some(b'=' | b'+' | b'-' | b'@' | b'\t' | b'\r') => {
951            let mut out = String::with_capacity(s.len() + 1);
952            out.push('\'');
953            out.push_str(s);
954            out
955        }
956        _ => s.to_owned(),
957    }
958}
959
960/// Convert a `snake_case` field name to a human-readable label.
961///
962/// `"created_at"` → `"Created At"`, `"user_id"` → `"User Id"`.
963fn humanize_field_name(name: &str) -> String {
964    let mut s = String::with_capacity(name.len());
965    for (i, word) in name.split('_').enumerate() {
966        if i > 0 {
967            s.push(' ');
968        }
969        let mut chars = word.chars();
970        if let Some(c) = chars.next() {
971            s.extend(c.to_uppercase());
972            s.extend(chars);
973        }
974    }
975    s
976}
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use std::sync::Mutex;
982
983    /// Test fixture: an `AdminModel` whose `delete` records the id it was
984    /// asked to delete. Doesn't override `execute_action` — that's the
985    /// behaviour under test.
986    struct DeletingModel {
987        deleted: Mutex<Vec<i64>>,
988        fail_on: Option<i64>,
989    }
990
991    impl AdminModel for DeletingModel {
992        fn slug(&self) -> &'static str {
993            "tracked"
994        }
995        fn display_name(&self) -> &'static str {
996            "Tracked"
997        }
998        fn display_name_plural(&self) -> &'static str {
999            "Tracked"
1000        }
1001        fn fields(&self) -> Vec<AdminField> {
1002            vec![]
1003        }
1004        fn list(
1005            &self,
1006            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1007                ::autumn_web::RuntimeConnection,
1008            >,
1009            _params: ListParams,
1010        ) -> AdminFuture<'_, ListResult> {
1011            Box::pin(async {
1012                Ok(ListResult {
1013                    records: vec![],
1014                    total: 0,
1015                    page: 1,
1016                    per_page: 25,
1017                })
1018            })
1019        }
1020        fn get(
1021            &self,
1022            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1023                ::autumn_web::RuntimeConnection,
1024            >,
1025            _id: i64,
1026        ) -> AdminFuture<'_, Option<Value>> {
1027            Box::pin(async { Ok(None) })
1028        }
1029        fn create(
1030            &self,
1031            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1032                ::autumn_web::RuntimeConnection,
1033            >,
1034            data: Value,
1035        ) -> AdminFuture<'_, Value> {
1036            Box::pin(async move { Ok(data) })
1037        }
1038        fn update(
1039            &self,
1040            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1041                ::autumn_web::RuntimeConnection,
1042            >,
1043            _id: i64,
1044            data: Value,
1045        ) -> AdminFuture<'_, Value> {
1046            Box::pin(async move { Ok(data) })
1047        }
1048        fn delete(
1049            &self,
1050            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1051                ::autumn_web::RuntimeConnection,
1052            >,
1053            id: i64,
1054        ) -> AdminFuture<'_, ()> {
1055            let deleted = &self.deleted;
1056            let fail_on = self.fail_on;
1057            Box::pin(async move {
1058                if Some(id) == fail_on {
1059                    return Err(AdminError::Database("simulated failure".into()));
1060                }
1061                deleted.lock().unwrap().push(id);
1062                Ok(())
1063            })
1064        }
1065    }
1066
1067    /// Test fixture: an `AdminModel` that supports soft delete and records
1068    /// which ids were restored/purged. Overrides `supports_soft_delete()` and
1069    /// the three soft-delete methods.
1070    #[derive(Default)]
1071    struct SoftDeleteModel {
1072        restored: Mutex<Vec<i64>>,
1073        purged: Mutex<Vec<i64>>,
1074    }
1075
1076    impl AdminModel for SoftDeleteModel {
1077        fn slug(&self) -> &'static str {
1078            "soft"
1079        }
1080        fn display_name(&self) -> &'static str {
1081            "Soft"
1082        }
1083        fn display_name_plural(&self) -> &'static str {
1084            "Softs"
1085        }
1086        fn fields(&self) -> Vec<AdminField> {
1087            vec![]
1088        }
1089        fn list(
1090            &self,
1091            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1092                ::autumn_web::RuntimeConnection,
1093            >,
1094            _params: ListParams,
1095        ) -> AdminFuture<'_, ListResult> {
1096            Box::pin(async {
1097                Ok(ListResult {
1098                    records: vec![],
1099                    total: 0,
1100                    page: 1,
1101                    per_page: 25,
1102                })
1103            })
1104        }
1105        fn get(
1106            &self,
1107            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1108                ::autumn_web::RuntimeConnection,
1109            >,
1110            _id: i64,
1111        ) -> AdminFuture<'_, Option<Value>> {
1112            Box::pin(async { Ok(None) })
1113        }
1114        fn create(
1115            &self,
1116            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1117                ::autumn_web::RuntimeConnection,
1118            >,
1119            data: Value,
1120        ) -> AdminFuture<'_, Value> {
1121            Box::pin(async move { Ok(data) })
1122        }
1123        fn update(
1124            &self,
1125            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1126                ::autumn_web::RuntimeConnection,
1127            >,
1128            _id: i64,
1129            data: Value,
1130        ) -> AdminFuture<'_, Value> {
1131            Box::pin(async move { Ok(data) })
1132        }
1133        fn delete(
1134            &self,
1135            _pool: &diesel_async::pooled_connection::deadpool::Pool<
1136                ::autumn_web::RuntimeConnection,
1137            >,
1138            _id: i64,
1139        ) -> AdminFuture<'_, ()> {
1140            Box::pin(async { Ok(()) })
1141        }
1142        fn supports_soft_delete(&self) -> bool {
1143            true
1144        }
1145        fn restore<'a>(
1146            &'a self,
1147            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1148                ::autumn_web::RuntimeConnection,
1149            >,
1150            id: i64,
1151        ) -> AdminFuture<'a, ()> {
1152            Box::pin(async move {
1153                self.restored.lock().unwrap().push(id);
1154                Ok(())
1155            })
1156        }
1157        fn purge<'a>(
1158            &'a self,
1159            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1160                ::autumn_web::RuntimeConnection,
1161            >,
1162            id: i64,
1163        ) -> AdminFuture<'a, ()> {
1164            Box::pin(async move {
1165                self.purged.lock().unwrap().push(id);
1166                Ok(())
1167            })
1168        }
1169        fn list_deleted<'a>(
1170            &'a self,
1171            _pool: &'a diesel_async::pooled_connection::deadpool::Pool<
1172                ::autumn_web::RuntimeConnection,
1173            >,
1174            _params: ListParams,
1175        ) -> AdminFuture<'a, ListResult> {
1176            Box::pin(async {
1177                Ok(ListResult {
1178                    records: vec![],
1179                    total: 0,
1180                    page: 1,
1181                    per_page: 25,
1182                })
1183            })
1184        }
1185    }
1186
1187    /// Build a `Pool` whose manager would fail to connect — the test models
1188    /// never call `pool.get()`, so the pool itself just sits unused.
1189    fn dummy_pool()
1190    -> diesel_async::pooled_connection::deadpool::Pool<::autumn_web::RuntimeConnection> {
1191        use diesel_async::pooled_connection::AsyncDieselConnectionManager;
1192        use diesel_async::pooled_connection::deadpool::Pool;
1193        let mgr = AsyncDieselConnectionManager::<::autumn_web::RuntimeConnection>::new(
1194            "postgresql://test",
1195        );
1196        Pool::builder(mgr).build().expect("build pool")
1197    }
1198
1199    #[tokio::test]
1200    async fn default_execute_action_delete_invokes_delete_for_each_id() {
1201        let model = DeletingModel {
1202            deleted: Mutex::new(vec![]),
1203            fail_on: None,
1204        };
1205        let pool = dummy_pool();
1206        let count = model
1207            .execute_action(&pool, "delete", vec![10, 20, 30])
1208            .await
1209            .expect("default delete should succeed");
1210        assert_eq!(count, 3);
1211        assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
1212    }
1213
1214    #[tokio::test]
1215    async fn default_execute_action_delete_aborts_on_first_failure() {
1216        let model = DeletingModel {
1217            deleted: Mutex::new(vec![]),
1218            fail_on: Some(20),
1219        };
1220        let pool = dummy_pool();
1221        let err = model
1222            .execute_action(&pool, "delete", vec![10, 20, 30])
1223            .await
1224            .expect_err("delete should propagate failure");
1225        assert!(matches!(err, AdminError::Database(_)));
1226        // Only the pre-failure id was committed.
1227        assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
1228    }
1229
1230    #[tokio::test]
1231    async fn default_execute_action_rejects_unknown_action() {
1232        let model = DeletingModel {
1233            deleted: Mutex::new(vec![]),
1234            fail_on: None,
1235        };
1236        let pool = dummy_pool();
1237        let err = model
1238            .execute_action(&pool, "promote", vec![1])
1239            .await
1240            .expect_err("unknown actions must error, not silently no-op");
1241        assert!(
1242            matches!(err, AdminError::Other(msg) if msg.contains("promote")),
1243            "error should name the unhandled action"
1244        );
1245        assert!(model.deleted.lock().unwrap().is_empty());
1246    }
1247
1248    #[test]
1249    fn humanize_converts_snake_case() {
1250        assert_eq!(humanize_field_name("created_at"), "Created At");
1251        assert_eq!(humanize_field_name("user_id"), "User Id");
1252        assert_eq!(humanize_field_name("name"), "Name");
1253        assert_eq!(humanize_field_name(""), "");
1254    }
1255
1256    #[test]
1257    fn list_result_total_pages() {
1258        let result = ListResult {
1259            records: vec![],
1260            total: 25,
1261            page: 1,
1262            per_page: 10,
1263        };
1264        assert_eq!(result.total_pages(), 3);
1265    }
1266
1267    #[test]
1268    fn list_result_total_pages_exact() {
1269        let result = ListResult {
1270            records: vec![],
1271            total: 20,
1272            page: 1,
1273            per_page: 10,
1274        };
1275        assert_eq!(result.total_pages(), 2);
1276    }
1277
1278    #[test]
1279    fn list_result_total_pages_zero_per_page() {
1280        let result = ListResult {
1281            records: vec![],
1282            total: 20,
1283            page: 1,
1284            per_page: 0,
1285        };
1286        assert_eq!(result.total_pages(), 0);
1287    }
1288
1289    #[test]
1290    fn admin_field_builder() {
1291        let field = AdminField::new("email", AdminFieldKind::Text)
1292            .label("Email Address")
1293            .searchable()
1294            .filterable()
1295            .optional();
1296
1297        assert_eq!(field.name, "email");
1298        assert_eq!(field.label, "Email Address");
1299        assert!(field.searchable);
1300        assert!(field.filterable);
1301        assert!(!field.required);
1302        assert!(field.editable);
1303    }
1304
1305    #[test]
1306    fn record_id_extracts_numeric_id() {
1307        assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
1308    }
1309
1310    #[test]
1311    fn record_id_returns_none_for_missing_or_non_numeric() {
1312        assert_eq!(record_id(&serde_json::json!({})), None);
1313        assert_eq!(record_id(&serde_json::json!({"id": null})), None);
1314        assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
1315        // Floats aren't valid IDs either — only integers.
1316        assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
1317    }
1318
1319    #[test]
1320    fn hidden_fields_default_to_not_editable() {
1321        // AdminFieldKind::Hidden is documented as "not editable". Ensure the
1322        // default matches the contract so admins who skip `.readonly()` still
1323        // get safe behaviour.
1324        let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
1325        assert!(
1326            !hidden.editable,
1327            "Hidden fields must default to editable=false"
1328        );
1329
1330        // Other kinds remain editable by default.
1331        let text = AdminField::new("name", AdminFieldKind::Text);
1332        assert!(text.editable);
1333    }
1334
1335    // ── Soft-delete admin support (issue #689) ────────────────────
1336
1337    #[test]
1338    fn admin_model_supports_soft_delete_defaults_to_false() {
1339        let model = DeletingModel {
1340            deleted: Mutex::new(vec![]),
1341            fail_on: None,
1342        };
1343        assert!(
1344            !model.supports_soft_delete(),
1345            "AdminModel::supports_soft_delete() must default to false"
1346        );
1347    }
1348
1349    #[tokio::test]
1350    async fn admin_model_restore_returns_error_when_soft_delete_not_supported() {
1351        let model = DeletingModel {
1352            deleted: Mutex::new(vec![]),
1353            fail_on: None,
1354        };
1355        let pool = dummy_pool();
1356        let err = model
1357            .restore(&pool, 1)
1358            .await
1359            .expect_err("restore must error when supports_soft_delete() is false");
1360        assert!(
1361            matches!(err, AdminError::Other(_)),
1362            "restore on non-soft-delete model must return AdminError::Other: {err:?}"
1363        );
1364    }
1365
1366    #[tokio::test]
1367    async fn admin_model_purge_returns_error_when_soft_delete_not_supported() {
1368        let model = DeletingModel {
1369            deleted: Mutex::new(vec![]),
1370            fail_on: None,
1371        };
1372        let pool = dummy_pool();
1373        let err = model
1374            .purge(&pool, 1)
1375            .await
1376            .expect_err("purge must error when supports_soft_delete() is false");
1377        assert!(
1378            matches!(err, AdminError::Other(_)),
1379            "purge on non-soft-delete model must return AdminError::Other: {err:?}"
1380        );
1381    }
1382
1383    #[tokio::test]
1384    async fn admin_model_list_deleted_returns_error_when_soft_delete_not_supported() {
1385        let model = DeletingModel {
1386            deleted: Mutex::new(vec![]),
1387            fail_on: None,
1388        };
1389        let pool = dummy_pool();
1390        let params = ListParams {
1391            page: 1,
1392            per_page: 25,
1393            ..Default::default()
1394        };
1395        let err = model
1396            .list_deleted(&pool, params)
1397            .await
1398            .expect_err("list_deleted must error when supports_soft_delete() is false");
1399        assert!(
1400            matches!(err, AdminError::Other(_)),
1401            "list_deleted on non-soft-delete model must return AdminError::Other: {err:?}"
1402        );
1403    }
1404
1405    #[test]
1406    fn default_actions_returns_only_delete_when_soft_delete_not_supported() {
1407        let model = DeletingModel {
1408            deleted: Mutex::new(vec![]),
1409            fail_on: None,
1410        };
1411        let acts = model.actions();
1412        assert_eq!(
1413            acts.len(),
1414            1,
1415            "default model must advertise exactly one action"
1416        );
1417        assert_eq!(acts[0].name, "delete");
1418    }
1419
1420    #[test]
1421    fn actions_includes_restore_and_purge_when_soft_delete_supported() {
1422        let model = SoftDeleteModel::default();
1423        let acts = model.actions();
1424        let names: Vec<&str> = acts.iter().map(|a| a.name).collect();
1425        assert!(
1426            names.contains(&"restore"),
1427            "soft-delete model must advertise restore action; got: {names:?}"
1428        );
1429        assert!(
1430            names.contains(&"purge"),
1431            "soft-delete model must advertise purge action; got: {names:?}"
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn execute_action_restore_dispatches_to_restore_method() {
1437        let model = SoftDeleteModel::default();
1438        let pool = dummy_pool();
1439        let count = model
1440            .execute_action(&pool, "restore", vec![10, 20])
1441            .await
1442            .expect("restore action should succeed on soft-delete model");
1443        assert_eq!(
1444            count, 2,
1445            "restore action must return count of restored records"
1446        );
1447        assert_eq!(*model.restored.lock().unwrap(), vec![10, 20]);
1448    }
1449
1450    #[tokio::test]
1451    async fn execute_action_purge_dispatches_to_purge_method() {
1452        let model = SoftDeleteModel::default();
1453        let pool = dummy_pool();
1454        let count = model
1455            .execute_action(&pool, "purge", vec![5])
1456            .await
1457            .expect("purge action should succeed on soft-delete model");
1458        assert_eq!(count, 1, "purge action must return count of purged records");
1459        assert_eq!(*model.purged.lock().unwrap(), vec![5]);
1460    }
1461
1462    // ── Version history tests (issue #700) ────────────────────────
1463
1464    #[test]
1465    fn admin_model_has_history_defaults_to_false() {
1466        let model = DeletingModel {
1467            deleted: Mutex::new(vec![]),
1468            fail_on: None,
1469        };
1470        assert!(
1471            !model.has_history(),
1472            "AdminModel::has_history() must default to false"
1473        );
1474    }
1475
1476    #[tokio::test]
1477    async fn admin_model_get_history_returns_error_when_not_opted_in() {
1478        let model = DeletingModel {
1479            deleted: Mutex::new(vec![]),
1480            fail_on: None,
1481        };
1482        let pool = dummy_pool();
1483        let err = model
1484            .get_history(&pool, 42, 1, 25)
1485            .await
1486            .expect_err("get_history must error when has_history() is false");
1487        assert!(
1488            matches!(err, AdminError::Other(_)),
1489            "get_history on non-versioned model must return AdminError::Other: {err:?}"
1490        );
1491    }
1492
1493    #[test]
1494    fn admin_history_page_total_pages() {
1495        let page = AdminHistoryPage {
1496            entries: vec![],
1497            total: 51,
1498            page: 1,
1499            per_page: 25,
1500        };
1501        assert_eq!(page.total_pages(), 3);
1502    }
1503
1504    #[test]
1505    fn admin_history_page_has_next_page() {
1506        let page = AdminHistoryPage {
1507            entries: vec![],
1508            total: 50,
1509            page: 1,
1510            per_page: 25,
1511        };
1512        assert!(page.has_next_page());
1513    }
1514
1515    #[test]
1516    fn admin_history_page_no_next_on_last() {
1517        let page = AdminHistoryPage {
1518            entries: vec![],
1519            total: 50,
1520            page: 2,
1521            per_page: 25,
1522        };
1523        assert!(!page.has_next_page());
1524    }
1525
1526    #[test]
1527    fn admin_history_page_zero_per_page() {
1528        let page = AdminHistoryPage {
1529            entries: vec![],
1530            total: 10,
1531            page: 1,
1532            per_page: 0,
1533        };
1534        assert_eq!(page.total_pages(), 0);
1535    }
1536
1537    // ── SortDirection and AdminField builder coverage ─────────────
1538
1539    #[test]
1540    fn sort_direction_as_str_returns_correct_values() {
1541        assert_eq!(SortDirection::Asc.as_str(), "asc");
1542        assert_eq!(SortDirection::Desc.as_str(), "desc");
1543    }
1544
1545    #[test]
1546    fn sort_direction_flipped_returns_opposite() {
1547        assert_eq!(SortDirection::Asc.flipped(), SortDirection::Desc);
1548        assert_eq!(SortDirection::Desc.flipped(), SortDirection::Asc);
1549    }
1550
1551    #[test]
1552    fn admin_field_readonly_sets_editable_false() {
1553        let field = AdminField::new("created_at", AdminFieldKind::DateTime).readonly();
1554        assert!(!field.editable, "readonly() must set editable = false");
1555    }
1556
1557    #[test]
1558    fn admin_field_hide_from_list_sets_list_display_false() {
1559        let field = AdminField::new("internal_token", AdminFieldKind::Text).hide_from_list();
1560        assert!(
1561            !field.list_display,
1562            "hide_from_list() must set list_display = false"
1563        );
1564    }
1565
1566    #[test]
1567    fn admin_model_record_display_includes_display_name_and_id() {
1568        let model = DeletingModel {
1569            deleted: Mutex::new(vec![]),
1570            fail_on: None,
1571        };
1572        let record = serde_json::json!({"id": 7, "name": "foo"});
1573        assert_eq!(model.record_display(&record), "Tracked #7");
1574    }
1575
1576    #[test]
1577    fn admin_model_record_display_placeholder_when_no_id() {
1578        let model = DeletingModel {
1579            deleted: Mutex::new(vec![]),
1580            fail_on: None,
1581        };
1582        let record = serde_json::json!({"name": "bar"});
1583        assert_eq!(model.record_display(&record), "Tracked <no id>");
1584    }
1585
1586    #[test]
1587    fn admin_model_per_page_default_is_25() {
1588        let model = DeletingModel {
1589            deleted: Mutex::new(vec![]),
1590            fail_on: None,
1591        };
1592        assert_eq!(model.per_page(), 25);
1593    }
1594
1595    #[test]
1596    fn version_page_converts_to_admin_history_page() {
1597        use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp, VersionPage};
1598        use chrono::Utc;
1599
1600        let entry = VersionEntry {
1601            id: 1,
1602            table_name: "posts".to_owned(),
1603            record_id: 42,
1604            op: VersionOp::Update,
1605            actor: "admin".to_owned(),
1606            request_id: Some("req-1".to_owned()),
1607            changes: vec![ColumnChange::new(
1608                "title",
1609                Some(serde_json::json!("old")),
1610                Some(serde_json::json!("new")),
1611            )],
1612            recorded_at: Utc::now(),
1613        };
1614        let vp = VersionPage {
1615            entries: vec![entry],
1616            total: 1,
1617            page: 1,
1618            per_page: 25,
1619        };
1620
1621        let ap = AdminHistoryPage::from(vp);
1622        assert_eq!(ap.total, 1);
1623        assert_eq!(ap.page, 1);
1624        assert_eq!(ap.per_page, 25);
1625        assert_eq!(ap.entries.len(), 1);
1626        let e = &ap.entries[0];
1627        assert_eq!(e.id, 1);
1628        assert_eq!(e.actor, "admin");
1629        assert_eq!(e.op, "update");
1630        assert_eq!(e.request_id.as_deref(), Some("req-1"));
1631        assert_eq!(e.changes.len(), 1);
1632    }
1633
1634    #[test]
1635    fn version_entry_converts_to_admin_history_entry() {
1636        use autumn_web::version_history::{ColumnChange, VersionEntry, VersionOp};
1637        use chrono::Utc;
1638
1639        let entry = VersionEntry {
1640            id: 7,
1641            table_name: "users".to_owned(),
1642            record_id: 3,
1643            op: VersionOp::Delete,
1644            actor: "system".to_owned(),
1645            request_id: None,
1646            changes: vec![ColumnChange::sensitive("password_digest")],
1647            recorded_at: Utc::now(),
1648        };
1649
1650        let admin_entry = AdminHistoryEntry::from(entry);
1651        assert_eq!(admin_entry.id, 7);
1652        assert_eq!(admin_entry.actor, "system");
1653        assert_eq!(admin_entry.op, "delete");
1654        assert!(admin_entry.request_id.is_none());
1655        assert_eq!(admin_entry.changes.len(), 1);
1656    }
1657
1658    // ── CsvImportMode ────────────────────────────────────────────────
1659
1660    #[test]
1661    fn csv_import_mode_from_form_value_recognises_insert() {
1662        assert_eq!(
1663            CsvImportMode::from_form_value("insert"),
1664            Some(CsvImportMode::Insert)
1665        );
1666        assert_eq!(
1667            CsvImportMode::from_form_value("Insert"),
1668            Some(CsvImportMode::Insert)
1669        );
1670    }
1671
1672    #[test]
1673    fn csv_import_mode_from_form_value_recognises_dry_run() {
1674        assert_eq!(
1675            CsvImportMode::from_form_value("dry_run"),
1676            Some(CsvImportMode::DryRun)
1677        );
1678        assert_eq!(
1679            CsvImportMode::from_form_value("DryRun"),
1680            Some(CsvImportMode::DryRun)
1681        );
1682        assert_eq!(
1683            CsvImportMode::from_form_value("dry-run"),
1684            Some(CsvImportMode::DryRun)
1685        );
1686    }
1687
1688    #[test]
1689    fn csv_import_mode_from_form_value_rejects_unknown() {
1690        assert_eq!(CsvImportMode::from_form_value("upsert"), None);
1691        assert_eq!(CsvImportMode::from_form_value(""), None);
1692        assert_eq!(CsvImportMode::from_form_value("INSERT"), None);
1693        assert_eq!(CsvImportMode::from_form_value("DRY_RUN"), None);
1694    }
1695
1696    #[test]
1697    fn csv_import_mode_default_is_insert() {
1698        assert_eq!(CsvImportMode::default(), CsvImportMode::Insert);
1699    }
1700
1701    // ── escape_csv_formula ──────────────────────────────────────────
1702
1703    #[test]
1704    fn escape_csv_formula_prefixes_equals_sign() {
1705        assert_eq!(escape_csv_formula("=SUM(A1)"), "'=SUM(A1)");
1706    }
1707
1708    #[test]
1709    fn escape_csv_formula_prefixes_plus_and_minus_and_at() {
1710        assert_eq!(escape_csv_formula("+cmd"), "'+cmd");
1711        assert_eq!(escape_csv_formula("-1+1"), "'-1+1");
1712        assert_eq!(escape_csv_formula("@A1"), "'@A1");
1713    }
1714
1715    #[test]
1716    fn escape_csv_formula_prefixes_tab_and_cr() {
1717        assert_eq!(escape_csv_formula("\thello"), "'\thello");
1718        assert_eq!(escape_csv_formula("\rhello"), "'\rhello");
1719    }
1720
1721    #[test]
1722    fn escape_csv_formula_leaves_normal_strings_unchanged() {
1723        assert_eq!(escape_csv_formula("hello world"), "hello world");
1724        assert_eq!(escape_csv_formula("123"), "123");
1725        assert_eq!(escape_csv_formula(""), "");
1726        assert_eq!(escape_csv_formula("normal,value"), "normal,value");
1727    }
1728
1729    // ── AdminModel CSV defaults ──────────────────────────────────────
1730
1731    #[test]
1732    fn admin_model_supports_csv_export_defaults_to_false() {
1733        let model = DeletingModel {
1734            deleted: Mutex::new(vec![]),
1735            fail_on: None,
1736        };
1737        assert!(
1738            !model.supports_csv_export(),
1739            "supports_csv_export must default to false to require explicit opt-in"
1740        );
1741    }
1742
1743    #[test]
1744    fn admin_model_supports_csv_import_defaults_to_false() {
1745        let model = DeletingModel {
1746            deleted: Mutex::new(vec![]),
1747            fail_on: None,
1748        };
1749        assert!(
1750            !model.supports_csv_import(),
1751            "supports_csv_import must default to false"
1752        );
1753    }
1754
1755    #[test]
1756    fn csv_export_row_extracts_columns_and_escapes_formulas() {
1757        let model = DeletingModel {
1758            deleted: Mutex::new(vec![]),
1759            fail_on: None,
1760        };
1761        let record = serde_json::json!({
1762            "id": 1,
1763            "name": "Alice",
1764            "formula": "=EVIL()",
1765            "amount": 42.5,
1766            "active": true,
1767            "notes": null,
1768        });
1769        let columns = &[
1770            "id", "name", "formula", "amount", "active", "notes", "missing",
1771        ];
1772        let row = model.csv_export_row(columns, &record);
1773        assert_eq!(row[0], "1");
1774        assert_eq!(row[1], "Alice");
1775        assert_eq!(row[2], "'=EVIL()", "formula-leading value must be escaped");
1776        assert_eq!(row[3], "42.5");
1777        assert_eq!(row[4], "true");
1778        assert_eq!(row[5], "", "null becomes empty string");
1779        assert_eq!(row[6], "", "missing column becomes empty string");
1780    }
1781
1782    fn list_params(page: u64, per_page: u64) -> ListParams {
1783        ListParams {
1784            page,
1785            per_page,
1786            search: None,
1787            sort_by: None,
1788            sort_dir: SortDirection::default(),
1789            filters: Vec::new(),
1790        }
1791    }
1792
1793    #[test]
1794    fn sql_offset_limit_treats_per_page_zero_as_unlimited() {
1795        assert_eq!(list_params(1, 0).sql_offset_limit(), (0, i64::MAX));
1796        // Even on page 3 — "no limit" ignores paging entirely.
1797        assert_eq!(list_params(3, 0).sql_offset_limit(), (0, i64::MAX));
1798    }
1799
1800    #[test]
1801    fn sql_offset_limit_paginates_from_page_one() {
1802        assert_eq!(list_params(1, 25).sql_offset_limit(), (0, 25));
1803        assert_eq!(list_params(2, 25).sql_offset_limit(), (25, 25));
1804        assert_eq!(list_params(3, 10).sql_offset_limit(), (20, 10));
1805    }
1806
1807    #[test]
1808    fn sql_offset_limit_treats_page_zero_like_page_one() {
1809        // `page.saturating_sub(1)` — page 0 behaves like page 1 rather than
1810        // underflowing.
1811        assert_eq!(list_params(0, 25).sql_offset_limit(), (0, 25));
1812    }
1813}