teksilo_settings/bundle.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SettingsBundle` — declarative configuration for the teksilo-app
5//! integration.
6//!
7//! `TeksiloAppBuilder::settings(bundle)` consumes a `SettingsBundle`,
8//! opens the requested services against the app's [`AppPaths`], and
9//! registers each one in the application's `app_state` registry so it
10//! is reachable from any handler via the `SettingsExt` trait
11//! (`use teksilo_settings::SettingsExt;`).
12//!
13//! ## What's in the bundle
14//!
15//! Only services the framework can construct without app-level type
16//! information:
17//!
18//! * [`SettingsStore`] — the dynamic K/V store for scalar settings.
19//! * [`WindowStateService`] — per-window geometry persistence (opt-in
20//! via [`with_window_state`](SettingsBundle::with_window_state)).
21//!
22//! Anything that needs an app-defined item type (recently-opened
23//! projects/files, color palettes, saved searches) is **not** in the
24//! bundle. Apps construct an [`MruList<T>`](crate::MruList) for each
25//! such collection and register it themselves via
26//! `TeksiloAppBuilder::app_state(handle)`.
27//!
28//! ## Example
29//!
30//! ```ignore
31//! use teksilo_settings::{AppPaths, SettingsBundle};
32//! use std::time::Duration;
33//!
34//! let paths = AppPaths::for_testing(std::env::temp_dir());
35//! let opened = SettingsBundle::new()
36//! .with_window_state(true)
37//! .with_debounce(Duration::ZERO)
38//! .open(&paths)
39//! .expect("bundle open failed");
40//! // opened.store and opened.window_state are now ready to register.
41//! ```
42
43use std::rc::Rc;
44use std::time::Duration;
45
46use crate::file::SettingsFileError;
47use crate::path::AppPaths;
48use crate::reload::Reloadable;
49use crate::store::{DEFAULT_DEBOUNCE, SettingsStore, SettingsStoreError};
50use crate::watch::SettingsRegistry;
51use crate::window_state::WindowStateService;
52
53/// Errors surfaced by [`SettingsBundle::open`].
54#[derive(Debug, thiserror::Error)]
55pub enum SettingsBundleError {
56 /// The K/V store could not be opened or flushed.
57 #[error("settings bundle: {0}")]
58 Store(#[from] SettingsStoreError),
59 /// A settings file (e.g. the window-state file) could not be opened or flushed.
60 #[error("settings bundle: {0}")]
61 File(#[from] SettingsFileError),
62}
63
64/// Declarative configuration for the persistence services an app
65/// wants installed.
66///
67/// ```
68/// use teksilo_settings::SettingsBundle;
69/// use std::time::Duration;
70///
71/// let bundle = SettingsBundle::new()
72/// .with_window_state(true)
73/// .with_debounce(Duration::from_millis(250));
74/// ```
75#[derive(Debug, Clone)]
76pub struct SettingsBundle {
77 store_name: String,
78 window_state_enabled: bool,
79 debounce: Duration,
80}
81
82impl SettingsBundle {
83 /// Default bundle: opens the K/V store under `general.toml`,
84 /// no window-state persistence.
85 pub fn new() -> Self {
86 Self {
87 store_name: "general".into(),
88 window_state_enabled: false,
89 debounce: DEFAULT_DEBOUNCE,
90 }
91 }
92
93 /// Override the K/V store filename (without `.toml`). Default: `general`.
94 pub fn with_store_name(mut self, name: impl Into<String>) -> Self {
95 self.store_name = name.into();
96 self
97 }
98
99 /// Enable the window-state service. The service stores
100 /// per-`label` entries, so a multi-window app records each
101 /// window's geometry under its own label (e.g. `"main"`,
102 /// `"log"`, `"inspector"`).
103 pub fn with_window_state(mut self, enabled: bool) -> Self {
104 self.window_state_enabled = enabled;
105 self
106 }
107
108 /// Override the debounce window passed to every service this bundle
109 /// opens.
110 ///
111 /// Both [`SettingsStore`] and [`WindowStateService`] actually debounce
112 /// on it: `SettingsStore`'s writes are frequent enough (every
113 /// `Signal::set`) that coalescing matters, and `WindowStateService`'s
114 /// `record` fires once per reported window-geometry frame during a
115 /// drag, so it schedules its own coalesced write through the same
116 /// delay via a `DebouncedWriter` (see `window_state.rs`'s module docs,
117 /// "`record` is debounced, not synchronous"). It is a plain
118 /// [`crate::SettingsFile`] — not opened by this bundle — whose writes
119 /// are always a synchronous locked read-modify-write, with nothing
120 /// left to debounce (see `file.rs`'s module docs).
121 pub fn with_debounce(mut self, delay: Duration) -> Self {
122 self.debounce = delay;
123 self
124 }
125
126 /// The filename stem (without `.toml`) used for the K/V store.
127 pub fn store_name(&self) -> &str {
128 &self.store_name
129 }
130
131 /// The debounce window passed to every service this bundle opens
132 /// (see [`with_debounce`](Self::with_debounce) for which services
133 /// actually honor it).
134 pub fn debounce(&self) -> Duration {
135 self.debounce
136 }
137
138 /// Open every requested service against `paths`.
139 ///
140 /// Every opened service is also registered into a fresh
141 /// [`SettingsRegistry`] (exposed as [`OpenedSettings::registry`]) under
142 /// its canonical path, so a [`crate::SettingsWatcher`] event naming
143 /// that path can be dispatched straight to it. The registration
144 /// handles are retained internally by `OpenedSettings` — see its
145 /// field docs — so they stay alive (and thus dispatchable) for as
146 /// long as the returned `OpenedSettings` (or any clone of it) is.
147 pub fn open(self, paths: &AppPaths) -> Result<OpenedSettings, SettingsBundleError> {
148 let store =
149 SettingsStore::open_with_delay(paths.config_file(&self.store_name), self.debounce)?;
150 let window_state = if self.window_state_enabled {
151 Some(WindowStateService::open_with_delay(paths, self.debounce)?)
152 } else {
153 None
154 };
155
156 let registry = SettingsRegistry::new();
157 let mut reload_handles: Vec<Rc<dyn Reloadable>> = Vec::new();
158 reload_handles.push(registry.register(Rc::new(store.clone())));
159 if let Some(window_state) = &window_state {
160 reload_handles.push(registry.register(Rc::new(window_state.clone())));
161 }
162
163 Ok(OpenedSettings {
164 store,
165 window_state,
166 registry,
167 reload_handles,
168 })
169 }
170}
171
172impl Default for SettingsBundle {
173 fn default() -> Self {
174 Self::new()
175 }
176}
177
178/// The outcome of [`SettingsBundle::open`]: ready-to-register handles.
179///
180/// `Clone` is cheap and **shared, not deep**. Each contained service
181/// is internally `Rc<>`-shaped (matching `ListModel<T>` / `TreeModel<T>`
182/// / `Signal<T>`); cloning produces a second handle to the same
183/// in-memory state and the same shared I/O thread queue. Mutations
184/// through any clone are visible to every clone, and `flush_all` /
185/// `Drop` semantics are unchanged.
186#[derive(Clone)]
187pub struct OpenedSettings {
188 pub store: SettingsStore,
189 pub window_state: Option<WindowStateService>,
190 /// Registry mapping every managed service's canonical path to its
191 /// live [`Reloadable`] handle, so a [`crate::SettingsWatcher`] event
192 /// can be dispatched to the right one. Exposed so application code
193 /// can register its own ad hoc [`crate::SettingsFile`] /
194 /// [`crate::PersistedListModel`] / [`crate::MruList`] handles into
195 /// the same registry (reachable elsewhere via
196 /// `ctx.app_state::<SettingsRegistry>()` once installed).
197 pub registry: SettingsRegistry,
198 /// Strong references backing `registry`'s `Weak` entries for
199 /// `store` / `window_state`. `SettingsRegistry::register` only ever
200 /// keeps a `Weak` — these are what keep that `Weak` upgradeable for
201 /// as long as this `OpenedSettings` (or any clone of it) is alive.
202 /// Never read after construction; kept only for its `Drop`.
203 reload_handles: Vec<Rc<dyn Reloadable>>,
204}
205
206impl std::fmt::Debug for OpenedSettings {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 f.debug_struct("OpenedSettings")
209 .field("store", &self.store)
210 .field("window_state", &self.window_state)
211 .field("registry", &self.registry)
212 .field("reload_handles", &self.reload_handles.len())
213 .finish()
214 }
215}
216
217impl OpenedSettings {
218 /// Synchronously flush every active service.
219 pub fn flush_all(&self) -> Result<(), SettingsBundleError> {
220 self.store.flush_now()?;
221 if let Some(w) = &self.window_state {
222 w.flush_now()?;
223 }
224 Ok(())
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use tempfile::tempdir;
232
233 #[test]
234 fn empty_bundle_opens_only_store() {
235 let dir = tempdir().unwrap();
236 let paths = AppPaths::for_testing(dir.path());
237 let opened = SettingsBundle::new()
238 .with_debounce(Duration::ZERO)
239 .open(&paths)
240 .unwrap();
241 assert!(opened.window_state.is_none());
242 }
243
244 #[test]
245 fn full_bundle_opens_window_state() {
246 let dir = tempdir().unwrap();
247 let paths = AppPaths::for_testing(dir.path());
248 let opened = SettingsBundle::new()
249 .with_window_state(true)
250 .with_debounce(Duration::ZERO)
251 .open(&paths)
252 .unwrap();
253 assert!(opened.window_state.is_some());
254 }
255
256 #[test]
257 fn store_name_overrides_path() {
258 let dir = tempdir().unwrap();
259 let paths = AppPaths::for_testing(dir.path());
260 let opened = SettingsBundle::new()
261 .with_store_name("editor")
262 .with_debounce(Duration::ZERO)
263 .open(&paths)
264 .unwrap();
265 assert_eq!(opened.store.path(), paths.config_file("editor"));
266 }
267
268 // -----------------------------------------------------------------
269 // Live cross-process reload wiring: `OpenedSettings::registry`.
270 // -----------------------------------------------------------------
271
272 const NAME: crate::store::SettingsKey<String> =
273 crate::store::SettingsKey::new("user.name", String::new);
274
275 #[test]
276 fn opened_settings_registry_dispatches_a_peers_store_write() {
277 let dir = tempdir().unwrap();
278 let paths = AppPaths::for_testing(dir.path());
279
280 let mine = SettingsBundle::new()
281 .with_debounce(Duration::ZERO)
282 .open(&paths)
283 .unwrap();
284 let name_signal = mine.store.signal_for(&NAME);
285 assert_eq!(name_signal.get(), "");
286
287 // A second process opening the same store file and writing a
288 // value — standing in for a peer, exactly like
289 // `two_shared_mode_services_over_one_file_do_not_clobber_each_others_overrides`.
290 let peer = SettingsBundle::new()
291 .with_debounce(Duration::ZERO)
292 .open(&paths)
293 .unwrap();
294 peer.store.signal_for(&NAME).set("peer-name".to_string());
295 peer.flush_all().unwrap();
296
297 let changed = mine.store.path().to_path_buf();
298 assert!(mine.registry.dispatch(&changed).unwrap());
299 assert_eq!(name_signal.get(), "peer-name");
300 }
301
302 #[test]
303 fn opened_settings_registry_dispatches_a_peers_window_state_write() {
304 let dir = tempdir().unwrap();
305 let paths = AppPaths::for_testing(dir.path());
306
307 let mine = SettingsBundle::new()
308 .with_window_state(true)
309 .with_debounce(Duration::ZERO)
310 .open(&paths)
311 .unwrap();
312 let window_state = mine.window_state.clone().unwrap();
313 assert!(window_state.state_for("main").is_none());
314
315 let peer = SettingsBundle::new()
316 .with_window_state(true)
317 .with_debounce(Duration::ZERO)
318 .open(&paths)
319 .unwrap();
320 peer.window_state
321 .as_ref()
322 .unwrap()
323 .record(crate::window_state::PerWindowState {
324 label: "main".into(),
325 x: 10,
326 y: 20,
327 width: 800,
328 height: 600,
329 placement: Default::default(),
330 })
331 .unwrap();
332 // `record` is debounced (it fires once per frame during a window drag),
333 // so push the peer's write out before asking the registry to pick it up.
334 peer.window_state.as_ref().unwrap().flush_now().unwrap();
335
336 let changed = window_state.path().to_path_buf();
337 assert!(mine.registry.dispatch(&changed).unwrap());
338 let restored = window_state.state_for("main").unwrap();
339 assert_eq!(restored.width, 800);
340 assert_eq!(restored.height, 600);
341 }
342
343 /// Dropping every clone of an `OpenedSettings` must deregister its
344 /// services from the (separately held) registry — proving the
345 /// `reload_handles` field actually backs the `Weak` entries, not
346 /// just a documentation claim.
347 #[test]
348 fn dropping_opened_settings_deregisters_its_services() {
349 let dir = tempdir().unwrap();
350 let paths = AppPaths::for_testing(dir.path());
351
352 let opened = SettingsBundle::new()
353 .with_window_state(true)
354 .with_debounce(Duration::ZERO)
355 .open(&paths)
356 .unwrap();
357 let registry = opened.registry.clone();
358 assert_eq!(registry.live_count(), 2, "store + window_state");
359
360 drop(opened);
361
362 assert_eq!(
363 registry.live_count(),
364 0,
365 "dropping every OpenedSettings clone must drop its reload_handles too"
366 );
367 }
368}