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    /// 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}