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