diff options
Diffstat (limited to '')
| -rw-r--r-- | crates/daemon/src/aclient/api_client.rs (renamed from crates/client/src/atuin_client/api_client.rs) | 4 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/database/mod.rs (renamed from crates/client/src/atuin_client/database.rs) | 11 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/encryption.rs (renamed from crates/client/src/atuin_client/encryption.rs) | 2 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/builder.rs (renamed from crates/client/src/atuin_client/history/builder.rs) | 0 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/mod.rs (renamed from crates/client/src/atuin_client/history.rs) | 26 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/history/store.rs (renamed from crates/client/src/atuin_client/history/store.rs) | 8 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/meta.rs (renamed from crates/client/src/atuin_client/meta.rs) | 4 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/mod.rs | 11 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/ordering.rs (renamed from crates/client/src/atuin_client/ordering.rs) | 0 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/record/encryption.rs (renamed from crates/client/src/atuin_client/record/encryption.rs) | 4 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/record/mod.rs (renamed from crates/client/src/atuin_client/record/mod.rs) | 3 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/record/sqlite_store.rs (renamed from crates/client/src/atuin_client/record/sqlite_store.rs) | 10 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/record/sync.rs (renamed from crates/client/src/atuin_client/record/sync.rs) | 18 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/secrets.rs (renamed from crates/client/src/atuin_client/secrets.rs) | 2 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/settings/meta.rs | 17 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/settings/mod.rs | 1544 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/settings/watcher.rs | 260 | ||||
| -rw-r--r-- | crates/daemon/src/aclient/utils.rs (renamed from crates/client/src/atuin_client/utils.rs) | 2 |
18 files changed, 1880 insertions, 46 deletions
diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/daemon/src/aclient/api_client.rs index bd5bf59e..666e6dce 100644 --- a/crates/client/src/atuin_client/api_client.rs +++ b/crates/daemon/src/aclient/api_client.rs @@ -6,8 +6,8 @@ use reqwest::{Response, StatusCode, Url, header::HeaderMap}; use tracing::debug; use uuid::Uuid; -use crate::atuin_common::{api::ErrorResponse, record::RecordStatus}; -use crate::atuin_common::{ +use turtle_common::{api::ErrorResponse, record::RecordStatus}; +use turtle_common::{ api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION}, record::{EncryptedData, HostId, Record, RecordIdx}, tls::ensure_crypto_provider, diff --git a/crates/client/src/atuin_client/database.rs b/crates/daemon/src/aclient/database/mod.rs index a9eb2058..36049f80 100644 --- a/crates/client/src/atuin_client/database.rs +++ b/crates/daemon/src/aclient/database/mod.rs @@ -4,7 +4,7 @@ use std::{ str::FromStr, }; -use crate::{atuin_client::utils::setup_db, atuin_common::utils}; +use crate::aclient::utils::setup_db; use fs_err::{self as fs}; use itertools::Itertools; use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote}; @@ -14,9 +14,10 @@ use sqlx::{ }; use time::OffsetDateTime; use tracing::debug; +use turtle_common::utils; use uuid::Uuid; -use crate::atuin_client::{ +use crate::aclient::{ history::{HistoryId, HistoryStats}, utils::get_host_user, }; @@ -93,12 +94,12 @@ fn get_session_start_time(session_id: &str) -> Option<i64> { // Intended for use on a developer machine and not a sync server. // TODO: implement IntoIterator #[derive(Debug, Clone)] -pub(crate) struct ClientSqlite { +pub struct ClientSqlite { pub(crate) pool: SqlitePool, } impl ClientSqlite { - pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { + pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { fn mk_opts(path: &str) -> Result<SqliteConnectOptions> { let opts = SqliteConnectOptions::from_str(path)? .journal_mode(SqliteJournalMode::Wal) @@ -800,7 +801,7 @@ impl SqlBuilderExt for SqlBuilder { #[cfg(test)] mod test { - use crate::atuin_client::settings::test_local_timeout; + use crate::aclient::settings::test_local_timeout; use super::{ ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode, diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/daemon/src/aclient/encryption.rs index f1c921cb..220ac74e 100644 --- a/crates/client/src/atuin_client/encryption.rs +++ b/crates/daemon/src/aclient/encryption.rs @@ -17,7 +17,7 @@ use eyre::{Context, Result, bail, ensure, eyre}; use fs_err as fs; use rmp::Marker; -use crate::atuin_client::settings::Settings; +use crate::aclient::settings::Settings; pub(crate) fn generate_encoded_key() -> Result<(Key, String)> { let key = XSalsa20Poly1305::generate_key(&mut OsRng); diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs index daa4ef49..daa4ef49 100644 --- a/crates/client/src/atuin_client/history/builder.rs +++ b/crates/daemon/src/aclient/history/builder.rs diff --git a/crates/client/src/atuin_client/history.rs b/crates/daemon/src/aclient/history/mod.rs index c38d8ccc..09d24169 100644 --- a/crates/client/src/atuin_client/history.rs +++ b/crates/daemon/src/aclient/history/mod.rs @@ -5,14 +5,14 @@ use rmp::{Marker, decode::Bytes}; use std::env; use std::fmt::Display; -use crate::atuin_common::record::DecryptedData; -use crate::atuin_common::utils::uuid_v7; +use turtle_common::record::DecryptedData; +use turtle_common::utils::uuid_v7; use eyre::{Result, bail, eyre}; -use crate::atuin_client::secrets::SECRET_PATTERNS_RE; -use crate::atuin_client::settings::Settings; -use crate::atuin_client::utils::get_host_user; +use crate::aclient::secrets::SECRET_PATTERNS_RE; +use crate::aclient::settings::Settings; +use crate::aclient::utils::get_host_user; use time::OffsetDateTime; mod builder; @@ -28,7 +28,7 @@ const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; #[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct HistoryId(pub(crate) String); +pub struct HistoryId(pub(crate) String); impl Display for HistoryId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -56,7 +56,7 @@ impl From<String> for HistoryId { // New fields must be added to `History::{serialize,deserialize}` in a backwards // compatible way (sensible defaults and careful `nfields` handling). #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub(crate) struct History { +pub struct History { /// A client-generated ID, used to identify the entry when syncing. /// /// Stored as `client_id` in the database. @@ -375,7 +375,7 @@ impl History { /// /// ## Examples /// ```rust - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// let history: History = History::capture() /// .timestamp(time::OffsetDateTime::now_utc()) @@ -388,7 +388,7 @@ impl History { /// Command without any required info cannot be captured, which is forced at compile time: /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `cwd` is missing /// let history: History = History::capture() @@ -413,7 +413,7 @@ impl History { /// /// ## Examples /// ```rust - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// let history: History = History::daemon() /// .timestamp(time::OffsetDateTime::now_utc()) @@ -428,7 +428,7 @@ impl History { /// Command without any required info cannot be captured, which is forced at compile time: /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `hostname` is missing /// let history: History = History::daemon() @@ -448,7 +448,7 @@ impl History { /// All fields are required, as they are all present in the database. /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `id` field is missing /// let history: History = History::from_db() @@ -486,7 +486,7 @@ mod tests { use regex::RegexSet; use time::macros::datetime; - use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings}; + use crate::aclient::{history::HISTORY_VERSION, settings::Settings}; use super::History; diff --git a/crates/client/src/atuin_client/history/store.rs b/crates/daemon/src/aclient/history/store.rs index 9c7771cc..db692590 100644 --- a/crates/client/src/atuin_client/history/store.rs +++ b/crates/daemon/src/aclient/history/store.rs @@ -5,11 +5,11 @@ use indicatif::{ProgressBar, ProgressState, ProgressStyle}; use rmp::decode::Bytes; use tracing::debug; -use crate::atuin_client::{ +use crate::aclient::{ database::{ClientSqlite, current_context}, record::{encryption::PASETO_V4, sqlite_store::SqliteStore}, }; -use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; +use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; @@ -364,10 +364,10 @@ impl HistoryStore { #[cfg(test)] mod tests { - use crate::atuin_common::record::DecryptedData; + use turtle_common::record::DecryptedData; use time::macros::datetime; - use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord}; + use crate::aclient::history::{HISTORY_VERSION, store::HistoryRecord}; use super::History; diff --git a/crates/client/src/atuin_client/meta.rs b/crates/daemon/src/aclient/meta.rs index 079c9926..00dabcce 100644 --- a/crates/client/src/atuin_client/meta.rs +++ b/crates/daemon/src/aclient/meta.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::str::FromStr; use std::time::Duration; -use crate::atuin_common::record::HostId; +use turtle_common::record::HostId; use eyre::{Result, eyre}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -108,7 +108,7 @@ impl MetaStore { return Ok(HostId(parsed)); } - let uuid = crate::atuin_common::utils::uuid_v7(); + let uuid = turtle_common::utils::uuid_v7(); self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref()) .await?; diff --git a/crates/daemon/src/aclient/mod.rs b/crates/daemon/src/aclient/mod.rs new file mode 100644 index 00000000..3f3709a9 --- /dev/null +++ b/crates/daemon/src/aclient/mod.rs @@ -0,0 +1,11 @@ +pub mod database; +pub mod history; +pub mod record; +pub mod settings; + +pub(crate) mod encryption; +pub(crate) mod meta; +pub(crate) mod utils; +pub(crate) mod ordering; +pub(crate) mod secrets; +pub(crate) mod api_client; diff --git a/crates/client/src/atuin_client/ordering.rs b/crates/daemon/src/aclient/ordering.rs index 84001f52..84001f52 100644 --- a/crates/client/src/atuin_client/ordering.rs +++ b/crates/daemon/src/aclient/ordering.rs diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/daemon/src/aclient/record/encryption.rs index d8587cf6..6851a99f 100644 --- a/crates/client/src/atuin_client/record/encryption.rs +++ b/crates/daemon/src/aclient/record/encryption.rs @@ -1,4 +1,4 @@ -use crate::atuin_common::record::{ +use turtle_common::record::{ AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx, }; use base64::{Engine, engine::general_purpose}; @@ -201,7 +201,7 @@ impl Assertions<'_> { #[cfg(test)] mod tests { - use crate::atuin_common::{ + use turtle_common::{ record::{Host, Record}, utils::uuid_v7, }; diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/daemon/src/aclient/record/mod.rs index 4e5774ea..2ace26f5 100644 --- a/crates/client/src/atuin_client/record/mod.rs +++ b/crates/daemon/src/aclient/record/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod encryption; -pub(crate) mod sqlite_store; pub(crate) mod sync; + +pub mod sqlite_store; diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/daemon/src/aclient/record/sqlite_store.rs index 18f5c869..f2fc9d84 100644 --- a/crates/client/src/atuin_client/record/sqlite_store.rs +++ b/crates/daemon/src/aclient/record/sqlite_store.rs @@ -14,22 +14,22 @@ use sqlx::{ }; use tracing::debug; -use crate::atuin_client::utils::setup_db; -use crate::atuin_common::record::{ +use crate::aclient::utils::setup_db; +use turtle_common::record::{ EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus, }; -use crate::atuin_common::utils; +use turtle_common::utils; use uuid::Uuid; use super::encryption::PASETO_V4; #[derive(Debug, Clone)] -pub(crate) struct SqliteStore { +pub struct SqliteStore { pool: SqlitePool, } impl SqliteStore { - pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { + pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { fn mk_opts(path: &str) -> sqlx::Result<SqliteConnectOptions> { let opts = SqliteConnectOptions::from_str(path)? .journal_mode(SqliteJournalMode::Wal) diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/daemon/src/aclient/record/sync.rs index da05533c..94764f67 100644 --- a/crates/client/src/atuin_client/record/sync.rs +++ b/crates/daemon/src/aclient/record/sync.rs @@ -6,10 +6,10 @@ use thiserror::Error; use tracing::error; use super::encryption::PASETO_V4; -use crate::atuin_client::record::sqlite_store::SqliteStore; -use crate::atuin_client::{api_client::Client, settings::Settings}; +use crate::aclient::record::sqlite_store::SqliteStore; +use crate::aclient::{api_client::Client, settings::Settings}; -use crate::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; +use turtle_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; use indicatif::{ProgressBar, ProgressState, ProgressStyle}; #[derive(Error, Debug)] @@ -374,10 +374,10 @@ pub(crate) async fn sync( #[cfg(test)] mod tests { - use crate::atuin_client::record::sync::Operation; - use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record}; + use crate::aclient::record::sync::Operation; + use turtle_common::record::{Diff, EncryptedData, HostId, Record}; - use crate::atuin_client::{ + use crate::aclient::{ record::{ sqlite_store::SqliteStore, sync::{self}, @@ -387,11 +387,11 @@ mod tests { fn test_record() -> Record<EncryptedData> { Record::builder() - .host(crate::atuin_common::record::Host::new(HostId( - crate::atuin_common::utils::uuid_v7(), + .host(turtle_common::record::Host::new(HostId( + turtle_common::utils::uuid_v7(), ))) .version("v1".into()) - .tag(crate::atuin_common::utils::uuid_v7().simple().to_string()) + .tag(turtle_common::utils::uuid_v7().simple().to_string()) .data(EncryptedData { data: String::new(), content_encryption_key: String::new(), diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/daemon/src/aclient/secrets.rs index 74d47ea6..08d24339 100644 --- a/crates/client/src/atuin_client/secrets.rs +++ b/crates/daemon/src/aclient/secrets.rs @@ -173,7 +173,7 @@ pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| { mod tests { use regex::Regex; - use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue}; + use crate::aclient::secrets::{SECRET_PATTERNS, TestValue}; #[test] fn test_secrets() { diff --git a/crates/daemon/src/aclient/settings/meta.rs b/crates/daemon/src/aclient/settings/meta.rs new file mode 100644 index 00000000..7993ef6d --- /dev/null +++ b/crates/daemon/src/aclient/settings/meta.rs @@ -0,0 +1,17 @@ +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/daemon/src/aclient/settings/mod.rs b/crates/daemon/src/aclient/settings/mod.rs new file mode 100644 index 00000000..58afd17c --- /dev/null +++ b/crates/daemon/src/aclient/settings/mod.rs @@ -0,0 +1,1544 @@ +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 crate::aclient::encryption::decode_key; +use clap::ValueEnum; +use config::{ + Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, +}; +use eyre::{Context, Error, Result, bail, eyre}; +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 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 { + #[serde(rename = "return-original")] + ReturnOriginal, + + #[serde(rename = "return-query")] + ReturnQuery, +} + +// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged +// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim +#[derive(Clone, Debug, Deserialize, Copy, Serialize)] +pub(crate) enum Dialect { + #[serde(rename = "us")] + Us, + + #[serde(rename = "uk")] + Uk, +} + +impl From<Dialect> for interim::Dialect { + fn from(d: Dialect) -> Self { + match d { + Dialect::Uk => Self::Uk, + Dialect::Us => Self::Us, + } + } +} + +/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats. +/// +/// Note that the parsing of this struct needs to be done before starting any +/// multithreaded runtime, otherwise it will fail on most Unix systems. +/// +/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426> +#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)] +pub(crate) struct Timezone(pub(crate) UtcOffset); +impl fmt::Display for Timezone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +/// format: <+|-><hour>[:<minute>[:<second>]] +static OFFSET_FMT: &[FormatItem<'_>] = format_description!( + "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]" +); +impl FromStr for Timezone { + type Err = Error; + + fn from_str(s: &str) -> Result<Self> { + // local timezone + if matches!(s.to_lowercase().as_str(), "l" | "local") { + // There have been some timezone issues, related to errors fetching it on some + // platforms + // Rather than fail to start, fallback to UTC. The user should still be able to specify + // their timezone manually in the config file. + let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); + return Ok(Self(offset)); + } + + if matches!(s.to_lowercase().as_str(), "0" | "utc") { + let offset = UtcOffset::UTC; + return Ok(Self(offset)); + } + + // offset from UTC + if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) { + return Ok(Self(offset)); + } + + // IDEA: Currently named timezones are not supported, because the well-known crate + // for this is `chrono_tz`, which is not really interoperable with the datetime crate + // that we currently use - `time`. If ever we migrate to using `chrono`, this would + // be a good feature to add. + + bail!(r#""{s}" is not a valid timezone spec"#) + } +} + +#[derive(Clone, Debug, Deserialize, Copy, Serialize)] +pub(crate) enum Style { + #[serde(rename = "auto")] + Auto, + + #[serde(rename = "full")] + Full, + + #[serde(rename = "compact")] + Compact, +} + +#[derive(Clone, Debug, Deserialize, Copy, Serialize)] +pub(crate) enum WordJumpMode { + #[serde(rename = "emacs")] + Emacs, + + #[serde(rename = "subl")] + Subl, +} + +#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] +pub(crate) enum KeymapMode { + #[serde(rename = "emacs")] + Emacs, + + #[serde(rename = "vim-normal")] + VimNormal, + + #[serde(rename = "vim-insert")] + VimInsert, + + #[serde(rename = "auto")] + Auto, +} + +// We want to translate the config to crossterm::cursor::SetCursorStyle, but +// the original type does not implement trait serde::Deserialize unfortunately. +// It seems impossible to implement Deserialize for external types when it is +// used in HashMap (https://stackoverflow.com/questions/67142663). We instead +// define an adapter type. +#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] +pub(crate) enum CursorStyle { + #[serde(rename = "default")] + DefaultUserShape, + + #[serde(rename = "blink-block")] + BlinkingBlock, + + #[serde(rename = "steady-block")] + SteadyBlock, + + #[serde(rename = "blink-underline")] + BlinkingUnderScore, + + #[serde(rename = "steady-underline")] + SteadyUnderScore, + + #[serde(rename = "blink-bar")] + BlinkingBar, + + #[serde(rename = "steady-bar")] + SteadyBar, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Stats { + #[serde(default = "Stats::common_prefix_default")] + pub(crate) common_prefix: Vec<String>, // sudo, etc. commands we want to strip off + #[serde(default = "Stats::common_subcommands_default")] + pub(crate) common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for + #[serde(default = "Stats::ignored_commands_default")] + pub(crate) ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats +} + +impl Stats { + fn common_prefix_default() -> Vec<String> { + vec!["sudo", "doas"].into_iter().map(String::from).collect() + } + + fn common_subcommands_default() -> Vec<String> { + vec![ + "apt", + "cargo", + "composer", + "dnf", + "docker", + "dotnet", + "git", + "go", + "ip", + "jj", + "kubectl", + "nix", + "nmcli", + "npm", + "pecl", + "pnpm", + "podman", + "port", + "systemctl", + "tmux", + "yarn", + ] + .into_iter() + .map(String::from) + .collect() + } + + fn ignored_commands_default() -> Vec<String> { + vec![] + } +} + +impl Default for Stats { + fn default() -> Self { + Self { + common_prefix: Self::common_prefix_default(), + common_subcommands: Self::common_subcommands_default(), + ignored_commands: Self::ignored_commands_default(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Default, Serialize)] +#[expect(clippy::struct_excessive_bools)] +pub(crate) struct Keys { + pub(crate) scroll_exits: bool, + pub(crate) exit_past_line_start: bool, + pub(crate) accept_past_line_end: bool, + pub(crate) accept_past_line_start: bool, + pub(crate) accept_with_backspace: bool, + pub(crate) prefix: String, +} + +impl Keys { + /// The standard default values for all `[keys]` options. + /// These match the config defaults set in `builder_with_data_dir()`. + pub(crate) fn standard_defaults() -> Self { + Self { + scroll_exits: true, + exit_past_line_start: true, + accept_past_line_end: true, + accept_past_line_start: false, + accept_with_backspace: false, + prefix: "a".to_string(), + } + } +} + +/// A single rule within a conditional keybinding config. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct KeyRuleConfig { + /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results"). + /// If absent, the rule always matches. + #[serde(default)] + pub(crate) when: Option<String>, + /// The action to perform (e.g. "exit", "cursor-left", "accept"). + pub(crate) action: String, +} + +/// A keybinding config value: either a simple action string or an ordered list of conditional rules. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub(crate) enum KeyBindingConfig { + /// Simple unconditional binding: `"ctrl-c" = "return-original"` + Simple(String), + /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]` + Rules(Vec<KeyRuleConfig>), +} + +/// User-facing keymap configuration. Each mode maps key strings to bindings. +/// Keys present here override the defaults for that key; unmentioned keys keep defaults. +#[derive(Clone, Debug, Deserialize, Serialize, Default)] +pub(crate) struct KeymapConfig { + #[serde(default)] + pub(crate) emacs: HashMap<String, KeyBindingConfig>, + #[serde(default, rename = "vim-normal")] + pub(crate) vim_normal: HashMap<String, KeyBindingConfig>, + #[serde(default, rename = "vim-insert")] + pub(crate) vim_insert: HashMap<String, KeyBindingConfig>, + #[serde(default)] + pub(crate) inspector: HashMap<String, KeyBindingConfig>, + #[serde(default)] + pub(crate) prefix: HashMap<String, KeyBindingConfig>, +} + +impl KeymapConfig { + /// Returns true if no keybinding overrides are configured in any mode. + pub(crate) fn is_empty(&self) -> bool { + self.emacs.is_empty() + && self.vim_normal.is_empty() + && self.vim_insert.is_empty() + && self.inspector.is_empty() + && self.prefix.is_empty() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Preview { + pub(crate) strategy: PreviewStrategy, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Daemon { + /// The daemon will handle sync on an interval. How often to sync, in seconds. + pub sync_frequency: u64, + + /// The path to the unix socket used by the daemon + pub socket_path: String, + + /// Path to the daemon pidfile used for process coordination. + pub pidfile_path: String, + + /// Use a socket passed via systemd's socket activation protocol, instead of the path + pub systemd_socket: bool, + + /// The port that should be used for TCP on non unix systems + pub 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`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LogLevel { + Trace, + Debug, + #[default] + Info, + Warn, + Error, +} + +impl LogLevel { + /// Convert to a tracing directive string for use with [`EnvFilter`]. + pub(crate) fn as_directive(self) -> &'static str { + match self { + Self::Trace => "trace", + Self::Debug => "debug", + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +/// Configuration for a specific log type (search or daemon). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub(crate) struct LogConfig { + /// Log file name (relative to dir) or absolute path. + pub(crate) file: String, + + /// Override global enabled setting for this log type. + pub(crate) enabled: Option<bool>, + + /// Override global level setting for this log type. + pub(crate) level: Option<LogLevel>, + + /// Override global retention days setting for this log type. + pub(crate) retention: Option<u64>, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub(crate) struct Logs { + /// Enable file logging globally. Defaults to true. + #[serde(default = "Logs::default_enabled")] + pub(crate) enabled: bool, + + /// Directory for log files. Defaults to ~/.atuin/logs + pub(crate) dir: String, + + /// Default log level for file logging. Defaults to "info". + /// Note: [`ATUIN_LOG`] environment variable overrides this. + #[serde(default)] + pub(crate) level: LogLevel, + + /// 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 { + fn default() -> Self { + Self { + strategy: PreviewStrategy::Auto, + } + } +} + +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, + } + } +} + +impl Default for Logs { + fn default() -> Self { + Self { + enabled: true, + 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() + }, + } + } +} + +impl Logs { + fn default_enabled() -> bool { + true + } + + 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. +#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] +pub(crate) enum PreviewStrategy { + // Preview height is calculated for the length of the selected command. + #[serde(rename = "auto")] + Auto, + + // Preview height is calculated for the length of the longest command stored in the history. + #[serde(rename = "static")] + Static, + + // max_preview_height is used as fixed height. + #[serde(rename = "fixed")] + 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 { + pub(crate) data_dir: Option<String>, + pub(crate) dialect: Dialect, + pub(crate) timezone: Timezone, + pub(crate) style: Style, + + pub db_path: String, + pub 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, + pub(crate) show_help: bool, + pub(crate) show_tabs: bool, + pub(crate) show_numeric_shortcuts: bool, + pub(crate) auto_hide_height: u16, + pub(crate) exit_mode: ExitMode, + pub(crate) keymap_mode: KeymapMode, + pub(crate) keymap_mode_shell: KeymapMode, + pub(crate) keymap_cursor: HashMap<String, CursorStyle>, + pub(crate) word_jump_mode: WordJumpMode, + pub(crate) word_chars: String, + pub(crate) scroll_context_lines: usize, + pub(crate) history_format: String, + pub(crate) strip_trailing_whitespace: bool, + pub(crate) prefers_reduced_motion: bool, + pub(crate) store_failed: bool, + pub(crate) no_mouse: bool, + + #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] + pub(crate) history_filter: RegexSet, + + #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] + pub(crate) cwd_filter: RegexSet, + + pub(crate) secrets_filter: bool, + pub(crate) workspaces: bool, + pub(crate) ctrl_n_shortcuts: bool, + + pub(crate) network_connect_timeout: u64, + pub(crate) network_timeout: u64, + pub local_timeout: f64, + pub(crate) enter_accept: bool, + pub(crate) smart_sort: bool, + pub(crate) command_chaining: bool, + + #[serde(default)] + pub(crate) sync: Sync, + + #[serde(default)] + pub(crate) stats: Stats, + + #[serde(default)] + pub(crate) keys: Keys, + + #[serde(default)] + pub(crate) keymap: KeymapConfig, + + #[serde(default)] + pub(crate) preview: Preview, + + #[serde(default)] + pub 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()) + } + + #[expect(clippy::too_many_lines)] + fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> { + let db_path = data_dir.join("history.db"); + 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}")? + .set_default("db_path", db_path.to_str())? + .set_default("record_store_path", record_store_path.to_str())? + .set_default("key_path", key_path.to_str())? + .set_default("dialect", "us")? + .set_default("timezone", "local")? + .set_default("auto_sync", true)? + .set_default("sync.address", "https://api.atuin.sh")? + .set_default("sync_frequency", "5m")? + .set_default("search_mode", "fuzzy")? + .set_default("filter_mode", None::<String>)? + .set_default("style", "compact")? + .set_default("inline_height", 40)? + .set_default("show_preview", true)? + .set_default("preview.strategy", "auto")? + .set_default("max_preview_height", 4)? + .set_default("show_help", true)? + .set_default("show_tabs", true)? + .set_default("show_numeric_shortcuts", true)? + .set_default("auto_hide_height", 8)? + .set_default("invert", false)? + .set_default("exit_mode", "return-original")? + .set_default("word_jump_mode", "emacs")? + .set_default( + "word_chars", + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", + )? + .set_default("scroll_context_lines", 1)? + .set_default("shell_up_key_binding", false)? + .set_default("workspaces", false)? + .set_default("ctrl_n_shortcuts", false)? + .set_default("secrets_filter", true)? + .set_default("strip_trailing_whitespace", true)? + .set_default("network_connect_timeout", 5)? + .set_default("network_timeout", 30)? + .set_default("local_timeout", 2.0)? + // enter_accept defaults to false here, but true in the default config file. The dissonance is + // intentional! + // Existing users will get the default "False", so we don't mess with any potential + // muscle memory. + // New users will get the new default, that is more similar to what they are used to. + .set_default("enter_accept", false)? + .set_default("keys.scroll_exits", true)? + .set_default("keys.accept_past_line_end", true)? + .set_default("keys.exit_past_line_start", true)? + .set_default("keys.accept_past_line_start", false)? + .set_default("keys.accept_with_backspace", false)? + .set_default("keys.prefix", "a")? + .set_default("keymap_mode", "emacs")? + .set_default("keymap_mode_shell", "auto")? + .set_default("keymap_cursor", HashMap::<String, String>::new())? + .set_default("smart_sort", false)? + .set_default("command_chaining", false)? + .set_default("store_failed", true)? + .set_default("daemon.sync_frequency", 300)? + .set_default("daemon.socket_path", socket_path.to_str())? + .set_default("daemon.pidfile_path", pidfile_path.to_str())? + .set_default("daemon.systemd_socket", false)? + .set_default("daemon.tcp_port", 8889)? + .set_default("logs.enabled", true)? + .set_default("logs.dir", logs_dir.to_str())? + .set_default("logs.level", "info")? + .set_default("logs.search.file", "search.log")? + .set_default("logs.daemon.file", "daemon.log")? + .set_default("logs.ai.file", "ai.log")? + .set_default("kv.db_path", kv_path.to_str())? + .set_default("scripts.db_path", scripts_path.to_str())? + .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)? + .set_default("ai.opening.send_last_command", false)? + .set_default( + "search.filters", + vec![ + "global", + "host", + "session", + "workspace", + "directory", + "session-preload", + ], + )? + .set_default("theme.name", "default")? + .set_default("theme.debug", None::<bool>)? + .set_default("tmux.enabled", false)? + .set_default("tmux.width", "80%")? + .set_default("tmux.height", "60%")? + .set_default( + "prefers_reduced_motion", + std::env::var("NO_MOTION").ok().map_or_else( + || config::Value::new(None, config::ValueKind::Boolean(false)), + |_| config::Value::new(None, config::ValueKind::Boolean(true)), + ), + )? + .set_default("no_mouse", false)? + .add_source( + Environment::with_prefix("atuin") + .prefix_separator("_") + .separator("__"), + )) + } + + pub(crate) fn get_config_path() -> Result<PathBuf> { + let config_dir = utils::config_dir(); + + create_dir_all(&config_dir) + .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?; + + let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else( + |_| { + let mut config_file = PathBuf::new(); + config_file.push(config_dir); + config_file + }, + PathBuf::from, + ); + + config_file.push("config.toml"); + + Ok(config_file) + } + + /// Build a merged `Config` from defaults, config file, and environment. + /// + /// This resolves `data_dir`, initializes the data directory on disk, + /// and layers defaults → config file → env overrides. Both `new()` and + /// `get_config_value()` use this so the resolution logic lives in one place. + fn build_config() -> Result<Config> { + let config_file = Self::get_config_path()?; + + // extract data_dir first so we can use it as the base for other path defaults + let effective_data_dir = if config_file.exists() { + #[derive(Deserialize, Default)] + struct DataDirOnly { + data_dir: Option<String>, + } + + let config_file_str = config_file + .to_str() + .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; + + let partial_config = Config::builder() + .add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) + .add_source( + Environment::with_prefix("atuin") + .prefix_separator("_") + .separator("__"), + ) + .build() + .ok(); + + let custom_data_dir = partial_config + .and_then(|c| c.try_deserialize::<DataDirOnly>().ok()) + .and_then(|d| d.data_dir); + + match custom_data_dir { + Some(dir) => { + let expanded = shellexpand::full(&dir) + .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?; + PathBuf::from(expanded.as_ref()) + } + None => utils::data_dir(), + } + } else { + utils::data_dir() + }; + + DATA_DIR.set(effective_data_dir.clone()).ok(); + + create_dir_all(&effective_data_dir) + .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?; + + let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?; + + config_builder = if config_file.exists() { + let config_file_str = config_file + .to_str() + .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; + config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) + } else { + // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a + // file with defaults. <2026-06-13> + create_dir_all(config_file.parent().unwrap())?; + + info!( + "No config file at: `{}`. Not adding one.", + config_file.display() + ); + + config_builder + }; + + // all paths should be expanded + let built = config_builder.build_cloned()?; + config_builder = [ + "db_path", + "record_store_path", + "key_path", + "daemon.socket_path", + "daemon.pidfile_path", + "logs.dir", + "logs.search.file", + "logs.daemon.file", + ] + .iter() + .map(|key| (key, built.get_string(key).unwrap_or_default())) + .filter_map(|(key, value)| match Self::expand_path(&value) { + Ok(expanded) => Some((key, expanded)), + Err(e) => { + log::warn!("failed to expand path for {key}: {e}"); + None + } + }) + .fold(config_builder, |builder, (key, value)| { + builder + .set_override(key, value) + .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}")) + }); + + config_builder.build().map_err(Into::into) + } + + /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`). + /// + /// Returns the effective value after merging defaults, config file, and + /// environment — without the side-effects of full `Settings` construction + /// (meta store init, path expansion, etc.). + pub(crate) fn get_config_value(key: &str) -> Result<String> { + let config = Self::build_config()?; + let value: config::Value = config + .get(key) + .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?; + Ok(Self::format_resolved_value(&value, key)) + } + + fn format_resolved_value(value: &config::Value, prefix: &str) -> String { + use config::ValueKind; + + match &value.kind { + ValueKind::Nil => String::new(), + ValueKind::Boolean(b) => b.to_string(), + ValueKind::I64(i) => i.to_string(), + ValueKind::I128(i) => i.to_string(), + ValueKind::U64(u) => u.to_string(), + ValueKind::U128(u) => u.to_string(), + ValueKind::Float(f) => f.to_string(), + ValueKind::String(s) => s.clone(), + ValueKind::Array(arr) => { + let items: Vec<String> = arr + .iter() + .map(|v| Self::format_resolved_value(v, "")) + .collect(); + format!("[{}]", items.join(", ")) + } + ValueKind::Table(map) => { + let mut lines = Vec::new(); + let mut keys: Vec<_> = map.keys().collect(); + keys.sort(); + + for k in keys { + let v = &map[k]; + let full_key = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + + match &v.kind { + ValueKind::Table(_) => { + lines.push(Self::format_resolved_value(v, &full_key)); + } + _ => { + lines.push(format!( + "{} = {}", + full_key, + Self::format_resolved_value(v, "") + )); + } + } + } + + lines.join("\n") + } + } + } + + pub fn new() -> Result<Self> { + let config = Self::build_config()?; + let settings: Self = config + .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) + } + + fn expand_path(path: &str) -> Result<String> { + shellexpand::full(&path) + .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 { + fn default() -> Self { + // if this panics something is very wrong, as the default config + // does not build or deserialize into the settings struct + Self::builder() + .expect("Could not build default") + .build() + .expect("Could not build config") + .try_deserialize() + .expect("Could not deserialize config") + } +} + +#[cfg(test)] +pub(crate) fn test_local_timeout() -> f64 { + std::env::var("ATUIN_TEST_LOCAL_TIMEOUT") + .ok() + .and_then(|x| x.parse().ok()) + // this hardcoded value should be replaced by a simple way to get the + // default local_timeout of Settings if possible + .unwrap_or(2.0) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use eyre::Result; + + use super::Timezone; + + #[test] + fn can_parse_offset_timezone_spec() -> Result<()> { + assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0)); + assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0)); + assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0)); + assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0)); + + // single digit hours are allowed + assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0)); + assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0)); + assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0)); + assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0)); + + // fully qualified form + assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0)); + assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0)); + + // these offsets don't really exist but are supported anyway + assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0)); + assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0)); + assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45)); + assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45)); + + // require a leading sign for clarity + assert!(Timezone::from_str("5").is_err()); + assert!(Timezone::from_str("10:30").is_err()); + + Ok(()) + } + + #[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; + + let custom_dir = PathBuf::from("/custom/data/dir"); + let builder = super::Settings::builder_with_data_dir(&custom_dir)?; + let config = builder.build()?; + + let db_path: String = config.get("db_path")?; + let key_path: String = config.get("key_path")?; + 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")?; + + assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap()); + assert_eq!(key_path, custom_dir.join("key").to_str().unwrap()); + assert_eq!( + record_store_path, + custom_dir.join("records.db").to_str().unwrap() + ); + assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap()); + assert_eq!( + 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() + .join("atuin.sock") + .to_str() + .unwrap() + ); + assert_eq!( + daemon_pidfile_path, + custom_dir.join("atuin-daemon.pid").to_str().unwrap() + ); + + Ok(()) + } + + #[test] + fn keymap_config_deserializes_simple_binding() { + let json = r#"{"emacs": {"ctrl-c": "exit"}}"#; + let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.emacs.len(), 1); + match &config.emacs["ctrl-c"] { + super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"), + _ => panic!("expected Simple variant"), + } + } + + #[test] + fn keymap_config_deserializes_conditional_binding() { + let json = r#"{ + "emacs": { + "left": [ + {"when": "cursor-at-start", "action": "exit"}, + {"action": "cursor-left"} + ] + } + }"#; + let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); + match &config.emacs["left"] { + super::KeyBindingConfig::Rules(rules) => { + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start")); + assert_eq!(rules[0].action, "exit"); + assert!(rules[1].when.is_none()); + assert_eq!(rules[1].action, "cursor-left"); + } + _ => panic!("expected Rules variant"), + } + } + + #[test] + fn keymap_config_deserializes_vim_normal() { + let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#; + let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.vim_normal.len(), 2); + assert!(config.emacs.is_empty()); + } + + #[test] + fn keymap_config_is_empty_when_default() { + let config = super::KeymapConfig::default(); + assert!(config.is_empty()); + } + + #[test] + fn keymap_config_mixed_modes() { + let json = r#"{ + "emacs": {"ctrl-c": "exit"}, + "vim-normal": {"q": "exit"}, + "inspector": {"d": "delete"} + }"#; + let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); + assert!(!config.is_empty()); + assert_eq!(config.emacs.len(), 1); + assert_eq!(config.vim_normal.len(), 1); + assert_eq!(config.inspector.len(), 1); + assert!(config.vim_insert.is_empty()); + assert!(config.prefix.is_empty()); + } +} diff --git a/crates/daemon/src/aclient/settings/watcher.rs b/crates/daemon/src/aclient/settings/watcher.rs new file mode 100644 index 00000000..01d20855 --- /dev/null +++ b/crates/daemon/src/aclient/settings/watcher.rs @@ -0,0 +1,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::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 + } + } + } + } +} diff --git a/crates/client/src/atuin_client/utils.rs b/crates/daemon/src/aclient/utils.rs index 989f9fc1..cf515183 100644 --- a/crates/client/src/atuin_client/utils.rs +++ b/crates/daemon/src/aclient/utils.rs @@ -28,7 +28,7 @@ macro_rules! setup_db { Ok(()) } - crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate) + crate::aclient::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate) }}; } pub(crate) use setup_db; |
