diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 17:54:34 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 17:54:34 +0200 |
| commit | 6bd2b80be51a8623640fbd77afa1da09289e40cc (patch) | |
| tree | 8fa76fc78e27a4f3d6f57af07805a83e9877f0ad /crates/client/src/atuin_client | |
| parent | chore: Commit (diff) | |
| download | atuin-6bd2b80be51a8623640fbd77afa1da09289e40cc.zip | |
chore: All compiles
Diffstat (limited to '')
| -rw-r--r-- | crates/client/src/atuin_client/mod.rs | 2 | ||||
| -rw-r--r-- | crates/client/src/atuin_client/settings/meta.rs | 17 | ||||
| -rw-r--r-- | crates/client/src/atuin_client/settings/mod.rs | 573 | ||||
| -rw-r--r-- | crates/client/src/atuin_client/settings/watcher.rs | 260 |
4 files changed, 4 insertions, 848 deletions
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs index 0678d8b7..fa40d8dd 100644 --- a/crates/client/src/atuin_client/mod.rs +++ b/crates/client/src/atuin_client/mod.rs @@ -1,2 +1,2 @@ pub(crate) mod settings; -pub(crate) mod theme; +// pub(crate) mod theme; diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs index 7993ef6d..e69de29b 100644 --- a/crates/client/src/atuin_client/settings/meta.rs +++ b/crates/client/src/atuin_client/settings/meta.rs @@ -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 = turtle_common::utils::data_dir(); - let path = dir.join("meta.db"); - - Self { - db_path: path.to_string_lossy().to_string(), - } - } -} diff --git a/crates/client/src/atuin_client/settings/mod.rs b/crates/client/src/atuin_client/settings/mod.rs index 0bddc09c..0eb228b9 100644 --- a/crates/client/src/atuin_client/settings/mod.rs +++ b/crates/client/src/atuin_client/settings/mod.rs @@ -1,12 +1,5 @@ -use crypto_secretbox::Key; -use std::{ - collections::HashMap, fmt, fs::read_to_string, path::PathBuf, str::FromStr, sync::OnceLock, -}; -use tokio::sync::OnceCell; -use tracing::info; -use uuid::Uuid; +use std::{collections::HashMap, fmt, path::PathBuf, str::FromStr, sync::OnceLock}; -use crate::aclient::encryption::decode_key; use clap::ValueEnum; use config::{ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, @@ -16,94 +9,11 @@ use fs_err::create_dir_all; use regex::RegexSet; use serde::{Deserialize, Serialize}; use serde_with::DeserializeFromStr; -use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description}; -use turtle_common::record::HostId; +use time::{UtcOffset, format_description::FormatItem, macros::format_description}; +use tracing::info; use turtle_common::utils; static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); -static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new(); -static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new(); - -pub(crate) mod meta; -pub(crate) mod watcher; - -#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)] -pub(crate) enum SearchMode { - #[serde(rename = "prefix")] - Prefix, - - #[serde(rename = "fulltext")] - #[clap(aliases = &["fulltext"])] - FullText, - - #[serde(rename = "fuzzy")] - Fuzzy, - - #[serde(rename = "skim")] - Skim, - - #[serde(rename = "daemon-fuzzy")] - #[clap(aliases = &["daemon-fuzzy"])] - DaemonFuzzy, -} - -impl SearchMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Prefix => "PREFIX", - Self::FullText => "FULLTXT", - Self::Fuzzy => "FUZZY", - Self::Skim => "SKIM", - Self::DaemonFuzzy => "DAEMON", - } - } - pub(crate) fn next(self, settings: &Settings) -> Self { - match self { - Self::Prefix => Self::FullText, - // if the user is using skim, we go to skim - Self::FullText if settings.search_mode == Self::Skim => Self::Skim, - // if the user is using daemon-fuzzy, we go to daemon-fuzzy - Self::FullText if settings.search_mode == Self::DaemonFuzzy => Self::DaemonFuzzy, - // otherwise fuzzy. - Self::FullText => Self::Fuzzy, - Self::Fuzzy | Self::Skim | Self::DaemonFuzzy => Self::Prefix, - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub(crate) enum FilterMode { - #[serde(rename = "global")] - Global = 0, - - #[serde(rename = "host")] - Host = 1, - - #[serde(rename = "session")] - Session = 2, - - #[serde(rename = "directory")] - Directory = 3, - - #[serde(rename = "workspace")] - Workspace = 4, - - #[serde(rename = "session-preload")] - SessionPreload = 5, -} - -impl FilterMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Global => "GLOBAL", - Self::Host => "HOST", - Self::Session => "SESSION", - Self::Directory => "DIRECTORY", - Self::Workspace => "WORKSPACE", - Self::SessionPreload => "SESSION+", - } - } -} #[derive(Clone, Debug, Deserialize, Copy, Serialize)] pub(crate) enum ExitMode { @@ -389,38 +299,11 @@ pub(crate) struct Preview { #[derive(Clone, Debug, Deserialize, Serialize)] pub(crate) struct Daemon { - /// The daemon will handle sync on an interval. How often to sync, in seconds. - pub(crate) sync_frequency: u64, - /// The path to the unix socket used by the daemon pub(crate) socket_path: String, - /// Path to the daemon pidfile used for process coordination. - pub(crate) pidfile_path: String, - /// Use a socket passed via systemd's socket activation protocol, instead of the path pub(crate) systemd_socket: bool, - - /// The port that should be used for TCP on non unix systems - pub(crate) tcp_port: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct Search { - /// The list of enabled filter modes, in order of priority. - pub(crate) filters: Vec<FilterMode>, - - /// The recency score multiplier for the search index (default: 1.0). - /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables. - pub(crate) recency_score_multiplier: f64, - - /// The frequency score multiplier for the search index (default: 1.0). - /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables. - pub(crate) frequency_score_multiplier: f64, - - /// The overall frecency score multiplier for the search index (default: 1.0). - /// Applied after combining recency and frequency scores. - pub(crate) frecency_score_multiplier: f64, } /// Log level for file logging. Maps to tracing's [`LevelFilter`]. @@ -481,14 +364,6 @@ pub(crate) struct Logs { /// Default retention days for log files. Defaults to 4. #[serde(default = "Logs::default_retention")] pub(crate) retention: u64, - - /// Search log settings - #[serde(default)] - pub(crate) search: LogConfig, - - /// Daemon log settings - #[serde(default)] - pub(crate) daemon: LogConfig, } impl Default for Preview { @@ -502,11 +377,8 @@ impl Default for Preview { impl Default for Daemon { fn default() -> Self { Self { - sync_frequency: 300, socket_path: String::new(), - pidfile_path: String::new(), systemd_socket: false, - tcp_port: 8889, } } } @@ -518,14 +390,6 @@ impl Default for Logs { dir: String::new(), level: LogLevel::default(), retention: Self::default_retention(), - search: LogConfig { - file: "search.log".to_string(), - ..Default::default() - }, - daemon: LogConfig { - file: "daemon.log".to_string(), - ..Default::default() - }, } } } @@ -538,61 +402,6 @@ impl Logs { fn default_retention() -> u64 { 4 } - - /// Returns whether search logging is enabled. - /// Uses search-specific setting if set, otherwise falls back to global. - pub(crate) fn search_enabled(&self) -> bool { - self.search.enabled.unwrap_or(self.enabled) - } - - /// Returns whether daemon logging is enabled. - /// Uses daemon-specific setting if set, otherwise falls back to global. - pub(crate) fn daemon_enabled(&self) -> bool { - self.daemon.enabled.unwrap_or(self.enabled) - } - - /// Returns the log level for search logging. - /// Uses search-specific setting if set, otherwise falls back to global. - pub(crate) fn search_level(&self) -> LogLevel { - self.search.level.unwrap_or(self.level) - } - - /// Returns the log level for daemon logging. - /// Uses daemon-specific setting if set, otherwise falls back to global. - pub(crate) fn daemon_level(&self) -> LogLevel { - self.daemon.level.unwrap_or(self.level) - } - - /// Returns the retention days for search logging. - /// Uses search-specific setting if set, otherwise falls back to global. - pub(crate) fn search_retention(&self) -> u64 { - self.search.retention.unwrap_or(self.retention) - } - - /// Returns the retention days for daemon logging. - /// Uses daemon-specific setting if set, otherwise falls back to global. - pub(crate) fn daemon_retention(&self) -> u64 { - self.daemon.retention.unwrap_or(self.retention) - } -} - -impl Default for Search { - fn default() -> Self { - Self { - filters: vec![ - FilterMode::Global, - FilterMode::Host, - FilterMode::Session, - FilterMode::SessionPreload, - FilterMode::Workspace, - FilterMode::Directory, - ], - - recency_score_multiplier: 1.0, - frequency_score_multiplier: 1.0, - frecency_score_multiplier: 1.0, - } - } } // The preview height strategy also takes max_preview_height into account. @@ -611,246 +420,6 @@ pub(crate) enum PreviewStrategy { Fixed, } -/// Column types available for the interactive search UI. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum UiColumnType { - /// Command execution duration (e.g., "123ms") - Duration, - /// Relative time since execution (e.g., "59s ago") - Time, - /// Absolute timestamp (e.g., "2025-01-22 14:35") - Datetime, - /// Working directory - Directory, - /// Hostname - Host, - /// Username - User, - /// Exit code - Exit, - /// The command itself (should be last, expands to fill) - Command, -} - -impl UiColumnType { - /// Returns the default width for this column type (in characters). - /// The Command column returns 0 as it expands to fill remaining space. - pub(crate) fn default_width(self) -> u16 { - match self { - Self::Duration => 5, // "814ms" - Self::Time => 9, // "459ms ago" - Self::Datetime => 16, // "2025-01-22 14:35" - Self::Directory => 20, - Self::Host => 15, - Self::User => 10, - Self::Exit => { - if cfg!(windows) { - 11 // 32-bit integer on Windows: "-1978335212" - } else { - 3 // Usually a byte on Unix - } - } - Self::Command => 0, // Expands to fill - } - } -} - -/// A column configuration with type and optional custom width. -/// Can be specified as just a string (uses default width) or as an object with type and width. -#[derive(Clone, Debug, Serialize)] -pub(crate) struct UiColumn { - pub(crate) column_type: UiColumnType, - pub(crate) width: u16, - /// If true, this column expands to fill remaining space. Only one column should expand. - pub(crate) expand: bool, -} - -impl UiColumn { - pub(crate) fn new(column_type: UiColumnType) -> Self { - Self { - width: column_type.default_width(), - expand: column_type == UiColumnType::Command, - column_type, - } - } -} - -// Custom deserialize to handle both string and object formats: -// "duration" or { type = "duration", width = 8, expand = true } -impl<'de> Deserialize<'de> for UiColumn { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - use serde::de::{self, MapAccess, Visitor}; - - struct UiColumnVisitor; - - impl<'de> Visitor<'de> for UiColumnVisitor { - type Value = UiColumn; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str( - "a column type string or an object with 'type' and optional 'width'/'expand'", - ) - } - - fn visit_str<E>(self, value: &str) -> Result<UiColumn, E> - where - E: de::Error, - { - let column_type: UiColumnType = - Deserialize::deserialize(de::value::StrDeserializer::new(value))?; - Ok(UiColumn::new(column_type)) - } - - fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error> - where - M: MapAccess<'de>, - { - let mut column_type: Option<UiColumnType> = None; - let mut width: Option<u16> = None; - let mut expand: Option<bool> = None; - - while let Some(key) = map.next_key::<String>()? { - match key.as_str() { - "type" => { - column_type = Some(map.next_value()?); - } - "width" => { - width = Some(map.next_value()?); - } - "expand" => { - expand = Some(map.next_value()?); - } - _ => { - let _: de::IgnoredAny = map.next_value()?; - } - } - } - - let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?; - let width = width.unwrap_or_else(|| column_type.default_width()); - let expand = expand.unwrap_or(column_type == UiColumnType::Command); - Ok(UiColumn { - column_type, - width, - expand, - }) - } - } - - deserializer.deserialize_any(UiColumnVisitor) - } -} - -/// UI-specific settings for the interactive search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct Ui { - /// Columns to display in interactive search, from left to right. - /// The indicator column (" > ") is always shown first implicitly. - /// The "command" column should be last as it expands to fill remaining space. - /// Can be simple strings or objects with type and width. - #[serde(default = "Ui::default_columns")] - pub(crate) columns: Vec<UiColumn>, -} - -impl Ui { - fn default_columns() -> Vec<UiColumn> { - vec![ - UiColumn::new(UiColumnType::Duration), - UiColumn::new(UiColumnType::Time), - UiColumn::new(UiColumnType::Command), - ] - } - - /// Validate the UI configuration. - /// Returns an error if more than one column has expand = true. - pub(crate) fn validate(&self) -> Result<()> { - let expand_count = self.columns.iter().filter(|c| c.expand).count(); - if expand_count > 1 { - bail!( - "Only one column can have expand = true, but {} columns are set to expand", - expand_count - ); - } - Ok(()) - } -} - -impl Default for Ui { - fn default() -> Self { - Self { - columns: Self::default_columns(), - } - } -} - -/// Sync-specific settings. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub(crate) struct Sync { - /// The sync address for atuin. - pub(crate) address: String, - - #[serde(default)] - pub(crate) frequency: String, - - #[serde(default)] - pub(crate) auto: bool, - - #[serde(default)] - pub(crate) user_id_path: Option<PathBuf>, - - #[serde(default)] - pub(crate) encryption_key_path: Option<PathBuf>, -} - -impl Sync { - fn try_read_file(file: Option<&PathBuf>) -> Result<Option<String>> { - if let Some(path) = file { - if path.try_exists()? { - let user = read_to_string(path)?; - - if user.is_empty() { - Ok(None) - } else { - Ok(Some(user)) - } - } else { - // It's okay that the file doesn't exist. - // The important part is to error out if we can't access it (e.g. Because of missing - // permissions). - Ok(None) - } - } else { - Ok(None) - } - } - - pub(crate) fn have_sync_user(&self) -> Result<bool> { - let sa = self.user_id()?; - Ok(sa.is_some()) - } - - pub(crate) fn user_id(&self) -> Result<Option<Uuid>> { - Self::try_read_file(self.user_id_path.as_ref())? - .map(|file| { - Uuid::parse_str(file.trim()).context( - "Failed to decode user id as UUID, while trying to decode sync user_id", - ) - }) - .transpose() - } - pub(crate) fn encryption_key(&self) -> Result<Option<Key>> { - Self::try_read_file(self.encryption_key_path.as_ref())? - .as_deref() - .map(str::trim) - .map(decode_key) - .transpose() - } -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[expect(clippy::struct_excessive_bools)] pub struct Settings { @@ -859,15 +428,7 @@ pub struct Settings { pub(crate) timezone: Timezone, pub(crate) style: Style, - pub(crate) db_path: String, - pub(crate) record_store_path: String, - pub(crate) search_mode: SearchMode, - pub(crate) filter_mode: Option<FilterMode>, - pub(crate) filter_mode_shell_up_key_binding: Option<FilterMode>, - pub(crate) search_mode_shell_up_key_binding: Option<SearchMode>, pub(crate) shell_up_key_binding: bool, - pub(crate) inline_height: u16, - pub(crate) inline_height_shell_up_key_binding: Option<u16>, pub(crate) invert: bool, pub(crate) show_preview: bool, pub(crate) max_preview_height: u16, @@ -906,9 +467,6 @@ pub struct Settings { pub(crate) command_chaining: bool, #[serde(default)] - pub(crate) sync: Sync, - - #[serde(default)] pub(crate) stats: Stats, #[serde(default)] @@ -924,61 +482,10 @@ pub struct Settings { pub(crate) daemon: Daemon, #[serde(default)] - pub(crate) search: Search, - - #[serde(default)] - pub(crate) ui: Ui, - - #[serde(default)] pub(crate) logs: Logs, - - #[serde(default)] - pub(crate) meta: meta::Settings, } impl Settings { - // -- Meta store: lazily initialized on first access -- - - pub(crate) async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> { - META_STORE - .get_or_try_init(|| async { - let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| { - eyre!("meta store config not set — Settings::new() has not been called") - })?; - crate::aclient::meta::MetaStore::new(db_path, *timeout).await - }) - .await - } - - pub(crate) async fn host_id() -> Result<HostId> { - Self::meta_store().await?.host_id().await - } - - pub(crate) async fn last_sync() -> Result<OffsetDateTime> { - Self::meta_store().await?.last_sync().await - } - - pub(crate) async fn save_sync_time() -> Result<()> { - Self::meta_store().await?.save_sync_time().await - } - - pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode { - self.filter_mode - .filter(|x| self.search.filters.contains(x)) - .or_else(|| { - self.search - .filters - .iter() - .find(|x| match (x, git_root, self.workspaces) { - (FilterMode::Workspace, true, true) => true, - (FilterMode::Workspace, _, _) => false, - (_, _, _) => true, - }) - .copied() - }) - .unwrap_or(FilterMode::Global) - } - pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> { Self::builder_with_data_dir(&utils::data_dir()) } @@ -989,13 +496,11 @@ impl Settings { let record_store_path = data_dir.join("records.db"); let kv_path = data_dir.join("kv.db"); let scripts_path = data_dir.join("scripts.db"); - let ai_sessions_path = data_dir.join("ai_sessions.db"); let socket_path = utils::runtime_dir().join("atuin.sock"); let pidfile_path = data_dir.join("atuin-daemon.pid"); let logs_dir = utils::logs_dir(); let key_path = data_dir.join("key"); - let meta_path = data_dir.join("meta.db"); Ok(Config::builder() .set_default("history_format", "{time}\t{command}\t{duration}")? @@ -1068,8 +573,6 @@ impl Settings { .set_default("search.recency_score_multiplier", 1.0)? .set_default("search.frequency_score_multiplier", 1.0)? .set_default("search.frecency_score_multiplier", 1.0)? - .set_default("meta.db_path", meta_path.to_str())? - .set_default("ai.db_path", ai_sessions_path.to_str())? .set_default("ai.session_continue_minutes", 60)? .set_default("ai.send_cwd", false)? .set_default("ai.opening.send_cwd", false)? @@ -1295,14 +798,6 @@ impl Settings { .try_deserialize() .map_err(|e| eyre!("failed to deserialize: {}", e))?; - // Validate UI settings - settings.ui.validate()?; - - // Register meta store config for lazy initialization on first access - META_CONFIG - .set((settings.meta.db_path.clone(), settings.local_timeout)) - .ok(); - Ok(settings) } @@ -1311,12 +806,6 @@ impl Settings { .map(|p| p.to_string()) .map_err(|e| eyre!("failed to expand path: {}", e)) } - - pub(crate) fn paths_ok(&self) -> bool { - // TODO(@bpeetz): Add the `sync.*` paths <2026-06-11> - let paths = [&self.db_path, &self.record_store_path, &self.meta.db_path]; - paths.iter().all(|p| !utils::broken_symlink(p)) - } } impl Default for Settings { @@ -1381,60 +870,6 @@ mod tests { } #[test] - fn can_choose_workspace_filters_when_in_git_context() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = true; - - assert_eq!( - settings.default_filter_mode(true), - super::FilterMode::Workspace, - ); - - Ok(()) - } - - #[test] - fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = true; - - assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,); - - Ok(()) - } - - #[test] - fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = false; - - assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,); - - Ok(()) - } - - #[test] fn builder_with_data_dir_uses_custom_paths() -> Result<()> { use std::path::PathBuf; @@ -1447,7 +882,6 @@ mod tests { let record_store_path: String = config.get("record_store_path")?; let kv_db_path: String = config.get("kv.db_path")?; let scripts_db_path: String = config.get("scripts.db_path")?; - let meta_db_path: String = config.get("meta.db_path")?; let daemon_socket_path: String = config.get("daemon.socket_path")?; let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?; @@ -1462,7 +896,6 @@ mod tests { scripts_db_path, custom_dir.join("scripts.db").to_str().unwrap() ); - assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap()); assert_eq!( daemon_socket_path, turtle_common::utils::runtime_dir() diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs deleted file mode 100644 index 01d20855..00000000 --- a/crates/client/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::aclient::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(|_| turtle_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 - } - } - } - } -} |
