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 /// Only [`SettingsStore`] actually debounces on it — its writes are
112 /// frequent enough (every `Signal::set`) that coalescing matters.
113 /// [`WindowStateService`] accepts the same parameter (so `open` can
114 /// call both uniformly) but ignores it: `SettingsFile`'s writes are
115 /// always a synchronous locked read-modify-write now, so there is
116 /// nothing left to debounce (see `file.rs`'s and `window_state.rs`'s
117 /// module docs).
118 pub fn with_debounce(mut self, delay: Duration) -> Self {
119 self.debounce = delay;
120 self
121 }
122
123 /// The filename stem (without `.toml`) used for the K/V store.
124 pub fn store_name(&self) -> &str {
125 &self.store_name
126 }
127
128 /// The debounce window passed to every service this bundle opens
129 /// (see [`with_debounce`](Self::with_debounce) for which services
130 /// actually honor it).
131 pub fn debounce(&self) -> Duration {
132 self.debounce
133 }
134
135 /// Open every requested service against `paths`.
136 ///
137 /// Every opened service is also registered into a fresh
138 /// [`SettingsRegistry`] (exposed as [`OpenedSettings::registry`]) under
139 /// its canonical path, so a [`crate::SettingsWatcher`] event naming
140 /// that path can be dispatched straight to it. The registration
141 /// handles are retained internally by `OpenedSettings` — see its
142 /// field docs — so they stay alive (and thus dispatchable) for as
143 /// long as the returned `OpenedSettings` (or any clone of it) is.
144 pub fn open(self, paths: &AppPaths) -> Result<OpenedSettings, SettingsBundleError> {
145 let store =
146 SettingsStore::open_with_delay(paths.config_file(&self.store_name), self.debounce)?;
147 let window_state = if self.window_state_enabled {
148 Some(WindowStateService::open_with_delay(paths, self.debounce)?)
149 } else {
150 None
151 };
152
153 let registry = SettingsRegistry::new();
154 let mut reload_handles: Vec<Rc<dyn Reloadable>> = Vec::new();
155 reload_handles.push(registry.register(Rc::new(store.clone())));
156 if let Some(window_state) = &window_state {
157 reload_handles.push(registry.register(Rc::new(window_state.clone())));
158 }
159
160 Ok(OpenedSettings {
161 store,
162 window_state,
163 registry,
164 reload_handles,
165 })
166 }
167}
168
169impl Default for SettingsBundle {
170 fn default() -> Self {
171 Self::new()
172 }
173}
174
175/// The outcome of [`SettingsBundle::open`]: ready-to-register handles.
176///
177/// `Clone` is cheap and **shared, not deep**. Each contained service
178/// is internally `Rc<>`-shaped (matching `ListModel<T>` / `TreeModel<T>`
179/// / `Signal<T>`); cloning produces a second handle to the same
180/// in-memory state and the same shared I/O thread queue. Mutations
181/// through any clone are visible to every clone, and `flush_all` /
182/// `Drop` semantics are unchanged.
183#[derive(Clone)]
184pub struct OpenedSettings {
185 pub store: SettingsStore,
186 pub window_state: Option<WindowStateService>,
187 /// Registry mapping every managed service's canonical path to its
188 /// live [`Reloadable`] handle, so a [`crate::SettingsWatcher`] event
189 /// can be dispatched to the right one. Exposed so application code
190 /// can register its own ad hoc [`crate::SettingsFile`] /
191 /// [`crate::PersistedListModel`] / [`crate::MruList`] handles into
192 /// the same registry (reachable elsewhere via
193 /// `ctx.app_state::<SettingsRegistry>()` once installed).
194 pub registry: SettingsRegistry,
195 /// Strong references backing `registry`'s `Weak` entries for
196 /// `store` / `window_state`. `SettingsRegistry::register` only ever
197 /// keeps a `Weak` — these are what keep that `Weak` upgradeable for
198 /// as long as this `OpenedSettings` (or any clone of it) is alive.
199 /// Never read after construction; kept only for its `Drop`.
200 reload_handles: Vec<Rc<dyn Reloadable>>,
201}
202
203impl std::fmt::Debug for OpenedSettings {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.debug_struct("OpenedSettings")
206 .field("store", &self.store)
207 .field("window_state", &self.window_state)
208 .field("registry", &self.registry)
209 .field("reload_handles", &self.reload_handles.len())
210 .finish()
211 }
212}
213
214impl OpenedSettings {
215 /// Synchronously flush every active service.
216 pub fn flush_all(&self) -> Result<(), SettingsBundleError> {
217 self.store.flush_now()?;
218 if let Some(w) = &self.window_state {
219 w.flush_now()?;
220 }
221 Ok(())
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use tempfile::tempdir;
229
230 #[test]
231 fn empty_bundle_opens_only_store() {
232 let dir = tempdir().unwrap();
233 let paths = AppPaths::for_testing(dir.path());
234 let opened = SettingsBundle::new()
235 .with_debounce(Duration::ZERO)
236 .open(&paths)
237 .unwrap();
238 assert!(opened.window_state.is_none());
239 }
240
241 #[test]
242 fn full_bundle_opens_window_state() {
243 let dir = tempdir().unwrap();
244 let paths = AppPaths::for_testing(dir.path());
245 let opened = SettingsBundle::new()
246 .with_window_state(true)
247 .with_debounce(Duration::ZERO)
248 .open(&paths)
249 .unwrap();
250 assert!(opened.window_state.is_some());
251 }
252
253 #[test]
254 fn store_name_overrides_path() {
255 let dir = tempdir().unwrap();
256 let paths = AppPaths::for_testing(dir.path());
257 let opened = SettingsBundle::new()
258 .with_store_name("editor")
259 .with_debounce(Duration::ZERO)
260 .open(&paths)
261 .unwrap();
262 assert_eq!(opened.store.path(), paths.config_file("editor"));
263 }
264
265 // -----------------------------------------------------------------
266 // Live cross-process reload wiring: `OpenedSettings::registry`.
267 // -----------------------------------------------------------------
268
269 const NAME: crate::store::SettingsKey<String> =
270 crate::store::SettingsKey::new("user.name", String::new);
271
272 #[test]
273 fn opened_settings_registry_dispatches_a_peers_store_write() {
274 let dir = tempdir().unwrap();
275 let paths = AppPaths::for_testing(dir.path());
276
277 let mine = SettingsBundle::new()
278 .with_debounce(Duration::ZERO)
279 .open(&paths)
280 .unwrap();
281 let name_signal = mine.store.signal_for(&NAME);
282 assert_eq!(name_signal.get(), "");
283
284 // A second process opening the same store file and writing a
285 // value — standing in for a peer, exactly like
286 // `two_shared_mode_services_over_one_file_do_not_clobber_each_others_overrides`.
287 let peer = SettingsBundle::new()
288 .with_debounce(Duration::ZERO)
289 .open(&paths)
290 .unwrap();
291 peer.store.signal_for(&NAME).set("peer-name".to_string());
292 peer.flush_all().unwrap();
293
294 let changed = mine.store.path().to_path_buf();
295 assert!(mine.registry.dispatch(&changed).unwrap());
296 assert_eq!(name_signal.get(), "peer-name");
297 }
298
299 #[test]
300 fn opened_settings_registry_dispatches_a_peers_window_state_write() {
301 let dir = tempdir().unwrap();
302 let paths = AppPaths::for_testing(dir.path());
303
304 let mine = SettingsBundle::new()
305 .with_window_state(true)
306 .with_debounce(Duration::ZERO)
307 .open(&paths)
308 .unwrap();
309 let window_state = mine.window_state.clone().unwrap();
310 assert!(window_state.state_for("main").is_none());
311
312 let peer = SettingsBundle::new()
313 .with_window_state(true)
314 .with_debounce(Duration::ZERO)
315 .open(&paths)
316 .unwrap();
317 peer.window_state
318 .as_ref()
319 .unwrap()
320 .record(crate::window_state::PerWindowState {
321 label: "main".into(),
322 x: 10,
323 y: 20,
324 width: 800,
325 height: 600,
326 placement: Default::default(),
327 })
328 .unwrap();
329 // `record` is debounced (it fires once per frame during a window drag),
330 // so push the peer's write out before asking the registry to pick it up.
331 peer.window_state.as_ref().unwrap().flush_now().unwrap();
332
333 let changed = window_state.path().to_path_buf();
334 assert!(mine.registry.dispatch(&changed).unwrap());
335 let restored = window_state.state_for("main").unwrap();
336 assert_eq!(restored.width, 800);
337 assert_eq!(restored.height, 600);
338 }
339
340 /// Dropping every clone of an `OpenedSettings` must deregister its
341 /// services from the (separately held) registry — proving the
342 /// `reload_handles` field actually backs the `Weak` entries, not
343 /// just a documentation claim.
344 #[test]
345 fn dropping_opened_settings_deregisters_its_services() {
346 let dir = tempdir().unwrap();
347 let paths = AppPaths::for_testing(dir.path());
348
349 let opened = SettingsBundle::new()
350 .with_window_state(true)
351 .with_debounce(Duration::ZERO)
352 .open(&paths)
353 .unwrap();
354 let registry = opened.registry.clone();
355 assert_eq!(registry.live_count(), 2, "store + window_state");
356
357 drop(opened);
358
359 assert_eq!(
360 registry.live_count(),
361 0,
362 "dropping every OpenedSettings clone must drop its reload_handles too"
363 );
364 }
365}