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 let mut s = String::with_capacity(name.len());
414 for (i, word) in name.split('_').enumerate() {
415 if i > 0 {
416 s.push(' ');
417 }
418 let mut chars = word.chars();
419 if let Some(c) = chars.next() {
420 s.extend(c.to_uppercase());
421 s.extend(chars);
422 }
423 }
424 s
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use std::sync::Mutex;
431
432 struct DeletingModel {
436 deleted: Mutex<Vec<i64>>,
437 fail_on: Option<i64>,
438 }
439
440 impl AdminModel for DeletingModel {
441 fn slug(&self) -> &'static str {
442 "tracked"
443 }
444 fn display_name(&self) -> &'static str {
445 "Tracked"
446 }
447 fn display_name_plural(&self) -> &'static str {
448 "Tracked"
449 }
450 fn fields(&self) -> Vec<AdminField> {
451 vec![]
452 }
453 fn list(
454 &self,
455 _pool: &diesel_async::pooled_connection::deadpool::Pool<
456 diesel_async::AsyncPgConnection,
457 >,
458 _params: ListParams,
459 ) -> AdminFuture<'_, ListResult> {
460 Box::pin(async {
461 Ok(ListResult {
462 records: vec![],
463 total: 0,
464 page: 1,
465 per_page: 25,
466 })
467 })
468 }
469 fn get(
470 &self,
471 _pool: &diesel_async::pooled_connection::deadpool::Pool<
472 diesel_async::AsyncPgConnection,
473 >,
474 _id: i64,
475 ) -> AdminFuture<'_, Option<Value>> {
476 Box::pin(async { Ok(None) })
477 }
478 fn create(
479 &self,
480 _pool: &diesel_async::pooled_connection::deadpool::Pool<
481 diesel_async::AsyncPgConnection,
482 >,
483 data: Value,
484 ) -> AdminFuture<'_, Value> {
485 Box::pin(async move { Ok(data) })
486 }
487 fn update(
488 &self,
489 _pool: &diesel_async::pooled_connection::deadpool::Pool<
490 diesel_async::AsyncPgConnection,
491 >,
492 _id: i64,
493 data: Value,
494 ) -> AdminFuture<'_, Value> {
495 Box::pin(async move { Ok(data) })
496 }
497 fn delete(
498 &self,
499 _pool: &diesel_async::pooled_connection::deadpool::Pool<
500 diesel_async::AsyncPgConnection,
501 >,
502 id: i64,
503 ) -> AdminFuture<'_, ()> {
504 let deleted = &self.deleted;
505 let fail_on = self.fail_on;
506 Box::pin(async move {
507 if Some(id) == fail_on {
508 return Err(AdminError::Database("simulated failure".into()));
509 }
510 deleted.lock().unwrap().push(id);
511 Ok(())
512 })
513 }
514 }
515
516 fn dummy_pool()
519 -> diesel_async::pooled_connection::deadpool::Pool<diesel_async::AsyncPgConnection> {
520 use diesel_async::pooled_connection::AsyncDieselConnectionManager;
521 use diesel_async::pooled_connection::deadpool::Pool;
522 let mgr = AsyncDieselConnectionManager::<diesel_async::AsyncPgConnection>::new(
523 "postgresql://test",
524 );
525 Pool::builder(mgr).build().expect("build pool")
526 }
527
528 #[tokio::test]
529 async fn default_execute_action_delete_invokes_delete_for_each_id() {
530 let model = DeletingModel {
531 deleted: Mutex::new(vec![]),
532 fail_on: None,
533 };
534 let pool = dummy_pool();
535 let count = model
536 .execute_action(&pool, "delete", vec![10, 20, 30])
537 .await
538 .expect("default delete should succeed");
539 assert_eq!(count, 3);
540 assert_eq!(*model.deleted.lock().unwrap(), vec![10, 20, 30]);
541 }
542
543 #[tokio::test]
544 async fn default_execute_action_delete_aborts_on_first_failure() {
545 let model = DeletingModel {
546 deleted: Mutex::new(vec![]),
547 fail_on: Some(20),
548 };
549 let pool = dummy_pool();
550 let err = model
551 .execute_action(&pool, "delete", vec![10, 20, 30])
552 .await
553 .expect_err("delete should propagate failure");
554 assert!(matches!(err, AdminError::Database(_)));
555 assert_eq!(*model.deleted.lock().unwrap(), vec![10]);
557 }
558
559 #[tokio::test]
560 async fn default_execute_action_rejects_unknown_action() {
561 let model = DeletingModel {
562 deleted: Mutex::new(vec![]),
563 fail_on: None,
564 };
565 let pool = dummy_pool();
566 let err = model
567 .execute_action(&pool, "promote", vec![1])
568 .await
569 .expect_err("unknown actions must error, not silently no-op");
570 assert!(
571 matches!(err, AdminError::Other(msg) if msg.contains("promote")),
572 "error should name the unhandled action"
573 );
574 assert!(model.deleted.lock().unwrap().is_empty());
575 }
576
577 #[test]
578 fn humanize_converts_snake_case() {
579 assert_eq!(humanize_field_name("created_at"), "Created At");
580 assert_eq!(humanize_field_name("user_id"), "User Id");
581 assert_eq!(humanize_field_name("name"), "Name");
582 assert_eq!(humanize_field_name(""), "");
583 }
584
585 #[test]
586 fn list_result_total_pages() {
587 let result = ListResult {
588 records: vec![],
589 total: 25,
590 page: 1,
591 per_page: 10,
592 };
593 assert_eq!(result.total_pages(), 3);
594 }
595
596 #[test]
597 fn list_result_total_pages_exact() {
598 let result = ListResult {
599 records: vec![],
600 total: 20,
601 page: 1,
602 per_page: 10,
603 };
604 assert_eq!(result.total_pages(), 2);
605 }
606
607 #[test]
608 fn list_result_total_pages_zero_per_page() {
609 let result = ListResult {
610 records: vec![],
611 total: 20,
612 page: 1,
613 per_page: 0,
614 };
615 assert_eq!(result.total_pages(), 0);
616 }
617
618 #[test]
619 fn admin_field_builder() {
620 let field = AdminField::new("email", AdminFieldKind::Text)
621 .label("Email Address")
622 .searchable()
623 .filterable()
624 .optional();
625
626 assert_eq!(field.name, "email");
627 assert_eq!(field.label, "Email Address");
628 assert!(field.searchable);
629 assert!(field.filterable);
630 assert!(!field.required);
631 assert!(field.editable);
632 }
633
634 #[test]
635 fn record_id_extracts_numeric_id() {
636 assert_eq!(record_id(&serde_json::json!({"id": 42})), Some(42));
637 }
638
639 #[test]
640 fn record_id_returns_none_for_missing_or_non_numeric() {
641 assert_eq!(record_id(&serde_json::json!({})), None);
642 assert_eq!(record_id(&serde_json::json!({"id": null})), None);
643 assert_eq!(record_id(&serde_json::json!({"id": "abc"})), None);
644 assert_eq!(record_id(&serde_json::json!({"id": 1.5})), None);
646 }
647
648 #[test]
649 fn hidden_fields_default_to_not_editable() {
650 let hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
654 assert!(
655 !hidden.editable,
656 "Hidden fields must default to editable=false"
657 );
658
659 let text = AdminField::new("name", AdminFieldKind::Text);
661 assert!(text.editable);
662 }
663}