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}