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