aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_client/settings/watcher.rs
blob: e280480c346e0d1e9c9e112a3bf1311984bbf14a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
//! Config file watching for automatic settings reload.
//!
//! This module provides a `SettingsWatcher` that monitors the config file
//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.
//!
//! # Example
//!
//! ```no_run
//! use crate::atuin_client::settings::watcher::global_settings_watcher;
//!
//! async fn example() -> eyre::Result<()> {
//!     let watcher = global_settings_watcher()?;
//!     let mut rx = watcher.subscribe();
//!
//!     // React to settings changes
//!     while rx.changed().await.is_ok() {
//!         let settings = rx.borrow();
//!         println!("Settings updated!");
//!     }
//!     Ok(())
//! }
//! ```

use std::{
    path::{Path, PathBuf},
    sync::{Arc, OnceLock},
    time::Duration,
};

use eyre::{Result, WrapErr};
use log::{debug, error, info, warn};
use notify::{
    Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher,
    event::{EventKind, ModifyKind},
};
use tokio::sync::watch;

use super::Settings;

/// Global singleton for the settings watcher.
static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new();

/// Get the global settings watcher singleton.
///
/// Initializes the watcher on first call. Subsequent calls return the same instance.
/// The watcher monitors the config file for changes and broadcasts updates.
pub(crate) fn global_settings_watcher() -> Result<&'static SettingsWatcher> {
    let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string()));

    match result {
        Ok(watcher) => Ok(watcher),
        Err(e) => Err(eyre::eyre!("{}", e)),
    }
}

/// Watches the config file for changes and broadcasts updated settings.
///
/// Uses `notify` for cross-platform file watching and `tokio::sync::watch`
/// for efficient broadcast to multiple subscribers.
pub(crate) struct SettingsWatcher {
    /// Receiver for settings updates. Clone this to subscribe.
    rx: watch::Receiver<Arc<Settings>>,
    /// Keeps the file watcher alive for the lifetime of this struct.
    _watcher: RecommendedWatcher,
}

impl SettingsWatcher {
    /// Create a new settings watcher.
    ///
    /// Loads initial settings and starts watching the config file for changes.
    /// Changes are debounced (500ms) to avoid multiple reloads during saves.
    pub(crate) fn new() -> Result<Self> {
        let initial_settings = Arc::new(Settings::new()?);
        let (tx, rx) = watch::channel(initial_settings);

        let config_path = Self::config_path();
        info!("starting config file watcher: {}", config_path.display());

        let watcher = Self::create_watcher(tx, &config_path)?;

        Ok(Self {
            rx,
            _watcher: watcher,
        })
    }

    /// Subscribe to settings updates.
    ///
    /// Returns a receiver that will be notified when settings change.
    /// Use `changed().await` to wait for the next update, then `borrow()`
    /// to access the current settings.
    pub(crate) fn subscribe(&self) -> watch::Receiver<Arc<Settings>> {
        self.rx.clone()
    }

    /// Get the config file path.
    fn config_path() -> PathBuf {
        let config_dir = std::env::var("ATUIN_CONFIG_DIR")
            .map_or_else(|_| crate::atuin_common::utils::config_dir(), PathBuf::from);
        config_dir.join("config.toml")
    }

