diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-09 21:43:23 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-09 21:43:23 +0200 |
| commit | 3223d93cb3c77ab02aa0a35f2a8314e447cee9a4 (patch) | |
| tree | 3971a1f37f5fe3cadb747c5229a0d54e868e45e3 /crates/turtle/src/atuin_client/settings | |
| parent | fix(client/sync): Pass through precise error on `SyncError::WrongKey` (diff) | |
| download | atuin-3223d93cb3c77ab02aa0a35f2a8314e447cee9a4.zip | |
chore: Separate daemon, client, server, and lib into crates
Diffstat (limited to 'crates/turtle/src/atuin_client/settings')
| -rw-r--r-- | crates/turtle/src/atuin_client/settings/meta.rs | 17 | ||||
| -rw-r--r-- | crates/turtle/src/atuin_client/settings/watcher.rs | 260 |
2 files changed, 0 insertions, 277 deletions
diff --git a/crates/turtle/src/atuin_client/settings/meta.rs b/crates/turtle/src/atuin_client/settings/meta.rs deleted file mode 100644 index cc5afcf7..00000000 --- a/crates/turtle/src/atuin_client/settings/meta.rs +++ /dev/null @@ -1,17 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub(crate) struct Settings { - pub(crate) db_path: String, -} - -impl Default for Settings { - fn default() -> Self { - let dir = crate::atuin_common::utils::data_dir(); - let path = dir.join("meta.db"); - - Self { - db_path: path.to_string_lossy().to_string(), - } - } -} diff --git a/crates/turtle/src/atuin_client/settings/watcher.rs b/crates/turtle/src/atuin_client/settings/watcher.rs deleted file mode 100644 index 5eec3692..00000000 --- a/crates/turtle/src/atuin_client/settings/watcher.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! 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" - ); - debounce_tx.send(()).expect("should still be active"); - 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) - debounce_tx.send(()).ok(); - } - } - 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 - } - } - } - } -} |
