use std::future::Future;
use std::pin::Pin;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdminFieldKind {
Text,
TextArea,
Integer,
Float,
Boolean,
Date,
DateTime,
Select(Vec<SelectOption>),
Hidden,
Password,
Json,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectOption {
pub value: String,
pub label: String,
}
#[derive(Debug, Clone)]
#[allow(clippy::struct_excessive_bools)] pub struct AdminField {
pub name: &'static str,
pub label: String,
pub kind: AdminFieldKind,
pub list_display: bool,
pub searchable: bool,
pub filterable: bool,
pub required: bool,
pub editable: bool,
pub sortable: bool,
}
impl AdminField {
#[must_use]
pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
let editable = !matches!(kind, AdminFieldKind::Hidden);
Self {
name,
label: humanize_field_name(name),
kind,
list_display: true,
searchable: false,
filterable: false,
required: true,
editable,
sortable: true,
}
}
#[must_use]
pub fn label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub const fn searchable(mut self) -> Self {
self.searchable = true;
self
}
#[must_use]
pub const fn filterable(mut self) -> Self {
self.filterable = true;
self
}
#[must_use]
pub const fn optional(mut self) -> Self {
self.required = false;
self
}
#[must_use]
pub const fn readonly(mut self) -> Self {
self.editable = false;
self
}
#[must_use]
pub const fn hide_from_list(mut self) -> Self {
self.list_display = false;
self
}
}
pub struct AdminAction {
pub name: &'static str,
pub label: String,
pub style: ActionStyle,
pub confirm: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionStyle {
Default,
Primary,
Danger,
}
pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
#[derive(Debug, thiserror::Error)]
pub enum AdminError {
#[error("Record not found")]
NotFound,
#[error("Validation failed: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(String),
#[error("{0}")]
Other(String),
}
pub trait AdminModel: Send + Sync + 'static {
fn slug(&self) -> &'static str;
fn display_name(&self) -> &'static str;
fn display_name_plural(&self) -> &'static str;
fn fields(&self) -> Vec<AdminField>;
fn actions(&self) -> Vec<AdminAction> {
vec![AdminAction {
name: "delete",
label: "Delete selected".to_owned(),
style: ActionStyle::Danger,
confirm: true,
}]
}
fn list(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
params: ListParams,
) -> AdminFuture<'_, ListResult>;
fn get(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, Option<Value>>;
fn create(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
data: Value,
) -> AdminFuture<'_, Value>;
fn update(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
data: Value,
) -> AdminFuture<'_, Value>;
fn delete(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
id: i64,
) -> AdminFuture<'_, ()>;
fn execute_action(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
action: &str,
ids: Vec<i64>,
) -> AdminFuture<'_, u64> {
let action = action.to_owned();
let pool = pool.clone();
Box::pin(async move {
match action.as_str() {
"delete" => {
let mut count: u64 = 0;
for id in ids {
self.delete(&pool, id).await?;
count += 1;
}
Ok(count)
}
other => Err(AdminError::Other(format!(
"unhandled bulk action '{other}'; \
override AdminModel::execute_action to support it"
))),
}
})
}
fn record_display(&self, record: &Value) -> String {
record_id(record).map_or_else(
|| format!("{} <no id>", self.display_name()),
|id| format!("{} #{id}", self.display_name()),
)
}
fn per_page(&self) -> u64 {
25
}
fn count(
&self,
pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
) -> AdminFuture<'_, u64> {
let params = ListParams {
page: 1,
per_page: 0,
..Default::default()
};
let fut = self.list(pool, params);
Box::pin(async move { fut.await.map(|r| r.total) })
}
}
#[must_use]
pub fn record_id(record: &Value) -> Option<i64> {
record.get("id").and_then(Value::as_i64)
}
#[derive(Debug, Clone, Default)]
pub struct ListParams {
pub page: u64,
pub per_page: u64,
pub search: Option<String>,
pub sort_by: Option<String>,
pub sort_dir: SortDirection,
pub filters: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortDirection {
#[default]
Asc,
Desc,
}
impl SortDirection {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Asc => "asc",
Self::Desc => "desc",
}
}
#[must_use]
pub const fn flipped(self) -> Self {
match self {
Self::Asc => Self::Desc,
Self::Desc => Self::Asc,
}
}
}
#[derive(Debug, Clone)]
pub struct ListResult {
pub records: Vec<Value>,
pub total: u64,
pub page: u64,
pub per_page: u64,
}
impl ListResult {
#[must_use]
pub const fn total_pages(&self) -> u64 {
if self.per_page == 0 {
return 0;
}
self.total.div_ceil(self.per_page)
}
}
fn humanize_field_name(name: &str) -> String {
name.split('_')
.map(|word| {
let mut chars = word.chars();
chars.next().map_or_else(String::new, |c| {
let mut s = c.to_uppercase().to_string();
s.extend(chars);
s
})
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct DeletingModel {
deleted: Mutex<Vec<i64>>,
fail_on: Option<i64>,
}
impl AdminModel for DeletingModel {
fn slug(&self) -> &'static str {
"tracked"
}
fn display_name(&self) -> &'static str {
"Tracked"
}
fn display_name_plural(&self) -> &'static str {
"Tracked"
}
fn fields(&self) -> Vec<AdminField> {
vec![]
}
fn list(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_params: ListParams,
) -> AdminFuture<'_, ListResult> {
Box::pin(async {
Ok(ListResult {
records: vec![],
total: 0,
page: 1,
per_page: 25,
})
})
}
fn get(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
) -> AdminFuture<'_, Option<Value>> {
Box::pin(async { Ok(None) })
}
fn create(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn update(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
_id: i64,
data: Value,
) -> AdminFuture<'_, Value> {
Box::pin(async move { Ok(data) })
}
fn delete(
&self,
_pool: &diesel_async::pooled_connection::deadpool::Pool<
diesel_async::AsyncPgConnection,
>,
id: i64,
) -> AdminFuture<'_, ()> {
let deleted = &self.deleted;
let fail_on = self.fail_on;
Box::pin(async move {
if Some(id) == fail_on {
return Err(AdminError::Database("simulated failure".into()));
}
deleted.lock().unwrap().push(id);
Ok(())
})
}
}
fn dummy_pool()
-> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
use diesel_async::pooled_connection::AsyncDieselConnectionManager;
use diesel_async::pooled_connection::deadpool::Pool;
let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
"postgresql://test",
);
Pool::builder(mgr).build().expect("build pool")
}
#[tokio::test]
async fn default_execute_action_delete_invokes_delete_for_each_id() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let count = model
.execute_action(&pool, "delete", vec![10, 20, 30])
.await
.expect("default delete should succeed");
assert_eq!(count, 3);
assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
}
#[tokio::test]
async fn default_execute_action_delete_aborts_on_first_failure() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: Some(20),
};
let pool = dummy_pool();
let err = model
.execute_action(&pool, "delete", vec![10, 20, 30])
.await
.expect_err("delete should propagate failure");
assert!(matches!(err, AdminError::Database(_)));
assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
}
#[tokio::test]
async fn default_execute_action_rejects_unknown_action() {
let model = DeletingModel {
deleted: Mutex::new(vec![]),
fail_on: None,
};
let pool = dummy_pool();
let err = model
.execute_action(&pool, "promote", vec![1])
.await
.expect_err("unknown actions must error, not silently no-op");
assert!(
matches!(err, AdminError::Other(msg) if msg.contains("promote")),
"error should name the unhandled action"
);
assert!(model.deleted.lock().unwrap().is_empty());
}
#[test]
fn humanize_converts_snake_case() {
assert_eq!(humanize_field_name("created_at"), "Created At");
assert_eq!(humanize_field_name("user_id"), "User Id");
assert_eq!(humanize_field_name("name"), "Name");
assert_eq!(humanize_field_name(""), "");
}
#[test]
fn list_result_total_pages() {
let result = ListResult {
records: vec![],
total: 25,
page: 1,
per_page: 10,
};
assert_eq!(result.total_pages(), 3);
}
#[test]
fn list_result_total_pages_exact() {
let result = ListResult {
records: vec![],
total: 20,
page: 1,
per_page: 10,
};
assert_eq!(result.total_pages(), 2);
}
#[test]
fn list_result_total_pages_zero_per_page() {
let result = ListResult {
records: vec![],
total: 20,
page: 1,
per_page: 0,
};
assert_eq!(result.total_pages(), 0);
}
#[test]
fn admin_field_builder() {
let field = AdminField::new("email", AdminFieldKind::Text)
.label("Email Address")
.searchable()
.filterable()
.optional();
assert_eq!(field.name, "email");
assert_eq!(field.label, "Email Address");
assert!(field.searchable);
assert!(field.filterable);
assert!(!field.required);
assert!(field.editable);
}
#[test]
fn record_id_extracts_numeric_id() {
assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
}
#[test]
fn record_id_returns_none_for_missing_or_non_numeric() {
assert_eq!(record_id(&serde_json::json!({})), None);
assert_eq!(record_id(&serde_json::json!({"id": null})), None);
assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
}
#[test]
fn hidden_fields_default_to_not_editable() {
let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
assert!(
!hidden.editable,
"Hidden fields must default to editable=false"
);
let text = AdminField::new("name", AdminFieldKind::Text);
assert!(text.editable);
}
}