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