Skip to main content

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}