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