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 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#[derive(Debug, Clone, Copy)]
77pub struct WatchOptions {
78 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
90pub 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 #[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 #[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 #[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#[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 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 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 match rx.recv() {
248 Ok(Ok(_)) => {}
249 Ok(Err(e)) => {
250 tracing::warn!("watcher error: {e}");
251 continue;
252 }
253 Err(_) => break,
254 }
255 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#[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 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 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
439pub 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 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 thread::sleep(Duration::from_millis(600));
594
595 assert_eq!(handle.read().value, 42);
596 }
597}