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}