Skip to main content

lattice_dashboard/
registry.rs

1//! The section registry — the single extensibility seam.
2//!
3//! Config selects and orders built-in sections today; the same registry will
4//! accept plugin-contributed sections tomorrow (DB.8). Composition is: pick +
5//! order the enabled sections, render each to a fragment. Turning fragments
6//! into buffer content is the compositor's job (DB.2+).
7
8use std::sync::Arc;
9
10use crate::fragment::DashboardFragment;
11use crate::section::{DashboardCtx, DashboardSection};
12
13/// Which sections to show, and in what order.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum SectionSelection {
16    /// User did not customise: every `default_enabled` section, sorted by
17    /// `order` (ties by `id`).
18    Default,
19    /// User pinned an explicit ordered list of ids (from `dashboard.sections`).
20    /// Only these show, in this order; unknown ids are skipped with a warning.
21    Explicit(Vec<String>),
22}
23
24impl SectionSelection {
25    /// Parse the `dashboard.sections` option value. Empty/whitespace ⇒
26    /// [`SectionSelection::Default`]; otherwise the ids split on commas
27    /// and/or whitespace, in order, de-duplicated (first occurrence wins).
28    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/// Ordered, id-keyed collection of sections.
48///
49/// **Shadowing, not overwriting.** A later registration for an id that is
50/// already taken is *appended*, and the id resolves to the LAST entry.
51/// That makes replace-by-id (CR.4's stated capability — a plugin replacing
52/// `getting-started`) a stack rather than a destructive write, so
53/// [`unregister_plugin`](Self::unregister_plugin) is a plain `retain` and
54/// the displaced builtin resurfaces on its own. The alternative —
55/// overwrite, and save the previous occupant somewhere for unload to put
56/// back — is explicit bookkeeping across three unload paths, and the kind
57/// that gets forgotten on one of them.
58#[derive(Clone, Default)]
59pub struct DashboardRegistry {
60    sections: Vec<Arc<dyn DashboardSection>>,
61}
62
63/// The runtime-mutable handle, registered as a boot service under this
64/// exact alias (the `ServiceRegistry` Arc/TypeId convention).
65///
66/// Copy-on-write RCU: `Editor::compose_dashboard_sections` takes one
67/// wait-free `.load()` snapshot per compose; writes happen only on plugin
68/// load and unload.
69pub type DashboardRegistryHandle = Arc<arc_swap::ArcSwap<DashboardRegistry>>;
70
71impl DashboardRegistry {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Wrap this registry in a fresh [`DashboardRegistryHandle`], so
77    /// consumers do not each have to name `arc_swap`.
78    pub fn into_handle(self) -> DashboardRegistryHandle {
79        Arc::new(arc_swap::ArcSwap::from_pointee(self))
80    }
81
82    /// Register a section.
83    ///
84    /// A registration for an id already held by a **different** owner
85    /// shadows it (see the type docs). A registration by the **same** owner
86    /// for the same id replaces in place, so re-running `builtin_registry`
87    /// stays idempotent and a plugin reload cannot grow the shadow stack.
88    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    /// Drop every section contributed by `plugin_id`, returning how many
105    /// were removed. Idempotent: a second call reports zero.
106    ///
107    /// A builtin the plugin had shadowed becomes visible again with no
108    /// restore step, because it was never removed — that is the whole
109    /// reason [`register`](Self::register) appends.
110    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    /// The section that currently owns `id` — the last registration for it.
117    pub fn resolve(&self, id: &str) -> Option<&Arc<dyn DashboardSection>> {
118        self.sections.iter().rev().find(|s| s.id() == id)
119    }
120
121    /// All registered ids, de-duplicated, in FIRST-registration order (not
122    /// the display order).
123    ///
124    /// First-registration order rather than last: a plugin replacing a
125    /// builtin keeps the slot the builtin occupied, so loading a plugin
126    /// does not reshuffle the list a user reads.
127    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    /// Resolve the display order for a selection.
138    ///
139    /// Unknown ids in an explicit selection are skipped with a logged
140    /// warning (never a hard error — a stale `dashboard.sections` entry must
141    /// not break the page).
142    pub fn ordered(&self, selection: &SectionSelection) -> Vec<Arc<dyn DashboardSection>> {
143        match selection {
144            SectionSelection::Default => {
145                // Resolve each distinct id to its current owner first — a
146                // shadowed builtin must not render alongside the plugin
147                // section that replaced it.
148                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    /// Render the selected sections in order to their fragments.
175    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        /// What `render` emits. Distinct from `id` so a shadowing test can
197        /// tell two sections claiming the SAME id apart — asserting on ids
198        /// alone cannot see whether the builtin or its replacement ran.
199        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    /// A plugin-contributed section claiming `id`, rendering `label`.
234    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        // Register out of order; equal order breaks by id.
266        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)); // ties with "about" at 20
270        assert_eq!(
271            ordered_ids(&reg, &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(&reg, &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        // Only the two listed, in listed order; "about" omitted.
292        assert_eq!(ordered_ids(&reg, &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(&reg, &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(&reg, &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)); // replace, new order
317        assert_eq!(reg.ids(), ["about"]);
318        assert_eq!(reg.ordered(&SectionSelection::Default)[0].order(), 5);
319    }
320
321    // ── CR.2: the runtime-writable handle + shadowing ────────────────
322
323    #[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        // One slot, not two — the shadowed builtin must not render
330        // alongside its replacement.
331        assert_eq!(reg.ids(), ["getting-started"]);
332        assert_eq!(
333            rendered_labels(&reg, &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        // Back to the builtin, in the slot it always had — not dropped,
347        // and not moved to the end.
348        assert_eq!(
349            rendered_labels(&reg, &SectionSelection::Default),
350            ["getting-started", "links"]
351        );
352        // Idempotent: the teardown contract's double-unload case.
353        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(&reg, &SectionSelection::Default),
365            ["plugin-b"]
366        );
367        reg.unregister_plugin(2);
368        assert_eq!(
369            rendered_labels(&reg, &SectionSelection::Default),
370            ["plugin-a"]
371        );
372        reg.unregister_plugin(1);
373        assert_eq!(
374            rendered_labels(&reg, &SectionSelection::Default),
375            ["getting-started"]
376        );
377    }
378
379    /// A reload re-registers without an intervening unload in some paths;
380    /// appending there would grow the shadow stack until a single unload
381    /// could no longer clear it.
382    #[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(&reg, &SectionSelection::Default), ["v2"]);
390        assert_eq!(reg.unregister_plugin(7), 1);
391        assert_eq!(
392            rendered_labels(&reg, &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    /// Coherence: a compose that snapshotted before a plugin loaded keeps
429    /// rendering the set it started from, rather than half of each.
430    #[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}