autumn_admin_plugin/
registry.rs1use std::collections::btree_map::{BTreeMap, Entry};
8
9use crate::traits::AdminModel;
10
11pub const JOBS_NAV_SLUG: &str = "__admin_jobs";
16
17pub const RUNTIME_CONFIG_NAV_SLUG: &str = "__admin_config";
21
22const RESERVED_MODEL_SLUGS: &[&str] = &["jobs", JOBS_NAV_SLUG, RUNTIME_CONFIG_NAV_SLUG];
27
28pub struct AdminRegistry {
33 models: BTreeMap<&'static str, Box<dyn AdminModel>>,
35}
36
37impl AdminRegistry {
38 pub(crate) fn new() -> Self {
40 Self {
41 models: BTreeMap::new(),
42 }
43 }
44
45 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 #[must_use]
67 pub fn model_count(&self) -> usize {
68 self.models.len()
69 }
70
71 #[must_use]
73 pub fn get(&self, slug: &str) -> Option<&dyn AdminModel> {
74 self.models.get(slug).map(Box::as_ref)
75 }
76
77 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 #[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 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 ::autumn_web::RuntimeConnection,
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 ::autumn_web::RuntimeConnection,
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 ::autumn_web::RuntimeConnection,
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 ::autumn_web::RuntimeConnection,
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 ::autumn_web::RuntimeConnection,
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 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 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}