Skip to main content

atuin_client/settings/
watcher.rs

1//! Config file watching for automatic settings reload.
2//!
3//! This module provides a `SettingsWatcher` that monitors the config file
4//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use atuin_client::settings::watcher::global_settings_watcher;
10//!
11//! async fn example() -> eyre::Result<()> {
12//!     let watcher = global_settings_watcher()?;
13//!     let mut rx = watcher.subscribe();
14//!
15//!     // React to settings changes
16//!     while rx.changed().await.is_ok() {
17//!         let settings = rx.borrow();
18//!         println!("Settings updated!");
19//!     }
20//!     Ok(())
21//! }
22//! ```
23
24use std::{
25    path::PathBuf,
26    sync::{Arc, OnceLock},
27    time::Duration,
28};
29
30use eyre::{Result, WrapErr};
31use log::{debug, error, info, warn};
32use notify::{
33    Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
34    event::{EventKind, ModifyKind},
35};
36use tokio::sync::watch;
37
38use super::Settings;
39
40/// Global singleton for the settings watcher.
41static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();
42
43/// Get the global settings watcher singleton.
44///
45/// Initializes the watcher on first call. Subsequent calls return the same instance.
46/// The watcher monitors the config file for changes and broadcasts updates.
47pub fn global_settings_watcher() -> Result<&'static SettingsWatcher> {
48    let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));
49
50    match result {
51        Ok(watcher) => Ok(watcher),
52        Err(e) => Err(eyre::eyre!("{}", e)),
53    }
54}
55
56/// Watches the config file for changes and broadcasts updated settings.
57///
58/// Uses `notify` for cross-platform file watching and `tokio::sync::watch`
59/// for efficient broadcast to multiple subscribers.
60pub struct SettingsWatcher {
61    /// Receiver for settings updates. Clone this to subscribe.
62    rx: watch::Receiver<Arc<Settings>>,
63    /// Keeps the file watcher alive for the lifetime of this struct.
64    _watcher: RecommendedWatcher,
65}
66
67impl SettingsWatcher {
68    /// Create a new settings watcher.
69    ///
70    /// Loads initial settings and starts watching the config file for changes.
71    /// Changes are debounced (500ms) to avoid multiple reloads during saves.
72    pub fn new() -> Result<Self> {
73        let initial_settings = Arc::new(Settings::new()?);
74        let (tx, rx) = watch::channel(initial_settings);
75
76        let config_path = Self::config_path();
77        info!("starting config file watcher: {:?}", config_path);
78
79        let watcher = Self::create_watcher(tx, config_path)?;
80
81        Ok(Self {
82            rx,
83            _watcher: watcher,
84        })
85    }
86
87    /// Subscribe to settings updates.
88    ///
89    /// Returns a receiver that will be notified when settings change.
90    /// Use `changed().await` to wait for the next update, then `borrow()`
91    /// to access the current settings.
92    pub fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {
93        self.rx.clone()
94    }
95
96    /// Get the current settings without subscribing to updates.
97    pub fn current(&self) -> Arc<Settings> {
98        self.rx.borrow().clone()
99    }
100
101    /// Get the config file path.
102    fn config_path() -> PathBuf {
103        let config_dir = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
104            PathBuf::from(p)
105        } else {
106            atuin_common::utils::config_dir()
107        };
108        config_dir.join("config.toml")
109    }
110
111    /// Create the file watcher with debouncing.
112    fn create_watcher(
113        tx: watch::Sender<Arc<Settings>>,
114        config_path: PathBuf,
115    ) -> Result<RecommendedWatcher> {
116        // Channel for debouncing file events
117        let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();
118
119        // Spawn debounce thread
120        let config_path_clone = config_path.clone();
121        std::thread::spawn(move || {
122            Self::debounce_loop(debounce_rx, tx, config_path_clone);
123        });
124
125        // Clone config_path for use in the watcher callback
126        let config_path_for_watcher = config_path.clone();
127
128        // Canonicalize config path for reliable comparison on macOS
129        // (handles symlinks like /var -> /private/var)
130        let canonical_config_path = config_path_for_watcher
131            .canonicalize()
132            .unwrap_or_else(|_| config_path_for_watcher.clone());
133
134        // Create file watcher
135        let mut watcher = RecommendedWatcher::new(
136            move |res: Result<notify::Event, notify::Error>| {
137                match res {
138                    Ok(event) => {
139                        // Defensive: if paths is empty, we can't filter, so assume
140                        // it might be our config file and trigger a reload to be safe
141                        if event.paths.is_empty() {
142                            warn!(
143                                "config watcher: event has no paths, triggering reload to be safe"
144                            );
145                            let _ = debounce_tx.send(());
146                            return;
147                        }
148
149                        // Only react to events for our specific config file
150                        // (filter out editor temp files, backups, etc.)
151                        let is_config_file = event.paths.iter().any(|path| {
152                            // Canonicalize for reliable comparison (handles macOS symlinks)
153                            let canonical_event_path =
154                                path.canonicalize().unwrap_or_else(|_| path.clone());
155
156                            // Check if this event is for our config file
157                            // (either exact match or the file was renamed to our config)
158                            canonical_event_path == canonical_config_path
159                                || path.file_name() == config_path_for_watcher.file_name()
160                        });
161
162                        if !is_config_file {
163                            return;
164                        }
165
166                        // Only react to modify events (content changes) or creates
167                        if matches!(
168                            event.kind,
169                            EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)
170                                | EventKind::Create(_)
171                        ) {
172                            debug!("config file event detected: {:?}", event);
173                            // Send to debounce channel (ignore send errors - receiver might be gone)
174                            let _ = debounce_tx.send(());
175                        }
176                    }
177                    Err(e) => {
178                        error!("file watcher error: {}", e);
179                    }
180                }
181            },
182            NotifyConfig::default(),
183        )
184        .wrap_err("failed to create file watcher")?;
185
186        // Watch the config file's parent directory (some editors create new files)
187        let watch_path = config_path.parent().unwrap_or(&config_path);
188
189        // Defensive: ensure watch path exists before trying to watch
190        if !watch_path.exists() {
191            warn!(
192                "config directory does not exist, creating it: {:?}",
193                watch_path
194            );
195            std::fs::create_dir_all(watch_path)
196                .wrap_err_with(|| format!("failed to create config directory: {:?}", watch_path))?;
197        }
198
199        watcher
200            .watch(watch_path, RecursiveMode::NonRecursive)
201            .wrap_err_with(|| format!("failed to watch config directory: {:?}", watch_path))?;
202
203        info!("config file watcher initialized for: {:?}", watch_path);
204        Ok(watcher)
205    }
206
207    /// Debounce loop that batches file events and reloads settings.
208    fn debounce_loop(
209        rx: std::sync::mpsc::Receiver<()>,
210        tx: watch::Sender<Arc<Settings>>,
211        config_path: PathBuf,
212    ) {
213        const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);
214
215        loop {
216            // Wait for first event
217            if rx.recv().is_err() {
218                // Channel closed, watcher was dropped
219                debug!("config watcher debounce loop exiting");
220                return;
221            }
222
223            // Drain any additional events within debounce window
224            while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {
225                // Keep draining
226            }
227
228            // Defensive: check if config file exists before reloading
229            // (handles case where file was deleted - we'll get notified when it's recreated)
230            if !config_path.exists() {
231                debug!(
232                    "config file does not exist, skipping reload: {:?}",
233                    config_path
234                );
235                continue;
236            }
237
238            // Now reload settings
239            info!("config file changed, reloading settings: {:?}", config_path);
240            match Settings::new() {
241                Ok(settings) => {
242                    if tx.send(Arc::new(settings)).is_err() {
243                        // All receivers dropped
244                        debug!("all settings subscribers dropped, exiting");
245                        return;
246                    }
247                    info!("settings reloaded successfully");
248                }
249                Err(e) => {
250                    warn!("failed to reload settings: {}", e);
251                    // Keep the old settings, don't broadcast the error
252                }
253            }
254        }
255    }
256}