    /// Create the file watcher with debouncing.
    fn create_watcher(
        tx: watch::Sender<Arc<Settings>>,
        config_path: &Path,
    ) -> Result<RecommendedWatcher> {
        // Channel for debouncing file events
        let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>();

        // Spawn debounce thread
        let config_path_clone = config_path.to_owned();
        std::thread::spawn(move || {
            Self::debounce_loop(&debounce_rx, &tx, &config_path_clone);
        });

        // Clone config_path for use in the watcher callback
        let config_path_for_watcher = config_path.to_owned();

        // Canonicalize config path for reliable comparison on macOS
        // (handles symlinks like /var -> /private/var)
        let canonical_config_path = config_path_for_watcher
            .canonicalize()
            .unwrap_or_else(|_| config_path_for_watcher.clone());

        // Create file watcher
        let mut watcher = RecommendedWatcher::new(
            move |res: Result<notify::Event, notify::Error>| {
                match res {
                    Ok(event) => {
                        // Defensive: if paths is empty, we can't filter, so assume
                        // it might be our config file and trigger a reload to be safe
                        if event.paths.is_empty() {
                            warn!(
                                "config watcher: event has no paths, triggering reload to be safe"
                            );
                            let _ = debounce_tx.send(());
                            return;
                        }

                        // Only react to events for our specific config file
                        // (filter out editor temp files, backups, etc.)
                        let is_config_file = event.paths.iter().any(|path| {
                            // Canonicalize for reliable comparison (handles macOS symlinks)
                            let canonical_event_path =
                                path.canonicalize().unwrap_or_else(|_| path.clone());

                            // Check if this event is for our config file
                            // (either exact match or the file was renamed to our config)
                            canonical_event_path == canonical_config_path
                                || path.file_name() == config_path_for_watcher.file_name()
                        });

                        if !is_config_file {
                            return;
                        }

                        // Only react to modify events (content changes) or creates
                        if matches!(
                            event.kind,
                            EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any)
                                | EventKind::Create(_)
                        ) {
                            debug!("config file event detected: {event:?}");
                            // Send to debounce channel (ignore send errors - receiver might be gone)
                            let _ = debounce_tx.send(());
                        }
                    }
                    Err(e) => {
                        error!("file watcher error: {e}");
                    }
                }
            },
            NotifyConfig::default(),
        )
        .wrap_err("failed to create file watcher")?;

        // Watch the config file's parent directory (some editors create new files)
        let watch_path = config_path.parent().unwrap_or(config_path);

        // Defensive: ensure watch path exists before trying to watch
        if !watch_path.exists() {
            warn!(
                "config directory does not exist, creating it: {}",
                watch_path.display()
            );
            std::fs::create_dir_all(watch_path).wrap_err_with(|| {
                format!(
                    "failed to create config directory: {}",
                    watch_path.display()
                )
            })?;
        }

        watcher
            .watch(watch_path, RecursiveMode::NonRecursive)
            .wrap_err_with(|| {
                format!("failed to watch config directory: {}", watch_path.display())
            })?;

        info!(
            "config file watcher initialized for: {}",
            watch_path.display()
        );
        Ok(watcher)
    }

    /// Debounce loop that batches file events and reloads settings.
    fn debounce_loop(
        rx: &std::sync::mpsc::Receiver<()>,
        tx: &watch::Sender<Arc<Settings>>,
        config_path: &Path,
    ) {
        const DEBOUNCE_DURATION: Duration = Duration::from_millis(500);

        loop {
            // Wait for first event
            if rx.recv().is_err() {
                // Channel closed, watcher was dropped
                debug!("config watcher debounce loop exiting");
                return;
            }

            // Drain any additional events within debounce window
            while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() {
                // Keep draining
            }

            // Defensive: check if config file exists before reloading
            // (handles case where file was deleted - we'll get notified when it's recreated)
            if !config_path.exists() {
                debug!(
                    "config file does not exist, skipping reload: {}",
                    config_path.display()
                );
                continue;
            }

            // Now reload settings
            info!(
                "config file changed, reloading settings: {}",
                config_path.display()
            );
            match Settings::new() {
                Ok(settings) => {
                    if tx.send(Arc::new(settings)).is_err() {
                        // All receivers dropped
                        debug!("all settings subscribers dropped, exiting");
                        return;
                    }
                    info!("settings reloaded successfully");
                }
                Err(e) => {
                    warn!("failed to reload settings: {e}");
                    // Keep the old settings, don't broadcast the error
                }
            }
        }
    }
}