diff options
Diffstat (limited to '')
| -rw-r--r-- | crates/daemon/Cargo.toml | 2 | ||||
| -rw-r--r-- | crates/daemon/build.rs | 1 | ||||
| -rw-r--r-- | crates/daemon/proto/search.proto | 35 | ||||
| -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 | ||||
| -rw-r--r-- | crates/daemon/src/client.rs | 134 | ||||
| -rw-r--r-- | crates/daemon/src/components/history.rs | 10 | ||||
| -rw-r--r-- | crates/daemon/src/components/mod.rs | 15 | ||||
| -rw-r--r-- | crates/daemon/src/components/search.rs | 407 | ||||
| -rw-r--r-- | crates/daemon/src/components/semantic.rs | 10 | ||||
| -rw-r--r-- | crates/daemon/src/components/sync.rs | 6 | ||||
| -rw-r--r-- | crates/daemon/src/control/mod.rs | 22 | ||||
| -rw-r--r-- | crates/daemon/src/daemon.rs | 50 | ||||
| -rw-r--r-- | crates/daemon/src/events.rs | 2 | ||||
| -rw-r--r-- | crates/daemon/src/generated.rs | 16 | ||||
| -rw-r--r-- | crates/daemon/src/lib.rs | 32 | ||||
| -rw-r--r-- | crates/daemon/src/main.rs (renamed from crates/client/src/command/client/daemon.rs) | 270 | ||||
| -rw-r--r-- | crates/daemon/src/search/mod.rs | 557 | ||||
| -rw-r--r-- | crates/daemon/src/server.rs | 23 |
35 files changed, 2014 insertions, 1504 deletions
diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 6d5de681..07fd84d7 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -14,8 +14,6 @@ repository = { workspace = true } [dependencies] turtle-common = { workspace = true } async-trait = "0.1.58" -atuin-nucleo-matcher = { workspace = true } -atuin-nucleo = { workspace = true } axum = "0.8" base64 = "0.22" clap = { version = "4.5.7", features = ["derive"] } diff --git a/crates/daemon/build.rs b/crates/daemon/build.rs index 646b8588..5abf6b2a 100644 --- a/crates/daemon/build.rs +++ b/crates/daemon/build.rs @@ -6,7 +6,6 @@ use protox::prost::Message; fn main() -> Result<(), std::io::Error> { let proto_paths = [ "proto/history.proto", - "proto/search.proto", "proto/control.proto", "proto/semantic.proto", ]; diff --git a/crates/daemon/proto/search.proto b/crates/daemon/proto/search.proto deleted file mode 100644 index 6b84acbd..00000000 --- a/crates/daemon/proto/search.proto +++ /dev/null @@ -1,35 +0,0 @@ -syntax = "proto3"; -package search; - -enum FilterMode { - GLOBAL = 0; - HOST = 1; - SESSION = 2; - DIRECTORY = 3; - WORKSPACE = 4; - SESSION_PRELOAD = 5; -} - -message SearchContext { - string session_id = 1; - string cwd = 2; - string hostname = 3; - string host_id = 4; - optional string git_root = 5; -} - -message SearchRequest { - string query = 1; - uint64 query_id = 2; // Incrementing ID to match responses to queries - FilterMode filter_mode = 3; - SearchContext context = 4; -} - -message SearchResponse { - uint64 query_id = 1; // Echo back the query ID - repeated bytes ids = 2; -} - -service Search { - rpc Search(stream SearchRequest) returns (stream SearchResponse); -} 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; diff --git a/crates/daemon/src/client.rs b/crates/daemon/src/client.rs index 5cccb5ff..5f8ea0f2 100644 --- a/crates/daemon/src/client.rs +++ b/crates/daemon/src/client.rs @@ -7,36 +7,25 @@ use hyper_util::rt::TokioIo; #[cfg(unix)] use tokio::net::UnixStream; -use tracing::{Level, instrument, span}; +use crate::aclient::{history::History, settings::Settings}; use crate::generated; use crate::{ - atuin_client::{ - database::Context, - history::History, - settings::{FilterMode, Settings}, - }, - atuin_daemon::{ - events::DaemonEvent, - generated::{ - control::{ - ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, HistoryRebuiltEvent, - SendEventRequest, SettingsReloadedEvent, ShutdownEvent, - control_client::ControlClient as ControlServiceClient, - }, - history::{ - EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, - StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply, - TailHistoryRequest, history_client::HistoryClient as HistoryServiceClient, - }, - search::{ - FilterMode as RpcFilterMode, SearchContext as RpcSearchContext, SearchRequest, - SearchResponse, search_client::SearchClient as SearchServiceClient, - }, - semantic::{ - CommandCapture, RecordCommandsReply, - semantic_client::SemanticClient as SemanticServiceClient, - }, + events::DaemonEvent, + generated::{ + control::{ + ForceSyncEvent, HistoryDeletedEvent, HistoryPrunedEvent, HistoryRebuiltEvent, + SendEventRequest, SettingsReloadedEvent, ShutdownEvent, + control_client::ControlClient as ControlServiceClient, + }, + history::{ + EndHistoryReply, EndHistoryRequest, ShutdownRequest, StartHistoryReply, + StartHistoryRequest, StatusReply, StatusRequest, TailHistoryReply, TailHistoryRequest, + history_client::HistoryClient as HistoryServiceClient, + }, + semantic::{ + CommandCapture, RecordCommandsReply, + semantic_client::SemanticClient as SemanticServiceClient, }, }, }; @@ -129,7 +118,7 @@ impl HistoryClient { Ok(self.client.status(StatusRequest {}).await?.into_inner()) } - pub async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> { + pub(crate) async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> { Ok(self .client .tail_history(TailHistoryRequest {}) @@ -143,92 +132,13 @@ impl HistoryClient { } } -pub struct SearchClient { - client: SearchServiceClient<Channel>, -} - -impl SearchClient { - #[cfg(unix)] - pub async fn new(path: String) -> Result<Self> { - let log_path = path.clone(); - let channel = Endpoint::try_from("http://atuin_local_daemon:0")? - .connect_with_connector(service_fn(move |_: Uri| { - let path = path.clone(); - - async move { - Ok::<_, std::io::Error>(TokioIo::new(UnixStream::connect(path.clone()).await?)) - } - })) - .await - .wrap_err_with(|| { - format!( - "failed to connect to local atuin daemon at {}. Is it running?", - &log_path - ) - })?; - - let client = SearchServiceClient::new(channel); - - Ok(Self { client }) - } - - #[instrument(skip_all, level = Level::TRACE, name = "daemon_client_search", fields(query = %query, query_id = query_id))] - pub async fn search( - &mut self, - query: String, - query_id: u64, - filter_mode: FilterMode, - context: Option<Context>, - ) -> Result<tonic::Streaming<SearchResponse>> { - let request = SearchRequest { - query, - query_id, - filter_mode: RpcFilterMode::from(filter_mode).into(), - context: context.map(RpcSearchContext::from), - }; - let request_stream = tokio_stream::once(request); - let response = span!(Level::TRACE, "daemon_client_search.request") - .in_scope(async || self.client.search(request_stream).await) - .await?; - - Ok(response.into_inner()) - } -} - -impl From<FilterMode> for RpcFilterMode { - fn from(filter_mode: FilterMode) -> Self { - match filter_mode { - FilterMode::Global => Self::Global, - FilterMode::Host => Self::Host, - FilterMode::Session => Self::Session, - FilterMode::Directory => Self::Directory, - FilterMode::Workspace => Self::Workspace, - FilterMode::SessionPreload => Self::SessionPreload, - } - } -} - -impl From<Context> for RpcSearchContext { - fn from(context: Context) -> Self { - Self { - session_id: context.session, - cwd: context.cwd, - hostname: context.hostname, - host_id: context.host_id, - git_root: context - .git_root - .map(|path| path.to_string_lossy().to_string()), - } - } -} - -pub struct SemanticClient { +pub(crate) struct SemanticClient { client: SemanticServiceClient<Channel>, } impl SemanticClient { #[cfg(unix)] - pub async fn new(path: String) -> Result<Self> { + pub(crate) async fn new(path: String) -> Result<Self> { let log_path = path.clone(); let channel = Endpoint::try_from("http://atuin_local_daemon:0")? .connect_with_connector(service_fn(move |_: Uri| { @@ -252,11 +162,11 @@ impl SemanticClient { } #[cfg(unix)] - pub async fn from_settings(settings: &Settings) -> Result<Self> { + pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> { Self::new(settings.daemon.socket_path.clone()).await } - pub async fn record_commands( + pub(crate) async fn record_commands( &mut self, captures: Vec<CommandCapture>, ) -> Result<RecordCommandsReply> { @@ -279,7 +189,7 @@ pub struct ControlClient { impl ControlClient { /// Connect to the daemon's control service. #[cfg(unix)] - pub async fn new(path: String) -> Result<Self> { + pub(crate) async fn new(path: String) -> Result<Self> { let log_path = path.clone(); let channel = Endpoint::try_from("http://atuin_local_daemon:0")? .connect_with_connector(service_fn(move |_: Uri| { diff --git a/crates/daemon/src/components/history.rs b/crates/daemon/src/components/history.rs index a75ff774..7a0882a3 100644 --- a/crates/daemon/src/components/history.rs +++ b/crates/daemon/src/components/history.rs @@ -4,7 +4,7 @@ use std::{pin::Pin, sync::Arc}; -use crate::atuin_client::{ +use crate::aclient::{ history::{History, HistoryId, store::HistoryStore}, settings::Settings, }; @@ -35,7 +35,7 @@ const DAEMON_PROTOCOL_VERSION: u32 = 1; /// - Saves completed commands to the database and record store /// - Emits history events for other components (e.g., search indexing) /// - Provides the History gRPC service -pub struct HistoryComponent { +pub(crate) struct HistoryComponent { inner: Arc<HistoryComponentInner>, } @@ -52,7 +52,7 @@ struct HistoryComponentInner { impl HistoryComponent { /// Create a new history component. - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { inner: Arc::new(HistoryComponentInner { running: DashMap::new(), @@ -65,7 +65,7 @@ impl HistoryComponent { /// Get the gRPC service for this component. /// /// This returns a tonic service that can be added to a gRPC server. - pub fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> { + pub(crate) fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> { HistoryServer::new(HistoryGrpcService { inner: self.inner.clone(), }) @@ -111,7 +111,7 @@ impl Component for HistoryComponent { /// The gRPC service implementation. /// /// This is a thin wrapper that delegates to the component's shared state. -pub struct HistoryGrpcService { +pub(crate) struct HistoryGrpcService { inner: Arc<HistoryComponentInner>, } diff --git a/crates/daemon/src/components/mod.rs b/crates/daemon/src/components/mod.rs index 447e31df..7c478efb 100644 --- a/crates/daemon/src/components/mod.rs +++ b/crates/daemon/src/components/mod.rs @@ -10,16 +10,13 @@ //! Available components: //! //! - [`history::HistoryComponent`]: Command history lifecycle management -//! - [`search::SearchComponent`]: Fuzzy search over history //! - [`semantic::SemanticComponent`]: In-memory semantic command captures //! - [`sync::SyncComponent`]: Cloud sync -pub mod history; -pub mod search; -pub mod semantic; -pub mod sync; +pub(crate) mod history; +pub(crate) mod semantic; +pub(crate) mod sync; -pub use history::HistoryComponent; -pub use search::SearchComponent; -pub use semantic::SemanticComponent; -pub use sync::SyncComponent; +pub(crate) use history::HistoryComponent; +pub(crate) use semantic::SemanticComponent; +pub(crate) use sync::SyncComponent; diff --git a/crates/daemon/src/components/search.rs b/crates/daemon/src/components/search.rs deleted file mode 100644 index 91f2db17..00000000 --- a/crates/daemon/src/components/search.rs +++ /dev/null @@ -1,407 +0,0 @@ -//! Search component. -//! -//! Provides fuzzy search over command history using the Nucleo search library -//! with frecency-based ranking and dynamic filtering. - -use std::{pin::Pin, sync::Arc}; - -use eyre::Result; -use tokio::sync::RwLock; -use tokio_stream::Stream; -use tonic::{Request, Response, Status, Streaming}; -use tracing::{Level, debug, info, instrument, span, trace}; -use uuid::Uuid; - -use crate::{ - daemon::{Component, DaemonHandle}, - events::DaemonEvent, - generated::search::{ - self, FilterMode, SearchRequest, SearchResponse, - search_server::{Search as SearchSvc, SearchServer}, - }, - search::{IndexFilterMode, QueryContext, SearchIndex}, -}; - -const PAGE_SIZE: usize = 5000; -const RESULTS_LIMIT: u32 = 200; -/// How often to rebuild the frecency map (in seconds). -const FRECENCY_REFRESH_INTERVAL_SECS: u64 = 60; - -/// Search component - provides fuzzy search over command history. -/// -/// This component: -/// - Maintains a deduplicated search index with frecency ranking -/// - Loads history from the database on startup -/// - Updates the index when history events occur -/// - Provides the Search gRPC service -pub struct SearchComponent { - index: Arc<RwLock<SearchIndex>>, - handle: RwLock<Option<DaemonHandle>>, - loader_handle: Option<tokio::task::JoinHandle<()>>, - frecency_handle: Option<tokio::task::JoinHandle<()>>, -} - -impl SearchComponent { - /// Create a new search component. - pub fn new() -> Self { - Self { - index: Arc::new(RwLock::new(SearchIndex::new())), - handle: RwLock::new(None), - loader_handle: None, - frecency_handle: None, - } - } - - /// Get the gRPC service for this component. - pub fn grpc_service(&self) -> SearchServer<SearchGrpcService> { - SearchServer::new(SearchGrpcService { - index: self.index.clone(), - }) - } - - /// Rebuild the entire search index from the database. - #[expect(clippy::significant_drop_tightening, reason = "false positive")] - async fn rebuild_index(&self) -> Result<()> { - let handle_guard = self.handle.read().await; - let handle = handle_guard - .as_ref() - .ok_or_else(|| eyre::eyre!("component not initialized"))?; - - info!("Rebuilding search index from database"); - - // Create a new index - let new_index = SearchIndex::new(); - - // Load all history into the new index - let db = handle.history_db().clone(); - let mut pager = db.all_paged(PAGE_SIZE, false, true); - loop { - match pager.next().await { - Ok(Some(histories)) => { - info!( - "Loading {} history entries into search index", - histories.len() - ); - new_index.add_histories(&histories); - } - Ok(None) => break, - Err(e) => { - tracing::error!("Failed to load history during rebuild: {}", e); - break; - } - } - } - - info!( - "Search index rebuild complete; {} unique commands", - new_index.command_count() - ); - - // Replace the old index with the new one - *self.index.write().await = new_index; - Ok(()) - } -} - -impl Default for SearchComponent { - fn default() -> Self { - Self::new() - } -} - -#[tonic::async_trait] -impl Component for SearchComponent { - fn name(&self) -> &'static str { - "search" - } - - #[expect(clippy::significant_drop_tightening, reason = "false positive")] - async fn start(&mut self, handle: DaemonHandle) -> Result<()> { - *self.handle.write().await = Some(handle.clone()); - - // Spawn background task to load history into index - let index = self.index.clone(); - let db = handle.history_db().clone(); - let handle_for_loader = handle.clone(); - - self.loader_handle = Some(tokio::spawn(async move { - info!( - "Loading history into search index; page size = {}", - PAGE_SIZE - ); - let mut pager = db.all_paged(PAGE_SIZE, false, true); - loop { - match pager.next().await { - Ok(Some(histories)) => { - info!( - "Loading {} history entries into search index", - histories.len() - ); - index.read().await.add_histories(&histories); - } - Ok(None) => { - info!( - "Initial history load complete; {} unique commands indexed", - index.read().await.command_count() - ); - // Build initial frecency map with current settings - let settings = handle_for_loader.settings().await; - index.read().await.rebuild_frecency(&settings.search).await; - info!("Initial frecency map built"); - break; - } - Err(e) => { - tracing::error!("Failed to load history: {}", e); - break; - } - } - } - })); - - // Spawn background task to periodically refresh frecency - let index_for_frecency = self.index.clone(); - let handle_for_frecency = handle.clone(); - self.frecency_handle = Some(tokio::spawn(async move { - let mut interval = tokio::time::interval(std::time::Duration::from_secs( - FRECENCY_REFRESH_INTERVAL_SECS, - )); - loop { - interval.tick().await; - trace!("Refreshing frecency map"); - let settings = handle_for_frecency.settings().await; - index_for_frecency - .read() - .await - .rebuild_frecency(&settings.search) - .await; - } - })); - - tracing::info!("search component started"); - Ok(()) - } - - #[expect(clippy::significant_drop_tightening, reason = "false positive")] - async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> { - match event { - DaemonEvent::RecordsAdded(records) => { - debug!( - count = records.len(), - "Processing added records for search index" - ); - - let handle_guard = self.handle.read().await; - if let Some(handle) = handle_guard.as_ref() { - let histories: Vec<_> = handle - .history_db() - .query_history( - format!( - "select * from history where id in ({})", - records - .iter() - .map(|record| record.0.to_string()) - .collect::<Vec<_>>() - .join(",") - ) - .as_str(), - ) - .await - .unwrap_or_default(); - - span!(Level::TRACE, "inject_records", count = histories.len()) - .in_scope(async || { - self.index.read().await.add_histories(&histories); - }) - .await; - } - } - DaemonEvent::HistoryStarted(history) => { - debug!(id = %history.id, command = %history.command, "History started (no index action)"); - } - DaemonEvent::HistoryEnded(history) => { - span!(Level::TRACE, "inject_history_ended") - .in_scope(async || { - self.index.read().await.add_history(history); - }) - .await; - } - DaemonEvent::HistoryPruned | DaemonEvent::HistoryRebuilt => { - info!("History store pruned or rebuilt, rebuilding search index"); - if let Err(e) = self.rebuild_index().await { - tracing::error!("Failed to rebuild search index: {}", e); - } - } - DaemonEvent::HistoryDeleted { ids } => { - info!( - count = ids.len(), - "History deleted, rebuilding search index" - ); - // For now, just rebuild the entire index. A more efficient implementation - // would remove specific items from the index. - if let Err(e) = self.rebuild_index().await { - tracing::error!("Failed to rebuild search index: {}", e); - } - } - DaemonEvent::SettingsReloaded => { - info!("Settings reloaded, rebuilding frecency map with new multipliers"); - let handle_guard = self.handle.read().await; - if let Some(handle) = handle_guard.as_ref() { - let settings = handle.settings().await; - self.index - .read() - .await - .rebuild_frecency(&settings.search) - .await; - } - } - // Events we don't care about - DaemonEvent::SyncCompleted { .. } - | DaemonEvent::SyncFailed { .. } - | DaemonEvent::ForceSync - | DaemonEvent::ShutdownRequested => {} - } - Ok(()) - } - - async fn stop(&mut self) -> Result<()> { - if let Some(handle) = self.loader_handle.take() { - handle.abort(); - } - if let Some(handle) = self.frecency_handle.take() { - handle.abort(); - } - tracing::info!("search component stopped"); - Ok(()) - } -} - -/// The gRPC service implementation. -pub struct SearchGrpcService { - index: Arc<RwLock<SearchIndex>>, -} - -#[tonic::async_trait] -impl SearchSvc for SearchGrpcService { - type SearchStream = Pin<Box<dyn Stream<Item = Result<SearchResponse, Status>> + Send>>; - - #[instrument(skip_all, level = Level::TRACE, name = "search_rpc")] - async fn search( - &self, - request: Request<Streaming<SearchRequest>>, - ) -> Result<Response<Self::SearchStream>, Status> { - let mut in_stream = request.into_inner(); - let index = self.index.clone(); - - // Create output channel - let (tx, rx) = tokio::sync::mpsc::channel::<Result<SearchResponse, Status>>(128); - - // Spawn task to handle incoming requests and send responses - tokio::spawn(async move { - while let Some(req) = in_stream.message().await.transpose() { - match req { - Ok(search_req) => { - let query = search_req.query; - let query_id = search_req.query_id; - let filter_mode: FilterMode = search_req - .filter_mode - .try_into() - .unwrap_or(FilterMode::Global); - let proto_context = search_req.context; - - debug!( - "search request: query = {}, query_id = {}, filter_mode = {}, context = {:?}", - query, - query_id, - filter_mode.as_str_name(), - proto_context - ); - - // Convert proto FilterMode + context to IndexFilterMode - let index_filter = convert_filter_mode(filter_mode, proto_context.as_ref()); - - // Build QueryContext from proto context - let query_context = proto_context - .map(|ctx| QueryContext { - cwd: Some(with_trailing_slash(&ctx.cwd)), - git_root: ctx.git_root.map(|s| with_trailing_slash(&s)), - hostname: Some(ctx.hostname), - session_id: Some(ctx.session_id), - }) - .unwrap_or_default(); - - // Perform the search - let history_ids = - span!(Level::TRACE, "daemon_search_query", %query, query_id) - .in_scope(|| async { - let index = index.read().await; - index - .search(&query, index_filter, &query_context, RESULTS_LIMIT) - .await - }) - .await; - - // Convert history IDs to bytes - let ids: Vec<Vec<u8>> = history_ids - .iter() - .filter_map(|id| { - Uuid::parse_str(id) - .ok() - .map(|uuid| uuid.as_bytes().to_vec()) - }) - .collect(); - - if tx.send(Ok(SearchResponse { query_id, ids })).await.is_err() { - break; // Client disconnected - } - } - Err(e) => { - drop(tx.send(Err(e)).await); - break; - } - } - } - }); - - // Convert receiver to stream - let out_stream = tokio_stream::wrappers::ReceiverStream::new(rx); - Ok(Response::new(Box::pin(out_stream))) - } -} - -/// Convert proto `FilterMode` and context to `IndexFilterMode`. -fn convert_filter_mode( - mode: FilterMode, - context: Option<&search::SearchContext>, -) -> IndexFilterMode { - #[expect( - clippy::match_same_arms, - reason = "wildcard pattern used in second one" - )] - match (mode, context) { - (FilterMode::Global, _) => IndexFilterMode::Global, - (FilterMode::Directory, Some(ctx)) => { - IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd)) - } - (FilterMode::Workspace, Some(ctx)) => ctx.git_root.as_ref().map_or_else( - || IndexFilterMode::Directory(with_trailing_slash(&ctx.cwd)), - |git_root| IndexFilterMode::Workspace(with_trailing_slash(git_root)), - ), - (FilterMode::Host, Some(ctx)) => IndexFilterMode::Host(ctx.hostname.clone()), - (FilterMode::Session, Some(ctx)) => IndexFilterMode::Session(ctx.session_id.clone()), - (FilterMode::SessionPreload, Some(ctx)) => { - // SessionPreload is similar to Session - filter by session - IndexFilterMode::Session(ctx.session_id.clone()) - } - // If no context provided, fall back to global - _ => IndexFilterMode::Global, - } -} - -#[cfg(not(windows))] -pub fn with_trailing_slash(s: &str) -> String { - if s.ends_with('/') { - s.to_string() - } else { - format!("{s}/") - } -} diff --git a/crates/daemon/src/components/semantic.rs b/crates/daemon/src/components/semantic.rs index 02f5c3d1..aec26887 100644 --- a/crates/daemon/src/components/semantic.rs +++ b/crates/daemon/src/components/semantic.rs @@ -8,7 +8,7 @@ use std::collections::{HashMap, VecDeque}; use std::fmt::{Display, Formatter}; use std::sync::Arc; -use crate::atuin_client::history::{History, HistoryId}; +use crate::aclient::history::{History, HistoryId}; use crate::generated::semantic; use eyre::Result; use tokio::sync::Mutex; @@ -30,7 +30,7 @@ const MAX_BYTES_PER_SESSION: usize = 32 * 1024 * 1024; const MAX_PENDING_HISTORIES: usize = 128; /// Stores completed command captures and associates them with history events. -pub struct SemanticComponent { +pub(crate) struct SemanticComponent { inner: Arc<SemanticComponentInner>, } @@ -84,7 +84,7 @@ struct SemanticCommandRecord { } impl SemanticComponent { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { inner: Arc::new(SemanticComponentInner { state: Mutex::new(SemanticState::default()), @@ -92,7 +92,7 @@ impl SemanticComponent { } } - pub fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> { + pub(crate) fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> { SemanticServer::new(SemanticGrpcService { inner: self.inner.clone(), }) @@ -453,7 +453,7 @@ impl Display for SessionId { } } -pub struct SemanticGrpcService { +pub(crate) struct SemanticGrpcService { inner: Arc<SemanticComponentInner>, } diff --git a/crates/daemon/src/components/sync.rs b/crates/daemon/src/components/sync.rs index e898e8bd..e200ad73 100644 --- a/crates/daemon/src/components/sync.rs +++ b/crates/daemon/src/components/sync.rs @@ -9,7 +9,7 @@ use rand::Rng; use tokio::sync::mpsc; use tokio::time::{self, MissedTickBehavior}; -use crate::atuin_client::{history::store::HistoryStore, record::sync, settings::Settings}; +use crate::aclient::{history::store::HistoryStore, record::sync, settings::Settings}; use crate::{ daemon::{Component, DaemonHandle}, @@ -41,14 +41,14 @@ enum SyncState { /// - Implements exponential backoff on sync failures /// - Responds to [`ForceSync`] events for immediate sync /// - Emits SyncCompleted/SyncFailed events -pub struct SyncComponent { +pub(crate) struct SyncComponent { task_handle: Option<tokio::task::JoinHandle<()>>, command_tx: Option<mpsc::Sender<SyncCommand>>, } impl SyncComponent { /// Create a new sync component. - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { task_handle: None, command_tx: None, diff --git a/crates/daemon/src/control/mod.rs b/crates/daemon/src/control/mod.rs index fcc2a0b8..f727a3a2 100644 --- a/crates/daemon/src/control/mod.rs +++ b/crates/daemon/src/control/mod.rs @@ -7,15 +7,13 @@ use tonic::{Request, Response, Status}; use tracing::{Level, info, instrument}; use crate::{ - atuin_client::history::HistoryId, - atuin_daemon::{ - daemon::DaemonHandle, - events::DaemonEvent, - generated::control::{ - SendEventRequest, SendEventResponse, - control_server::{Control, ControlServer}, - send_event_request::Event, - }, + aclient::history::HistoryId, + daemon::DaemonHandle, + events::DaemonEvent, + generated::control::{ + SendEventRequest, SendEventResponse, + control_server::{Control, ControlServer}, + send_event_request::Event, }, }; @@ -23,18 +21,18 @@ use crate::{ /// /// This service is used by external processes to inject events into the daemon. /// It's not a component - it's part of the daemon's core infrastructure. -pub struct ControlService { +pub(crate) struct ControlService { handle: DaemonHandle, } impl ControlService { /// Create a new control service with the given daemon handle. - pub fn new(handle: DaemonHandle) -> Self { + pub(crate) fn new(handle: DaemonHandle) -> Self { Self { handle } } /// Get a tonic server for this service. - pub fn into_server(self) -> ControlServer<Self> { + pub(crate) fn into_server(self) -> ControlServer<Self> { ControlServer::new(self) } } diff --git a/crates/daemon/src/daemon.rs b/crates/daemon/src/daemon.rs index 8f0a5957..4e691be2 100644 --- a/crates/daemon/src/daemon.rs +++ b/crates/daemon/src/daemon.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use crate::atuin_client::{ +use crate::aclient::{ database::ClientSqlite as HistoryDatabase, encryption, record::sqlite_store::SqliteStore, settings::Settings, }; @@ -27,7 +27,7 @@ use crate::events::DaemonEvent; /// /// This contains all the resources that components and services need access to. /// The state is wrapped in an `Arc` and accessed via [`DaemonHandle`]. -pub struct DaemonState { +pub(crate) struct DaemonState { // Event bus event_tx: broadcast::Sender<DaemonEvent>, @@ -72,7 +72,7 @@ pub struct DaemonState { /// let history = handle.history_db().load(id).await?; /// ``` #[derive(Clone)] -pub struct DaemonHandle { +pub(crate) struct DaemonHandle { state: Arc<DaemonState>, } @@ -83,7 +83,7 @@ impl DaemonHandle { /// /// This is fire-and-forget - if no receivers are listening (which shouldn't /// happen in normal operation), the event is dropped silently. - pub fn emit(&self, event: DaemonEvent) { + pub(crate) fn emit(&self, event: DaemonEvent) { if let Err(e) = self.state.event_tx.send(event) { tracing::warn!("failed to emit event (no receivers?): {e}"); } @@ -94,12 +94,12 @@ impl DaemonHandle { /// Returns a receiver that will receive all events emitted after this call. /// Useful for components that need to listen for events outside of the /// normal `handle_event` callback flow. - pub fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> { + pub(crate) fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> { self.state.event_tx.subscribe() } /// Request graceful shutdown of the daemon. - pub fn shutdown(&self) { + pub(crate) fn shutdown(&self) { self.emit(DaemonEvent::ShutdownRequested); } @@ -109,7 +109,7 @@ impl DaemonHandle { /// /// This acquires a read lock on the settings. For most use cases, clone /// the settings if you need to hold onto them. - pub async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> { + pub(crate) async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> { self.state.settings.read().await } @@ -117,26 +117,26 @@ impl DaemonHandle { /// /// Use this when settings have already been loaded (e.g., from a file watcher) /// to avoid parsing the config file twice. - pub async fn apply_settings(&self, settings: Settings) { + pub(crate) async fn apply_settings(&self, settings: Settings) { *self.state.settings.write().await = settings; self.emit(DaemonEvent::SettingsReloaded); tracing::info!("settings applied"); } /// Get the encryption key. - pub fn encryption_key(&self) -> &[u8; 32] { + pub(crate) fn encryption_key(&self) -> &[u8; 32] { &self.state.encryption_key } // ---- Database ---- /// Get a reference to the history database. - pub fn history_db(&self) -> &HistoryDatabase { + pub(crate) fn history_db(&self) -> &HistoryDatabase { &self.state.history_db } /// Get a reference to the record store. - pub fn store(&self) -> &SqliteStore { + pub(crate) fn store(&self) -> &SqliteStore { &self.state.store } } @@ -171,7 +171,7 @@ impl std::fmt::Debug for DaemonHandle { /// # Example /// /// ```ignore -/// pub struct MyComponent { +/// pub(crate) struct MyComponent { /// handle: Option<DaemonHandle>, /// } /// @@ -203,7 +203,7 @@ impl std::fmt::Debug for DaemonHandle { /// } /// ``` #[tonic::async_trait] -pub trait Component: Send + Sync { +pub(crate) trait Component: Send + Sync { /// Human-readable name for logging and debugging. fn name(&self) -> &'static str; @@ -247,21 +247,21 @@ pub trait Component: Send + Sync { /// /// Events emitted during handling are queued and processed in subsequent /// iterations, ensuring the loop eventually drains. -pub struct Daemon { +pub(crate) struct Daemon { components: Vec<Box<dyn Component>>, handle: DaemonHandle, } impl Daemon { /// Create a new daemon builder. - pub fn builder(settings: Settings) -> DaemonBuilder { + pub(crate) fn builder(settings: Settings) -> DaemonBuilder { DaemonBuilder::new(settings) } /// Get a clone of the daemon handle. /// /// The handle can be used to emit events, access settings, etc. - pub fn handle(&self) -> DaemonHandle { + pub(crate) fn handle(&self) -> DaemonHandle { self.handle.clone() } @@ -269,7 +269,7 @@ impl Daemon { /// /// This must be called before `run_event_loop()`. It initializes all /// registered components with the daemon handle. - pub async fn start_components(&mut self) -> Result<()> { + pub(crate) async fn start_components(&mut self) -> Result<()> { for component in &mut self.components { tracing::info!(component = component.name(), "starting component"); component @@ -284,7 +284,7 @@ impl Daemon { /// /// This processes events until a [`ShutdownRequested`] event is received. /// Components must be started first via `start_components()`. - pub async fn run_event_loop(&mut self) -> Result<()> { + pub(crate) async fn run_event_loop(&mut self) -> Result<()> { let mut event_rx = self.handle.subscribe(); loop { match event_rx.recv().await { @@ -314,7 +314,7 @@ impl Daemon { /// Stop all components. /// /// This performs graceful shutdown of all components. - pub async fn stop_components(&mut self) { + pub(crate) async fn stop_components(&mut self) { for component in &mut self.components { tracing::info!(component = component.name(), "stopping component"); if let Err(e) = component.stop().await { @@ -361,7 +361,7 @@ impl Daemon { /// /// daemon.run().await?; /// ``` -pub struct DaemonBuilder { +pub(crate) struct DaemonBuilder { settings: Settings, store: Option<SqliteStore>, history_db: Option<HistoryDatabase>, @@ -370,7 +370,7 @@ pub struct DaemonBuilder { impl DaemonBuilder { /// Create a new daemon builder with the given settings. - pub fn new(settings: Settings) -> Self { + pub(crate) fn new(settings: Settings) -> Self { Self { settings, store: None, @@ -380,13 +380,13 @@ impl DaemonBuilder { } /// Set the record store. - pub fn store(mut self, store: SqliteStore) -> Self { + pub(crate) fn store(mut self, store: SqliteStore) -> Self { self.store = Some(store); self } /// Set the history database. - pub fn history_db(mut self, db: HistoryDatabase) -> Self { + pub(crate) fn history_db(mut self, db: HistoryDatabase) -> Self { self.history_db = Some(db); self } @@ -394,7 +394,7 @@ impl DaemonBuilder { /// Register a component. /// /// Components are started in registration order and stopped in reverse order. - pub fn component(mut self, component: impl Component + 'static) -> Self { + pub(crate) fn component(mut self, component: impl Component + 'static) -> Self { self.components.push(Box::new(component)); self } @@ -402,7 +402,7 @@ impl DaemonBuilder { /// Build the daemon. /// /// This loads the encryption key and creates the daemon state. - pub fn build(self) -> Result<Daemon> { + pub(crate) fn build(self) -> Result<Daemon> { let store = self.store.ok_or_else(|| eyre::eyre!("store is required"))?; let history_db = self .history_db diff --git a/crates/daemon/src/events.rs b/crates/daemon/src/events.rs index 32ed1ff1..092e5f32 100644 --- a/crates/daemon/src/events.rs +++ b/crates/daemon/src/events.rs @@ -7,7 +7,7 @@ //! External processes (like CLI commands) can also inject events via the //! Control gRPC service. -use crate::atuin_client::history::{History, HistoryId}; +use crate::aclient::history::{History, HistoryId}; use turtle_common::record::RecordId; /// Events that flow through the daemon's event bus. diff --git a/crates/daemon/src/generated.rs b/crates/daemon/src/generated.rs index 6620e94c..9deb4e0c 100644 --- a/crates/daemon/src/generated.rs +++ b/crates/daemon/src/generated.rs @@ -11,22 +11,14 @@ )] /// Semantic command capture gRPC service types. -pub mod semantic { +pub(crate) mod semantic { tonic::include_proto!("semantic"); } -/// Search module for the daemon gRPC search service. -/// -/// This module provides fuzzy search over command history using Nucleo. -pub mod search { - // Include the generated proto code - tonic::include_proto!("search"); -} - /// History module for the daemon gRPC history service. /// /// This module contains the proto-generated types for the history gRPC service. -pub mod history { +pub(crate) mod history { // Include the generated proto code tonic::include_proto!("history"); } @@ -35,10 +27,10 @@ pub mod history { /// /// This module provides the gRPC service that allows external processes /// (like CLI commands) to inject events into the daemon's event bus. -pub mod control { +pub(crate) mod control { // Include the generated proto code tonic::include_proto!("control"); // Re-export the service - pub use crate::control::ControlService; + pub(crate) use crate::control::ControlService; } diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 1abf0314..a5d233c6 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -1,27 +1,25 @@ -use crate::atuin_client::database::ClientSqlite as HistoryDatabase; -use crate::atuin_client::record::sqlite_store::SqliteStore; -use crate::atuin_client::settings::{Settings, watcher::global_settings_watcher}; +use crate::aclient::database::ClientSqlite as HistoryDatabase; +use crate::aclient::record::sqlite_store::SqliteStore; +use crate::aclient::settings::{Settings, watcher::global_settings_watcher}; use eyre::Result; +pub mod aclient; pub mod client; -pub mod components; -pub mod control; -pub mod daemon; -pub mod events; -pub mod search; -pub mod server; -pub mod generated; +pub(crate) mod components; +pub(crate) mod control; +pub(crate) mod daemon; +pub(crate) mod events; +pub(crate) mod server; + +pub(crate) mod generated; // Re-export core daemon types for convenience -pub use daemon::Daemon; +pub(crate) use daemon::Daemon; pub use events::DaemonEvent; // Re-export components -pub use components::{HistoryComponent, SearchComponent, SemanticComponent, SyncComponent}; - -// Re-export client helpers -pub use client::SemanticClient; +pub(crate) use components::{HistoryComponent, SemanticComponent, SyncComponent}; /// Boot the daemon using the new component-based architecture. /// @@ -34,14 +32,12 @@ pub async fn boot( ) -> Result<()> { // Create the components let history_component = HistoryComponent::new(); - let search_component = SearchComponent::new(); let semantic_component = SemanticComponent::new(); let sync_component = SyncComponent::new(); // Get the gRPC services before moving components into the daemon // (The services share state with the components via Arc) let history_service = history_component.grpc_service(); - let search_service = search_component.grpc_service(); let semantic_service = semantic_component.grpc_service(); // Build the daemon @@ -49,7 +45,6 @@ pub async fn boot( .store(store) .history_db(history_db) .component(history_component) - .component(search_component) .component(semantic_component) .component(sync_component) .build()?; @@ -94,7 +89,6 @@ pub async fn boot( server::run_grpc_server( &settings, history_service, - search_service, semantic_service, control_service.into_server(), handle, diff --git a/crates/client/src/command/client/daemon.rs b/crates/daemon/src/main.rs index 39aa1b1e..26a5cafd 100644 --- a/crates/client/src/command/client/daemon.rs +++ b/crates/daemon/src/main.rs @@ -1,3 +1,7 @@ +#[allow(unused_imports)] +use clap::Parser; +use eyre::{Result, WrapErr, bail, eyre}; +use fs4::fs_std::FileExt; use std::fs::{self, File, OpenOptions}; use std::io::{ErrorKind, Write}; #[cfg(unix)] @@ -5,30 +9,19 @@ use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; - -use crate::atuin_client::{ - database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings, -}; -use crate::atuin_daemon::DaemonEvent; -use crate::atuin_daemon::client::{ - ControlClient, DaemonClientErrorKind, HistoryClient, classify_error, -}; -use clap::Subcommand; -#[cfg(unix)] -use daemonize::Daemonize; -use eyre::{Result, WrapErr, bail, eyre}; -use fs4::fs_std::FileExt; use tokio::time::sleep; +use turtle_daemon::{ + DaemonEvent, + aclient::{ + database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, + settings::Settings, + }, + client::{ControlClient, DaemonClientErrorKind, HistoryClient, classify_error}, +}; -#[derive(clap::Args, Debug)] -pub(crate) struct Cmd { - #[command(subcommand)] - subcmd: SubCmd, -} - -#[derive(Subcommand, Debug)] +#[derive(Parser, Debug)] #[command(infer_subcommands = true)] -pub(crate) enum SubCmd { +pub(crate) enum Cmd { /// Start the daemon server Start { #[arg(long, hide = true)] @@ -37,10 +30,6 @@ pub(crate) enum SubCmd { /// Also write daemon logs to the console (useful for debugging) #[arg(long)] show_logs: bool, - - /// Force start: kill existing daemon process and reset the socket - #[arg(long)] - force: bool, }, /// Show the daemon's current status @@ -48,45 +37,35 @@ pub(crate) enum SubCmd { /// Stop the daemon gracefully Stop, - - /// Restart the daemon (stop, then start in background) - Restart, } impl Cmd { - /// Returns `true` when the process should daemonize before creating the - /// async runtime or opening any database connections. - #[cfg(unix)] - pub(crate) fn should_daemonize(&self) -> bool { - match &self.subcmd { - SubCmd::Start { daemonize, .. } => *daemonize, - _ => false, - } - } - - /// Returns `true` when logs should also be written to the console. - pub(crate) fn show_logs(&self) -> bool { - match &self.subcmd { - SubCmd::Start { show_logs, .. } => *show_logs, - _ => false, - } - } - pub(crate) async fn run( self, settings: Settings, store: SqliteStore, history_db: ClientSqlite, ) -> Result<()> { - match self.subcmd { - SubCmd::Start { force, .. } => run(settings, store, history_db, force).await, - SubCmd::Status => status_cmd(&settings).await, - SubCmd::Stop => stop_cmd(&settings).await, - SubCmd::Restart => restart_cmd(&settings).await, + match self { + Cmd::Start { .. } => run(settings, store, history_db).await, + Cmd::Status => status_cmd(&settings).await, + Cmd::Stop => stop_cmd(&settings).await, } } } +#[tokio::main] +async fn main() -> Result<()> { + let settings = Settings::new().wrap_err("could not load client settings")?; + let db_path = PathBuf::from(settings.db_path.as_str()); + let record_store_path = PathBuf::from(settings.record_store_path.as_str()); + + let db = ClientSqlite::new(db_path, settings.local_timeout).await?; + let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; + + Cmd::parse().run(settings, sqlite_store, db).await +} + const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); const DAEMON_PROTOCOL_VERSION: u32 = 1; const STARTUP_POLL: Duration = Duration::from_millis(40); @@ -221,87 +200,10 @@ async fn request_shutdown(settings: &Settings) { } } -fn spawn_daemon_process() -> Result<()> { - let exe = std::env::current_exe().wrap_err("could not locate atuin executable")?; - - let mut cmd = Command::new(exe); - cmd.arg("daemon") - .arg("start") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - #[cfg(unix)] - cmd.arg("--daemonize"); - - cmd.spawn().wrap_err("failed to spawn daemon process")?; - - Ok(()) -} - fn startup_timeout(settings: &Settings) -> Duration { Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0) } -#[cfg(unix)] -fn remove_stale_socket_if_present(settings: &Settings) -> Result<()> { - if settings.daemon.systemd_socket { - return Ok(()); - } - - let socket_path = Path::new(&settings.daemon.socket_path); - if !socket_path.exists() { - return Ok(()); - } - - match StdUnixStream::connect(socket_path) { - Ok(stream) => { - drop(stream); - Ok(()) - } - Err(err) if err.kind() == ErrorKind::ConnectionRefused => { - fs::remove_file(socket_path).wrap_err_with(|| { - format!( - "failed to remove stale daemon socket {}", - socket_path.display() - ) - })?; - Ok(()) - } - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(_) => Ok(()), - } -} - -async fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result<HistoryClient> { - let start = Instant::now(); - let mut last_error = eyre!("daemon did not become ready"); - - loop { - match probe(settings).await { - Probe::Ready(client) => return Ok(client), - Probe::NeedsRestart(reason) => { - last_error = eyre!(reason); - } - Probe::Unreachable(err) => { - if is_legacy_daemon_error(&err) { - return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)); - } - last_error = err; - } - } - - if start.elapsed() >= timeout { - return Err(last_error.wrap_err(format!( - "timed out waiting for daemon startup after {}ms", - timeout.as_millis() - ))); - } - - sleep(STARTUP_POLL).await; - } -} - pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> { match async { connect_client(settings) @@ -429,123 +331,15 @@ async fn stop_cmd(settings: &Settings) -> Result<()> { } } -async fn restart_cmd(settings: &Settings) -> Result<()> { - // Stop if running - match probe(settings).await { - Probe::Ready(_) | Probe::NeedsRestart(_) => { - request_shutdown(settings).await; - println!("Stopping daemon..."); - - let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); - let timeout = Duration::from_secs(5); - wait_for_pidfile_available(&pidfile_path, timeout) - .await - .wrap_err("Timed out waiting for old daemon to stop")?; - } - Probe::Unreachable(_) => { - println!("No daemon running"); - } - } - - #[cfg(unix)] - remove_stale_socket_if_present(settings)?; - - spawn_daemon_process()?; - println!("Starting daemon..."); - - let timeout = startup_timeout(settings); - let status = wait_until_ready(settings, timeout).await?.status().await?; - - println!("Daemon restarted"); - println!(" PID: {}", status.pid); - println!(" Version: {}", status.version); - - Ok(()) -} - -/// Daemonize the current process. Must be called before creating the tokio -/// runtime or opening database connections, since `fork()` inside an async -/// runtime corrupts its internal state. -#[cfg(unix)] -pub(crate) fn daemonize_current_process() -> Result<()> { - let cwd = - std::env::current_dir().wrap_err("could not determine current directory for daemon")?; - - Daemonize::new() - .working_directory(cwd) - .start() - .wrap_err("failed to daemonize process")?; - - Ok(()) -} - -async fn run( - settings: Settings, - store: SqliteStore, - history_db: ClientSqlite, - force: bool, -) -> Result<()> { - if force { - force_cleanup(&settings); - } - +async fn run(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; - crate::atuin_daemon::boot(settings, store, history_db).await?; + turtle_daemon::boot(settings, store, history_db).await?; Ok(()) } -/// Force cleanup: kill existing daemon process and remove socket. -fn force_cleanup(settings: &Settings) { - let pidfile_path = Path::new(&settings.daemon.pidfile_path); - - // Read and kill the existing process if pidfile exists - if pidfile_path.exists() { - if let Ok(contents) = fs::read_to_string(pidfile_path) - && let Some(pid_str) = contents.lines().next() - && let Ok(pid) = pid_str.parse::<u32>() - { - kill_process(pid); - // Give it a moment to release resources - std::thread::sleep(Duration::from_millis(100)); - } - - // Remove the pidfile - if let Err(e) = fs::remove_file(pidfile_path) - && e.kind() != ErrorKind::NotFound - { - tracing::warn!("failed to remove pidfile: {e}"); - } - } - - // Remove the socket file - #[cfg(unix)] - { - let socket_path = Path::new(&settings.daemon.socket_path); - if socket_path.exists() - && let Err(e) = fs::remove_file(socket_path) - && e.kind() != ErrorKind::NotFound - { - tracing::warn!("failed to remove socket: {e}"); - } - } -} - -/// Kill a process by PID. -#[cfg(unix)] -fn kill_process(pid: u32) { - // Use kill command to send SIGTERM for graceful shutdown - drop( - Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(), - ); -} - #[cfg(test)] mod tests { use super::{ diff --git a/crates/daemon/src/search/mod.rs b/crates/daemon/src/search/mod.rs deleted file mode 100644 index b4d03bcd..00000000 --- a/crates/daemon/src/search/mod.rs +++ /dev/null @@ -1,557 +0,0 @@ -//! Search index with frecency-based ranking. -//! -//! This module provides a deduplicated search index where each unique command -//! is stored once, with metadata about all its invocations. This enables: -//! -//! - Efficient fuzzy matching (fewer items to match) -//! - Frecency-based ranking (frequency + recency) -//! - Dynamic filtering by directory, host, session, etc. - -use std::{ - collections::{HashMap, HashSet}, - sync::Arc, -}; - -use crate::atuin_client::settings::Search; -use crate::{ - atuin_client::history::History, atuin_daemon::components::search::with_trailing_slash, -}; -use atuin_nucleo::{Injector, Nucleo, pattern}; -use dashmap::DashMap; -use lasso::{Spur, ThreadedRodeo}; -use time::OffsetDateTime; -use tokio::sync::RwLock; -use tracing::{Level, instrument}; -use uuid::Uuid; - -/// Parse a UUID string into a 16-byte array. -/// Returns None if the string is not a valid UUID. -fn parse_uuid_bytes(s: &str) -> Option<[u8; 16]> { - Uuid::parse_str(s).ok().map(|u| *u.as_bytes()) -} - -/// Format a 16-byte array as a UUID string. -fn format_uuid_bytes(bytes: &[u8; 16]) -> String { - Uuid::from_bytes(*bytes).to_string() -} - -/// Pre-computed frecency data for O(1) lookup. -#[derive(Debug, Clone, Default)] -pub struct FrecencyData { - /// Total number of times this command was used. - pub count: u32, - /// Most recent usage timestamp (unix seconds). - pub last_used: i64, -} - -impl FrecencyData { - /// Record a new usage of this command. - pub fn record_use(&mut self, timestamp: i64) { - self.count += 1; - if timestamp > self.last_used { - self.last_used = timestamp; - } - } - - /// Compute frecency score based on count and recency. - /// - /// Uses a decay function where more recent commands score higher. - /// The formula balances frequency (how often) with recency (how recent). - /// - /// Multipliers allow tuning the relative weights: - /// - `recency_mul`: Multiplier for recency score (default: 1.0) - /// - `frequency_mul`: Multiplier for frequency score (default: 1.0) - /// - /// A multiplier of 0.0 disables that component, 1.0 is unchanged, 2.0 doubles weight. - /// Values like 0.5 reduce weight by half, 1.5 increases by 50%, etc. - #[instrument(level = Level::TRACE, name = "index_frecency_compute")] - pub fn compute(&self, now: i64, recency_mul: f64, frequency_mul: f64) -> u32 { - if self.count == 0 { - return 0; - } - - // Time-based decay: score decreases as time passes - let age_seconds = (now - self.last_used).max(0) as u64; - let age_hours = age_seconds / 3600; - - // Decay factor: recent commands get higher scores - // - Last hour: multiplier ~1.0 - // - Last day: multiplier ~0.5 - // - Last week: multiplier ~0.1 - // - Older: multiplier approaches 0 - let recency_score: f64 = match age_hours { - 0 => 100.0, - 1..=6 => 90.0, - 7..=24 => 70.0, - 25..=72 => 50.0, - 73..=168 => 30.0, - 169..=720 => 15.0, - _ => 5.0, - }; - - // Frequency boost: more uses = higher score (with diminishing returns) - let frequency_score = (f64::from(self.count).ln() * 20.0).min(100.0); - - // Apply multipliers and combine scores, then round to u32 - recency_score - .mul_add(recency_mul, frequency_score * frequency_mul) - .round() as u32 - } -} - -/// Data for a unique command. -pub struct CommandData { - /// History ID of the most recent invocation (16-byte UUID). - most_recent_id: [u8; 16], - /// Timestamp of the most recent invocation. - most_recent_timestamp: i64, - /// Pre-computed global frecency. - pub global_frecency: FrecencyData, - - // Pre-computed indexes for O(1) filter lookups - // Using HashSet instead of DashSet since CommandData lives inside DashMap (already synchronized) - /// All directories where this command has been run (interned keys). - directories: HashSet<Spur>, - /// All hostnames where this command has been run (interned keys). - hosts: HashSet<Spur>, - /// All sessions where this command has been run (as 16-byte UUIDs). - sessions: HashSet<[u8; 16]>, -} - -impl CommandData { - /// Create a new [`CommandData`] from a history entry. - /// Returns None if the history entry has invalid UUIDs. - pub fn new(history: &History, interner: &ThreadedRodeo) -> Option<Self> { - let history_id = parse_uuid_bytes(&history.id.0)?; - let session = parse_uuid_bytes(&history.session)?; - let timestamp = history.timestamp.unix_timestamp(); - - let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); - let host_key = interner.get_or_intern(&history.hostname); - - let mut directories = HashSet::new(); - directories.insert(dir_key); - - let mut hosts = HashSet::new(); - hosts.insert(host_key); - - let mut sessions = HashSet::new(); - sessions.insert(session); - - let mut global_frecency = FrecencyData::default(); - global_frecency.record_use(timestamp); - - Some(Self { - most_recent_id: history_id, - most_recent_timestamp: timestamp, - global_frecency, - directories, - hosts, - sessions, - }) - } - - /// Add an invocation from a history entry. - /// Returns false if the history entry has invalid UUIDs. - pub fn add_invocation(&mut self, history: &History, interner: &ThreadedRodeo) -> bool { - let Some(history_id) = parse_uuid_bytes(&history.id.0) else { - return false; - }; - let Some(session) = parse_uuid_bytes(&history.session) else { - return false; - }; - - let timestamp = history.timestamp.unix_timestamp(); - - // Update global frecency - self.global_frecency.record_use(timestamp); - - // Update pre-computed indexes for O(1) filter lookups - let dir_key = interner.get_or_intern(with_trailing_slash(&history.cwd)); - self.directories.insert(dir_key); - self.hosts.insert(interner.get_or_intern(&history.hostname)); - self.sessions.insert(session); - - // Update most recent if this invocation is newer - if timestamp > self.most_recent_timestamp { - self.most_recent_id = history_id; - self.most_recent_timestamp = timestamp; - } - - true - } - - /// Get the most recent history ID for this command. - pub fn most_recent_id(&self) -> String { - format_uuid_bytes(&self.most_recent_id) - } - - /// Check if any invocation matches a directory filter (exact match). - /// O(1) lookup using pre-computed index. - pub fn has_invocation_in_dir(&self, dir: &str, interner: &ThreadedRodeo) -> bool { - interner - .get(dir) - .is_some_and(|spur| self.directories.contains(&spur)) - } - - /// Check if any invocation matches a directory prefix (workspace/git root). - /// O(n) where n = number of unique directories for this command. - pub fn has_invocation_in_workspace( - &self, - prefix: &str, - interner: &ThreadedRodeo, - ) -> bool { - self.directories - .iter() - .any(|&spur| interner.resolve(&spur).starts_with(prefix)) - } - - /// Check if any invocation matches a hostname. - /// O(1) lookup using pre-computed index. - pub fn has_invocation_on_host(&self, hostname: &str, interner: &ThreadedRodeo) -> bool { - interner - .get(hostname) - .is_some_and(|spur| self.hosts.contains(&spur)) - } - - /// Check if any invocation matches a session. - /// O(1) lookup using pre-computed index. - pub fn has_invocation_in_session(&self, session: &str) -> bool { - parse_uuid_bytes(session).is_some_and(|bytes| self.sessions.contains(&bytes)) - } -} - -/// Filter mode for search queries. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum IndexFilterMode { - /// No filtering - search all commands. - Global, - /// Filter to commands run in a specific directory. - Directory(String), - /// Filter to commands run in a workspace (directory prefix). - Workspace(String), - /// Filter to commands run on a specific host. - Host(String), - /// Filter to commands run in a specific session. - Session(String), -} - -/// Context for search queries. -#[derive(Debug, Clone, Default)] -pub struct QueryContext { - #[expect(dead_code)] - pub cwd: Option<String>, - #[expect(dead_code)] - pub git_root: Option<String>, - #[expect(dead_code)] - pub hostname: Option<String>, - #[expect(dead_code)] - pub session_id: Option<String>, -} - -/// Shareable frecency map: command -> frecency score. -/// Wrapped in Arc for zero-copy sharing with scorer callbacks. -type FrecencyMap = Arc<HashMap<Arc<str>, u32>>; - -/// A deduplicated search index with frecency-based ranking. -/// -/// Commands are stored by their text, with metadata about all invocations. -/// Nucleo handles fuzzy matching, while frecency is computed via scorer callback. -/// -/// Global frecency is precomputed by a background task and used for scoring. -/// If frecency data is not available, search still works but without frecency ranking; -/// although this should never happen due to precomputing the frecency map. -pub struct SearchIndex { - /// Map from command text to command data. - /// Using `DashMap` for concurrent read/write access, wrapped in Arc for sharing with scorer. - /// Keys are Arc<str> to enable zero-copy sharing with `frecency_map`. - commands: Arc<DashMap<Arc<str>, CommandData>>, - /// Nucleo fuzzy matcher - items are command strings. - nucleo: RwLock<Nucleo<String>>, - /// Injector for adding new commands to Nucleo. - injector: Injector<String>, - /// Precomputed global frecency map. Updated by background task. - frecency_map: RwLock<Option<FrecencyMap>>, - /// String interner for deduplicating cwd, hostname, and directory paths. - interner: Arc<ThreadedRodeo>, -} - -impl SearchIndex { - /// Create a new empty search index. - pub fn new() -> Self { - let nucleo_config = atuin_nucleo::Config::DEFAULT; - // Single column for command text - let nucleo = Nucleo::<String>::new(nucleo_config, Arc::new(|| {}), None, 1); - let injector = nucleo.injector(); - - Self { - commands: Arc::new(DashMap::new()), - nucleo: RwLock::new(nucleo), - injector, - frecency_map: RwLock::new(None), - interner: Arc::new(ThreadedRodeo::new()), - } - } - - /// Add a history entry to the index. - /// - /// If the command already exists, updates its invocation data. - /// If it's a new command, adds it to both the map and Nucleo. - pub fn add_history(&self, history: &History) { - let command = history.command.as_str(); - - // DashMap with Arc<str> keys can be looked up with &str via Borrow trait - if let Some(mut entry) = self.commands.get_mut(command) { - // Existing command - just update invocations - entry.add_invocation(history, &self.interner); - } else { - // New command - create Arc<str> once and share it - let Some(data) = CommandData::new(history, &self.interner) else { - return; // Invalid UUIDs, skip this entry - }; - let command_arc: Arc<str> = command.into(); - self.commands.insert(Arc::clone(&command_arc), data); - // Nucleo still needs String (unavoidable copy for fuzzy matching) - self.injector.push(command_arc.to_string(), |cmd, cols| { - cols[0] = cmd.clone().into(); - }); - } - // Note: frecency_map is rebuilt by background task, not invalidated here - } - - /// Add multiple history entries to the index. - pub fn add_histories(&self, histories: &[History]) { - for history in histories { - self.add_history(history); - } - } - - /// Get the number of unique commands in the index. - pub fn command_count(&self) -> usize { - self.commands.len() - } - - /// Search for commands matching a query. - /// - /// Returns a list of history IDs (most recent invocation per command). - /// Uses precomputed global frecency for scoring if available. - #[instrument(skip_all, level = Level::TRACE, name = "index_search", fields(query = %query))] - #[expect( - clippy::significant_drop_tightening, - reason = "The nucleo early drop is a false-positive" - )] - pub async fn search( - &self, - query: &str, - filter_mode: IndexFilterMode, - // TODO(@bpeetz): Use the query context here <2026-06-12> - #[expect(unused)] context: &QueryContext, - limit: u32, - ) -> Vec<String> { - let mut nucleo = self.nucleo.write().await; - - // Get precomputed frecency map (may be None if not yet computed) - let frecency_map = self.frecency_map.read().await.clone(); - - // Build filter based on mode - let filter = self.build_filter(&filter_mode); - nucleo.set_filter(filter); - - // Build scorer from precomputed frecency (or None if not available) - let scorer = Self::build_scorer(frecency_map); - nucleo.set_scorer(scorer); - - // Update pattern - nucleo.pattern.reparse( - 0, - query, - pattern::CaseMatching::Smart, - pattern::Normalization::Smart, - false, - ); - - tracing::span!(Level::TRACE, "index_search_tick").in_scope(|| { - // Tick until complete - while nucleo.tick(10).running {} - }); - - // Collect results - let snapshot = nucleo.snapshot(); - let matched_count = snapshot.matched_item_count().min(limit); - - tracing::span!(Level::TRACE, "index_search_results").in_scope(|| { - snapshot - .matched_items(..matched_count) - .filter_map(|item| { - let cmd = item.data; - // DashMap<Arc<str>, _>::get accepts &str via Borrow trait - self.commands - .get(cmd.as_str()) - .map(|data| data.most_recent_id()) - }) - .collect() - }) - } - - /// Rebuild the global frecency map. - /// - /// This should be called by a background task periodically. - /// The map is used for scoring search results. - /// - /// Uses multipliers from search settings: - /// - `recency_score_multiplier`: Weight for recency component - /// - `frequency_score_multiplier`: Weight for frequency component - /// - `frecency_score_multiplier`: Overall multiplier for final score - #[instrument(skip_all, level = Level::DEBUG, name = "rebuild_frecency")] - pub async fn rebuild_frecency(&self, search_settings: &Search) { - let now = OffsetDateTime::now_utc().unix_timestamp(); - let mut frecency_map: HashMap<Arc<str>, u32> = HashMap::new(); - - // Clamp multipliers to non-negative values to prevent broken frecency ranking - // (negative values would produce unexpected results when cast to u32) - let recency_mul = search_settings.recency_score_multiplier.max(0.0); - let frequency_mul = search_settings.frequency_score_multiplier.max(0.0); - let frecency_mul = search_settings.frecency_score_multiplier.max(0.0); - - for entry in self.commands.iter() { - let frecency = entry - .global_frecency - .compute(now, recency_mul, frequency_mul); - // Apply overall frecency multiplier and round to u32 - let frecency = (f64::from(frecency) * frecency_mul).round() as u32; - // Arc::clone is cheap - just increments reference count - frecency_map.insert(Arc::clone(entry.key()), frecency); - } - - *self.frecency_map.write().await = Some(Arc::new(frecency_map)); - } - - /// Build filter predicate for the given mode. - fn build_filter(&self, mode: &IndexFilterMode) -> Option<atuin_nucleo::Filter<String>> { - // For Global mode, no filter needed - if matches!(mode, IndexFilterMode::Global) { - return None; - } - - // Pre-compute which commands pass the filter - // Use HashSet<String> for the short-lived filter (simpler than Arc lookup) - let passing_commands: Arc<HashSet<String>> = { - let mut set = HashSet::new(); - for entry in self.commands.iter() { - let passes = match mode { - IndexFilterMode::Global => unreachable!(), - IndexFilterMode::Directory(dir) => { - entry.has_invocation_in_dir(dir, &self.interner) - } - IndexFilterMode::Workspace(prefix) => { - entry.has_invocation_in_workspace(prefix, &self.interner) - } - IndexFilterMode::Host(hostname) => { - entry.has_invocation_on_host(hostname, &self.interner) - } - IndexFilterMode::Session(session) => entry.has_invocation_in_session(session), - }; - if passes { - // Convert Arc<str> to String for filter lookup - set.insert(entry.key().to_string()); - } - } - Arc::new(set) - }; - - Some(Arc::new(move |cmd: &String| passing_commands.contains(cmd))) - } - - /// Build scorer from precomputed frecency map. - /// - /// Returns None if frecency map is not available (search still works, just without frecency ranking). - fn build_scorer(frecency_map: Option<FrecencyMap>) -> Option<atuin_nucleo::Scorer<String>> { - let map = frecency_map?; - Some(Arc::new(move |cmd: &String, fuzzy_score: u32| { - // HashMap<Arc<str>, _>::get accepts &str via Borrow trait - let frecency = map.get(cmd.as_str()).copied().unwrap_or(0); - fuzzy_score + frecency - })) - } -} - -impl Default for SearchIndex { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::FrecencyData; - - #[test] - fn frecency_data_compute() { - let now = 1_000_000i64; - - // Recent command (with default multipliers of 1.0) - let recent = FrecencyData { - count: 5, - last_used: now - 60, // 1 minute ago - }; - assert!(recent.compute(now, 1.0, 1.0) > 100); // High score - - // Old command - let old = FrecencyData { - count: 5, - last_used: now - 86400 * 30, // 30 days ago - }; - assert!(old.compute(now, 1.0, 1.0) < recent.compute(now, 1.0, 1.0)); - - // Frequently used old command - let frequent_old = FrecencyData { - count: 100, - last_used: now - 86400 * 7, // 1 week ago - }; - // Should still have decent score due to frequency - assert!(frequent_old.compute(now, 1.0, 1.0) > 50); - } - - #[test] - fn frecency_data_compute_with_multipliers() { - let now = 1_000_000_i64; - - let data = FrecencyData { - count: 5, - last_used: now - 60, // 1 minute ago (recency_score = 100) - }; - - // Default multipliers (1.0, 1.0) - let default_score = data.compute(now, 1.0, 1.0); - - // Double recency weight - let double_recency = data.compute(now, 2.0, 1.0); - assert!(double_recency > default_score); - - // Double frequency weight - let double_frequency = data.compute(now, 1.0, 2.0); - assert!(double_frequency > default_score); - - // Zero out recency (only frequency counts) - let no_recency = data.compute(now, 0.0, 1.0); - assert!(no_recency < default_score); - - // Zero out frequency (only recency counts) - let no_frequency = data.compute(now, 1.0, 0.0); - assert!(no_frequency < default_score); - - // Zero both (should be zero) - let no_score = data.compute(now, 0.0, 0.0); - assert_eq!(no_score, 0); - - // Fractional multipliers - let half_recency = data.compute(now, 0.5, 1.0); - assert!(half_recency < default_score); - assert!(half_recency > no_recency); - - // 1.5x multiplier - let boost_recency = data.compute(now, 1.5, 1.0); - assert!(boost_recency > default_score); - assert!(boost_recency < double_recency); - } -} diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 335f8260..97c4fe48 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -1,18 +1,13 @@ use eyre::Result; use crate::{ - atuin_client::settings::Settings, - atuin_daemon::{ - components::{ - history::HistoryGrpcService, search::SearchGrpcService, semantic::SemanticGrpcService, - }, - daemon::DaemonHandle, - generated::{ - control::{ControlService, control_server::ControlServer}, - history::history_server::HistoryServer, - search::search_server::SearchServer, - semantic::semantic_server::SemanticServer, - }, + aclient::settings::Settings, + components::{history::HistoryGrpcService, semantic::SemanticGrpcService}, + daemon::DaemonHandle, + generated::{ + control::{ControlService, control_server::ControlServer}, + history::history_server::HistoryServer, + semantic::semantic_server::SemanticServer, }, }; @@ -21,10 +16,9 @@ use crate::{ /// This starts the gRPC server in the background and returns immediately. /// The server will shut down when a [`ShutdownRequested`] event is received. #[cfg(unix)] -pub fn run_grpc_server( +pub(crate) fn run_grpc_server( settings: &Settings, history_service: HistoryServer<HistoryGrpcService>, - search_service: SearchServer<SearchGrpcService>, semantic_service: SemanticServer<SemanticGrpcService>, control_service: ControlServer<ControlService>, handle: DaemonHandle, @@ -107,7 +101,6 @@ pub fn run_grpc_server( if let Err(e) = Server::builder() .add_service(history_service) - .add_service(search_service) .add_service(semantic_service) .add_service(control_service) .serve_with_incoming_shutdown(uds_stream, shutdown_signal) |
