aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src/atuin_client
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 17:54:34 +0200
commit6bd2b80be51a8623640fbd77afa1da09289e40cc (patch)
tree8fa76fc78e27a4f3d6f57af07805a83e9877f0ad /crates/client/src/atuin_client
parentchore: Commit (diff)
downloadatuin-6bd2b80be51a8623640fbd77afa1da09289e40cc.zip
chore: All compiles
Diffstat (limited to 'crates/client/src/atuin_client')
-rw-r--r--crates/client/src/atuin_client/mod.rs2
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs17
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs573
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs260
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
- }
- }
- }
- }
-}