Skip to main content

autumn_admin_plugin/
registry.rs

1//! Runtime registry that collects admin-enabled models.
2//!
3//! The [`AdminRegistry`] is built during plugin construction and stored
4//! in [`AppState`](autumn_web::AppState) via `with_extension`. Route handlers retrieve it to
5//! discover which models are available and how to render them.
6
7use std::collections::btree_map::{BTreeMap, Entry};
8
9use crate::traits::AdminModel;
10
11const RESERVED_MODEL_SLUGS: &[&str] = &["jobs"];
12
13/// Holds all registered admin models, keyed by their URL slug.
14///
15/// Stored as an `Arc<AdminRegistry>` in `AppState` extensions so route
16/// handlers can access it cheaply.
17pub struct AdminRegistry {
18    /// Models keyed by slug, ordered alphabetically for consistent nav.
19    models: BTreeMap<&'static str, Box<dyn AdminModel>>,
20}
21
22impl AdminRegistry {
23    /// Create an empty registry.
24    pub(crate) fn new() -> Self {
25        Self {
26            models: BTreeMap::new(),
27        }
28    }
29
30    /// Register a model. Panics on duplicate slugs (catches config bugs
31    /// at startup rather than silently shadowing).
32    pub(crate) fn register<M: AdminModel>(&mut self, model: M) {
33        let slug = model.slug();
34        assert!(
35            !RESERVED_MODEL_SLUGS.contains(&slug),
36            "autumn-admin: reserved model slug '{slug}' conflicts with built-in admin routes",
37        );
38        let name = model.display_name();
39        match self.models.entry(slug) {
40            Entry::Occupied(_) => panic!(
41                "autumn-admin: duplicate model slug '{slug}' — each model must have a unique slug",
42            ),
43            Entry::Vacant(e) => {
44                tracing::debug!(slug, name, "Registered admin model");
45                e.insert(Box::new(model));
46            }
47        }
48    }
49
50    /// Number of registered models.
51    #[must_use]
52    pub fn model_count(&self) -> usize {
53        self.models.len()
54    }
55
56    /// Get a model by its URL slug.
57    #[must_use]
58    pub fn get(&self, slug: &str) -> Option<&dyn AdminModel> {
59        self.models.get(slug).map(Box::as_ref)
60    }
61
62    /// Iterate over all registered models in alphabetical order.
63    pub fn iter(&self) -> impl Iterator<Item = (&'static str, &dyn AdminModel)> {
64        self.models
65            .iter()
66            .map(|(&slug, model)| (slug, model.as_ref()))
67    }
68
69    /// Get all slugs (for nav rendering).
70    #[must_use]
71    pub fn slugs(&self) -> Vec<&'static str> {
72        self.models.keys().copied().collect()
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::traits::*;
80    use serde_json::Value;
81
82    struct DummyModel {
83        slug: &'static str,
84        name: &'static str,
85    }
86
87    impl AdminModel for DummyModel {
88        fn slug(&self) -> &'static str {
89            self.slug
90        }
91        fn display_name(&self) -> &'static str {
92            self.name
93        }
94        fn display_name_plural(&self) -> &'static str {
95            self.name
96        }
97        fn fields(&self) -> Vec<AdminField> {
98            vec![]
99        }
100        fn list(
101            &self,
102            _pool: &diesel_async::pooled_connection::deadpool::Pool<
103                diesel_async::AsyncPgConnection,
104            >,
105            _params: ListParams,
106        ) -> AdminFuture<'_, ListResult> {
107            Box::pin(async {
108                Ok(ListResult {
109                    records: vec![],
110                    total: 0,
111                    page: 1,
112                    per_page: 25,
113                })
114            })
115        }
116        fn get(
117            &self,
118            _pool: &diesel_async::pooled_connection::deadpool::Pool<
119                diesel_async::AsyncPgConnection,
120            >,
121            _id: i64,
122        ) -> AdminFuture<'_, Option<Value>> {
123            Box::pin(async { Ok(None) })
124        }
125        fn create(
126            &self,
127            _pool: &diesel_async::pooled_connection::deadpool::Pool<
128                diesel_async::AsyncPgConnection,
129            >,
130            data: Value,
131        ) -> AdminFuture<'_, Value> {
132            Box::pin(async move { Ok(data) })
133        }
134        fn update(
135            &self,
136            _pool: &diesel_async::pooled_connection::deadpool::Pool<
137                diesel_async::AsyncPgConnection,
138            >,
139            _id: i64,
140            data: Value,
141        ) -> AdminFuture<'_, Value> {
142            Box::pin(async move { Ok(data) })
143        }
144        fn delete(
145            &self,
146            _pool: &diesel_async::pooled_connection::deadpool::Pool<
147                diesel_async::AsyncPgConnection,
148            >,
149            _id: i64,
150        ) -> AdminFuture<'_, ()> {
151            Box::pin(async { Ok(()) })
152        }
153    }
154
155    #[test]
156    fn register_and_retrieve() {
157        let mut registry = AdminRegistry::new();
158        registry.register(DummyModel {
159            slug: "projects",
160            name: "Project",
161        });
162        assert_eq!(registry.model_count(), 1);
163        assert!(registry.get("projects").is_some());
164        assert!(registry.get("nonexistent").is_none());
165    }
166
167    #[test]
168    fn iter_is_sorted() {
169        let mut registry = AdminRegistry::new();
170        registry.register(DummyModel {
171            slug: "tickets",
172            name: "Ticket",
173        });
174        registry.register(DummyModel {
175            slug: "projects",
176            name: "Project",
177        });
178        let slugs: Vec<_> = registry.iter().map(|(s, _)| s).collect();
179        assert_eq!(slugs, vec!["projects", "tickets"]);
180    }
181
182    #[test]
183    #[should_panic(expected = "duplicate model slug")]
184    fn duplicate_slug_panics() {
185        let mut registry = AdminRegistry::new();
186        registry.register(DummyModel {
187            slug: "projects",
188            name: "Project",
189        });
190        registry.register(DummyModel {
191            slug: "projects",
192            name: "Project 2",
193        });
194    }
195
196    #[test]
197    #[should_panic(expected = "reserved model slug 'jobs'")]
198    fn reserved_jobs_slug_panics() {
199        let mut registry = AdminRegistry::new();
200        registry.register(DummyModel {
201            slug: "jobs",
202            name: "Jobs",
203        });
204    }
205}