aboutsummaryrefslogtreecommitdiffstats
path: root/crates/daemon
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-07-20 12:57:36 +0200
commit73be69cd99f5a4784fe69f5d78f423c72e837284 (patch)
treecb47246d2c7edd8cd828abb25a033b732d2289ae /crates/daemon
parentchore: Move more stuff out of atuin-client (diff)
downloadatuin-73be69cd99f5a4784fe69f5d78f423c72e837284.zip
chore: Commit
Diffstat (limited to '')
-rw-r--r--crates/daemon/Cargo.toml2
-rw-r--r--crates/daemon/build.rs1
-rw-r--r--crates/daemon/proto/search.proto35
-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.rs11
-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.rs17
-rw-r--r--crates/daemon/src/aclient/settings/mod.rs1544
-rw-r--r--crates/daemon/src/aclient/settings/watcher.rs260
-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.rs134
-rw-r--r--crates/daemon/src/components/history.rs10
-rw-r--r--crates/daemon/src/components/mod.rs15
-rw-r--r--crates/daemon/src/components/search.rs407
-rw-r--r--crates/daemon/src/components/semantic.rs10
-rw-r--r--crates/daemon/src/components/sync.rs6
-rw-r--r--crates/daemon/src/control/mod.rs22
-rw-r--r--crates/daemon/src/daemon.rs50
-rw-r--r--crates/daemon/src/events.rs2
-rw-r--r--crates/daemon/src/generated.rs16
-rw-r--r--crates/daemon/src/lib.rs32
-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.rs557
-rw-r--r--crates/daemon/src/server.rs23
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)