Skip to main content

teksilo_data/
debug_registry.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `debug_registry` — opt-in registry of named data models for the Teksilo inspector.
5//!
6//! Provides the infrastructure for the inspector's *Models* tab: models register
7//! themselves under a human-readable name, and the inspector calls [`snapshot`] to
8//! obtain a live list of every registered model without keeping them alive past
9//! their natural lifetime.
10//!
11//! The registry is a thread-local `Vec` of `Weak<dyn ModelDebug>` entries.
12//! Models opt in via the debug-only `.debug_named("name")` builder method on
13//! [`crate::ListModel`], [`crate::TreeModel`], and [`crate::SelectionModel`];
14//! that method creates a strong `Rc<dyn ModelDebug>` adapter and stores it
15//! inside the model's own `Rc<RefCell<Inner>>`, while registering a `Weak`
16//! clone here. When the last model handle is dropped the `Weak` becomes dead,
17//! and the next call to [`snapshot`] prunes it automatically.
18//!
19//! This entire module is compiled only when `debug_assertions` are enabled
20//! and contributes zero overhead to release builds.
21
22#![cfg(debug_assertions)]
23
24use std::cell::RefCell;
25use std::rc::{Rc, Weak};
26
27/// Type-erased debug view of a data model. Implementors live in
28/// `teksilo-data` itself (`ListModel`, `TreeModel`, etc.); the inspector
29/// uses these methods to render the Data Models tab without needing
30/// to know `T`.
31#[allow(clippy::len_without_is_empty)]
32pub trait ModelDebug: 'static {
33    /// Discriminator string — `"ListModel"`, `"TreeModel"`,
34    /// `"SelectionModel"`, … Shown verbatim in the inspector.
35    fn kind(&self) -> &'static str;
36
37    /// Number of items / nodes in the model. Cheap.
38    fn len(&self) -> usize;
39
40    /// Write a human-readable dump of the model's items into `out`.
41    /// Each item should land on its own line. Best-effort; long
42    /// outputs may be truncated by callers.
43    fn debug_dump(&self, out: &mut dyn std::fmt::Write);
44}
45
46thread_local! {
47    static REGISTRY: RefCell<Vec<Entry>> = const { RefCell::new(Vec::new()) };
48}
49
50struct Entry {
51    name: String,
52    weak: Weak<dyn ModelDebug>,
53}
54
55/// Register a model under a human-readable name. The name does not
56/// need to be unique across registrations — the inspector lists every
57/// entry — but uniqueness aids tracking.
58///
59/// Stores a `Weak` to the adapter so the registry never keeps models
60/// alive. The caller is responsible for retaining a strong `Rc` to
61/// the adapter (typically by stashing it inside the model's own
62/// `Rc<RefCell<Inner>>`, see `ListModel::debug_named`).
63///
64/// Prunes dead entries first — [`snapshot`] also prunes, but a session that
65/// churns many short-lived models (e.g. lazily realized rows) without ever
66/// calling `snapshot` would otherwise grow the registry unbounded.
67pub fn register(name: impl Into<String>, adapter: Weak<dyn ModelDebug>) {
68    REGISTRY.with(|cell| {
69        let mut registry = cell.borrow_mut();
70        registry.retain(|entry| entry.weak.strong_count() > 0);
71        registry.push(Entry {
72            name: name.into(),
73            weak: adapter,
74        });
75    });
76}
77
78/// Snapshot every live registration. Drops dead `Weak`s during the
79/// walk so the registry doesn't grow unbounded as models churn.
80pub fn snapshot() -> Vec<(String, Rc<dyn ModelDebug>)> {
81    REGISTRY.with(|cell| {
82        let mut out: Vec<(String, Rc<dyn ModelDebug>)> = Vec::new();
83        cell.borrow_mut().retain(|entry| {
84            if let Some(strong) = entry.weak.upgrade() {
85                out.push((entry.name.clone(), strong));
86                true
87            } else {
88                false
89            }
90        });
91        out
92    })
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use std::cell::Cell;
99
100    struct DummyModel {
101        len: Cell<usize>,
102    }
103
104    impl ModelDebug for DummyModel {
105        fn kind(&self) -> &'static str {
106            "DummyModel"
107        }
108        fn len(&self) -> usize {
109            self.len.get()
110        }
111        fn debug_dump(&self, out: &mut dyn std::fmt::Write) {
112            let _ = write!(out, "len={}", self.len.get());
113        }
114    }
115
116    #[test]
117    fn register_and_snapshot_round_trip() {
118        // Use a fresh thread-local to avoid pollution from other tests.
119        // Tests are isolated by thread; this thread's REGISTRY starts empty.
120        let snap_before = snapshot();
121        let initial = snap_before.len();
122
123        let m: Rc<dyn ModelDebug> = Rc::new(DummyModel { len: Cell::new(3) });
124        register("alpha", Rc::downgrade(&m));
125
126        let snap = snapshot();
127        assert_eq!(snap.len(), initial + 1);
128        let added = snap.iter().find(|(n, _)| n == "alpha").unwrap();
129        assert_eq!(added.1.len(), 3);
130    }
131
132    #[test]
133    fn dead_weaks_are_pruned() {
134        let snap_before = snapshot();
135        let initial = snap_before.len();
136
137        {
138            let m: Rc<dyn ModelDebug> = Rc::new(DummyModel { len: Cell::new(0) });
139            register("ephemeral", Rc::downgrade(&m));
140            // m drops here, weak becomes dead
141        }
142
143        let snap = snapshot();
144        // The dead entry has been pruned — count is back to initial.
145        assert_eq!(snap.len(), initial);
146        assert!(snap.iter().all(|(n, _)| n != "ephemeral"));
147    }
148
149    #[test]
150    fn register_prunes_dead_entries_without_a_snapshot_call() {
151        // Regression: `register` must prune dead weaks itself rather than
152        // relying on the caller to eventually call `snapshot` — a session
153        // that churns many short-lived models (lazily realized rows, say)
154        // without ever opening the inspector would otherwise grow the
155        // registry unbounded. Fresh thread-local (tests run on their own
156        // thread, see `dead_weaks_are_pruned`), so it starts empty.
157        {
158            let m: Rc<dyn ModelDebug> = Rc::new(DummyModel { len: Cell::new(0) });
159            register("ephemeral", Rc::downgrade(&m));
160            // m drops here, weak becomes dead — never snapshotted.
161        }
162
163        let m2: Rc<dyn ModelDebug> = Rc::new(DummyModel { len: Cell::new(1) });
164        register("still-alive", Rc::downgrade(&m2));
165
166        // `register` pruned the dead "ephemeral" entry before pushing the
167        // new one, so exactly one raw entry remains — not two.
168        let raw_len = REGISTRY.with(|cell| cell.borrow().len());
169        assert_eq!(raw_len, 1);
170    }
171}