1use std::sync::Arc;
9
10use crate::fragment::DashboardFragment;
11use crate::section::{DashboardCtx, DashboardSection};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SectionSelection {
16 Default,
19 Explicit(Vec<String>),
22}
23
24impl SectionSelection {
25 pub fn parse(raw: &str) -> Self {
29 let mut ids: Vec<String> = Vec::new();
30 for tok in raw.split([',', ' ', '\t', '\n']) {
31 let tok = tok.trim();
32 if tok.is_empty() {
33 continue;
34 }
35 if !ids.iter().any(|existing| existing == tok) {
36 ids.push(tok.to_string());
37 }
38 }
39 if ids.is_empty() {
40 SectionSelection::Default
41 } else {
42 SectionSelection::Explicit(ids)
43 }
44 }
45}
46
47#[derive(Clone, Default)]
59pub struct DashboardRegistry {
60 sections: Vec<Arc<dyn DashboardSection>>,
61}
62
63pub type DashboardRegistryHandle = Arc<arc_swap::ArcSwap<DashboardRegistry>>;
70
71impl DashboardRegistry {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn into_handle(self) -> DashboardRegistryHandle {
79 Arc::new(arc_swap::ArcSwap::from_pointee(self))
80 }
81
82 pub fn register(&mut self, section: Arc<dyn DashboardSection>) -> &mut Self {
89 let id = section.id().to_string();
90 let owner = section.plugin_id();
91 if let Some(slot) = self
92 .sections
93 .iter_mut()
94 .rev()
95 .find(|s| s.id() == id && s.plugin_id() == owner)
96 {
97 *slot = section;
98 } else {
99 self.sections.push(section);
100 }
101 self
102 }
103
104 pub fn unregister_plugin(&mut self, plugin_id: u64) -> usize {
111 let before = self.sections.len();
112 self.sections.retain(|s| s.plugin_id() != Some(plugin_id));
113 before - self.sections.len()
114 }
115
116 pub fn resolve(&self, id: &str) -> Option<&Arc<dyn DashboardSection>> {
118 self.sections.iter().rev().find(|s| s.id() == id)
119 }
120
121 pub fn ids(&self) -> Vec<&str> {
128 let mut out: Vec<&str> = Vec::new();
129 for s in &self.sections {
130 if !out.contains(&s.id()) {
131 out.push(s.id());
132 }
133 }
134 out
135 }
136
137 pub fn ordered(&self, selection: &SectionSelection) -> Vec<Arc<dyn DashboardSection>> {
143 match selection {
144 SectionSelection::Default => {
145 let mut enabled: Vec<Arc<dyn DashboardSection>> = self
149 .ids()
150 .into_iter()
151 .filter_map(|id| self.resolve(id))
152 .filter(|s| s.default_enabled())
153 .cloned()
154 .collect();
155 enabled.sort_by(|a, b| a.order().cmp(&b.order()).then_with(|| a.id().cmp(b.id())));
156 enabled
157 }
158 SectionSelection::Explicit(ids) => ids
159 .iter()
160 .filter_map(|id| {
161 let found = self.resolve(id).cloned();
162 if found.is_none() {
163 tracing::warn!(
164 section = %id,
165 "dashboard.sections lists unknown section id; skipping"
166 );
167 }
168 found
169 })
170 .collect(),
171 }
172 }
173
174 pub fn compose(
176 &self,
177 ctx: &DashboardCtx,
178 selection: &SectionSelection,
179 ) -> Vec<DashboardFragment> {
180 self.ordered(selection)
181 .iter()
182 .map(|s| s.render(ctx))
183 .collect()
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::fragment::{DashboardFragment, DashboardRole};
191
192 struct StubSection {
193 id: &'static str,
194 order: i32,
195 enabled: bool,
196 label: &'static str,
200 plugin_id: Option<u64>,
201 }
202
203 impl DashboardSection for StubSection {
204 fn id(&self) -> &str {
205 self.id
206 }
207 fn order(&self) -> i32 {
208 self.order
209 }
210 fn default_enabled(&self) -> bool {
211 self.enabled
212 }
213 fn plugin_id(&self) -> Option<u64> {
214 self.plugin_id
215 }
216 fn render(&self, _ctx: &DashboardCtx) -> DashboardFragment {
217 let mut f = DashboardFragment::new();
218 f.line(self.label, DashboardRole::Body);
219 f
220 }
221 }
222
223 fn stub(id: &'static str, order: i32, enabled: bool) -> Arc<dyn DashboardSection> {
224 Arc::new(StubSection {
225 id,
226 order,
227 enabled,
228 label: id,
229 plugin_id: None,
230 })
231 }
232
233 fn plugin_stub(
235 id: &'static str,
236 label: &'static str,
237 plugin_id: u64,
238 ) -> Arc<dyn DashboardSection> {
239 Arc::new(StubSection {
240 id,
241 order: 0,
242 enabled: true,
243 label,
244 plugin_id: Some(plugin_id),
245 })
246 }
247
248 fn rendered_labels(reg: &DashboardRegistry, sel: &SectionSelection) -> Vec<String> {
249 reg.compose(&DashboardCtx::default(), sel)
250 .iter()
251 .flat_map(|f| f.rows.iter().map(|r| r.text()))
252 .collect()
253 }
254
255 fn ordered_ids(reg: &DashboardRegistry, sel: &SectionSelection) -> Vec<String> {
256 reg.ordered(sel)
257 .iter()
258 .map(|s| s.id().to_string())
259 .collect()
260 }
261
262 #[test]
263 fn default_orders_by_order_then_id() {
264 let mut reg = DashboardRegistry::new();
265 reg.register(stub("branding", 10, true));
267 reg.register(stub("links", 30, true));
268 reg.register(stub("about", 20, true));
269 reg.register(stub("aaa", 20, true)); assert_eq!(
271 ordered_ids(®, &SectionSelection::Default),
272 ["branding", "aaa", "about", "links"]
273 );
274 }
275
276 #[test]
277 fn default_excludes_disabled() {
278 let mut reg = DashboardRegistry::new();
279 reg.register(stub("branding", 10, true));
280 reg.register(stub("hidden", 20, false));
281 assert_eq!(ordered_ids(®, &SectionSelection::Default), ["branding"]);
282 }
283
284 #[test]
285 fn explicit_selects_and_reorders() {
286 let mut reg = DashboardRegistry::new();
287 reg.register(stub("branding", 10, true));
288 reg.register(stub("about", 20, true));
289 reg.register(stub("links", 30, true));
290 let sel = SectionSelection::parse("links, branding");
291 assert_eq!(ordered_ids(®, &sel), ["links", "branding"]);
293 }
294
295 #[test]
296 fn explicit_can_show_a_default_disabled_section() {
297 let mut reg = DashboardRegistry::new();
298 reg.register(stub("branding", 10, true));
299 reg.register(stub("hidden", 20, false));
300 let sel = SectionSelection::parse("hidden branding");
301 assert_eq!(ordered_ids(®, &sel), ["hidden", "branding"]);
302 }
303
304 #[test]
305 fn explicit_skips_unknown_ids() {
306 let mut reg = DashboardRegistry::new();
307 reg.register(stub("branding", 10, true));
308 let sel = SectionSelection::parse("nope, branding, also-nope");
309 assert_eq!(ordered_ids(®, &sel), ["branding"]);
310 }
311
312 #[test]
313 fn register_same_id_replaces() {
314 let mut reg = DashboardRegistry::new();
315 reg.register(stub("about", 20, true));
316 reg.register(stub("about", 5, true)); assert_eq!(reg.ids(), ["about"]);
318 assert_eq!(reg.ordered(&SectionSelection::Default)[0].order(), 5);
319 }
320
321 #[test]
324 fn a_plugin_section_shadows_the_builtin_with_the_same_id() {
325 let mut reg = DashboardRegistry::new();
326 reg.register(stub("getting-started", 10, true));
327 reg.register(plugin_stub("getting-started", "from-plugin", 7));
328
329 assert_eq!(reg.ids(), ["getting-started"]);
332 assert_eq!(
333 rendered_labels(®, &SectionSelection::Default),
334 ["from-plugin"]
335 );
336 }
337
338 #[test]
339 fn unregister_plugin_restores_the_builtin_it_displaced() {
340 let mut reg = DashboardRegistry::new();
341 reg.register(stub("getting-started", 10, true));
342 reg.register(stub("links", 30, true));
343 reg.register(plugin_stub("getting-started", "from-plugin", 7));
344
345 assert_eq!(reg.unregister_plugin(7), 1);
346 assert_eq!(
349 rendered_labels(®, &SectionSelection::Default),
350 ["getting-started", "links"]
351 );
352 assert_eq!(reg.unregister_plugin(7), 0);
354 }
355
356 #[test]
357 fn two_plugins_shadowing_one_id_unwind_in_reverse_order() {
358 let mut reg = DashboardRegistry::new();
359 reg.register(stub("getting-started", 10, true));
360 reg.register(plugin_stub("getting-started", "plugin-a", 1));
361 reg.register(plugin_stub("getting-started", "plugin-b", 2));
362
363 assert_eq!(
364 rendered_labels(®, &SectionSelection::Default),
365 ["plugin-b"]
366 );
367 reg.unregister_plugin(2);
368 assert_eq!(
369 rendered_labels(®, &SectionSelection::Default),
370 ["plugin-a"]
371 );
372 reg.unregister_plugin(1);
373 assert_eq!(
374 rendered_labels(®, &SectionSelection::Default),
375 ["getting-started"]
376 );
377 }
378
379 #[test]
383 fn the_same_owner_re_registering_does_not_grow_the_stack() {
384 let mut reg = DashboardRegistry::new();
385 reg.register(stub("getting-started", 10, true));
386 reg.register(plugin_stub("getting-started", "v1", 7));
387 reg.register(plugin_stub("getting-started", "v2", 7));
388
389 assert_eq!(rendered_labels(®, &SectionSelection::Default), ["v2"]);
390 assert_eq!(reg.unregister_plugin(7), 1);
391 assert_eq!(
392 rendered_labels(®, &SectionSelection::Default),
393 ["getting-started"]
394 );
395 }
396
397 #[test]
398 fn unregister_plugin_removes_only_that_plugins_sections() {
399 let mut reg = DashboardRegistry::new();
400 reg.register(stub("branding", 10, true));
401 reg.register(plugin_stub("a-one", "a1", 1));
402 reg.register(plugin_stub("a-two", "a2", 1));
403 reg.register(plugin_stub("b-one", "b1", 2));
404
405 assert_eq!(reg.unregister_plugin(1), 2);
406 assert_eq!(reg.ids(), ["branding", "b-one"]);
407 }
408
409 #[test]
410 fn an_rcu_write_is_visible_through_a_handle_captured_beforehand() {
411 let handle = {
412 let mut reg = DashboardRegistry::new();
413 reg.register(stub("branding", 10, true));
414 reg.into_handle()
415 };
416 let captured = handle.clone();
417 assert_eq!(captured.load().ids(), ["branding"]);
418
419 handle.rcu(|current| {
420 let mut next = (**current).clone();
421 next.register(plugin_stub("extra", "from-plugin", 7));
422 Arc::new(next)
423 });
424
425 assert_eq!(captured.load().ids(), ["branding", "extra"]);
426 }
427
428 #[test]
431 fn a_snapshot_taken_before_a_write_still_reads_the_old_set() {
432 let handle = {
433 let mut reg = DashboardRegistry::new();
434 reg.register(stub("branding", 10, true));
435 reg.into_handle()
436 };
437 let before = handle.load_full();
438
439 handle.rcu(|current| {
440 let mut next = (**current).clone();
441 next.register(plugin_stub("extra", "from-plugin", 7));
442 Arc::new(next)
443 });
444
445 assert_eq!(before.ids(), ["branding"]);
446 assert_eq!(handle.load().ids(), ["branding", "extra"]);
447 }
448
449 #[test]
450 fn parse_empty_is_default() {
451 assert_eq!(SectionSelection::parse(""), SectionSelection::Default);
452 assert_eq!(SectionSelection::parse(" \t"), SectionSelection::Default);
453 }
454
455 #[test]
456 fn parse_dedupes_preserving_first() {
457 assert_eq!(
458 SectionSelection::parse("a, b, a"),
459 SectionSelection::Explicit(vec!["a".into(), "b".into()])
460 );
461 }
462
463 #[test]
464 fn compose_renders_in_order() {
465 let mut reg = DashboardRegistry::new();
466 reg.register(stub("branding", 10, true));
467 reg.register(stub("about", 20, true));
468 let frags = reg.compose(&DashboardCtx::default(), &SectionSelection::Default);
469 let texts: Vec<String> = frags.iter().map(|f| f.rows[0].text()).collect();
470 assert_eq!(texts, ["branding", "about"]);
471 }
472}