1use std::future::Future;
4use std::pin::Pin;
5
6use serde::Deserialize;
7use serde_json::Value;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum AdminFieldKind {
14 Text,
16 TextArea,
18 Integer,
20 Float,
22 Boolean,
24 Date,
26 DateTime,
28 Select(Vec<SelectOption>),
30 Hidden,
32 Password,
34 Json,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SelectOption {
41 pub value: String,
42 pub label: String,
43}
44
45#[derive(Debug, Clone)]
47#[allow(clippy::struct_excessive_bools)] pub struct AdminField {
49 pub name: &'static str,
51 pub label: String,
53 pub kind: AdminFieldKind,
55 pub list_display: bool,
57 pub searchable: bool,
59 pub filterable: bool,
61 pub required: bool,
63 pub editable: bool,
65 pub sortable: bool,
67}
68
69impl AdminField {
70 #[must_use]
78 pub fn new(name: &'static str, kind: AdminFieldKind) -> Self {
79 let editable = !matches!(kind, AdminFieldKind::Hidden);
80 Self {
81 name,
82 label: humanize_field_name(name),
83 kind,
84 list_display: true,
85 searchable: false,
86 filterable: false,
87 required: true,
88 editable,
89 sortable: true,
90 }
91 }
92
93 #[must_use]
95 pub fn label(mut self, label: impl Into<String>) -> Self {
96 self.label = label.into();
97 self
98 }
99
100 #[must_use]
102 pub const fn searchable(mut self) -> Self {
103 self.searchable = true;
104 self
105 }
106
107 #[must_use]
109 pub const fn filterable(mut self) -> Self {
110 self.filterable = true;
111 self
112 }
113
114 #[must_use]
116 pub const fn optional(mut self) -> Self {
117 self.required = false;
118 self
119 }
120
121 #[must_use]
123 pub const fn readonly(mut self) -> Self {
124 self.editable = false;
125 self
126 }
127
128 #[must_use]
130 pub const fn hide_from_list(mut self) -> Self {
131 self.list_display = false;
132 self
133 }
134}
135
136pub struct AdminAction {
140 pub name: &'static str,
142 pub label: String,
144 pub style: ActionStyle,
146 pub confirm: bool,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum ActionStyle {
153 Default,
155 Primary,
157 Danger,
159}
160
161pub type AdminFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, AdminError>> + Send + 'a>>;
165
166#[derive(Debug, thiserror::Error)]
168pub enum AdminError {
169 #[error("Record not found")]
170 NotFound,
171
172 #[error("Validation failed: {0}")]
173 Validation(String),
174
175 #[error("Database error: {0}")]
176 Database(String),
177
178 #[error("{0}")]
179 Other(String),
180}
181
182pub trait AdminModel: Send + Sync + 'static {
194 fn slug(&self) -> &'static str;
197
198 fn display_name(&self) -> &'static str;
200
201 fn display_name_plural(&self) -> &'static str;
203
204 fn fields(&self) -> Vec<AdminField>;
206
207 fn actions(&self) -> Vec<AdminAction> {
209 vec![AdminAction {
210 name: "delete",
211 label: "Delete selected".to_owned(),
212 style: ActionStyle::Danger,
213 confirm: true,
214 }]
215 }
216
217 fn list(
221 &self,
222 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
223 params: ListParams,
224 ) -> AdminFuture<'_, ListResult>;
225
226 fn get(
228 &self,
229 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
230 id: i64,
231 ) -> AdminFuture<'_, Option<Value>>;
232
233 fn create(
235 &self,
236 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
237 data: Value,
238 ) -> AdminFuture<'_, Value>;
239
240 fn update(
242 &self,
243 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
244 id: i64,
245 data: Value,
246 ) -> AdminFuture<'_, Value>;
247
248 fn delete(
250 &self,
251 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
252 id: i64,
253 ) -> AdminFuture<'_, ()>;
254
255 fn execute_action(
257 &self,
258 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
259 action: &str,
260 ids: Vec<i64>,
261 ) -> AdminFuture<'_, u64> {
262 let action = action.to_owned();
272 let pool = pool.clone();
273 Box::pin(async move {
274 match action.as_str() {
275 "delete" => {
276 let mut count: u64 = 0;
277 for id in ids {
278 self.delete(&pool, id).await?;
279 count += 1;
280 }
281 Ok(count)
282 }
283 other => Err(AdminError::Other(format!(
284 "unhandled bulk action '{other}'; \
285 override AdminModel::execute_action to support it"
286 ))),
287 }
288 })
289 }
290
291 fn record_display(&self, record: &Value) -> String {
296 record_id(record).map_or_else(
297 || format!("{} <no id>", self.display_name()),
298 |id| format!("{} #{id}", self.display_name()),
299 )
300 }
301
302 fn per_page(&self) -> u64 {
304 25
305 }
306
307 fn count(
311 &self,
312 pool: &diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection>,
313 ) -> AdminFuture<'_, u64> {
314 let params = ListParams {
315 page: 1,
316 per_page: 0,
317 ..Default::default()
318 };
319 let fut = self.list(pool, params);
320 Box::pin(async move { fut.await.map(|r| r.total) })
321 }
322}
323
324#[must_use]
331pub fn record_id(record: &Value) -> Option<i64> {
332 record.get("id").and_then(Value::as_i64)
333}
334
335#[derive(Debug, Clone, Default)]
339pub struct ListParams {
340 pub page: u64,
342 pub per_page: u64,
344 pub search: Option<String>,
346 pub sort_by: Option<String>,
348 pub sort_dir: SortDirection,
350 pub filters: Vec<(String, String)>,
352}
353
354#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
356#[serde(rename_all = "lowercase")]
357pub enum SortDirection {
358 #[default]
359 Asc,
360 Desc,
361}
362
363impl SortDirection {
364 #[must_use]
366 pub const fn as_str(self) -> &'static str {
367 match self {
368 Self::Asc => "asc",
369 Self::Desc => "desc",
370 }
371 }
372
373 #[must_use]
375 pub const fn flipped(self) -> Self {
376 match self {
377 Self::Asc => Self::Desc,
378 Self::Desc => Self::Asc,
379 }
380 }
381}
382
383#[derive(Debug, Clone)]
385pub struct ListResult {
386 pub records: Vec<Value>,
388 pub total: u64,
390 pub page: u64,
392 pub per_page: u64,
394}
395
396impl ListResult {
397 #[must_use]
399 pub const fn total_pages(&self) -> u64 {
400 if self.per_page == 0 {
401 return 0;
402 }
403 self.total.div_ceil(self.per_page)
404 }
405}
406
407fn humanize_field_name(name: &str) -> String {
413 name.split('_')
414 .map(|word| {
415 let mut chars = word.chars();
416 chars.next().map_or_else(String::new, |c| {
417 let mut s = c.to_uppercase().to_string();
418 s.extend(chars);
419 s
420 })
421 })
422 .collect::<Vec<_>>()
423 .join(" ")
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use std::sync::Mutex;
430
431 struct DeletingModel {
435 deleted: Mutex<Vec<i64>>,
436 fail_on: Option<i64>,
437 }
438
439 impl AdminModel for DeletingModel {
440 fn slug(&self) -> &'static str {
441 "tracked"
442 }
443 fn display_name(&self) -> &'static str {
444 "Tracked"
445 }
446 fn display_name_plural(&self) -> &'static str {
447 "Tracked"
448 }
449 fn fields(&self) -> Vec<AdminField> {
450 vec![]
451 }
452 fn list(
453 &self,
454 _pool: &diesel_async::pooled_connection::deadpool::Pool<
455 diesel_async::AsyncPgConnection,
456 >,
457 _params: ListParams,
458 ) -> AdminFuture<'_, ListResult> {
459 Box::pin(async {
460 Ok(ListResult {
461 records: vec![],
462 total: 0,
463 page: 1,
464 per_page: 25,
465 })
466 })
467 }
468 fn get(
469 &self,
470 _pool: &diesel_async::pooled_connection::deadpool::Pool<
471 diesel_async::AsyncPgConnection,
472 >,
473 _id: i64,
474 ) -> AdminFuture<'_, Option<Value>> {
475 Box::pin(async { Ok(None) })
476 }
477 fn create(
478 &self,
479 _pool: &diesel_async::pooled_connection::deadpool::Pool<
480 diesel_async::AsyncPgConnection,
481 >,
482 data: Value,
483 ) -> AdminFuture<'_, Value> {
484 Box::pin(async move { Ok(data) })
485 }
486 fn update(
487 &self,
488 _pool: &diesel_async::pooled_connection::deadpool::Pool<
489 diesel_async::AsyncPgConnection,
490 >,
491 _id: i64,
492 data: Value,
493 ) -> AdminFuture<'_, Value> {
494 Box::pin(async move { Ok(data) })
495 }
496 fn delete(
497 &self,
498 _pool: &diesel_async::pooled_connection::deadpool::Pool<
499 diesel_async::AsyncPgConnection,
500 >,
501 id: i64,
502 ) -> AdminFuture<'_, ()> {
503 let deleted = &self.deleted;
504 let fail_on = self.fail_on;
505 Box::pin(async move {
506 if Some(id) == fail_on {
507 return Err(AdminError::Database("simulated failure".into()));
508 }
509 deleted.lock().unwrap().push(id);
510 Ok(())
511 })
512 }
513 }
514
515 fn dummy_pool()
518 -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
519 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
520 use diesel_async::pooled_connection::deadpool::Pool;
521 let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
522 "postgresql://test",
523 );
524 Pool::builder(mgr).build().expect("build pool")
525 }
526
527 #[tokio::test]
528 async fn default_execute_action_delete_invokes_delete_for_each_id() {
529 let model = DeletingModel {
530 deleted: Mutex::new(vec![]),
531 fail_on: None,
532 };
533 let pool = dummy_pool();
534 let count = model
535 .execute_action(&pool, "delete", vec![10, 20, 30])
536 .await
537 .expect("default delete should succeed");
538 assert_eq!(count, 3);
539 assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
540 }
541
542 #[tokio::test]
543 async fn default_execute_action_delete_aborts_on_first_failure() {
544 let model = DeletingModel {
545 deleted: Mutex::new(vec![]),
546 fail_on: Some(20),
547 };
548 let pool = dummy_pool();
549 let err = model
550 .execute_action(&pool, "delete", vec![10, 20, 30])
551 .await
552 .expect_err("delete should propagate failure");
553 assert!(matches!(err, AdminError::Database(_)));
554 assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
556 }
557
558 #[tokio::test]
559 async fn default_execute_action_rejects_unknown_action() {
560 let model = DeletingModel {
561 deleted: Mutex::new(vec![]),
562 fail_on: None,
563 };
564 let pool = dummy_pool();
565 let err = model
566 .execute_action(&pool, "promote", vec![1])
567 .await
568 .expect_err("unknown actions must error, not silently no-op");
569 assert!(
570 matches!(err, AdminError::Other(msg) if msg.contains("promote")),
571 "error should name the unhandled action"
572 );
573 assert!(model.deleted.lock().unwrap().is_empty());
574 }
575
576 #[test]
577 fn humanize_converts_snake_case() {
578 assert_eq!(humanize_field_name("created_at"), "Created At");
579 assert_eq!(humanize_field_name("user_id"), "User Id");
580 assert_eq!(humanize_field_name("name"), "Name");
581 assert_eq!(humanize_field_name(""), "");
582 }
583
584 #[test]
585 fn list_result_total_pages() {
586 let result = ListResult {
587 records: vec![],
588 total: 25,
589 page: 1,
590 per_page: 10,
591 };
592 assert_eq!(result.total_pages(), 3);
593 }
594
595 #[test]
596 fn list_result_total_pages_exact() {
597 let result = ListResult {
598 records: vec![],
599 total: 20,
600 page: 1,
601 per_page: 10,
602 };
603 assert_eq!(result.total_pages(), 2);
604 }
605
606 #[test]
607 fn list_result_total_pages_zero_per_page() {
608 let result = ListResult {
609 records: vec![],
610 total: 20,
611 page: 1,
612 per_page: 0,
613 };
614 assert_eq!(result.total_pages(), 0);
615 }
616
617 #[test]
618 fn admin_field_builder() {
619 let field = AdminField::new("email", AdminFieldKind::Text)
620 .label("Email Address")
621 .searchable()
622 .filterable()
623 .optional();
624
625 assert_eq!(field.name, "email");
626 assert_eq!(field.label, "Email Address");
627 assert!(field.searchable);
628 assert!(field.filterable);
629 assert!(!field.required);
630 assert!(field.editable);
631 }
632
633 #[test]
634 fn record_id_extracts_numeric_id() {
635 assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
636 }
637
638 #[test]
639 fn record_id_returns_none_for_missing_or_non_numeric() {
640 assert_eq!(record_id(&serde_json::json!({})), None);
641 assert_eq!(record_id(&serde_json::json!({"id": null})), None);
642 assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
643 assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
645 }
646
647 #[test]
648 fn hidden_fields_default_to_not_editable() {
649 let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
653 assert!(
654 !hidden.editable,
655 "Hidden fields must default to editable=false"
656 );
657
658 let text = AdminField::new("name", AdminFieldKind::Text);
660 assert!(text.editable);
661 }
662}