Skip to main content

confex_core/
handle.rs

1use std::{
2    ops::{Deref, DerefMut},
3    path::PathBuf,
4    sync::Arc,
5};
6
7use arc_swap::ArcSwap;
8use serde::Serialize;
9
10use crate::{
11    config::ConfigSchema, deserialize::to_config_value, format::Format, value::ConfigValue,
12};
13
14#[cfg(feature = "hot-reload")]
15use crate::error::LoadError;
16
17#[derive(Debug)]
18pub enum HotReloadError {
19    Watch(String),
20    Serialize(String),
21    Io(std::io::Error),
22    /// Failure during the initial config load before the watcher could start.
23    Load(crate::error::LoadError),
24}
25
26impl std::fmt::Display for HotReloadError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Watch(msg) => write!(f, "failed to set up file watcher: {msg}"),
30            Self::Serialize(msg) => write!(f, "failed to serialize config: {msg}"),
31            Self::Io(e) => write!(f, "I/O error persisting config: {e}"),
32            Self::Load(e) => write!(f, "failed to load initial config: {e}"),
33        }
34    }
35}
36
37impl std::error::Error for HotReloadError {
38    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
39        match self {
40            Self::Io(e) => Some(e),
41            Self::Load(e) => Some(e),
42            _ => None,
43        }
44    }
45}
46
47impl From<std::io::Error> for HotReloadError {
48    fn from(e: std::io::Error) -> Self {
49        Self::Io(e)
50    }
51}
52
53impl From<crate::error::LoadError> for HotReloadError {
54    fn from(e: crate::error::LoadError) -> Self {
55        Self::Load(e)
56    }
57}
58
59pub(crate) struct FileWriteBack {
60    path: PathBuf,
61    format: Arc<dyn Format + Send + Sync>,
62}
63
64impl FileWriteBack {
65    fn persist(&self, value: &ConfigValue) -> Result<(), HotReloadError> {
66        let bytes = self
67            .format
68            .serialize(value)
69            .map_err(|e| HotReloadError::Serialize(e.to_string()))?;
70        std::fs::write(&self.path, bytes)?;
71        Ok(())
72    }
73}
74
75/// Tuning options for the file watcher.
76#[derive(Debug, Clone, Copy)]
77pub struct WatchOptions {
78    /// Quiet period after the last file event before reloading. Default: 200 ms.
79    pub debounce: std::time::Duration,
80}
81
82impl Default for WatchOptions {
83    fn default() -> Self {
84        Self {
85            debounce: std::time::Duration::from_millis(200),
86        }
87    }
88}
89
90/// Live, cloneable handle to a loaded config value.
91///
92/// Reads are lock-free via `ArcSwap`. Writes go through [`WriteGuard`], which
93/// atomically updates the in-memory state on drop and optionally persists to
94/// the config file (watcher mode only).
95///
96/// Clone the handle freely — all clones share the same live value.
97pub struct ConfigHandle<T> {
98    swap: Arc<ArcSwap<T>>,
99    write_back: Option<Arc<FileWriteBack>>,
100}
101
102impl<T> Clone for ConfigHandle<T> {
103    fn clone(&self) -> Self {
104        Self {
105            swap: Arc::clone(&self.swap),
106            write_back: self.write_back.clone(),
107        }
108    }
109}
110
111impl<T: ConfigSchema> From<T> for ConfigHandle<T> {
112    fn from(config: T) -> Self {
113        Self {
114            swap: Arc::new(ArcSwap::from_pointee(config)),
115            write_back: None,
116        }
117    }
118}
119
120impl<T: ConfigSchema> ConfigHandle<T> {
121    /// Lock-free snapshot of the current config. Equivalent to `Arc::clone` cost.
122    #[must_use]
123    pub fn read(&self) -> arc_swap::Guard<Arc<T>> {
124        self.swap.load()
125    }
126}
127
128impl<T: ConfigSchema + Clone + Serialize> ConfigHandle<T> {
129    /// Write-back mode: no file watcher, but every [`WriteGuard`] drop persists to `path`.
130    ///
131    /// Use this when you want `config.write()` to auto-save without spinning up a
132    /// hot-reload thread.
133    #[must_use]
134    pub fn write_back(
135        initial: T,
136        path: impl Into<std::path::PathBuf>,
137        format: impl Format + Send + Sync + 'static,
138    ) -> Self {
139        Self {
140            swap: Arc::new(ArcSwap::from_pointee(initial)),
141            write_back: Some(Arc::new(FileWriteBack {
142                path: path.into(),
143                format: Arc::new(format),
144            })),
145        }
146    }
147
148    /// Return a write guard. On drop the guard:
149    ///
150    /// 1. Serializes the modified value and writes it to the config file (watcher mode).
151    /// 2. Atomically swaps it into the `ArcSwap` so all readers see the new value.
152    ///
153    /// In manual mode only step 2 happens (no file path is stored).
154    #[must_use]
155    pub fn write(&self) -> WriteGuard<T> {
156        let current = self.swap.load();
157        WriteGuard {
158            value: (**current).clone(),
159            swap: Arc::clone(&self.swap),
160            write_back: self.write_back.clone(),
161        }
162    }
163}
164
165/// Keeps the file watcher alive. Drop to stop watching.
166#[cfg(feature = "hot-reload")]
167pub struct WatcherHandle {
168    _watcher: notify::RecommendedWatcher,
169}
170
171#[cfg(feature = "hot-reload")]
172impl<T: ConfigSchema + Clone + Send + Sync + 'static> ConfigHandle<T> {
173    /// Watcher mode: watches `path` for external changes and reloads atomically.
174    ///
175    /// `reload` is called on every detected change after the debounce window.
176    /// If it fails the previous config is kept and the error is logged via `tracing`.
177    ///
178    /// # Errors
179    ///
180    /// Returns [`HotReloadError::Watch`] if the file watcher cannot be initialised.
181    pub fn watch(
182        initial: T,
183        path: &std::path::Path,
184        format: impl Format + Send + Sync + 'static,
185        reload: impl Fn() -> Result<T, LoadError> + Send + Sync + 'static,
186        options: WatchOptions,
187    ) -> Result<(Self, WatcherHandle), HotReloadError> {
188        let write_back = Some(Arc::new(FileWriteBack {
189            path: path.to_path_buf(),
190            format: Arc::new(format),
191        }));
192        let reload: Arc<dyn Fn() -> Result<T, LoadError> + Send + Sync> = Arc::new(reload);
193        Self::spawn_watcher(initial, path, &reload, write_back, options.debounce)
194    }
195
196    /// Shared watcher setup used by both file-backed and redb-backed watch modes.
197    pub(crate) fn spawn_watcher(
198        initial: T,
199        watch_path: &std::path::Path,
200        reload: &Arc<dyn Fn() -> Result<T, LoadError> + Send + Sync>,
201        write_back: Option<Arc<FileWriteBack>>,
202        debounce: std::time::Duration,
203    ) -> Result<(Self, WatcherHandle), HotReloadError> {
204        use notify::{Config, RecursiveMode, Watcher as _};
205        use std::sync::mpsc;
206
207        let swap = Arc::new(ArcSwap::from_pointee(initial));
208        let swap_bg = Arc::clone(&swap);
209        let reload_bg = Arc::clone(reload);
210
211        let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
212        let mut watcher = notify::RecommendedWatcher::new(
213            move |ev| {
214                let _ = tx.send(ev);
215            },
216            Config::default(),
217        )
218        .map_err(|e| HotReloadError::Watch(e.to_string()))?;
219
220        watcher
221            .watch(watch_path, RecursiveMode::NonRecursive)
222            .map_err(|e| HotReloadError::Watch(e.to_string()))?;
223
224        std::thread::Builder::new()
225            .name("confex-watcher".into())
226            .spawn(move || watcher_loop(&rx, &swap_bg, &reload_bg, debounce))
227            .map_err(HotReloadError::Io)?;
228
229        Ok((
230            Self { swap, write_back },
231            WatcherHandle { _watcher: watcher },
232        ))
233    }
234}
235
236#[cfg(feature = "hot-reload")]
237fn watcher_loop<T: Send + Sync + 'static>(
238    rx: &std::sync::mpsc::Receiver<notify::Result<notify::Event>>,
239    swap: &Arc<ArcSwap<T>>,
240    reload: &Arc<dyn Fn() -> Result<T, LoadError> + Send + Sync>,
241    debounce: std::time::Duration,
242) {
243    use std::sync::mpsc::RecvTimeoutError;
244
245    loop {
246        // Block until the first event for this change burst.
247        match rx.recv() {
248            Ok(Ok(_)) => {}
249            Ok(Err(e)) => {
250                tracing::warn!("watcher error: {e}");
251                continue;
252            }
253            Err(_) => break,
254        }
255        // Drain additional events within the debounce window.
256        loop {
257            match rx.recv_timeout(debounce) {
258                Ok(_) => {}
259                Err(RecvTimeoutError::Timeout) => break,
260                Err(RecvTimeoutError::Disconnected) => return,
261            }
262        }
263        match reload() {
264            Ok(cfg) => {
265                swap.store(Arc::new(cfg));
266                tracing::debug!("config hot-reloaded");
267            }
268            Err(e) => {
269                tracing::warn!("hot-reload failed, keeping previous config: {e}");
270            }
271        }
272    }
273}
274
275/// Per-source entry for selective hot-reload.
276///
277/// Only the source whose `watch_paths` match the event path is re-collected;
278/// all other entries reuse their cached `ConfigValue`.
279#[cfg(feature = "hot-reload")]
280pub(crate) struct SelectiveEntry {
281    pub(crate) source: std::sync::Arc<dyn crate::source::Source + Send + Sync>,
282    pub(crate) cache: std::sync::Mutex<ConfigValue>,
283    pub(crate) watch_paths: Vec<std::path::PathBuf>,
284}
285
286#[cfg(feature = "hot-reload")]
287impl<T: ConfigSchema + Clone + Send + Sync + 'static> ConfigHandle<T> {
288    /// Selective-reload watcher: watches all paths declared by watchable sources
289    /// and re-collects only the source that actually changed.
290    pub(crate) fn spawn_selective_watcher(
291        initial: T,
292        initial_merged: ConfigValue,
293        entries: Vec<SelectiveEntry>,
294        strategies: crate::merge::FieldStrategies,
295        transform: std::sync::Arc<dyn Fn(ConfigValue) -> Result<T, LoadError> + Send + Sync>,
296        debounce: std::time::Duration,
297    ) -> Result<(Self, WatcherHandle), HotReloadError> {
298        use notify::{Config, RecursiveMode, Watcher as _};
299        use std::{collections::HashSet, path::PathBuf, sync::mpsc};
300
301        let swap = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(initial));
302        let swap_bg = std::sync::Arc::clone(&swap);
303
304        let (tx, rx) = mpsc::channel::<notify::Result<notify::Event>>();
305        let mut watcher = notify::RecommendedWatcher::new(
306            move |ev| {
307                let _ = tx.send(ev);
308            },
309            Config::default(),
310        )
311        .map_err(|e| HotReloadError::Watch(e.to_string()))?;
312
313        let mut registered: HashSet<PathBuf> = HashSet::new();
314        for entry in &entries {
315            for path in &entry.watch_paths {
316                if registered.insert(path.clone()) {
317                    watcher
318                        .watch(path, RecursiveMode::NonRecursive)
319                        .map_err(|e| HotReloadError::Watch(e.to_string()))?;
320                }
321            }
322        }
323
324        std::thread::Builder::new()
325            .name("confex-selective-watcher".into())
326            .spawn(move || {
327                selective_watcher_loop(
328                    rx,
329                    swap_bg,
330                    entries,
331                    initial_merged,
332                    strategies,
333                    transform,
334                    debounce,
335                );
336            })
337            .map_err(HotReloadError::Io)?;
338
339        Ok((
340            Self {
341                swap,
342                write_back: None,
343            },
344            WatcherHandle { _watcher: watcher },
345        ))
346    }
347}
348
349#[cfg(feature = "hot-reload")]
350#[allow(clippy::needless_pass_by_value)]
351fn selective_watcher_loop<T: Send + Sync + 'static>(
352    rx: std::sync::mpsc::Receiver<notify::Result<notify::Event>>,
353    swap: std::sync::Arc<arc_swap::ArcSwap<T>>,
354    entries: Vec<SelectiveEntry>,
355    mut last_raw: ConfigValue,
356    strategies: crate::merge::FieldStrategies,
357    transform: std::sync::Arc<dyn Fn(ConfigValue) -> Result<T, LoadError> + Send + Sync>,
358    debounce: std::time::Duration,
359) {
360    use std::{collections::HashSet, path::PathBuf, sync::mpsc::RecvTimeoutError};
361
362    loop {
363        let first_ev = match rx.recv() {
364            Ok(Ok(ev)) => ev,
365            Ok(Err(e)) => {
366                tracing::warn!("watcher error: {e}");
367                continue;
368            }
369            Err(_) => break,
370        };
371
372        let mut affected: HashSet<PathBuf> = first_ev.paths.into_iter().collect();
373
374        loop {
375            match rx.recv_timeout(debounce) {
376                Ok(Ok(ev)) => {
377                    affected.extend(ev.paths);
378                }
379                Ok(Err(e)) => tracing::warn!("watcher error: {e}"),
380                Err(RecvTimeoutError::Timeout) => break,
381                Err(RecvTimeoutError::Disconnected) => return,
382            }
383        }
384
385        let mut any_updated = false;
386        for entry in &entries {
387            let matches = entry
388                .watch_paths
389                .iter()
390                .any(|wp| affected.iter().any(|ap| ap == wp || ap.starts_with(wp)));
391            if !matches {
392                continue;
393            }
394            match entry.source.collect() {
395                Ok(new_cache) => {
396                    *entry
397                        .cache
398                        .lock()
399                        .expect("selective-reload cache mutex should never be poisoned") =
400                        new_cache;
401                    any_updated = true;
402                }
403                Err(e) => tracing::warn!("failed to re-collect source on change: {e}"),
404            }
405        }
406
407        if !any_updated {
408            continue;
409        }
410
411        let mut new_merged = ConfigValue::Table(std::collections::BTreeMap::new());
412        for entry in &entries {
413            let cache = entry
414                .cache
415                .lock()
416                .expect("selective-reload cache mutex should never be poisoned")
417                .clone();
418            new_merged = crate::merge::merge(new_merged, cache, strategies);
419        }
420
421        // Skip the swap when the merged result is unchanged (self-write suppression).
422        if new_merged == last_raw {
423            continue;
424        }
425        last_raw = new_merged.clone();
426
427        match transform(new_merged) {
428            Ok(cfg) => {
429                swap.store(std::sync::Arc::new(cfg));
430                tracing::debug!("config selectively hot-reloaded");
431            }
432            Err(e) => {
433                tracing::warn!("selective hot-reload transform failed, keeping previous: {e}");
434            }
435        }
436    }
437}
438
439/// Holds a mutable snapshot of `T`. On drop:
440///
441/// 1. If a file path is configured, serializes and writes to disk.
442/// 2. Atomically swaps the new value into the live handle.
443///
444/// Disk errors are logged via `tracing::error!`; the in-memory swap always happens.
445pub struct WriteGuard<T: ConfigSchema + Clone + Serialize> {
446    value: T,
447    swap: Arc<ArcSwap<T>>,
448    write_back: Option<Arc<FileWriteBack>>,
449}
450
451impl<T: ConfigSchema + Clone + Serialize> Deref for WriteGuard<T> {
452    type Target = T;
453    fn deref(&self) -> &T {
454        &self.value
455    }
456}
457
458impl<T: ConfigSchema + Clone + Serialize> DerefMut for WriteGuard<T> {
459    fn deref_mut(&mut self) -> &mut T {
460        &mut self.value
461    }
462}
463
464impl<T: ConfigSchema + Clone + Serialize> Drop for WriteGuard<T> {
465    fn drop(&mut self) {
466        if let Some(wb) = &self.write_back {
467            match to_config_value(&self.value) {
468                Ok(cv) => {
469                    if let Err(e) = wb.persist(&cv) {
470                        tracing::error!("failed to write config to disk: {e}");
471                    }
472                }
473                Err(e) => tracing::error!("failed to serialize config for disk write: {e}"),
474            }
475        }
476        self.swap.store(Arc::new(self.value.clone()));
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use serde::{Deserialize, Serialize};
483
484    use super::*;
485    use crate::{config::ConfigSchema, merge::FieldStrategies};
486
487    #[derive(Debug, Clone, Serialize, Deserialize)]
488    struct Cfg {
489        value: i64,
490    }
491
492    impl ConfigSchema for Cfg {
493        const APP_NAME: &'static str = "test";
494        const SCHEMA_VERSION: u32 = 1;
495        fn field_strategies() -> FieldStrategies {
496            &[]
497        }
498        fn encrypted_fields() -> &'static [&'static str] {
499            &[]
500        }
501    }
502
503    #[test]
504    fn read_returns_initial_value() {
505        let h = ConfigHandle::from(Cfg { value: 42 });
506        assert_eq!(h.read().value, 42);
507    }
508
509    #[test]
510    fn write_guard_updates_in_memory_on_drop() {
511        let h = ConfigHandle::from(Cfg { value: 1 });
512        {
513            let mut g = h.write();
514            (*g).value = 99;
515        }
516        assert_eq!(h.read().value, 99);
517    }
518
519    #[test]
520    fn cloned_handle_sees_write() {
521        let h = ConfigHandle::from(Cfg { value: 0 });
522        let h2 = h.clone();
523        {
524            let mut g = h.write();
525            (*g).value = 7;
526        }
527        assert_eq!(h2.read().value, 7);
528    }
529
530    #[cfg(feature = "hot-reload")]
531    #[test]
532    fn selective_watcher_only_recollects_changed_source() {
533        use crate::{
534            config::ConfigLoader,
535            format::TomlFormat,
536            handle::WatchOptions,
537            source::{EnvSource, FileSource},
538        };
539        use std::{thread, time::Duration};
540
541        let dir = tempfile::tempdir().unwrap();
542        let path = dir.path().join("config.toml");
543        std::fs::write(&path, "value = 10\n").unwrap();
544
545        // EnvSource is passive; FileSource is watchable via with_watched_source.
546        let (handle, _watcher) = ConfigLoader::<Cfg>::default()
547            .with_source(EnvSource::from("CONFEX_TEST_SEL"))
548            .with_watched_source(FileSource::new(vec![path.clone()], TomlFormat))
549            .watch_auto(WatchOptions::default())
550            .unwrap();
551
552        assert_eq!(handle.read().value, 10);
553
554        std::fs::write(&path, "value = 77\n").unwrap();
555        thread::sleep(Duration::from_millis(600));
556
557        assert_eq!(handle.read().value, 77);
558    }
559
560    #[cfg(feature = "hot-reload")]
561    #[test]
562    fn watcher_reloads_on_file_change() {
563        use crate::{
564            format::TomlFormat,
565            source::{FileSource, Source},
566        };
567        use std::{thread, time::Duration};
568
569        let dir = tempfile::tempdir().unwrap();
570        let path = dir.path().join("config.toml");
571        std::fs::write(&path, "value = 1\n").unwrap();
572
573        let path_clone = path.clone();
574        let (handle, _watcher) = ConfigHandle::watch(
575            Cfg { value: 1 },
576            &path,
577            TomlFormat,
578            move || {
579                let src = FileSource::new(vec![path_clone.clone()], TomlFormat);
580                let cv = src.collect()?;
581                Ok(serde::de::Deserialize::deserialize(
582                    crate::deserialize::ConfigValueDeserializer(cv),
583                )?)
584            },
585            WatchOptions::default(),
586        )
587        .unwrap();
588
589        assert_eq!(handle.read().value, 1);
590
591        std::fs::write(&path, "value = 42\n").unwrap();
592        // Wait for debounce + watcher to fire.
593        thread::sleep(Duration::from_millis(600));
594
595        assert_eq!(handle.read().value, 42);
596    }
597}