aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src')
-rw-r--r--crates/turtle/src/atuin_client/api_client.rs197
-rw-r--r--crates/turtle/src/atuin_client/database.rs1313
-rw-r--r--crates/turtle/src/atuin_client/encryption.rs142
-rw-r--r--crates/turtle/src/atuin_client/history.rs686
-rw-r--r--crates/turtle/src/atuin_client/history/builder.rs154
-rw-r--r--crates/turtle/src/atuin_client/history/store.rs437
-rw-r--r--crates/turtle/src/atuin_client/meta.rs182
-rw-r--r--crates/turtle/src/atuin_client/mod.rs12
-rw-r--r--crates/turtle/src/atuin_client/ordering.rs31
-rw-r--r--crates/turtle/src/atuin_client/record/encryption.rs379
-rw-r--r--crates/turtle/src/atuin_client/record/mod.rs3
-rw-r--r--crates/turtle/src/atuin_client/record/sqlite_store.rs563
-rw-r--r--crates/turtle/src/atuin_client/record/sync.rs456
-rw-r--r--crates/turtle/src/atuin_client/secrets.rs223
-rw-r--r--crates/turtle/src/atuin_client/settings.rs1543
-rw-r--r--crates/turtle/src/atuin_client/settings/meta.rs17
-rw-r--r--crates/turtle/src/atuin_client/settings/watcher.rs260
-rw-r--r--crates/turtle/src/atuin_client/theme.rs41
-rw-r--r--crates/turtle/src/atuin_client/utils.rs92
-rw-r--r--crates/turtle/src/atuin_common/api.rs22
-rw-r--r--crates/turtle/src/atuin_common/mod.rs58
-rw-r--r--crates/turtle/src/atuin_common/record.rs284
-rw-r--r--crates/turtle/src/atuin_common/shell.rs52
-rw-r--r--crates/turtle/src/atuin_common/tls.rs15
-rw-r--r--crates/turtle/src/atuin_common/utils.rs257
-rw-r--r--crates/turtle/src/atuin_daemon/client.rs346
-rw-r--r--crates/turtle/src/atuin_daemon/components/history.rs325
-rw-r--r--crates/turtle/src/atuin_daemon/components/mod.rs25
-rw-r--r--crates/turtle/src/atuin_daemon/components/search.rs407
-rw-r--r--crates/turtle/src/atuin_daemon/components/semantic.rs912
-rw-r--r--crates/turtle/src/atuin_daemon/components/sync.rs285
-rw-r--r--crates/turtle/src/atuin_daemon/control/mod.rs76
-rw-r--r--crates/turtle/src/atuin_daemon/daemon.rs436
-rw-r--r--crates/turtle/src/atuin_daemon/events.rs77
-rw-r--r--crates/turtle/src/atuin_daemon/generated.rs44
-rw-r--r--crates/turtle/src/atuin_daemon/mod.rs124
-rw-r--r--crates/turtle/src/atuin_daemon/search/mod.rs557
-rw-r--r--crates/turtle/src/atuin_daemon/server.rs121
-rw-r--r--crates/turtle/src/atuin_history/mod.rs2
-rw-r--r--crates/turtle/src/atuin_history/sort.rs46
-rw-r--r--crates/turtle/src/atuin_history/stats.rs527
-rw-r--r--crates/turtle/src/atuin_pty_proxy/capture.rs470
-rw-r--r--crates/turtle/src/atuin_pty_proxy/debug.rs53
-rw-r--r--crates/turtle/src/atuin_pty_proxy/mod.rs17
-rw-r--r--crates/turtle/src/atuin_pty_proxy/osc133.rs899
-rw-r--r--crates/turtle/src/atuin_pty_proxy/pty_proxy.rs237
-rw-r--r--crates/turtle/src/atuin_pty_proxy/runtime.rs184
-rw-r--r--crates/turtle/src/atuin_pty_proxy/screen.rs96
-rw-r--r--crates/turtle/src/atuin_server/database/db/mod.rs274
-rw-r--r--crates/turtle/src/atuin_server/database/db/wrappers.rs32
-rw-r--r--crates/turtle/src/atuin_server/database/mod.rs99
-rw-r--r--crates/turtle/src/atuin_server/database/models.rs5
-rw-r--r--crates/turtle/src/atuin_server/handlers/mod.rs54
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/mod.rs1
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/record.rs113
-rw-r--r--crates/turtle/src/atuin_server/metrics.rs55
-rw-r--r--crates/turtle/src/atuin_server/mod.rs86
-rw-r--r--crates/turtle/src/atuin_server/router.rs98
-rw-r--r--crates/turtle/src/atuin_server/settings.rs106
l---------crates/turtle/src/command/CONTRIBUTORS1
-rw-r--r--crates/turtle/src/command/client.rs327
-rw-r--r--crates/turtle/src/command/client/config.rs352
-rw-r--r--crates/turtle/src/command/client/daemon.rs583
-rw-r--r--crates/turtle/src/command/client/default_config.rs4
-rw-r--r--crates/turtle/src/command/client/history.rs1136
-rw-r--r--crates/turtle/src/command/client/info.rs34
-rw-r--r--crates/turtle/src/command/client/init.rs129
-rw-r--r--crates/turtle/src/command/client/init/bash.rs13
-rw-r--r--crates/turtle/src/command/client/init/fish.rs74
-rw-r--r--crates/turtle/src/command/client/init/powershell.rs20
-rw-r--r--crates/turtle/src/command/client/init/xonsh.rs19
-rw-r--r--crates/turtle/src/command/client/init/zsh.rs26
-rw-r--r--crates/turtle/src/command/client/search.rs366
-rw-r--r--crates/turtle/src/command/client/search/cursor.rs404
-rw-r--r--crates/turtle/src/command/client/search/duration.rs63
-rw-r--r--crates/turtle/src/command/client/search/engines.rs86
-rw-r--r--crates/turtle/src/command/client/search/engines/daemon.rs213
-rw-r--r--crates/turtle/src/command/client/search/engines/db.rs107
-rw-r--r--crates/turtle/src/command/client/search/engines/skim.rs222
-rw-r--r--crates/turtle/src/command/client/search/history_list.rs431
-rw-r--r--crates/turtle/src/command/client/search/inspector.rs414
-rw-r--r--crates/turtle/src/command/client/search/interactive.rs3024
-rw-r--r--crates/turtle/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/turtle/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/turtle/src/command/client/search/keybindings/defaults.rs1274
-rw-r--r--crates/turtle/src/command/client/search/keybindings/key.rs633
-rw-r--r--crates/turtle/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/turtle/src/command/client/search/keybindings/mod.rs14
-rw-r--r--crates/turtle/src/command/client/server.rs56
-rw-r--r--crates/turtle/src/command/client/stats.rs82
-rw-r--r--crates/turtle/src/command/client/store.rs108
-rw-r--r--crates/turtle/src/command/client/store/pull.rs95
-rw-r--r--crates/turtle/src/command/client/store/purge.rs24
-rw-r--r--crates/turtle/src/command/client/store/push.rs113
-rw-r--r--crates/turtle/src/command/client/store/rebuild.rs56
-rw-r--r--crates/turtle/src/command/client/store/rekey.rs46
-rw-r--r--crates/turtle/src/command/client/store/verify.rs24
-rw-r--r--crates/turtle/src/command/client/sync.rs120
-rw-r--r--crates/turtle/src/command/client/sync/status.rs25
-rw-r--r--crates/turtle/src/command/client/wrapped.rs327
-rw-r--r--crates/turtle/src/command/contributors.rs5
-rw-r--r--crates/turtle/src/command/gen_completions.rs84
-rw-r--r--crates/turtle/src/command/mod.rs141
-rw-r--r--crates/turtle/src/lib.rs0
-rw-r--r--crates/turtle/src/main.rs76
-rw-r--r--crates/turtle/src/print_error.rs123
-rw-r--r--crates/turtle/src/shell/.gitattributes1
-rw-r--r--crates/turtle/src/shell/atuin.bash672
-rw-r--r--crates/turtle/src/shell/atuin.fish102
-rw-r--r--crates/turtle/src/shell/atuin.nu121
-rw-r--r--crates/turtle/src/shell/atuin.ps1240
-rw-r--r--crates/turtle/src/shell/atuin.xsh86
-rw-r--r--crates/turtle/src/shell/atuin.zsh167
-rw-r--r--crates/turtle/src/sync.rs34
114 files changed, 0 insertions, 28861 deletions
diff --git a/crates/turtle/src/atuin_client/api_client.rs b/crates/turtle/src/atuin_client/api_client.rs
deleted file mode 100644
index bd5bf59e..00000000
--- a/crates/turtle/src/atuin_client/api_client.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-use std::env;
-use std::time::Duration;
-
-use eyre::{Result, bail, eyre};
-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::{
- api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},
- record::{EncryptedData, HostId, Record, RecordIdx},
- tls::ensure_crypto_provider,
-};
-
-use semver::Version;
-
-static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
-
-pub(crate) struct Client<'a> {
- sync_addr: &'a str,
- user_id: Uuid,
- inner: reqwest::Client,
-}
-
-fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> {
- let address = address.strip_suffix('/').unwrap_or(address);
-
- // `join()` expects a trailing `/` in order to join paths
- // e.g. it treats `http://host:port/subdir` as a file called `subdir`
- let address = &format!("{address}/api/v0/{user_id}/");
-
- // passing a path with a leading `/` will cause `join()` to replace the entire URL path
- let path = path.strip_prefix("/").unwrap_or(path);
-
- let url = Url::parse(address)
- .map(|url| url.join(path))?
- .map_err(|_| eyre!("invalid address"))?;
-
- Ok(url.to_string())
-}
-
-pub(crate) fn ensure_version(response: &Response) -> Result<bool> {
- let version = response.headers().get(ATUIN_HEADER_VERSION);
-
- let version = if let Some(version) = version {
- match version.to_str() {
- Ok(v) => Version::parse(v),
- Err(e) => bail!("failed to parse server version: {:?}", e),
- }
- } else {
- bail!("Server not reporting its version: it is either too old or unhealthy");
- }?;
-
- // If the client is newer than the server
- if version.major < ATUIN_VERSION.major {
- println!(
- "Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"
- );
- println!("Client: {ATUIN_CARGO_VERSION}");
- println!("Server: {version}");
-
- return Ok(false);
- }
-
- Ok(true)
-}
-
-async fn handle_resp_error(resp: Response) -> Result<Response> {
- let status = resp.status();
- let url = resp.url().to_string();
-
- if status == StatusCode::SERVICE_UNAVAILABLE {
- bail!(
- "Service unavailable: check https://status.atuin.sh (or get in touch with your host)"
- );
- }
-
- if status == StatusCode::TOO_MANY_REQUESTS {
- bail!("Rate limited; please wait before doing that again");
- }
-
- if !status.is_success() {
- if let Ok(error) = resp.json::<ErrorResponse<'_>>().await {
- let reason = error.reason;
-
- if status.is_client_error() {
- bail!("Invalid request to the service at {url}, {status} - {reason}.")
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host"
- )
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host"
- )
- }
-
- Ok(resp)
-}
-
-impl<'a> Client<'a> {
- pub(crate) fn new(
- sync_addr: &'a str,
- connect_timeout: u64,
- timeout: u64,
- user_id: Uuid,
- ) -> Result<Self> {
- ensure_crypto_provider();
- let mut headers = HeaderMap::new();
-
- // used for semver server check
- headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
-
- Ok(Client {
- user_id,
- sync_addr,
- inner: reqwest::Client::builder()
- .user_agent(APP_USER_AGENT)
- .default_headers(headers)
- .connect_timeout(Duration::new(connect_timeout, 0))
- .timeout(Duration::new(timeout, 0))
- .build()?,
- })
- }
-
- pub(crate) async fn delete_store(&self) -> Result<()> {
- let url = make_url(self.sync_addr, "/store", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.delete(url).send().await?;
-
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn post_records(&self, records: &[Record<EncryptedData>]) -> Result<()> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- debug!("uploading {} records to {url}", records.len());
-
- let resp = self.inner.post(url).json(records).send().await?;
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn next_records(
- &self,
- host: HostId,
- tag: String,
- start: RecordIdx,
- count: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- debug!("fetching record/s from host {}/{}/{}", host.0, tag, start);
-
- let url = make_url(
- self.sync_addr,
- &format!(
- "/record/next?host={}&tag={}&count={}&start={}",
- host.0, tag, count, start
- ),
- self.user_id,
- )?;
-
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- let records = resp.json::<Vec<Record<EncryptedData>>>().await?;
-
- Ok(records)
- }
-
- pub(crate) async fn record_status(&self) -> Result<RecordStatus> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- if !ensure_version(&resp)? {
- bail!("could not sync records due to version mismatch");
- }
-
- let index = resp.json().await?;
-
- debug!("got remote index {index:?}");
-
- Ok(index)
- }
-}
diff --git a/crates/turtle/src/atuin_client/database.rs b/crates/turtle/src/atuin_client/database.rs
deleted file mode 100644
index a9eb2058..00000000
--- a/crates/turtle/src/atuin_client/database.rs
+++ /dev/null
@@ -1,1313 +0,0 @@
-use std::{
- env,
- path::{Path, PathBuf},
- str::FromStr,
-};
-
-use crate::{atuin_client::utils::setup_db, atuin_common::utils};
-use fs_err::{self as fs};
-use itertools::Itertools;
-use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
-use sqlx::{
- Result, Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow, SqliteSynchronous},
-};
-use time::OffsetDateTime;
-use tracing::debug;
-use uuid::Uuid;
-
-use crate::atuin_client::{
- history::{HistoryId, HistoryStats},
- utils::get_host_user,
-};
-
-use super::{
- history::History,
- ordering,
- settings::{FilterMode, SearchMode, Settings},
-};
-
-#[derive(Clone)]
-pub(crate) struct Context {
- pub(crate) session: String,
- pub(crate) cwd: String,
- pub(crate) hostname: String,
- pub(crate) host_id: String,
- pub(crate) git_root: Option<PathBuf>,
-}
-
-#[derive(Default, Clone)]
-pub(crate) struct OptFilters {
- pub(crate) exit: Option<i64>,
- pub(crate) exclude_exit: Option<i64>,
- pub(crate) cwd: Option<String>,
- pub(crate) exclude_cwd: Option<String>,
- pub(crate) before: Option<String>,
- pub(crate) after: Option<String>,
- pub(crate) limit: Option<i64>,
- pub(crate) offset: Option<i64>,
- pub(crate) reverse: bool,
- pub(crate) include_duplicates: bool,
-}
-
-pub(crate) async fn current_context() -> eyre::Result<Context> {
- let session = env::var("ATUIN_SESSION").map_err(|_| {
- eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.")
- })?;
- let hostname = get_host_user();
- let cwd = utils::get_current_dir();
- let host_id = Settings::host_id().await?;
- let git_root = utils::in_git_repo(cwd.as_str());
-
- Ok(Context {
- session,
- hostname,
- cwd,
- git_root,
- host_id: host_id.0.as_simple().to_string(),
- })
-}
-
-impl Context {
- pub(crate) fn from_history(entry: &History) -> Self {
- Self {
- session: entry.session.clone(),
- cwd: entry.cwd.clone(),
- hostname: entry.hostname.clone(),
- host_id: String::new(),
- git_root: utils::in_git_repo(entry.cwd.as_str()),
- }
- }
-}
-
-fn get_session_start_time(session_id: &str) -> Option<i64> {
- if let Ok(uuid) = Uuid::parse_str(session_id)
- && let Some(timestamp) = uuid.get_timestamp()
- {
- let (seconds, nanos) = timestamp.to_unix();
- return Some(seconds as i64 * 1_000_000_000 + i64::from(nanos));
- }
- None
-}
-
-// Intended for use on a developer machine and not a sync server.
-// TODO: implement IntoIterator
-#[derive(Debug, Clone)]
-pub(crate) struct ClientSqlite {
- pub(crate) pool: SqlitePool,
-}
-
-impl ClientSqlite {
- pub(crate) 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)
- .optimize_on_close(true, None)
- .synchronous(SqliteSynchronous::Normal)
- .with_regexp()
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-migrations").await?;
- Ok(Self { pool })
- }
-
- async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {
- sqlx::query(
- "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
- )
- .bind(h.id.0.as_str())
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(h.duration)
- .bind(h.exit)
- .bind(h.command.as_str())
- .bind(h.cwd.as_str())
- .bind(h.session.as_str())
- .bind(h.hostname.as_str())
- .bind(h.author.as_str())
- .bind(h.intent.as_deref())
- .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- async fn delete_row_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- id: HistoryId,
- ) -> Result<()> {
- sqlx::query("delete from history where id = ?1")
- .bind(id.0.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(clippy::needless_pass_by_value)]
- fn query_history_inner(row: SqliteRow) -> History {
- let deleted_at: Option<i64> = row.get("deleted_at");
- let hostname: String = row.get("hostname");
- let author: Option<String> = row.try_get("author").ok().flatten();
- let author = author
- .filter(|author| !author.trim().is_empty())
- .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));
- let intent: Option<String> = row.try_get("intent").ok().flatten();
- let intent = intent.filter(|intent| !intent.trim().is_empty());
-
- History::from_db()
- .id(row.get("id"))
- .timestamp(
- OffsetDateTime::from_unix_timestamp_nanos(i128::from(
- row.get::<i64, _>("timestamp"),
- ))
- .unwrap(),
- )
- .duration(row.get("duration"))
- .exit(row.get("exit"))
- .command(row.get("command"))
- .cwd(row.get("cwd"))
- .session(row.get("session"))
- .hostname(hostname)
- .author(author)
- .intent(intent)
- .deleted_at(
- deleted_at
- .and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)).ok()),
- )
- .build()
- .into()
- }
-}
-
-impl ClientSqlite {
- pub(crate) async fn save(&self, h: &History) -> Result<()> {
- debug!("saving history to sqlite");
- let mut tx = self.pool.begin().await?;
- Self::save_raw(&mut tx, h).await?;
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn save_bulk(&self, h: &[History]) -> Result<()> {
- debug!("saving history to sqlite");
-
- let mut tx = self.pool.begin().await?;
-
- for i in h {
- Self::save_raw(&mut tx, i).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn load(&self, id: &str) -> Result<Option<History>> {
- debug!("loading history item {}", id);
-
- let res = sqlx::query("select * from history where id = ?1")
- .bind(id)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- // make a unique list, that only shows the *newest* version of things
- pub(crate) async fn list(
- &self,
- filters: &[FilterMode],
- context: &Context,
- max: Option<usize>,
- unique: bool,
- include_deleted: bool,
- ) -> Result<Vec<History>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
- query.field("*").order_desc("timestamp");
- if !include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- for filter in filters {
- match filter {
- FilterMode::Global => &mut query,
- FilterMode::Host => query.and_where_eq("hostname", quote(&context.hostname)),
- FilterMode::Session => query.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- query.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- query.or_where_lt("timestamp", session_start);
- }
- &mut query
- }
- FilterMode::Directory => query.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => query.and_where_like_left("cwd", &git_root),
- };
- }
-
- if unique {
- query.group_by("command").having("max(timestamp)");
- }
-
- if let Some(max) = max {
- query.limit(max);
- }
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn range(
- &self,
- from: OffsetDateTime,
- to: OffsetDateTime,
- ) -> Result<Vec<History>> {
- debug!("listing history from {:?} to {:?}", from, to);
-
- let res = sqlx::query(
- "select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc",
- )
- .bind(from.unix_timestamp_nanos() as i64)
- .bind(to.unix_timestamp_nanos() as i64)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn last(&self) -> Result<Option<History>> {
- let res = sqlx::query(
- "select * from history where duration >= 0 order by timestamp desc limit 1",
- )
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn history_count(&self, include_deleted: bool) -> Result<i64> {
- let query = if include_deleted {
- "select count(1) from history"
- } else {
- "select count(1) from history where deleted_at is null"
- };
-
- let res: (i64,) = sqlx::query_as(query).fetch_one(&self.pool).await?;
- Ok(res.0)
- }
-
- // Yes I know, it's a lot.
- // Could maybe break it down to a searchparams struct or smth but that feels a little... pointless.
- // Been debating maybe a DSL for search? eg "before:time limit:1 the query"
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn search(
- &self,
- search_mode: SearchMode,
- filter: FilterMode,
- context: &Context,
- query: &str,
- filter_options: OptFilters,
- ) -> Result<Vec<History>> {
- let mut sql = SqlBuilder::select_from("history");
-
- if !filter_options.include_duplicates {
- sql.group_by("command").having("max(timestamp)");
- }
-
- if let Some(limit) = filter_options.limit {
- sql.limit(limit);
- }
-
- if let Some(offset) = filter_options.offset {
- sql.offset(offset);
- }
-
- if filter_options.reverse {
- sql.order_asc("timestamp");
- } else {
- sql.order_desc("timestamp");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- match filter {
- FilterMode::Global => &mut sql,
- FilterMode::Host => {
- sql.and_where_eq("lower(hostname)", quote(context.hostname.to_lowercase()))
- }
- FilterMode::Session => sql.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- sql.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- sql.or_where_lt("timestamp", session_start);
- }
- &mut sql
- }
- FilterMode::Directory => sql.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => sql.and_where_like_left("cwd", git_root),
- };
-
- let orig_query = query;
-
- let mut regexes = Vec::new();
- if search_mode == SearchMode::Prefix {
- sql.and_where_like_left("command", query.replace('*', "%"))
- } else {
- let mut is_or = false;
- for token in QueryTokenizer::new(query) {
- // TODO smart case mode could be made configurable like in fzf
- let (is_glob, glob) = if token.has_uppercase() {
- (true, "*")
- } else {
- (false, "%")
- };
- let param = match token {
- QueryToken::Regex(r) => {
- regexes.push(String::from(r));
- continue;
- }
- QueryToken::Or => {
- if !is_or {
- is_or = true;
- continue;
- }
-
- format!("{glob}|{glob}")
- }
- QueryToken::MatchStart(term, _) => {
- format!("{term}{glob}")
- }
- QueryToken::MatchEnd(term, _) => {
- format!("{glob}{term}")
- }
- QueryToken::MatchFull(term, _) => {
- format!("{glob}{term}{glob}")
- }
- QueryToken::Match(term, _) => {
- if search_mode == SearchMode::FullText {
- format!("{glob}{term}{glob}")
- } else {
- term.split("").join(glob)
- }
- }
- };
-
- sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
- is_or = false;
- }
-
- &mut sql
- };
-
- for regex in regexes {
- sql.and_where("command regexp ?".bind(&regex));
- }
-
- filter_options
- .exit
- .map(|exit| sql.and_where_eq("exit", exit));
-
- filter_options
- .exclude_exit
- .map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
-
- filter_options
- .cwd
- .map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
-
- filter_options
- .exclude_cwd
- .map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
-
- filter_options.before.map(|before| {
- interim::parse_date_string(
- before.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|before| {
- sql.and_where_lt("timestamp", quote(before.unix_timestamp_nanos() as i64))
- })
- });
-
- filter_options.after.map(|after| {
- interim::parse_date_string(
- after.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|after| sql.and_where_gt("timestamp", quote(after.unix_timestamp_nanos() as i64)))
- });
-
- sql.and_where_is_null("deleted_at");
-
- let query = sql.sql().expect("bug in search query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(ordering::reorder_fuzzy(search_mode, orig_query, res))
- }
-
- pub(crate) async fn query_history(&self, query: &str) -> Result<Vec<History>> {
- let res = sqlx::query(query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn all_with_count(&self) -> Result<Vec<(History, i32)>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query
- .fields(&[
- "id",
- "max(timestamp) as timestamp",
- "max(duration) as duration",
- "exit",
- "command",
- "deleted_at",
- "null as author",
- "null as intent",
- "group_concat(cwd, ':') as cwd",
- "group_concat(session) as session",
- "group_concat(hostname, ',') as hostname",
- "count(*) as count",
- ])
- .group_by("command")
- .group_by("exit")
- .and_where("deleted_at is null")
- .order_desc("timestamp");
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(|row: SqliteRow| {
- let count: i32 = row.get("count");
- (Self::query_history_inner(row), count)
- })
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged {
- Paged::new(self.clone(), page_size, include_deleted, unique)
- }
-
- pub(crate) async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for id in ids {
- Self::delete_row_raw(&mut tx, id.clone()).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn stats(&self, h: &History) -> Result<HistoryStats> {
- // We select the previous in the session by time
- let mut prev = SqlBuilder::select_from("history");
- prev.field("*")
- .and_where("timestamp < ?1")
- .and_where("session = ?2")
- .order_by("timestamp", true)
- .limit(1);
-
- let mut next = SqlBuilder::select_from("history");
- next.field("*")
- .and_where("timestamp > ?1")
- .and_where("session = ?2")
- .order_by("timestamp", false)
- .limit(1);
-
- let mut total = SqlBuilder::select_from("history");
- total.field("count(1)").and_where("command = ?1");
-
- let mut average = SqlBuilder::select_from("history");
- average.field("avg(duration)").and_where("command = ?1");
-
- let mut exits = SqlBuilder::select_from("history");
- exits
- .fields(&["exit", "count(1) as count"])
- .and_where("command = ?1")
- .group_by("exit");
-
- // rewrite the following with sqlbuilder
- let mut day_of_week = SqlBuilder::select_from("history");
- day_of_week
- .fields(&[
- "strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week",
- "count(1) as count",
- ])
- .and_where("command = ?1")
- .group_by("day_of_week");
-
- // Intentionally format the string with 01 hardcoded. We want the average runtime for the
- // _entire month_, but will later parse it as a datetime for sorting
- // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a
- // string sort, which won't be correct.
- let mut duration_over_time = SqlBuilder::select_from("history");
- duration_over_time
- .fields(&[
- "strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year",
- "avg(duration) as duration",
- ])
- .and_where("command = ?1")
- .group_by("month_year")
- .having("duration > 0");
-
- let prev = prev.sql().expect("issue in stats previous query");
- let next = next.sql().expect("issue in stats next query");
- let total = total.sql().expect("issue in stats average query");
- let average = average.sql().expect("issue in stats previous query");
- let exits = exits.sql().expect("issue in stats exits query");
- let day_of_week = day_of_week.sql().expect("issue in stats day of week query");
- let duration_over_time = duration_over_time
- .sql()
- .expect("issue in stats duration over time query");
-
- let prev = sqlx::query(&prev)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let next = sqlx::query(&next)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let total: (i64,) = sqlx::query_as(&total)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let average: (f64,) = sqlx::query_as(&average)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let exits: Vec<(i64, i64)> = sqlx::query_as(&exits)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time = duration_over_time
- .iter()
- .map(|f| (f.0.clone(), f.1.round() as i64))
- .collect();
-
- Ok(HistoryStats {
- next,
- previous: prev,
- total: total.0 as u64,
- average_duration: average.0 as u64,
- exits,
- day_of_week,
- duration_over_time,
- })
- }
-
- pub(crate) async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>> {
- let res = sqlx::query(
- "SELECT * FROM (
- SELECT *, ROW_NUMBER()
- OVER (PARTITION BY command, cwd, hostname ORDER BY timestamp DESC)
- AS rn
- FROM history
- ) sub
- WHERE rn > ?1 and timestamp < ?2;
- ",
- )
- .bind(dupkeep)
- .bind(before)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-pub(crate) struct Paged {
- database: ClientSqlite,
- page_size: usize,
- last_id: Option<String>,
- include_deleted: bool,
- unique: bool,
-}
-
-impl Paged {
- pub(crate) fn new(
- database: ClientSqlite,
- page_size: usize,
- include_deleted: bool,
- unique: bool,
- ) -> Self {
- Self {
- database,
- page_size,
- last_id: None,
- include_deleted,
- unique,
- }
- }
-
- pub(crate) async fn next(&mut self) -> Result<Option<Vec<History>>> {
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query.field("*").order_desc("id");
-
- if !self.include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- if self.unique {
- // We want to deduplicate on command, but the user can search via cwd, hostname, and session.
- // Without those fields, filter modes won't work right. With those fields, we get duplicates.
- // This must be handled upstream.
- query
- .group_by("command, cwd, hostname, session")
- .having("max(timestamp)");
- }
-
- query.limit(self.page_size);
-
- if let Some(last_id) = &self.last_id {
- query.and_where_lt("id", quote(last_id));
- }
-
- let query = query.sql().expect("bug in list query. please report");
- let res = self.database.query_history(&query).await?;
-
- if res.is_empty() {
- Ok(None)
- } else {
- self.last_id = Some(res.last().unwrap().id.0.clone());
- Ok(Some(res))
- }
- }
-}
-
-trait SqlBuilderExt {
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self;
-}
-
-impl SqlBuilderExt for SqlBuilder {
- /// adapted from the sql-builder *like functions
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self {
- let mut cond = field.to_string();
- if inverse {
- cond.push_str(" NOT");
- }
- if glob {
- cond.push_str(" GLOB '");
- } else {
- cond.push_str(" LIKE '");
- }
- cond.push_str(&esc(mask.to_string()));
- cond.push('\'');
- if is_or {
- self.or_where(cond)
- } else {
- self.and_where(cond)
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- use crate::atuin_client::settings::test_local_timeout;
-
- use super::{
- ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode,
- };
- use std::time::{Duration, Instant};
-
- async fn assert_search_eq(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected: usize,
- ) -> Result<Vec<History>> {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let results = db
- .search(
- mode,
- filter_mode,
- &context,
- query,
- OptFilters {
- ..Default::default()
- },
- )
- .await?;
-
- assert_eq!(
- results.len(),
- expected,
- "query \"{}\", commands: {:?}",
- query,
- results.iter().map(|a| &a.command).collect::<Vec<&String>>()
- );
- Ok(results)
- }
-
- async fn assert_search_commands(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected_commands: Vec<&str>,
- ) {
- let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
- .await
- .unwrap();
- let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
- assert_eq!(commands, expected_commands);
- }
-
- async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> {
- let mut captured: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command(cmd)
- .cwd("/home/ellie")
- .build()
- .into();
-
- captured.exit = 0;
- captured.duration = 1;
- captured.session = "beep boop".to_string();
- captured.hostname = "booop".to_string();
-
- db.save(&captured).await
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_prefix() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fulltext() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ho", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "hm", 0)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r/^ls ", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / ie$",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / !ie",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "meow r/ls/",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r//hom/", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home//",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home///",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home.*e", 0)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/home.*e",
- 1,
- )
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
- new_history_item(&mut db, "ls /home/frank").await.unwrap();
- new_history_item(&mut db, "cd /home/Ellie").await.unwrap();
- new_history_item(&mut db, "/home/ellie/.bin/rustup")
- .await
- .unwrap();
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4)
- .await
- .unwrap();
-
- // single term operators
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2)
- .await
- .unwrap();
-
- // multiple terms
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup",
- 2,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup 'ls",
- 1,
- )
- .await
- .unwrap();
-
- // case matching
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/^ls ", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/[Ee]llie", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e r/^ls ", 1)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_reordered_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- // test ordering of results: we should choose the first, even though it happened longer ago.
-
- new_history_item(&mut db, "curl").await.unwrap();
- new_history_item(&mut db, "corburl").await.unwrap();
-
- // if fuzzy reordering is on, it should come back in a more sensible order
- assert_search_commands(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "curl",
- vec!["curl", "corburl"],
- )
- .await;
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- #[expect(clippy::similar_names)]
- async fn test_paged_basic() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add 5 history items
- for i in 0..5 {
- new_history_item(&mut db, &format!("command{}", i))
- .await
- .unwrap();
- }
-
- // Create a paged iterator with page_size of 2
- let mut paged = db.all_paged(2, false, false);
-
- // First page should have 2 items
- let page1 = paged.next().await.unwrap();
- assert!(page1.is_some());
- assert_eq!(page1.unwrap().len(), 2);
-
- // Second page should have 2 items
- let page2 = paged.next().await.unwrap();
- assert!(page2.is_some());
- assert_eq!(page2.unwrap().len(), 2);
-
- // Third page should have 1 item
- let page3 = paged.next().await.unwrap();
- assert!(page3.is_some());
- assert_eq!(page3.unwrap().len(), 1);
-
- // Fourth page should be None (exhausted)
- let page4 = paged.next().await.unwrap();
- assert!(page4.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_empty() {
- let db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Create a paged iterator on empty database
- let mut paged = db.all_paged(10, false, false);
-
- // Should return None immediately
- let page = paged.next().await.unwrap();
- assert!(page.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_unique() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add duplicate commands
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "unique1").await.unwrap();
- new_history_item(&mut db, "unique2").await.unwrap();
-
- // Without unique flag - should get all 4
- let mut paged = db.all_paged(10, false, false);
- let page = paged.next().await.unwrap().unwrap();
- assert_eq!(page.len(), 4);
-
- // With unique flag - should get 3 (duplicates collapsed)
- let mut paged_unique = db.all_paged(10, false, true);
- let paged_unique = paged_unique.next().await.unwrap().unwrap();
- assert_eq!(paged_unique.len(), 3);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_bench_dupes() {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- for _i in 1..10000 {
- new_history_item(&mut db, "i am a duplicated command")
- .await
- .unwrap();
- }
- let start = Instant::now();
- let _results = db
- .search(
- SearchMode::Fuzzy,
- FilterMode::Global,
- &context,
- "",
- OptFilters {
- ..Default::default()
- },
- )
- .await
- .unwrap();
- let duration = start.elapsed();
-
- assert!(duration < Duration::from_secs(15));
- }
-}
-
-pub(crate) struct QueryTokenizer<'a> {
- query: &'a str,
- last_pos: usize,
-}
-
-pub(crate) enum QueryToken<'a> {
- Match(&'a str, bool),
- MatchStart(&'a str, bool),
- MatchEnd(&'a str, bool),
- MatchFull(&'a str, bool),
- Or,
- Regex(&'a str),
-}
-
-impl QueryToken<'_> {
- pub(crate) fn has_uppercase(&self) -> bool {
- match self {
- Self::Match(term, _)
- | Self::MatchStart(term, _)
- | Self::MatchEnd(term, _)
- | Self::MatchFull(term, _) => term.contains(char::is_uppercase),
- _ => false,
- }
- }
-
- pub(crate) fn is_inverse(&self) -> bool {
- match self {
- Self::Match(_, inv)
- | Self::MatchStart(_, inv)
- | Self::MatchEnd(_, inv)
- | Self::MatchFull(_, inv) => *inv,
- _ => false,
- }
- }
-}
-
-impl<'a> QueryTokenizer<'a> {
- pub(crate) fn new(query: &'a str) -> Self {
- Self { query, last_pos: 0 }
- }
-}
-
-impl<'a> Iterator for QueryTokenizer<'a> {
- type Item = QueryToken<'a>;
- fn next(&mut self) -> Option<Self::Item> {
- let remaining = &self.query[self.last_pos..];
- if remaining.is_empty() {
- return None;
- }
-
- if let Some(remaining) = remaining.strip_prefix("r/") {
- let (regex, next_pos) = if let Some(end) = remaining.find("/ ") {
- (&remaining[..end], self.last_pos + 2 + end + 2)
- } else if let Some(remaining) = remaining.strip_suffix('/') {
- (remaining, self.query.len())
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
- Some(QueryToken::Regex(regex))
- } else {
- let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {
- (&remaining[..sp], self.last_pos + sp + 1)
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
-
- if part == "|" {
- return Some(QueryToken::Or);
- }
-
- let is_inverse = part.strip_prefix('!').is_some_and(|s| {
- part = s;
- true
- });
-
- #[expect(clippy::option_if_let_else, reason = "It's too ugly")]
- let token = if let Some(s) = part.strip_prefix('^') {
- QueryToken::MatchStart(s, is_inverse)
- } else if let Some(s) = part.strip_suffix('$') {
- QueryToken::MatchEnd(s, is_inverse)
- } else if let Some(s) = part.strip_prefix('\'') {
- QueryToken::MatchFull(s, is_inverse)
- } else {
- QueryToken::Match(part, is_inverse)
- };
- Some(token)
- }
- }
-}
diff --git a/crates/turtle/src/atuin_client/encryption.rs b/crates/turtle/src/atuin_client/encryption.rs
deleted file mode 100644
index f1c921cb..00000000
--- a/crates/turtle/src/atuin_client/encryption.rs
+++ /dev/null
@@ -1,142 +0,0 @@
-// The general idea is that we NEVER send cleartext history to the server
-// This way the odds of anything private ending up where it should not are
-// very low
-// The server authenticates via the usual username and password. This has
-// nothing to do with the encryption, and is purely authentication! The client
-// generates its own secret key, and encrypts all shell history with libsodium's
-// secretbox. The data is then sent to the server, where it is stored. All
-// clients must share the secret in order to be able to sync, as it is needed
-// to decrypt
-
-use std::io::prelude::Write;
-
-use base64::prelude::{BASE64_STANDARD, Engine};
-pub(crate) use crypto_secretbox::Key;
-use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead::OsRng};
-use eyre::{Context, Result, bail, ensure, eyre};
-use fs_err as fs;
-use rmp::Marker;
-
-use crate::atuin_client::settings::Settings;
-
-pub(crate) fn generate_encoded_key() -> Result<(Key, String)> {
- let key = XSalsa20Poly1305::generate_key(&mut OsRng);
- let encoded = encode_key(&key)?;
-
- Ok((key, encoded))
-}
-
-pub(crate) fn new_key(settings: &Settings) -> Result<Key> {
- if settings.sync.encryption_key()?.is_some() {
- bail!("key already exists! cannot overwrite");
- } else if let Some(path) = settings.sync.encryption_key_path.as_ref() {
- let (key, encoded) = generate_encoded_key()?;
-
- let mut file = fs::File::create(path)?;
- file.write_all(encoded.as_bytes())?;
-
- Ok(key)
- } else {
- bail!("No key-path set, cannot generate key")
- }
-}
-
-// Loads the secret key, will create + save if it doesn't exist
-pub(crate) fn load_key(settings: &Settings) -> Result<Key> {
- if let Some(key) = settings.sync.encryption_key()? {
- Ok(key)
- } else {
- Ok(new_key(settings)?)
- }
-}
-
-pub(crate) fn encode_key(key: &Key) -> Result<String> {
- let mut buf = vec![];
- rmp::encode::write_array_len(&mut buf, key.len() as u32)
- .wrap_err("could not encode key to message pack")?;
- for b in key {
- rmp::encode::write_uint(&mut buf, u64::from(*b))
- .wrap_err("could not encode key to message pack")?;
- }
- let buf = BASE64_STANDARD.encode(buf);
-
- Ok(buf)
-}
-
-pub(crate) fn decode_key(key: &str) -> Result<Key> {
- use rmp::decode;
-
- let buf = BASE64_STANDARD
- .decode(key.trim_end())
- .wrap_err("encryption key is not a valid base64 encoding")?;
-
- // old code wrote the key as a fixed length array of 32 bytes
- // new code writes the key with a length prefix
- if let Ok(key) = <[u8; 32]>::try_from(&*buf) {
- Ok(key.into())
- } else {
- let mut bytes = decode::Bytes::new(&buf);
-
- match Marker::from_u8(buf[0]) {
- Marker::Bin8 => {
- let len = decode::read_bin_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
- let key = <[u8; 32]>::try_from(bytes.remaining_slice())
- .context("could not decode encryption key")?;
- Ok(key.into())
- }
- Marker::Array16 => {
- let len = decode::read_array_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
-
- let mut key = Key::default();
- for i in &mut key {
- *i = decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- }
- Ok(key)
- }
- _ => bail!("could not decode encryption key"),
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- #[test]
- fn key_encodings() {
- use super::{Key, decode_key, encode_key};
-
- // a history of our key encodings.
- // v11.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v12.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // c7d89c1 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/805)
- // b53ca35 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/974)
- // v15.0.0 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==
- // b8b57c8 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q== (https://github.com/ellie/atuin/pull/1057)
- // 8c94d79 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/1089)
-
- let key = Key::from([
- 27, 91, 42, 91, 210, 107, 9, 216, 170, 190, 242, 62, 6, 84, 69, 148, 148, 53, 251, 117,
- 226, 167, 173, 52, 82, 34, 138, 110, 169, 124, 92, 229,
- ]);
-
- assert_eq!(
- encode_key(&key).unwrap(),
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q=="
- );
-
- // key encodings we have to support
- let valid_encodings = [
- "xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==",
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==",
- ];
-
- for k in valid_encodings {
- assert_eq!(decode_key(k).expect(k), key);
- }
- }
-}
diff --git a/crates/turtle/src/atuin_client/history.rs b/crates/turtle/src/atuin_client/history.rs
deleted file mode 100644
index c38d8ccc..00000000
--- a/crates/turtle/src/atuin_client/history.rs
+++ /dev/null
@@ -1,686 +0,0 @@
-use core::fmt::Formatter;
-use rmp::decode::DecodeStringError;
-use rmp::decode::ValueReadError;
-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 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 time::OffsetDateTime;
-
-mod builder;
-pub(crate) mod store;
-
-pub(crate) const HISTORY_VERSION_V0: &str = "v0";
-pub(crate) const HISTORY_VERSION_V1: &str = "v1";
-const HISTORY_RECORD_VERSION_V0: u16 = 0;
-const HISTORY_RECORD_VERSION_V1: u16 = 1;
-pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;
-pub(crate) const HISTORY_TAG: &str = "history";
-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);
-
-impl Display for HistoryId {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From<String> for HistoryId {
- fn from(s: String) -> Self {
- Self(s)
- }
-}
-
-/// Client-side history entry.
-///
-/// Client stores data unencrypted, and only encrypts it before sending to the server.
-///
-/// To create a new history entry, use one of the builders:
-/// - [`History::import()`] to import an entry from the shell history file
-/// - [`History::capture()`] to capture an entry via hook
-/// - [`History::from_db()`] to create an instance from the database entry
-//
-// ## Implementation Notes
-//
-// 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 {
- /// A client-generated ID, used to identify the entry when syncing.
- ///
- /// Stored as `client_id` in the database.
- pub(crate) id: HistoryId,
-
- /// When the command was run.
- pub(crate) timestamp: OffsetDateTime,
-
- /// How long the command took to run.
- pub(crate) duration: i64,
-
- /// The exit code of the command.
- pub(crate) exit: i64,
-
- /// The command that was run.
- pub(crate) command: String,
-
- /// The current working directory when the command was run.
- pub(crate) cwd: String,
-
- /// The session ID, associated with a terminal session.
- pub(crate) session: String,
-
- /// The hostname of the machine the command was run on.
- pub(crate) hostname: String,
-
- /// Who wrote this command (human user or automation/agent identity).
- pub(crate) author: String,
-
- /// Optional rationale for why the command was executed.
- pub(crate) intent: Option<String>,
-
- /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
- pub(crate) deleted_at: Option<OffsetDateTime>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
-pub(crate) struct HistoryStats {
- /// The command that was ran after this one in the session
- pub(crate) next: Option<History>,
-
- /// The command that was ran before this one in the session
- pub(crate) previous: Option<History>,
-
- /// How many times has this command been ran?
- pub(crate) total: u64,
-
- pub(crate) average_duration: u64,
-
- pub(crate) exits: Vec<(i64, i64)>,
-
- pub(crate) day_of_week: Vec<(String, i64)>,
-
- pub(crate) duration_over_time: Vec<(String, i64)>,
-}
-
-impl History {
- pub(crate) fn author_from_hostname(hostname: &str) -> String {
- hostname
- .split_once(':')
- .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
- }
-
- fn normalize_optional_field(field: Option<String>) -> Option<String> {
- field.and_then(|value| {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_owned())
- }
- })
- }
-
- #[expect(clippy::too_many_arguments)]
- fn new(
- timestamp: OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- duration: i64,
- session: Option<String>,
- hostname: Option<String>,
- author: Option<String>,
- intent: Option<String>,
- deleted_at: Option<OffsetDateTime>,
- ) -> Self {
- let session = session
- .or_else(|| env::var("ATUIN_SESSION").ok())
- .unwrap_or_else(|| uuid_v7().as_simple().to_string());
- let hostname = hostname.unwrap_or_else(get_host_user);
- let author = Self::normalize_optional_field(author)
- .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok()))
- .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));
- let intent = Self::normalize_optional_field(intent)
- .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok()));
-
- Self {
- id: uuid_v7().as_simple().to_string().into(),
- timestamp,
- command,
- cwd,
- exit,
- duration,
- session,
- hostname,
- author,
- intent,
- deleted_at,
- }
- }
-
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
- // This is pretty much the same as what we used for the old history, with one difference -
- // it uses integers for timestamps rather than a string format.
-
- use rmp::encode;
-
- let mut output = vec![];
-
- // write the version
- encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;
- let include_intent = self.intent.is_some();
- encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
-
- encode::write_str(&mut output, &self.id.0)?;
- encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
- encode::write_sint(&mut output, self.duration)?;
- encode::write_sint(&mut output, self.exit)?;
- encode::write_str(&mut output, &self.command)?;
- encode::write_str(&mut output, &self.cwd)?;
- encode::write_str(&mut output, &self.session)?;
- encode::write_str(&mut output, &self.hostname)?;
-
- match self.deleted_at {
- Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,
- None => encode::write_nil(&mut output)?,
- }
-
- encode::write_str(&mut output, self.author.as_str())?;
- if let Some(intent) = &self.intent {
- encode::write_str(&mut output, intent.as_str())?;
- }
-
- Ok(DecryptedData(output))
- }
-
- fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- match decode::read_str_from_slice(bytes) {
- Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),
- Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
- let mut cursor = Bytes::new(bytes);
- decode::read_nil(&mut cursor).map_err(error_report)?;
-
- Ok((None, cursor.remaining_slice()))
- }
- Err(err) => Err(error_report(err)),
- }
- }
-
- fn deserialize_v0(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V0 {
- bail!("expected decoding v0 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if nfields != 9 {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: Self::author_from_hostname(hostname),
- intent: None,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- fn deserialize_v1(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V1 {
- bail!("expected decoding v1 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if !(10..=11).contains(&nfields) {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- let (author, bytes) = Self::read_optional_string(bytes)?;
- let (intent, bytes) = if nfields > 10 {
- Self::read_optional_string(bytes)?
- } else {
- (None, bytes)
- };
-
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),
- intent,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
- match version {
- HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
- HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
-
- _ => bail!("unknown version {version:?}"),
- }
- }
-
- /// Builder for a history entry that is captured via hook.
- ///
- /// This builder is used only at the `start` step of the hook,
- /// so it doesn't have any fields which are known only after
- /// the command is finished, such as `exit` or `duration`.
- ///
- /// ## Examples
- /// ```rust
- /// use crate::atuin_client::history::History;
- ///
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .build()
- /// .into();
- /// ```
- ///
- /// Command without any required info cannot be captured, which is forced at compile time:
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `cwd` is missing
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn capture() -> builder::HistoryCapturedBuilder {
- builder::HistoryCaptured::builder()
- }
-
- /// Builder for a history entry that is captured via hook, and sent to the daemon.
- ///
- /// This builder is used only at the `start` step of the hook,
- /// so it doesn't have any fields which are known only after
- /// the command is finished, such as `exit` or `duration`.
- ///
- /// It does, however, include information that can usually be inferred.
- ///
- /// This is because the daemon we are sending a request to lacks the context of the command
- ///
- /// ## Examples
- /// ```rust
- /// use crate::atuin_client::history::History;
- ///
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .hostname("computer:ellie")
- /// .build()
- /// .into();
- /// ```
- ///
- /// Command without any required info cannot be captured, which is forced at compile time:
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `hostname` is missing
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder {
- builder::HistoryDaemonCapture::builder()
- }
-
- /// Builder for a history entry that is imported from the database.
- ///
- /// All fields are required, as they are all present in the database.
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `id` field is missing
- /// let history: History = History::from_db()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la".to_string())
- /// .cwd("/home/user".to_string())
- /// .exit(0)
- /// .duration(100)
- /// .session("somesession".to_string())
- /// .hostname("localhost".to_string())
- /// .author("user".to_string())
- /// .intent(None)
- /// .deleted_at(None)
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn from_db() -> builder::HistoryFromDbBuilder {
- builder::HistoryFromDb::builder()
- }
-
- pub(crate) fn success(&self) -> bool {
- self.exit == 0 || self.duration == -1
- }
-
- pub(crate) fn should_save(&self, settings: &Settings) -> bool {
- !(self.command.is_empty()
- || settings.history_filter.is_match(&self.command)
- || settings.cwd_filter.is_match(&self.cwd)
- || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use regex::RegexSet;
- use time::macros::datetime;
-
- use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings};
-
- use super::History;
-
- // Test that we don't save history where necessary
- #[test]
- fn privacy_test() {
- let settings = Settings {
- cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
- history_filter: RegexSet::new(["^psql"]).unwrap(),
- ..Settings::default()
- };
-
- let normal_command: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo foo")
- .cwd("/")
- .build()
- .into();
-
- let with_space: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command(" echo bar")
- .cwd("/")
- .build()
- .into();
-
- let empty: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("")
- .cwd("/")
- .build()
- .into();
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- let secret_dir: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo ohno")
- .cwd("/supasecret")
- .build()
- .into();
-
- let with_psql: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("psql")
- .cwd("/supasecret")
- .build()
- .into();
-
- assert!(normal_command.should_save(&settings));
- assert!(!with_space.should_save(&settings));
- assert!(!empty.should_save(&settings));
- assert!(!stripe_key.should_save(&settings));
- assert!(!secret_dir.should_save(&settings));
- assert!(!with_psql.should_save(&settings));
- }
-
- #[test]
- fn disable_secrets() {
- let settings = Settings {
- secrets_filter: false,
- ..Settings::new().unwrap()
- };
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- assert!(stripe_key.should_save(&settings));
- }
-
- #[test]
- fn test_serialize_deserialize() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- assert_eq!(
- &serialized.0[0..3],
- [205, 0, 1],
- "should encode as history v1"
- );
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_deleted() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_with_author_and_intent() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "claude".to_owned(),
- intent: Some("check repository status".to_owned()),
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_version() {
- // v0
- let bytes_v0 = [
- 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
- 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
- 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
- 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
- 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
- 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
- 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
- 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
- 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
- ];
-
- let deserialized = History::deserialize(&bytes_v0, "v0");
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
- assert!(deserialized.is_err());
-
- let current = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let bytes_v1 = current.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v1.0, "v0");
- assert!(deserialized.is_err());
- }
-}
diff --git a/crates/turtle/src/atuin_client/history/builder.rs b/crates/turtle/src/atuin_client/history/builder.rs
deleted file mode 100644
index daa4ef49..00000000
--- a/crates/turtle/src/atuin_client/history/builder.rs
+++ /dev/null
@@ -1,154 +0,0 @@
-use typed_builder::TypedBuilder;
-
-use super::History;
-
-/// Builder for a history entry that is imported from shell history.
-///
-/// The only two required fields are `timestamp` and `command`.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryImported {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(default = "unknown".into(), setter(into))]
- cwd: String,
- #[builder(default = -1)]
- exit: i64,
- #[builder(default = -1)]
- duration: i64,
- #[builder(default, setter(strip_option, into))]
- session: Option<String>,
- #[builder(default, setter(strip_option, into))]
- hostname: Option<String>,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryImported> for History {
- fn from(imported: HistoryImported) -> Self {
- Self::new(
- imported.timestamp,
- imported.command,
- imported.cwd,
- imported.exit,
- imported.duration,
- imported.session,
- imported.hostname,
- imported.author,
- imported.intent,
- None,
- )
- }
-}
-
-/// Builder for a history entry that is captured via hook.
-///
-/// This builder is used only at the `start` step of the hook,
-/// so it doesn't have any fields which are known only after
-/// the command is finished, such as `exit` or `duration`.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryCaptured {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(setter(into))]
- cwd: String,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryCaptured> for History {
- fn from(captured: HistoryCaptured) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- None,
- None,
- captured.author,
- captured.intent,
- None,
- )
- }
-}
-
-/// Builder for a history entry that is loaded from the database.
-///
-/// All fields are required, as they are all present in the database.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryFromDb {
- id: String,
- timestamp: time::OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- duration: i64,
- session: String,
- hostname: String,
- author: String,
- intent: Option<String>,
- deleted_at: Option<time::OffsetDateTime>,
-}
-
-impl From<HistoryFromDb> for History {
- fn from(from_db: HistoryFromDb) -> Self {
- Self {
- id: from_db.id.into(),
- timestamp: from_db.timestamp,
- exit: from_db.exit,
- command: from_db.command,
- cwd: from_db.cwd,
- duration: from_db.duration,
- session: from_db.session,
- hostname: from_db.hostname,
- author: from_db.author,
- intent: from_db.intent,
- deleted_at: from_db.deleted_at,
- }
- }
-}
-
-/// Builder for a history entry that is captured via hook and sent to the daemon
-///
-/// This builder is similar to Capture, but we just require more information up front.
-/// For the old setup, we could just rely on `History::new` to read some of the missing
-/// data. This is no longer the case.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryDaemonCapture {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(setter(into))]
- cwd: String,
- #[builder(setter(into))]
- session: String,
- #[builder(setter(into))]
- hostname: String,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryDaemonCapture> for History {
- fn from(captured: HistoryDaemonCapture) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- Some(captured.session),
- Some(captured.hostname),
- captured.author,
- captured.intent,
- None,
- )
- }
-}
diff --git a/crates/turtle/src/atuin_client/history/store.rs b/crates/turtle/src/atuin_client/history/store.rs
deleted file mode 100644
index 9c7771cc..00000000
--- a/crates/turtle/src/atuin_client/history/store.rs
+++ /dev/null
@@ -1,437 +0,0 @@
-use std::{collections::HashSet, fmt::Write, time::Duration};
-
-use eyre::{Result, bail, eyre};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-use rmp::decode::Bytes;
-use tracing::debug;
-
-use crate::atuin_client::{
- database::{ClientSqlite, current_context},
- record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
-};
-use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
-
-use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
-
-#[derive(Debug, Clone)]
-pub(crate) struct HistoryStore {
- pub(crate) store: SqliteStore,
- pub(crate) host_id: HostId,
- pub(crate) encryption_key: [u8; 32],
-}
-
-#[derive(Debug, Eq, PartialEq, Clone)]
-pub(crate) enum HistoryRecord {
- Create(History), // Create a history record
- Delete(HistoryId), // Delete a history record, identified by ID
-}
-
-impl HistoryRecord {
- /// Serialize a history record, returning `DecryptedData`
- /// The record will be of a certain type
- /// We map those like so:
- ///
- /// `HistoryRecord::Create` -> 0
- /// `HistoryRecord::Delete`-> 1
- ///
- /// This numeric identifier is then written as the first byte to the buffer. For history, we
- /// append the serialized history right afterwards, to avoid having to handle serialization
- /// twice.
- ///
- /// Deletion simply refers to the history by ID
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
- // probably don't actually need to use rmp here, but if we ever need to extend it, it's a
- // nice wrapper around raw byte stuff
- use rmp::encode;
-
- let mut output = vec![];
-
- match self {
- Self::Create(history) => {
- // 0 -> a history create
- encode::write_u8(&mut output, 0)?;
-
- let bytes = history.serialize()?;
-
- encode::write_bin(&mut output, &bytes.0)?;
- }
- Self::Delete(id) => {
- // 1 -> a history delete
- encode::write_u8(&mut output, 1)?;
- encode::write_str(&mut output, id.0.as_str())?;
- }
- }
-
- Ok(DecryptedData(output))
- }
-
- pub(crate) fn deserialize(bytes: &DecryptedData, version: &str) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(&bytes.0);
-
- let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;
-
- match record_type {
- // 0 -> HistoryRecord::Create
- 0 => {
- // not super useful to us atm, but perhaps in the future
- // written by write_bin above
- decode::read_bin_len(&mut bytes).map_err(error_report)?;
-
- let record = History::deserialize(bytes.remaining_slice(), version)?;
-
- Ok(Self::Create(record))
- }
-
- // 1 -> HistoryRecord::Delete
- 1 => {
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- if !bytes.is_empty() {
- bail!(
- "trailing bytes decoding HistoryRecord::Delete - malformed? got {bytes:?}"
- );
- }
-
- Ok(Self::Delete(id.to_string().into()))
- }
-
- n => {
- bail!("unknown HistoryRecord type {n}")
- }
- }
- }
-}
-
-impl HistoryStore {
- pub(crate) fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {
- Self {
- store,
- host_id,
- encryption_key,
- }
- }
-
- async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {
- let bytes = record.serialize()?;
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx)
- .data(bytes)
- .build();
-
- let id = record.id;
-
- self.store
- .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))
- .await?;
-
- Ok((id, idx))
- }
-
- async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {
- let mut ret = Vec::new();
-
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- // Could probably _also_ do this as an iterator, but let's see how this is for now.
- // optimizing for minimal sqlite transactions, this code can be optimised later
- for (n, record) in records.enumerate() {
- let bytes = record.serialize()?;
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx + n as u64)
- .data(bytes)
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&self.encryption_key);
-
- ret.push(record);
- }
-
- self.store.push_batch(ret.iter()).await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> {
- let record = HistoryRecord::Delete(id);
-
- self.push_record(record).await
- }
-
- /// Delete a batch of history entries via the record store.
- /// Returns the record IDs so the caller can run `incremental_build` when ready.
- pub(crate) async fn delete_entries(
- &self,
- entries: impl IntoIterator<Item = History>,
- ) -> Result<Vec<RecordId>> {
- let mut record_ids = Vec::new();
- for entry in entries {
- let (id, _) = self.delete(entry.id).await?;
- record_ids.push(id);
- }
- Ok(record_ids)
- }
-
- pub(crate) async fn push(&self, history: History) -> Result<(RecordId, RecordIdx)> {
- // TODO(ellie): move the history store to its own file
- // it's tiny rn so fine as is
- let record = HistoryRecord::Create(history);
-
- self.push_record(record).await
- }
-
- pub(crate) async fn history(&self) -> Result<Vec<HistoryRecord>> {
- // Atm this loads all history into memory
- // Not ideal as that is potentially quite a lot, although history will be small.
- let records = self.store.all_tagged(HISTORY_TAG).await?;
- let mut ret = Vec::with_capacity(records.len());
-
- for record in records {
- let hist = match record.version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
-
- HistoryRecord::deserialize(&decrypted.data, version.as_str())
- }
- version => bail!("unknown history version {version:?}"),
- }?;
-
- ret.push(hist);
- }
-
- Ok(ret)
- }
-
- pub(crate) async fn build(&self, database: &ClientSqlite) -> Result<()> {
- // I'd like to change how we rebuild and not couple this with the database, but need to
- // consider the structure more deeply. This will be easy to change.
-
- // TODO(ellie): page or iterate this
- let history = self.history().await?;
-
- // In theory we could flatten this here
- // The current issue is that the database may have history in it already, from the old sync
- // This didn't actually delete old history
- // If we're sure we have a DB only maintained by the new store, we can flatten
- // create/delete before we even get to sqlite
- let mut creates = Vec::new();
- let mut deletes = Vec::new();
-
- for i in history {
- match i {
- HistoryRecord::Create(h) => {
- creates.push(h);
- }
- HistoryRecord::Delete(id) => {
- deletes.push(id);
- }
- }
- }
-
- database.save_bulk(&creates).await?;
- database.delete_rows(&deletes).await?;
-
- Ok(())
- }
-
- pub(crate) async fn incremental_build(
- &self,
- database: &ClientSqlite,
- ids: &[RecordId],
- ) -> Result<()> {
- for id in ids {
- let record = self.store.get(*id).await;
-
- if let Ok(record) = record {
- if record.tag != HISTORY_TAG {
- continue;
- }
-
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
- let record = match version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- HistoryRecord::deserialize(&decrypted.data, version.as_str())?
- }
- version => bail!("unknown history version {version:?}"),
- };
-
- match record {
- HistoryRecord::Create(h) => {
- // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time
- database.save(&h).await?;
- }
- HistoryRecord::Delete(id) => {
- database.delete_rows(&[id]).await?;
- }
- }
- }
- }
-
- Ok(())
- }
-
- /// Get a list of history IDs that exist in the store
- /// Note: This currently involves loading all history into memory. This is not going to be a
- /// large amount in absolute terms, but do not all it in a hot loop.
- pub(crate) async fn history_ids(&self) -> Result<HashSet<HistoryId>> {
- let history = self.history().await?;
-
- let ret = history
- .iter()
- .map(|h| match h {
- HistoryRecord::Create(h) => h.id.clone(),
- HistoryRecord::Delete(id) => id.clone(),
- })
- .collect::<HashSet<_>>();
-
- Ok(ret)
- }
-
- pub(crate) async fn init_store(&self, db: &ClientSqlite) -> Result<()> {
- let pb = ProgressBar::new_spinner();
- pb.set_style(
- ProgressStyle::with_template("{spinner:.blue} {msg}")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
- write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap();
- })
- .progress_chars("#>-"),
- );
- pb.enable_steady_tick(Duration::from_millis(500));
-
- pb.set_message("Fetching history from old database");
-
- let context = current_context().await?;
- let history = db.list(&[], &context, None, false, true).await?;
-
- pb.set_message("Fetching history already in store");
- let store_ids = self.history_ids().await?;
-
- pb.set_message("Converting old history to new store");
- let mut records = Vec::new();
-
- for i in history {
- debug!("loaded {}", i.id);
-
- if store_ids.contains(&i.id) {
- debug!("skipping {} - already exists", i.id);
- continue;
- }
-
- if i.deleted_at.is_some() {
- records.push(HistoryRecord::Delete(i.id));
- } else {
- records.push(HistoryRecord::Create(i));
- }
- }
-
- pb.set_message("Writing to db");
-
- if !records.is_empty() {
- self.push_batch(records.into_iter()).await?;
- }
-
- pb.finish_with_message("Import complete");
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::record::DecryptedData;
- use time::macros::datetime;
-
- use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord};
-
- use super::History;
-
- #[test]
- fn test_serialize_deserialize_create() {
- let bytes = [
- 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
- 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,
- 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,
- 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,
- 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,
- 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,
- 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,
- 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,
- ];
-
- let history = History {
- id: "018cd4fe81757cd2aee65cd7861f9c81".to_owned().into(),
- timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),
- duration: 100,
- exit: 0,
- command: "ls".to_owned(),
- cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
- session: "018cd4fead897597852527a31c998059".to_owned(),
- hostname: "boop:ellie".to_owned(),
- author: "ellie".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let record = HistoryRecord::Create(history);
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- // check the snapshot too
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-
- #[test]
- fn test_serialize_deserialize_delete() {
- let bytes = [
- 204, 1, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50,
- 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49,
- ];
- let record = HistoryRecord::Delete("018cd4fe81757cd2aee65cd7861f9c81".to_string().into());
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-}
diff --git a/crates/turtle/src/atuin_client/meta.rs b/crates/turtle/src/atuin_client/meta.rs
deleted file mode 100644
index 079c9926..00000000
--- a/crates/turtle/src/atuin_client/meta.rs
+++ /dev/null
@@ -1,182 +0,0 @@
-use std::path::Path;
-use std::str::FromStr;
-use std::time::Duration;
-
-use crate::atuin_common::record::HostId;
-use eyre::{Result, eyre};
-use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
-use time::{OffsetDateTime, format_description::well_known::Rfc3339};
-use tokio::sync::OnceCell;
-use tracing::debug;
-use uuid::Uuid;
-
-const KEY_HOST_ID: &str = "host_id";
-const KEY_LAST_SYNC: &str = "last_sync_time";
-
-pub(crate) struct MetaStore {
- pool: SqlitePool,
- cached_host_id: OnceCell<HostId>,
-}
-
-impl MetaStore {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
- let path = path.as_ref();
- let path_str = path
- .as_os_str()
- .to_str()
- .ok_or_else(|| eyre!("meta database path is not valid UTF-8: {path:?}"))?;
- debug!("opening meta sqlite database at {path:?}");
-
- let is_memory = path_str.contains(":memory:");
-
- if !is_memory
- && !path.exists()
- && let Some(dir) = path.parent()
- {
- fs_err::create_dir_all(dir)?;
- }
-
- // Use DELETE journal mode instead of WAL. This is a small, infrequently-
- // written KV store — WAL's concurrency benefits aren't needed, and DELETE
- // mode avoids creating auxiliary -wal/-shm files that complicate
- // permission handling.
- let opts = SqliteConnectOptions::from_str(path_str)?
- .journal_mode(SqliteJournalMode::Delete)
- .optimize_on_close(true, None)
- .create_if_missing(true);
-
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- sqlx::migrate!("./db/client-meta-migrations")
- .run(&pool)
- .await?;
-
- // Session tokens are stored in this database, so restrict permissions.
- #[cfg(unix)]
- if !is_memory {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
- }
-
- let store = Self {
- pool,
- cached_host_id: OnceCell::const_new(),
- };
-
- Ok(store)
- }
-
- // Generic key-value operations
-
- pub(crate) async fn get(&self, key: &str) -> Result<Option<String>> {
- let row: Option<(String,)> = sqlx::query_as("SELECT value FROM meta WHERE key = ?1")
- .bind(key)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(row.map(|r| r.0))
- }
-
- pub(crate) async fn set(&self, key: &str, value: &str) -> Result<()> {
- sqlx::query(
- "
- INSERT INTO meta (key, value, updated_at)
- VALUES (?1, ?2, strftime('%s', 'now'))
- ON CONFLICT(key) DO UPDATE
- SET value = ?2, updated_at = strftime('%s', 'now')
- ",
- )
- .bind(key)
- .bind(value)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- // Typed accessors
-
- pub(crate) async fn host_id(&self) -> Result<HostId> {
- self.cached_host_id
- .get_or_try_init(|| async {
- if let Some(id) = self.get(KEY_HOST_ID).await? {
- let parsed = Uuid::from_str(id.as_str())
- .map_err(|e| eyre!("failed to parse host ID: {e}"))?;
- return Ok(HostId(parsed));
- }
-
- let uuid = crate::atuin_common::utils::uuid_v7();
- self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
- .await?;
-
- Ok(HostId(uuid))
- })
- .await
- .copied()
- }
-
- pub(crate) async fn last_sync(&self) -> Result<OffsetDateTime> {
- match self.get(KEY_LAST_SYNC).await? {
- Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
- None => Ok(OffsetDateTime::UNIX_EPOCH),
- }
- }
-
- pub(crate) async fn save_sync_time(&self) -> Result<()> {
- self.set(
- KEY_LAST_SYNC,
- OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
- )
- .await
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{MetaStore, OffsetDateTime};
-
- async fn new_test_store() -> MetaStore {
- MetaStore::new("sqlite::memory:", 2.0).await.unwrap()
- }
-
- #[tokio::test]
- async fn test_get_set_delete() {
- let store = new_test_store().await;
-
- assert_eq!(store.get("foo").await.unwrap(), None);
-
- store.set("foo", "bar").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("bar".to_string()));
-
- store.set("foo", "baz").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("baz".to_string()));
-
- store.delete("foo").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), None);
- }
-
- #[tokio::test]
- async fn test_host_id_generation_and_stability() {
- let store = new_test_store().await;
-
- let id1 = store.host_id().await.unwrap();
- let id2 = store.host_id().await.unwrap();
-
- assert_eq!(id1, id2, "host_id should be stable across calls");
- }
-
- #[tokio::test]
- async fn test_sync_time() {
- let store = new_test_store().await;
-
- let t = store.last_sync().await.unwrap();
- assert_eq!(t, OffsetDateTime::UNIX_EPOCH);
-
- store.save_sync_time().await.unwrap();
- let t = store.last_sync().await.unwrap();
- assert!(t > OffsetDateTime::UNIX_EPOCH);
- }
-}
diff --git a/crates/turtle/src/atuin_client/mod.rs b/crates/turtle/src/atuin_client/mod.rs
deleted file mode 100644
index 851dfbdb..00000000
--- a/crates/turtle/src/atuin_client/mod.rs
+++ /dev/null
@@ -1,12 +0,0 @@
-pub(crate) mod api_client;
-pub(crate) mod database;
-pub(crate) mod encryption;
-pub(crate) mod history;
-pub(crate) mod meta;
-pub(crate) mod ordering;
-pub(crate) mod record;
-pub(crate) mod secrets;
-pub(crate) mod settings;
-pub(crate) mod theme;
-
-mod utils;
diff --git a/crates/turtle/src/atuin_client/ordering.rs b/crates/turtle/src/atuin_client/ordering.rs
deleted file mode 100644
index 84001f52..00000000
--- a/crates/turtle/src/atuin_client/ordering.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use minspan::minspan;
-
-use super::{history::History, settings::SearchMode};
-
-pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {
- match mode {
- SearchMode::Fuzzy => reorder(query, |x| &x.command, res),
- _ => res,
- }
-}
-
-#[expect(clippy::needless_pass_by_value, reason = "makes things easier")]
-fn reorder<F, A>(query: &str, f: F, res: Vec<A>) -> Vec<A>
-where
- F: Fn(&A) -> &String,
- A: Clone,
-{
- let mut r = res.clone();
- let qvec = &query.chars().collect();
- r.sort_by_cached_key(|h| {
- // TODO for fzf search we should sum up scores for each matched term
- //
- // The fallback is a little unfortunate: when we are asked to match a query that is found nowhere,
- // we don't want to return a None, as the comparison behaviour would put the worst matches
- // at the front. Therefore, we'll return a set of indices that are one larger than the longest
- // possible legitimate match. This is meaningless except as a comparison.
- let (from, to) = minspan::span(qvec, &(f(h).chars().collect())).unwrap_or((0, res.len()));
- 1 + to - from
- });
- r
-}
diff --git a/crates/turtle/src/atuin_client/record/encryption.rs b/crates/turtle/src/atuin_client/record/encryption.rs
deleted file mode 100644
index d8587cf6..00000000
--- a/crates/turtle/src/atuin_client/record/encryption.rs
+++ /dev/null
@@ -1,379 +0,0 @@
-use crate::atuin_common::record::{
- AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
-};
-use base64::{Engine, engine::general_purpose};
-use eyre::{Context, Result, ensure};
-use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
-use rusty_paseto::core::{
- ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
-};
-use serde::{Deserialize, Serialize};
-
-/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.
-#[expect(non_camel_case_types)]
-pub(crate) struct PASETO_V4;
-
-/*
-Why do we use a random content-encryption key?
-Originally I was planning on using a derived key for encryption based on additional data.
-This would be a lot more secure than using the master key directly.
-
-However, there's an established norm of using a random key. This scheme might be otherwise known as
-- client-side encryption
-- envelope encryption
-- key wrapping
-
-A HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey
-will have some keys that they keep to themselves. These keys never leave their physical hardware.
-If they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.
-This is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM
-and then store that with your data.
-
-See
- - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>
- - <https://cloud.google.com/kms/docs/envelope-encryption>
- - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>
- - <https://www.yubico.com/gb/product/yubihsm-2-fips/>
- - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>
-
-Why would we care? In the past we have received some requests for company solutions. If in future we can configure a
-KMS service with little effort, then that would solve a lot of issues for their security team.
-
-Even for personal use, if a user is not comfortable with sharing keys between hosts,
-GCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs
-1000 atuin records a day, that would only cost them $1 and 10 cent a month.
-
-Additionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.
-This makes it very fast to rotate a key in bulk.
-
-For future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting
-will need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption
-that happens in the background can make the network calls to the HSM
-*/
-
-impl Encryption for PASETO_V4 {
- fn re_encrypt(
- mut data: EncryptedData,
- _ad: AdditionalData<'_>,
- old_key: &[u8; 32],
- new_key: &[u8; 32],
- ) -> Result<EncryptedData> {
- let cek = Self::decrypt_cek(&data.content_encryption_key, old_key)?;
- data.content_encryption_key = Self::encrypt_cek(cek, new_key);
- Ok(data)
- }
-
- fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData {
- // generate a random key for this entry
- // aka content-encryption-key (CEK)
- let random_key = Key::<V4, Local>::new_os_random();
-
- let assertions = Assertions::from(ad).encode();
-
- // build the payload and encrypt the token
- let payload = serde_json::to_string(&AtuinPayload {
- data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
- })
- .expect("json encoding can't fail");
- let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
- let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
-
- let token = Paseto::<V4, LocalPurpose>::builder()
- .set_payload(Payload::from(payload.as_str()))
- .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
- .try_encrypt(&random_key.into(), &nonce)
- .expect("error encrypting atuin data");
-
- EncryptedData {
- data: token,
- content_encryption_key: Self::encrypt_cek(random_key, key),
- }
- }
-
- fn decrypt(
- data: EncryptedData,
- ad: AdditionalData<'_>,
- key: &[u8; 32],
- ) -> Result<DecryptedData> {
- let token = data.data;
- let cek = Self::decrypt_cek(&data.content_encryption_key, key)?;
-
- // encode the implicit assertions
- let assertions = Assertions::from(ad).encode();
-
- // decrypt the payload with the footer and implicit assertions
- let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
- &token,
- &cek.into(),
- None,
- ImplicitAssertion::from(&*assertions),
- )
- .context("could not decrypt entry")?;
-
- let payload: AtuinPayload = serde_json::from_str(&payload)?;
- let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
- Ok(DecryptedData(data))
- }
-}
-
-impl PASETO_V4 {
- fn decrypt_cek(wrapped_cek: &str, key: &[u8; 32]) -> Result<Key<V4, Local>> {
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
-
- // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));
-
- let AtuinFooter { kid, wpk } = serde_json::from_str(wrapped_cek)
- .context("wrapped cek did not contain the correct contents")?;
-
- // check that the wrapping key matches the required key to decrypt.
- // In future, we could support multiple keys and use this key to
- // look up the key rather than only allow one key.
- // For now though we will only support the one key and key rotation will
- // have to be a hard reset
- let current_kid = wrapping_key.to_id();
-
- ensure!(
- current_kid == kid,
- "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
- );
-
- // decrypt the random key
- Ok(wpk.unwrap_key(&wrapping_key)?)
- }
-
- fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
- // aka key-encryption-key (KEK)
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
- let kid = wrapping_key.to_id();
-
- // wrap the random key so we can decrypt it later
- let wrapped_cek = AtuinFooter {
- wpk: cek.wrap_pie(&wrapping_key),
- kid,
- };
- serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
- }
-}
-
-#[derive(Serialize, Deserialize)]
-struct AtuinPayload {
- data: String,
-}
-
-#[derive(Serialize, Deserialize)]
-/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.
-/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>
-struct AtuinFooter {
- /// Wrapped key
- wpk: PieWrappedKey<V4, Local>,
- /// ID of the key which was used to wrap
- kid: KeyId<V4, Local>,
-}
-
-/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.
-// This cannot be changed, otherwise it breaks the authenticated encryption.
-#[derive(Debug, Copy, Clone, Serialize)]
-struct Assertions<'a> {
- id: &'a RecordId,
- idx: &'a RecordIdx,
- version: &'a str,
- tag: &'a str,
- host: &'a HostId,
-}
-
-impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
- fn from(ad: AdditionalData<'a>) -> Self {
- Self {
- id: ad.id,
- version: ad.version,
- tag: ad.tag,
- host: ad.host,
- idx: ad.idx,
- }
- }
-}
-
-impl Assertions<'_> {
- fn encode(&self) -> String {
- serde_json::to_string(self).expect("could not serialize implicit assertions")
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::{
- record::{Host, Record},
- utils::uuid_v7,
- };
-
- use super::{
- AdditionalData, DecryptedData, Encryption, HostId, Key, Local, PASETO_V4, RecordId, V4,
- };
-
- #[test]
- fn round_trip() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn same_entry_different_output() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- assert_ne!(
- encrypted.data, encrypted2.data,
- "re-encrypting the same contents should have different output due to key randomization"
- );
- }
-
- #[test]
- fn cannot_decrypt_different_key() {
- let key = Key::<V4, Local>::new_os_random();
- let fake_key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
- drop(PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn cannot_decrypt_different_id() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- ..ad
- };
- drop(PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn re_encrypt_round_trip() {
- let key1 = Key::<V4, Local>::new_os_random();
- let key2 = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
- let encrypted2 =
- PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
- .unwrap();
-
- // we only re-encrypt the content keys
- assert_eq!(encrypted1.data, encrypted2.data);
- assert_ne!(
- encrypted1.content_encryption_key,
- encrypted2.content_encryption_key
- );
-
- let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
-
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn full_record_round_trip() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- assert!(!encrypted.data.data.is_empty());
- assert!(!encrypted.data.content_encryption_key.is_empty());
-
- let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
-
- assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
- }
-
- #[test]
- fn full_record_round_trip_fail() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- let mut enc1 = encrypted.clone();
- enc1.host = Host::new(HostId(uuid_v7()));
- let _ = enc1
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the host should result in auth failure");
-
- let mut enc2 = encrypted;
- enc2.id = RecordId(uuid_v7());
- let _ = enc2
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the id should result in auth failure");
- }
-}
diff --git a/crates/turtle/src/atuin_client/record/mod.rs b/crates/turtle/src/atuin_client/record/mod.rs
deleted file mode 100644
index 4e5774ea..00000000
--- a/crates/turtle/src/atuin_client/record/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub(crate) mod encryption;
-pub(crate) mod sqlite_store;
-pub(crate) mod sync;
diff --git a/crates/turtle/src/atuin_client/record/sqlite_store.rs b/crates/turtle/src/atuin_client/record/sqlite_store.rs
deleted file mode 100644
index 18f5c869..00000000
--- a/crates/turtle/src/atuin_client/record/sqlite_store.rs
+++ /dev/null
@@ -1,563 +0,0 @@
-// Here we are using sqlite as a pretty dumb store, and will not be running any complex queries.
-// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index
-// by tag/host
-
-use std::path::Path;
-use std::str::FromStr;
-
-use eyre::{Result, eyre};
-use fs_err as fs;
-
-use sqlx::{
- Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow},
-};
-use tracing::debug;
-
-use crate::atuin_client::utils::setup_db;
-use crate::atuin_common::record::{
- EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,
-};
-use crate::atuin_common::utils;
-use uuid::Uuid;
-
-use super::encryption::PASETO_V4;
-
-#[derive(Debug, Clone)]
-pub(crate) struct SqliteStore {
- pool: SqlitePool,
-}
-
-impl SqliteStore {
- pub(crate) 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)
- .foreign_keys(true)
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
-
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-record-migrations").await?;
-
- Ok(Self { pool })
- }
-
- async fn save_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- r: &Record<EncryptedData>,
- ) -> Result<()> {
- // In sqlite, we are "limited" to i64. But that is still fine, until 2262.
- sqlx::query(
- "insert or ignore into store(id, idx, host, tag, timestamp, version, data, cek)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
- )
- .bind(r.id.0.as_hyphenated().to_string())
- .bind(r.idx as i64)
- .bind(r.host.id.0.as_hyphenated().to_string())
- .bind(r.tag.as_str())
- .bind(r.timestamp as i64)
- .bind(r.version.as_str())
- .bind(r.data.data.as_str())
- .bind(r.data.content_encryption_key.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(
- clippy::needless_pass_by_value,
- reason = "this is used in a place with fixed function signature"
- )]
- fn query_row(row: SqliteRow) -> Record<EncryptedData> {
- let idx: i64 = row.get("idx");
- let timestamp: i64 = row.get("timestamp");
-
- // tbh at this point things are pretty fucked so just panic
- let id = Uuid::from_str(row.get("id")).expect("invalid id UUID format in sqlite DB");
- let host = Uuid::from_str(row.get("host")).expect("invalid host UUID format in sqlite DB");
-
- Record {
- id: RecordId(id),
- idx: idx as u64,
- host: Host::new(HostId(host)),
- timestamp: timestamp as u64,
- tag: row.get("tag"),
- version: row.get("version"),
- data: EncryptedData {
- data: row.get("data"),
- content_encryption_key: row.get("cek"),
- },
- }
- }
-
- async fn load_all(&self) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store ")
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-/// A record store stores records
-/// In more detail - we tend to need to process this into _another_ format to actually query it.
-/// As is, the record store is intended as the source of truth for arbitrary data, which could
-/// be shell history, kvs, etc.
-impl SqliteStore {
- /// Push a record
- pub(crate) async fn push(&self, record: &Record<EncryptedData>) -> Result<()> {
- self.push_batch(std::iter::once(record)).await
- }
-
- /// Push a batch of records, all in one transaction
- pub(crate) async fn push_batch(
- &self,
- records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,
- ) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for record in records {
- Self::save_raw(&mut tx, record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>> {
- let res = sqlx::query("select * from store where store.id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn delete(&self, id: RecordId) -> Result<()> {
- sqlx::query("delete from store where id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete_all(&self) -> Result<()> {
- sqlx::query("delete from store").execute(&self.pool).await?;
-
- Ok(())
- }
-
- pub(crate) async fn last(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res =
- sqlx::query("select * from store where host=?1 and tag=?2 order by idx desc limit 1")
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(record) => Ok(Some(record)),
- }
- }
-
- pub(crate) async fn first(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- self.idx(host, tag, 0).await
- }
-
- pub(crate) async fn len_tag(&self, tag: &str) -> Result<u64> {
- let res: Result<(i64,), sqlx::Error> =
- sqlx::query_as("select count(*) from store where tag=?1")
- .bind(tag)
- .fetch_one(&self.pool)
- .await;
- match res {
- Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
- Ok(v) => Ok(v.0 as u64),
- }
- }
-
- /// Get the next `limit` records, after and including the given index
- pub(crate) async fn next(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- limit: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query(
- "select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4",
- )
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .bind(limit as i64)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Get the first record for a given host and tag
- pub(crate) async fn idx(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where idx = ?1 and host = ?2 and tag = ?3")
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(v) => Ok(Some(v)),
- }
- }
-
- pub(crate) async fn status(&self) -> Result<RecordStatus> {
- let mut status = RecordStatus::new();
-
- let res: Result<Vec<(String, String, i64)>, sqlx::Error> =
- sqlx::query_as("select host, tag, max(idx) from store group by host, tag")
- .fetch_all(&self.pool)
- .await;
-
- let res = match res {
- Err(e) => return Err(eyre!("failed to fetch local store status: {}", e)),
- Ok(v) => v,
- };
-
- for i in res {
- let host = HostId(
- Uuid::from_str(i.0.as_str()).expect("failed to parse uuid for local store status"),
- );
-
- status.set_raw(host, i.1, i.2 as u64);
- }
-
- Ok(status)
- }
-
- /// Get all records for a given tag
- pub(crate) async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where tag = ?1 order by timestamp asc")
- .bind(tag)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Reencrypt every single item in this store with a new key
- /// Be careful - this may mess with sync.
- pub(crate) async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()> {
- // Load all the records
- // In memory like some of the other code here
- // This will never be called in a hot loop, and only under the following circumstances
- // 1. The user has logged into a new account, with a new key. They are unlikely to have a
- // lot of data
- // 2. The user has encountered some sort of issue, and runs a maintenance command that
- // invokes this
- let all = self.load_all().await?;
-
- let re_encrypted = all
- .into_iter()
- .map(|record| record.re_encrypt::<PASETO_V4>(old_key, new_key))
- .collect::<Result<Vec<_>>>()?;
-
- // next up, we delete all the old data and reinsert the new stuff
- // do it in one transaction, so if anything fails we rollback OK
-
- let mut tx = self.pool.begin().await?;
-
- let res = sqlx::query("delete from store").execute(&mut *tx).await?;
-
- let rows = res.rows_affected();
- debug!("deleted {rows} rows");
-
- // don't call push_batch, as it will start its own transaction
- // call the underlying save_raw
-
- for record in re_encrypted {
- Self::save_raw(&mut tx, &record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn verify(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- all.into_iter()
- .map(|record| record.decrypt::<PASETO_V4>(key))
- .collect::<Result<Vec<_>>>()?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn purge(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- for record in &all {
- if record.clone().decrypt::<PASETO_V4>(key).is_ok() {
- continue;
- }
-
- println!(
- "Failed to decrypt {}, deleting",
- record.id.0.as_hyphenated()
- );
-
- self.delete(record.id).await?;
- }
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{
- atuin_client::{
- encryption::generate_encoded_key, record::encryption::PASETO_V4,
- settings::test_local_timeout,
- },
- atuin_common::{
- record::{DecryptedData, EncryptedData, Host, HostId, Record},
- utils::uuid_v7,
- },
- };
-
- use super::SqliteStore;
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(Host::new(HostId(uuid_v7())))
- .version("v1".into())
- .tag(uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: "1234".into(),
- content_encryption_key: "1234".into(),
- })
- .idx(0)
- .build()
- }
-
- #[tokio::test]
- async fn create_db() {
- let db = SqliteStore::new(":memory:", test_local_timeout()).await;
-
- assert!(
- db.is_ok(),
- "db could not be created, {:?}",
- db.err().unwrap()
- );
- }
-
- #[tokio::test]
- async fn push_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
-
- db.push(&record).await.expect("failed to insert record");
- }
-
- #[tokio::test]
- async fn get_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let new_record = db.get(record.id).await.expect("failed to fetch record");
-
- assert_eq!(record, new_record, "records are not equal");
- }
-
- #[tokio::test]
- async fn last() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let last = db
- .last(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- last.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn first() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let first = db
- .first(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- first.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn len() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn len_tag() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len_tag(record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn re_encrypt() {
- let store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let (key, _) = generate_encoded_key().unwrap();
- let data = vec![0u8, 1u8, 2u8, 3u8];
- let host_id = HostId(uuid_v7());
-
- for i in 0..10 {
- let record = Record::builder()
- .host(Host::new(host_id))
- .version(String::from("test"))
- .tag(String::from("test"))
- .idx(i)
- .data(DecryptedData(data.clone()))
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&key.into());
- store
- .push(&record)
- .await
- .expect("failed to push encrypted record");
- }
-
- // first, check that we can decrypt the data with the current key
- let all = store.all_tagged("test").await.unwrap();
-
- assert_eq!(all.len(), 10, "failed to fetch all records");
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- // reencrypt the store, then check if
- // 1) it cannot be decrypted with the old key
- // 2) it can be decrypted with the new key
-
- let (new_key, _) = generate_encoded_key().unwrap();
- store
- .re_encrypt(&key.into(), &new_key.into())
- .await
- .expect("failed to re-encrypt store");
-
- let all = store.all_tagged("test").await.unwrap();
-
- for record in all.iter() {
- let decrypted = record.clone().decrypt::<PASETO_V4>(&key.into());
- assert!(
- decrypted.is_err(),
- "did not get error decrypting with old key after re-encrypt"
- )
- }
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&new_key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- assert_eq!(store.len(host_id, "test").await.unwrap(), 10);
- }
-}
diff --git a/crates/turtle/src/atuin_client/record/sync.rs b/crates/turtle/src/atuin_client/record/sync.rs
deleted file mode 100644
index da05533c..00000000
--- a/crates/turtle/src/atuin_client/record/sync.rs
+++ /dev/null
@@ -1,456 +0,0 @@
-// do a sync :O
-use std::{cmp::Ordering, fmt::Write};
-
-use eyre::{OptionExt, Result};
-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::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-
-#[derive(Error, Debug)]
-pub(crate) enum SyncError {
- #[error("an issue with the local database occurred: {msg:?}")]
- LocalStoreError { msg: String },
-
- #[error("something has gone wrong with the sync logic: {msg:?}")]
- SyncLogicError { msg: String },
-
- #[error("operational error: {msg:?}")]
- OperationalError { msg: String },
-
- #[error("a request to the sync server failed: {msg}")]
- RemoteRequestError { msg: String },
-
- #[error(
- "the encryption key on this machine does not match the data on the server. \
- this usually means a new machine was set up without copying the existing key. \
- to fix: run `atuin key` on a machine that already syncs correctly, then run \
- `atuin store rekey <key>` on this machine with the value from the other machine"
- )]
- WrongKey,
-}
-
-#[derive(Debug, Eq, PartialEq)]
-pub(crate) enum Operation {
- // Either upload or download until the states matches the below
- Upload {
- local: RecordIdx,
- remote: Option<RecordIdx>,
- host: HostId,
- tag: String,
- },
- Download {
- local: Option<RecordIdx>,
- remote: RecordIdx,
- host: HostId,
- tag: String,
- },
- Noop {
- host: HostId,
- tag: String,
- },
-}
-
-pub(crate) fn build_client(settings: &Settings) -> Result<Client<'_>, SyncError> {
- Client::new(
- &settings.sync.address,
- settings.network_connect_timeout,
- settings.network_timeout,
- settings
- .sync
- .user_id()
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?
- .ok_or_eyre("No sync user-id set")
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,
- )
- .map_err(|e| SyncError::OperationalError { msg: e.to_string() })
-}
-
-pub(crate) async fn diff(
- client: &Client<'_>,
- store: &SqliteStore,
-) -> Result<(Vec<Diff>, RecordStatus), SyncError> {
- let local_index = store
- .status()
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- let remote_index = client
- .record_status()
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let diff = local_index.diff(&remote_index);
-
- Ok((diff, remote_index))
-}
-
-// Take a diff, along with a local store, and resolve it into a set of operations.
-// With the store as context, we can determine if a tail exists locally or not and therefore if it needs uploading or download.
-// In theory this could be done as a part of the diffing stage, but it's easier to reason
-// about and test this way
-pub(crate) fn operations(
- diffs: Vec<Diff>,
- _store: &SqliteStore,
-) -> Result<Vec<Operation>, SyncError> {
- let mut operations = Vec::with_capacity(diffs.len());
-
- for diff in diffs {
- let op = match (diff.local, diff.remote) {
- // We both have it! Could be either. Compare.
- (Some(local), Some(remote)) => match local.cmp(&remote) {
- Ordering::Equal => Operation::Noop {
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Greater => Operation::Upload {
- local,
- remote: Some(remote),
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Less => Operation::Download {
- local: Some(local),
- remote,
- host: diff.host,
- tag: diff.tag,
- },
- },
-
- // Remote has it, we don't. Gotta be download
- (None, Some(remote)) => Operation::Download {
- local: None,
- remote,
- host: diff.host,
- tag: diff.tag,
- },
-
- // We have it, remote doesn't. Gotta be upload.
- (Some(local), None) => Operation::Upload {
- local,
- remote: None,
- host: diff.host,
- tag: diff.tag,
- },
-
- // something is pretty fucked.
- (None, None) => {
- return Err(SyncError::SyncLogicError {
- msg: String::from(
- "diff has nothing for local or remote - (host, tag) does not exist",
- ),
- });
- }
- };
-
- operations.push(op);
- }
-
- // sort them - purely so we have a stable testing order, and can rely on
- // same input = same output
- // We can sort by ID so long as we continue to use UUIDv7 or something
- // with the same properties
-
- operations.sort_by_key(|op| match op {
- Operation::Noop { host, tag } => (0, *host, tag.clone()),
-
- Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),
-
- Operation::Download { host, tag, .. } => (2, *host, tag.clone()),
- });
-
- Ok(operations)
-}
-
-async fn sync_upload(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: RecordIdx,
- remote: Option<RecordIdx>,
- page_size: u64,
-) -> Result<i64, SyncError> {
- let remote = remote.unwrap_or(0);
- let expected = local - remote;
- let mut progress = 0;
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- println!(
- "Uploading {} records to {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- loop {
- let page = store
- .next(host, tag.as_str(), remote + progress, page_size)
- .await
- .map_err(|e| {
- error!("failed to read upload page: {e:?}");
-
- SyncError::LocalStoreError { msg: e.to_string() }
- })?;
-
- if page.is_empty() {
- break;
- }
-
- client.post_records(&page).await.map_err(|e| {
- error!("failed to post records: {e:?}");
-
- SyncError::RemoteRequestError { msg: e.to_string() }
- })?;
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Uploaded records");
-
- Ok(progress as i64)
-}
-
-async fn sync_download(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: Option<RecordIdx>,
- remote: RecordIdx,
- page_size: u64,
-) -> Result<Vec<RecordId>, SyncError> {
- let local = local.unwrap_or(0);
- let expected = remote - local;
- let mut progress = 0;
- let mut ret = Vec::new();
-
- println!(
- "Downloading {} records from {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- loop {
- let page = client
- .next_records(host, tag.clone(), local + progress, page_size)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- if page.is_empty() {
- break;
- }
-
- store
- .push_batch(page.iter())
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- ret.extend(page.iter().map(|f| f.id));
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Downloaded records");
-
- Ok(ret)
-}
-
-pub(crate) async fn sync_remote(
- client: &Client<'_>,
- operations: Vec<Operation>,
- local_store: &SqliteStore,
- page_size: u64,
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let mut uploaded = 0;
- let mut downloaded = Vec::new();
-
- // this can totally run in parallel, but lets get it working first
- for i in operations {
- match i {
- Operation::Upload {
- host,
- tag,
- local,
- remote,
- } => {
- uploaded +=
- sync_upload(local_store, client, host, tag, local, remote, page_size).await?;
- }
-
- Operation::Download {
- host,
- tag,
- local,
- remote,
- } => {
- let mut d =
- sync_download(local_store, client, host, tag, local, remote, page_size).await?;
- downloaded.append(&mut d);
- }
-
- Operation::Noop { .. } => (),
- }
- }
-
- Ok((uploaded, downloaded))
-}
-
-pub(crate) async fn check_encryption_key(
- client: &Client<'_>,
- remote_index: &RecordStatus,
- encryption_key: &[u8; 32],
-) -> Result<(), SyncError> {
- let sample = remote_index
- .hosts
- .iter()
- .flat_map(|(host, tags)| tags.keys().map(move |tag| (*host, tag.clone())))
- .next();
-
- let Some((host, tag)) = sample else {
- return Ok(());
- };
-
- let records = client
- .next_records(host, tag, 0, 1)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let Some(record) = records.into_iter().next() else {
- return Ok(());
- };
-
- record.decrypt::<PASETO_V4>(encryption_key).map_err(|err| {
- error!("Wrong key error: {err}");
- SyncError::WrongKey
- })?;
-
- Ok(())
-}
-
-pub(crate) async fn sync(
- settings: &Settings,
- store: &SqliteStore,
- encryption_key: &[u8; 32],
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let client = build_client(settings)?;
- let (diff, remote_index) = diff(&client, store).await?;
-
- // Bail before mutating either side if the local key can't read the remote.
- check_encryption_key(&client, &remote_index, encryption_key).await?;
-
- let operations = operations(diff, store)?;
- let (uploaded, downloaded) = sync_remote(&client, operations, store, 100).await?;
-
- Ok((uploaded, downloaded))
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::record::sync::Operation;
- use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record};
-
- use crate::atuin_client::{
- record::{
- sqlite_store::SqliteStore,
- sync::{self},
- },
- settings::test_local_timeout,
- };
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(crate::atuin_common::record::Host::new(HostId(
- crate::atuin_common::utils::uuid_v7(),
- )))
- .version("v1".into())
- .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: String::new(),
- content_encryption_key: String::new(),
- })
- .idx(0)
- .build()
- }
-
- // Take a list of local records, and a list of remote records.
- // Return the local database, and a diff of local/remote, ready to build
- // ops
- async fn build_test_diff(
- local_records: Vec<Record<EncryptedData>>,
- remote_records: Vec<Record<EncryptedData>>,
- ) -> (SqliteStore, Vec<Diff>) {
- let local_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite");
- let remote_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite"); // "remote"
-
- for i in local_records {
- local_store.push(&i).await.unwrap();
- }
-
- for i in remote_records {
- remote_store.push(&i).await.unwrap();
- }
-
- let local_index = local_store.status().await.unwrap();
- let remote_index = remote_store.status().await.unwrap();
-
- let diff = local_index.diff(&remote_index);
-
- (local_store, diff)
- }
-
- #[tokio::test]
- async fn test_basic_diff() {
- // a diff where local is ahead of remote. nothing else.
-
- let record = test_record();
- let (store, diff) = build_test_diff(vec![record.clone()], vec![]).await;
-
- assert_eq!(diff.len(), 1);
-
- let operations = sync::operations(diff, &store).unwrap();
-
- assert_eq!(operations.len(), 1);
-
- assert_eq!(
- operations[0],
- Operation::Upload {
- host: record.host.id,
- tag: record.tag,
- local: record.idx,
- remote: None,
- }
- );
- }
-}
diff --git a/crates/turtle/src/atuin_client/secrets.rs b/crates/turtle/src/atuin_client/secrets.rs
deleted file mode 100644
index 74d47ea6..00000000
--- a/crates/turtle/src/atuin_client/secrets.rs
+++ /dev/null
@@ -1,223 +0,0 @@
-// This file will probably trigger a lot of scanners. Sorry.
-
-use regex::RegexSet;
-use std::sync::LazyLock;
-
-#[cfg(test)]
-pub(crate) enum TestValue<'a> {
- Single(&'a str),
- Multiple(&'a [&'a str]),
-}
-
-#[cfg(test)]
-type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)];
-
-#[cfg(not(test))]
-type SpType<'a> = &'a [(&'a str, &'a str)];
-
-/// A list of `(name, regex, test)`, where `test` should match against `regex`.
-pub(crate) static SECRET_PATTERNS: SpType<'_> = &[
- (
- "AWS Access Key ID",
- "A[KS]IA[0-9A-Z]{16}",
- #[cfg(test)]
- TestValue::Single("AKIAIOSFODNN7EXAMPLE"),
- ),
- (
- "AWS Secret Access Key env var",
- "AWS_SECRET_ACCESS_KEY",
- #[cfg(test)]
- TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"),
- ),
- (
- "AWS Session Token env var",
- "AWS_SESSION_TOKEN",
- #[cfg(test)]
- TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"),
- ),
- (
- "Microsoft Azure secret access key env var",
- "AZURE_.*_KEY",
- #[cfg(test)]
- TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"),
- ),
- (
- "Google cloud platform key env var",
- "GOOGLE_SERVICE_ACCOUNT_KEY",
- #[cfg(test)]
- TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"),
- ),
- (
- "Atuin login",
- r"atuin\s+login",
- #[cfg(test)]
- TestValue::Single(
- "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"",
- ),
- ),
- (
- "GitHub PAT (old)",
- "ghp_[a-zA-Z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("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"), // legit, I expired it
- ),
- (
- "GitHub PAT (new)",
- "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}",
- #[cfg(test)]
- TestValue::Multiple(&[
- "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm",
- "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", // also legit, also expired
- ]),
- ),
- (
- "GitHub OAuth Access Token",
- "gho_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
- ),
- (
- "GitHub OAuth Access Token (user)",
- "ghu_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
- ),
- (
- "GitHub App Installation Access Token",
- "ghs_[A-Za-z0-9._-]{36,}",
- #[cfg(test)]
- TestValue::Multiple(&[
- "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token
- "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data
- ]),
- ),
- (
- "GitHub Refresh Token",
- "ghr_[A-Za-z0-9]{76}",
- #[cfg(test)]
- TestValue::Single(
- "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx",
- ), // not a real token
- ),
- (
- "GitHub App Installation Access Token v1",
- "v1\\.[0-9A-Fa-f]{40}",
- #[cfg(test)]
- TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token
- ),
- (
- "GitLab PAT",
- "glpat-[a-zA-Z0-9_]{20}",
- #[cfg(test)]
- TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"),
- ),
- (
- "Slack OAuth v2 bot",
- "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
- ),
- (
- "Slack OAuth v2 user token",
- "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
- ),
- (
- "Slack webhook",
- "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}",
- #[cfg(test)]
- TestValue::Single(
- "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
- ),
- ),
- (
- "Stripe test key",
- "sk_test_[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("sk_test_1234567890abcdefghijklmnop"),
- ),
- (
- "Stripe live key",
- "sk_live_[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("sk_live_1234567890abcdefghijklmnop"),
- ),
- (
- "Netlify authentication token",
- "nf[pcoub]_[0-9a-zA-Z]{36}",
- #[cfg(test)]
- TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"),
- ),
- (
- "npm token",
- "npm_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("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"),
- ),
- (
- "Pulumi personal access token",
- "pul-[0-9a-f]{40}",
- #[cfg(test)]
- TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"),
- ),
-];
-
-/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`.
-pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {
- let exprs = SECRET_PATTERNS.iter().map(|f| f.1);
- RegexSet::new(exprs).expect("Failed to build secrets regex")
-});
-
-#[cfg(test)]
-mod tests {
- use regex::Regex;
-
- use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue};
-
- #[test]
- fn test_secrets() {
- for (name, regex, test) in SECRET_PATTERNS {
- let re =
- Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
-
- match test {
- TestValue::Single(test) => {
- assert!(re.is_match(test), "{name} test failed!");
- }
- TestValue::Multiple(tests) => {
- for test_str in tests.iter() {
- assert!(
- re.is_match(test_str),
- "{name} test with value \"{test_str}\" failed!"
- );
- }
- }
- }
- }
- }
-
- #[test]
- fn test_secrets_embedded() {
- for (name, regex, test) in SECRET_PATTERNS {
- let re =
- Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
-
- match test {
- TestValue::Single(test) => {
- let embedded = format!("some random text {test} some more random text");
- assert!(re.is_match(&embedded), "{name} embedded test failed!");
- }
- TestValue::Multiple(tests) => {
- for test_str in tests.iter() {
- let embedded = format!("some random text {test_str} some more random text");
- assert!(
- re.is_match(&embedded),
- "{name} embedded test with value \"{test_str}\" failed!"
- );
- }
- }
- }
- }
- }
-}
diff --git a/crates/turtle/src/atuin_client/settings.rs b/crates/turtle/src/atuin_client/settings.rs
deleted file mode 100644
index 9e14c4c8..00000000
--- a/crates/turtle/src/atuin_client/settings.rs
+++ /dev/null
@@ -1,1543 +0,0 @@
-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::atuin_common::utils;
-use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId};
-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};
-
-static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
-static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
-static META_STORE: OnceCell<crate::atuin_client::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(crate) struct Daemon {
- /// The daemon will handle sync on an interval. How often to sync, in seconds.
- pub(crate) sync_frequency: u64,
-
- /// The path to the unix socket used by the daemon
- pub(crate) socket_path: String,
-
- /// Path to the daemon pidfile used for process coordination.
- pub(crate) pidfile_path: String,
-
- /// Use a socket passed via systemd's socket activation protocol, instead of the path
- pub(crate) systemd_socket: bool,
-
- /// The port that should be used for TCP on non unix systems
- pub(crate) tcp_port: u64,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Search {
- /// The list of enabled filter modes, in order of priority.
- pub(crate) filters: Vec<FilterMode>,
-
- /// The recency score multiplier for the search index (default: 1.0).
- /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
- pub(crate) recency_score_multiplier: f64,
-
- /// The frequency score multiplier for the search index (default: 1.0).
- /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
- pub(crate) frequency_score_multiplier: f64,
-
- /// The overall frecency score multiplier for the search index (default: 1.0).
- /// Applied after combining recency and frequency scores.
- pub(crate) frecency_score_multiplier: f64,
-}
-
-/// Log level for file logging. Maps to tracing's [`LevelFilter`].
-#[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(crate) struct Settings {
- pub(crate) data_dir: Option<String>,
- pub(crate) dialect: Dialect,
- pub(crate) timezone: Timezone,
- pub(crate) style: Style,
-
- pub(crate) db_path: String,
- pub(crate) record_store_path: String,
- pub(crate) search_mode: SearchMode,
- pub(crate) filter_mode: Option<FilterMode>,
- pub(crate) filter_mode_shell_up_key_binding: Option<FilterMode>,
- pub(crate) search_mode_shell_up_key_binding: Option<SearchMode>,
- pub(crate) shell_up_key_binding: bool,
- pub(crate) inline_height: u16,
- pub(crate) inline_height_shell_up_key_binding: Option<u16>,
- pub(crate) invert: bool,
- pub(crate) show_preview: bool,
- pub(crate) max_preview_height: u16,
- 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(crate) 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(crate) daemon: Daemon,
-
- #[serde(default)]
- pub(crate) search: Search,
-
- #[serde(default)]
- pub(crate) ui: Ui,
-
- #[serde(default)]
- pub(crate) logs: Logs,
-
- #[serde(default)]
- pub(crate) meta: meta::Settings,
-}
-
-impl Settings {
- // -- Meta store: lazily initialized on first access --
-
- pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::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::atuin_client::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(crate) 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,
- crate::atuin_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/turtle/src/atuin_client/settings/meta.rs b/crates/turtle/src/atuin_client/settings/meta.rs
deleted file mode 100644
index cc5afcf7..00000000
--- a/crates/turtle/src/atuin_client/settings/meta.rs
+++ /dev/null
@@ -1,17 +0,0 @@
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug, Serialize, Deserialize, Clone)]
-pub(crate) struct Settings {
- pub(crate) db_path: String,
-}
-
-impl Default for Settings {
- fn default() -> Self {
- let dir = crate::atuin_common::utils::data_dir();
- let path = dir.join("meta.db");
-
- Self {
- db_path: path.to_string_lossy().to_string(),
- }
- }
-}
diff --git a/crates/turtle/src/atuin_client/settings/watcher.rs b/crates/turtle/src/atuin_client/settings/watcher.rs
deleted file mode 100644
index 5eec3692..00000000
--- a/crates/turtle/src/atuin_client/settings/watcher.rs
+++ /dev/null
@@ -1,260 +0,0 @@
-//! Config file watching for automatic settings reload.
-//!
-//! This module provides a `SettingsWatcher` that monitors the config file
-//! for changes and broadcasts updated settings via a `tokio::sync::watch` channel.
-//!
-//! # Example
-//!
-//! ```no_run
-//! use crate::atuin_client::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(|_| crate::atuin_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/turtle/src/atuin_client/theme.rs b/crates/turtle/src/atuin_client/theme.rs
deleted file mode 100644
index ec0538e9..00000000
--- a/crates/turtle/src/atuin_client/theme.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
-pub(crate) fn style_base() -> ContentStyle {
- ContentStyle::default()
-}
-pub(crate) fn style_annotation() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::DarkGrey),
- ..ContentStyle::default()
- }
-}
-pub(crate) fn style_important() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::White),
- attributes: Attributes::from(Attribute::Bold),
- ..ContentStyle::default()
- }
-}
-pub(crate) fn style_guidance() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::DarkBlue),
- ..ContentStyle::default()
- }
-}
-pub(crate) fn style_alerterror() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::DarkRed),
- ..ContentStyle::default()
- }
-}
-pub(crate) fn style_alertinfo() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::DarkGreen),
- ..ContentStyle::default()
- }
-}
-pub(crate) fn style_alertwarn() -> ContentStyle {
- ContentStyle {
- foreground_color: Some(Color::DarkYellow),
- ..ContentStyle::default()
- }
-}
diff --git a/crates/turtle/src/atuin_client/utils.rs b/crates/turtle/src/atuin_client/utils.rs
deleted file mode 100644
index 989f9fc1..00000000
--- a/crates/turtle/src/atuin_client/utils.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-pub(crate) fn get_hostname() -> String {
- std::env::var("ATUIN_HOST_NAME")
- .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string()))
-}
-
-pub(crate) fn get_username() -> String {
- std::env::var("ATUIN_HOST_USER")
- .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string()))
-}
-
-/// Returns a pair of the hostname and username, separated by a colon.
-pub(crate) fn get_host_user() -> String {
- format!("{}:{}", get_hostname(), get_username())
-}
-
-/// Setup a [`SQLite`] database.
-///
-/// This takes care of correct locking, so that we avoid a race when setting up the database.
-macro_rules! setup_db {
- (
- $db_path:expr,
- $a_timeout:expr,
- $opts:expr,
- $m_name:literal $(,)?
- ) => {{
- async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> {
- { sqlx::sqlx_macros::migrate!($m_name) }.run(pool).await?;
- Ok(())
- }
-
- crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
- }};
-}
-pub(crate) use setup_db;
-
-use std::{os::fd::AsRawFd, path::Path, time::Duration};
-
-use fs_err::OpenOptions;
-use sqlx::{
- SqlitePool,
- sqlite::{SqliteConnectOptions, SqlitePoolOptions},
-};
-use tracing::debug;
-
-/// Helper for `setup_db!`
-pub(crate) async fn setup_db_inner(
- db_path: &Path,
- acquire_timeout: f64,
- mk_opts: fn(&str) -> sqlx::Result<SqliteConnectOptions>,
- migrate: impl AsyncFn(&SqlitePool) -> sqlx::Result<()>,
-) -> sqlx::Result<SqlitePool> {
- async fn open_db(timeout: f64, opts: SqliteConnectOptions) -> sqlx::Result<SqlitePool> {
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- Ok(pool)
- }
-
- {
- let file = OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .open(db_path)?;
-
- // Lock the db file while we are running the migrations.
- // Why? Because there is a small chance that we start running migrations (e.g. as the daemon)
- // and then another process is started, which will also try to run migrations.
- // Essentially, one of the processes will receive with a SQLite UNIQUE constraint failure.
- // So let's avoid that possibility from the start.
- file.lock()?;
-
- let pool = open_db(
- acquire_timeout,
- mk_opts(format!("/proc/self/fd/{}", file.as_raw_fd()).as_str())?,
- )
- .await?;
-
- debug!("running sqlite database setup");
-
- migrate(&pool).await?;
-
- file.unlock()?;
- }
-
- let real_opts = mk_opts(db_path.to_str().expect("Should be utf-8"))?;
- let pool = open_db(acquire_timeout, real_opts).await?;
-
- Ok(pool)
-}
diff --git a/crates/turtle/src/atuin_common/api.rs b/crates/turtle/src/atuin_common/api.rs
deleted file mode 100644
index 0868943d..00000000
--- a/crates/turtle/src/atuin_common/api.rs
+++ /dev/null
@@ -1,22 +0,0 @@
-use semver::Version;
-use serde::{Deserialize, Serialize};
-use std::borrow::Cow;
-use std::sync::LazyLock;
-
-// the usage of X- has been deprecated for quite along time, it turns out
-pub(crate) static ATUIN_HEADER_VERSION: &str = "Atuin-Version";
-pub(crate) static ATUIN_CARGO_VERSION: &str = env!("CARGO_PKG_VERSION");
-
-pub(crate) static ATUIN_VERSION: LazyLock<Version> =
- LazyLock::new(|| Version::parse(ATUIN_CARGO_VERSION).expect("failed to parse self semver"));
-
-#[derive(Debug, Serialize, Deserialize)]
-pub(crate) struct ErrorResponse<'a> {
- pub(crate) reason: Cow<'a, str>,
-}
-
-#[derive(Debug, Serialize, Deserialize)]
-pub(crate) struct IndexResponse {
- pub(crate) homage: String,
- pub(crate) version: String,
-}
diff --git a/crates/turtle/src/atuin_common/mod.rs b/crates/turtle/src/atuin_common/mod.rs
deleted file mode 100644
index 635c9fc3..00000000
--- a/crates/turtle/src/atuin_common/mod.rs
+++ /dev/null
@@ -1,58 +0,0 @@
-/// Defines a new UUID type wrapper
-macro_rules! new_uuid {
- ($name:ident) => {
- #[derive(
- Debug,
- Copy,
- Clone,
- PartialEq,
- Eq,
- Hash,
- PartialOrd,
- Ord,
- serde::Serialize,
- serde::Deserialize,
- )]
- #[serde(transparent)]
- pub(crate) struct $name(pub(crate) Uuid);
-
- impl<DB: sqlx::Database> sqlx::Type<DB> for $name
- where
- Uuid: sqlx::Type<DB>,
- {
- fn type_info() -> <DB as sqlx::Database>::TypeInfo {
- Uuid::type_info()
- }
- }
-
- impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for $name
- where
- Uuid: sqlx::Decode<'r, DB>,
- {
- fn decode(
- value: DB::ValueRef<'r>,
- ) -> std::result::Result<Self, sqlx::error::BoxDynError> {
- Uuid::decode(value).map(Self)
- }
- }
-
- impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for $name
- where
- Uuid: sqlx::Encode<'q, DB>,
- {
- fn encode_by_ref(
- &self,
- buf: &mut DB::ArgumentBuffer<'q>,
- ) -> Result<sqlx::encode::IsNull, Box<dyn std::error::Error + Send + Sync + 'static>>
- {
- self.0.encode_by_ref(buf)
- }
- }
- };
-}
-
-pub(crate) mod api;
-pub(crate) mod record;
-pub(crate) mod shell;
-pub(crate) mod tls;
-pub(crate) mod utils;
diff --git a/crates/turtle/src/atuin_common/record.rs b/crates/turtle/src/atuin_common/record.rs
deleted file mode 100644
index f8f9f8a7..00000000
--- a/crates/turtle/src/atuin_common/record.rs
+++ /dev/null
@@ -1,284 +0,0 @@
-use std::collections::HashMap;
-
-use eyre::Result;
-use serde::{Deserialize, Serialize};
-use typed_builder::TypedBuilder;
-use uuid::Uuid;
-
-#[derive(Clone, Debug, PartialEq)]
-pub(crate) struct DecryptedData(pub(crate) Vec<u8>);
-
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub(crate) struct EncryptedData {
- pub(crate) data: String,
- pub(crate) content_encryption_key: String,
-}
-
-#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
-pub(crate) struct Diff {
- pub(crate) host: HostId,
- pub(crate) tag: String,
- pub(crate) local: Option<RecordIdx>,
- pub(crate) remote: Option<RecordIdx>,
-}
-
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
-pub(crate) struct Host {
- pub(crate) id: HostId,
- pub(crate) name: String,
-}
-
-impl Host {
- pub(crate) fn new(id: HostId) -> Self {
- Self {
- id,
- name: String::new(),
- }
- }
-}
-
-new_uuid!(RecordId);
-new_uuid!(HostId);
-
-pub(crate) type RecordIdx = u64;
-
-/// A single record stored inside of our local database
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TypedBuilder)]
-pub(crate) struct Record<Data> {
- /// a unique ID
- #[builder(default = RecordId(crate::atuin_common::utils::uuid_v7()))]
- pub(crate) id: RecordId,
-
- /// The integer record ID. This is only unique per (host, tag).
- pub(crate) idx: RecordIdx,
-
- /// The unique ID of the host.
- // TODO(ellie): Optimize the storage here. We use a bunch of IDs, and currently store
- // as strings. I would rather avoid normalization, so store as UUID binary instead of
- // encoding to a string and wasting much more storage.
- pub(crate) host: Host,
-
- /// The creation time in nanoseconds since unix epoch
- #[builder(default = time::OffsetDateTime::now_utc().unix_timestamp_nanos() as u64)]
- pub(crate) timestamp: u64,
-
- /// The version the data in the entry conforms to
- // However we want to track versions for this tag, eg v2
- pub(crate) version: String,
-
- /// The type of data we are storing here. Eg, "history"
- pub(crate) tag: String,
-
- /// Some data. This can be anything you wish to store. Use the tag field to know how to handle it.
- pub(crate) data: Data,
-}
-
-/// Extra data from the record that should be encoded in the data
-#[derive(Debug, Copy, Clone)]
-pub(crate) struct AdditionalData<'a> {
- pub(crate) id: &'a RecordId,
- pub(crate) idx: &'a u64,
- pub(crate) version: &'a str,
- pub(crate) tag: &'a str,
- pub(crate) host: &'a HostId,
-}
-
-/// An index representing the current state of the record stores
-/// This can be both remote, or local, and compared in either direction
-#[derive(Debug, Serialize, Deserialize)]
-pub(crate) struct RecordStatus {
- // A map of host -> tag -> max(idx)
- pub(crate) hosts: HashMap<HostId, HashMap<String, RecordIdx>>,
-}
-
-impl Default for RecordStatus {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl Extend<(HostId, String, RecordIdx)> for RecordStatus {
- fn extend<T: IntoIterator<Item = (HostId, String, RecordIdx)>>(&mut self, iter: T) {
- for (host, tag, tail_idx) in iter {
- self.set_raw(host, tag, tail_idx);
- }
- }
-}
-
-impl RecordStatus {
- pub(crate) fn new() -> Self {
- Self {
- hosts: HashMap::new(),
- }
- }
-
- /// Insert a new tail record into the store
- pub(crate) fn set_raw(&mut self, host: HostId, tag: String, tail_id: RecordIdx) {
- self.hosts.entry(host).or_default().insert(tag, tail_id);
- }
-
- pub(crate) fn get(&self, host: HostId, tag: &str) -> Option<RecordIdx> {
- self.hosts.get(&host).and_then(|v| v.get(tag)).copied()
- }
-
- /// Diff this index with another, likely remote index.
- /// The two diffs can then be reconciled, and the optimal change set calculated
- /// Returns a tuple, with (host, tag, Option(OTHER))
- /// OTHER is set to the value of the idx on the other machine. If it is greater than our index,
- /// then we need to do some downloading. If it is smaller, then we need to do some uploading
- /// Note that we cannot upload if we are not the owner of the record store - hosts can only
- /// write to their own store.
- pub(crate) fn diff(&self, other: &Self) -> Vec<Diff> {
- let mut ret = Vec::new();
-
- // First, we check if other has everything that self has
- for (host, tag_map) in &self.hosts {
- for (tag, idx) in tag_map {
- match other.get(*host, tag) {
- // The other store is all up to date! No diff.
- Some(t) if t.eq(idx) => (),
-
- // The other store does exist, and it is either ahead or behind us. A diff regardless
- Some(t) => ret.push(Diff {
- host: *host,
- tag: tag.clone(),
- local: Some(*idx),
- remote: Some(t),
- }),
-
- // The other store does not exist :O
- None => ret.push(Diff {
- host: *host,
- tag: tag.clone(),
- local: Some(*idx),
- remote: None,
- }),
- }
- }
- }
-
- // At this point, there is a single case we have not yet considered.
- // If the other store knows of a tag that we are not yet aware of, then the diff will be missed
-
- // account for that!
- for (host, tag_map) in &other.hosts {
- for (tag, idx) in tag_map {
- match self.get(*host, tag) {
- // If we have this host/tag combo, the comparison and diff will have already happened above
- Some(_) => (),
-
- None => ret.push(Diff {
- host: *host,
- tag: tag.clone(),
- remote: Some(*idx),
- local: None,
- }),
- }
- }
- }
-
- // Stability is a nice property to have
- ret.sort();
- ret
- }
-}
-
-pub(crate) trait Encryption {
- fn re_encrypt(
- data: EncryptedData,
- ad: AdditionalData<'_>,
- old_key: &[u8; 32],
- new_key: &[u8; 32],
- ) -> Result<EncryptedData> {
- let data = Self::decrypt(data, ad, old_key)?;
- Ok(Self::encrypt(data, ad, new_key))
- }
- fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData;
- fn decrypt(
- data: EncryptedData,
- ad: AdditionalData<'_>,
- key: &[u8; 32],
- ) -> Result<DecryptedData>;
-}
-
-impl Record<DecryptedData> {
- pub(crate) fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {
- let ad = AdditionalData {
- id: &self.id,
- version: &self.version,
- tag: &self.tag,
- host: &self.host.id,
- idx: &self.idx,
- };
- Record {
- data: E::encrypt(self.data, ad, key),
- id: self.id,
- host: self.host,
- idx: self.idx,
- timestamp: self.timestamp,
- version: self.version,
- tag: self.tag,
- }
- }
-}
-
-impl Record<EncryptedData> {
- pub(crate) fn decrypt<E: Encryption>(self, key: &[u8; 32]) -> Result<Record<DecryptedData>> {
- let ad = AdditionalData {
- id: &self.id,
- version: &self.version,
- tag: &self.tag,
- host: &self.host.id,
- idx: &self.idx,
- };
- Ok(Record {
- data: E::decrypt(self.data, ad, key)?,
- id: self.id,
- host: self.host,
- idx: self.idx,
- timestamp: self.timestamp,
- version: self.version,
- tag: self.tag,
- })
- }
-
- pub(crate) fn re_encrypt<E: Encryption>(
- self,
- old_key: &[u8; 32],
- new_key: &[u8; 32],
- ) -> Result<Self> {
- let ad = AdditionalData {
- id: &self.id,
- version: &self.version,
- tag: &self.tag,
- host: &self.host.id,
- idx: &self.idx,
- };
- Ok(Self {
- data: E::re_encrypt(self.data, ad, old_key, new_key)?,
- id: self.id,
- host: self.host,
- idx: self.idx,
- timestamp: self.timestamp,
- version: self.version,
- tag: self.tag,
- })
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::record::{Host, HostId};
-
- use super::{DecryptedData, Record};
-
- fn test_record() -> Record<DecryptedData> {
- Record::builder()
- .host(Host::new(HostId(crate::atuin_common::utils::uuid_v7())))
- .version("v1".into())
- .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
- .data(DecryptedData(vec![0, 1, 2, 3]))
- .idx(0)
- .build()
- }
-}
diff --git a/crates/turtle/src/atuin_common/shell.rs b/crates/turtle/src/atuin_common/shell.rs
deleted file mode 100644
index 880ff00f..00000000
--- a/crates/turtle/src/atuin_common/shell.rs
+++ /dev/null
@@ -1,52 +0,0 @@
-#[derive(PartialEq)]
-pub(crate) enum Shell {
- Sh,
- Bash,
- Fish,
- Zsh,
- Xonsh,
- Nu,
- Powershell,
-
- Unknown,
-}
-
-impl std::fmt::Display for Shell {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- let shell = match self {
- Self::Bash => "bash",
- Self::Fish => "fish",
- Self::Zsh => "zsh",
- Self::Nu => "nu",
- Self::Xonsh => "xonsh",
- Self::Sh => "sh",
- Self::Powershell => "powershell",
-
- Self::Unknown => "unknown",
- };
-
- write!(f, "{shell}")
- }
-}
-
-impl Shell {
- pub(crate) fn from_env() -> Self {
- std::env::var("ATUIN_SHELL").map_or(Self::Unknown, |shell| {
- Self::from_string(shell.trim().to_lowercase().as_str())
- })
- }
-
- pub(crate) fn from_string(name: &str) -> Self {
- match name {
- "bash" => Self::Bash,
- "fish" => Self::Fish,
- "zsh" => Self::Zsh,
- "xonsh" => Self::Xonsh,
- "nu" => Self::Nu,
- "sh" => Self::Sh,
- "powershell" => Self::Powershell,
-
- _ => Self::Unknown,
- }
- }
-}
diff --git a/crates/turtle/src/atuin_common/tls.rs b/crates/turtle/src/atuin_common/tls.rs
deleted file mode 100644
index 3736f878..00000000
--- a/crates/turtle/src/atuin_common/tls.rs
+++ /dev/null
@@ -1,15 +0,0 @@
-use std::sync::Once;
-
-static INIT: Once = Once::new();
-
-/// Ensure the rustls crypto provider (ring) is installed.
-///
-/// Must be called before creating any reqwest clients. Safe to call
-/// multiple times — only the first call installs the provider.
-pub(crate) fn ensure_crypto_provider() {
- INIT.call_once(|| {
- rustls::crypto::ring::default_provider()
- .install_default()
- .expect("Failed to install rustls crypto provider");
- });
-}
diff --git a/crates/turtle/src/atuin_common/utils.rs b/crates/turtle/src/atuin_common/utils.rs
deleted file mode 100644
index dbe9dfbc..00000000
--- a/crates/turtle/src/atuin_common/utils.rs
+++ /dev/null
@@ -1,257 +0,0 @@
-use std::borrow::Cow;
-use std::env;
-use std::path::{Path, PathBuf};
-
-use uuid::Uuid;
-
-pub(crate) fn uuid_v7() -> Uuid {
- Uuid::now_v7()
-}
-
-pub(crate) fn has_git_dir(path: &str) -> bool {
- let mut gitdir = PathBuf::from(path);
- gitdir.push(".git");
-
- gitdir.exists()
-}
-
-// in a git worktree, .git is a file containing "gitdir: <path>" pointing
-// to the main repo's .git/worktrees/<name> directory. follow the pointer
-// back to the main repo root so all worktrees share a workspace.
-fn resolve_git_worktree(path: &Path) -> Option<PathBuf> {
- let git_path = path.join(".git");
-
- if !git_path.is_file() {
- return None;
- }
-
- let contents = std::fs::read_to_string(&git_path).ok()?;
- let gitdir_str = contents.strip_prefix("gitdir: ")?.trim();
-
- let gitdir = PathBuf::from(gitdir_str);
- let gitdir = if gitdir.is_absolute() {
- gitdir
- } else {
- path.join(gitdir_str)
- };
-
- // walk up from e.g. /repo/.git/worktrees/feature to find /repo
- let mut candidate = gitdir.as_path();
- while let Some(parent) = candidate.parent() {
- if parent.join(".git").is_dir() {
- return Some(parent.to_path_buf());
- }
- candidate = parent;
- }
-
- None
-}
-
-// detect if any parent dir has a git repo in it
-// I really don't want to bring in libgit for something simple like this
-// If we start to do anything more advanced, then perhaps
-pub(crate) fn in_git_repo(path: &str) -> Option<PathBuf> {
- let mut gitdir = PathBuf::from(path);
-
- while gitdir.parent().is_some() && !has_git_dir(gitdir.to_str().unwrap()) {
- gitdir.pop();
- }
-
- // No parent? then we hit root, finding no git
- if gitdir.parent().is_some() {
- // if .git is a file (worktree), resolve to the main repo root
- if let Some(main_repo) = resolve_git_worktree(&gitdir) {
- return Some(main_repo);
- }
- return Some(gitdir);
- }
-
- None
-}
-
-// TODO: more reliable, more tested
-// I don't want to use ProjectDirs, it puts config in awkward places on
-// mac. Data too. Seems to be more intended for GUI apps.
-
-pub(crate) fn home_dir() -> PathBuf {
- directories::BaseDirs::new()
- .map(|d| d.home_dir().to_path_buf())
- .expect("could not determine home directory")
-}
-
-pub(crate) fn config_dir() -> PathBuf {
- let config_dir =
- env::var("XDG_CONFIG_HOME").map_or_else(|_| home_dir().join(".config"), PathBuf::from);
- config_dir.join("atuin")
-}
-
-pub(crate) fn data_dir() -> PathBuf {
- let data_dir = env::var("XDG_DATA_HOME")
- .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
-
- data_dir.join("atuin")
-}
-
-pub(crate) fn runtime_dir() -> PathBuf {
- env::var("XDG_RUNTIME_DIR").map_or_else(|_| data_dir(), PathBuf::from)
-}
-
-pub(crate) fn logs_dir() -> PathBuf {
- home_dir().join(".atuin").join("logs")
-}
-
-pub(crate) fn get_current_dir() -> String {
- // Prefer PWD environment variable over cwd if available to better support symbolic links
- env::var("PWD").unwrap_or_else(|_| {
- env::current_dir().map_or_else(|_| String::new(), |dir| dir.display().to_string())
- })
-}
-
-pub(crate) fn broken_symlink<P: Into<PathBuf>>(path: P) -> bool {
- let path = path.into();
- path.is_symlink() && !path.exists()
-}
-
-/// Extension trait for anything that can behave like a string to make it easy to escape control
-/// characters.
-///
-/// Intended to help prevent control characters being printed and interpreted by the terminal when
-/// printing history as well as to ensure the commands that appear in the interactive search
-/// reflect the actual command run rather than just the printable characters.
-pub(crate) trait Escapable: AsRef<str> {
- fn escape_control(&self) -> Cow<'_, str> {
- if self.as_ref().contains(|c: char| c.is_ascii_control()) {
- let mut remaining = self.as_ref();
- // Not a perfect way to reserve space but should reduce the allocations
- let mut buf = String::with_capacity(remaining.len());
- while let Some(i) = remaining.find(|c: char| c.is_ascii_control()) {
- // safe to index with `..i`, `i` and `i+1..` as part[i] is a single byte ascii char
- buf.push_str(&remaining[..i]);
- buf.push('^');
- buf.push(match remaining.as_bytes()[i] {
- 0x7F => '?',
- code => char::from_u32(u32::from(code) + 64).unwrap(),
- });
- remaining = &remaining[i + 1..];
- }
- buf.push_str(remaining);
- buf.into()
- } else {
- self.as_ref().into()
- }
- }
-}
-
-impl<T: AsRef<str>> Escapable for T {}
-
-#[cfg(test)]
-mod tests {
- use super::{Cow, Escapable, Uuid, env, in_git_repo, uuid_v7};
-
- use std::collections::HashSet;
-
- #[test]
- fn uuid_is_unique() {
- let how_many: usize = 1_000_000;
-
- // for peace of mind
- let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
-
- // there will be many in the same millisecond
- for _ in 0..how_many {
- let uuid = uuid_v7();
- uuids.insert(uuid);
- }
-
- assert_eq!(uuids.len(), how_many);
- }
-
- #[test]
- fn escape_control_characters() {
- use super::Escapable;
- // CSI colour sequence
- assert_eq!("\x1b[31mfoo".escape_control(), "^[[31mfoo");
-
- // Tabs count as control chars
- assert_eq!("foo\tbar".escape_control(), "foo^Ibar");
-
- // space is in control char range but should be excluded
- assert_eq!("two words".escape_control(), "two words");
-
- // unicode multi-byte characters
- let s = "🐢\x1b[32m🦀";
- assert_eq!(s.escape_control(), s.replace("\x1b", "^["));
- }
-
- #[test]
- fn escape_no_control_characters() {
- use super::Escapable as _;
- assert!(matches!(
- "no control characters".escape_control(),
- Cow::Borrowed(_)
- ));
- assert!(matches!(
- "with \x1b[31mcontrol\x1b[0m characters".escape_control(),
- Cow::Owned(_)
- ));
- }
-
- #[cfg(not(windows))]
- #[test]
- fn in_git_repo_regular() {
- // regular git repo should resolve to the directory containing .git
- let tmp = env::temp_dir().join("atuin-test-regular-git");
- drop(std::fs::remove_dir_all(&tmp));
- let subdir = tmp.join("src").join("deep");
- std::fs::create_dir_all(&subdir).unwrap();
- std::fs::create_dir_all(tmp.join(".git")).unwrap();
-
- let result = in_git_repo(subdir.to_str().unwrap());
- assert_eq!(result, Some(tmp.clone()));
-
- std::fs::remove_dir_all(&tmp).unwrap();
- }
-
- #[cfg(not(windows))]
- #[test]
- fn in_git_repo_worktree_resolves_to_main_repo() {
- // worktree .git is a file pointing back to the main repo —
- // in_git_repo should follow it so all worktrees share a workspace
- let tmp = env::temp_dir().join("atuin-test-worktree-git");
- drop(std::fs::remove_dir_all(&tmp));
-
- // main repo at tmp/main with a real .git directory
- let main_repo = tmp.join("main");
- let worktree_git_dir = main_repo.join(".git").join("worktrees").join("feature");
- std::fs::create_dir_all(&worktree_git_dir).unwrap();
-
- // worktree at tmp/worktree with a .git file
- let worktree = tmp.join("worktree");
- let worktree_subdir = worktree.join("src");
- std::fs::create_dir_all(&worktree_subdir).unwrap();
- std::fs::write(
- worktree.join(".git"),
- format!("gitdir: {}", worktree_git_dir.to_str().unwrap()),
- )
- .unwrap();
-
- // should resolve to the main repo root, not the worktree root
- let result = in_git_repo(worktree_subdir.to_str().unwrap());
- assert_eq!(result, Some(main_repo.clone()));
-
- std::fs::remove_dir_all(&tmp).unwrap();
- }
-
- #[test]
- fn dumb_random_test() {
- // Obviously not a test of randomness, but make sure we haven't made some
- // catastrophic error
-
- assert_ne!(crypto_random_string::<1>(), crypto_random_string::<1>());
- assert_ne!(crypto_random_string::<2>(), crypto_random_string::<2>());
- assert_ne!(crypto_random_string::<4>(), crypto_random_string::<4>());
- assert_ne!(crypto_random_string::<8>(), crypto_random_string::<8>());
- assert_ne!(crypto_random_string::<16>(), crypto_random_string::<16>());
- assert_ne!(crypto_random_string::<32>(), crypto_random_string::<32>());
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/client.rs b/crates/turtle/src/atuin_daemon/client.rs
deleted file mode 100644
index 2ea7ffc5..00000000
--- a/crates/turtle/src/atuin_daemon/client.rs
+++ /dev/null
@@ -1,346 +0,0 @@
-use eyre::{Context as EyreContext, Result};
-use tonic::Code;
-use tonic::transport::{Channel, Endpoint, Uri};
-use tower::service_fn;
-
-use hyper_util::rt::TokioIo;
-
-#[cfg(unix)]
-use tokio::net::UnixStream;
-use tracing::{Level, instrument, span};
-
-use crate::atuin_daemon::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,
- },
- },
- },
-};
-
-pub(crate) struct HistoryClient {
- client: HistoryServiceClient<Channel>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub(crate) enum DaemonClientErrorKind {
- Connect,
- Unavailable,
- Unimplemented,
- Other,
-}
-
-#[must_use]
-pub(crate) fn classify_error(error: &eyre::Report) -> DaemonClientErrorKind {
- for cause in error.chain() {
- if cause.downcast_ref::<tonic::transport::Error>().is_some() {
- return DaemonClientErrorKind::Connect;
- }
-
- if let Some(status) = cause.downcast_ref::<tonic::Status>() {
- return match status.code() {
- Code::Unavailable => DaemonClientErrorKind::Unavailable,
- Code::Unimplemented => DaemonClientErrorKind::Unimplemented,
- _ => DaemonClientErrorKind::Other,
- };
- }
- }
-
- DaemonClientErrorKind::Other
-}
-
-// Wrap the grpc client
-impl HistoryClient {
- #[cfg(unix)]
- pub(crate) async fn new(path: String) -> Result<Self> {
- use eyre::Context;
-
- 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 = HistoryServiceClient::new(channel);
-
- Ok(Self { client })
- }
-
- pub(crate) async fn start_history(&mut self, h: History) -> Result<StartHistoryReply> {
- let req = StartHistoryRequest {
- command: h.command,
- cwd: h.cwd,
- hostname: h.hostname,
- session: h.session,
- timestamp: h.timestamp.unix_timestamp_nanos() as u64,
- author: h.author,
- intent: h.intent.unwrap_or_default(),
- };
-
- Ok(self.client.start_history(req).await?.into_inner())
- }
-
- pub(crate) async fn end_history(
- &mut self,
- id: String,
- duration: u64,
- exit: i64,
- ) -> Result<EndHistoryReply> {
- let req = EndHistoryRequest { id, exit, duration };
-
- Ok(self.client.end_history(req).await?.into_inner())
- }
-
- pub(crate) async fn status(&mut self) -> Result<StatusReply> {
- Ok(self.client.status(StatusRequest {}).await?.into_inner())
- }
-
- pub(crate) async fn tail_history(&mut self) -> Result<tonic::Streaming<TailHistoryReply>> {
- Ok(self
- .client
- .tail_history(TailHistoryRequest {})
- .await?
- .into_inner())
- }
-
- pub(crate) async fn shutdown(&mut self) -> Result<bool> {
- let resp = self.client.shutdown(ShutdownRequest {}).await?.into_inner();
- Ok(resp.accepted)
- }
-}
-
-pub(crate) struct SearchClient {
- client: SearchServiceClient<Channel>,
-}
-
-impl SearchClient {
- #[cfg(unix)]
- 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| {
- 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(crate) 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(crate) struct SemanticClient {
- client: SemanticServiceClient<Channel>,
-}
-
-impl SemanticClient {
- #[cfg(unix)]
- 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| {
- 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 = SemanticServiceClient::new(channel);
-
- Ok(Self { client })
- }
-
- #[cfg(unix)]
- pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> {
- Self::new(settings.daemon.socket_path.clone()).await
- }
-
- pub(crate) async fn record_commands(
- &mut self,
- captures: Vec<CommandCapture>,
- ) -> Result<RecordCommandsReply> {
- let stream = tokio_stream::iter(captures);
- Ok(self.client.record_commands(stream).await?.into_inner())
- }
-}
-
-// ============================================================================
-// Control Client
-// ============================================================================
-
-/// Client for the Control gRPC service.
-///
-/// Used to inject events into a running daemon from external processes.
-pub(crate) struct ControlClient {
- client: ControlServiceClient<Channel>,
-}
-
-impl ControlClient {
- /// Connect to the daemon's control service.
- #[cfg(unix)]
- 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| {
- 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 = ControlServiceClient::new(channel);
-
- Ok(Self { client })
- }
-
- /// Connect using settings.
- #[cfg(unix)]
- pub(crate) async fn from_settings(settings: &Settings) -> Result<Self> {
- Self::new(settings.daemon.socket_path.clone()).await
- }
-
- /// Send an event to the daemon.
- pub(crate) async fn send_event(&mut self, event: DaemonEvent) -> Result<()> {
- let proto_event = daemon_event_to_proto(event);
- let request = SendEventRequest {
- event: Some(proto_event),
- };
- self.client.send_event(request).await?;
- Ok(())
- }
-}
-
-/// Convert a daemon event to its proto representation.
-fn daemon_event_to_proto(event: DaemonEvent) -> generated::control::send_event_request::Event {
- use generated::control::send_event_request::Event;
-
- match event {
- DaemonEvent::HistoryPruned => Event::HistoryPruned(HistoryPrunedEvent {}),
- DaemonEvent::HistoryRebuilt => Event::HistoryRebuilt(HistoryRebuiltEvent {}),
- DaemonEvent::HistoryDeleted { ids } => Event::HistoryDeleted(HistoryDeletedEvent {
- ids: ids.into_iter().map(|id| id.0).collect(),
- }),
- DaemonEvent::ForceSync => Event::ForceSync(ForceSyncEvent {}),
- DaemonEvent::SettingsReloaded => Event::SettingsReloaded(SettingsReloadedEvent {}),
- DaemonEvent::ShutdownRequested => Event::Shutdown(ShutdownEvent {}),
- // These events are internal and not sent via the control service
- DaemonEvent::HistoryStarted(_)
- | DaemonEvent::HistoryEnded(_)
- | DaemonEvent::RecordsAdded(_)
- | DaemonEvent::SyncCompleted { .. }
- | DaemonEvent::SyncFailed { .. } => {
- // Use shutdown as a fallback, though this shouldn't happen
- tracing::warn!("attempted to send internal event via control service");
- Event::Shutdown(ShutdownEvent {})
- }
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/components/history.rs b/crates/turtle/src/atuin_daemon/components/history.rs
deleted file mode 100644
index b4f91b06..00000000
--- a/crates/turtle/src/atuin_daemon/components/history.rs
+++ /dev/null
@@ -1,325 +0,0 @@
-//! History component.
-//!
-//! Handles command history lifecycle (start/end) and provides the History gRPC service.
-
-use std::{pin::Pin, sync::Arc};
-
-use crate::atuin_client::{
- history::{History, HistoryId, store::HistoryStore},
- settings::Settings,
-};
-use dashmap::DashMap;
-use eyre::Result;
-use time::OffsetDateTime;
-use tokio_stream::Stream;
-use tonic::{Request, Response, Status};
-use tracing::{Level, instrument};
-
-use crate::atuin_daemon::{
- daemon::{Component, DaemonHandle},
- events::DaemonEvent,
- generated::history::{
- EndHistoryReply, EndHistoryRequest, HistoryEntry, HistoryEventKind, ShutdownReply,
- ShutdownRequest, StartHistoryReply, StartHistoryRequest, StatusReply, StatusRequest,
- TailHistoryReply, TailHistoryRequest,
- history_server::{History as HistorySvc, HistoryServer},
- },
-};
-
-const DAEMON_PROTOCOL_VERSION: u32 = 1;
-
-/// History component - manages command history lifecycle.
-///
-/// This component:
-/// - Tracks currently running commands (stored in memory)
-/// - 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(crate) struct HistoryComponent {
- inner: Arc<HistoryComponentInner>,
-}
-
-struct HistoryComponentInner {
- /// Commands currently running (not yet completed).
- running: DashMap<HistoryId, History>,
-
- /// Handle to the daemon (set during start).
- handle: tokio::sync::RwLock<Option<DaemonHandle>>,
-
- /// History store for pushing records (set during start).
- history_store: tokio::sync::RwLock<Option<HistoryStore>>,
-}
-
-impl HistoryComponent {
- /// Create a new history component.
- pub(crate) fn new() -> Self {
- Self {
- inner: Arc::new(HistoryComponentInner {
- running: DashMap::new(),
- handle: tokio::sync::RwLock::new(None),
- history_store: tokio::sync::RwLock::new(None),
- }),
- }
- }
-
- /// Get the gRPC service for this component.
- ///
- /// This returns a tonic service that can be added to a gRPC server.
- pub(crate) fn grpc_service(&self) -> HistoryServer<HistoryGrpcService> {
- HistoryServer::new(HistoryGrpcService {
- inner: self.inner.clone(),
- })
- }
-}
-
-impl Default for HistoryComponent {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[tonic::async_trait]
-impl Component for HistoryComponent {
- fn name(&self) -> &'static str {
- "history"
- }
-
- async fn start(&mut self, handle: DaemonHandle) -> Result<()> {
- // Create the history store
- let host_id = Settings::host_id().await?;
- let history_store =
- HistoryStore::new(handle.store().clone(), host_id, *handle.encryption_key());
-
- *self.inner.history_store.write().await = Some(history_store);
- *self.inner.handle.write().await = Some(handle);
-
- tracing::info!("history component started");
- Ok(())
- }
-
- async fn handle_event(&mut self, _event: &DaemonEvent) -> Result<()> {
- // History component produces events but doesn't need to react to them
- Ok(())
- }
-
- async fn stop(&mut self) -> Result<()> {
- tracing::info!("history component stopped");
- Ok(())
- }
-}
-
-/// The gRPC service implementation.
-///
-/// This is a thin wrapper that delegates to the component's shared state.
-pub(crate) struct HistoryGrpcService {
- inner: Arc<HistoryComponentInner>,
-}
-
-fn history_to_tail_reply(kind: HistoryEventKind, history: History) -> TailHistoryReply {
- TailHistoryReply {
- kind: kind as i32,
- history: Some(HistoryEntry {
- timestamp: history.timestamp.unix_timestamp_nanos() as u64,
- id: history.id.0,
- command: history.command,
- cwd: history.cwd,
- session: history.session,
- hostname: history.hostname,
- author: history.author,
- intent: history.intent.unwrap_or_default(),
- exit: history.exit,
- duration: history.duration,
- }),
- }
-}
-
-#[tonic::async_trait]
-impl HistorySvc for HistoryGrpcService {
- type TailHistoryStream = Pin<Box<dyn Stream<Item = Result<TailHistoryReply, Status>> + Send>>;
-
- #[instrument(skip_all, level = Level::INFO)]
- async fn start_history(
- &self,
- request: Request<StartHistoryRequest>,
- ) -> Result<Response<StartHistoryReply>, Status> {
- let req = request.into_inner();
-
- let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(req.timestamp))
- .map_err(|_| {
- Status::invalid_argument(
- "failed to parse timestamp as unix time (expected nanos since epoch)",
- )
- })?;
-
- let h: History = History::daemon()
- .timestamp(timestamp)
- .command(req.command)
- .cwd(req.cwd)
- .session(req.session)
- .hostname(req.hostname)
- .author(req.author)
- .intent(req.intent)
- .build()
- .into();
-
- // Emit the event
- if let Some(handle) = self.inner.handle.read().await.as_ref() {
- handle.emit(DaemonEvent::HistoryStarted(h.clone()));
- }
-
- let id = h.id.clone();
- tracing::info!(id = id.to_string(), "start history");
- self.inner.running.insert(id.clone(), h);
-
- let reply = StartHistoryReply {
- id: id.to_string(),
- version: env!("CARGO_PKG_VERSION").to_string(),
- protocol: DAEMON_PROTOCOL_VERSION,
- };
-
- Ok(Response::new(reply))
- }
-
- #[instrument(skip_all, level = Level::INFO)]
- #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")]
- async fn end_history(
- &self,
- request: Request<EndHistoryRequest>,
- ) -> Result<Response<EndHistoryReply>, Status> {
- let req = request.into_inner();
- let id = HistoryId(req.id);
-
- if let Some((_, mut history)) = self.inner.running.remove(&id) {
- history.exit = req.exit;
- history.duration = match req.duration {
- 0 => i64::try_from(
- (OffsetDateTime::now_utc() - history.timestamp).whole_nanoseconds(),
- )
- .expect("failed to convert calculated duration to i64"),
- value => i64::try_from(value).expect("failed to get i64 duration"),
- };
-
- // Get the handle and store to save the history
- let handle_guard = self.inner.handle.read().await;
- let handle = handle_guard
- .as_ref()
- .ok_or_else(|| Status::internal("component not initialized"))?;
-
- let store_guard = self.inner.history_store.read().await;
- let history_store = store_guard
- .as_ref()
- .ok_or_else(|| Status::internal("component not initialized"))?;
-
- // Save to database
- handle
- .history_db()
- .save(&history)
- .await
- .map_err(|e| Status::internal(format!("failed to write to db: {e:?}")))?;
-
- tracing::info!(id = id.0, duration = history.duration, "end history");
-
- // Push to record store
- let (record_id, idx) = history_store
- .push(history.clone())
- .await
- .map_err(|e| Status::internal(format!("failed to push record to store: {e:?}")))?;
-
- // Emit the event
- handle.emit(DaemonEvent::HistoryEnded(history));
-
- let reply = EndHistoryReply {
- id: record_id.0.to_string(),
- idx,
- version: env!("CARGO_PKG_VERSION").to_string(),
- protocol: DAEMON_PROTOCOL_VERSION,
- };
-
- return Ok(Response::new(reply));
- }
-
- Err(Status::not_found(format!(
- "could not find history with id: {id}"
- )))
- }
-
- #[instrument(skip_all, level = Level::INFO)]
- #[expect(clippy::significant_drop_tightening, reason = "Would be a logic-bug")]
- async fn tail_history(
- &self,
- _request: Request<TailHistoryRequest>,
- ) -> Result<Response<Self::TailHistoryStream>, Status> {
- let handle_guard = self.inner.handle.read().await;
- let handle = handle_guard
- .as_ref()
- .cloned()
- .ok_or_else(|| Status::internal("component not initialized"))?;
-
- let mut rx = handle.subscribe();
- let (tx, out_rx) = tokio::sync::mpsc::channel::<Result<TailHistoryReply, Status>>(128);
-
- tokio::spawn(async move {
- loop {
- let event = match rx.recv().await {
- Ok(event) => event,
- Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
- drop(
- tx.send(Err(Status::resource_exhausted(format!(
- "tail stream lagged behind and dropped {skipped} events"
- ))))
- .await,
- );
- break;
- }
- Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
- };
-
- let reply = match event {
- DaemonEvent::HistoryStarted(history) => {
- Some(history_to_tail_reply(HistoryEventKind::Started, history))
- }
- DaemonEvent::HistoryEnded(history) => {
- Some(history_to_tail_reply(HistoryEventKind::Ended, history))
- }
- _ => None,
- };
-
- if let Some(reply) = reply
- && tx.send(Ok(reply)).await.is_err()
- {
- break;
- }
- }
- });
-
- let stream = tokio_stream::wrappers::ReceiverStream::new(out_rx);
- Ok(Response::new(Box::pin(stream)))
- }
-
- #[instrument(skip_all, level = Level::INFO)]
- async fn status(
- &self,
- _request: Request<StatusRequest>,
- ) -> Result<Response<StatusReply>, Status> {
- let reply = StatusReply {
- healthy: true,
- version: env!("CARGO_PKG_VERSION").to_string(),
- pid: std::process::id(),
- protocol: DAEMON_PROTOCOL_VERSION,
- };
-
- Ok(Response::new(reply))
- }
-
- #[instrument(skip_all, level = Level::INFO)]
- async fn shutdown(
- &self,
- _request: Request<ShutdownRequest>,
- ) -> Result<Response<ShutdownReply>, Status> {
- // Use the daemon handle to request shutdown
- if let Some(handle) = self.inner.handle.read().await.as_ref() {
- handle.shutdown();
- }
- Ok(Response::new(ShutdownReply { accepted: true }))
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/components/mod.rs b/crates/turtle/src/atuin_daemon/components/mod.rs
deleted file mode 100644
index 5a93fbc1..00000000
--- a/crates/turtle/src/atuin_daemon/components/mod.rs
+++ /dev/null
@@ -1,25 +0,0 @@
-//! Daemon components.
-//!
-//! Components are the building blocks of the daemon. Each component handles
-//! a specific domain and can:
-//!
-//! - Expose gRPC services
-//! - React to events
-//! - Spawn background tasks
-//!
-//! 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(crate) mod history;
-pub(crate) mod search;
-pub(crate) mod semantic;
-pub(crate) mod sync;
-
-pub(crate) use history::HistoryComponent;
-pub(crate) use search::SearchComponent;
-pub(crate) use semantic::SemanticComponent;
-pub(crate) use sync::SyncComponent;
diff --git a/crates/turtle/src/atuin_daemon/components/search.rs b/crates/turtle/src/atuin_daemon/components/search.rs
deleted file mode 100644
index bcd60cc4..00000000
--- a/crates/turtle/src/atuin_daemon/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::atuin_daemon::{
- 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn with_trailing_slash(s: &str) -> String {
- if s.ends_with('/') {
- s.to_string()
- } else {
- format!("{s}/")
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/components/semantic.rs b/crates/turtle/src/atuin_daemon/components/semantic.rs
deleted file mode 100644
index e1d376de..00000000
--- a/crates/turtle/src/atuin_daemon/components/semantic.rs
+++ /dev/null
@@ -1,912 +0,0 @@
-//! Semantic command capture component.
-//!
-//! This is a prototype in-memory store for completed command captures emitted
-//! by atuin-pty-proxy. It keeps recent captures per Atuin session and indexes
-//! them by history ID for AI tool lookup.
-
-use std::collections::{HashMap, VecDeque};
-use std::fmt::{Display, Formatter};
-use std::sync::Arc;
-
-use crate::atuin_client::history::{History, HistoryId};
-use crate::atuin_daemon::generated::semantic;
-use eyre::Result;
-use tokio::sync::Mutex;
-use tonic::{Request, Response, Status, Streaming};
-use tracing::{Level, instrument};
-
-use crate::atuin_daemon::{
- daemon::{Component, DaemonHandle},
- events::DaemonEvent,
- generated::semantic::{
- CommandCapture, CommandOutputReply, CommandOutputRequest, OutputLine, RecordCommandsReply,
- semantic_server::{Semantic as SemanticSvc, SemanticServer},
- },
-};
-
-const MAX_SESSIONS: usize = 20;
-const MAX_COMMANDS_PER_SESSION: usize = 128;
-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(crate) struct SemanticComponent {
- inner: Arc<SemanticComponentInner>,
-}
-
-struct SemanticComponentInner {
- state: Mutex<SemanticState>,
-}
-
-#[derive(Default)]
-struct SemanticState {
- sessions: HashMap<SessionId, SessionCaptures>,
- session_lru: VecDeque<SessionId>,
- history_index: HashMap<HistoryId, CaptureRef>,
- pending_histories: VecDeque<History>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-struct SessionId(String);
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-struct CaptureId(u64);
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct CaptureRef {
- session_id: SessionId,
- capture_id: CaptureId,
-}
-
-#[derive(Default)]
-struct SessionCaptures {
- next_id: u64,
- records: VecDeque<StoredCapture>,
- output_bytes: usize,
-}
-
-struct StoredCapture {
- id: CaptureId,
- history_id: HistoryId,
- output_bytes: usize,
- record: SemanticCommandRecord,
-}
-
-struct EvictedCapture {
- history_id: HistoryId,
- capture_id: CaptureId,
-}
-
-#[derive(Debug, Clone)]
-struct SemanticCommandRecord {
- capture: CommandCapture,
- history: Option<History>,
-}
-
-impl SemanticComponent {
- pub(crate) fn new() -> Self {
- Self {
- inner: Arc::new(SemanticComponentInner {
- state: Mutex::new(SemanticState::default()),
- }),
- }
- }
-
- pub(crate) fn grpc_service(&self) -> SemanticServer<SemanticGrpcService> {
- SemanticServer::new(SemanticGrpcService {
- inner: self.inner.clone(),
- })
- }
-}
-
-impl Default for SemanticComponent {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[tonic::async_trait]
-impl Component for SemanticComponent {
- fn name(&self) -> &'static str {
- "semantic"
- }
-
- async fn start(&mut self, _handle: DaemonHandle) -> Result<()> {
- tracing::info!("semantic component started");
- Ok(())
- }
-
- async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {
- if let DaemonEvent::HistoryEnded(history) = event {
- self.inner.record_history(history.clone()).await;
- }
-
- Ok(())
- }
-
- async fn stop(&mut self) -> Result<()> {
- let state = self.inner.state.lock().await;
- tracing::info!(
- sessions = state.sessions.len(),
- records = state.record_count(),
- indexed_histories = state.history_index.len(),
- pending_histories = state.pending_histories.len(),
- "semantic component stopped"
- );
- Ok(())
- }
-}
-
-impl SemanticComponentInner {
- async fn record_capture(&self, capture: CommandCapture) -> bool {
- let mut state = self.state.lock().await;
- state.record_capture(capture)
- }
-
- async fn record_history(&self, history: History) {
- let mut state = self.state.lock().await;
- state.record_history(history);
- }
-
- async fn command_output(&self, request: &CommandOutputRequest) -> CommandOutputReply {
- let mut state = self.state.lock().await;
- state.command_output(request)
- }
-}
-
-impl SemanticState {
- fn record_capture(&mut self, mut capture: CommandCapture) -> bool {
- let Some(history_id) = history_id_from_str(capture.history_id.as_deref()) else {
- tracing::debug!(
- command_bytes = capture.command.len(),
- prompt_bytes = capture.prompt.len(),
- output_bytes = capture.output.len(),
- output_truncated = capture.output_truncated,
- "dropping semantic command capture without history id"
- );
- return false;
- };
-
- let history = take_pending_history(&mut self.pending_histories, &history_id);
- let Some(session_id) = capture
- .session_id
- .as_deref()
- .and_then(|session_id| SessionId::try_from(session_id).ok())
- .or_else(|| {
- history
- .as_ref()
- .and_then(|history| SessionId::try_from(history.session.as_str()).ok())
- })
- else {
- tracing::debug!(
- history_id = %history_id,
- command_bytes = capture.command.len(),
- prompt_bytes = capture.prompt.len(),
- output_bytes = capture.output.len(),
- output_truncated = capture.output_truncated,
- "dropping semantic command capture without session id"
- );
- return false;
- };
-
- capture.history_id = Some(history_id.to_string());
- capture.session_id = Some(session_id.to_string());
- if capture.output_observed_bytes == 0 {
- capture.output_observed_bytes = capture.output.len() as u64;
- }
-
- let record = SemanticCommandRecord { capture, history };
- log_record(&record, "recorded semantic command capture");
- self.push_record(&session_id, history_id, record);
- true
- }
-
- fn record_history(&mut self, history: History) {
- let history_id = history.id.clone();
-
- if let Some(capture_ref) = self.history_index.get(&history_id).cloned() {
- if let Some(stored) = self.stored_capture_mut(&capture_ref) {
- stored.record.history = Some(history);
- log_record(
- &stored.record,
- "associated semantic command capture with history",
- );
- return;
- }
-
- self.history_index.remove(&history_id);
- }
-
- tracing::debug!(
- id = %history.id,
- command_bytes = history.command.len(),
- "history ended before semantic capture arrived"
- );
- push_pending_history(&mut self.pending_histories, history);
- }
-
- fn command_output(&mut self, request: &CommandOutputRequest) -> CommandOutputReply {
- let Some(history_id) = history_id_from_str(Some(&request.history_id)) else {
- return command_output_not_found();
- };
- let Some(capture_ref) = self.history_index.get(&history_id).cloned() else {
- return command_output_not_found();
- };
-
- let Some(reply) = self.command_output_for_ref(&capture_ref, &request.ranges) else {
- self.history_index.remove(&history_id);
- return command_output_not_found();
- };
-
- self.touch_session(&capture_ref.session_id);
- reply
- }
-
- fn command_output_for_ref(
- &self,
- capture_ref: &CaptureRef,
- ranges: &[semantic::OutputRange],
- ) -> Option<CommandOutputReply> {
- let stored = self
- .sessions
- .get(&capture_ref.session_id)?
- .stored_capture(capture_ref.capture_id)?;
- let output = &stored.record.capture.output;
- let output_observed_bytes = stored
- .record
- .capture
- .output_observed_bytes
- .max(output.len() as u64);
-
- Some(CommandOutputReply {
- found: true,
- output: String::new(),
- total_bytes: output.len() as u64,
- total_lines: output.lines().count() as u64,
- lines: select_output_ranges(output, ranges),
- output_truncated: stored.record.capture.output_truncated,
- output_observed_bytes,
- })
- }
-
- fn push_record(
- &mut self,
- session_id: &SessionId,
- history_id: HistoryId,
- record: SemanticCommandRecord,
- ) {
- self.touch_session(session_id);
-
- let (capture_id, evicted) = {
- let session = self.sessions.entry(session_id.clone()).or_default();
- session.push(history_id.clone(), record)
- };
-
- let capture_ref = CaptureRef {
- session_id: session_id.clone(),
- capture_id,
- };
- self.history_index.insert(history_id, capture_ref);
-
- for evicted in evicted {
- self.remove_history_index_if_matches(
- session_id,
- &evicted.history_id,
- evicted.capture_id,
- );
- }
-
- self.expire_lru_sessions();
- }
-
- fn touch_session(&mut self, session_id: &SessionId) {
- if let Some(index) = self.session_lru.iter().position(|id| id == session_id) {
- self.session_lru.remove(index);
- }
- self.session_lru.push_back(session_id.clone());
- }
-
- fn expire_lru_sessions(&mut self) {
- while self.session_lru.len() > MAX_SESSIONS {
- let Some(session_id) = self.session_lru.pop_front() else {
- break;
- };
- let Some(session) = self.sessions.remove(&session_id) else {
- continue;
- };
-
- for stored in session.records {
- self.remove_history_index_if_matches(&session_id, &stored.history_id, stored.id);
- }
- }
- }
-
- fn remove_history_index_if_matches(
- &mut self,
- session_id: &SessionId,
- history_id: &HistoryId,
- capture_id: CaptureId,
- ) {
- if self
- .history_index
- .get(history_id)
- .is_some_and(|capture_ref| {
- &capture_ref.session_id == session_id && capture_ref.capture_id == capture_id
- })
- {
- self.history_index.remove(history_id);
- }
- }
-
- fn stored_capture_mut(&mut self, capture_ref: &CaptureRef) -> Option<&mut StoredCapture> {
- self.sessions
- .get_mut(&capture_ref.session_id)?
- .stored_capture_mut(capture_ref.capture_id)
- }
-
- fn record_count(&self) -> usize {
- self.sessions
- .values()
- .map(|session| session.records.len())
- .sum()
- }
-}
-
-impl SessionCaptures {
- fn push(
- &mut self,
- history_id: HistoryId,
- record: SemanticCommandRecord,
- ) -> (CaptureId, Vec<EvictedCapture>) {
- self.push_with_limits(
- history_id,
- record,
- MAX_COMMANDS_PER_SESSION,
- MAX_BYTES_PER_SESSION,
- )
- }
-
- fn push_with_limits(
- &mut self,
- history_id: HistoryId,
- record: SemanticCommandRecord,
- max_commands: usize,
- max_output_bytes: usize,
- ) -> (CaptureId, Vec<EvictedCapture>) {
- let capture_id = CaptureId(self.next_id);
- self.next_id = self.next_id.saturating_add(1);
- let output_bytes = record.capture.output.len();
- self.output_bytes = self.output_bytes.saturating_add(output_bytes);
- self.records.push_back(StoredCapture {
- id: capture_id,
- history_id,
- output_bytes,
- record,
- });
-
- (
- capture_id,
- self.evict_to_limits(max_commands, max_output_bytes),
- )
- }
-
- fn evict_to_limits(
- &mut self,
- max_commands: usize,
- max_output_bytes: usize,
- ) -> Vec<EvictedCapture> {
- let mut evicted = Vec::new();
- while self.records.len() > max_commands || self.output_bytes > max_output_bytes {
- let Some(record) = self.records.pop_front() else {
- break;
- };
- self.output_bytes = self.output_bytes.saturating_sub(record.output_bytes);
- evicted.push(EvictedCapture {
- history_id: record.history_id,
- capture_id: record.id,
- });
- }
- evicted
- }
-
- fn stored_capture(&self, capture_id: CaptureId) -> Option<&StoredCapture> {
- self.records.iter().find(|record| record.id == capture_id)
- }
-
- fn stored_capture_mut(&mut self, capture_id: CaptureId) -> Option<&mut StoredCapture> {
- self.records
- .iter_mut()
- .find(|record| record.id == capture_id)
- }
-}
-
-impl TryFrom<&str> for SessionId {
- type Error = ();
-
- fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
- let value = value.trim();
- if value.is_empty() {
- return Err(());
- }
-
- Ok(Self(value.to_string()))
- }
-}
-
-impl TryFrom<String> for SessionId {
- type Error = ();
-
- fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
- Self::try_from(value.as_str())
- }
-}
-
-impl AsRef<str> for SessionId {
- fn as_ref(&self) -> &str {
- &self.0
- }
-}
-
-impl Display for SessionId {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-pub(crate) struct SemanticGrpcService {
- inner: Arc<SemanticComponentInner>,
-}
-
-#[tonic::async_trait]
-impl SemanticSvc for SemanticGrpcService {
- #[instrument(skip_all, level = Level::INFO)]
- async fn record_commands(
- &self,
- request: Request<Streaming<CommandCapture>>,
- ) -> Result<Response<RecordCommandsReply>, Status> {
- let mut stream = request.into_inner();
- let mut accepted = 0_u64;
-
- while let Some(capture) = stream.message().await? {
- if self.inner.record_capture(capture).await {
- accepted += 1;
- }
- }
-
- Ok(Response::new(RecordCommandsReply { accepted }))
- }
-
- #[instrument(skip_all, level = Level::INFO)]
- async fn command_output(
- &self,
- request: Request<CommandOutputRequest>,
- ) -> Result<Response<CommandOutputReply>, Status> {
- let request = request.into_inner();
- if request.history_id.trim().is_empty() {
- return Err(Status::invalid_argument("history_id is required"));
- }
-
- Ok(Response::new(self.inner.command_output(&request).await))
- }
-}
-
-fn history_id_from_str(value: Option<&str>) -> Option<HistoryId> {
- let value = value?.trim();
- (!value.is_empty()).then(|| HistoryId(value.to_string()))
-}
-
-fn take_pending_history(
- histories: &mut VecDeque<History>,
- history_id: &HistoryId,
-) -> Option<History> {
- let index = histories
- .iter()
- .position(|history| &history.id == history_id)?;
- histories.remove(index)
-}
-
-fn push_pending_history(histories: &mut VecDeque<History>, history: History) {
- if let Some(index) = histories
- .iter()
- .position(|pending| pending.id == history.id)
- {
- histories.remove(index);
- }
-
- histories.push_back(history);
- trim_front(histories, MAX_PENDING_HISTORIES);
-}
-
-fn trim_front<T>(records: &mut VecDeque<T>, max_len: usize) {
- while records.len() > max_len {
- records.pop_front();
- }
-}
-
-fn command_output_not_found() -> CommandOutputReply {
- CommandOutputReply {
- found: false,
- output: String::new(),
- total_bytes: 0,
- total_lines: 0,
- lines: Vec::new(),
- output_truncated: false,
- output_observed_bytes: 0,
- }
-}
-
-fn select_output_ranges(output: &str, ranges: &[semantic::OutputRange]) -> Vec<OutputLine> {
- let lines: Vec<&str> = output.lines().collect();
- if lines.is_empty() {
- return Vec::new();
- }
-
- let ranges = if ranges.is_empty() {
- vec![semantic::OutputRange { start: 0, end: 999 }]
- } else {
- ranges.to_vec()
- };
-
- let mut ranges = ranges
- .into_iter()
- .filter_map(|range| normalize_line_range(range.start, range.end, lines.len()))
- .collect::<Vec<_>>();
- ranges.sort_unstable_by_key(|(start, _)| *start);
-
- let mut merged: Vec<(usize, usize)> = Vec::new();
- for (start, end) in ranges {
- match merged.last_mut() {
- Some((_, merged_end)) if start <= merged_end.saturating_add(1) => {
- *merged_end = (*merged_end).max(end);
- }
- _ => merged.push((start, end)),
- }
- }
-
- merged
- .into_iter()
- .flat_map(|(start, end)| {
- lines[start..=end]
- .iter()
- .enumerate()
- .map(move |(offset, line)| OutputLine {
- line_number: (start + offset + 1) as u64,
- content: (*line).to_string(),
- })
- })
- .collect()
-}
-
-fn normalize_line_range(start: i64, end: i64, line_count: usize) -> Option<(usize, usize)> {
- let line_count = i64::try_from(line_count).ok()?;
- let start = if start < 0 { line_count + start } else { start };
- let end = if end < 0 { line_count + end } else { end };
-
- if end < 0 || start >= line_count {
- return None;
- }
-
- let start = start.max(0);
- let end = end.min(line_count - 1);
-
- (start <= end).then_some((start as usize, end as usize))
-}
-
-fn log_record(record: &SemanticCommandRecord, message: &'static str) {
- let history_id = record.capture.history_id.as_deref().unwrap_or("<missing>");
- let associated_history_id = record
- .history
- .as_ref()
- .map(|history| history.id.to_string());
- let exit = record.history.as_ref().map(|history| history.exit);
- let duration = record.history.as_ref().map(|history| history.duration);
- let author = record
- .history
- .as_ref()
- .map(|history| history.author.as_str());
- let session_id = record.capture.session_id.as_deref();
-
- tracing::debug!(
- history_id = %history_id,
- associated_history_id = ?associated_history_id,
- session_id = ?session_id,
- command_bytes = record.capture.command.len(),
- prompt_bytes = record.capture.prompt.len(),
- output_bytes = record.capture.output.len(),
- output_truncated = record.capture.output_truncated,
- output_observed_bytes = record.capture.output_observed_bytes,
- capture_exit_code = ?record.capture.exit_code,
- history_exit = ?exit,
- duration = ?duration,
- author = ?author,
- "{message}"
- );
-}
-
-#[cfg(test)]
-mod tests {
- use time::OffsetDateTime;
-
- use crate::{
- atuin_client::history::{History, HistoryId},
- atuin_daemon::{
- components::semantic::{
- MAX_COMMANDS_PER_SESSION, MAX_SESSIONS, SemanticCommandRecord, SemanticState,
- SessionCaptures, SessionId, select_output_ranges,
- },
- generated::semantic::{self, CommandOutputReply, CommandOutputRequest, OutputLine},
- },
- atuin_pty_proxy::CommandCapture,
- };
-
- fn history(id: &str, session: &str, command: &str) -> History {
- History {
- id: HistoryId(id.to_string()),
- timestamp: OffsetDateTime::UNIX_EPOCH,
- duration: 0,
- exit: 0,
- command: command.to_string(),
- cwd: String::new(),
- session: session.to_string(),
- hostname: String::new(),
- author: String::new(),
- intent: None,
- deleted_at: None,
- }
- }
-
- fn capture(history_id: Option<&str>, session_id: Option<&str>, output: &str) -> CommandCapture {
- CommandCapture {
- prompt: String::new(),
- command: String::new(),
- output: output.to_string(),
- exit_code: None,
- history_id: history_id.map(str::to_string),
- session_id: session_id.map(str::to_string),
- output_truncated: false,
- output_observed_bytes: output.len() as u64,
- }
- }
-
- fn command_output(state: &mut SemanticState, history_id: &str) -> CommandOutputReply {
- state.command_output(&CommandOutputRequest {
- history_id: history_id.to_string(),
- ranges: Vec::new(),
- })
- }
-
- fn output_line(line_number: u64, content: &str) -> OutputLine {
- OutputLine {
- line_number,
- content: content.to_string(),
- }
- }
-
- #[test]
- fn drops_capture_without_history_id() {
- let mut state = SemanticState::default();
-
- assert!(!state.record_capture(capture(None, Some("session-1"), "output")));
- assert!(!command_output(&mut state, "id-1").found);
- assert_eq!(state.record_count(), 0);
- }
-
- #[test]
- fn stores_capture_by_session_and_history_id() {
- let mut state = SemanticState::default();
-
- assert!(state.record_capture(capture(Some("id-1"), Some("session-1"), "output")));
-
- let reply = command_output(&mut state, "id-1");
- assert!(reply.found);
- assert_eq!(reply.total_bytes, 6);
- assert_eq!(reply.output_observed_bytes, 6);
- assert_eq!(reply.lines, vec![output_line(1, "output")]);
- }
-
- #[test]
- fn uses_pending_history_session_when_capture_session_is_missing() {
- let mut state = SemanticState::default();
-
- state.record_history(history("id-1", "session-from-history", "cargo test"));
- assert!(state.record_capture(capture(Some("id-1"), None, "output")));
-
- assert!(
- state
- .sessions
- .contains_key(&SessionId("session-from-history".to_string()))
- );
- assert!(command_output(&mut state, "id-1").found);
- }
-
- #[test]
- fn associates_history_by_id_after_capture_arrives() {
- let mut state = SemanticState::default();
-
- assert!(state.record_capture(capture(Some("id-1"), Some("session-1"), "output")));
- state.record_history(history("id-1", "session-1", "different command"));
-
- let capture_ref = state
- .history_index
- .get(&HistoryId("id-1".to_string()))
- .unwrap();
- let stored = state
- .sessions
- .get(&capture_ref.session_id)
- .unwrap()
- .stored_capture(capture_ref.capture_id)
- .unwrap();
- assert!(stored.record.history.is_some());
- }
-
- #[test]
- fn evicts_oldest_command_when_session_ring_is_full() {
- let mut state = SemanticState::default();
-
- for index in 0..=MAX_COMMANDS_PER_SESSION {
- assert!(state.record_capture(capture(
- Some(&format!("id-{index}")),
- Some("session-1"),
- "output",
- )));
- }
-
- assert!(!command_output(&mut state, "id-0").found);
- assert!(command_output(&mut state, &format!("id-{MAX_COMMANDS_PER_SESSION}")).found);
- assert_eq!(state.record_count(), MAX_COMMANDS_PER_SESSION);
- }
-
- #[test]
- fn evicts_oldest_session_after_lru_limit() {
- let mut state = SemanticState::default();
-
- for index in 0..MAX_SESSIONS {
- assert!(state.record_capture(capture(
- Some(&format!("id-{index}")),
- Some(&format!("session-{index}")),
- "output",
- )));
- }
- assert!(command_output(&mut state, "id-0").found);
-
- assert!(state.record_capture(capture(Some("new-id"), Some("new-session"), "output",)));
-
- assert!(command_output(&mut state, "id-0").found);
- assert!(!command_output(&mut state, "id-1").found);
- assert!(command_output(&mut state, "new-id").found);
- assert_eq!(state.sessions.len(), MAX_SESSIONS);
- }
-
- #[test]
- fn evicts_by_session_byte_limit() {
- let mut session = SessionCaptures::default();
- let first_output = "x".repeat(10);
- let second_output = "y";
- let (_, evicted_first) = session.push_with_limits(
- HistoryId("first".to_string()),
- SemanticCommandRecord {
- capture: capture(Some("first"), Some("session-1"), &first_output),
- history: None,
- },
- MAX_COMMANDS_PER_SESSION,
- 10,
- );
- assert!(evicted_first.is_empty());
-
- let (_, evicted_second) = session.push_with_limits(
- HistoryId("second".to_string()),
- SemanticCommandRecord {
- capture: capture(Some("second"), Some("session-1"), second_output),
- history: None,
- },
- MAX_COMMANDS_PER_SESSION,
- 10,
- );
-
- assert_eq!(evicted_second.len(), 1);
- assert_eq!(evicted_second[0].history_id, HistoryId("first".to_string()));
- assert_eq!(session.records.len(), 1);
- assert_eq!(session.output_bytes, 1);
- }
-
- #[test]
- fn command_output_reports_truncation_metadata() {
- let mut state = SemanticState::default();
- let mut capture = capture(Some("id-1"), Some("session-1"), "partial");
- capture.output_truncated = true;
- capture.output_observed_bytes = 1024;
-
- assert!(state.record_capture(capture));
-
- let reply = command_output(&mut state, "id-1");
- assert!(reply.output_truncated);
- assert_eq!(reply.total_bytes, 7);
- assert_eq!(reply.output_observed_bytes, 1024);
- }
-
- #[test]
- fn output_ranges_are_line_based_inclusive_and_support_negative_offsets() {
- let output = "zero\none\ntwo\nthree\nfour";
- let ranges = vec![
- semantic::OutputRange { start: 1, end: 2 },
- semantic::OutputRange { start: -2, end: -1 },
- ];
-
- assert_eq!(
- select_output_ranges(output, &ranges),
- vec![
- output_line(2, "one"),
- output_line(3, "two"),
- output_line(4, "three"),
- output_line(5, "four"),
- ]
- );
- }
-
- #[test]
- fn output_ranges_merge_overlaps_and_adjacent_ranges() {
- let output = (0..100)
- .map(|n| format!("line {n}"))
- .collect::<Vec<_>>()
- .join("\n");
- let ranges = vec![
- semantic::OutputRange { start: 0, end: 100 },
- semantic::OutputRange {
- start: -100,
- end: -1,
- },
- ];
-
- let selected = select_output_ranges(&output, &ranges);
-
- assert_eq!(selected.len(), 100);
- assert_eq!(selected.first(), Some(&output_line(1, "line 0")));
- assert_eq!(selected.last(), Some(&output_line(100, "line 99")));
- }
-
- #[test]
- fn output_ranges_can_leave_gaps_for_client_formatting() {
- let output = "zero\none\ntwo\nthree\nfour";
- let ranges = vec![
- semantic::OutputRange { start: 0, end: 1 },
- semantic::OutputRange { start: 4, end: 4 },
- ];
-
- assert_eq!(
- select_output_ranges(output, &ranges),
- vec![
- output_line(1, "zero"),
- output_line(2, "one"),
- output_line(5, "four"),
- ]
- );
- }
-
- #[test]
- fn empty_output_ranges_default_to_first_thousand_lines() {
- let output = (0..1001)
- .map(|n| format!("line {n}"))
- .collect::<Vec<_>>()
- .join("\n");
-
- let selected = select_output_ranges(&output, &[]);
-
- assert_eq!(selected.len(), 1000);
- assert_eq!(selected.first(), Some(&output_line(1, "line 0")));
- assert_eq!(selected.last(), Some(&output_line(1000, "line 999")));
- }
-
- #[test]
- fn output_ranges_skip_ranges_fully_outside_output() {
- let output = "zero\none\ntwo";
- let ranges = vec![
- semantic::OutputRange { start: 10, end: 20 },
- semantic::OutputRange {
- start: -20,
- end: -10,
- },
- ];
-
- assert_eq!(select_output_ranges(output, &ranges), Vec::new());
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/components/sync.rs b/crates/turtle/src/atuin_daemon/components/sync.rs
deleted file mode 100644
index 20d49839..00000000
--- a/crates/turtle/src/atuin_daemon/components/sync.rs
+++ /dev/null
@@ -1,285 +0,0 @@
-//! Sync component.
-//!
-//! Handles periodic synchronization with the Atuin cloud server.
-
-use std::time::Duration;
-
-use eyre::Result;
-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::atuin_daemon::{
- daemon::{Component, DaemonHandle},
- events::DaemonEvent,
-};
-
-/// Commands that can be sent to the sync task.
-enum SyncCommand {
- /// Trigger an immediate sync.
- ForceSync,
- /// Stop the sync loop.
- Stop,
-}
-
-/// Sync state - tracks whether we're in normal operation or retrying after failure.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SyncState {
- /// Normal operation. Periodic syncs only run if [`auto_sync`] is enabled.
- Idle,
- /// Retrying after a sync failure. Retries continue regardless of [`auto_sync`]
- /// until the sync succeeds.
- Retrying,
-}
-
-/// Sync component - handles periodic cloud synchronization.
-///
-/// This component:
-/// - Runs a background sync loop on a configurable interval
-/// - Implements exponential backoff on sync failures
-/// - Responds to [`ForceSync`] events for immediate sync
-/// - Emits SyncCompleted/SyncFailed events
-pub(crate) struct SyncComponent {
- task_handle: Option<tokio::task::JoinHandle<()>>,
- command_tx: Option<mpsc::Sender<SyncCommand>>,
-}
-
-impl SyncComponent {
- /// Create a new sync component.
- pub(crate) fn new() -> Self {
- Self {
- task_handle: None,
- command_tx: None,
- }
- }
-}
-
-impl Default for SyncComponent {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[tonic::async_trait]
-impl Component for SyncComponent {
- fn name(&self) -> &'static str {
- "sync"
- }
-
- async fn start(&mut self, handle: DaemonHandle) -> Result<()> {
- let (cmd_tx, cmd_rx) = mpsc::channel(16);
- self.command_tx = Some(cmd_tx);
-
- // Spawn the sync loop with its own copy of the handle
- self.task_handle = Some(tokio::spawn(sync_loop(handle, cmd_rx)));
-
- tracing::info!("sync component started");
- Ok(())
- }
-
- async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {
- match event {
- DaemonEvent::ForceSync => {
- tracing::info!("force sync requested");
- if let Some(tx) = &self.command_tx {
- drop(tx.send(SyncCommand::ForceSync).await);
- }
- }
- DaemonEvent::SyncFailed { error } => {
- tracing::error!(?error, "Sync failed.");
- }
- _ => (),
- }
- Ok(())
- }
-
- async fn stop(&mut self) -> Result<()> {
- if let Some(tx) = &self.command_tx {
- drop(tx.send(SyncCommand::Stop).await);
- }
- if let Some(handle) = self.task_handle.take() {
- // Give the task a moment to shut down gracefully
- drop(time::timeout(Duration::from_secs(5), handle).await);
- }
- tracing::info!("sync component stopped");
- Ok(())
- }
-}
-
-/// The main sync loop.
-///
-/// This runs in a spawned task and handles periodic sync as well as
-/// force sync requests.
-#[expect(clippy::significant_drop_tightening, reason = "false positive")]
-async fn sync_loop(handle: DaemonHandle, mut cmd_rx: mpsc::Receiver<SyncCommand>) {
- tracing::info!("sync loop starting");
-
- // Clone settings since we need them across await points
- let settings = handle.settings().await.clone();
- let host_id = match Settings::host_id().await {
- Ok(id) => id,
- Err(e) => {
- tracing::error!("failed to get host id, sync disabled: {e}");
- return;
- }
- };
-
- // Create the stores we need
- let encryption_key = *handle.encryption_key();
- let history_store = HistoryStore::new(handle.store().clone(), host_id, encryption_key);
-
- // Don't backoff by more than 30 mins (with a random jitter of up to 1 min)
- let max_interval: f64 = 60.0f64.mul_add(30.0, rand::thread_rng().gen_range(0.0..60.0));
-
- let mut ticker = time::interval(Duration::from_secs(settings.daemon.sync_frequency));
-
- // IMPORTANT: without this, if we miss ticks because a sync takes ages or is otherwise delayed,
- // we may end up running a lot of syncs in a hot loop.
- ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
-
- let mut sync_state = SyncState::Idle;
-
- loop {
- tokio::select! {
- _ = ticker.tick() => {
- let settings = handle.settings().await;
-
- // Skip periodic ticks if auto_sync is disabled AND we're not retrying
- // a previous failure. Retries must continue regardless of auto_sync.
- if !settings.sync.auto && sync_state == SyncState::Idle {
- tracing::debug!("auto_sync disabled, skipping periodic sync tick");
- continue;
- }
-
- sync_state = do_sync_tick(
- &handle,
- &history_store,
- &mut ticker,
- max_interval,
- &settings,
- ).await;
- }
- cmd = cmd_rx.recv() => {
- match cmd {
- Some(SyncCommand::ForceSync) => {
- tracing::info!("executing force sync");
- let settings = handle.settings().await;
- sync_state = do_sync_tick(
- &handle,
- &history_store,
- &mut ticker,
- max_interval,
- &settings,
- ).await;
- }
- Some(SyncCommand::Stop) | None => {
- tracing::info!("sync loop stopping");
- break;
- }
- }
- }
- }
- }
-}
-
-/// Execute a single sync tick.
-///
-/// Returns the new sync state: `Idle` on success, `Retrying` on failure.
-async fn do_sync_tick(
- handle: &DaemonHandle,
- history_store: &HistoryStore,
- ticker: &mut time::Interval,
- max_interval: f64,
- settings: &Settings,
-) -> SyncState {
- tracing::info!("sync tick");
-
- // Check if logged in
- let logged_in = match settings.sync.have_sync_user() {
- Ok(v) => v,
- Err(e) => {
- tracing::warn!("failed to check login status, skipping sync tick: {e}");
- return SyncState::Idle;
- }
- };
-
- if !logged_in {
- tracing::debug!("not logged in, skipping sync tick");
- return SyncState::Idle;
- }
-
- // Perform the sync
- let res = sync::sync(settings, handle.store(), handle.encryption_key()).await;
-
- match res {
- Err(e) => {
- tracing::error!("sync tick failed with {e}");
-
- // Emit failure event
- handle.emit(DaemonEvent::SyncFailed {
- error: e.to_string(),
- });
-
- // Exponential backoff
- let mut rng = rand::thread_rng();
- let mut new_interval = ticker.period().as_secs_f64() * rng.gen_range(2.0..2.2);
-
- if new_interval > max_interval {
- new_interval = max_interval;
- }
-
- *ticker = time::interval_at(
- time::Instant::now() + Duration::from_secs(new_interval as u64),
- Duration::from_secs(new_interval as u64),
- );
- ticker.reset_after(Duration::from_secs(new_interval as u64));
- ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
-
- tracing::error!("backing off, next sync tick in {new_interval}");
-
- SyncState::Retrying
- }
- Ok((uploaded_count, downloaded_records)) => {
- tracing::info!(
- uploaded = uploaded_count,
- downloaded = downloaded_records.len(),
- "sync complete"
- );
-
- // Build history from downloaded records
- if let Err(e) = history_store
- .incremental_build(handle.history_db(), &downloaded_records)
- .await
- {
- tracing::error!("failed to build history from downloaded records: {e}");
- }
-
- // Emit the records added event (for search indexing)
- handle.emit(DaemonEvent::RecordsAdded(downloaded_records.clone()));
-
- // Emit sync completed event
- handle.emit(DaemonEvent::SyncCompleted {
- uploaded: uploaded_count as usize,
- downloaded: downloaded_records.len(),
- });
-
- // Reset backoff on success
- if ticker.period().as_secs() != settings.daemon.sync_frequency {
- *ticker = time::interval_at(
- time::Instant::now() + Duration::from_secs(settings.daemon.sync_frequency),
- Duration::from_secs(settings.daemon.sync_frequency),
- );
- ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
- }
-
- // Store sync time
- if let Err(e) = Settings::save_sync_time().await {
- tracing::error!("failed to save sync time: {e}");
- }
-
- SyncState::Idle
- }
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/control/mod.rs b/crates/turtle/src/atuin_daemon/control/mod.rs
deleted file mode 100644
index 79398d61..00000000
--- a/crates/turtle/src/atuin_daemon/control/mod.rs
+++ /dev/null
@@ -1,76 +0,0 @@
-//! Control service implementation.
-//!
-//! This gRPC service allows external processes (like CLI commands) to inject
-//! events into the daemon's event bus.
-
-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,
- },
- },
-};
-
-/// The Control gRPC service.
-///
-/// 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(crate) struct ControlService {
- handle: DaemonHandle,
-}
-
-impl ControlService {
- /// Create a new control service with the given daemon handle.
- pub(crate) fn new(handle: DaemonHandle) -> Self {
- Self { handle }
- }
-
- /// Get a tonic server for this service.
- pub(crate) fn into_server(self) -> ControlServer<Self> {
- ControlServer::new(self)
- }
-}
-
-#[tonic::async_trait]
-impl Control for ControlService {
- #[instrument(skip_all, level = Level::INFO, name = "control_send_event")]
- async fn send_event(
- &self,
- request: Request<SendEventRequest>,
- ) -> Result<Response<SendEventResponse>, Status> {
- let req = request.into_inner();
-
- let event = req
- .event
- .ok_or_else(|| Status::invalid_argument("event is required"))?;
-
- let daemon_event = proto_event_to_daemon_event(event);
-
- info!(?daemon_event, "received control event");
- self.handle.emit(daemon_event);
-
- Ok(Response::new(SendEventResponse {}))
- }
-}
-
-/// Convert a proto event to a daemon event.
-fn proto_event_to_daemon_event(event: Event) -> DaemonEvent {
- match event {
- Event::HistoryPruned(_) => DaemonEvent::HistoryPruned,
- Event::HistoryRebuilt(_) => DaemonEvent::HistoryRebuilt,
- Event::HistoryDeleted(e) => DaemonEvent::HistoryDeleted {
- ids: e.ids.into_iter().map(HistoryId).collect(),
- },
- Event::ForceSync(_) => DaemonEvent::ForceSync,
- Event::SettingsReloaded(_) => DaemonEvent::SettingsReloaded,
- Event::Shutdown(_) => DaemonEvent::ShutdownRequested,
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/daemon.rs b/crates/turtle/src/atuin_daemon/daemon.rs
deleted file mode 100644
index 80aaeef8..00000000
--- a/crates/turtle/src/atuin_daemon/daemon.rs
+++ /dev/null
@@ -1,436 +0,0 @@
-//! Core daemon infrastructure.
-//!
-//! This module provides the foundational types for building the atuin daemon:
-//!
-//! - [`DaemonState`]: Shared state owned by the daemon
-//! - [`DaemonHandle`]: A lightweight, cloneable handle for accessing daemon state
-//! - [`Component`]: A trait for implementing daemon components
-//! - [`Daemon`]: The main daemon orchestrator
-//! - [`DaemonBuilder`]: Builder for constructing and configuring the daemon
-
-use std::sync::Arc;
-
-use crate::atuin_client::{
- database::ClientSqlite as HistoryDatabase, encryption, record::sqlite_store::SqliteStore,
- settings::Settings,
-};
-use eyre::{Context, Result};
-use tokio::sync::{RwLock, broadcast};
-
-use crate::atuin_daemon::events::DaemonEvent;
-
-// ============================================================================
-// DaemonState
-// ============================================================================
-
-/// Shared state owned by the daemon.
-///
-/// This contains all the resources that components and services need access to.
-/// The state is wrapped in an `Arc` and accessed via [`DaemonHandle`].
-pub(crate) struct DaemonState {
- // Event bus
- event_tx: broadcast::Sender<DaemonEvent>,
-
- // Configuration (mutable - can be reloaded)
- settings: RwLock<Settings>,
-
- // Encryption key (immutable - derived at startup)
- encryption_key: [u8; 32],
-
- // Database handles
- history_db: HistoryDatabase,
- store: SqliteStore,
-}
-
-// ============================================================================
-// DaemonHandle
-// ============================================================================
-
-/// A lightweight handle to the daemon's shared state.
-///
-/// This is the primary way for components, gRPC services, and spawned tasks to
-/// interact with the daemon. It provides access to:
-///
-/// - Event emission and subscription
-/// - Configuration (settings, encryption key)
-/// - Database handles
-///
-/// The handle is cheaply cloneable (wraps an `Arc`) and can be freely passed
-/// around to any code that needs daemon access.
-///
-/// # Example
-///
-/// ```ignore
-/// // Emit an event
-/// handle.emit(DaemonEvent::HistoryPruned);
-///
-/// // Access settings
-/// let settings = handle.settings().await;
-/// let sync_freq = settings.daemon.sync_frequency;
-///
-/// // Access database
-/// let history = handle.history_db().load(id).await?;
-/// ```
-#[derive(Clone)]
-pub(crate) struct DaemonHandle {
- state: Arc<DaemonState>,
-}
-
-impl DaemonHandle {
- // ---- Events ----
-
- /// Emit an event to the daemon's event bus.
- ///
- /// This is fire-and-forget - if no receivers are listening (which shouldn't
- /// happen in normal operation), the event is dropped silently.
- 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}");
- }
- }
-
- /// Subscribe to the event bus.
- ///
- /// 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(crate) fn subscribe(&self) -> broadcast::Receiver<DaemonEvent> {
- self.state.event_tx.subscribe()
- }
-
- /// Request graceful shutdown of the daemon.
- pub(crate) fn shutdown(&self) {
- self.emit(DaemonEvent::ShutdownRequested);
- }
-
- // ---- Configuration ----
-
- /// Get the current settings.
- ///
- /// This acquires a read lock on the settings. For most use cases, clone
- /// the settings if you need to hold onto them.
- pub(crate) async fn settings(&self) -> tokio::sync::RwLockReadGuard<'_, Settings> {
- self.state.settings.read().await
- }
-
- /// Apply already-loaded settings and emit a [`SettingsReloaded`] event.
- ///
- /// Use this when settings have already been loaded (e.g., from a file watcher)
- /// to avoid parsing the config file twice.
- 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(crate) fn encryption_key(&self) -> &[u8; 32] {
- &self.state.encryption_key
- }
-
- // ---- Database ----
-
- /// Get a reference to the history database.
- pub(crate) fn history_db(&self) -> &HistoryDatabase {
- &self.state.history_db
- }
-
- /// Get a reference to the record store.
- pub(crate) fn store(&self) -> &SqliteStore {
- &self.state.store
- }
-}
-
-impl std::fmt::Debug for DaemonHandle {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.debug_struct("DaemonHandle").finish_non_exhaustive()
- }
-}
-
-// ============================================================================
-// Component Trait
-// ============================================================================
-
-/// A daemon component that handles a specific domain.
-///
-/// Components are the building blocks of the daemon. Each component:
-///
-/// - Has a unique name for logging and debugging
-/// - Can optionally expose gRPC services
-/// - Receives a [`DaemonHandle`] on startup for accessing daemon resources
-/// - Handles events from the event bus
-/// - Performs cleanup on shutdown
-///
-/// # Lifecycle
-///
-/// 1. **Construction**: Component is created (usually via `new()`)
-/// 2. **Start**: `start()` is called with a [`DaemonHandle`]
-/// 3. **Running**: `handle_event()` is called for each event on the bus
-/// 4. **Shutdown**: `stop()` is called for cleanup
-///
-/// # Example
-///
-/// ```ignore
-/// pub(crate) struct MyComponent {
-/// handle: Option<DaemonHandle>,
-/// }
-///
-/// #[async_trait]
-/// impl Component for MyComponent {
-/// fn name(&self) -> &'static str { "my-component" }
-///
-/// async fn start(&mut self, handle: DaemonHandle) -> Result<()> {
-/// self.handle = Some(handle);
-/// Ok(())
-/// }
-///
-/// async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()> {
-/// match event {
-/// DaemonEvent::SomeEvent => {
-/// // Handle the event
-/// if let Some(handle) = &self.handle {
-/// handle.emit(DaemonEvent::ResponseEvent);
-/// }
-/// }
-/// _ => {}
-/// }
-/// Ok(())
-/// }
-///
-/// async fn stop(&mut self) -> Result<()> {
-/// Ok(())
-/// }
-/// }
-/// ```
-#[tonic::async_trait]
-pub(crate) trait Component: Send + Sync {
- /// Human-readable name for logging and debugging.
- fn name(&self) -> &'static str;
-
- /// Called once at startup.
- ///
- /// Store the handle if you need to emit events or access daemon resources
- /// later. The handle is cheaply cloneable, so feel free to clone it for
- /// spawned tasks.
- async fn start(&mut self, handle: DaemonHandle) -> Result<()>;
-
- /// Handle an incoming event.
- ///
- /// Called for every event on the bus. To emit new events in response,
- /// use the handle stored during `start()`. Events emitted here will be
- /// processed in subsequent event loop iterations.
- async fn handle_event(&mut self, event: &DaemonEvent) -> Result<()>;
-
- /// Called on graceful shutdown.
- ///
- /// Use this to clean up resources, abort spawned tasks, etc.
- async fn stop(&mut self) -> Result<()>;
-}
-
-// ============================================================================
-// Daemon
-// ============================================================================
-
-/// The main daemon orchestrator.
-///
-/// The daemon manages components, runs the event loop, and coordinates startup
-/// and shutdown. It is constructed via [`DaemonBuilder`].
-///
-/// # Event Loop
-///
-/// The daemon runs a simple event loop:
-///
-/// 1. Wait for an event on the bus
-/// 2. Dispatch the event to all components (in registration order)
-/// 3. Components may emit new events in response
-/// 4. Repeat until `ShutdownRequested` is received
-///
-/// Events emitted during handling are queued and processed in subsequent
-/// iterations, ensuring the loop eventually drains.
-pub(crate) struct Daemon {
- components: Vec<Box<dyn Component>>,
- handle: DaemonHandle,
-}
-
-impl Daemon {
- /// Create a new daemon builder.
- 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(crate) fn handle(&self) -> DaemonHandle {
- self.handle.clone()
- }
-
- /// Start all components.
- ///
- /// This must be called before `run_event_loop()`. It initializes all
- /// registered components with the daemon handle.
- pub(crate) async fn start_components(&mut self) -> Result<()> {
- for component in &mut self.components {
- tracing::info!(component = component.name(), "starting component");
- component
- .start(self.handle.clone())
- .await
- .with_context(|| format!("failed to start component: {}", component.name()))?;
- }
- Ok(())
- }
-
- /// Run the daemon event loop.
- ///
- /// This processes events until a [`ShutdownRequested`] event is received.
- /// Components must be started first via `start_components()`.
- pub(crate) async fn run_event_loop(&mut self) -> Result<()> {
- let mut event_rx = self.handle.subscribe();
- loop {
- match event_rx.recv().await {
- Ok(DaemonEvent::ShutdownRequested) => {
- tracing::info!("shutdown requested, stopping daemon");
- break;
- }
- Ok(event) => {
- tracing::debug!(?event, "processing event");
- self.dispatch_event(&event).await;
- }
- Err(broadcast::error::RecvError::Lagged(n)) => {
- tracing::warn!(
- skipped = n,
- "event receiver lagged, some events were dropped"
- );
- }
- Err(broadcast::error::RecvError::Closed) => {
- tracing::info!("event bus closed, stopping daemon");
- break;
- }
- }
- }
- Ok(())
- }
-
- /// Stop all components.
- ///
- /// This performs graceful shutdown of all components.
- 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 {
- tracing::error!(
- component = component.name(),
- error = ?e,
- "error stopping component"
- );
- }
- }
- tracing::info!("all components stopped");
- }
-
- async fn dispatch_event(&mut self, event: &DaemonEvent) {
- for component in &mut self.components {
- if let Err(e) = component.handle_event(event).await {
- tracing::error!(
- component = component.name(),
- error = ?e,
- "error handling event"
- );
- }
- }
- }
-}
-
-// ============================================================================
-// DaemonBuilder
-// ============================================================================
-
-/// Builder for constructing a [`Daemon`].
-///
-/// # Example
-///
-/// ```ignore
-/// let daemon = Daemon::builder(settings)
-/// .store(store)
-/// .history_db(history_db)
-/// .component(HistoryComponent::new())
-/// .component(SearchComponent::new())
-/// .component(SyncComponent::new())
-/// .build()
-/// .await?;
-///
-/// daemon.run().await?;
-/// ```
-pub(crate) struct DaemonBuilder {
- settings: Settings,
- store: Option<SqliteStore>,
- history_db: Option<HistoryDatabase>,
- components: Vec<Box<dyn Component>>,
-}
-
-impl DaemonBuilder {
- /// Create a new daemon builder with the given settings.
- pub(crate) fn new(settings: Settings) -> Self {
- Self {
- settings,
- store: None,
- history_db: None,
- components: Vec::new(),
- }
- }
-
- /// Set the record store.
- pub(crate) fn store(mut self, store: SqliteStore) -> Self {
- self.store = Some(store);
- self
- }
-
- /// Set the history database.
- pub(crate) fn history_db(mut self, db: HistoryDatabase) -> Self {
- self.history_db = Some(db);
- self
- }
-
- /// Register a component.
- ///
- /// Components are started in registration order and stopped in reverse order.
- pub(crate) fn component(mut self, component: impl Component + 'static) -> Self {
- self.components.push(Box::new(component));
- self
- }
-
- /// Build the daemon.
- ///
- /// This loads the encryption key and creates the daemon state.
- 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
- .ok_or_else(|| eyre::eyre!("history_db is required"))?;
-
- // Load encryption key
- let encryption_key: [u8; 32] = encryption::load_key(&self.settings)
- .context("could not load encryption key")?
- .into();
-
- // Create the event bus
- let (event_tx, _) = broadcast::channel(64);
-
- // Create the shared state
- let state = Arc::new(DaemonState {
- event_tx,
- settings: RwLock::new(self.settings),
- encryption_key,
- history_db,
- store,
- });
-
- // Create the handle (just a reference to the state)
- let handle = DaemonHandle { state };
-
- Ok(Daemon {
- components: self.components,
- handle,
- })
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/events.rs b/crates/turtle/src/atuin_daemon/events.rs
deleted file mode 100644
index d379277d..00000000
--- a/crates/turtle/src/atuin_daemon/events.rs
+++ /dev/null
@@ -1,77 +0,0 @@
-//! Daemon events.
-//!
-//! Events are the primary communication mechanism within the daemon.
-//! Components emit events to notify others of state changes, and handle
-//! events to react to changes elsewhere in the system.
-//!
-//! External processes (like CLI commands) can also inject events via the
-//! Control gRPC service.
-
-use crate::atuin_client::history::{History, HistoryId};
-use crate::atuin_common::record::RecordId;
-
-/// Events that flow through the daemon's event bus.
-///
-/// Events are broadcast to all components. Each component decides which
-/// events it cares about in its `handle_event` implementation.
-#[derive(Debug, Clone)]
-pub(crate) enum DaemonEvent {
- // ---- History lifecycle ----
- /// A command has started running.
- HistoryStarted(History),
-
- /// A command has finished running.
- HistoryEnded(History),
-
- // ---- Sync ----
- /// Records were synced from the server.
- ///
- /// The search component uses this to update its index with new history.
- RecordsAdded(Vec<RecordId>),
-
- /// Sync completed successfully.
- SyncCompleted {
- /// Number of records uploaded.
- #[expect(unused)]
- uploaded: usize,
-
- /// Number of records downloaded.
- #[expect(unused)]
- downloaded: usize,
- },
-
- /// Sync failed.
- SyncFailed {
- /// Error message describing what went wrong.
- error: String,
- },
-
- /// Request an immediate sync (external trigger).
- ForceSync,
-
- // ---- External commands ----
- /// History was pruned - search index needs a full rebuild.
- ///
- /// Emitted when the user runs `atuin history prune` or similar.
- HistoryPruned,
-
- /// History was rebuilt - search index needs a full rebuild.
- ///
- /// Emitted when the user runs `atuin store rebuild history` or similar.
- HistoryRebuilt,
-
- /// Specific history items were deleted.
- ///
- /// The search component should remove these from its index.
- HistoryDeleted {
- /// IDs of the deleted history entries.
- ids: Vec<HistoryId>,
- },
-
- /// Settings have changed, components should reload if needed.
- SettingsReloaded,
-
- // ---- Lifecycle ----
- /// Request graceful shutdown of the daemon.
- ShutdownRequested,
-}
diff --git a/crates/turtle/src/atuin_daemon/generated.rs b/crates/turtle/src/atuin_daemon/generated.rs
deleted file mode 100644
index a3ea4d9d..00000000
--- a/crates/turtle/src/atuin_daemon/generated.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-#![expect(
- unreachable_pub,
- unused_qualifications,
- clippy::doc_markdown,
- clippy::default_trait_access,
- clippy::too_many_lines,
- clippy::trivially_copy_pass_by_ref,
- clippy::allow_attributes,
- clippy::derive_partial_eq_without_eq,
- reason = "All of these lints are triggered by the generated code"
-)]
-
-/// Semantic command capture gRPC service types.
-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(crate) 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(crate) mod history {
- // Include the generated proto code
- tonic::include_proto!("history");
-}
-
-/// Control module for external event injection.
-///
-/// This module provides the gRPC service that allows external processes
-/// (like CLI commands) to inject events into the daemon's event bus.
-pub(crate) mod control {
- // Include the generated proto code
- tonic::include_proto!("control");
-
- // Re-export the service
- pub(crate) use crate::atuin_daemon::control::ControlService;
-}
diff --git a/crates/turtle/src/atuin_daemon/mod.rs b/crates/turtle/src/atuin_daemon/mod.rs
deleted file mode 100644
index 5f0f489e..00000000
--- a/crates/turtle/src/atuin_daemon/mod.rs
+++ /dev/null
@@ -1,124 +0,0 @@
-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 eyre::Result;
-
-pub(crate) mod client;
-pub(crate) mod components;
-pub(crate) mod control;
-pub(crate) mod daemon;
-pub(crate) mod events;
-pub(crate) mod search;
-pub(crate) mod server;
-
-pub(crate) mod generated;
-
-// Re-export core daemon types for convenience
-pub(crate) use daemon::Daemon;
-pub(crate) use events::DaemonEvent;
-
-// Re-export components
-pub(crate) use components::{HistoryComponent, SearchComponent, SemanticComponent, SyncComponent};
-
-// Re-export client helpers
-pub(crate) use client::SemanticClient;
-
-/// Boot the daemon using the new component-based architecture.
-///
-/// This creates a daemon with the standard components (history, search, sync),
-/// starts the gRPC server with their services, and runs the event loop.
-pub(crate) async fn boot(
- settings: Settings,
- store: SqliteStore,
- history_db: HistoryDatabase,
-) -> 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
- let mut daemon = Daemon::builder(settings.clone())
- .store(store)
- .history_db(history_db)
- .component(history_component)
- .component(search_component)
- .component(semantic_component)
- .component(sync_component)
- .build()?;
-
- // Get a handle for the control service and gRPC server shutdown
- let handle = daemon.handle();
-
- // Create the control service
- let control_service = control::ControlService::new(handle.clone());
-
- // Start all components first (so gRPC services can work)
- daemon.start_components().await?;
-
- // Spawn config file watcher to reload settings on changes
- if let Ok(watcher) = global_settings_watcher() {
- let mut settings_rx = watcher.subscribe();
- let watcher_handle = handle.clone();
- tokio::spawn(async move {
- tracing::info!("config file watcher started");
- while settings_rx.changed().await.is_ok() {
- // Use the already-loaded settings from the watcher
- // (avoids parsing the config file twice)
- let new_settings = (*settings_rx.borrow()).clone();
- watcher_handle.apply_settings((*new_settings).clone()).await;
- }
- tracing::debug!("config file watcher stopped");
- });
- } else {
- tracing::warn!(
- "failed to start config file watcher; settings changes will require daemon restart"
- );
- }
-
- // Spawn signal handler to emit ShutdownRequested on Ctrl+C/SIGTERM
- let signal_handle = handle.clone();
- tokio::spawn(async move {
- shutdown_signal().await;
- tracing::info!("received shutdown signal");
- signal_handle.shutdown();
- });
-
- server::run_grpc_server(
- &settings,
- history_service,
- search_service,
- semantic_service,
- control_service.into_server(),
- handle,
- )?;
-
- daemon.run_event_loop().await?;
-
- // After the event loop exited, we shut-down the components.
- daemon.stop_components().await;
-
- tracing::info!("daemon shut down complete");
- Ok(())
-}
-
-/// Wait for a shutdown signal (Ctrl+C or SIGTERM).
-#[cfg(unix)]
-async fn shutdown_signal() {
- let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
- .expect("failed to register sigterm handler");
- let mut int = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
- .expect("failed to register sigint handler");
-
- tokio::select! {
- _ = term.recv() => {},
- _ = int.recv() => {},
- }
-}
diff --git a/crates/turtle/src/atuin_daemon/search/mod.rs b/crates/turtle/src/atuin_daemon/search/mod.rs
deleted file mode 100644
index 02c79c9c..00000000
--- a/crates/turtle/src/atuin_daemon/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(crate) struct FrecencyData {
- /// Total number of times this command was used.
- pub(crate) count: u32,
- /// Most recent usage timestamp (unix seconds).
- pub(crate) last_used: i64,
-}
-
-impl FrecencyData {
- /// Record a new usage of this command.
- pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) struct QueryContext {
- #[expect(dead_code)]
- pub(crate) cwd: Option<String>,
- #[expect(dead_code)]
- pub(crate) git_root: Option<String>,
- #[expect(dead_code)]
- pub(crate) hostname: Option<String>,
- #[expect(dead_code)]
- pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) fn add_histories(&self, histories: &[History]) {
- for history in histories {
- self.add_history(history);
- }
- }
-
- /// Get the number of unique commands in the index.
- pub(crate) 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(crate) 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(crate) 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/turtle/src/atuin_daemon/server.rs b/crates/turtle/src/atuin_daemon/server.rs
deleted file mode 100644
index 36954cca..00000000
--- a/crates/turtle/src/atuin_daemon/server.rs
+++ /dev/null
@@ -1,121 +0,0 @@
-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,
- },
- },
-};
-
-/// Run the gRPC server with the given services.
-///
-/// 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(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,
-) -> Result<()> {
- use tokio::net::UnixListener;
- use tokio_stream::wrappers::UnixListenerStream;
-
- let socket_path = settings.daemon.socket_path.clone();
-
- let (uds, cleanup) = if cfg!(target_os = "linux") && settings.daemon.systemd_socket {
- #[cfg(target_os = "linux")]
- {
- use eyre::{OptionExt, WrapErr};
- use std::os::unix::net::SocketAddr;
- use std::path::PathBuf;
- tracing::info!("getting systemd socket");
- let listener = listenfd::ListenFd::from_env()
- .take_unix_listener(0)?
- .ok_or_eyre("missing systemd socket")?;
- listener.set_nonblocking(true)?;
- let actual_path: Result<PathBuf, eyre::Report> = listener
- .local_addr()
- .context("getting systemd socket's path")
- .and_then(|addr: SocketAddr| {
- addr.as_pathname()
- .ok_or_eyre("systemd socket missing path")
- .map(|path: &std::path::Path| path.to_owned())
- });
- match actual_path {
- Ok(actual_path) => {
- tracing::info!("listening on systemd socket: {actual_path:?}");
- if actual_path != std::path::Path::new(&socket_path) {
- tracing::warn!(
- "systemd socket is not at configured client path: {socket_path:?}"
- );
- }
- }
- Err(err) => {
- tracing::warn!(
- "could not detect systemd socket path, ensure that it's at the configured path: {socket_path:?}, error: {err:?}"
- );
- }
- }
- (UnixListener::from_std(listener)?, false)
- }
- } else {
- tracing::info!("listening on unix socket {socket_path:?}");
- (UnixListener::bind(socket_path.clone())?, true)
- };
-
- let uds_stream = UnixListenerStream::new(uds);
-
- // Create shutdown signal from daemon handle
- let shutdown_signal = async move {
- let mut rx = handle.subscribe();
-
- loop {
- use crate::atuin_daemon::DaemonEvent;
-
- match rx.recv().await {
- Err(_) | Ok(DaemonEvent::ShutdownRequested) => break,
- Ok(_) => (),
- }
- }
-
- if cleanup {
- eprintln!("Removing socket...");
- if let Err(e) = std::fs::remove_file(&socket_path)
- && e.kind() != std::io::ErrorKind::NotFound
- {
- eprintln!("failed to remove socket: {e}");
- }
- }
- eprintln!("Shutting down gRPC server...");
- };
-
- // Spawn the server in the background
- tokio::spawn(async move {
- use tonic::transport::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)
- .await
- {
- tracing::error!("gRPC server error: {e}");
- }
- });
-
- Ok(())
-}
diff --git a/crates/turtle/src/atuin_history/mod.rs b/crates/turtle/src/atuin_history/mod.rs
deleted file mode 100644
index 41336a14..00000000
--- a/crates/turtle/src/atuin_history/mod.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-pub(crate) mod sort;
-pub(crate) mod stats;
diff --git a/crates/turtle/src/atuin_history/sort.rs b/crates/turtle/src/atuin_history/sort.rs
deleted file mode 100644
index 3143fa68..00000000
--- a/crates/turtle/src/atuin_history/sort.rs
+++ /dev/null
@@ -1,46 +0,0 @@
-use crate::atuin_client::history::History;
-
-type ScoredHistory = (f64, History);
-
-// Fuzzy search already comes sorted by minspan
-// This sorting should be applicable to all search modes, and solve the more "obvious" issues
-// first.
-// Later on, we can pass in context and do some boosts there too.
-pub(crate) fn sort(query: &str, input: Vec<History>) -> Vec<History> {
- // This can totally be extended. We need to be _careful_ that it's not slow.
- // We also need to balance sorting db-side with sorting here. SQLite can do a lot,
- // but some things are just much easier/more doable in Rust.
-
- let mut scored = input
- .into_iter()
- .map(|h| {
- // If history is _prefixed_ with the query, score it more highly
- let score = if h.command.starts_with(query) {
- 2.0
- } else if h.command.contains(query) {
- 1.75
- } else {
- 1.0
- };
-
- // calculate how long ago the history was, in seconds
- let now = time::OffsetDateTime::now_utc().unix_timestamp();
- let time = h.timestamp.unix_timestamp();
- let diff = std::cmp::max(1, now - time); // no /0 please
-
- // prefer newer history, but not hugely so as to offset the other scoring
- // the numbers will get super small over time, but I don't want time to overpower other
- // scoring
- #[expect(clippy::cast_precision_loss)]
- let time_score = 1.0 + (1.0 / diff as f64);
- let score = score * time_score;
-
- (score, h)
- })
- .collect::<Vec<ScoredHistory>>();
-
- scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().reverse());
-
- // Remove the scores and return the history
- scored.into_iter().map(|(_, h)| h).collect::<Vec<History>>()
-}
diff --git a/crates/turtle/src/atuin_history/stats.rs b/crates/turtle/src/atuin_history/stats.rs
deleted file mode 100644
index c53dafb2..00000000
--- a/crates/turtle/src/atuin_history/stats.rs
+++ /dev/null
@@ -1,527 +0,0 @@
-use std::collections::{HashMap, HashSet};
-
-use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor};
-use serde::{Deserialize, Serialize};
-use unicode_segmentation::UnicodeSegmentation;
-
-use crate::atuin_client::{history::History, settings::Settings};
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub(crate) struct Stats {
- pub(crate) total_commands: usize,
- pub(crate) unique_commands: usize,
- pub(crate) top: Vec<(Vec<String>, usize)>,
-}
-
-fn first_non_whitespace(s: &str) -> Option<usize> {
- s.char_indices()
- // find the first non whitespace char
- .find(|(_, c)| !c.is_ascii_whitespace())
- // return the index of that char
- .map(|(i, _)| i)
-}
-
-fn first_whitespace(s: &str) -> usize {
- s.char_indices()
- // find the first whitespace char
- .find(|(_, c)| c.is_ascii_whitespace())
- // return the index of that char, (or the max length of the string)
- .map_or(s.len(), |(i, _)| i)
-}
-
-fn interesting_command<'a>(settings: &Settings, mut command: &'a str) -> &'a str {
- // Sort by length so that we match the longest prefix first
- let mut common_prefix = settings.stats.common_prefix.clone();
- common_prefix.sort_by_key(|b| std::cmp::Reverse(b.len()));
-
- // Trim off the common prefix, if it exists
- for p in &common_prefix {
- if command.starts_with(p) {
- let i = p.len();
- let prefix = &command[..i];
- command = command[i..].trim_start();
- if command.is_empty() {
- // no commands following, just use the prefix
- return prefix;
- }
- break;
- }
- }
-
- // Sort the common_subcommands by length so that we match the longest subcommand first
- let mut common_subcommands = settings.stats.common_subcommands.clone();
- common_subcommands.sort_by_key(|b| std::cmp::Reverse(b.len()));
-
- // Check for a common subcommand
- for p in &common_subcommands {
- if command.starts_with(p) {
- // if the subcommand is the same length as the command, then we just use the subcommand
- if p.len() == command.len() {
- return command;
- }
- // otherwise we need to use the subcommand + the next word
- let non_whitespace = first_non_whitespace(&command[p.len()..]).unwrap_or(0);
- let j =
- p.len() + non_whitespace + first_whitespace(&command[p.len() + non_whitespace..]);
- return &command[..j];
- }
- }
- // Return the first word if there is no subcommand
- &command[..first_whitespace(command)]
-}
-
-fn split_at_pipe(command: &str) -> Vec<&str> {
- let mut result = vec![];
- let mut quoted = false;
- let mut start = 0;
- let mut graphemes = UnicodeSegmentation::grapheme_indices(command, true);
-
- while let Some((i, c)) = graphemes.next() {
- let current = i;
- match c {
- "\"" if command[start..current] != *"\"" => {
- quoted = !quoted;
- }
- "'" if command[start..current] != *"'" => {
- quoted = !quoted;
- }
- "\\" if graphemes.next().is_some() => {}
- "|" if !quoted => {
- if current > start && command[start..].starts_with('|') {
- start += 1;
- }
- result.push(&command[start..current]);
- start = current;
- }
- _ => {}
- }
- }
- if command[start..].starts_with('|') {
- start += 1;
- }
- result.push(&command[start..]);
- result
-}
-
-fn strip_leading_env_vars(command: &str) -> &str {
- // fast path: no equals sign, no environment variable
- if !command.contains('=') {
- return command;
- }
-
- let mut in_token = false;
- let mut token_start_pos = 0;
- let mut in_single_quotes = false;
- let mut in_double_quotes = false;
- let mut escape_next = false;
- let mut has_equals_outside_quotes = false;
-
- for (i, g) in UnicodeSegmentation::grapheme_indices(command, true) {
- if escape_next {
- escape_next = false;
- continue;
- }
-
- if !in_token {
- token_start_pos = i;
- }
-
- match g {
- "\\" => {
- escape_next = true;
- in_token = true;
- }
- "'" if !in_double_quotes => {
- in_single_quotes = !in_single_quotes;
- in_token = true;
- }
- "\"" if !in_single_quotes => {
- in_double_quotes = !in_double_quotes;
- in_token = true;
- }
- "=" if !in_single_quotes && !in_double_quotes => {
- has_equals_outside_quotes = true;
- in_token = true;
- }
- " " | "\t" if !in_single_quotes && !in_double_quotes => {
- if in_token {
- if !has_equals_outside_quotes {
- // if we're not in an env var, we can break early
- break;
- }
- in_token = false;
- has_equals_outside_quotes = false;
- }
- }
- _ => {
- in_token = true;
- }
- }
- }
-
- command[token_start_pos..].trim()
-}
-
-pub(crate) fn pretty_print(stats: Stats, ngram_size: usize) {
- let max = stats.top.iter().map(|x| x.1).max().unwrap();
- let num_pad = max.ilog10() as usize + 1;
-
- // Find the length of the longest command name for each column
- let column_widths = stats
- .top
- .iter()
- .map(|(commands, _)| commands.iter().map(String::len).collect::<Vec<usize>>())
- .fold(vec![0; ngram_size], |acc, item| {
- acc.iter()
- .zip(item.iter())
- .map(|(a, i)| *std::cmp::max(a, i))
- .collect()
- });
-
- for (command, count) in stats.top {
- let gray = SetForegroundColor(Color::Grey);
- let bold = SetAttribute(crossterm::style::Attribute::Bold);
-
- let in_ten = 10 * count / max;
-
- print!("[");
- print!("{}", SetForegroundColor(Color::Red));
-
- for i in 0..in_ten {
- if i == 2 {
- print!("{}", SetForegroundColor(Color::Yellow));
- }
-
- if i == 5 {
- print!("{}", SetForegroundColor(Color::Green));
- }
-
- print!("▮");
- }
-
- for _ in in_ten..10 {
- print!(" ");
- }
-
- let formatted_command = command
- .iter()
- .zip(column_widths.iter())
- .map(|(cmd, width)| format!("{cmd:width$}"))
- .collect::<Vec<_>>()
- .join(" | ");
-
- println!(
- "{ResetColor}] {gray}{count:num_pad$}{ResetColor} {bold}{formatted_command}{ResetColor}"
- );
- }
- println!("Total commands: {}", stats.total_commands);
- println!("Unique commands: {}", stats.unique_commands);
-}
-
-pub(crate) fn compute(
- settings: &Settings,
- history: &[History],
- count: usize,
- ngram_size: usize,
-) -> Option<Stats> {
- let mut commands = HashSet::<&str>::with_capacity(history.len());
- let mut total_unignored = 0;
- let mut prefixes = HashMap::<Vec<&str>, usize>::with_capacity(history.len());
-
- for i in history {
- // just in case it somehow has a leading tab or space or something (legacy atuin didn't ignore space prefixes)
- let command = strip_leading_env_vars(i.command.trim());
- let prefix = interesting_command(settings, command);
-
- if settings.stats.ignored_commands.iter().any(|c| c == prefix) {
- continue;
- }
-
- total_unignored += 1;
- commands.insert(command);
-
- split_at_pipe(command)
- .iter()
- .map(|l| {
- let command = l.trim();
- commands.insert(command);
- command
- })
- .collect::<Vec<_>>()
- .windows(ngram_size)
- .for_each(|w| {
- *prefixes
- .entry(w.iter().map(|c| interesting_command(settings, c)).collect())
- .or_default() += 1;
- });
- }
-
- let unique = commands.len();
- let mut top = prefixes.into_iter().collect::<Vec<_>>();
-
- top.sort_unstable_by_key(|x| std::cmp::Reverse(x.1));
- top.truncate(count);
-
- if top.is_empty() {
- return None;
- }
-
- Some(Stats {
- unique_commands: unique,
- total_commands: total_unignored,
- top: top
- .into_iter()
- .map(|t| (t.0.into_iter().map(ToString::to_string).collect(), t.1))
- .collect(),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::history::History;
- use crate::atuin_client::settings::Settings;
- use time::OffsetDateTime;
-
- use super::compute;
- use super::{interesting_command, split_at_pipe, strip_leading_env_vars};
-
- #[test]
- fn ignored_env_vars() {
- let settings = Settings::new().unwrap();
-
- let history: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("FOO='BAR=🚀' echo foo")
- .cwd("/")
- .build()
- .into();
-
- let stats = compute(&settings, &[history], 10, 1).expect("failed to compute stats");
- assert_eq!(stats.top.first().unwrap().0, vec!["echo"]);
- }
-
- #[test]
- fn ignored_commands() {
- let mut settings = Settings::new().unwrap();
- settings.stats.ignored_commands.push("cd".to_string());
-
- let history = [
- History::import()
- .timestamp(OffsetDateTime::now_utc())
- .command("cd foo")
- .build()
- .into(),
- History::import()
- .timestamp(OffsetDateTime::now_utc())
- .command("cargo build stuff")
- .build()
- .into(),
- ];
-
- let stats = compute(&settings, &history, 10, 1).expect("failed to compute stats");
- assert_eq!(stats.total_commands, 1);
- assert_eq!(stats.unique_commands, 1);
- }
-
- #[test]
- fn interesting_commands() {
- let settings = Settings::new().unwrap();
-
- assert_eq!(interesting_command(&settings, "cargo"), "cargo");
- assert_eq!(
- interesting_command(&settings, "cargo build foo bar"),
- "cargo build"
- );
- assert_eq!(
- interesting_command(&settings, "sudo cargo build foo bar"),
- "cargo build"
- );
- assert_eq!(interesting_command(&settings, "sudo"), "sudo");
- }
-
- // Test with spaces in the common_prefix
- #[test]
- fn interesting_commands_spaces() {
- let mut settings = Settings::new().unwrap();
- settings.stats.common_prefix.push("sudo test".to_string());
-
- assert_eq!(interesting_command(&settings, "sudo test"), "sudo test");
- assert_eq!(interesting_command(&settings, "sudo test "), "sudo test");
- assert_eq!(interesting_command(&settings, "sudo test foo bar"), "foo");
- assert_eq!(
- interesting_command(&settings, "sudo test foo bar"),
- "foo"
- );
-
- // Works with a common_subcommand as well
- assert_eq!(
- interesting_command(&settings, "sudo test cargo build foo bar"),
- "cargo build"
- );
-
- // We still match on just the sudo prefix
- assert_eq!(interesting_command(&settings, "sudo"), "sudo");
- assert_eq!(interesting_command(&settings, "sudo foo"), "foo");
- }
-
- // Test with spaces in the common_subcommand
- #[test]
- fn interesting_commands_spaces_subcommand() {
- let mut settings = Settings::new().unwrap();
- settings
- .stats
- .common_subcommands
- .push("cargo build".to_string());
-
- assert_eq!(interesting_command(&settings, "cargo build"), "cargo build");
- assert_eq!(
- interesting_command(&settings, "cargo build "),
- "cargo build"
- );
- assert_eq!(
- interesting_command(&settings, "cargo build foo bar"),
- "cargo build foo"
- );
-
- // Works with a common_prefix as well
- assert_eq!(
- interesting_command(&settings, "sudo cargo build foo bar"),
- "cargo build foo"
- );
-
- // We still match on just cargo as a subcommand
- assert_eq!(interesting_command(&settings, "cargo"), "cargo");
- assert_eq!(interesting_command(&settings, "cargo foo"), "cargo foo");
- }
-
- // Test with spaces in the common_prefix and common_subcommand
- #[test]
- fn interesting_commands_spaces_both() {
- let mut settings = Settings::new().unwrap();
- settings.stats.common_prefix.push("sudo test".to_string());
- settings
- .stats
- .common_subcommands
- .push("cargo build".to_string());
-
- assert_eq!(
- interesting_command(&settings, "sudo test cargo build"),
- "cargo build"
- );
- assert_eq!(
- interesting_command(&settings, "sudo test cargo build"),
- "cargo build"
- );
- assert_eq!(
- interesting_command(&settings, "sudo test cargo build "),
- "cargo build"
- );
- assert_eq!(
- interesting_command(&settings, "sudo test cargo build foo bar"),
- "cargo build foo"
- );
- }
-
- #[test]
- fn split_simple() {
- assert_eq!(split_at_pipe("fd | rg"), ["fd ", " rg"]);
- }
-
- #[test]
- fn split_multi() {
- assert_eq!(
- split_at_pipe("kubectl | jq | rg"),
- ["kubectl ", " jq ", " rg"]
- );
- }
-
- #[test]
- fn split_simple_quoted() {
- assert_eq!(
- split_at_pipe("foo | bar 'baz {} | quux' | xyzzy"),
- ["foo ", " bar 'baz {} | quux' ", " xyzzy"]
- );
- }
-
- #[test]
- fn split_multi_quoted() {
- assert_eq!(
- split_at_pipe("foo | bar 'baz \"{}\" | quux' | xyzzy"),
- ["foo ", " bar 'baz \"{}\" | quux' ", " xyzzy"]
- );
- }
-
- #[test]
- fn escaped_pipes() {
- assert_eq!(
- split_at_pipe("foo | bar baz \\| quux"),
- ["foo ", " bar baz \\| quux"]
- );
- }
-
- #[test]
- fn emoji() {
- assert_eq!(
- split_at_pipe("git commit -m \"🚀\""),
- ["git commit -m \"🚀\""]
- );
- }
-
- #[test]
- fn starts_with_pipe() {
- assert_eq!(
- split_at_pipe("| sed 's/[0-9a-f]//g'"),
- ["", " sed 's/[0-9a-f]//g'"]
- );
- }
-
- #[test]
- fn starts_with_spaces_and_pipe() {
- assert_eq!(
- split_at_pipe(" | sed 's/[0-9a-f]//g'"),
- [" ", " sed 's/[0-9a-f]//g'"]
- );
- }
-
- #[test]
- fn strip_leading_env_vars_simple() {
- assert_eq!(
- strip_leading_env_vars("FOO=bar BAZ=quux echo foo"),
- "echo foo"
- );
- }
-
- #[test]
- fn strip_leading_env_vars_quoted_single() {
- assert_eq!(strip_leading_env_vars("FOO='BAR=baz' echo foo"), "echo foo");
- }
-
- #[test]
- fn strip_leading_env_vars_quoted_double() {
- assert_eq!(
- strip_leading_env_vars("FOO=\"BAR=baz\" echo foo"),
- "echo foo"
- );
- }
-
- #[test]
- fn strip_leading_env_vars_quoted_single_and_double() {
- assert_eq!(
- strip_leading_env_vars("FOO='BAR=\"baz\"' echo foo \"BAR=quux\""),
- "echo foo \"BAR=quux\""
- );
- }
-
- #[test]
- fn strip_leading_env_vars_emojis() {
- assert_eq!(
- strip_leading_env_vars("FOO='BAR=🚀' echo foo \"BAR=quux\" foo"),
- "echo foo \"BAR=quux\" foo"
- );
- }
-
- #[test]
- fn strip_leading_env_vars_name_same_as_command() {
- assert_eq!(strip_leading_env_vars("FOO='bar' bar baz"), "bar baz");
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/capture.rs b/crates/turtle/src/atuin_pty_proxy/capture.rs
deleted file mode 100644
index 55bc1788..00000000
--- a/crates/turtle/src/atuin_pty_proxy/capture.rs
+++ /dev/null
@@ -1,470 +0,0 @@
-use std::sync::Arc;
-use std::sync::atomic::{AtomicU16, Ordering};
-
-use crate::atuin_pty_proxy::osc133::{Event, Params, Parser, Zone};
-
-const HISTORY_ID_PARAM: &str = "history_id";
-const SESSION_ID_PARAM: &str = "session_id";
-const MAX_OUTPUT_CAPTURE_BYTES: usize = 1024 * 1024;
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct CommandCapture {
- pub(crate) prompt: String,
- pub(crate) command: String,
- pub(crate) output: String,
- pub(crate) exit_code: Option<i32>,
- pub(crate) history_id: Option<String>,
- pub(crate) session_id: Option<String>,
- pub(crate) output_truncated: bool,
- pub(crate) output_observed_bytes: u64,
-}
-
-pub(crate) type CommandCaptureSink = Box<dyn Fn(CommandCapture) + Send + 'static>;
-
-#[derive(Default)]
-struct CaptureBuffers {
- prompt: Vec<u8>,
- command: Vec<u8>,
- output: Vec<u8>,
- output_observed_bytes: u64,
- output_truncated: bool,
- exit_code: Option<i32>,
- history_id: Option<String>,
- session_id: Option<String>,
-}
-
-pub(crate) struct CommandCaptureTracker {
- parser: Parser,
- zone: Zone,
- buffers: CaptureBuffers,
- cols: Arc<AtomicU16>,
-}
-
-impl CommandCaptureTracker {
- pub(crate) fn new(cols: Arc<AtomicU16>) -> Self {
- Self {
- parser: Parser::new(),
- zone: Zone::Unknown,
- buffers: CaptureBuffers::default(),
- cols,
- }
- }
-
- pub(crate) fn push(&mut self, data: &[u8], mut on_capture: impl FnMut(CommandCapture)) {
- let mut events = Vec::new();
- self.parser
- .push_located(data, |located| events.push(located));
-
- let mut start = 0;
- for located in events {
- let marker_start = located.start_offset.min(data.len()).max(start);
- let offset = located.offset.min(data.len());
- self.append(&data[start..marker_start]);
- self.handle_event(located.event, &located.params, &mut on_capture);
- self.zone = located.zone;
- start = offset;
- }
-
- let append_end = self
- .parser
- .incomplete_osc_sequence_start()
- .map_or(data.len(), |sequence_start| {
- sequence_start.min(data.len()).max(start)
- });
- if start < append_end {
- self.append(&data[start..append_end]);
- }
- }
-
- fn append(&mut self, data: &[u8]) {
- match self.zone {
- Zone::Prompt => self.buffers.prompt.extend_from_slice(data),
- Zone::Input => self.buffers.command.extend_from_slice(data),
- Zone::Output => self.append_output(data),
- Zone::Unknown => {}
- }
- }
-
- fn append_output(&mut self, data: &[u8]) {
- self.buffers.output_observed_bytes = self
- .buffers
- .output_observed_bytes
- .saturating_add(data.len() as u64);
-
- if self.buffers.output_truncated {
- return;
- }
-
- let remaining = MAX_OUTPUT_CAPTURE_BYTES.saturating_sub(self.buffers.output.len());
- let retained = data.len().min(remaining);
- self.buffers.output_truncated = retained < data.len();
-
- if retained > 0 {
- self.buffers.output.extend_from_slice(&data[..retained]);
- }
- }
-
- fn handle_event(
- &mut self,
- event: Event,
- params: &Params,
- on_capture: &mut impl FnMut(CommandCapture),
- ) {
- match event {
- Event::PromptStart => {
- if self.zone != Zone::Prompt {
- self.buffers = CaptureBuffers::default();
- }
- }
- Event::CommandStart | Event::CommandExecuted => {}
- Event::CommandFinished { exit_code } => {
- let Some(history_id) = params.get(HISTORY_ID_PARAM).map(str::to_owned) else {
- return;
- };
-
- if exit_code.is_some() || self.buffers.exit_code.is_none() {
- self.buffers.exit_code = exit_code;
- }
- self.buffers.history_id = Some(history_id);
- self.buffers.session_id = params.get(SESSION_ID_PARAM).map(str::to_owned);
-
- if let Some(capture) = self.finish_capture() {
- on_capture(capture);
- }
- }
- }
- }
-
- fn finish_capture(&mut self) -> Option<CommandCapture> {
- let buffers = std::mem::take(&mut self.buffers);
- let cols = self.cols.load(Ordering::Relaxed).max(1);
- let prompt = render_plain_text(&buffers.prompt, cols);
- let command = render_plain_text(&buffers.command, cols)
- .trim_matches(|c| c == '\r' || c == '\n')
- .to_string();
- let output = render_plain_text(&buffers.output, cols);
- let output_truncated = buffers.output_truncated;
- let output_observed_bytes = buffers.output_observed_bytes;
- let exit_code = buffers.exit_code;
- let history_id = buffers.history_id;
- let session_id = buffers.session_id;
-
- if command.is_empty() && output.is_empty() {
- return None;
- }
-
- Some(CommandCapture {
- prompt,
- command,
- output,
- exit_code,
- history_id,
- session_id,
- output_truncated,
- output_observed_bytes,
- })
- }
-}
-
-const CLEAN_TEXT_MAX_ROWS: usize = 10_000;
-
-fn render_plain_text(bytes: &[u8], cols: u16) -> String {
- if bytes.is_empty() {
- return String::new();
- }
-
- let cols = cols.max(1);
- let mut parser = vt100::Parser::new(estimated_rows(bytes, cols), cols, 0);
- parser.process(bytes);
- normalize_screen_contents(&parser.screen().contents())
-}
-
-fn normalize_screen_contents(contents: &str) -> String {
- let mut lines = contents.lines().map(str::trim_end).collect::<Vec<_>>();
- while lines.last().is_some_and(|line| line.is_empty()) {
- lines.pop();
- }
- lines.join("\n")
-}
-
-#[expect(clippy::naive_bytecount, reason = "This is just an estimation")]
-fn estimated_rows(bytes: &[u8], cols: u16) -> u16 {
- let newline_rows = bytes.iter().filter(|byte| **byte == b'\n').count() + 1;
- let wrapped_rows = bytes.len() / cols as usize;
- newline_rows
- .saturating_add(wrapped_rows)
- .saturating_add(1)
- .clamp(1, CLEAN_TEXT_MAX_ROWS) as u16
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_pty_proxy::capture::render_plain_text;
-
- use super::{Arc, AtomicU16, CommandCapture, CommandCaptureTracker, MAX_OUTPUT_CAPTURE_BYTES};
-
- fn tracker(cols: u16) -> CommandCaptureTracker {
- CommandCaptureTracker::new(Arc::new(AtomicU16::new(cols)))
- }
-
- fn assert_no_terminal_controls(text: &str) {
- assert!(
- !text
- .chars()
- .any(|ch| ch.is_control() && ch != '\n' && ch != '\t'),
- "text still contains terminal controls: {text:?}"
- );
- }
-
- #[test]
- fn command_text_collapses_terminal_echo_edits() {
- assert_eq!(render_plain_text(b"e\x08echo hi", 80), "echo hi");
- assert_eq!(
- render_plain_text(
- b"e\x08echo\x08 \x08\x08 \x08\x08\x08e \x08\x08 \x08e\x08echo hi",
- 80
- ),
- "echo hi"
- );
- assert_eq!(render_plain_text(b"echo hi", 80), "echo hi");
- }
-
- #[test]
- fn text_cleaning_strips_ansi_and_terminal_controls() {
- let text = render_plain_text(
- b"\x1b[32mhi\x1b[0m\r\n% \r \r",
- 80,
- );
-
- assert_eq!(text, "hi");
- assert_no_terminal_controls(&text);
- }
-
- #[test]
- fn text_cleaning_preserves_valid_utf8_after_backspace() {
- let text = render_plain_text("🦀x\x08 \x08 crab".as_bytes(), 80);
-
- assert_eq!(text, "🦀 crab");
- assert_no_terminal_controls(&text);
- }
-
- #[test]
- fn command_text_replays_backspaces() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- let input =
- b"\x1b]133;A\x07$ \x1b]133;B\x07e\x08echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ";
- tracker.push(input, |capture| captures.push(capture));
-
- assert_eq!(captures.len(), 1);
- assert_eq!(captures[0].command, "echo hi");
- assert_eq!(captures[0].output, "hi");
- assert_no_terminal_controls(&captures[0].command);
- assert_no_terminal_controls(&captures[0].output);
- }
-
- #[test]
- fn captures_complete_command() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;A\x07$ \x1b]133;B\x07echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ",
- |capture| captures.push(capture),
- );
-
- assert_eq!(
- captures,
- vec![CommandCapture {
- prompt: "$".to_string(),
- command: "echo hi".to_string(),
- output: "hi".to_string(),
- exit_code: Some(0),
- history_id: Some("hist".to_string()),
- session_id: Some("sess".to_string()),
- output_truncated: false,
- output_observed_bytes: 4,
- }]
- );
- }
-
- #[test]
- fn strips_ansi_and_split_markers() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(b"\x1b]133;A\x07\x1b[32m%\x1b[0m ", |_| {});
- tracker.push(b"\x1b]133;B\x07ls\x1b]133;C", |_| {});
- tracker.push(
- b"\x07\x1b[31mfile\x1b[0m\r\n\x1b]133;D;1;history_id=hist;session_id=sess\x07\x1b]133;A\x07% ",
- |capture| {
- captures.push(capture);
- },
- );
-
- assert_eq!(
- captures,
- vec![CommandCapture {
- prompt: "%".to_string(),
- command: "ls".to_string(),
- output: "file".to_string(),
- exit_code: Some(1),
- history_id: Some("hist".to_string()),
- session_id: Some("sess".to_string()),
- output_truncated: false,
- output_observed_bytes: 15,
- }]
- );
- }
-
- #[test]
- fn duplicate_prompt_start_does_not_reset_prompt_capture() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;A\x07$ \x1b]133;A\x07continued \x1b]133;B\x07echo hi\r\n\x1b]133;C\x07hi\r\n\x1b]133;D;0;history_id=hist;session_id=sess\x07\x1b]133;A\x07$ ",
- |capture| captures.push(capture),
- );
-
- assert_eq!(captures.len(), 1);
- assert_eq!(captures[0].prompt, "$ continued");
- assert_eq!(captures[0].command, "echo hi");
- assert_eq!(captures[0].output, "hi");
- }
-
- #[test]
- fn bare_finish_without_metadata_is_ignored() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(b"\x1b]133;C\x07line one\r\n\x1b]133;D;0\x07", |capture| {
- captures.push(capture);
- });
-
- tracker.push(b"\x1b]133;A\x07$ ", |capture| captures.push(capture));
-
- assert!(captures.is_empty());
- }
-
- #[test]
- fn bare_finish_before_metadata_in_same_push_ignored() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;C\x07line one\r\n\x1b]133;D;1\x07\x1b]133;D;0;history_id=018f;session_id=abcd\x07",
- |capture| captures.push(capture),
- );
-
- assert_eq!(captures.len(), 1);
- assert_eq!(captures[0].output, "line one");
- assert_eq!(captures[0].exit_code, Some(0));
- assert_eq!(captures[0].history_id.as_deref(), Some("018f"));
- assert_eq!(captures[0].session_id.as_deref(), Some("abcd"));
- }
-
- #[test]
- fn metadata_arriving_after_bare_finish_across_pushes() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(b"\x1b]133;C\x07line one\r\n\x1b]133;D;0\x07", |capture| {
- captures.push(capture);
- });
- tracker.push(b"\x1b]133;D;0;history_id=018f", |capture| {
- captures.push(capture)
- });
-
- assert!(captures.is_empty());
-
- tracker.push(b";session_id=abcd\x07", |capture| captures.push(capture));
-
- assert_eq!(captures.len(), 1);
- assert_eq!(captures[0].output, "line one");
- assert_eq!(captures[0].exit_code, Some(0));
- assert_eq!(captures[0].history_id.as_deref(), Some("018f"));
- assert_eq!(captures[0].session_id.as_deref(), Some("abcd"));
- }
-
- #[test]
- fn split_finish_marker_is_not_counted_as_output() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;C\x07line one\r\n\x1b]133;D;0;history_id=018f",
- |capture| {
- captures.push(capture);
- },
- );
- assert!(captures.is_empty());
-
- tracker.push(b";session_id=abcd\x07", |capture| captures.push(capture));
-
- assert_eq!(captures.len(), 1);
- assert_eq!(captures[0].output, "line one");
- assert_eq!(captures[0].output_observed_bytes, 10);
- }
-
- #[test]
- fn captures_output_with_history_metadata_from_d_marker() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;C\x07line one\r\n\x1b]133;D;0;history_id=018f;session_id=abcd\x07",
- |capture| captures.push(capture),
- );
-
- assert_eq!(
- captures,
- vec![CommandCapture {
- prompt: String::new(),
- command: String::new(),
- output: "line one".to_string(),
- exit_code: Some(0),
- history_id: Some("018f".to_string()),
- session_id: Some("abcd".to_string()),
- output_truncated: false,
- output_observed_bytes: 10,
- }]
- );
- }
-
- #[test]
- fn output_capture_is_capped_and_reports_observed_bytes() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
- let mut input = b"\x1b]133;C\x07".to_vec();
- input.extend(std::iter::repeat_n(b'x', MAX_OUTPUT_CAPTURE_BYTES + 10));
- input.extend_from_slice(b"\x1b]133;D;0;history_id=big;session_id=session-1\x07");
-
- tracker.push(&input, |capture| captures.push(capture));
-
- assert_eq!(captures.len(), 1);
- assert!(captures[0].output_truncated);
- assert_eq!(
- captures[0].output_observed_bytes,
- (MAX_OUTPUT_CAPTURE_BYTES + 10) as u64
- );
- }
-
- #[test]
- fn resets_buffers_between_c_d_only_captures() {
- let mut tracker = tracker(80);
- let mut captures = Vec::new();
-
- tracker.push(
- b"\x1b]133;C\x07first\r\n\x1b]133;D;0;history_id=one\x07\x1b]133;C\x07second\r\n\x1b]133;D;1;history_id=two\x07",
- |capture| captures.push(capture),
- );
-
- assert_eq!(captures.len(), 2);
- assert_eq!(captures[0].output, "first");
- assert_eq!(captures[0].history_id.as_deref(), Some("one"));
- assert_eq!(captures[1].output, "second");
- assert_eq!(captures[1].history_id.as_deref(), Some("two"));
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/debug.rs b/crates/turtle/src/atuin_pty_proxy/debug.rs
deleted file mode 100644
index c2a1691c..00000000
--- a/crates/turtle/src/atuin_pty_proxy/debug.rs
+++ /dev/null
@@ -1,53 +0,0 @@
-use crate::atuin_pty_proxy::osc133::{Event, Parser};
-
-pub(crate) const RESET: &[u8] = b"\x1b[0m";
-
-pub(crate) struct Osc133DebugHighlighter {
- parser: Parser,
-}
-
-impl Osc133DebugHighlighter {
- pub(crate) fn new() -> Self {
- Self {
- parser: Parser::new(),
- }
- }
-
- pub(crate) fn render(&mut self, data: &[u8]) -> Vec<u8> {
- let mut events = Vec::new();
- self.parser
- .push_located(data, |located| events.push(located));
-
- if events.is_empty() {
- return data.to_vec();
- }
-
- let mut rendered = Vec::with_capacity(data.len() + (events.len() * 64));
- let mut start = 0;
-
- for located in events {
- let offset = located.offset.min(data.len());
- if offset > start {
- rendered.extend_from_slice(&data[start..offset]);
- }
-
- rendered.extend_from_slice(event_label(located.event));
- rendered.extend_from_slice(RESET);
- start = offset;
- }
-
- rendered.extend_from_slice(&data[start..]);
- rendered
- }
-}
-
-fn event_label(event: Event) -> &'static [u8] {
- match event {
- Event::PromptStart => b"\x1b[1;37;45m[OSC133:A prompt]\x1b[0m",
- Event::CommandStart => b"\x1b[1;30;43m[OSC133:B input]\x1b[0m",
- Event::CommandExecuted => b"\x1b[1;30;46m[OSC133:C output]\x1b[0m",
- Event::CommandFinished { exit_code: Some(0) } => b"\x1b[1;37;42m[OSC133:D exit=0]\x1b[0m",
- Event::CommandFinished { exit_code: Some(_) } => b"\x1b[1;37;41m[OSC133:D exit!=0]\x1b[0m",
- Event::CommandFinished { exit_code: None } => b"\x1b[1;37;44m[OSC133:D exit=?]\x1b[0m",
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/mod.rs b/crates/turtle/src/atuin_pty_proxy/mod.rs
deleted file mode 100644
index e1d01c83..00000000
--- a/crates/turtle/src/atuin_pty_proxy/mod.rs
+++ /dev/null
@@ -1,17 +0,0 @@
-#[cfg(unix)]
-mod capture;
-#[cfg(unix)]
-mod debug;
-#[cfg(unix)]
-mod osc133;
-#[cfg(unix)]
-mod pty_proxy;
-#[cfg(unix)]
-mod runtime;
-#[cfg(unix)]
-mod screen;
-
-#[cfg(unix)]
-pub(crate) use capture::{CommandCapture, CommandCaptureSink};
-#[cfg(unix)]
-pub(crate) use pty_proxy::PtyProxy;
diff --git a/crates/turtle/src/atuin_pty_proxy/osc133.rs b/crates/turtle/src/atuin_pty_proxy/osc133.rs
deleted file mode 100644
index f2a796f1..00000000
--- a/crates/turtle/src/atuin_pty_proxy/osc133.rs
+++ /dev/null
@@ -1,899 +0,0 @@
-//! Streaming parser for OSC 133 ([`FinalTerm`] semantic prompt) escape sequences.
-//!
-//! OSC 133 marks four regions of a shell interaction:
-//!
-//! | Marker | Meaning |
-//! |--------|--------------------------------------|
-//! | A | Prompt is about to be printed |
-//! | B | Prompt ended — command input begins |
-//! | C | Command submitted — output begins |
-//! | D[;n] | Command finished with exit code *n* |
-//!
-//! The wire format is `ESC ] 133 ; <cmd> [; <params>] ST` where ST is BEL
-//! (0x07), ESC \ (0x1B 0x5C), or C1 ST (0x9C).
-//!
-//! # Design goals
-//!
-//! * **Transparent** — the parser observes the byte stream without modifying it;
-//! the caller remains responsible for forwarding bytes to their destination.
-//! * **Bounded** — OSC parameter buffering is capped so malformed output cannot
-//! grow memory without limit.
-//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available
-//! and returns immediately.
-//! * **Extensible** — marker parameters are preserved so Atuin-specific metadata
-//! can ride alongside standard OSC 133 markers.
-
-/// Events emitted when an OSC 133 marker is detected.
-#[derive(Debug, Clone, PartialEq, Eq, Copy)]
-pub(crate) enum Event {
- /// `ESC ] 133 ; A ST` — the shell is about to display its prompt.
- PromptStart,
- /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command.
- CommandStart,
- /// `ESC ] 133 ; C ST` — the command has been submitted for execution.
- CommandExecuted,
- /// `ESC ] 133 ; D [; <exit_code>] ST` — command output is complete.
- CommandFinished {
- /// The exit code reported after the `;`, if present and valid.
- exit_code: Option<i32>,
- },
-}
-
-/// Parameters attached to an OSC 133 marker.
-#[derive(Debug, Default, Clone, PartialEq, Eq)]
-pub(crate) struct Params {
- items: Vec<Param>,
-}
-
-impl Params {
- /// Iterate over all marker parameters in order.
- #[cfg(test)]
- #[inline]
- pub(crate) fn iter(&self) -> impl Iterator<Item = &Param> {
- self.items.iter()
- }
-
- /// Return the value for the first `key=value` parameter with this key.
- #[inline]
- pub(crate) fn get(&self, key: &str) -> Option<&str> {
- self.items.iter().find_map(|item| match item {
- Param::KeyValue {
- key: item_key,
- value,
- } if item_key == key => Some(value.as_str()),
- Param::Value(_) | Param::KeyValue { .. } => None,
- })
- }
-}
-
-/// A single OSC 133 marker parameter.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum Param {
- /// A positional parameter without an equals sign.
- Value(String),
- /// A `key=value` parameter.
- KeyValue { key: String, value: String },
-}
-
-/// An OSC 133 event with its position in the most recent input chunk.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) struct LocatedEvent {
- /// The OSC 133 event that was parsed.
- pub(crate) event: Event,
- /// Offset where this marker starts in the current chunk.
- ///
- /// If a marker started in an earlier [`Parser::push_located`] call, this is
- /// `0` in the chunk that completed the marker.
- pub(crate) start_offset: usize,
- /// Offset immediately after this marker's terminator in the current chunk.
- ///
- /// If a marker spans multiple [`Parser::push_located`] calls, this is still
- /// the offset in the chunk that completed the marker.
- pub(crate) offset: usize,
- /// The semantic zone after applying this event.
- pub(crate) zone: Zone,
- /// Metadata parameters attached to this marker.
- pub(crate) params: Params,
-}
-
-/// The current semantic zone as determined by the most recent OSC 133 marker.
-#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum Zone {
- /// No marker seen yet, or after a `D` marker (between commands).
- #[default]
- Unknown,
- /// Between `A` and `B` — the shell is rendering its prompt.
- Prompt,
- /// Between `B` and `C` — the user is editing a command line.
- Input,
- /// Between `C` and `D` — command output is being produced.
- Output,
-}
-
-// ---------------------------------------------------------------------------
-// Internal constants
-// ---------------------------------------------------------------------------
-
-const ESC: u8 = 0x1B;
-const BEL: u8 = 0x07;
-const C1_ST: u8 = 0x9C;
-const BACKSLASH: u8 = b'\\';
-const RIGHT_BRACKET: u8 = b']';
-
-/// Maximum bytes we'll buffer for the OSC parameter string. This is large enough
-/// for Atuin metadata such as history/session IDs while still bounding malformed
-/// OSC sequences.
-const PARAM_BUF_CAP: usize = 512;
-
-// ---------------------------------------------------------------------------
-// State machine
-// ---------------------------------------------------------------------------
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum State {
- /// Normal pass-through.
- Ground,
- /// Saw ESC (0x1B).
- Esc,
- /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes.
- OscParam,
- /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \`
- /// (string terminator) or something else.
- OscEsc,
-}
-
-/// A streaming, zero-allocation parser for OSC 133 escape sequences.
-///
-/// Feed arbitrary byte slices into [`Parser::push`]. The parser detects
-/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback
-/// without modifying the data. It can sit transparently between a PTY reader
-/// and stdout.
-pub(crate) struct Parser {
- state: State,
- zone: Zone,
- sequence_start: Option<usize>,
- param_buf: [u8; PARAM_BUF_CAP],
- param_len: usize,
-}
-
-impl Default for Parser {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl Parser {
- /// Create a new parser in the initial (ground / unknown-zone) state.
- #[inline]
- pub(crate) fn new() -> Self {
- Self {
- state: State::Ground,
- zone: Zone::Unknown,
- sequence_start: None,
- param_buf: [0u8; PARAM_BUF_CAP],
- param_len: 0,
- }
- }
-
- /// The current semantic zone based on markers seen so far.
- #[inline]
- #[expect(dead_code)]
- pub(crate) fn zone(&self) -> Zone {
- self.zone
- }
-
- /// Start offset of an incomplete OSC sequence in the most recent chunk.
- #[inline]
- pub(crate) fn incomplete_osc_sequence_start(&self) -> Option<usize> {
- matches!(self.state, State::OscParam | State::OscEsc)
- .then(|| self.sequence_start.unwrap_or(0))
- }
-
- /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker
- /// found.
- ///
- /// All bytes in `data` should still be forwarded to the terminal by the
- /// caller — this method only *observes* the stream.
- #[cfg(test)]
- #[inline]
- pub(crate) fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) {
- self.push_located(data, |located| on_event(located.event));
- }
-
- /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker
- /// found with its byte offset in this chunk.
- ///
- /// The offset points to the first byte after the marker terminator, making
- /// it suitable for callers that need to split the original chunk at marker
- /// boundaries.
- #[inline]
- pub(crate) fn push_located(&mut self, data: &[u8], mut on_event: impl FnMut(LocatedEvent)) {
- self.sequence_start = (self.state != State::Ground).then_some(0);
-
- for (offset, &byte) in data.iter().enumerate() {
- match self.state {
- State::Ground => {
- if byte == ESC {
- self.state = State::Esc;
- self.sequence_start = Some(offset);
- }
- }
- State::Esc => {
- if byte == RIGHT_BRACKET {
- self.state = State::OscParam;
- self.param_len = 0;
- } else {
- self.state = State::Ground;
- self.sequence_start = None;
- }
- }
- State::OscParam => {
- if byte == BEL || byte == C1_ST {
- self.dispatch(offset + 1, &mut on_event);
- self.state = State::Ground;
- self.sequence_start = None;
- } else if byte == ESC {
- self.state = State::OscEsc;
- } else if self.param_len < PARAM_BUF_CAP {
- self.param_buf[self.param_len] = byte;
- self.param_len += 1;
- }
- // If param_len == PARAM_BUF_CAP we silently stop
- // accumulating — dispatch will ignore non-133 sequences.
- }
- State::OscEsc => {
- if byte == BACKSLASH {
- self.dispatch(offset + 1, &mut on_event);
- }
- // Whether we got a valid ST or not, return to ground.
- // (A new ESC ] would restart accumulation via the Ground
- // -> Esc -> OscParam path on the *next* byte.)
- self.state = State::Ground;
- self.sequence_start = None;
- }
- }
- }
- }
-
- /// Inspect the accumulated parameter buffer. If it holds an OSC 133
- /// payload, emit the corresponding [`Event`] and update the zone.
- #[inline]
- fn dispatch(&mut self, offset: usize, on_event: &mut impl FnMut(LocatedEvent)) {
- let payload = &self.param_buf[..self.param_len];
-
- if payload.len() < 5 || &payload[..4] != b"133;" {
- return;
- }
-
- if payload.len() > 5 && payload[5] != b';' {
- return;
- }
-
- let metadata = payload.get(6..).unwrap_or_default();
- let cmd = payload[4];
- let (event, params) = match cmd {
- b'A' => {
- self.zone = Zone::Prompt;
- (Event::PromptStart, parse_params(metadata))
- }
- b'B' => {
- self.zone = Zone::Input;
- (Event::CommandStart, parse_params(metadata))
- }
- b'C' => {
- self.zone = Zone::Output;
- (Event::CommandExecuted, parse_params(metadata))
- }
- b'D' => {
- let (exit_code, params) = parse_command_finished_params(metadata);
- self.zone = Zone::Unknown;
- (Event::CommandFinished { exit_code }, params)
- }
- _ => return,
- };
-
- on_event(LocatedEvent {
- event,
- start_offset: self.sequence_start.unwrap_or(0),
- offset,
- zone: self.zone,
- params,
- });
- }
-}
-
-fn parse_command_finished_params(metadata: &[u8]) -> (Option<i32>, Params) {
- if metadata.is_empty() {
- return (None, Params::default());
- }
-
- let Some(separator) = metadata.iter().position(|byte| *byte == b';') else {
- return parse_exit_code(metadata).map_or_else(
- || (None, parse_params(metadata)),
- |exit_code| (Some(exit_code), Params::default()),
- );
- };
-
- let (first, rest) = metadata.split_at(separator);
- let rest = &rest[1..];
-
- parse_exit_code(first).map_or_else(
- || (None, parse_params(metadata)),
- |exit_code| (Some(exit_code), parse_params(rest)),
- )
-}
-
-fn parse_exit_code(code: &[u8]) -> Option<i32> {
- if code.is_empty() {
- return None;
- }
-
- std::str::from_utf8(code)
- .ok()
- .and_then(|code| code.parse::<i32>().ok())
-}
-
-fn parse_params(metadata: &[u8]) -> Params {
- let items = metadata
- .split(|byte| *byte == b';')
- .filter(|part| !part.is_empty())
- .map(parse_param)
- .collect();
-
- Params { items }
-}
-
-fn parse_param(param: &[u8]) -> Param {
- let param = String::from_utf8_lossy(param);
-
- if let Some((key, value)) = param.split_once('=') {
- return Param::KeyValue {
- key: key.to_string(),
- value: value.to_string(),
- };
- }
-
- Param::Value(param.into_owned())
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{BEL, Event, LocatedEvent, Param, Params, Parser, Zone};
-
- /// Collect all events from a single `push` call.
- fn parse_events(data: &[u8]) -> Vec<Event> {
- let mut parser = Parser::new();
- let mut events = Vec::new();
- parser.push(data, |e| events.push(e));
- events
- }
-
- // -- Basic event detection ------------------------------------------------
-
- #[test]
- fn detect_prompt_start_bel() {
- let data = b"\x1b]133;A\x07";
- assert_eq!(parse_events(data), vec![Event::PromptStart]);
- }
-
- #[test]
- fn detect_prompt_start_st() {
- let data = b"\x1b]133;A\x1b\\";
- assert_eq!(parse_events(data), vec![Event::PromptStart]);
- }
-
- #[test]
- fn detect_command_start_bel() {
- let data = b"\x1b]133;B\x07";
- assert_eq!(parse_events(data), vec![Event::CommandStart]);
- }
-
- #[test]
- fn detect_command_start_st() {
- let data = b"\x1b]133;B\x1b\\";
- assert_eq!(parse_events(data), vec![Event::CommandStart]);
- }
-
- #[test]
- fn detect_command_executed_bel() {
- let data = b"\x1b]133;C\x07";
- assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
- }
-
- #[test]
- fn detect_command_executed_st() {
- let data = b"\x1b]133;C\x1b\\";
- assert_eq!(parse_events(data), vec![Event::CommandExecuted]);
- }
-
- #[test]
- fn detect_command_finished_no_exit_code() {
- let data = b"\x1b]133;D\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished { exit_code: None }]
- );
- }
-
- #[test]
- fn detect_command_finished_exit_zero() {
- let data = b"\x1b]133;D;0\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished { exit_code: Some(0) }]
- );
- }
-
- #[test]
- fn detect_command_finished_exit_nonzero() {
- let data = b"\x1b]133;D;127\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished {
- exit_code: Some(127)
- }]
- );
- }
-
- #[test]
- fn detect_command_finished_negative_exit_code() {
- let data = b"\x1b]133;D;-1\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished {
- exit_code: Some(-1)
- }]
- );
- }
-
- #[test]
- fn detect_command_finished_exit_code_st() {
- let data = b"\x1b]133;D;42\x1b\\";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished {
- exit_code: Some(42)
- }]
- );
- }
-
- #[test]
- fn invalid_exit_code_yields_none() {
- let data = b"\x1b]133;D;abc\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished { exit_code: None }]
- );
- }
-
- // -- Zone tracking --------------------------------------------------------
-
- #[test]
- fn zone_starts_unknown() {
- let parser = Parser::new();
- assert_eq!(parser.zone(), Zone::Unknown);
- }
-
- #[test]
- fn full_zone_cycle() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b]133;A\x07", |e| events.push(e));
- assert_eq!(parser.zone(), Zone::Prompt);
-
- parser.push(b"\x1b]133;B\x07", |e| events.push(e));
- assert_eq!(parser.zone(), Zone::Input);
-
- parser.push(b"\x1b]133;C\x07", |e| events.push(e));
- assert_eq!(parser.zone(), Zone::Output);
-
- parser.push(b"\x1b]133;D;0\x07", |e| events.push(e));
- assert_eq!(parser.zone(), Zone::Unknown);
-
- assert_eq!(
- events,
- vec![
- Event::PromptStart,
- Event::CommandStart,
- Event::CommandExecuted,
- Event::CommandFinished { exit_code: Some(0) },
- ]
- );
- }
-
- // -- Multiple events in one push ------------------------------------------
-
- #[test]
- fn multiple_events_single_push() {
- let data = b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file.txt\n\x1b]133;D;0\x07";
- let events = parse_events(data);
- assert_eq!(
- events,
- vec![
- Event::PromptStart,
- Event::CommandStart,
- Event::CommandExecuted,
- Event::CommandFinished { exit_code: Some(0) },
- ]
- );
- }
-
- // -- Split across push boundaries -----------------------------------------
-
- #[test]
- fn split_esc_and_bracket() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b", |e| events.push(e));
- assert!(events.is_empty());
-
- parser.push(b"]133;A\x07", |e| events.push(e));
- assert_eq!(events, vec![Event::PromptStart]);
- }
-
- #[test]
- fn split_mid_param() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b]13", |e| events.push(e));
- assert!(events.is_empty());
-
- parser.push(b"3;D;42\x07", |e| events.push(e));
- assert_eq!(
- events,
- vec![Event::CommandFinished {
- exit_code: Some(42)
- }]
- );
- }
-
- #[test]
- fn split_before_terminator() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b]133;B", |e| events.push(e));
- assert!(events.is_empty());
-
- parser.push(b"\x07", |e| events.push(e));
- assert_eq!(events, vec![Event::CommandStart]);
- }
-
- #[test]
- fn split_esc_backslash_terminator() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b]133;C\x1b", |e| events.push(e));
- assert!(events.is_empty());
-
- parser.push(b"\\", |e| events.push(e));
- assert_eq!(events, vec![Event::CommandExecuted]);
- }
-
- // -- Interleaved normal text ----------------------------------------------
-
- #[test]
- fn normal_text_before_and_after() {
- let data = b"hello world\x1b]133;A\x07prompt text\x1b]133;B\x07command";
- let events = parse_events(data);
- assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
- }
-
- // -- Non-133 OSC sequences (should be ignored) ----------------------------
-
- #[test]
- fn non_133_osc_ignored() {
- let data = b"\x1b]0;window title\x07\x1b]133;A\x07";
- let events = parse_events(data);
- assert_eq!(events, vec![Event::PromptStart]);
- }
-
- #[test]
- fn osc_7_ignored() {
- let data = b"\x1b]7;file:///home/user\x07";
- assert!(parse_events(data).is_empty());
- }
-
- // -- Unknown command letter -----------------------------------------------
-
- #[test]
- fn unknown_command_ignored() {
- let data = b"\x1b]133;Z\x07";
- assert!(parse_events(data).is_empty());
- }
-
- #[test]
- fn marker_with_unexpected_trailing_bytes_ignored() {
- let data = b"\x1b]133;ABC\x07";
- assert!(parse_events(data).is_empty());
- }
-
- // -- Malformed sequences --------------------------------------------------
-
- #[test]
- fn esc_followed_by_non_bracket() {
- let data = b"\x1b[31m\x1b]133;A\x07";
- let events = parse_events(data);
- assert_eq!(events, vec![Event::PromptStart]);
- }
-
- #[test]
- fn lone_esc_at_end_of_chunk() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push(b"\x1b", |e| events.push(e));
- assert!(events.is_empty());
-
- // Feed non-bracket to abort the escape, then a real sequence.
- parser.push(b"x\x1b]133;A\x07", |e| events.push(e));
- assert_eq!(events, vec![Event::PromptStart]);
- }
-
- #[test]
- fn truncated_133_prefix() {
- // "13" followed by terminator — not "133;" so no event.
- let data = b"\x1b]13\x07";
- assert!(parse_events(data).is_empty());
- }
-
- #[test]
- fn empty_osc() {
- let data = b"\x1b]\x07";
- assert!(parse_events(data).is_empty());
- }
-
- // -- Buffer overflow (very long non-133 OSC) ------------------------------
-
- #[test]
- fn very_long_osc_does_not_panic() {
- let mut data = Vec::new();
- data.extend_from_slice(b"\x1b]");
- data.extend(std::iter::repeat_n(b'x', 1000));
- data.push(BEL);
- // Should not panic and should produce no event.
- assert!(parse_events(&data).is_empty());
- }
-
- // -- Empty input ----------------------------------------------------------
-
- #[test]
- fn empty_input() {
- assert!(parse_events(b"").is_empty());
- }
-
- #[test]
- fn only_normal_text() {
- let data = b"just some regular terminal output\r\n";
- assert!(parse_events(data).is_empty());
- }
-
- // -- Repeated prompts (empty command) ------------------------------------
-
- #[test]
- fn repeated_prompt_cycle() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- // User hits enter on an empty prompt twice.
- let data = b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;D\x07\x1b]133;A\x07$ \x1b]133;B\x07";
- parser.push(data, |e| events.push(e));
-
- assert_eq!(
- events,
- vec![
- Event::PromptStart,
- Event::CommandStart,
- Event::CommandFinished { exit_code: None },
- Event::PromptStart,
- Event::CommandStart,
- ]
- );
- assert_eq!(parser.zone(), Zone::Input);
- }
-
- // -- Byte-at-a-time feeding -----------------------------------------------
-
- #[test]
- fn byte_at_a_time() {
- let data = b"\x1b]133;D;99\x07";
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- for &byte in data {
- parser.push(&[byte], |e| events.push(e));
- }
-
- assert_eq!(
- events,
- vec![Event::CommandFinished {
- exit_code: Some(99)
- }]
- );
- }
-
- // -- Mixed terminators ----------------------------------------------------
-
- #[test]
- fn mixed_bel_and_st_terminators() {
- let data = b"\x1b]133;A\x07\x1b]133;B\x1b\\\x1b]133;C\x07\x1b]133;D;1\x1b\\";
- let events = parse_events(data);
- assert_eq!(
- events,
- vec![
- Event::PromptStart,
- Event::CommandStart,
- Event::CommandExecuted,
- Event::CommandFinished { exit_code: Some(1) },
- ]
- );
- }
-
- #[test]
- fn detects_c1_st_terminator() {
- let data = b"\x1b]133;A\x9c";
- assert_eq!(parse_events(data), vec![Event::PromptStart]);
- }
-
- // -- Located event offsets ------------------------------------------------
-
- #[test]
- fn located_event_reports_offset_after_marker() {
- let data = b"before\x1b]133;A\x07prompt";
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push_located(data, |e| events.push(e));
-
- assert_eq!(
- events,
- vec![LocatedEvent {
- event: Event::PromptStart,
- start_offset: b"before".len(),
- offset: b"before\x1b]133;A\x07".len(),
- zone: Zone::Prompt,
- params: Params::default(),
- }]
- );
- }
-
- #[test]
- fn located_event_offset_is_relative_to_completing_chunk() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push_located(b"\x1b]133;", |e| events.push(e));
- parser.push_located(b"D;42\x07after", |e| events.push(e));
-
- assert_eq!(
- events,
- vec![LocatedEvent {
- event: Event::CommandFinished {
- exit_code: Some(42)
- },
- start_offset: 0,
- offset: b"D;42\x07".len(),
- zone: Zone::Unknown,
- params: Params::default(),
- }]
- );
- }
-
- #[test]
- fn located_event_preserves_metadata_params() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push_located(
- b"\x1b]133;D;127;history_id=018f;session_id=abcd;flag\x07",
- |event| events.push(event),
- );
-
- assert_eq!(events.len(), 1);
- let event = &events[0];
- assert_eq!(
- event.event,
- Event::CommandFinished {
- exit_code: Some(127)
- }
- );
- assert_eq!(event.params.get("history_id"), Some("018f"));
- assert_eq!(event.params.get("session_id"), Some("abcd"));
- assert!(
- event
- .params
- .iter()
- .any(|param| param == &Param::Value("flag".to_string()))
- );
- }
-
- #[test]
- fn command_finished_metadata_without_exit_code_is_preserved() {
- let mut parser = Parser::new();
- let mut events = Vec::new();
-
- parser.push_located(b"\x1b]133;D;history_id=018f;session_id=abcd\x07", |event| {
- events.push(event);
- });
-
- assert_eq!(events.len(), 1);
- let event = &events[0];
- assert_eq!(event.event, Event::CommandFinished { exit_code: None });
- assert_eq!(event.params.get("history_id"), Some("018f"));
- assert_eq!(event.params.get("session_id"), Some("abcd"));
- }
-
- // -- Default trait --------------------------------------------------------
-
- #[test]
- fn parser_default() {
- let parser = Parser::default();
- assert_eq!(parser.zone(), Zone::Unknown);
- }
-
- #[test]
- fn zone_default() {
- assert_eq!(Zone::default(), Zone::Unknown);
- }
-
- // -- D with empty exit code field -----------------------------------------
-
- #[test]
- fn d_with_semicolon_but_empty_code() {
- // "133;D;" — semicolon present but no digits.
- let data = b"\x1b]133;D;\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished { exit_code: None }]
- );
- }
-
- // -- Consecutive OSC sequences without gap --------------------------------
-
- #[test]
- fn back_to_back_osc_no_gap() {
- let data = b"\x1b]133;A\x07\x1b]133;B\x07";
- let events = parse_events(data);
- assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
- }
-
- // -- CSI sequences interleaved (should not confuse parser) ----------------
-
- #[test]
- fn csi_sequences_ignored() {
- // CSI (ESC [) color codes mixed with OSC 133.
- let data = b"\x1b[32m\x1b]133;A\x07\x1b[0m$ \x1b]133;B\x07";
- let events = parse_events(data);
- assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]);
- }
-
- // -- Large exit codes -----------------------------------------------------
-
- #[test]
- fn large_exit_code() {
- let data = b"\x1b]133;D;2147483647\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished {
- exit_code: Some(i32::MAX)
- }]
- );
- }
-
- #[test]
- fn overflow_exit_code_yields_none() {
- let data = b"\x1b]133;D;9999999999999\x07";
- assert_eq!(
- parse_events(data),
- vec![Event::CommandFinished { exit_code: None }]
- );
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/pty_proxy.rs b/crates/turtle/src/atuin_pty_proxy/pty_proxy.rs
deleted file mode 100644
index ef4b0c37..00000000
--- a/crates/turtle/src/atuin_pty_proxy/pty_proxy.rs
+++ /dev/null
@@ -1,237 +0,0 @@
-use clap::{Args, Subcommand, ValueEnum};
-
-use crate::atuin_pty_proxy::{CommandCaptureSink, runtime};
-
-#[derive(Args, Debug)]
-pub(crate) struct PtyProxy {
- /// Highlight OSC 133 prompt, input, output, and exit-code regions
- #[arg(long)]
- debug_osc133: bool,
-
- #[command(subcommand)]
- cmd: Option<Cmd>,
-}
-
-#[derive(Subcommand, Debug)]
-pub(crate) enum Cmd {
- /// Print shell code to initialize atuin pty-proxy on shell startup
- Init(Init),
-}
-
-#[derive(Args, Debug)]
-pub(crate) struct Init {
- /// Shell to generate init for. If omitted, attempt auto-detection
- #[arg(value_enum)]
- shell: Option<Shell>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
-#[value(rename_all = "lower")]
-enum Shell {
- /// Zsh setup
- Zsh,
-
- /// Bash setup
- Bash,
-
- /// Fish setup
- Fish,
-
- /// Nu setup
- Nu,
-}
-
-pub(crate) struct RuntimeOptions {
- pub(crate) debug_osc133: bool,
- pub(crate) command_capture_sink: Option<CommandCaptureSink>,
-}
-
-impl RuntimeOptions {
- fn new(debug_osc133: bool, command_capture_sink: Option<CommandCaptureSink>) -> Self {
- Self {
- debug_osc133: debug_osc133 || env_flag("ATUIN_PTY_PROXY_DEBUG"),
- command_capture_sink,
- }
- }
-}
-
-impl PtyProxy {
- pub(crate) fn run(self, command_capture_sink: Option<CommandCaptureSink>) {
- match self.cmd {
- Some(Cmd::Init(init)) => {
- if let Err(err) = init.run() {
- eprintln!("atuin pty-proxy: {err}");
- std::process::exit(1);
- }
- }
- None => runtime::main(RuntimeOptions::new(self.debug_osc133, command_capture_sink)),
- }
- }
-}
-
-impl Init {
- fn run(self) -> Result<(), String> {
- let shell = detect_shell(self.shell)?;
- let script = render_init(shell);
- print!("{script}");
- Ok(())
- }
-}
-
-fn detect_shell(cli_shell: Option<Shell>) -> Result<Shell, String> {
- if let Some(shell) = cli_shell {
- return Ok(shell);
- }
-
- if let Ok(shell) = std::env::var("ATUIN_SHELL")
- && let Some(shell) = shell_from_name(&shell)
- {
- return Ok(shell);
- }
-
- if let Ok(shell) = std::env::var("SHELL")
- && let Some(shell) = shell_from_name(&shell)
- {
- return Ok(shell);
- }
-
- Err(
- "could not detect a supported shell. Please specify one explicitly: bash, zsh, fish, or nu"
- .to_string(),
- )
-}
-
-fn shell_from_name(name: &str) -> Option<Shell> {
- let shell = name
- .trim()
- .rsplit('/')
- .next()
- .unwrap_or(name)
- .trim_start_matches('-')
- .to_ascii_lowercase();
-
- match shell.as_str() {
- "bash" => Some(Shell::Bash),
- "zsh" => Some(Shell::Zsh),
- "fish" => Some(Shell::Fish),
- "nu" => Some(Shell::Nu),
- _ => None,
- }
-}
-
-fn env_flag(name: &str) -> bool {
- std::env::var(name).is_ok_and(|value| {
- matches!(
- value.trim().to_ascii_lowercase().as_str(),
- "1" | "true" | "yes" | "on"
- )
- })
-}
-
-#[expect(
- clippy::literal_string_with_formatting_args,
- reason = "It's shell code"
-)]
-fn render_init(shell: Shell) -> &'static str {
- match shell {
- Shell::Bash | Shell::Zsh => {
- r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then
- _atuin_pty_proxy_tmux_current="${TMUX:-}"
- _atuin_pty_proxy_tmux_previous="${ATUIN_PTY_PROXY_TMUX:-}"
-
- if [[ -z "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || [[ "$_atuin_pty_proxy_tmux_current" != "$_atuin_pty_proxy_tmux_previous" ]]; then
- export ATUIN_PTY_PROXY_ACTIVE=1
- export ATUIN_PTY_PROXY_TMUX="$_atuin_pty_proxy_tmux_current"
- exec atuin pty-proxy
- fi
-
- unset _atuin_pty_proxy_tmux_current _atuin_pty_proxy_tmux_previous
-fi
-"#
- }
- Shell::Fish => {
- r#"if status is-interactive; and test -t 0; and test -t 1
- set -l _atuin_pty_proxy_tmux_current ""
- if set -q TMUX
- set _atuin_pty_proxy_tmux_current "$TMUX"
- end
-
- set -l _atuin_pty_proxy_tmux_previous ""
- if set -q ATUIN_PTY_PROXY_TMUX
- set _atuin_pty_proxy_tmux_previous "$ATUIN_PTY_PROXY_TMUX"
- end
-
- if not set -q ATUIN_PTY_PROXY_ACTIVE
- set -gx ATUIN_PTY_PROXY_ACTIVE 1
- set -gx ATUIN_PTY_PROXY_TMUX "$_atuin_pty_proxy_tmux_current"
- exec atuin pty-proxy
- else if test "$_atuin_pty_proxy_tmux_current" != "$_atuin_pty_proxy_tmux_previous"
- set -gx ATUIN_PTY_PROXY_ACTIVE 1
- set -gx ATUIN_PTY_PROXY_TMUX "$_atuin_pty_proxy_tmux_current"
- exec atuin pty-proxy
- end
-end
-"#
- }
- // Nushell cannot dynamically source the output of `atuin init nu`,
- // so we only output the pty-proxy preamble here. Users must also set up
- // `atuin init nu` separately.
- Shell::Nu => {
- r#"if (is-terminal --stdin) and (is-terminal --stdout) {
- let tmux_current = ($env.TMUX? | default "")
- let tmux_previous = ($env.ATUIN_PTY_PROXY_TMUX? | default "")
-
- if (($env.ATUIN_PTY_PROXY_ACTIVE? | default "") | is-empty) or ($tmux_current != $tmux_previous) {
- $env.ATUIN_PTY_PROXY_ACTIVE = "1"
- $env.ATUIN_PTY_PROXY_TMUX = $tmux_current
- exec atuin pty-proxy
- }
-}
-"#
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{Shell, render_init, shell_from_name};
-
- #[test]
- fn shell_from_name_handles_paths() {
- assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh));
- assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash));
- assert_eq!(shell_from_name("fish"), Some(Shell::Fish));
- assert_eq!(shell_from_name("nu"), Some(Shell::Nu));
- }
-
- #[test]
- fn posix_init_uses_exec_and_tmux_guard() {
- let script = render_init(Shell::Bash);
- assert!(script.contains("exec atuin pty-proxy"));
- assert!(script.contains("ATUIN_PTY_PROXY_TMUX"));
- assert!(!script.contains("eval \"$(atuin init bash)\""));
- }
-
- #[test]
- fn posix_init_has_no_double_braces() {
- let script = render_init(Shell::Bash);
- assert!(!script.contains("${{"), "double braces in bash init script");
- }
-
- #[test]
- fn fish_init_uses_source() {
- let script = render_init(Shell::Fish);
- assert!(script.contains("exec atuin pty-proxy"));
- assert!(!script.contains("atuin init fish | source"));
- }
-
- #[test]
- fn nu_init_uses_exec_and_tty_guard() {
- let script = render_init(Shell::Nu);
- assert!(script.contains("exec atuin pty-proxy"));
- assert!(script.contains("ATUIN_PTY_PROXY_TMUX"));
- assert!(script.contains("is-terminal --stdin"));
- assert!(script.contains("is-terminal --stdout"));
- assert!(script.contains("ATUIN_PTY_PROXY_ACTIVE"));
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/runtime.rs b/crates/turtle/src/atuin_pty_proxy/runtime.rs
deleted file mode 100644
index 69b3a075..00000000
--- a/crates/turtle/src/atuin_pty_proxy/runtime.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-use std::io::{Read, Write};
-use std::sync::Arc;
-use std::sync::atomic::{AtomicU16, Ordering};
-use std::sync::mpsc;
-
-use crossterm::terminal;
-use portable_pty::{CommandBuilder, PtySize, native_pty_system};
-
-use crate::atuin_pty_proxy::capture::CommandCaptureTracker;
-use crate::atuin_pty_proxy::debug::{Osc133DebugHighlighter, RESET};
-use crate::atuin_pty_proxy::pty_proxy::RuntimeOptions;
-use crate::atuin_pty_proxy::screen::{self, Msg};
-
-pub(crate) fn main(options: RuntimeOptions) {
- if let Err(e) = run(options) {
- drop(terminal::disable_raw_mode());
- eprintln!("atuin pty-proxy: {e:#}");
- std::process::exit(1);
- }
-}
-
-fn run(options: RuntimeOptions) -> eyre::Result<()> {
- let (cols, rows) = terminal::size()?;
-
- let pty_system = native_pty_system();
- let pair = pty_system
- .openpty(PtySize {
- rows,
- cols,
- pixel_width: 0,
- pixel_height: 0,
- })
- .map_err(|e| eyre::eyre!("{e:#}"))?;
-
- let sock_path = screen::socket_path();
- drop(std::fs::remove_file(&sock_path));
-
- let mut cmd = CommandBuilder::new_default_prog();
- cmd.cwd(std::env::current_dir()?);
- cmd.env("ATUIN_PTY_PROXY_SOCKET", sock_path.as_os_str());
- cmd.env("ATUIN_PTY_PROXY_ACTIVE", "1");
-
- let mut child = pair
- .slave
- .spawn_command(cmd)
- .map_err(|e| eyre::eyre!("{e:#}"))?;
-
- drop(pair.slave);
-
- let mut pty_reader = pair
- .master
- .try_clone_reader()
- .map_err(|e| eyre::eyre!("{e:#}"))?;
- let mut pty_writer = pair
- .master
- .take_writer()
- .map_err(|e| eyre::eyre!("{e:#}"))?;
-
- let (msg_tx, msg_rx) = mpsc::sync_channel::<Msg>(64);
- let current_cols = Arc::new(AtomicU16::new(cols.max(1)));
-
- screen::spawn_parser_thread(rows, cols, msg_rx);
- screen::spawn_socket_server(sock_path.clone(), msg_tx.clone());
- spawn_resize_handler(pair.master, msg_tx.clone(), current_cols.clone())?;
-
- terminal::enable_raw_mode()?;
-
- let stdout_thread = std::thread::spawn(move || {
- let mut stdout = std::io::stdout();
- let mut highlighter = options.debug_osc133.then(Osc133DebugHighlighter::new);
- let mut capture_tracker = options
- .command_capture_sink
- .as_ref()
- .map(|_| CommandCaptureTracker::new(current_cols));
- let mut buf = [0u8; 8192];
-
- loop {
- match pty_reader.read(&mut buf) {
- Ok(0) | Err(_) => break,
- Ok(n) => {
- if let (Some(tracker), Some(sink)) = (
- capture_tracker.as_mut(),
- options.command_capture_sink.as_ref(),
- ) {
- tracker.push(&buf[..n], sink);
- }
-
- if let Some(highlighter) = highlighter.as_mut() {
- let rendered = highlighter.render(&buf[..n]);
- drop(msg_tx.try_send(Msg::Data(rendered.clone())));
-
- if stdout.write_all(&rendered).is_err() {
- break;
- }
- } else {
- drop(msg_tx.try_send(Msg::Data(buf[..n].to_vec())));
-
- if stdout.write_all(&buf[..n]).is_err() {
- break;
- }
- }
- drop(stdout.flush());
- }
- }
- }
-
- if highlighter.is_some() {
- drop(stdout.write_all(RESET));
- drop(stdout.flush());
- }
- });
-
- std::thread::spawn(move || {
- let mut stdin = std::io::stdin();
- let mut buf = [0u8; 8192];
- loop {
- match stdin.read(&mut buf) {
- Ok(0) | Err(_) => break,
- Ok(n) => {
- if pty_writer.write_all(&buf[..n]).is_err() {
- break;
- }
- }
- }
- }
- });
-
- let status = child.wait()?;
- drop(stdout_thread.join());
-
- drop(terminal::disable_raw_mode());
- drop(std::fs::remove_file(&sock_path));
-
- std::process::exit(process_exit_code(status.exit_code()));
-}
-
-fn spawn_resize_handler(
- master: Box<dyn portable_pty::MasterPty + Send>,
- resize_tx: mpsc::SyncSender<Msg>,
- current_cols: Arc<AtomicU16>,
-) -> eyre::Result<()> {
- use signal_hook::consts::SIGWINCH;
- use signal_hook::iterator::Signals;
-
- let mut signals = Signals::new([SIGWINCH])?;
-
- std::thread::spawn(move || {
- for _ in signals.forever() {
- if let Ok((cols, rows)) = terminal::size() {
- current_cols.store(cols.max(1), Ordering::Relaxed);
- drop(master.resize(PtySize {
- rows,
- cols,
- pixel_width: 0,
- pixel_height: 0,
- }));
- drop(resize_tx.try_send(Msg::Resize { rows, cols }));
- }
- }
- });
-
- Ok(())
-}
-
-fn process_exit_code(code: u32) -> i32 {
- i32::try_from(code).unwrap_or(1)
-}
-
-#[cfg(test)]
-mod tests {
- use super::process_exit_code;
-
- #[test]
- fn process_exit_code_preserves_valid_values() {
- assert_eq!(process_exit_code(0), 0);
- assert_eq!(process_exit_code(127), 127);
- assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX);
- }
-
- #[test]
- fn process_exit_code_defaults_when_out_of_range() {
- assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1);
- }
-}
diff --git a/crates/turtle/src/atuin_pty_proxy/screen.rs b/crates/turtle/src/atuin_pty_proxy/screen.rs
deleted file mode 100644
index 58ebd2eb..00000000
--- a/crates/turtle/src/atuin_pty_proxy/screen.rs
+++ /dev/null
@@ -1,96 +0,0 @@
-use std::io::Write;
-use std::os::unix::net::UnixListener;
-use std::path::PathBuf;
-use std::sync::mpsc::{self, Receiver, SyncSender};
-
-pub(crate) enum Msg {
- Data(Vec<u8>),
- Resize { rows: u16, cols: u16 },
- ScreenRequest(mpsc::Sender<Vec<u8>>),
-}
-
-pub(crate) fn socket_path() -> PathBuf {
- let dir = std::env::temp_dir();
- dir.join(format!("atuin-pty-proxy-{}.sock", std::process::id()))
-}
-
-pub(crate) fn spawn_parser_thread(rows: u16, cols: u16, msg_rx: Receiver<Msg>) {
- std::thread::spawn(move || {
- let mut parser = vt100::Parser::new(rows, cols, 0);
-
- while let Ok(first) = msg_rx.recv() {
- handle_parser_msg(&mut parser, first);
-
- while let Ok(msg) = msg_rx.try_recv() {
- handle_parser_msg(&mut parser, msg);
- }
- }
- });
-}
-
-pub(crate) fn spawn_socket_server(sock_path: PathBuf, screen_tx: SyncSender<Msg>) {
- std::thread::spawn(move || {
- let listener = match UnixListener::bind(&sock_path) {
- Ok(l) => l,
- Err(e) => {
- eprintln!("atuin pty-proxy: failed to bind socket: {e}");
- return;
- }
- };
-
- for stream in listener.incoming() {
- let Ok(mut stream) = stream else { break };
-
- let (reply_tx, reply_rx) = mpsc::channel();
- if screen_tx.send(Msg::ScreenRequest(reply_tx)).is_err() {
- break;
- }
- if let Ok(data) = reply_rx.recv() {
- drop(stream.write_all(&data));
- drop(stream.flush());
- }
- }
- });
-}
-
-/// Wire format written to the Unix socket:
-///
-/// ```text
-/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
-/// [row_0_len: u32 BE][row_0_bytes...]
-/// [row_1_len: u32 BE][row_1_bytes...]
-/// ...
-/// ```
-///
-/// Each row's bytes come from `screen.rows_formatted(0, cols)` and contain
-/// pre-built ANSI escape sequences. The client can write them directly to
-/// stdout without needing its own vt100 parser.
-fn encode_screen(parser: &vt100::Parser) -> Vec<u8> {
- let screen = parser.screen();
- let (rows, cols) = screen.size();
- let (cursor_row, cursor_col) = screen.cursor_position();
-
- let mut buf: Vec<u8> = Vec::with_capacity(256 + (rows as usize * cols as usize));
- buf.extend_from_slice(&rows.to_be_bytes());
- buf.extend_from_slice(&cols.to_be_bytes());
- buf.extend_from_slice(&cursor_row.to_be_bytes());
- buf.extend_from_slice(&cursor_col.to_be_bytes());
-
- for row_bytes in screen.rows_formatted(0, cols) {
- let len = row_bytes.len() as u32;
- buf.extend_from_slice(&len.to_be_bytes());
- buf.extend_from_slice(&row_bytes);
- }
-
- buf
-}
-
-fn handle_parser_msg(parser: &mut vt100::Parser, msg: Msg) {
- match msg {
- Msg::Data(data) => parser.process(&data),
- Msg::Resize { rows, cols } => parser.screen_mut().set_size(rows, cols),
- Msg::ScreenRequest(reply_tx) => {
- drop(reply_tx.send(encode_screen(parser)));
- }
- }
-}
diff --git a/crates/turtle/src/atuin_server/database/db/mod.rs b/crates/turtle/src/atuin_server/database/db/mod.rs
deleted file mode 100644
index 77bd0c61..00000000
--- a/crates/turtle/src/atuin_server/database/db/mod.rs
+++ /dev/null
@@ -1,274 +0,0 @@
-use std::collections::HashMap;
-
-use rand::Rng;
-
-use crate::{
- atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus},
- atuin_server::database::{DbError, DbResult, DbSettings, models::User},
-};
-use sqlx::postgres::PgPoolOptions;
-
-use tracing::instrument;
-use uuid::Uuid;
-use wrappers::DbRecord;
-
-mod wrappers;
-
-const MIN_PG_VERSION: u32 = 14;
-
-#[derive(Clone)]
-pub(crate) struct ServerPostgres {
- pool: sqlx::Pool<sqlx::postgres::Postgres>,
- /// Optional read replica pool for read-only queries
- read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
-}
-
-impl ServerPostgres {
- /// Returns the appropriate pool for read operations.
- /// Uses `read_pool` if available, otherwise falls back to the primary pool.
- fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
- self.read_pool.as_ref().unwrap_or(&self.pool)
- }
-}
-
-impl ServerPostgres {
- pub(crate) async fn new(settings: &DbSettings) -> DbResult<Self> {
- let pool = PgPoolOptions::new()
- .max_connections(100)
- .connect(settings.db_uri.as_str())
- .await?;
-
- // Call server_version_num to get the DB server's major version number
- // The call returns None for servers older than 8.x.
- let pg_major_version: u32 =
- pool.acquire()
- .await?
- .server_version_num()
- .ok_or(DbError::Other(eyre::Report::msg(
- "could not get PostgreSQL version",
- )))?
- / 10000;
-
- if pg_major_version < MIN_PG_VERSION {
- return Err(DbError::Other(eyre::Report::msg(format!(
- "unsupported PostgreSQL version {pg_major_version}, minimum required is {MIN_PG_VERSION}"
- ))));
- }
-
- sqlx::migrate!("./db/server-pg-migrations")
- .run(&pool)
- .await
- .map_err(|error| DbError::Other(error.into()))?;
-
- // Create read replica pool if configured
- let read_pool = if let Some(read_db_uri) = &settings.read_db_uri {
- tracing::info!("Connecting to read replica database");
- let read_pool = PgPoolOptions::new()
- .max_connections(100)
- .connect(read_db_uri.as_str())
- .await?;
-
- // Verify the read replica is also a supported PostgreSQL version
- let read_pg_major_version: u32 = read_pool
- .acquire()
- .await?
- .server_version_num()
- .ok_or(DbError::Other(eyre::Report::msg(
- "could not get PostgreSQL version from read replica",
- )))?
- / 10000;
-
- if read_pg_major_version < MIN_PG_VERSION {
- return Err(DbError::Other(eyre::Report::msg(format!(
- "unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}"
- ))));
- }
-
- Some(read_pool)
- } else {
- None
- };
-
- Ok(Self { pool, read_pool })
- }
-
- #[instrument(skip_all)]
- pub(crate) async fn add_records(
- &self,
- user: &User,
- records: &[Record<EncryptedData>],
- ) -> DbResult<()> {
- let mut tx = self.pool.begin().await?;
-
- // We won't have uploaded this data if it wasn't the max. Therefore, we can deduce the max
- // idx without having to make further database queries. Doing the query on this small
- // amount of data should be much, much faster.
- //
- // Worst case, say we get this wrong. We end up caching data that isn't actually the max
- // idx, so clients upload again. The cache logic can be verified with a sql query anyway :)
-
- let mut heads = HashMap::<(HostId, &str), u64>::new();
-
- for i in records {
- let id = crate::atuin_common::utils::uuid_v7();
-
- let result = sqlx::query(
- "
- INSERT INTO store (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
- ON conflict DO nothing
- ",
- )
- .bind(id)
- .bind(i.id)
- .bind(i.host.id)
- .bind(i.idx as i64)
- .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time
- .bind(&i.version)
- .bind(&i.tag)
- .bind(&i.data.data)
- .bind(&i.data.content_encryption_key)
- .bind(user.id)
- .execute(&mut *tx)
- .await?;
-
- // Only update heads if we actually inserted the record
- if result.rows_affected() > 0 {
- heads
- .entry((i.host.id, &i.tag))
- .and_modify(|e| {
- if i.idx > *e {
- *e = i.idx;
- }
- })
- .or_insert(i.idx);
- }
- }
-
- // we've built the map of heads for this push, so commit it to the database
- for ((host, tag), idx) in heads {
- sqlx::query(
- "
- INSERT INTO store_idx_cache (user_id, host, tag, idx)
- VALUES ($1, $2, $3, $4)
- ON conflict(user_id, host, tag) DO update
- SET idx = greatest(store_idx_cache.idx, $4)
- ",
- )
- .bind(user.id)
- .bind(host)
- .bind(tag)
- .bind(idx as i64)
- .execute(&mut *tx)
- .await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- pub(crate) async fn next_records(
- &self,
- user: &User,
- host: HostId,
- tag: String,
- start: Option<RecordIdx>,
- count: u64,
- ) -> DbResult<Vec<Record<EncryptedData>>> {
- tracing::debug!("{:?} - {:?} - {:?}", host, tag, start);
- let start = start.unwrap_or(0);
-
- let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
- "
- SELECT client_id, host, idx, timestamp, version, tag, data, cek FROM store
- WHERE user_id = $1
- AND tag = $2
- AND host = $3
- AND idx >= $4
- ORDER BY idx asc
- LIMIT $5
- ",
- )
- .bind(user.id)
- .bind(tag.clone())
- .bind(host)
- .bind(start as i64)
- .bind(count as i64)
- .fetch_all(self.read_pool())
- .await
- .map_err(Into::into);
-
- let ret = match records {
- Ok(records) => {
- let records: Vec<Record<EncryptedData>> = records
- .into_iter()
- .map(|f| {
- let record: Record<EncryptedData> = f.into();
- record
- })
- .collect();
-
- records
- }
- Err(DbError::NotFound) => {
- tracing::debug!("no records found in store: {:?}/{}", host, tag);
- return Ok(vec![]);
- }
- Err(e) => return Err(e),
- };
-
- Ok(ret)
- }
-
- pub(crate) async fn status(&self, user: &User) -> DbResult<RecordStatus> {
- // If IDX_CACHE_ROLLOUT is set, then we
- // 1. Read the value of the var, use it as a % chance of using the cache
- // 2. If we use the cache, just read from the cache table
- // 3. If we don't use the cache, read from the store table
- // IDX_CACHE_ROLLOUT should be between 0 and 100.
-
- let idx_cache_rollout =
- std::env::var("IDX_CACHE_ROLLOUT").unwrap_or_else(|_| "0".to_string());
- let idx_cache_rollout = idx_cache_rollout.parse::<f64>().unwrap_or(0.0);
- let use_idx_cache = rand::thread_rng().gen_bool(idx_cache_rollout / 100.0);
-
- let mut res: Vec<(Uuid, String, i64)> = if use_idx_cache {
- tracing::debug!("using idx cache for user {}", user.id);
- sqlx::query_as(
- "
- SELECT host, tag, idx
- FROM store_idx_cache
- WHERE user_id = $1
- ",
- )
- .bind(user.id)
- .fetch_all(self.read_pool())
- .await?
- } else {
- tracing::debug!("using aggregate query for user {}", user.id);
- sqlx::query_as(
- "
- SELECT host, tag, max(idx)
- FROM store
- WHERE user_id = $1
- GROUP BY host, tag
- ",
- )
- .bind(user.id)
- .fetch_all(self.read_pool())
- .await?
- };
-
- res.sort();
-
- let mut status = RecordStatus::new();
-
- for i in &res {
- status.set_raw(HostId(i.0), i.1.clone(), i.2 as u64);
- }
-
- Ok(status)
- }
-}
diff --git a/crates/turtle/src/atuin_server/database/db/wrappers.rs b/crates/turtle/src/atuin_server/database/db/wrappers.rs
deleted file mode 100644
index 0315e331..00000000
--- a/crates/turtle/src/atuin_server/database/db/wrappers.rs
+++ /dev/null
@@ -1,32 +0,0 @@
-use crate::atuin_common::record::{EncryptedData, Host, Record};
-use sqlx::{Row, postgres::PgRow};
-
-pub(crate) struct DbRecord(pub Record<EncryptedData>);
-
-impl<'a> ::sqlx::FromRow<'a, PgRow> for DbRecord {
- fn from_row(row: &'a PgRow) -> ::sqlx::Result<Self> {
- let timestamp: i64 = row.try_get("timestamp")?;
- let idx: i64 = row.try_get("idx")?;
-
- let data = EncryptedData {
- data: row.try_get("data")?,
- content_encryption_key: row.try_get("cek")?,
- };
-
- Ok(Self(Record {
- id: row.try_get("client_id")?,
- host: Host::new(row.try_get("host")?),
- idx: idx as u64,
- timestamp: timestamp as u64,
- version: row.try_get("version")?,
- tag: row.try_get("tag")?,
- data,
- }))
- }
-}
-
-impl From<DbRecord> for Record<EncryptedData> {
- fn from(other: DbRecord) -> Self {
- other.0
- }
-}
diff --git a/crates/turtle/src/atuin_server/database/mod.rs b/crates/turtle/src/atuin_server/database/mod.rs
deleted file mode 100644
index 43fe5c3b..00000000
--- a/crates/turtle/src/atuin_server/database/mod.rs
+++ /dev/null
@@ -1,99 +0,0 @@
-pub(crate) mod db;
-pub(crate) mod models;
-
-use std::fmt::{Debug, Display};
-
-use serde::{Deserialize, Serialize};
-
-#[derive(Debug)]
-pub(crate) enum DbError {
- NotFound,
- Other(eyre::Report),
-}
-
-impl Display for DbError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::NotFound => write!(f, "Not found"),
- Self::Other(report) => write!(f, "Other: {report}"),
- }
- }
-}
-
-impl From<time::error::ComponentRange> for DbError {
- fn from(error: time::error::ComponentRange) -> Self {
- Self::Other(error.into())
- }
-}
-
-impl From<time::error::Error> for DbError {
- fn from(error: time::error::Error) -> Self {
- Self::Other(error.into())
- }
-}
-
-impl From<sqlx::Error> for DbError {
- fn from(error: sqlx::Error) -> Self {
- match error {
- sqlx::Error::RowNotFound => Self::NotFound,
- error => Self::Other(error.into()),
- }
- }
-}
-
-impl std::error::Error for DbError {}
-
-pub(crate) type DbResult<T> = Result<T, DbError>;
-
-#[derive(Debug, PartialEq)]
-pub(crate) enum DbType {
- Postgres,
- Unknown,
-}
-
-#[derive(Clone, Deserialize, Serialize)]
-pub(crate) struct DbSettings {
- pub(crate) db_uri: String,
-
- /// Optional URI for read replicas. If set, read-only queries will use this connection.
- pub(crate) read_db_uri: Option<String>,
-}
-
-impl DbSettings {
- pub(crate) fn db_type(&self) -> DbType {
- if self.db_uri.starts_with("postgres://") || self.db_uri.starts_with("postgresql://") {
- DbType::Postgres
- } else {
- DbType::Unknown
- }
- }
-}
-
-fn redact_db_uri(uri: &str) -> String {
- url::Url::parse(uri).map_or_else(
- |_| uri.to_string(),
- |mut url| {
- url.set_password(Some("****")).expect("should be possible");
- url.to_string()
- },
- )
-}
-
-// Do our best to redact passwords so they're not logged in the event of an error.
-impl Debug for DbSettings {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if self.db_type() == DbType::Postgres {
- let redacted_uri = redact_db_uri(&self.db_uri);
- let redacted_read_uri = self.read_db_uri.as_ref().map(|uri| redact_db_uri(uri));
- f.debug_struct("DbSettings")
- .field("db_uri", &redacted_uri)
- .field("read_db_uri", &redacted_read_uri)
- .finish()
- } else {
- f.debug_struct("DbSettings")
- .field("db_uri", &self.db_uri)
- .field("read_db_uri", &self.read_db_uri)
- .finish()
- }
- }
-}
diff --git a/crates/turtle/src/atuin_server/database/models.rs b/crates/turtle/src/atuin_server/database/models.rs
deleted file mode 100644
index 3fa6f471..00000000
--- a/crates/turtle/src/atuin_server/database/models.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-use uuid::Uuid;
-
-pub(crate) struct User {
- pub(crate) id: Uuid,
-}
diff --git a/crates/turtle/src/atuin_server/handlers/mod.rs b/crates/turtle/src/atuin_server/handlers/mod.rs
deleted file mode 100644
index b73df27b..00000000
--- a/crates/turtle/src/atuin_server/handlers/mod.rs
+++ /dev/null
@@ -1,54 +0,0 @@
-use crate::atuin_common::api::{ErrorResponse, IndexResponse};
-use axum::{Json, extract::State, http, response::IntoResponse};
-
-use crate::atuin_server::router::AppState;
-
-pub(crate) mod v0;
-
-const VERSION: &str = env!("CARGO_PKG_VERSION");
-
-pub(crate) async fn index(state: State<AppState>) -> Json<IndexResponse> {
- let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#;
-
- let version = state
- .settings
- .fake_version
- .clone()
- .unwrap_or_else(|| VERSION.to_string());
-
- Json(IndexResponse {
- homage: homage.to_string(),
- version,
- })
-}
-
-impl IntoResponse for ErrorResponseStatus<'_> {
- fn into_response(self) -> axum::response::Response {
- (self.status, Json(self.error)).into_response()
- }
-}
-
-pub(crate) struct ErrorResponseStatus<'a> {
- pub(crate) error: ErrorResponse<'a>,
- pub(crate) status: http::StatusCode,
-}
-
-pub(crate) trait RespExt<'a> {
- fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a>;
- fn reply(reason: &'a str) -> Self;
-}
-
-impl<'a> RespExt<'a> for ErrorResponse<'a> {
- fn with_status(self, status: http::StatusCode) -> ErrorResponseStatus<'a> {
- ErrorResponseStatus {
- error: self,
- status,
- }
- }
-
- fn reply(reason: &'a str) -> Self {
- Self {
- reason: reason.into(),
- }
- }
-}
diff --git a/crates/turtle/src/atuin_server/handlers/v0/mod.rs b/crates/turtle/src/atuin_server/handlers/v0/mod.rs
deleted file mode 100644
index 78fb47b8..00000000
--- a/crates/turtle/src/atuin_server/handlers/v0/mod.rs
+++ /dev/null
@@ -1 +0,0 @@
-pub(crate) mod record;
diff --git a/crates/turtle/src/atuin_server/handlers/v0/record.rs b/crates/turtle/src/atuin_server/handlers/v0/record.rs
deleted file mode 100644
index 9350e1c8..00000000
--- a/crates/turtle/src/atuin_server/handlers/v0/record.rs
+++ /dev/null
@@ -1,113 +0,0 @@
-use axum::{Json, extract::Query, extract::State, http::StatusCode};
-use metrics::counter;
-use serde::Deserialize;
-use tracing::{error, instrument};
-
-use crate::atuin_server::{
- handlers::{ErrorResponse, ErrorResponseStatus, RespExt},
- router::{AppState, UserAuth},
-};
-
-use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
-
-#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn post(
- UserAuth(user): UserAuth,
- state: State<AppState>,
- Json(records): Json<Vec<Record<EncryptedData>>>,
-) -> Result<(), ErrorResponseStatus<'static>> {
- let State(AppState { database, settings }) = state;
-
- tracing::debug!(
- count = records.len(),
- user = user.id.to_string(),
- "request to add records"
- );
-
- counter!("atuin_record_uploaded").increment(records.len() as u64);
-
- let keep = records
- .iter()
- .all(|r| r.data.data.len() <= settings.max_record_size || settings.max_record_size == 0);
-
- if !keep {
- counter!("atuin_record_too_large").increment(1);
-
- return Err(
- ErrorResponse::reply("could not add records; record too large")
- .with_status(StatusCode::BAD_REQUEST),
- );
- }
-
- if let Err(e) = database.add_records(&user, &records).await {
- error!("failed to add record: {}", e);
-
- return Err(ErrorResponse::reply("failed to add record")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
-
- Ok(())
-}
-
-#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn index(
- UserAuth(user): UserAuth,
- state: State<AppState>,
-) -> Result<Json<RecordStatus>, ErrorResponseStatus<'static>> {
- let State(AppState {
- database,
- settings: _,
- }) = state;
-
- let record_index = match database.status(&user).await {
- Ok(index) => index,
- Err(e) => {
- error!("failed to get record index: {}", e);
-
- return Err(ErrorResponse::reply("failed to calculate record index")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
- };
-
- tracing::debug!(user = user.id.to_string(), "record index request");
-
- Ok(Json(record_index))
-}
-
-#[derive(Deserialize)]
-pub(crate) struct NextParams {
- host: HostId,
- tag: String,
- start: Option<RecordIdx>,
- count: u64,
-}
-
-#[instrument(skip_all, fields(user.id = user.id.to_string()))]
-pub(crate) async fn next(
- params: Query<NextParams>,
- UserAuth(user): UserAuth,
- state: State<AppState>,
-) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> {
- let State(AppState {
- database,
- settings: _,
- }) = state;
- let params = params.0;
-
- let records = match database
- .next_records(&user, params.host, params.tag, params.start, params.count)
- .await
- {
- Ok(records) => records,
- Err(e) => {
- error!("failed to get record index: {}", e);
-
- return Err(ErrorResponse::reply("failed to calculate record index")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
- };
-
- counter!("atuin_record_downloaded").increment(records.len() as u64);
-
- Ok(Json(records))
-}
diff --git a/crates/turtle/src/atuin_server/metrics.rs b/crates/turtle/src/atuin_server/metrics.rs
deleted file mode 100644
index 6380bef1..00000000
--- a/crates/turtle/src/atuin_server/metrics.rs
+++ /dev/null
@@ -1,55 +0,0 @@
-use std::time::Instant;
-
-use axum::{
- extract::{MatchedPath, Request},
- middleware::Next,
- response::IntoResponse,
-};
-use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
-
-pub(crate) fn setup_metrics_recorder() -> PrometheusHandle {
- const EXPONENTIAL_SECONDS: &[f64] = &[
- 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
- ];
-
- PrometheusBuilder::new()
- .set_buckets_for_metric(
- Matcher::Full("http_requests_duration_seconds".to_string()),
- EXPONENTIAL_SECONDS,
- )
- .unwrap()
- .install_recorder()
- .unwrap()
-}
-
-/// Middleware to record some common HTTP metrics
-/// Generic over B to allow for arbitrary body types (eg Vec<u8>, Streams, a deserialized thing, etc)
-/// Someday tower-http might provide a metrics middleware: <https://github.com/tower-rs/tower-http/issues/57>
-pub(crate) async fn track_metrics(req: Request, next: Next) -> impl IntoResponse {
- let start = Instant::now();
-
- let path = req.extensions().get::<MatchedPath>().map_or_else(
- || req.uri().path().to_owned(),
- |matched_path| matched_path.as_str().to_owned(),
- );
-
- let method = req.method().clone();
-
- // Run the rest of the request handling first, so we can measure it and get response
- // codes.
- let response = next.run(req).await;
-
- let latency = start.elapsed().as_secs_f64();
- let status = response.status().as_u16().to_string();
-
- let labels = [
- ("method", method.to_string()),
- ("path", path),
- ("status", status),
- ];
-
- metrics::counter!("http_requests_total", &labels).increment(1);
- metrics::histogram!("http_requests_duration_seconds", &labels).record(latency);
-
- response
-}
diff --git a/crates/turtle/src/atuin_server/mod.rs b/crates/turtle/src/atuin_server/mod.rs
deleted file mode 100644
index a4b10acf..00000000
--- a/crates/turtle/src/atuin_server/mod.rs
+++ /dev/null
@@ -1,86 +0,0 @@
-use std::future::Future;
-use std::net::SocketAddr;
-
-use axum::{Router, serve};
-use database::db::ServerPostgres;
-use eyre::{Context, Result};
-
-pub(crate) mod database;
-mod handlers;
-mod metrics;
-mod router;
-
-pub(crate) use settings::Settings;
-
-pub(crate) mod settings;
-
-use tokio::net::TcpListener;
-use tokio::signal;
-
-#[cfg(target_family = "unix")]
-async fn shutdown_signal() {
- let mut term = signal::unix::signal(signal::unix::SignalKind::terminate())
- .expect("failed to register signal handler");
- let mut interrupt = signal::unix::signal(signal::unix::SignalKind::interrupt())
- .expect("failed to register signal handler");
-
- tokio::select! {
- _ = term.recv() => {},
- _ = interrupt.recv() => {},
- };
- eprintln!("Shutting down gracefully...");
-}
-
-pub(crate) async fn launch(settings: Settings, addr: SocketAddr) -> Result<()> {
- launch_with_tcp_listener(
- settings,
- TcpListener::bind(addr)
- .await
- .context("could not connect to socket")?,
- shutdown_signal(),
- )
- .await
-}
-
-pub(crate) async fn launch_with_tcp_listener(
- settings: Settings,
- listener: TcpListener,
- shutdown: impl Future<Output = ()> + Send + 'static,
-) -> Result<()> {
- let r = make_router(settings).await?;
-
- serve(listener, r.into_make_service())
- .with_graceful_shutdown(shutdown)
- .await?;
-
- Ok(())
-}
-
-// The separate listener means it's much easier to ensure metrics are not accidentally exposed to
-// the public.
-pub(crate) async fn launch_metrics_server(host: String, port: u16) -> Result<()> {
- let listener = TcpListener::bind((host, port))
- .await
- .context("failed to bind metrics tcp")?;
-
- let recorder_handle = metrics::setup_metrics_recorder();
-
- let router = Router::new().route(
- "/metrics",
- axum::routing::get(move || std::future::ready(recorder_handle.render())),
- );
-
- serve(listener, router.into_make_service())
- .with_graceful_shutdown(shutdown_signal())
- .await?;
-
- Ok(())
-}
-
-async fn make_router(settings: Settings) -> Result<Router, eyre::Error> {
- let db = ServerPostgres::new(&settings.db_settings)
- .await
- .wrap_err_with(|| format!("failed to connect to db: {:?}", settings.db_settings))?;
- let r = router::router(db, settings);
- Ok(r)
-}
diff --git a/crates/turtle/src/atuin_server/router.rs b/crates/turtle/src/atuin_server/router.rs
deleted file mode 100644
index d6fe9482..00000000
--- a/crates/turtle/src/atuin_server/router.rs
+++ /dev/null
@@ -1,98 +0,0 @@
-use crate::{
- atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse},
- atuin_server::database::{db::ServerPostgres, models::User},
-};
-use axum::{
- Router,
- extract::{FromRequestParts, Path, Request},
- http::{self, request::Parts},
- middleware::Next,
- response::{IntoResponse, Response},
- routing::{get, post},
-};
-use eyre::Result;
-use tower::ServiceBuilder;
-use tower_http::trace::TraceLayer;
-use uuid::Uuid;
-
-use super::handlers;
-use crate::atuin_server::{
- handlers::{ErrorResponseStatus, RespExt},
- metrics,
- settings::Settings,
-};
-
-pub(crate) struct UserAuth(pub(crate) User);
-
-impl FromRequestParts<AppState> for UserAuth {
- type Rejection = ErrorResponseStatus<'static>;
-
- async fn from_request_parts(
- req: &mut Parts,
- state: &AppState,
- ) -> Result<Self, Self::Rejection> {
- let user_id = {
- let Path(user_id) =
- <Path<Uuid> as FromRequestParts<AppState>>::from_request_parts(req, state)
- .await
- .map_err(|_| {
- ErrorResponse::reply("invalid user_id path param")
- .with_status(http::StatusCode::BAD_REQUEST)
- })?;
-
- user_id
- };
-
- let user = User { id: user_id };
-
- Ok(Self(user))
- }
-}
-
-async fn teapot() -> impl IntoResponse {
- // This used to return 418: 🫖
- // Much as it was fun, it wasn't as useful or informative as it should be
- (http::StatusCode::NOT_FOUND, "404 not found")
-}
-
-/// Ensure that we only try and sync with clients on the same major version
-async fn semver(request: Request, next: Next) -> Response {
- let mut response = next.run(request).await;
- response
- .headers_mut()
- .insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse().unwrap());
-
- response
-}
-
-#[derive(Clone)]
-pub(crate) struct AppState {
- pub(crate) database: ServerPostgres,
- pub(crate) settings: Settings,
-}
-
-pub(crate) fn router(database: ServerPostgres, settings: Settings) -> Router {
- let routes = Router::new()
- .route("/", get(handlers::index))
- .route("/api/v0/{user_id}/record", post(handlers::v0::record::post))
- .route("/api/v0/{user_id}/record", get(handlers::v0::record::index))
- .route(
- "/api/v0/{user_id}/record/next",
- get(handlers::v0::record::next),
- );
-
- let path = settings.path.as_str();
- if path.is_empty() {
- routes
- } else {
- Router::new().nest(path, routes)
- }
- .fallback(teapot)
- .with_state(AppState { database, settings })
- .layer(
- ServiceBuilder::new()
- .layer(TraceLayer::new_for_http())
- .layer(axum::middleware::from_fn(metrics::track_metrics))
- .layer(axum::middleware::from_fn(semver)),
- )
-}
diff --git a/crates/turtle/src/atuin_server/settings.rs b/crates/turtle/src/atuin_server/settings.rs
deleted file mode 100644
index 73fc5e42..00000000
--- a/crates/turtle/src/atuin_server/settings.rs
+++ /dev/null
@@ -1,106 +0,0 @@
-use std::path::PathBuf;
-
-use config::{Config, Environment, File as ConfigFile, FileFormat};
-use eyre::{Result, eyre};
-use fs_err::create_dir_all;
-use serde::{Deserialize, Serialize};
-use tracing::info;
-
-use crate::atuin_server::database::DbSettings;
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Metrics {
- #[serde(alias = "enabled")]
- pub(crate) enable: bool,
- pub(crate) host: String,
- pub(crate) port: u16,
-}
-
-impl Default for Metrics {
- fn default() -> Self {
- Self {
- enable: false,
- host: String::from("127.0.0.1"),
- port: 9001,
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Settings {
- pub(crate) host: String,
- pub(crate) port: u16,
- pub(crate) path: String,
- pub(crate) max_history_length: usize,
- pub(crate) max_record_size: usize,
- pub(crate) page_size: i64,
- pub(crate) metrics: Metrics,
-
- /// Advertise a version that is not what we are _actually_ running
- /// Many clients compare their version with api.atuin.sh, and if they differ, notify the user
- /// that an update is available.
- /// Now that we take beta releases, we should be able to advertise a different version to avoid
- /// notifying users when the server runs something that is not a stable release.
- pub(crate) fake_version: Option<String>,
-
- #[serde(flatten)]
- #[expect(clippy::struct_field_names)]
- pub(crate) db_settings: DbSettings,
-}
-
-impl Settings {
- pub(crate) fn new() -> Result<Self> {
- let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else(
- |_| {
- let mut config_file = PathBuf::new();
- let config_dir = crate::atuin_common::utils::config_dir();
- config_file.push(config_dir);
- config_file
- },
- PathBuf::from,
- );
-
- config_file.push("server.toml");
-
- // create the config file if it does not exist
- let mut config_builder = Config::builder()
- .set_default("host", "127.0.0.1")?
- .set_default("port", 8888)?
- .set_default("max_history_length", 8192)?
- .set_default("max_record_size", 1024 * 1024 * 1024)? // pretty chonky
- .set_default("path", "")?
- .set_default("page_size", 1100)?
- .set_default("metrics.enable", false)?
- .set_default("metrics.host", "127.0.0.1")?
- .set_default("metrics.port", 9001)?
- .add_source(
- Environment::with_prefix("atuin")
- .prefix_separator("_")
- .separator("__"),
- );
-
- config_builder = if config_file.exists() {
- config_builder.add_source(ConfigFile::new(
- config_file.to_str().unwrap(),
- 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
- };
-
- let config = config_builder.build()?;
-
- config
- .try_deserialize()
- .map_err(|e| eyre!("failed to deserialize: {}", e))
- }
-}
diff --git a/crates/turtle/src/command/CONTRIBUTORS b/crates/turtle/src/command/CONTRIBUTORS
deleted file mode 120000
index 1ca4115a..00000000
--- a/crates/turtle/src/command/CONTRIBUTORS
+++ /dev/null
@@ -1 +0,0 @@
-../../../../CONTRIBUTORS \ No newline at end of file
diff --git a/crates/turtle/src/command/client.rs b/crates/turtle/src/command/client.rs
deleted file mode 100644
index 6c0e70e8..00000000
--- a/crates/turtle/src/command/client.rs
+++ /dev/null
@@ -1,327 +0,0 @@
-use std::fs::{self, OpenOptions};
-use std::path::{Path, PathBuf};
-
-use clap::Subcommand;
-use eyre::{Result, WrapErr};
-use tracing_subscriber::util::SubscriberInitExt;
-
-use tracing_appender::rolling::{RollingFileAppender, Rotation};
-use tracing_subscriber::{
- Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan,
- prelude::__tracing_subscriber_SubscriberExt,
-};
-
-use crate::atuin_client::{
- database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
-};
-
-fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) {
- let cutoff = std::time::SystemTime::now()
- - std::time::Duration::from_secs(retention_days * 24 * 60 * 60);
-
- let Ok(entries) = fs::read_dir(log_dir) else {
- return;
- };
-
- for entry in entries.flatten() {
- let path = entry.path();
- let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
- continue;
- };
-
- // Match files like "search.log.2024-02-23" or "daemon.log.2024-02-23"
- if !name.starts_with(prefix) || name == prefix {
- continue;
- }
-
- if let Ok(metadata) = entry.metadata()
- && let Ok(modified) = metadata.modified()
- && modified < cutoff
- {
- drop(fs::remove_file(&path));
- }
- }
-}
-
-mod config;
-mod daemon;
-mod default_config;
-mod history;
-mod info;
-mod init;
-mod search;
-mod server;
-mod stats;
-mod store;
-mod sync;
-mod wrapped;
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Manipulate shell history
- #[command(subcommand)]
- History(history::Cmd),
-
- /// Interactive history search
- Search(search::Cmd),
-
- #[command(subcommand)]
- /// Request a sync or view sync status
- Sync(sync::Cmd),
-
- /// Manage the atuin server
- #[command(subcommand)]
- Server(server::Cmd),
-
- /// Manage the atuin data store
- #[command(subcommand)]
- Store(store::Cmd),
-
- /// Print Atuin's shell init script
- #[command()]
- Init(init::Cmd),
-
- /// Information about dotfiles locations and ENV vars
- #[command()]
- Info,
-
- /// Calculate statistics for your history
- Stats(stats::Cmd),
-
- #[command()]
- /// Display a recap of your last year's history
- Wrapped { year: Option<i32> },
-
- /// *Experimental* Manage the background daemon
- #[command()]
- Daemon(daemon::Cmd),
-
- /// Print the default atuin configuration (config.toml)
- #[command()]
- DefaultConfig,
-
- #[command(subcommand)]
- /// Manage your configuration
- Config(config::Cmd),
-}
-
-impl Cmd {
- pub(crate) fn run(self) -> Result<()> {
- // Daemonize before creating the async runtime – fork() inside a live
- // tokio runtime corrupts its internal state.
- #[cfg(unix)]
- if let Self::Daemon(ref cmd) = self
- && cmd.should_daemonize()
- {
- daemon::daemonize_current_process()?;
- }
-
- let mut runtime = tokio::runtime::Builder::new_current_thread();
-
- let runtime = runtime.enable_all().build().unwrap();
-
- // Start the server before descending into the client-specific setup code.
- // We simply cannot setup settings or a theme on the server, because the client-specific
- // stuff will error out.
- let res = if let Self::Server(server) = self {
- runtime.block_on(server.run())
- } else {
- // For non-history commands, we want to initialize logging and the theme manager before
- // doing anything else. History commands are performance-sensitive and run before and after
- // every shell command, so we want to skip any unnecessary initialization for them.
- let settings = Settings::new().wrap_err("could not load client settings")?;
-
- runtime.block_on(self.run_inner(settings))
- };
-
- runtime.shutdown_timeout(std::time::Duration::from_millis(50));
-
- res
- }
-
- #[expect(clippy::too_many_lines)]
- async fn run_inner(self, mut settings: Settings) -> Result<()> {
- // ATUIN_LOG env var overrides config file level settings
- let env_log_set = std::env::var("ATUIN_LOG").is_ok();
-
- // Base filter from env var (or empty if not set)
- let base_filter =
- EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?);
-
- let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive());
- // Use file-based logging for interactive search (TUI mode)
- let use_search_logging = is_interactive_search && settings.logs.search_enabled();
-
- // Use file-based logging for daemon
- let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled();
-
- // Check if daemon should also log to console
- let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs());
-
- // Set up span timing JSON logs if ATUIN_SPAN is set
- let span_path = std::env::var("ATUIN_SPAN").ok().map(|p| {
- if p.is_empty() {
- "atuin-spans.json".to_string()
- } else {
- p
- }
- });
-
- // Helper to create span timing layer
- macro_rules! make_span_layer {
- ($path:expr) => {{
- let span_file = OpenOptions::new()
- .create(true)
- .truncate(true)
- .write(true)
- .open($path)?;
- Some(
- fmt::layer()
- .json()
- .with_writer(span_file)
- .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
- .with_filter(LevelFilter::TRACE),
- )
- }};
- }
-
- // Build the subscriber with all configured layers
- if use_search_logging {
- let search_filename = settings.logs.search.file.clone();
- let log_dir = PathBuf::from(&settings.logs.dir);
- fs::create_dir_all(&log_dir)?;
-
- // Clean up old log files
- cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention());
-
- let file_appender =
- RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename);
-
- // Use config level unless ATUIN_LOG is set
- let filter = if env_log_set {
- base_filter
- } else {
- EnvFilter::default()
- .add_directive(settings.logs.search_level().as_directive().parse()?)
- .add_directive("sqlx_sqlite::regexp=off".parse()?)
- };
-
- let base = tracing_subscriber::registry().with(
- fmt::layer()
- .with_writer(file_appender)
- .with_ansi(false)
- .with_filter(filter),
- );
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- } else if use_daemon_logging {
- let daemon_filename = settings.logs.daemon.file.clone();
- let log_dir = PathBuf::from(&settings.logs.dir);
- fs::create_dir_all(&log_dir)?;
-
- // Clean up old log files
- cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention());
-
- let file_appender =
- RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename);
-
- // Use config level unless ATUIN_LOG is set
- let file_filter = if env_log_set {
- base_filter
- } else {
- EnvFilter::default()
- .add_directive(settings.logs.daemon_level().as_directive().parse()?)
- .add_directive("sqlx_sqlite::regexp=off".parse()?)
- };
-
- let file_layer = fmt::layer()
- .with_writer(file_appender)
- .with_ansi(false)
- .with_filter(file_filter);
-
- // Optionally add console layer for --show-logs
- if daemon_show_logs {
- let console_filter = EnvFilter::from_env("ATUIN_LOG")
- .add_directive("sqlx_sqlite::regexp=off".parse()?);
-
- let console_layer = fmt::layer().with_filter(console_filter);
-
- let base = tracing_subscriber::registry()
- .with(file_layer)
- .with(console_layer);
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- } else {
- let base = tracing_subscriber::registry().with(file_layer);
-
- match &span_path {
- Some(sp) => {
- base.with(make_span_layer!(sp)).init();
- }
- None => {
- base.init();
- }
- }
- }
- }
-
- tracing::trace!(command = ?self, "client command");
-
- // Skip initializing any databases for history
- // This is a pretty hot path, as it runs before and after every single command the user
- // runs
- match self {
- Self::History(history) => return history.run(&settings).await,
- Self::Init(init) => {
- init.run(&settings);
- return Ok(());
- }
- Self::Config(config) => return config.run(&settings).await,
- _ => {}
- }
-
- 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?;
-
- match self {
- Self::Stats(stats) => stats.run(&db, &settings).await,
- Self::Search(search) => search.run(db, &mut settings, sqlite_store).await,
- Self::Wrapped { year } => wrapped::run(year, &db, &settings).await,
-
- Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await,
-
- Self::Store(store) => store.run(&settings, &db, sqlite_store).await,
-
- Self::Info => info::run(&settings),
-
- Self::DefaultConfig => {
- default_config::run();
- Ok(())
- }
-
- Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await,
-
- Self::History(_) | Self::Init(_) | Self::Config(_) | Self::Server(_) => {
- unreachable!()
- }
- }
- }
-}
diff --git a/crates/turtle/src/command/client/config.rs b/crates/turtle/src/command/client/config.rs
deleted file mode 100644
index 73d1c35e..00000000
--- a/crates/turtle/src/command/client/config.rs
+++ /dev/null
@@ -1,352 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use clap::{Args, Subcommand, ValueEnum};
-use eyre::Result;
-use toml_edit::{Document, DocumentMut, Item, Table, TableLike, Value};
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Get a configuration value from your config.toml file
- /// or after defaults and overrides are applied
- #[command()]
- Get(GetCmd),
-
- /// Set a configuration value in your config.toml file
- #[command()]
- Set(SetCmd),
-
- /// Print all configuration values from your config.toml file
- /// in TOML format
- ///
- /// If a key is provided, only print the value of that key and all its children
- #[command()]
- Print(PrintCmd),
-}
-
-impl Cmd {
- pub(crate) async fn run(self, settings: &Settings) -> Result<()> {
- match self {
- Self::Get(get) => get.run(settings).await,
- Self::Set(set) => set.run(settings).await,
- Self::Print(print) => print.run(settings).await,
- }
- }
-}
-
-/// Get a configuration value from your config.toml file,
-/// or optionally the effective value after defaults and overrides are applied.
-#[derive(Args, Debug)]
-pub(crate) struct GetCmd {
- /// The configuration key to get
- pub(crate) key: String,
-
- /// Print the value after defaults and overrides are applied
- #[arg(long, short)]
- pub(crate) resolved: bool,
-
- /// Print both the config file value and the resolved value
- #[arg(long, short)]
- pub(crate) verbose: bool,
-}
-
-impl GetCmd {
- pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> {
- let key = self.key.trim();
- if key.is_empty() || key.contains(char::is_whitespace) {
- eyre::bail!("Config key must be non-empty and must not contain whitespace");
- }
-
- if self.verbose {
- println!("Config file:");
- self.print_current_value(key, " ").await?;
- println!("\nResolved:");
- Self::print_effective_value(key, " ");
- return Ok(());
- }
-
- if self.resolved {
- Self::print_effective_value(key, "");
- } else {
- self.print_current_value(key, "").await?;
- }
-
- Ok(())
- }
-
- async fn print_current_value(&self, key: &str, prefix: &str) -> Result<()> {
- let config_file = Settings::get_config_path()?;
- let config_str = tokio::fs::read_to_string(&config_file).await?;
- let doc = config_str.parse::<Document<_>>()?;
-
- let current = get_deep_key(&doc, key);
-
- match current {
- Some(item) if item.is_table() || item.is_inline_table() => {
- let table = item
- .as_table_like()
- .expect("is_table()/is_inline_table() but no table");
- println!("{prefix}[{key}]");
- dump_table(table, prefix, &mut vec![key.to_string()])?;
- }
- Some(item) => {
- let val = item.to_string();
- let val = val.trim().trim_matches('"');
- println!("{prefix}{val}");
- }
- None => {
- println!("{prefix}(not set in config file)");
- }
- }
-
- Ok(())
- }
-
- fn print_effective_value(key: &str, prefix: &str) {
- match Settings::get_config_value(key) {
- Ok(value) => {
- for line in value.lines() {
- println!("{prefix}{line}");
- }
- }
- Err(_) => {
- println!("{prefix}(unknown key)");
- }
- }
- }
-}
-
-#[derive(Args, Debug)]
-pub(crate) struct SetCmd {
- /// The configuration key to set
- pub(crate) key: String,
-
- /// The value to set
- pub(crate) value: String,
-
- /// Store value as an explicit type
- #[arg(long = "type", short, value_enum, default_value_t = ValueType::Auto, value_name = "TYPE")]
- pub(crate) the_type: ValueType,
-}
-
-#[derive(ValueEnum, Debug, Clone, PartialEq, Eq)]
-pub(crate) enum ValueType {
- /// Automatically determine the type of the value
- Auto,
- /// Store value as a string
- String,
- /// Store value as a boolean
- Boolean,
- /// Store value as an integer
- Integer,
- /// Store the value as a float
- Float,
-}
-
-impl SetCmd {
- pub(crate) async fn run(self, _settings: &Settings) -> Result<()> {
- let key = self.key.trim();
- if key.is_empty() || key.contains(char::is_whitespace) {
- eyre::bail!("Config key must be non-empty and must not contain whitespace");
- }
-
- let config_file = Settings::get_config_path()?;
- let config_str = tokio::fs::read_to_string(&config_file).await?;
- let mut doc: DocumentMut = config_str.parse()?;
-
- // When using auto type detection, try to match the existing value's type
- // so we don't accidentally change e.g. "300" (string) to 300 (integer)
- let existing_type = detect_existing_type(&doc, key);
- let value = self.parse_value(existing_type.as_ref())?;
- set_deep_key(&mut doc, key, value)?;
-
- tokio::fs::write(&config_file, doc.to_string()).await?;
-
- Ok(())
- }
-
- fn parse_value(&self, existing_type: Option<&ValueType>) -> Result<Value> {
- let raw = &self.value;
-
- // Explicit --type takes priority, then existing value type, then auto-detect
- let effective_type = if self.the_type != ValueType::Auto {
- &self.the_type
- } else if let Some(existing) = existing_type {
- existing
- } else {
- &ValueType::Auto
- };
-
- match effective_type {
- ValueType::String => Ok(Value::from(raw.as_str())),
- ValueType::Boolean => {
- let b: bool = raw
- .parse()
- .map_err(|_| eyre::eyre!("invalid boolean value: {raw}"))?;
- Ok(Value::from(b))
- }
- ValueType::Integer => {
- let i: i64 = raw
- .parse()
- .map_err(|_| eyre::eyre!("invalid integer value: {raw}"))?;
- Ok(Value::from(i))
- }
- ValueType::Float => {
- let f: f64 = raw
- .parse()
- .map_err(|_| eyre::eyre!("invalid float value: {raw}"))?;
- Ok(Value::from(f))
- }
- ValueType::Auto => {
- if raw == "true" || raw == "false" {
- return Ok(Value::from(raw == "true"));
- }
- if let Ok(i) = raw.parse::<i64>() {
- return Ok(Value::from(i));
- }
- if let Ok(f) = raw.parse::<f64>() {
- return Ok(Value::from(f));
- }
- Ok(Value::from(raw.as_str()))
- }
- }
- }
-}
-
-#[derive(Args, Debug)]
-pub(crate) struct PrintCmd {
- /// Print the value of a specific key and all its children
- pub(crate) key: Option<String>,
-}
-
-impl PrintCmd {
- pub(crate) async fn run(&self, _settings: &Settings) -> Result<()> {
- let config_file = Settings::get_config_path()?;
- let config_str = tokio::fs::read_to_string(&config_file).await?;
- let doc = config_str.parse::<Document<_>>()?;
-
- if let Some(key) = &self.key {
- let current = get_deep_key(&doc, key);
-
- if let Some(current) = current {
- if current.is_table() || current.is_inline_table() {
- println!("[{key}]");
- dump_table(
- current
- .as_table_like()
- .expect("is_table()/is_inline_table() but no table"),
- "",
- &mut vec![key.clone()],
- )?;
- } else {
- println!("{}", current.to_string().trim().trim_matches('"'));
- }
- } else {
- println!("key not found");
- }
- } else {
- dump_table(doc.as_table(), "", &mut Vec::new())?;
- }
-
- Ok(())
- }
-}
-
-fn dump_table(table: &dyn TableLike, prefix: &str, stack: &mut Vec<String>) -> Result<()> {
- for (key, value) in table.iter() {
- if value.is_table() || value.is_inline_table() {
- stack.push(key.to_string());
-
- let table = value
- .as_table_like()
- .expect("is_table()/is_inline_table() but no table");
-
- println!("\n{}[{}]", prefix, stack.join("."));
-
- dump_table(table, prefix, stack)?;
-
- stack.pop();
- } else {
- println!("{prefix}{key} = {value}");
- }
- }
-
- Ok(())
-}
-
-fn get_deep_key<'doc>(doc: &'doc Document<String>, key: &str) -> Option<&'doc Item> {
- let parts = key.split('.');
- let mut current: Option<&Item> = Some(doc.as_item());
-
- for part in parts {
- current = current
- .and_then(|item| item.as_table_like())
- .and_then(|table| table.get(part));
- }
-
- current
-}
-
-/// Detect the TOML type of an existing key in the document, so `set` with auto
-/// type detection preserves the original type rather than guessing from the value string.
-fn detect_existing_type(doc: &DocumentMut, key: &str) -> Option<ValueType> {
- let parts: Vec<&str> = key.split('.').collect();
- let mut current: &dyn TableLike = doc.as_table();
-
- for &part in &parts[..parts.len().saturating_sub(1)] {
- current = current.get(part)?.as_table_like()?;
- }
-
- let last = parts.last()?;
- let v = current.get(last)?.as_value()?;
-
- if v.is_str() {
- Some(ValueType::String)
- } else if v.is_bool() {
- Some(ValueType::Boolean)
- } else if v.is_integer() {
- Some(ValueType::Integer)
- } else if v.is_float() {
- Some(ValueType::Float)
- } else {
- None
- }
-}
-
-fn set_deep_key(doc: &mut DocumentMut, key: &str, value: Value) -> Result<()> {
- let parts: Vec<&str> = key.split('.').collect();
-
- if parts.is_empty() {
- eyre::bail!("empty config key");
- }
-
- let mut current: &mut dyn TableLike = doc.as_table_mut();
-
- // Navigate/create intermediate tables
- for &part in &parts[..parts.len() - 1] {
- if !current.contains_key(part) {
- current.insert(part, Item::Table(Table::new()));
- }
- current = current
- .get_mut(part)
- .expect("just inserted or already exists")
- .as_table_like_mut()
- .ok_or_else(|| eyre::eyre!("'{}' exists but is not a table", part))?;
- }
-
- let last = *parts.last().unwrap();
-
- // Don't silently overwrite a table with a scalar value
- if let Some(existing) = current.get(last)
- && (existing.is_table() || existing.is_inline_table())
- {
- eyre::bail!(
- "'{}' is a table; use a dotted key like '{}.key' to set a value within it",
- key,
- key
- );
- }
-
- current.insert(last, Item::Value(value));
-
- Ok(())
-}
diff --git a/crates/turtle/src/command/client/daemon.rs b/crates/turtle/src/command/client/daemon.rs
deleted file mode 100644
index 39aa1b1e..00000000
--- a/crates/turtle/src/command/client/daemon.rs
+++ /dev/null
@@ -1,583 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::io::{ErrorKind, Write};
-#[cfg(unix)]
-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;
-
-#[derive(clap::Args, Debug)]
-pub(crate) struct Cmd {
- #[command(subcommand)]
- subcmd: SubCmd,
-}
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum SubCmd {
- /// Start the daemon server
- Start {
- #[arg(long, hide = true)]
- daemonize: bool,
-
- /// 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
- Status,
-
- /// 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,
- }
- }
-}
-
-const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
-const DAEMON_PROTOCOL_VERSION: u32 = 1;
-const STARTUP_POLL: Duration = Duration::from_millis(40);
-const LOCK_POLL: Duration = Duration::from_millis(20);
-const LEGACY_DAEMON_RESTART_MESSAGE: &str = "legacy daemon detected; restart daemon manually";
-
-struct PidfileGuard {
- file: File,
-}
-
-impl PidfileGuard {
- fn acquire(path: &Path) -> Result<Self> {
- let mut file = open_lock_file(path)?;
-
- if !file.try_lock_exclusive()? {
- bail!(
- "daemon already running (pidfile lock busy at {})",
- path.display()
- );
- }
-
- file.set_len(0)
- .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?;
- writeln!(file, "{}", std::process::id())
- .and_then(|()| writeln!(file, "{DAEMON_VERSION}"))
- .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?;
-
- Ok(Self { file })
- }
-}
-
-impl Drop for PidfileGuard {
- fn drop(&mut self) {
- drop(self.file.unlock());
- }
-}
-
-enum Probe {
- Ready(HistoryClient),
- NeedsRestart(String),
- Unreachable(eyre::Report),
-}
-
-fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
- version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION
-}
-
-fn daemon_mismatch_message(version: &str, protocol: u32) -> String {
- if protocol == DAEMON_PROTOCOL_VERSION {
- format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}")
- } else {
- format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}")
- }
-}
-
-fn is_legacy_daemon_error(err: &eyre::Report) -> bool {
- matches!(classify_error(err), DaemonClientErrorKind::Unimplemented)
-}
-
-fn open_lock_file(path: &Path) -> Result<File> {
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)
- .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?;
- }
-
- OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .truncate(false)
- .open(path)
- .wrap_err_with(|| format!("could not open lock file {}", path.display()))
-}
-
-async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {
- let file = open_lock_file(path)?;
- let start = Instant::now();
-
- loop {
- match file.try_lock_exclusive() {
- Ok(true) => return Ok(file),
- Ok(false) => {
- if start.elapsed() >= timeout {
- bail!("timed out waiting for lock at {}", path.display());
- }
-
- sleep(LOCK_POLL).await;
- }
- Err(err) => {
- return Err(eyre!("could not lock {}: {err}", path.display()));
- }
- }
- }
-}
-
-async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {
- let file = wait_for_lock(path, timeout).await?;
- file.unlock()
- .wrap_err_with(|| format!("failed to unlock {}", path.display()))?;
- Ok(())
-}
-
-async fn connect_client(settings: &Settings) -> Result<HistoryClient> {
- HistoryClient::new(
- #[cfg(unix)]
- settings.daemon.socket_path.clone(),
- )
- .await
-}
-
-async fn probe(settings: &Settings) -> Probe {
- let mut client = match connect_client(settings).await {
- Ok(client) => client,
- Err(err) => return Probe::Unreachable(err),
- };
-
- match client.status().await {
- Ok(status) => {
- if daemon_matches_expected(&status.version, status.protocol) {
- Probe::Ready(client)
- } else {
- Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))
- }
- }
- Err(err) => Probe::Unreachable(err),
- }
-}
-
-async fn request_shutdown(settings: &Settings) {
- if let Ok(mut client) = connect_client(settings).await {
- drop(client.shutdown().await);
- }
-}
-
-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)
- .await?
- .start_history(history.clone())
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(resp.id);
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-pub(crate) async fn end_history(
- settings: &Settings,
- id: String,
- duration: u64,
- exit: i64,
-) -> Result<()> {
- match async {
- connect_client(settings)
- .await?
- .end_history(id.clone(), duration, exit)
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(());
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-/// Emit a daemon event.
-pub(crate) async fn emit_event(settings: &Settings, event: DaemonEvent) {
- // Try to connect and send
- match ControlClient::from_settings(settings).await {
- Ok(mut client) => {
- if let Err(e) = client.send_event(event).await {
- tracing::debug!(?e, "failed to send event to daemon");
- }
- }
- Err(e) => {
- tracing::debug!(?e, "daemon not available, skipping event emission");
- }
- }
-}
-
-pub(crate) async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
- match probe(settings).await {
- Probe::Ready(client) => Ok(client),
- Probe::NeedsRestart(reason) => {
- bail!("{reason}. Restart the daemon manually");
- }
- Probe::Unreachable(err) if is_legacy_daemon_error(&err) => {
- Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE))
- }
- Probe::Unreachable(err) => Err(err),
- }
-}
-
-async fn status_cmd(settings: &Settings) -> Result<()> {
- match probe(settings).await {
- Probe::Ready(mut client) => {
- let status = client.status().await?;
- println!("Daemon running");
- println!(" PID: {}", status.pid);
- println!(" Version: {}", status.version);
- println!(" Protocol: {}", status.protocol);
- println!(" Healthy: {}", status.healthy);
- #[cfg(unix)]
- println!(" Socket: {}", settings.daemon.socket_path);
- }
- Probe::NeedsRestart(reason) => {
- println!("Daemon running (needs restart)");
- println!(" Reason: {reason}");
- }
- Probe::Unreachable(_) => {
- println!("Daemon is not running");
- }
- }
-
- Ok(())
-}
-
-async fn stop_cmd(settings: &Settings) -> Result<()> {
- let Ok(mut client) = connect_client(settings).await else {
- println!("Daemon is not running");
- return Ok(());
- };
-
- match client.shutdown().await {
- Ok(true) => {
- println!("Shutdown requested");
-
- let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
- let timeout = Duration::from_secs(5);
- match wait_for_pidfile_available(&pidfile_path, timeout).await {
- Ok(()) => println!("Daemon stopped"),
- Err(_) => println!("Daemon may still be shutting down"),
- }
-
- Ok(())
- }
- Ok(false) => bail!("Daemon rejected shutdown request"),
- Err(err) => Err(err.wrap_err("Failed to send shutdown request")),
- }
-}
-
-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);
- }
-
- 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?;
-
- 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::{
- DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, daemon_matches_expected, daemon_mismatch_message,
- };
-
- #[test]
- fn test_version_matches() {
- assert!(daemon_matches_expected(
- DAEMON_VERSION,
- DAEMON_PROTOCOL_VERSION
- ));
- }
-
- #[test]
- fn test_version_mismatch() {
- assert!(!daemon_matches_expected("0.0.0", DAEMON_PROTOCOL_VERSION));
- assert!(!daemon_matches_expected(DAEMON_VERSION, 999));
- assert!(!daemon_matches_expected("0.0.0", 999));
- }
-
- #[test]
- fn test_mismatch_message_version() {
- let msg = daemon_mismatch_message("0.0.0", DAEMON_PROTOCOL_VERSION);
- assert!(msg.contains("out of date"), "got: {msg}");
- assert!(msg.contains("0.0.0"));
- assert!(msg.contains(DAEMON_VERSION));
- }
-
- #[test]
- fn test_mismatch_message_protocol() {
- let msg = daemon_mismatch_message(DAEMON_VERSION, 999);
- assert!(msg.contains("protocol mismatch"), "got: {msg}");
- }
-}
diff --git a/crates/turtle/src/command/client/default_config.rs b/crates/turtle/src/command/client/default_config.rs
deleted file mode 100644
index 4b03c909..00000000
--- a/crates/turtle/src/command/client/default_config.rs
+++ /dev/null
@@ -1,4 +0,0 @@
-pub(crate) fn run() {
- // TODO(@bpeetz): Re-add the default settings option back (Settings::example_config()) <2026-06-11>
- println!("TODO");
-}
diff --git a/crates/turtle/src/command/client/history.rs b/crates/turtle/src/command/client/history.rs
deleted file mode 100644
index 9f383a08..00000000
--- a/crates/turtle/src/command/client/history.rs
+++ /dev/null
@@ -1,1136 +0,0 @@
-use std::{
- fmt::{self, Display},
- io::{self, IsTerminal, Write},
- path::PathBuf,
- time::Duration,
-};
-
-use crate::{
- atuin_common::utils::{self, Escapable as _},
- command::client::daemon,
-};
-use clap::Subcommand;
-use eyre::{Context, Result, bail};
-use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt};
-
-use colored::Colorize;
-use serde::Serialize;
-
-use crate::atuin_daemon::generated::history::{HistoryEventKind, TailHistoryReply};
-
-use crate::atuin_client::{
- database::{ClientSqlite, current_context},
- encryption,
- history::{History, store::HistoryStore},
- record::sqlite_store::SqliteStore,
- settings::{
- FilterMode::{Directory, Global, Session},
- Settings, Timezone,
- },
-};
-
-use log::debug;
-use time::{OffsetDateTime, macros::format_description};
-
-use super::search::format_duration_into;
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Begins a new command in the history
- Start {
- /// Collects the command from the `ATUIN_COMMAND_LINE` environment variable,
- /// which does not need escaping and is more compatible between OS and shells
- #[arg(long = "command-from-env", hide = true)]
- cmd_env: bool,
-
- /// Author of this command, eg `ellie`, `claude`, or `copilot`
- #[arg(long)]
- author: Option<String>,
-
- /// Optional intent/rationale for running this command
- #[arg(long)]
- intent: Option<String>,
-
- command: Vec<String>,
- },
-
- /// Finishes a new command in the history (adds time, exit code)
- End {
- id: String,
-
- #[arg(long, short)]
- exit: i64,
-
- #[arg(long, short)]
- duration: Option<u64>,
- },
-
- /// Stream history events from the daemon as they are received
- Tail,
-
- /// List all items in history
- List {
- #[arg(long, short)]
- cwd: bool,
-
- #[arg(long, short)]
- session: bool,
-
- #[arg(long)]
- human: bool,
-
- /// Show only the text of the command
- #[arg(long)]
- cmd_only: bool,
-
- /// Terminate the output with a null, for better multiline support
- #[arg(long)]
- print0: bool,
-
- #[arg(long, short, default_value = "true")]
- // accept no value
- #[arg(num_args(0..=1), default_missing_value("true"))]
- // accept a value
- #[arg(action = clap::ArgAction::Set)]
- reverse: bool,
-
- /// Display the command time in another timezone other than the configured default.
- ///
- /// This option takes one of the following kinds of values:
- /// - the special value "local" (or "l") which refers to the system time zone
- /// - an offset from UTC (e.g. "+9", "-2:30")
- #[arg(long, visible_alias = "tz")]
- timezone: Option<Timezone>,
-
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {exit}, {time}, {session}, and {uuid}
- /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
- #[arg(long, short)]
- format: Option<String>,
- },
-
- /// Get the last command ran
- Last {
- #[arg(long)]
- human: bool,
-
- /// Show only the text of the command
- #[arg(long)]
- cmd_only: bool,
-
- /// Display the command time in another timezone other than the configured default.
- ///
- /// This option takes one of the following kinds of values:
- /// - the special value "local" (or "l") which refers to the system time zone
- /// - an offset from UTC (e.g. "+9", "-2:30")
- #[arg(long, visible_alias = "tz")]
- timezone: Option<Timezone>,
-
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {author}, {intent}, {time}, {session}, {uuid} and {relativetime}.
- /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
- #[arg(long, short)]
- format: Option<String>,
- },
-
- InitStore,
-
- /// Delete history entries matching the configured exclusion filters
- Prune {
- /// List matching history lines without performing the actual deletion.
- #[arg(short = 'n', long)]
- dry_run: bool,
- },
-
- /// Delete duplicate history entries (that have the same command, cwd and hostname)
- Dedup {
- /// List matching history lines without performing the actual deletion.
- #[arg(short = 'n', long)]
- dry_run: bool,
-
- /// Only delete results added before this date
- #[arg(long, short)]
- before: String,
-
- /// How many recent duplicates to keep
- #[arg(long)]
- dupkeep: u32,
- },
-}
-
-#[derive(Clone, Copy, Debug)]
-pub(crate) enum ListMode {
- Human,
- CmdOnly,
- Regular,
-}
-
-impl ListMode {
- pub(crate) const fn from_flags(human: bool, cmd_only: bool) -> Self {
- if human {
- Self::Human
- } else if cmd_only {
- Self::CmdOnly
- } else {
- Self::Regular
- }
- }
-}
-
-pub(crate) fn print_list(
- h: &[History],
- list_mode: ListMode,
- format: Option<&str>,
- print0: bool,
- reverse: bool,
- tz: Timezone,
-) {
- let w = io::stdout();
- let mut w = w.lock();
-
- let fmt_str = match list_mode {
- ListMode::Human => format
- .unwrap_or("{time} · {duration}\t{command}")
- .replace("\\t", "\t"),
- ListMode::Regular => format
- .unwrap_or("{time}\t{command}\t{duration}")
- .replace("\\t", "\t"),
- // not used
- ListMode::CmdOnly => String::new(),
- };
-
- let parsed_fmt = match list_mode {
- ListMode::Human | ListMode::Regular => parse_fmt(&fmt_str),
- ListMode::CmdOnly => std::iter::once(ParseSegment::Key("command")).collect(),
- };
-
- #[expect(trivial_casts)]
- let iterator = if reverse {
- Box::new(h.iter().rev()) as Box<dyn Iterator<Item = &History>>
- } else {
- Box::new(h.iter()) as Box<dyn Iterator<Item = &History>>
- };
-
- let entry_terminator = if print0 { "\0" } else { "\n" };
- let flush_each_line = print0;
-
- for history in iterator {
- let fh = FmtHistory {
- history,
- cmd_format: CmdFormat::for_output(&w),
- tz: &tz,
- };
- let args = parsed_fmt.with_args(&fh);
-
- // Check for formatting errors before attempting to write
- if let Err(err) = args.status() {
- eprintln!("ERROR: history output failed with: {err}");
- std::process::exit(1);
- }
-
- let write_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
- write!(w, "{args}{entry_terminator}")
- }));
-
- match write_result {
- Ok(Ok(())) => {
- // Write succeeded
- }
- Ok(Err(err)) => {
- if err.kind() != io::ErrorKind::BrokenPipe {
- eprintln!("ERROR: Failed to write history output: {err}");
- std::process::exit(1);
- }
- }
- Err(_) => {
- eprintln!("ERROR: Format string caused a formatting error.");
- eprintln!(
- "This may be due to an unsupported format string containing special characters."
- );
- eprintln!(
- "Please check your format string syntax and ensure literal braces are properly escaped."
- );
- std::process::exit(1);
- }
- }
- if flush_each_line {
- check_for_write_errors(w.flush());
- }
- }
-
- if !flush_each_line {
- check_for_write_errors(w.flush());
- }
-}
-
-fn check_for_write_errors(write: Result<(), io::Error>) {
- if let Err(err) = write {
- // Ignore broken pipe (issue #626)
- if err.kind() != io::ErrorKind::BrokenPipe {
- eprintln!("ERROR: History output failed with the following error: {err}");
- std::process::exit(1);
- }
- }
-}
-
-/// Type wrapper around `History` with formatting settings.
-#[derive(Clone, Copy, Debug)]
-struct FmtHistory<'a> {
- history: &'a History,
- cmd_format: CmdFormat,
- tz: &'a Timezone,
-}
-
-#[derive(Clone, Copy, Debug)]
-enum CmdFormat {
- Literal,
- Escaped,
-}
-impl CmdFormat {
- fn for_output<O: IsTerminal>(out: &O) -> Self {
- if out.is_terminal() {
- Self::Escaped
- } else {
- Self::Literal
- }
- }
-}
-
-static TIME_FMT: &[time::format_description::FormatItem<'static>] =
- format_description!("[year]-[month]-[day] [hour repr:24]:[minute]:[second]");
-
-/// defines how to format the history
-impl FormatKey for FmtHistory<'_> {
- #[expect(clippy::cast_sign_loss)]
- fn fmt(&self, key: &str, f: &mut fmt::Formatter<'_>) -> Result<(), FormatKeyError> {
- match key {
- "command" => match self.cmd_format {
- CmdFormat::Literal => f.write_str(self.history.command.trim()),
- CmdFormat::Escaped => f.write_str(&self.history.command.trim().escape_control()),
- }?,
- "directory" => f.write_str(self.history.cwd.trim())?,
- "exit" => f.write_str(&self.history.exit.to_string())?,
- "duration" => {
- let dur = Duration::from_nanos(std::cmp::max(self.history.duration, 0) as u64);
- format_duration_into(dur, f)?;
- }
- "time" => {
- self.history
- .timestamp
- .to_offset(self.tz.0)
- .format(TIME_FMT)
- .map_err(|_| fmt::Error)?
- .fmt(f)?;
- }
- "relativetime" => {
- let since = OffsetDateTime::now_utc() - self.history.timestamp;
- let d = Duration::try_from(since).unwrap_or_default();
- format_duration_into(d, f)?;
- }
- "host" => f.write_str(
- self.history
- .hostname
- .split_once(':')
- .map_or(&self.history.hostname, |(host, _)| host),
- )?,
- "author" => f.write_str(&self.history.author)?,
- "intent" => f.write_str(self.history.intent.as_deref().unwrap_or_default())?,
- "user" => f.write_str(
- self.history
- .hostname
- .split_once(':')
- .map_or("", |(_, user)| user),
- )?,
- "session" => f.write_str(&self.history.session)?,
- "uuid" => f.write_str(&self.history.id.0)?,
- _ => return Err(FormatKeyError::UnknownKey),
- }
- Ok(())
- }
-}
-
-fn parse_fmt(format: &str) -> ParsedFmt<'_> {
- match ParsedFmt::new(format) {
- Ok(fmt) => fmt,
- Err(err) => {
- eprintln!("ERROR: History formatting failed with the following error: {err}");
-
- if format.contains('"') && (format.contains(":{") || format.contains(",{")) {
- eprintln!("It looks like you're trying to create JSON output.");
- eprintln!("For JSON, you need to escape literal braces by doubling them:");
- eprintln!("Example: '{{\"command\":\"{{command}}\",\"time\":\"{{time}}\"}}'");
- } else {
- eprintln!(
- "If your formatting string contains literal curly braces, you need to escape them by doubling:"
- );
- eprintln!("Use {{{{ for literal {{ and }}}} for literal }}");
- }
- std::process::exit(1)
- }
- }
-}
-
-fn apply_start_metadata(history: &mut History, author: Option<&str>, intent: Option<&str>) {
- if let Some(author) = author.map(str::trim).filter(|author| !author.is_empty()) {
- author.clone_into(&mut history.author);
- }
-
- if let Some(intent) = intent.map(str::trim).filter(|intent| !intent.is_empty()) {
- history.intent = Some(intent.to_owned());
- } else if intent.is_some() {
- history.intent = None;
- }
-}
-
-fn normalize_command_for_storage<'a>(command: &'a str, settings: &Settings) -> &'a str {
- if !settings.strip_trailing_whitespace {
- return command;
- }
-
- let trimmed = command.trim_end_matches([' ', '\t']);
- if trimmed.len() == command.len() {
- return command;
- }
-
- let trailing_backslashes = trimmed
- .as_bytes()
- .iter()
- .rev()
- .take_while(|&&byte| byte == b'\\')
- .count();
-
- if trailing_backslashes % 2 == 1 {
- command
- } else {
- trimmed
- }
-}
-
-async fn handle_daemon_start(
- settings: &Settings,
- command: &str,
- author: Option<&str>,
- intent: Option<&str>,
-) -> Result<Option<String>> {
- // It's better for atuin to silently fail here and attempt to
- // store whatever is ran, than to throw an error to the terminal
- let cwd = utils::get_current_dir();
- let command = normalize_command_for_storage(command, settings);
-
- let mut h: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command(command)
- .cwd(cwd)
- .build()
- .into();
- apply_start_metadata(&mut h, author, intent);
-
- if !h.should_save(settings) {
- return Ok(None);
- }
-
- // Attempt to start history via daemon, but silently ignore errors
- // to avoid breaking the shell when the daemon is unavailable or disk is full
- let resp = match daemon::start_history(settings, h.clone()).await {
- Ok(id) => id,
- Err(e) => {
- debug!("failed to start history via daemon: {e}");
- h.id.0.clone()
- }
- };
-
- Ok(Some(resp))
-}
-
-async fn handle_daemon_end(
- settings: &Settings,
- id: &str,
- exit: i64,
- duration: Option<u64>,
-) -> Result<()> {
- daemon::end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?;
-
- Ok(())
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum TailKind {
- Started,
- Ended,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct TailEvent {
- kind: TailKind,
- history: History,
-}
-
-#[derive(Serialize)]
-struct TailJsonEvent<'a> {
- event: &'static str,
- history: TailJsonHistory<'a>,
-}
-
-#[derive(Serialize)]
-struct TailJsonHistory<'a> {
- id: &'a str,
- timestamp: String,
- timestamp_unix_ns: u64,
- command: &'a str,
- cwd: &'a str,
- session: &'a str,
- hostname: &'a str,
- host: &'a str,
- user: &'a str,
- author: &'a str,
- #[serde(skip_serializing_if = "Option::is_none")]
- intent: Option<&'a str>,
- #[serde(skip_serializing_if = "Option::is_none")]
- exit: Option<i64>,
- #[serde(skip_serializing_if = "Option::is_none")]
- duration_ns: Option<i64>,
- #[serde(skip_serializing_if = "Option::is_none")]
- duration: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- success: Option<bool>,
- #[serde(skip_serializing_if = "Option::is_none")]
- finished_at: Option<String>,
-}
-
-impl TailEvent {
- fn from_proto(reply: TailHistoryReply) -> Result<Self> {
- let history = reply
- .history
- .ok_or_else(|| eyre::eyre!("daemon sent a history tail event without history"))?;
- let timestamp = OffsetDateTime::from_unix_timestamp_nanos(i128::from(history.timestamp))
- .context("invalid daemon history timestamp")?;
- let kind = match HistoryEventKind::try_from(reply.kind)
- .unwrap_or(HistoryEventKind::Unspecified)
- {
- HistoryEventKind::Started => TailKind::Started,
- HistoryEventKind::Ended => TailKind::Ended,
- HistoryEventKind::Unspecified => bail!("daemon sent an unspecified history tail event"),
- };
-
- Ok(Self {
- kind,
- history: History {
- id: history.id.into(),
- timestamp,
- duration: history.duration,
- exit: history.exit,
- command: history.command,
- cwd: history.cwd,
- session: history.session,
- hostname: history.hostname,
- author: history.author,
- intent: normalize_optional_field(&history.intent),
- deleted_at: None,
- },
- })
- }
-
- fn render(&self, tty: bool, tz: Timezone) -> Result<String> {
- if tty {
- Ok(self.render_pretty(tz))
- } else {
- let mut json = self.render_json(tz)?;
- json.push('\n');
- Ok(json)
- }
- }
-
- fn render_json(&self, tz: Timezone) -> Result<String> {
- let payload = TailJsonEvent {
- event: self.kind.as_str(),
- history: TailJsonHistory {
- id: &self.history.id.0,
- timestamp: format_history_time(self.history.timestamp, tz)?,
- timestamp_unix_ns: u64::try_from(self.history.timestamp.unix_timestamp_nanos())
- .context("history timestamp predates unix epoch")?,
- command: &self.history.command,
- cwd: &self.history.cwd,
- session: &self.history.session,
- hostname: &self.history.hostname,
- host: self.host(),
- user: self.user(),
- author: &self.history.author,
- intent: self.history.intent.as_deref(),
- exit: self.exit_value(),
- duration_ns: self.duration_value(),
- duration: self.duration_value().map(format_duration_ns),
- success: self.success_value(),
- finished_at: self
- .finished_at()
- .map(|time| format_history_time(time, tz))
- .transpose()?,
- },
- };
-
- Ok(serde_json::to_string(&payload)?)
- }
-
- fn render_pretty(&self, tz: Timezone) -> String {
- let mut out = String::new();
- let border = match self.kind {
- TailKind::Started => "-".repeat(72).bright_blue().to_string(),
- TailKind::Ended if self.history.exit == 0 => "-".repeat(72).bright_green().to_string(),
- TailKind::Ended => "-".repeat(72).bright_red().to_string(),
- };
-
- out.push_str(&border);
- out.push('\n');
-
- let command = self.history.command.trim();
- let escaped_command = command.escape_control();
- let mut command_lines = escaped_command.lines();
- let header = format!(
- "{} {}",
- self.kind.badge(self.history.exit),
- command_lines.next().unwrap_or_default().bold()
- );
- out.push_str(&header);
- out.push('\n');
-
- for line in command_lines {
- out.push_str(" ");
- out.push_str(line);
- out.push('\n');
- }
-
- push_pretty_field(
- &mut out,
- "start",
- &format_history_time(self.history.timestamp, tz)
- .unwrap_or_else(|_| "invalid".to_owned()),
- );
- push_pretty_field(&mut out, "history", &self.history.id.0);
- push_pretty_field(&mut out, "session", &self.history.session);
- push_pretty_field(&mut out, "exit", &self.exit_display());
- push_pretty_field(&mut out, "duration", &self.duration_display());
-
- out.push('\n');
-
- push_pretty_field(&mut out, "cwd", &self.history.cwd);
- push_pretty_field(&mut out, "hostname", &self.history.hostname);
- push_pretty_field(&mut out, "host", self.host());
- push_pretty_field(&mut out, "user", self.user());
- push_pretty_field(&mut out, "author", &self.history.author);
-
- if let Some(intent) = self.history.intent.as_deref() {
- push_pretty_field(&mut out, "intent", intent);
- }
-
- if let Some(finished) = self.finished_at() {
- let finished =
- format_history_time(finished, tz).unwrap_or_else(|_| "invalid".to_owned());
- push_pretty_field(&mut out, "finished", &finished);
- }
-
- out.push_str(&border);
- out.push_str("\n\n");
- out
- }
-
- fn host(&self) -> &str {
- self.history
- .hostname
- .split_once(':')
- .map_or(self.history.hostname.as_str(), |(host, _)| host)
- }
-
- fn user(&self) -> &str {
- self.history
- .hostname
- .split_once(':')
- .map_or("", |(_, user)| user)
- }
-
- fn exit_value(&self) -> Option<i64> {
- matches!(self.kind, TailKind::Ended).then_some(self.history.exit)
- }
-
- fn duration_value(&self) -> Option<i64> {
- matches!(self.kind, TailKind::Ended).then_some(self.history.duration)
- }
-
- fn success_value(&self) -> Option<bool> {
- matches!(self.kind, TailKind::Ended).then_some(self.history.exit == 0)
- }
-
- fn finished_at(&self) -> Option<OffsetDateTime> {
- self.duration_value()
- .filter(|duration| *duration >= 0)
- .map(time::Duration::nanoseconds)
- .and_then(|duration| self.history.timestamp.checked_add(duration))
- }
-
- fn exit_display(&self) -> String {
- match self.exit_value() {
- Some(0) => "0 (success)".bright_green().to_string(),
- Some(code) => format!("{code} (failure)").bright_red().to_string(),
- None => "pending".bright_yellow().to_string(),
- }
- }
-
- fn duration_display(&self) -> String {
- match self.duration_value() {
- Some(duration) if duration >= 0 => format_duration_ns(duration),
- Some(_) => "unknown".bright_yellow().to_string(),
- None => "running".bright_yellow().to_string(),
- }
- }
-}
-
-impl TailKind {
- const fn as_str(self) -> &'static str {
- match self {
- Self::Started => "started",
- Self::Ended => "ended",
- }
- }
-
- fn badge(self, exit: i64) -> colored::ColoredString {
- match self {
- Self::Started => "STARTED".bold().bright_blue(),
- Self::Ended if exit == 0 => "ENDED".bold().bright_green(),
- Self::Ended => "ENDED".bold().bright_red(),
- }
- }
-}
-
-fn format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> {
- Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?)
-}
-
-fn format_duration_ns(duration_ns: i64) -> String {
- struct F(Duration);
- impl Display for F {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- format_duration_into(self.0, f)
- }
- }
-
- F(Duration::from_nanos(duration_ns.max(0).cast_unsigned())).to_string()
-}
-
-fn push_pretty_field(out: &mut String, label: &str, value: &str) {
- out.push_str(" ");
- let label = format!("{label}:");
- out.push_str(&label.bright_cyan().bold().to_string());
- if label.len() < 10 {
- out.push_str(&" ".repeat(10 - label.len()));
- }
-
- let mut lines = value.lines();
- if let Some(first) = lines.next() {
- out.push_str(first);
- }
- out.push('\n');
-
- for line in lines {
- out.push_str(" ");
- out.push_str(line);
- out.push('\n');
- }
-}
-
-fn normalize_optional_field(value: &str) -> Option<String> {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_owned())
- }
-}
-
-impl Cmd {
- async fn handle_tail(settings: &Settings) -> Result<()> {
- let tty = io::stdout().is_terminal();
- let mut client = daemon::tail_client(settings).await?;
- let mut stream = client.tail_history().await?;
- let stdout = io::stdout();
-
- while let Some(reply) = stream.message().await? {
- let event = TailEvent::from_proto(reply)?;
- let rendered = event.render(tty, settings.timezone)?;
- let mut out = stdout.lock();
-
- match out.write_all(rendered.as_bytes()) {
- Ok(()) => out.flush()?,
- Err(err) if err.kind() == io::ErrorKind::BrokenPipe => break,
- Err(err) => return Err(err.into()),
- }
- }
-
- Ok(())
- }
-
- #[expect(clippy::too_many_arguments)]
- #[expect(clippy::fn_params_excessive_bools)]
- async fn handle_list(
- db: &ClientSqlite,
- settings: &Settings,
- context: crate::atuin_client::database::Context,
- session: bool,
- cwd: bool,
- mode: ListMode,
- format: Option<String>,
- include_deleted: bool,
- print0: bool,
- reverse: bool,
- tz: Timezone,
- ) -> Result<()> {
- let filters = match (session, cwd) {
- (true, true) => [Session, Directory],
- (true, false) => [Session, Global],
- (false, true) => [Global, Directory],
- (false, false) => [
- settings.default_filter_mode(context.git_root.is_some()),
- Global,
- ],
- };
-
- let history = db
- .list(&filters, &context, None, false, include_deleted)
- .await?;
-
- print_list(
- &history,
- mode,
- match format {
- None => Some(settings.history_format.as_str()),
- _ => format.as_deref(),
- },
- print0,
- reverse,
- tz,
- );
-
- Ok(())
- }
-
- async fn handle_prune(
- db: &ClientSqlite,
- settings: &Settings,
- store: SqliteStore,
- context: crate::atuin_client::database::Context,
- dry_run: bool,
- ) -> Result<()> {
- // Grab all executed commands and filter them using History::should_save.
- // We could iterate or paginate here if memory usage becomes an issue.
- let matches: Vec<History> = db
- .list(&[Global], &context, None, false, false)
- .await?
- .into_iter()
- .filter(|h| !h.should_save(settings))
- .collect();
-
- match matches.len() {
- 0 => {
- println!("No entries to prune.");
- return Ok(());
- }
- 1 => println!("Found 1 entry to prune."),
- n => println!("Found {n} entries to prune."),
- }
-
- if dry_run {
- print_list(
- &matches,
- ListMode::Human,
- Some(settings.history_format.as_str()),
- false,
- false,
- settings.timezone,
- );
- } else {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- for entry in matches {
- eprintln!("deleting {}", entry.id);
- let (id, _) = history_store.delete(entry.id.clone()).await?;
- history_store.incremental_build(db, &[id]).await?;
- }
-
- daemon::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryPruned).await;
- }
- Ok(())
- }
-
- async fn handle_dedup(
- db: &ClientSqlite,
- settings: &Settings,
- store: SqliteStore,
- before: i64,
- dupkeep: u32,
- dry_run: bool,
- ) -> Result<()> {
- if dupkeep == 0 {
- eprintln!(
- "\"--dupkeep 0\" would keep 0 copies of duplicate commands and thus delete all of them! Use \"atuin search --delete ...\" if you really want that."
- );
- std::process::exit(1);
- }
-
- let matches: Vec<History> = db.get_dups(before, dupkeep).await?;
-
- match matches.len() {
- 0 => {
- println!("No duplicates to delete.");
- return Ok(());
- }
- 1 => println!("Found 1 duplicate to delete."),
- n => println!("Found {n} duplicates to delete."),
- }
-
- if dry_run {
- print_list(
- &matches,
- ListMode::Human,
- Some(settings.history_format.as_str()),
- false,
- false,
- settings.timezone,
- );
- } else {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- let ids = matches.iter().map(|h| h.id.clone()).collect::<Vec<_>>();
-
- for entry in matches {
- eprintln!("deleting {}", entry.id);
- let (id, _) = history_store.delete(entry.id).await?;
- history_store.incremental_build(db, &[id]).await?;
- }
-
- daemon::emit_event(
- settings,
- crate::atuin_daemon::DaemonEvent::HistoryDeleted { ids },
- )
- .await;
- }
- Ok(())
- }
-
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn run(self, settings: &Settings) -> Result<()> {
- match self {
- Self::Start {
- cmd_env,
- author,
- intent,
- command,
- } => {
- let command = if cmd_env {
- std::env::var("ATUIN_COMMAND_LINE").unwrap_or_default()
- } else {
- command.join(" ")
- };
-
- if let Some(id) =
- handle_daemon_start(settings, &command, author.as_deref(), intent.as_deref())
- .await?
- {
- println!("{id}");
- }
-
- Ok(())
- }
- Self::End { id, exit, duration } => {
- handle_daemon_end(settings, &id, exit, duration).await
- }
- Self::Tail => {
- return Self::handle_tail(settings).await;
- }
- cmd => {
- let context = current_context().await?;
-
- 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 store = SqliteStore::new(record_store_path, settings.local_timeout).await?;
-
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- match cmd {
- Self::List {
- session,
- cwd,
- human,
- cmd_only,
- print0,
- reverse,
- timezone,
- format,
- } => {
- let mode = ListMode::from_flags(human, cmd_only);
- let tz = timezone.unwrap_or(settings.timezone);
- Self::handle_list(
- &db, settings, context, session, cwd, mode, format, false, print0,
- reverse, tz,
- )
- .await
- }
-
- Self::Last {
- human,
- cmd_only,
- timezone,
- format,
- } => {
- let last = db.last().await?;
- let last = last.as_slice();
- let tz = timezone.unwrap_or(settings.timezone);
- print_list(
- last,
- ListMode::from_flags(human, cmd_only),
- match format {
- None => Some(settings.history_format.as_str()),
- _ => format.as_deref(),
- },
- false,
- true,
- tz,
- );
-
- Ok(())
- }
-
- Self::InitStore => history_store.init_store(&db).await,
-
- Self::Prune { dry_run } => {
- Self::handle_prune(&db, settings, store, context, dry_run).await
- }
-
- Self::Dedup {
- dry_run,
- before,
- dupkeep,
- } => {
- let before = i64::try_from(
- interim::parse_date_string(
- before.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )?
- .unix_timestamp_nanos(),
- )?;
- Self::handle_dedup(&db, settings, store, before, dupkeep, dry_run).await
- }
-
- Self::Start { .. } | Self::End { .. } | Self::Tail => unreachable!(),
- }
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use time::macros::datetime;
-
- use super::{
- History, Settings, TailEvent, TailKind, Timezone, normalize_command_for_storage, parse_fmt,
- };
-
- #[test]
- fn normalize_command_strips_trailing_spaces_and_tabs() {
- let settings = Settings::new().unwrap();
-
- assert!(settings.strip_trailing_whitespace);
- assert_eq!(normalize_command_for_storage("ls \t", &settings), "ls");
- }
-
- #[test]
- fn normalize_command_preserves_escaped_trailing_space() {
- let settings = Settings::new().unwrap();
-
- assert_eq!(
- normalize_command_for_storage("printf foo\\ ", &settings),
- "printf foo\\ "
- );
- assert_eq!(
- normalize_command_for_storage("printf foo\\\\ ", &settings),
- "printf foo\\\\"
- );
- }
-
- #[test]
- fn test_format_string_no_panic() {
- // Don't panic but provide helpful output (issue #2776)
- let malformed_json = r#"{"command":"{command}","key":"value"}"#;
-
- let result = std::panic::catch_unwind(|| parse_fmt(malformed_json));
-
- assert!(result.is_ok());
- }
-
- #[test]
- fn test_valid_formats_still_work() {
- assert!(std::panic::catch_unwind(|| parse_fmt("{command}")).is_ok());
- assert!(std::panic::catch_unwind(|| parse_fmt("{time} - {command}")).is_ok());
- }
-
- fn sample_tail_event(kind: TailKind) -> TailEvent {
- TailEvent {
- kind,
- history: History {
- id: "history-id".to_owned().into(),
- timestamp: datetime!(2026-04-09 17:18:19 UTC),
- duration: 12_345_678,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/tmp/repo".to_owned(),
- session: "session-id".to_owned(),
- hostname: "host:ellie".to_owned(),
- author: "claude".to_owned(),
- intent: Some("inspect repository state".to_owned()),
- deleted_at: None,
- },
- }
- }
-
- #[test]
- fn test_tail_json_output_contains_history_fields() {
- let json = sample_tail_event(TailKind::Ended)
- .render(false, Timezone(time::UtcOffset::UTC))
- .unwrap();
- let value: serde_json::Value = serde_json::from_str(&json).unwrap();
-
- assert_eq!(value["event"], "ended");
- assert_eq!(value["history"]["id"], "history-id");
- assert_eq!(value["history"]["duration_ns"], 12_345_678);
- assert_eq!(value["history"]["success"], true);
- assert!(value.get("record").is_none());
- }
-
- #[test]
- fn test_tail_pretty_output_shows_pending_fields_for_started_events() {
- let rendered = sample_tail_event(TailKind::Started)
- .render(true, Timezone(time::UtcOffset::UTC))
- .unwrap();
- let plain = regex::Regex::new(r"\x1b\[[0-9;]*m")
- .unwrap()
- .replace_all(&rendered, "");
-
- assert!(plain.contains("STARTED git status"));
- assert!(plain.contains("exit:"));
- assert!(plain.contains("pending"));
- assert!(plain.contains("duration:"));
- assert!(plain.contains("running"));
- }
-}
diff --git a/crates/turtle/src/command/client/info.rs b/crates/turtle/src/command/client/info.rs
deleted file mode 100644
index 49c92193..00000000
--- a/crates/turtle/src/command/client/info.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use crate::{SHA, VERSION};
-
-use eyre::Result;
-
-pub(crate) fn run(settings: &Settings) -> Result<()> {
- let config = crate::atuin_common::utils::config_dir();
- let mut config_file = config.clone();
- config_file.push("config.toml");
- let mut sever_config = config;
- sever_config.push("server.toml");
-
- let config_paths = format!(
- "Config files:\nclient config: {:?}\nserver config: {:?}\nclient db path: {:?}\nkey path: {:?}\nmeta db path: {:?}",
- config_file.to_string_lossy(),
- sever_config.to_string_lossy(),
- settings.db_path,
- settings.sync.encryption_key()?,
- settings.meta.db_path
- );
-
- let env_vars = format!(
- "Env Vars:\nATUIN_CONFIG_DIR = {:?}",
- std::env::var("ATUIN_CONFIG_DIR").unwrap_or_else(|_| "None".into())
- );
-
- let general_info = format!("Version info:\nversion: {VERSION}\ncommit: {SHA}");
-
- let print_out = format!("{config_paths}\n\n{env_vars}\n\n{general_info}");
-
- println!("{print_out}");
-
- Ok(())
-}
diff --git a/crates/turtle/src/command/client/init.rs b/crates/turtle/src/command/client/init.rs
deleted file mode 100644
index 0db9b143..00000000
--- a/crates/turtle/src/command/client/init.rs
+++ /dev/null
@@ -1,129 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use clap::{Parser, ValueEnum};
-
-mod bash;
-mod fish;
-mod powershell;
-mod xonsh;
-mod zsh;
-
-#[derive(Parser, Debug)]
-pub(crate) struct Cmd {
- shell: Shell,
-
- /// Disable the binding of CTRL-R to atuin
- #[clap(long)]
- disable_ctrl_r: bool,
-
- /// Disable the binding of the Up Arrow key to atuin
- #[clap(long)]
- disable_up_arrow: bool,
-
- /// Disable the binding of ? to Atuin AI
- #[clap(long)]
- disable_ai: bool,
-}
-
-#[derive(Clone, Copy, ValueEnum, Debug)]
-#[value(rename_all = "lower")]
-#[expect(clippy::enum_variant_names)]
-pub(crate) enum Shell {
- /// Zsh setup
- Zsh,
-
- /// Bash setup
- Bash,
-
- /// Fish setup
- Fish,
-
- /// Nu setup
- Nu,
-
- /// Xonsh setup
- Xonsh,
-
- /// PowerShell setup
- PowerShell,
-}
-
-impl Cmd {
- fn init_nu(&self) {
- let full = include_str!("../../shell/atuin.nu");
-
- println!("{full}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- const BIND_CTRL_R: &str = r"$env.config = (
- $env.config | upsert keybindings (
- $env.config.keybindings
- | append {
- name: atuin
- modifier: control
- keycode: char_r
- mode: [emacs, vi_normal, vi_insert]
- event: { send: executehostcommand cmd: (_atuin_search_cmd) }
- }
- )
-)";
- const BIND_UP_ARROW: &str = r"
-$env.config = (
- $env.config | upsert keybindings (
- $env.config.keybindings
- | append {
- name: atuin
- modifier: none
- keycode: up
- mode: [emacs, vi_normal, vi_insert]
- event: {
- until: [
- {send: menuup}
- {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') }
- ]
- }
- }
- )
-)
-";
- if !self.disable_ctrl_r {
- println!("{BIND_CTRL_R}");
- }
- if !self.disable_up_arrow {
- println!("{BIND_UP_ARROW}");
- }
- }
- }
-
- fn static_init(&self) {
- match self.shell {
- Shell::Zsh => {
- zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Bash => {
- bash::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Fish => {
- fish::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::Nu => {
- self.init_nu();
- }
- Shell::Xonsh => {
- xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- Shell::PowerShell => {
- powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r);
- }
- }
- }
-
- pub(crate) fn run(self, settings: &Settings) {
- if !settings.paths_ok() {
- eprintln!(
- "Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose."
- );
- }
-
- self.static_init();
- }
-}
diff --git a/crates/turtle/src/command/client/init/bash.rs b/crates/turtle/src/command/client/init/bash.rs
deleted file mode 100644
index c16663e2..00000000
--- a/crates/turtle/src/command/client/init/bash.rs
+++ /dev/null
@@ -1,13 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.bash");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!("__atuin_bind_ctrl_r={bind_ctrl_r}");
- println!("__atuin_bind_up_arrow={bind_up_arrow}");
- println!("{base}");
-}
diff --git a/crates/turtle/src/command/client/init/fish.rs b/crates/turtle/src/command/client/init/fish.rs
deleted file mode 100644
index 0a992b9c..00000000
--- a/crates/turtle/src/command/client/init/fish.rs
+++ /dev/null
@@ -1,74 +0,0 @@
-fn print_bindings(
- indent: &str,
- disable_up_arrow: bool,
- disable_ctrl_r: bool,
- bind_ctrl_r: &str,
- bind_up_arrow: &str,
- bind_ctrl_r_ins: &str,
- bind_up_arrow_ins: &str,
-) {
- if !disable_ctrl_r {
- println!("{indent}{bind_ctrl_r}");
- }
- if !disable_up_arrow {
- println!("{indent}{bind_up_arrow}");
- }
-
- println!("{indent}if bind -M insert >/dev/null 2>&1");
- if !disable_ctrl_r {
- println!("{indent}{indent}{bind_ctrl_r_ins}");
- }
- if !disable_up_arrow {
- println!("{indent}{indent}{bind_up_arrow_ins}");
- }
- println!("{indent}end");
-}
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let indent = " ".repeat(4);
-
- let base = include_str!("../../../shell/atuin.fish");
-
- println!("{base}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- println!("if string match -q '4.*' $version");
-
- // In fish 4.0 and above the option bind -k doesn't exist anymore,
- // instead we can use key names and modifiers directly.
- print_bindings(
- &indent,
- disable_up_arrow,
- disable_ctrl_r,
- "bind ctrl-r _atuin_search",
- "bind up _atuin_bind_up",
- "bind -M insert ctrl-r _atuin_search",
- "bind -M insert up _atuin_bind_up",
- );
-
- println!("else");
-
- // We keep these for compatibility with fish 3.x
- print_bindings(
- &indent,
- disable_up_arrow,
- disable_ctrl_r,
- r"bind \cr _atuin_search",
- &[
- r"bind -k up _atuin_bind_up",
- r"bind \eOA _atuin_bind_up",
- r"bind \e\[A _atuin_bind_up",
- ]
- .join("; "),
- r"bind -M insert \cr _atuin_search",
- &[
- r"bind -M insert -k up _atuin_bind_up",
- r"bind -M insert \eOA _atuin_bind_up",
- r"bind -M insert \e\[A _atuin_bind_up",
- ]
- .join("; "),
- );
-
- println!("end");
- }
-}
diff --git a/crates/turtle/src/command/client/init/powershell.rs b/crates/turtle/src/command/client/init/powershell.rs
deleted file mode 100644
index 94d89c67..00000000
--- a/crates/turtle/src/command/client/init/powershell.rs
+++ /dev/null
@@ -1,20 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.ps1");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!("{base}");
- println!(
- "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}",
- ps_bool(bind_ctrl_r),
- ps_bool(bind_up_arrow)
- );
-}
-
-fn ps_bool(value: bool) -> &'static str {
- if value { "$true" } else { "$false" }
-}
diff --git a/crates/turtle/src/command/client/init/xonsh.rs b/crates/turtle/src/command/client/init/xonsh.rs
deleted file mode 100644
index 25f867f7..00000000
--- a/crates/turtle/src/command/client/init/xonsh.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.xsh");
-
- let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() {
- (false, false)
- } else {
- (!disable_ctrl_r, !disable_up_arrow)
- };
-
- println!(
- "_ATUIN_BIND_CTRL_R={}",
- if bind_ctrl_r { "True" } else { "False" }
- );
- println!(
- "_ATUIN_BIND_UP_ARROW={}",
- if bind_up_arrow { "True" } else { "False" }
- );
- println!("{base}");
-}
diff --git a/crates/turtle/src/command/client/init/zsh.rs b/crates/turtle/src/command/client/init/zsh.rs
deleted file mode 100644
index 96a817d0..00000000
--- a/crates/turtle/src/command/client/init/zsh.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
- let base = include_str!("../../../shell/atuin.zsh");
-
- println!("{base}");
-
- if std::env::var("ATUIN_NOBIND").is_err() {
- const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search
-bindkey -M viins '^r' atuin-search-viins
-bindkey -M vicmd '/' atuin-search";
-
- const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search
-bindkey -M vicmd '^[[A' atuin-up-search-vicmd
-bindkey -M viins '^[[A' atuin-up-search-viins
-bindkey -M emacs '^[OA' atuin-up-search
-bindkey -M vicmd '^[OA' atuin-up-search-vicmd
-bindkey -M viins '^[OA' atuin-up-search-viins
-bindkey -M vicmd 'k' atuin-up-search-vicmd";
-
- if !disable_ctrl_r {
- println!("{BIND_CTRL_R}");
- }
- if !disable_up_arrow {
- println!("{BIND_UP_ARROW}");
- }
- }
-}
diff --git a/crates/turtle/src/command/client/search.rs b/crates/turtle/src/command/client/search.rs
deleted file mode 100644
index 359864cb..00000000
--- a/crates/turtle/src/command/client/search.rs
+++ /dev/null
@@ -1,366 +0,0 @@
-use std::fs::File;
-use std::io::{IsTerminal as _, Write, stderr, stdout};
-
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_common::utils::{self, Escapable as _};
-use clap::Parser;
-use eyre::Result;
-
-use crate::atuin_client::{
- database::{OptFilters, current_context},
- encryption,
- history::{History, store::HistoryStore},
- record::sqlite_store::SqliteStore,
- settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone},
-};
-
-use super::history::ListMode;
-
-mod cursor;
-mod duration;
-mod engines;
-mod history_list;
-mod inspector;
-mod interactive;
-pub(crate) mod keybindings;
-
-pub(crate) use duration::format_duration_into;
-
-#[expect(clippy::struct_excessive_bools, clippy::struct_field_names)]
-#[derive(Parser, Debug)]
-pub(crate) struct Cmd {
- /// Filter search result by directory
- #[arg(long, short)]
- cwd: Option<String>,
-
- /// Exclude directory from results
- #[arg(long = "exclude-cwd")]
- exclude_cwd: Option<String>,
-
- /// Filter search result by exit code
- #[arg(long, short)]
- exit: Option<i64>,
-
- /// Exclude results with this exit code
- #[arg(long = "exclude-exit")]
- exclude_exit: Option<i64>,
-
- /// Only include results added before this date
- #[arg(long, short)]
- before: Option<String>,
-
- /// Only include results after this date
- #[arg(long)]
- after: Option<String>,
-
- /// How many entries to return at most
- #[arg(long)]
- limit: Option<i64>,
-
- /// Offset from the start of the results
- #[arg(long)]
- offset: Option<i64>,
-
- /// Open interactive search UI
- #[arg(long, short)]
- interactive: bool,
-
- /// Allow overriding filter mode over config
- #[arg(long = "filter-mode")]
- filter_mode: Option<FilterMode>,
-
- /// Allow overriding search mode over config
- #[arg(long = "search-mode")]
- search_mode: Option<SearchMode>,
-
- /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion)
- #[arg(long = "shell-up-key-binding", hide = true)]
- shell_up_key_binding: bool,
-
- /// Notify the keymap at the shell's side
- #[arg(long = "keymap-mode", default_value = "auto")]
- keymap_mode: KeymapMode,
-
- /// Use human-readable formatting for time
- #[arg(long)]
- human: bool,
-
- #[arg(allow_hyphen_values = true)]
- query: Option<Vec<String>>,
-
- /// Show only the text of the command
- #[arg(long)]
- cmd_only: bool,
-
- /// Terminate the output with a null, for better multiline handling
- #[arg(long)]
- print0: bool,
-
- /// Delete anything matching this query. Will not print out the match
- #[arg(long)]
- delete: bool,
-
- /// Delete EVERYTHING!
- #[arg(long)]
- delete_it_all: bool,
-
- /// Reverse the order of results, oldest first
- #[arg(long, short)]
- reverse: bool,
-
- /// Display the command time in another timezone other than the configured default.
- ///
- /// This option takes one of the following kinds of values:
- /// - the special value "local" (or "l") which refers to the system time zone
- /// - an offset from UTC (e.g. "+9", "-2:30")
- #[arg(long, visible_alias = "tz")]
- #[arg(allow_hyphen_values = true)]
- // Clippy warns about `Option<Option<T>>`, but we suppress it because we need
- // this distinction for proper argument handling.
- #[expect(clippy::option_option)]
- timezone: Option<Option<Timezone>>,
-
- /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and
- /// {relativetime}.
- /// Example: --format "{time} - [{duration}] - {directory}$\t{command}"
- #[arg(long, short)]
- format: Option<String>,
-
- /// Set the maximum number of lines Atuin's interface should take up.
- #[arg(long = "inline-height")]
- inline_height: Option<u16>,
-
- /// Filter by author. Supports $all-user (non-agents), $all-agent, or literal names.
- /// Can be specified multiple times.
- #[arg(long)]
- author: Option<Vec<String>>,
-
- /// Include duplicate commands in the output (non-interactive only)
- #[arg(long)]
- include_duplicates: bool,
-
- /// File name to write the result to (hidden from help as this is meant to be used from a script)
- #[arg(long = "result-file", hide = true)]
- result_file: Option<String>,
-}
-
-impl Cmd {
- /// Returns true if this search command will run in interactive (TUI) mode
- pub(crate) fn is_interactive(&self) -> bool {
- self.interactive
- }
-
- // clippy: please write this instead
- // clippy: now it has too many lines
- // me: I'll do it later OKAY
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn run(
- self,
- db: ClientSqlite,
- settings: &mut Settings,
- store: SqliteStore,
- ) -> Result<()> {
- let query = self.query.unwrap_or_else(|| {
- std::env::var("ATUIN_QUERY").map_or_else(
- |_| vec![],
- |query| query.split(' ').map(ToString::to_string).collect(),
- )
- });
-
- if (self.delete_it_all || self.delete) && self.limit.is_some() {
- // Because of how deletion is implemented, it will always delete all matches
- // and disregard the limit option. It is also not clear what deletion with a
- // limit would even mean. Deleting the LIMIT most recent entries that match
- // the search query would make sense, but that wouldn't match what's displayed
- // when running the equivalent search, but deleting those entries that are
- // displayed with the search would leave any duplicates of those lines which may
- // or may not have been intended to be deleted.
- eprintln!("\"--limit\" is not compatible with deletion.");
- return Ok(());
- }
-
- if self.delete && query.is_empty() {
- eprintln!(
- "Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all"
- );
- return Ok(());
- }
-
- if self.delete_it_all && !query.is_empty() {
- eprintln!(
- "--delete-it-all will delete ALL of your history! It does not require a query."
- );
- return Ok(());
- }
-
- if let Some(search_mode) = self.search_mode {
- settings.search_mode = search_mode;
- }
- if let Some(filter_mode) = self.filter_mode {
- settings.filter_mode = Some(filter_mode);
- }
- if let Some(inline_height) = self.inline_height {
- settings.inline_height = inline_height;
- }
-
- settings.shell_up_key_binding = self.shell_up_key_binding;
-
- // `keymap_mode` specified in config.toml overrides the `--keymap-mode`
- // option specified in the keybindings.
- settings.keymap_mode = match settings.keymap_mode {
- KeymapMode::Auto => self.keymap_mode,
- value => value,
- };
- settings.keymap_mode_shell = self.keymap_mode;
-
- let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- if self.interactive {
- let item = interactive::history(&query, settings, db, &history_store).await?;
-
- if let Some(result_file) = self.result_file {
- let mut file = File::create(result_file)?;
- write!(file, "{item}")?;
- } else if !stdout().is_terminal() {
- // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i)
- // Write to stdout so it gets captured. This requires some care on Windows, as the current
- // console code page or `[Console]::OutputEncoding` on PowerShell may be different from UTF-8.
- println!("{item}");
- } else if stderr().is_terminal() {
- eprintln!("{}", item.escape_control());
- } else {
- eprintln!("{item}");
- }
- } else {
- let opt_filter = OptFilters {
- exit: self.exit,
- exclude_exit: self.exclude_exit,
- cwd: self.cwd,
- exclude_cwd: self.exclude_cwd,
- before: self.before,
- after: self.after,
- limit: self.limit,
- offset: self.offset,
- reverse: self.reverse,
- include_duplicates: self.include_duplicates,
- };
-
- let mut entries =
- run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
-
- if entries.is_empty() {
- std::process::exit(1)
- }
-
- // if we aren't deleting, print it all
- if self.delete || self.delete_it_all {
- // delete it
- // it only took me _years_ to add this
- // sorry
- while !entries.is_empty() {
- for entry in &entries {
- eprintln!("deleting {}", entry.id);
- }
-
- let ids = history_store.delete_entries(entries).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- entries =
- run_non_interactive(settings, opt_filter.clone(), &query, &db).await?;
- }
- } else {
- let format = match self.format {
- None => Some(settings.history_format.as_str()),
- _ => self.format.as_deref(),
- };
- let tz = match self.timezone {
- Some(Some(tz)) => tz, // User provided a value
- Some(None) | None => settings.timezone, // No value was provided
- };
-
- super::history::print_list(
- &entries,
- ListMode::from_flags(self.human, self.cmd_only),
- format,
- self.print0,
- true,
- tz,
- );
- }
- }
- Ok(())
- }
-}
-
-// This is supposed to more-or-less mirror the command line version, so ofc
-// it is going to have a lot of args
-async fn run_non_interactive(
- settings: &Settings,
- filter_options: OptFilters,
- query: &[String],
- db: &ClientSqlite,
-) -> Result<Vec<History>> {
- let dir = if filter_options.cwd.as_deref() == Some(".") {
- Some(utils::get_current_dir())
- } else {
- filter_options.cwd
- };
-
- let context = current_context().await?;
-
- let opt_filter = OptFilters {
- cwd: dir.clone(),
- ..filter_options
- };
-
- let filter_mode = settings.default_filter_mode(context.git_root.is_some());
-
- let results = db
- .search(
- settings.search_mode,
- filter_mode,
- &context,
- query.join(" ").as_str(),
- opt_filter,
- )
- .await?;
-
- Ok(results)
-}
-
-#[cfg(test)]
-mod tests {
- use super::Cmd;
- use clap::Parser;
-
- #[test]
- fn search_for_triple_dash() {
- // Issue #3028: searching for `---` should not be treated as a CLI flag
- let cmd = Cmd::try_parse_from(["search", "---"]);
- assert!(cmd.is_ok(), "Failed to parse '---' as a query: {cmd:?}");
- let cmd = cmd.unwrap();
- assert_eq!(cmd.query, Some(vec!["---".to_string()]));
- }
-
- #[test]
- fn search_for_double_dash_value() {
- // Searching for strings starting with -- should also work
- let cmd = Cmd::try_parse_from(["search", "--", "--foo"]);
- assert!(cmd.is_ok());
- let cmd = cmd.unwrap();
- assert_eq!(cmd.query, Some(vec!["--foo".to_string()]));
- }
-
- #[test]
- fn search_author_cli_flag() {
- let cmd =
- Cmd::try_parse_from(["search", "--author", "codex", "--author", "ellie"]).unwrap();
- assert_eq!(
- cmd.author,
- Some(vec!["codex".to_string(), "ellie".to_string()])
- );
- }
-}
diff --git a/crates/turtle/src/command/client/search/cursor.rs b/crates/turtle/src/command/client/search/cursor.rs
deleted file mode 100644
index e13e52b3..00000000
--- a/crates/turtle/src/command/client/search/cursor.rs
+++ /dev/null
@@ -1,404 +0,0 @@
-use crate::atuin_client::settings::WordJumpMode;
-
-pub(crate) struct Cursor {
- source: String,
- index: usize,
-}
-
-impl From<String> for Cursor {
- fn from(source: String) -> Self {
- Self { source, index: 0 }
- }
-}
-
-pub(crate) struct WordJumper<'a> {
- word_chars: &'a str,
- word_jump_mode: WordJumpMode,
-}
-
-impl WordJumper<'_> {
- fn is_word_boundary(&self, c: char, next_c: char) -> bool {
- (c.is_whitespace() && !next_c.is_whitespace())
- || (!c.is_whitespace() && next_c.is_whitespace())
- || (self.word_chars.contains(c) && !self.word_chars.contains(next_c))
- || (!self.word_chars.contains(c) && self.word_chars.contains(next_c))
- }
-
- fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (index + 1..source.len().saturating_sub(1))
- .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(source.len());
- (index + 1..source.len().saturating_sub(1))
- .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(source.len())
- }
-
- fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (1..index)
- .rev()
- .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap()))
- .unwrap_or(0);
- (1..index)
- .rev()
- .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap()))
- .map_or(0, |i| i + 1)
- }
-
- fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (index..source.len().saturating_sub(1)).find(|&i| {
- self.is_word_boundary(
- source.chars().nth(i).unwrap(),
- source.chars().nth(i + 1).unwrap(),
- )
- });
- if index.is_none() {
- return source.len();
- }
- (index.unwrap() + 1..source.len())
- .find(|&i| !source.chars().nth(i).unwrap().is_whitespace())
- .unwrap_or(source.len())
- }
-
- fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- let index = (1..index)
- .rev()
- .find(|&i| !source.chars().nth(i).unwrap().is_whitespace());
- if index.is_none() {
- return 0;
- }
- (1..index.unwrap())
- .rev()
- .find(|&i| {
- self.is_word_boundary(
- source.chars().nth(i - 1).unwrap(),
- source.chars().nth(i).unwrap(),
- )
- })
- .unwrap_or(0)
- }
-
- fn get_next_word_pos(&self, source: &str, index: usize) -> usize {
- match self.word_jump_mode {
- WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index),
- WordJumpMode::Subl => self.subl_get_next_word_pos(source, index),
- }
- }
-
- fn get_prev_word_pos(&self, source: &str, index: usize) -> usize {
- match self.word_jump_mode {
- WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index),
- WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index),
- }
- }
-}
-
-impl Cursor {
- pub(crate) fn as_str(&self) -> &str {
- self.source.as_str()
- }
-
- pub(crate) fn into_inner(self) -> String {
- self.source
- }
-
- /// Returns the string before the cursor
- pub(crate) fn substring(&self) -> &str {
- &self.source[..self.index]
- }
-
- /// Returns the currently selected [`char`]
- pub(crate) fn char(&self) -> Option<char> {
- self.source[self.index..].chars().next()
- }
-
- pub(crate) fn right(&mut self) {
- if self.index < self.source.len() {
- loop {
- self.index += 1;
- if self.source.is_char_boundary(self.index) {
- break;
- }
- }
- }
- }
-
- pub(crate) fn left(&mut self) -> bool {
- if self.index > 0 {
- loop {
- self.index -= 1;
- if self.source.is_char_boundary(self.index) {
- break true;
- }
- }
- } else {
- false
- }
- }
-
- pub(crate) fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- self.index = word_jumper.get_next_word_pos(&self.source, self.index);
- }
-
- pub(crate) fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- self.index = word_jumper.get_prev_word_pos(&self.source, self.index);
- }
-
- /// Move cursor to the end of the current/next word (vim `e` motion).
- ///
- /// If cursor is in the middle of a word, moves to the end of that word.
- /// If cursor is at the end of a word (or on whitespace), moves to the
- /// end of the next word.
- pub(crate) fn word_end(&mut self, word_chars: &str) {
- let len = self.source.len();
- if self.index >= len {
- return;
- }
-
- let chars: Vec<char> = self.source.chars().collect();
- let mut char_idx = self.source[..self.index].chars().count();
-
- if char_idx >= chars.len() {
- return;
- }
-
- let current = chars[char_idx];
-
- // Check if we're at a word boundary (end of current word or on whitespace)
- let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || {
- let next = chars[char_idx + 1];
- next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next))
- };
-
- // If at word boundary, advance past it and skip whitespace to find next word
- if at_word_boundary {
- char_idx += 1;
- while char_idx < chars.len() && chars[char_idx].is_whitespace() {
- char_idx += 1;
- }
- }
-
- // If we've gone past end, go to end of string
- if char_idx >= chars.len() {
- self.index = len;
- return;
- }
-
- // Find end of word: advance until next char is whitespace or different word type
- let in_word_chars = word_chars.contains(chars[char_idx]);
- while char_idx < chars.len() {
- let next_idx = char_idx + 1;
- if next_idx >= chars.len() {
- // At last char, move past it
- char_idx = next_idx;
- break;
- }
- let next_c = chars[next_idx];
- if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) {
- // Next char is start of new word/whitespace, so current char is end
- char_idx = next_idx;
- break;
- }
- char_idx += 1;
- }
-
- // Convert char index back to byte index
- self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum();
- }
-
- pub(crate) fn insert(&mut self, c: char) {
- self.source.insert(self.index, c);
- self.index += c.len_utf8();
- }
-
- pub(crate) fn remove(&mut self) -> Option<char> {
- if self.index < self.source.len() {
- Some(self.source.remove(self.index))
- } else {
- None
- }
- }
-
- pub(crate) fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- let next_index = word_jumper.get_next_word_pos(&self.source, self.index);
- self.source.replace_range(self.index..next_index, "");
- }
-
- pub(crate) fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) {
- let word_jumper = WordJumper {
- word_chars,
- word_jump_mode,
- };
- let next_index = word_jumper.get_prev_word_pos(&self.source, self.index);
- self.source.replace_range(next_index..self.index, "");
- self.index = next_index;
- }
-
- pub(crate) fn back(&mut self) -> Option<char> {
- if self.left() { self.remove() } else { None }
- }
-
- pub(crate) fn clear(&mut self) {
- self.source.clear();
- self.index = 0;
- }
-
- pub(crate) fn clear_to_start(&mut self) {
- self.source.replace_range(..self.index, "");
- self.index = 0;
- }
-
- pub(crate) fn clear_to_end(&mut self) {
- self.source.replace_range(self.index.., "");
- self.index = self.source.len();
- }
-
- pub(crate) fn end(&mut self) {
- self.index = self.source.len();
- }
-
- pub(crate) fn start(&mut self) {
- self.index = 0;
- }
-
- pub(crate) fn position(&self) -> usize {
- self.index
- }
-}
-
-#[cfg(test)]
-mod cursor_tests {
- use super::{Cursor, WordJumpMode, WordJumper};
-
- static EMACS_WORD_JUMPER: WordJumper<'_> = WordJumper {
- word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
- word_jump_mode: WordJumpMode::Emacs,
- };
-
- static SUBL_WORD_JUMPER: WordJumper<'_> = WordJumper {
- word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?",
- word_jump_mode: WordJumpMode::Subl,
- };
-
- #[test]
- fn right() {
- // ö is 2 bytes
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];
- for i in indices {
- assert_eq!(c.index, i);
- c.right();
- }
- }
-
- #[test]
- fn left() {
- // ö is 2 bytes
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- c.end();
- let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];
- for i in indices {
- assert_eq!(c.index, i);
- c.left();
- }
- }
-
- #[test]
- fn test_emacs_get_next_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(0, 6), (3, 6), (7, 18), (19, 30)];
- for (i_src, i_dest) in indices {
- assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_emacs_get_prev_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(30, 15), (29, 15), (15, 3), (3, 0)];
- for (i_src, i_dest) in indices {
- assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_subl_get_next_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)];
- for (i_src, i_dest) in indices {
- assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0);
- }
-
- #[test]
- fn test_subl_get_prev_word_pos() {
- let s = String::from(" aaa ((()))bbb ((())) ");
- let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)];
- for (i_src, i_dest) in indices {
- assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest);
- }
- assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0);
- }
-
- #[test]
- fn pop() {
- let mut s = String::from("öaöböcödöeöfö");
- let mut c = Cursor::from(s.clone());
- c.end();
- while !s.is_empty() {
- let c1 = s.pop();
- let c2 = c.back();
- assert_eq!(c1, c2);
- assert_eq!(s.as_str(), c.substring());
- }
- let c1 = s.pop();
- let c2 = c.back();
- assert_eq!(c1, c2);
- }
-
- #[test]
- fn back() {
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- // move to ^
- for _ in 0..4 {
- c.right();
- }
- assert_eq!(c.substring(), "öaöb");
- assert_eq!(c.back(), Some('b'));
- assert_eq!(c.back(), Some('ö'));
- assert_eq!(c.back(), Some('a'));
- assert_eq!(c.back(), Some('ö'));
- assert_eq!(c.back(), None);
- assert_eq!(c.as_str(), "öcödöeöfö");
- }
-
- #[test]
- fn insert() {
- let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
- // move to ^
- for _ in 0..4 {
- c.right();
- }
- assert_eq!(c.substring(), "öaöb");
- c.insert('ö');
- c.insert('g');
- c.insert('ö');
- c.insert('h');
- assert_eq!(c.substring(), "öaöbögöh");
- assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö");
- }
-}
diff --git a/crates/turtle/src/command/client/search/duration.rs b/crates/turtle/src/command/client/search/duration.rs
deleted file mode 100644
index 0d70353a..00000000
--- a/crates/turtle/src/command/client/search/duration.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-use core::fmt;
-use std::{ops::ControlFlow, time::Duration};
-
-pub(crate) fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> {
- if value > 0 {
- ControlFlow::Break((unit, value))
- } else {
- ControlFlow::Continue(())
- }
- }
-
- // impl taken and modified from
- // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331
- // Copyright (c) 2016 The humantime Developers
- fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> {
- let secs = f.as_secs();
- let nanos = f.subsec_nanos();
-
- let years = secs / 31_557_600; // 365.25d
- let year_days = secs % 31_557_600;
- let months = year_days / 2_630_016; // 30.44d
- let month_days = year_days % 2_630_016;
- let days = month_days / 86400;
- let day_secs = month_days % 86400;
- let hours = day_secs / 3600;
- let minutes = day_secs % 3600 / 60;
- let seconds = day_secs % 60;
-
- let millis = nanos / 1_000_000;
- let micros = nanos / 1_000;
-
- // a difference from our impl than the original is that
- // we only care about the most-significant segment of the duration.
- // If the item call returns `Break`, then the `?` will early-return.
- // This allows for a very consise impl
- item("y", years)?;
- item("mo", months)?;
- item("d", days)?;
- item("h", hours)?;
- item("m", minutes)?;
- item("s", seconds)?;
- item("ms", u64::from(millis))?;
- item("us", u64::from(micros))?;
- item("ns", u64::from(nanos))?;
- ControlFlow::Continue(())
- }
-
- match fmt(dur) {
- ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"),
- ControlFlow::Continue(()) => write!(f, "0s"),
- }
-}
-
-pub(crate) fn format_duration(f: Duration) -> String {
- struct F(Duration);
- impl fmt::Display for F {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- format_duration_into(self.0, f)
- }
- }
- F(f).to_string()
-}
diff --git a/crates/turtle/src/command/client/search/engines.rs b/crates/turtle/src/command/client/search/engines.rs
deleted file mode 100644
index 9fbff278..00000000
--- a/crates/turtle/src/command/client/search/engines.rs
+++ /dev/null
@@ -1,86 +0,0 @@
-use crate::atuin_client::{
- database::{ClientSqlite, Context, OptFilters},
- history::{History, HistoryId},
- settings::{FilterMode, SearchMode, Settings},
-};
-use async_trait::async_trait;
-use eyre::Result;
-
-use super::cursor::Cursor;
-
-pub(crate) mod daemon;
-pub(crate) mod db;
-pub(crate) mod skim;
-
-pub(crate) fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> {
- match search_mode {
- SearchMode::Skim => Box::new(skim::Search::new()),
- SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)),
- mode => Box::new(db::Search(mode)),
- }
-}
-
-pub(crate) struct SearchState {
- pub(crate) input: Cursor,
- pub(crate) filter_mode: FilterMode,
- pub(crate) context: Context,
- pub(crate) custom_context: Option<HistoryId>,
-}
-
-impl SearchState {
- pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) {
- let mut i = settings
- .search
- .filters
- .iter()
- .position(|&m| m == self.filter_mode)
- .unwrap_or_default();
- for _ in 0..settings.search.filters.len() {
- i = (i.wrapping_add_signed(offset)) % settings.search.filters.len();
- let mode = settings.search.filters[i];
- if self.filter_mode_available(mode, settings) {
- self.filter_mode = mode;
- break;
- }
- }
- }
-
- fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool {
- match mode {
- FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(),
- FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(),
- _ => true,
- }
- }
-}
-
-#[async_trait]
-pub(crate) trait SearchEngine: Send + Sync + 'static {
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>>;
-
- async fn query(&mut self, state: &SearchState, db: &mut ClientSqlite) -> Result<Vec<History>> {
- if state.input.as_str().is_empty() {
- Ok(db
- .search(
- SearchMode::FullText,
- state.filter_mode,
- &state.context,
- "",
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await?
- .into_iter()
- .collect::<Vec<_>>())
- } else {
- self.full_query(state, db).await
- }
- }
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>;
-}
diff --git a/crates/turtle/src/command/client/search/engines/daemon.rs b/crates/turtle/src/command/client/search/engines/daemon.rs
deleted file mode 100644
index ee92ebaf..00000000
--- a/crates/turtle/src/command/client/search/engines/daemon.rs
+++ /dev/null
@@ -1,213 +0,0 @@
-use crate::atuin_client::{
- database::{ClientSqlite, OptFilters},
- history::History,
- settings::{SearchMode, Settings},
-};
-use crate::atuin_daemon::client::SearchClient;
-use async_trait::async_trait;
-use atuin_nucleo_matcher::{
- Config, Matcher, Utf32Str,
- pattern::{CaseMatching, Normalization, Pattern},
-};
-use eyre::Result;
-use tracing::{Level, debug, instrument, span};
-use uuid::Uuid;
-
-use super::{SearchEngine, SearchState};
-
-pub(crate) struct Search {
- client: Option<SearchClient>,
- query_id: u64,
- #[cfg(unix)]
- socket_path: String,
-}
-
-impl Search {
- pub(crate) fn new(settings: &Settings) -> Self {
- Self {
- client: None,
- query_id: 0,
- #[cfg(unix)]
- socket_path: settings.daemon.socket_path.clone(),
- }
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")]
- async fn get_client(&mut self) -> Result<&mut SearchClient> {
- if self.client.is_none() {
- self.connect().await?;
- }
- Ok(self.client.as_mut().unwrap())
- }
-
- async fn connect(&mut self) -> Result<()> {
- #[cfg(unix)]
- let client = SearchClient::new(self.socket_path.clone()).await?;
-
- self.client = Some(client);
- Ok(())
- }
-
- fn next_query_id(&mut self) -> u64 {
- self.query_id += 1;
- self.query_id
- }
-
- /// Check if query contains regex pattern (r/.../)
- /// Nucleo doesn't support regex, so we fall back to database search
- fn contains_regex_pattern(query: &str) -> bool {
- query.starts_with("r/") || query.contains(" r/")
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")]
- async fn fallback_to_db_search(
- &self,
- state: &SearchState,
- db: &ClientSqlite,
- ) -> Result<Vec<History>> {
- let results = db
- .search(
- SearchMode::FullText,
- state.filter_mode,
- &state.context,
- state.input.as_str(),
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await
- .map_or(Vec::new(), |r| r.into_iter().collect());
- Ok(results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))]
- async fn hydrate_from_db(&self, db: &ClientSqlite, ids: &[String]) -> Result<Vec<History>> {
- let placeholders: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
- let sql_query = format!(
- "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC",
- placeholders.join(",")
- );
- Ok(db.query_history(&sql_query).await?)
- }
-}
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- let query = state.input.as_str().to_string();
-
- // Fall back to database for regex queries (Nucleo doesn't support regex)
- if Self::contains_regex_pattern(&query) {
- debug!(query = %query, "[daemon-client] regex detected, falling back to db");
- return self.fallback_to_db_search(state, db).await;
- }
-
- let query_id = self.next_query_id();
-
- let span =
- span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id);
-
- // Try to connect and search; if it fails with a retriable error,
- // auto-start the daemon and retry once.
- let first_attempt = async {
- let client = self.get_client().await?;
- client
- .search(
- query.clone(),
- query_id,
- state.filter_mode,
- Some(state.context.clone()),
- )
- .await
- }
- .await;
-
- let mut stream = match first_attempt {
- Ok(stream) => stream,
- Err(err) => return Err(err),
- };
-
- let mut ids = Vec::with_capacity(200);
- span!(Level::TRACE, "daemon_search.resp")
- .in_scope(async || {
- while let Ok(Some(response)) = stream.message().await {
- let span2 = span!(
- Level::TRACE,
- "daemon_search.resp.item",
- query_id = response.query_id
- );
- let _span2 = span2.enter();
- // Only process if the query_id matches (prevents stale responses)
- if response.query_id == query_id {
- let uuids = response
- .ids
- .iter()
- .map(|id| {
- let bytes: [u8; 16] =
- id.as_slice().try_into().expect("id should be 16 bytes");
- Uuid::from_bytes(bytes).as_simple().to_string()
- })
- .collect::<Vec<_>>();
- ids.extend(uuids);
- }
- drop(_span2);
- drop(span2);
- }
- })
- .await;
- drop(span);
-
- if ids.is_empty() {
- debug!(query = %query, results = 0, "[daemon-client] empty results");
- return Ok(Vec::new());
- }
-
- // // Hydrate from local database
- let results = self.hydrate_from_db(db, &ids).await?;
-
- // // Reorder results to match the order from the daemon (which is ranked by relevance)
- let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| {
- let mut ordered_results = Vec::with_capacity(results.len());
- for id in &ids {
- if let Some(history) = results.iter().find(|h| h.id.0 == *id) {
- ordered_results.push(history.clone());
- }
- }
- ordered_results
- });
-
- debug!(
- query = %query,
- results = results.len(),
- "[daemon-client]"
- );
-
- Ok(ordered_results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- // Use fulltext highlighting for regex queries
- if Self::contains_regex_pattern(search_input) {
- return super::db::get_highlight_indices_fulltext(command, search_input);
- }
-
- let mut matcher = Matcher::new(Config::DEFAULT);
- let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart);
-
- let mut indices: Vec<u32> = Vec::new();
- let mut haystack_buf = Vec::new();
-
- let haystack = Utf32Str::new(command, &mut haystack_buf);
- pattern.indices(haystack, &mut matcher, &mut indices);
-
- // Convert u32 indices to usize
- indices.into_iter().map(|i| i as usize).collect()
- }
-}
diff --git a/crates/turtle/src/command/client/search/engines/db.rs b/crates/turtle/src/command/client/search/engines/db.rs
deleted file mode 100644
index 0eb86878..00000000
--- a/crates/turtle/src/command/client/search/engines/db.rs
+++ /dev/null
@@ -1,107 +0,0 @@
-use super::{SearchEngine, SearchState};
-use crate::atuin_client::{
- database::{ClientSqlite, OptFilters, QueryToken, QueryTokenizer},
- history::History,
- settings::SearchMode,
-};
-use async_trait::async_trait;
-use eyre::Result;
-use norm::Metric;
-use norm::fzf::{FzfParser, FzfV2};
-use std::ops::Range;
-use tracing::{Level, instrument};
-
-pub(crate) struct Search(pub(crate) SearchMode);
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- let results = db
- .search(
- self.0,
- state.filter_mode,
- &state.context,
- state.input.as_str(),
- OptFilters {
- limit: Some(200),
- ..Default::default()
- },
- )
- .await
- // ignore errors as it may be caused by incomplete regex
- .map_or(Vec::new(), |r| r.into_iter().collect());
- Ok(results)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- if self.0 == SearchMode::Prefix {
- return vec![];
- } else if self.0 == SearchMode::FullText {
- return get_highlight_indices_fulltext(command, search_input);
- }
- let mut fzf = FzfV2::new();
- let mut parser = FzfParser::new();
- let query = parser.parse(search_input);
- let mut ranges: Vec<Range<usize>> = Vec::new();
- fzf.distance_and_ranges(query, command, &mut ranges);
-
- // convert ranges to all indices
- ranges.into_iter().flatten().collect()
- }
-}
-
-#[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")]
-pub(crate) fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> {
- let mut ranges = vec![];
- let lower_command = command.to_ascii_lowercase();
-
- for token in QueryTokenizer::new(search_input) {
- let matchee = if token.has_uppercase() {
- command
- } else {
- &lower_command
- };
-
- if token.is_inverse() {
- continue;
- }
-
- match token {
- QueryToken::Or => {}
- QueryToken::Regex(r) => {
- if let Ok(re) = regex::Regex::new(r) {
- for m in re.find_iter(command) {
- ranges.push(m.range());
- }
- }
- }
- QueryToken::MatchStart(term, _) => {
- if matchee.starts_with(term) {
- ranges.push(0..term.len());
- }
- }
- QueryToken::MatchEnd(term, _) => {
- if matchee.ends_with(term) {
- let l = matchee.len();
- ranges.push((l - term.len())..l);
- }
- }
- QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => {
- for (idx, m) in matchee.match_indices(term) {
- ranges.push(idx..(idx + m.len()));
- }
- }
- }
- }
-
- let mut ret: Vec<_> = ranges.into_iter().flatten().collect();
- ret.sort_unstable();
- ret.dedup();
- ret
-}
diff --git a/crates/turtle/src/command/client/search/engines/skim.rs b/crates/turtle/src/command/client/search/engines/skim.rs
deleted file mode 100644
index e090e40d..00000000
--- a/crates/turtle/src/command/client/search/engines/skim.rs
+++ /dev/null
@@ -1,222 +0,0 @@
-use std::path::Path;
-
-use crate::atuin_client::{database::ClientSqlite, history::History, settings::FilterMode};
-use async_trait::async_trait;
-use eyre::Result;
-use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
-use itertools::Itertools;
-use time::OffsetDateTime;
-use tokio::task::yield_now;
-use tracing::{Level, instrument, warn};
-
-use super::{SearchEngine, SearchState};
-
-pub(crate) struct Search {
- all_history: Vec<(History, i32)>,
- engine: SkimMatcherV2,
-}
-
-impl Search {
- pub(crate) fn new() -> Self {
- Self {
- all_history: vec![],
- engine: SkimMatcherV2::default(),
- }
- }
-}
-
-#[async_trait]
-impl SearchEngine for Search {
- #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))]
- async fn full_query(
- &mut self,
- state: &SearchState,
- db: &mut ClientSqlite,
- ) -> Result<Vec<History>> {
- if self.all_history.is_empty() {
- self.all_history = load_all_history(db).await;
- }
-
- Ok(fuzzy_search(&self.engine, state, &self.all_history).await)
- }
-
- #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")]
- fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> {
- let (_, indices) = self
- .engine
- .fuzzy_indices(command, search_input)
- .unwrap_or_default();
- indices
- }
-}
-
-#[instrument(skip_all, level = Level::TRACE, name = "load_all_history")]
-async fn load_all_history(db: &ClientSqlite) -> Vec<(History, i32)> {
- db.all_with_count().await.unwrap()
-}
-
-#[expect(clippy::too_many_lines)]
-#[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))]
-async fn fuzzy_search(
- engine: &SkimMatcherV2,
- state: &SearchState,
- all_history: &[(History, i32)],
-) -> Vec<History> {
- let mut set = Vec::with_capacity(200);
- let mut ranks = Vec::with_capacity(200);
- let query = state.input.as_str();
- let now = OffsetDateTime::now_utc();
-
- for (i, (history, count)) in all_history.iter().enumerate() {
- if i % 256 == 0 {
- yield_now().await;
- }
-
- let context = &state.context;
- let git_root = context
- .git_root
- .as_ref()
- .and_then(|git_root| git_root.to_str())
- .unwrap_or(&context.cwd);
- match state.filter_mode {
- FilterMode::Global => {}
- // we aggregate host by ',' separating them
- FilterMode::Host
- if history
- .hostname
- .split(',')
- .contains(&context.hostname.as_str()) => {}
- // we aggregate session by concattenating them.
- // sessions are 32 byte simple uuid formats
- FilterMode::Session
- if history
- .session
- .as_bytes()
- .chunks(32)
- .contains(&context.session.as_bytes()) => {}
- // SessionPreload: include current session + global history from before session start
- FilterMode::SessionPreload => {
- let is_current_session = {
- history
- .session
- .as_bytes()
- .chunks(32)
- .any(|chunk| chunk == context.session.as_bytes())
- };
-
- if !is_current_session {
- let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else {
- warn!("failed to parse session id '{}'", context.session);
- continue;
- };
- let Some(timestamp) = uuid.get_timestamp() else {
- warn!(
- "failed to get timestamp from uuid '{}'",
- uuid.as_hyphenated()
- );
- continue;
- };
- let (seconds, nanos) = timestamp.to_unix();
- let Ok(session_start) = OffsetDateTime::from_unix_timestamp_nanos(
- i128::from(seconds) * 1_000_000_000 + i128::from(nanos),
- ) else {
- warn!(
- "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}"
- );
- continue;
- };
-
- if history.timestamp >= session_start {
- continue;
- }
- }
- }
- // we aggregate directory by ':' separating them
- FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {}
- FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {}
- _ => continue,
- }
- #[expect(clippy::cast_lossless, clippy::cast_precision_loss)]
- if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) {
- let begin = indices.first().copied().unwrap_or_default();
-
- let mut duration = (now - history.timestamp).as_seconds_f64().log2();
- if !duration.is_finite() || duration <= 1.0 {
- duration = 1.0;
- }
- // these + X.0 just make the log result a bit smoother.
- // log is very spiky towards 1-4, but I want a gradual decay.
- // eg:
- // log2(4) = 2, log2(5) = 2.3 (16% increase)
- // log2(8) = 3, log2(9) = 3.16 (5% increase)
- // log2(16) = 4, log2(17) = 4.08 (2% increase)
- let count = (*count as f64 + 8.0).log2();
- let begin = (begin as f64 + 16.0).log2();
- let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref());
- let path = (path as f64 + 8.0).log2();
-
- // reduce longer durations, raise higher counts, raise matches close to the start
- let score = (-score as f64) * count / path / duration / begin;
-
- 'insert: {
- // algorithm:
- // 1. find either the position that this command ranks
- // 2. find the same command positioned better than our rank.
- for i in 0..set.len() {
- // do we out score the current position?
- if ranks[i] > score {
- ranks.insert(i, score);
- set.insert(i, history.clone());
- let mut j = i + 1;
- while j < set.len() {
- // remove duplicates that have a worse score
- if set[j].command == history.command {
- ranks.remove(j);
- set.remove(j);
-
- // break this while loop because there won't be any other
- // duplicates.
- break;
- }
- j += 1;
- }
-
- // keep it limited
- if ranks.len() > 200 {
- ranks.pop();
- set.pop();
- }
-
- break 'insert;
- }
- // don't continue if this command has a better score already
- if set[i].command == history.command {
- break 'insert;
- }
- }
-
- if set.len() < 200 {
- ranks.push(score);
- set.push(history.clone());
- }
- }
- }
- }
-
- set
-}
-
-fn path_dist(a: &Path, b: &Path) -> usize {
- let mut a: Vec<_> = a.components().collect();
- let b: Vec<_> = b.components().collect();
-
- let mut dist = 0;
-
- // pop a until there's a common ancestor
- while !b.starts_with(&a) {
- dist += 1;
- a.pop();
- }
-
- b.len() - a.len() + dist
-}
diff --git a/crates/turtle/src/command/client/search/history_list.rs b/crates/turtle/src/command/client/search/history_list.rs
deleted file mode 100644
index e46f37b7..00000000
--- a/crates/turtle/src/command/client/search/history_list.rs
+++ /dev/null
@@ -1,431 +0,0 @@
-use std::time::Duration;
-
-use super::duration::format_duration;
-use super::engines::SearchEngine;
-use crate::atuin_client::{
- history::History,
- settings::{UiColumn, UiColumnType},
- theme::{
- style_alerterror, style_alertinfo, style_alertwarn, style_annotation, style_base,
- style_guidance,
- },
-};
-use crate::atuin_common::utils::Escapable as _;
-use itertools::Itertools;
-use ratatui::{
- backend::FromCrossterm,
- buffer::Buffer,
- crossterm::style,
- layout::Rect,
- style::{Modifier, Style},
- widgets::{Block, StatefulWidget, Widget},
-};
-use time::OffsetDateTime;
-
-pub(crate) struct HistoryHighlighter<'a> {
- pub(crate) engine: &'a dyn SearchEngine,
- pub(crate) search_input: &'a str,
-}
-
-impl HistoryHighlighter<'_> {
- pub(crate) fn get_highlight_indices(&self, command: &str) -> Vec<usize> {
- self.engine
- .get_highlight_indices(command, self.search_input)
- }
-}
-
-pub(crate) struct HistoryList<'a> {
- history: &'a [History],
- block: Option<Block<'a>>,
- inverted: bool,
- /// Apply an alternative highlighting to the selected row
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- /// Columns to display (in order, after the indicator)
- columns: &'a [UiColumn],
-}
-
-#[derive(Default)]
-pub(crate) struct ListState {
- offset: usize,
- selected: usize,
- max_entries: usize,
-}
-
-impl ListState {
- pub(crate) fn selected(&self) -> usize {
- self.selected
- }
-
- pub(crate) fn max_entries(&self) -> usize {
- self.max_entries
- }
-
- pub(crate) fn offset(&self) -> usize {
- self.offset
- }
-
- pub(crate) fn select(&mut self, index: usize) {
- self.selected = index;
- }
-}
-
-impl StatefulWidget for HistoryList<'_> {
- type State = ListState;
-
- fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
- let list_area = self.block.take().map_or(area, |b| {
- let inner_area = b.inner(area);
- b.render(area, buf);
- inner_area
- });
-
- if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() {
- return;
- }
- let list_height = list_area.height as usize;
-
- let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height);
- state.offset = start;
- state.max_entries = end - start;
-
- let mut s = DrawState {
- buf,
- list_area,
- x: 0,
- y: 0,
- state,
- inverted: self.inverted,
- alternate_highlight: self.alternate_highlight,
- now: &self.now,
- indicator: self.indicator,
-
- history_highlighter: self.history_highlighter,
- show_numeric_shortcuts: self.show_numeric_shortcuts,
- columns: self.columns,
- };
-
- for item in self.history.iter().skip(state.offset).take(end - start) {
- s.render_row(item);
-
- // reset line
- s.y += 1;
- s.x = 0;
- }
- }
-}
-
-impl<'a> HistoryList<'a> {
- #[expect(clippy::too_many_arguments)]
- pub(crate) fn new(
- history: &'a [History],
- inverted: bool,
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
- ) -> Self {
- Self {
- history,
- block: None,
- inverted,
- alternate_highlight,
- now,
- indicator,
- history_highlighter,
- show_numeric_shortcuts,
- columns,
- }
- }
-
- pub(crate) fn block(mut self, block: Block<'a>) -> Self {
- self.block = Some(block);
- self
- }
-
- fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) {
- let offset = offset.min(self.history.len().saturating_sub(1));
-
- let max_scroll_space = height.min(10).min(self.history.len() - selected);
- if offset + height < selected + max_scroll_space {
- let end = selected + max_scroll_space;
- (end - height, end)
- } else if selected < offset {
- (selected, selected + height)
- } else {
- (offset, offset + height)
- }
- }
-}
-
-struct DrawState<'a> {
- buf: &'a mut Buffer,
- list_area: Rect,
- x: u16,
- y: u16,
- state: &'a ListState,
- inverted: bool,
- alternate_highlight: bool,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
-}
-
-// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
-// Yes, this is a hack, but it makes me feel happy
-static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 ";
-
-impl DrawState<'_> {
- /// Render a complete row for a history item based on configured columns.
- fn render_row(&mut self, h: &History) {
- // Always render the indicator first (width 3)
- self.index();
-
- // Calculate the width for the expanding column
- // Fixed columns use their configured width + 1 (trailing space)
- let indicator_width: u16 = 3;
- let fixed_width: u16 = self
- .columns
- .iter()
- .filter(|c| !c.expand)
- .map(|c| c.width + 1)
- .sum();
- let expand_width = self
- .list_area
- .width
- .saturating_sub(indicator_width + fixed_width);
-
- let style = style_base();
- // Render each configured column
- for (idx, column) in self.columns.iter().enumerate() {
- if idx != 0 {
- self.draw(" ", Style::from_crossterm(style));
- }
- let width = if column.expand {
- expand_width
- } else {
- column.width
- };
- match column.column_type {
- UiColumnType::Duration => self.duration(h, width),
- UiColumnType::Time => self.time(h, width),
- UiColumnType::Datetime => self.datetime(h, width),
- UiColumnType::Directory => self.directory(h, width),
- UiColumnType::Host => self.host(h, width),
- UiColumnType::User => self.user(h, width),
- UiColumnType::Exit => self.exit_code(h, width),
- UiColumnType::Command => self.command(h),
- }
- }
- }
-
- fn index(&mut self) {
- if !self.show_numeric_shortcuts {
- let i = self.y as usize + self.state.offset;
- let is_selected = i == self.state.selected();
- let prompt: &str = if is_selected { self.indicator } else { " " };
- self.draw(prompt, Style::default());
- return;
- }
-
- // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form.
- // Yes, this is a hack, but it makes me feel happy
-
- let i = self.y as usize + self.state.offset;
- let i = i.checked_sub(self.state.selected);
- let i = i.unwrap_or(10).min(10) * 2;
- let prompt: &str = if i == 0 {
- self.indicator
- } else {
- &SLICES[i..i + 3]
- };
- self.draw(prompt, Style::default());
- }
-
- fn duration(&mut self, h: &History, width: u16) {
- let style = if h.success() {
- style_alertinfo()
- } else {
- style_alerterror()
- };
- let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0));
- let formatted = format_duration(duration);
- let w = width as usize;
- // Right-align duration within its column width, plus trailing space
- let display = format!("{formatted:>w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn time(&mut self, h: &History, width: u16) {
- let style = style_guidance();
-
- // Account for the chance that h.timestamp is "in the future"
- // This would mean that "since" is negative, and the unwrap here
- // would fail.
- // If the timestamp would otherwise be in the future, display
- // the time since as 0.
- let since = (self.now)() - h.timestamp;
- let time = format_duration(since.try_into().unwrap_or_default());
-
- // Format as "Xs ago" right-aligned within column width
- let w = width as usize;
- let time_str = format!("{time} ago");
-
- let display = format!("{time_str:>w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn command(&mut self, h: &History) {
- let mut style = style_base();
- let mut row_highlighted = false;
- if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
- {
- row_highlighted = true;
- // if not applying alternative highlighting to the whole row, color the command
- style = style_alerterror();
- style.attributes.set(style::Attribute::Bold);
- }
-
- let highlight_indices = self.history_highlighter.get_highlight_indices(
- h.command
- .escape_control()
- .split_ascii_whitespace()
- .join(" ")
- .as_str(),
- );
-
- let mut pos = 0;
- for section in h.command.escape_control().split_ascii_whitespace() {
- if pos != 0 {
- self.draw(" ", Style::from_crossterm(style));
- }
- for ch in section.chars() {
- if self.x > self.list_area.width {
- // Avoid attempting to draw a command section beyond the width
- // of the list
- return;
- }
- let mut style = style;
- if highlight_indices.contains(&pos) {
- if row_highlighted {
- // if the row is highlighted bold is not enough as the whole row is bold
- // change the color too
- style = style_alertwarn();
- }
- style.attributes.set(style::Attribute::Bold);
- }
- let s = ch.to_string();
- self.draw(&s, Style::from_crossterm(style));
- pos += s.len();
- }
- pos += 1;
- }
- }
-
- /// Render the absolute datetime column (e.g., "2025-01-22 14:35")
- fn datetime(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- // Format: YYYY-MM-DD HH:MM
- let formatted = h
- .timestamp
- .format(
- &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]")
- .expect("valid format"),
- )
- .unwrap_or_else(|_| "????-??-?? ??:??".to_string());
- let w = width as usize;
- let display = format!("{formatted:w$}");
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the directory column (working directory, truncated)
- fn directory(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- let cwd = &h.cwd;
- let char_count = cwd.chars().count();
- // Truncate from the left with "..." if too long, plus trailing space
- // Use character count for comparison and skip for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect();
- format!("...{truncated}")
- } else {
- format!("{cwd:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the host column (just the hostname)
- fn host(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- // Database stores hostname as "hostname:username"
- let host = h.hostname.split(':').next().unwrap_or(&h.hostname);
- let char_count = host.chars().count();
- // Use character count for comparison and take for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = host.chars().take(w.saturating_sub(4)).collect();
- format!("{truncated}...")
- } else {
- format!("{host:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the user column
- fn user(&mut self, h: &History, width: u16) {
- let style = style_annotation();
- let w = width as usize;
- // Database stores hostname as "hostname:username"
- let user = h.hostname.split(':').nth(1).unwrap_or("");
- let char_count = user.chars().count();
- // Use character count for comparison and take for UTF-8 safety
- let display = if char_count > w && w >= 4 {
- let truncated: String = user.chars().take(w.saturating_sub(4)).collect();
- format!("{truncated}...")
- } else {
- format!("{user:w$}")
- };
- self.draw(&display, Style::from_crossterm(style));
- }
-
- /// Render the exit code column
- fn exit_code(&mut self, h: &History, width: u16) {
- let style = if h.success() {
- style_alertinfo()
- } else {
- style_alerterror()
- };
- let w = width as usize;
- let display = format!("{:>w$}", h.exit);
- self.draw(&display, Style::from_crossterm(style));
- }
-
- fn draw(&mut self, s: &str, mut style: Style) {
- let cx = self.list_area.left() + self.x;
-
- let cy = if self.inverted {
- self.list_area.top() + self.y
- } else {
- self.list_area.bottom() - self.y - 1
- };
-
- if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected)
- {
- style = style.add_modifier(Modifier::REVERSED);
- }
-
- let w = (self.list_area.width - self.x) as usize;
- self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx;
- }
-}
diff --git a/crates/turtle/src/command/client/search/inspector.rs b/crates/turtle/src/command/client/search/inspector.rs
deleted file mode 100644
index 186dcd3a..00000000
--- a/crates/turtle/src/command/client/search/inspector.rs
+++ /dev/null
@@ -1,414 +0,0 @@
-use std::time::Duration;
-use time::macros::format_description;
-
-use crate::atuin_client::{
- history::{History, HistoryStats},
- settings::{Settings, Timezone},
- theme::{style_annotation, style_base, style_important},
-};
-use ratatui::{
- Frame,
- backend::FromCrossterm,
- layout::Rect,
- prelude::{Constraint, Direction, Layout},
- style::Style,
- text::{Span, Text},
- widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
-};
-
-use super::duration::format_duration;
-
-use super::interactive::{Compactness, to_compactness};
-
-#[expect(clippy::cast_sign_loss)]
-fn u64_or_zero(num: i64) -> u64 {
- if num < 0 { 0 } else { num as u64 }
-}
-
-pub(crate) fn draw_commands(
- f: &mut Frame<'_>,
- parent: Rect,
- history: &History,
- stats: &HistoryStats,
- compact: bool,
-) {
- let commands = Layout::default()
- .direction(if compact {
- Direction::Vertical
- } else {
- Direction::Horizontal
- })
- .constraints(if compact {
- [
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ]
- } else {
- [
- Constraint::Ratio(1, 4),
- Constraint::Ratio(1, 2),
- Constraint::Ratio(1, 4),
- ]
- })
- .split(parent);
-
- let command = Paragraph::new(Text::from(Span::styled(
- history.command.clone(),
- Style::from_crossterm(style_important()),
- )))
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_base()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_base()))
- .title("Command")
- .padding(Padding::horizontal(1))
- });
-
- let previous = Paragraph::new(
- stats
- .previous
- .clone()
- .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
- )
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_annotation()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_annotation()))
- .title("Previous command")
- .padding(Padding::horizontal(1))
- });
-
- // Add [] around blank text, as when this is shown in a list
- // compacted, it makes it more obviously control text.
- let next = Paragraph::new(
- stats
- .next
- .clone()
- .map_or_else(|| "[No next command]".to_string(), |next| next.command),
- )
- .block(if compact {
- Block::new()
- .borders(Borders::NONE)
- .style(Style::from_crossterm(style_annotation()))
- } else {
- Block::new()
- .borders(Borders::ALL)
- .title("Next command")
- .padding(Padding::horizontal(1))
- .style(Style::from_crossterm(style_annotation()))
- });
-
- f.render_widget(previous, commands[0]);
- f.render_widget(command, commands[1]);
- f.render_widget(next, commands[2]);
-}
-
-pub(crate) fn draw_stats_table(
- f: &mut Frame<'_>,
- parent: Rect,
- history: &History,
- tz: Timezone,
- stats: &HistoryStats,
-) {
- let duration = Duration::from_nanos(u64_or_zero(history.duration));
- let avg_duration = Duration::from_nanos(stats.average_duration);
- let (host, user) = history.hostname.split_once(':').unwrap_or(("", ""));
-
- let rows = [
- Row::new(vec!["Host".to_string(), host.to_string()]),
- Row::new(vec!["User".to_string(), user.to_string()]),
- Row::new(vec![
- "Time".to_string(),
- history.timestamp.to_offset(tz.0).to_string(),
- ]),
- Row::new(vec!["Duration".to_string(), format_duration(duration)]),
- Row::new(vec![
- "Avg duration".to_string(),
- format_duration(avg_duration),
- ]),
- Row::new(vec!["Exit".to_string(), history.exit.to_string()]),
- Row::new(vec!["Directory".to_string(), history.cwd.clone()]),
- Row::new(vec!["Session".to_string(), history.session.clone()]),
- Row::new(vec!["Total runs".to_string(), stats.total.to_string()]),
- ];
-
- let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)];
-
- let table = Table::new(rows, widths).column_spacing(1).block(
- Block::default()
- .title("Command stats")
- .borders(Borders::ALL)
- .style(Style::from_crossterm(style_base()))
- .padding(Padding::vertical(1)),
- );
-
- f.render_widget(table, parent);
-}
-
-fn num_to_day(num: &str) -> String {
- match num {
- "0" => "Sunday".to_string(),
- "1" => "Monday".to_string(),
- "2" => "Tuesday".to_string(),
- "3" => "Wednesday".to_string(),
- "4" => "Thursday".to_string(),
- "5" => "Friday".to_string(),
- "6" => "Saturday".to_string(),
- _ => "Invalid day".to_string(),
- }
-}
-
-fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> {
- let format = format_description!("[day]-[month]-[year]");
- let output = format_description!("[month]/[year repr:last_two]");
-
- let mut durations: Vec<(time::Date, i64)> = durations
- .iter()
- .map(|d| {
- (
- time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"),
- d.1,
- )
- })
- .collect();
-
- durations.sort_by_key(|a| a.0);
-
- durations
- .iter()
- .map(|(date, duration)| {
- (
- date.format(output).expect("failed to format sqlite date"),
- *duration,
- )
- })
- .collect()
-}
-
-fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) {
- let exits: Vec<Bar<'_>> = stats
- .exits
- .iter()
- .map(|(exit, count)| {
- Bar::default()
- .label(exit.to_string())
- .value(u64_or_zero(*count))
- })
- .collect();
-
- let exits = BarChart::default()
- .block(
- Block::default()
- .title("Exit code distribution")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(3)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&exits));
-
- let day_of_week: Vec<Bar<'_>> = stats
- .day_of_week
- .iter()
- .map(|(day, count)| {
- Bar::default()
- .label(num_to_day(day.as_str()))
- .value(u64_or_zero(*count))
- })
- .collect();
-
- let day_of_week = BarChart::default()
- .block(
- Block::default()
- .title("Runs per day")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(3)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&day_of_week));
-
- let duration_over_time = sort_duration_over_time(&stats.duration_over_time);
- let duration_over_time: Vec<Bar<'_>> = duration_over_time
- .iter()
- .map(|(date, duration)| {
- let d = Duration::from_nanos(u64_or_zero(*duration));
- Bar::default()
- .label(date.clone())
- .value(u64_or_zero(*duration))
- .text_value(format_duration(d))
- })
- .collect();
-
- let duration_over_time = BarChart::default()
- .block(
- Block::default()
- .title("Duration over time")
- .style(Style::from_crossterm(style_base()))
- .borders(Borders::ALL),
- )
- .bar_width(5)
- .bar_gap(1)
- .bar_style(Style::default())
- .value_style(Style::default())
- .label_style(Style::default())
- .data(BarGroup::default().bars(&duration_over_time));
-
- let layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([
- Constraint::Ratio(1, 3),
- Constraint::Ratio(1, 3),
- Constraint::Ratio(1, 3),
- ])
- .split(parent);
-
- f.render_widget(exits, layout[0]);
- f.render_widget(day_of_week, layout[1]);
- f.render_widget(duration_over_time, layout[2]);
-}
-
-pub(crate) fn draw(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
- settings: &Settings,
- tz: Timezone,
-) {
- let compactness = to_compactness(f, settings);
-
- match compactness {
- Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats),
- _ => draw_full(f, chunk, history, stats, tz),
- }
-}
-
-pub(crate) fn draw_ultracompact(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
-) {
- draw_commands(f, chunk, history, stats, true);
-}
-
-pub(crate) fn draw_full(
- f: &mut Frame<'_>,
- chunk: Rect,
- history: &History,
- stats: &HistoryStats,
- tz: Timezone,
-) {
- let vert_layout = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)])
- .split(chunk);
-
- let stats_layout = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
- .split(vert_layout[1]);
-
- draw_commands(f, vert_layout[0], history, stats, false);
- draw_stats_table(f, stats_layout[0], history, tz, stats);
- draw_stats_charts(f, stats_layout[1], stats);
-}
-
-#[cfg(test)]
-mod tests {
- use super::draw_ultracompact;
- use crate::atuin_client::history::{History, HistoryId, HistoryStats};
- use ratatui::{
- backend::TestBackend,
- prelude::{Line, Rect, Terminal},
- };
- use time::OffsetDateTime;
-
- fn mock_history_stats() -> (History, HistoryStats) {
- let history = History {
- id: HistoryId::from("test1".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 3,
- exit: 0,
- command: "/bin/cmd".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let next = History {
- id: HistoryId::from("test2".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 2,
- exit: 0,
- command: "/bin/cmd -os".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let prev = History {
- id: HistoryId::from("test3".to_string()),
- timestamp: OffsetDateTime::now_utc(),
- duration: 1,
- exit: 0,
- command: "/bin/cmd -a".to_string(),
- cwd: "/toot".to_string(),
- session: "sesh1".to_string(),
- hostname: "hostn".to_string(),
- author: "hostn".to_string(),
- intent: None,
- deleted_at: None,
- };
- let stats = HistoryStats {
- next: Some(next.clone()),
- previous: Some(prev.clone()),
- total: 2,
- average_duration: 3,
- exits: Vec::new(),
- day_of_week: Vec::new(),
- duration_over_time: Vec::new(),
- };
- (history, stats)
- }
-
- #[test]
- fn test_output_looks_correct_for_ultracompact() {
- let backend = TestBackend::new(22, 5);
- let mut terminal = Terminal::new(backend).expect("Could not create terminal");
- let chunk = Rect::new(0, 0, 22, 5);
- let (history, stats) = mock_history_stats();
- let prev = stats.previous.clone().unwrap();
- let next = stats.next.clone().unwrap();
-
- drop(terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats)));
- let mut lines = [" "; 5].map(|l| Line::from(l));
- for (n, entry) in [prev, history, next].iter().enumerate() {
- let mut l = lines[n].to_string();
- l.replace_range(0..entry.command.len(), &entry.command);
- lines[n] = Line::from(l);
- }
-
- terminal.backend().assert_buffer_lines(lines);
- }
-}
diff --git a/crates/turtle/src/command/client/search/interactive.rs b/crates/turtle/src/command/client/search/interactive.rs
deleted file mode 100644
index b8cf53db..00000000
--- a/crates/turtle/src/command/client/search/interactive.rs
+++ /dev/null
@@ -1,3024 +0,0 @@
-use std::{
- io::{IsTerminal, Write, stdout},
- time::Duration,
-};
-
-#[cfg(unix)]
-use std::io::Read as _;
-
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- theme::{style_annotation, style_base, style_important},
- },
- atuin_common::{shell::Shell, utils::Escapable as _},
-};
-use eyre::Result;
-use time::OffsetDateTime;
-use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
-
-use super::{
- cursor::Cursor,
- engines::{SearchEngine, SearchState},
- history_list::{HistoryList, ListState},
-};
-use crate::atuin_client::{
- database::{Context, current_context},
- history::{History, HistoryId, HistoryStats, store::HistoryStore},
- settings::{
- CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
- UiColumn,
- },
-};
-
-use crate::command::client::search::history_list::HistoryHighlighter;
-use crate::command::client::search::keybindings::KeymapSet;
-use crate::{VERSION, command::client::search::engines};
-
-use ratatui::{
- Frame, Terminal, TerminalOptions, Viewport,
- backend::{CrosstermBackend, FromCrossterm},
- crossterm::{
- cursor::SetCursorStyle,
- event::{self, Event, KeyEvent, MouseEvent},
- execute, queue, terminal,
- },
- layout::{Alignment, Constraint, Direction, Layout},
- prelude::Rect,
- style::{Modifier, Style},
- text::{Line, Span, Text},
- widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs},
-};
-
-#[cfg(not(target_os = "windows"))]
-use ratatui::crossterm::event::{
- KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
-};
-
-const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
-
-pub(crate) enum InputAction {
- Accept(usize),
- AcceptInspecting,
- Copy(usize),
- Delete(usize),
- DeleteAllMatching(usize),
- ReturnOriginal,
- ReturnQuery,
- Continue,
- Redraw,
- SwitchContext(Option<usize>),
-}
-
-#[derive(Clone)]
-pub(crate) struct InspectingState {
- current: Option<HistoryId>,
- next: Option<HistoryId>,
- previous: Option<HistoryId>,
-}
-
-impl InspectingState {
- pub(crate) fn move_to_previous(&mut self) {
- let previous = self.previous.clone();
- self.reset();
- self.current = previous;
- }
-
- pub(crate) fn move_to_next(&mut self) {
- let next = self.next.clone();
- self.reset();
- self.current = next;
- }
-
- pub(crate) fn reset(&mut self) {
- self.current = None;
- self.next = None;
- self.previous = None;
- }
-}
-
-pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness {
- if match settings.style {
- crate::atuin_client::settings::Style::Auto => f.area().height < 14,
- crate::atuin_client::settings::Style::Compact => true,
- crate::atuin_client::settings::Style::Full => false,
- } {
- if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
- Compactness::Ultracompact
- } else {
- Compactness::Compact
- }
- } else {
- Compactness::Full
- }
-}
-
-#[expect(clippy::struct_field_names)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct State {
- history_count: i64,
- results_state: ListState,
- switched_search_mode: bool,
- search_mode: SearchMode,
- results_len: usize,
- accept: bool,
- keymap_mode: KeymapMode,
- prefix: bool,
- current_cursor: Option<CursorStyle>,
- tab_index: usize,
- pending_vim_key: Option<char>,
- original_input_empty: bool,
-
- pub(crate) inspecting_state: InspectingState,
-
- keymaps: KeymapSet,
- search: SearchState,
- engine: Box<dyn SearchEngine>,
- now: Box<dyn Fn() -> OffsetDateTime + Send>,
-}
-
-#[derive(Clone, Copy)]
-pub(crate) enum Compactness {
- Ultracompact,
- Compact,
- Full,
-}
-
-#[derive(Clone, Copy)]
-struct StyleState {
- compactness: Compactness,
- invert: bool,
- inner_width: usize,
-}
-
-impl State {
- async fn query_results(
- &mut self,
- db: &mut ClientSqlite,
- smart_sort: bool,
- ) -> Result<Vec<History>> {
- let results = self.engine.query(&self.search, db).await?;
-
- self.inspecting_state = InspectingState {
- current: None,
- next: None,
- previous: None,
- };
- self.results_state.select(0);
- self.results_len = results.len();
-
- if smart_sort {
- Ok(crate::atuin_history::sort::sort(
- self.search.input.as_str(),
- results,
- ))
- } else {
- Ok(results)
- }
- }
-
- fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction {
- match input {
- Event::Key(k) => self.handle_key_input(settings, k),
- Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert),
- Event::Paste(d) => self.handle_paste_input(d),
- _ => InputAction::Continue,
- }
- }
-
- fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction {
- match (input.kind, inverted) {
- (event::MouseEventKind::ScrollDown, false)
- | (event::MouseEventKind::ScrollUp, true) => {
- self.scroll_down(1);
- }
- (event::MouseEventKind::ScrollDown, true)
- | (event::MouseEventKind::ScrollUp, false) => {
- self.scroll_up(1);
- }
- _ => {}
- }
- InputAction::Continue
- }
-
- fn handle_paste_input(&mut self, input: &str) -> InputAction {
- for i in input.chars() {
- self.search.input.insert(i);
- }
- InputAction::Continue
- }
-
- fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle {
- match style {
- CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape,
- CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock,
- CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock,
- CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore,
- CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore,
- CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar,
- CursorStyle::SteadyBar => SetCursorStyle::SteadyBar,
- }
- }
-
- fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) {
- let cursor_style = if keymap_name == "__clear__" {
- None
- } else {
- settings.keymap_cursor.get(keymap_name).copied()
- }
- .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape));
-
- if cursor_style != self.current_cursor
- && let Some(style) = cursor_style
- {
- self.current_cursor = cursor_style;
- drop(execute!(stdout(), Self::cast_cursor_style(style)));
- }
- }
-
- pub(crate) fn initialize_keymap_cursor(&mut self, settings: &Settings) {
- match self.keymap_mode {
- KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
- KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
- KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
- KeymapMode::Auto => {}
- }
- }
-
- pub(crate) fn finalize_keymap_cursor(&mut self, settings: &Settings) {
- match settings.keymap_mode_shell {
- KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"),
- KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"),
- KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"),
- KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"),
- }
- }
-
- fn handle_key_exit(settings: &Settings) -> InputAction {
- match settings.exit_mode {
- ExitMode::ReturnOriginal => InputAction::ReturnOriginal,
- ExitMode::ReturnQuery => InputAction::ReturnQuery,
- }
- }
-
- /// Select the keymap for the current mode (ignoring prefix).
- fn mode_keymap(&self) -> &super::keybindings::Keymap {
- if self.tab_index == 1 {
- &self.keymaps.inspector
- } else {
- match self.keymap_mode {
- KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs,
- KeymapMode::VimNormal => &self.keymaps.vim_normal,
- KeymapMode::VimInsert => &self.keymaps.vim_insert,
- }
- }
- }
-
- /// Whether the current mode supports character insertion on unmatched keys.
- fn is_insert_mode(&self) -> bool {
- matches!(
- self.keymap_mode,
- KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert
- )
- }
-
- fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
- use super::keybindings::Action;
- use super::keybindings::EvalContext;
- use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey};
-
- // Skip release events
- if input.kind == event::KeyEventKind::Release {
- return InputAction::Continue;
- }
-
- // Reset switched_search_mode at start of each key event
- self.switched_search_mode = false;
-
- // Build evaluation context from current state
- let ctx = EvalContext {
- cursor_position: self.search.input.position(),
- input_width: UnicodeWidthStr::width(self.search.input.as_str()),
- input_byte_len: self.search.input.as_str().len(),
- selected_index: self.results_state.selected(),
- results_len: self.results_len,
- original_input_empty: self.original_input_empty,
- has_context: self.search.custom_context.is_some(),
- };
-
- // Convert KeyEvent to SingleKey
- let Some(single) = SingleKey::from_event(input) else {
- return InputAction::Continue;
- };
-
- // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---
-
- // Take pending key before any immutable borrows of self
- let pending = self.pending_vim_key.take();
-
- // If in prefix mode, try prefix keymap first (single keys only)
- let prefix_action = if self.prefix {
- let ki = KeyInput::Single(single.clone());
- self.keymaps.prefix.resolve(&ki, &ctx)
- } else {
- None
- };
-
- // The if-let/else-if chain here is clearer than map_or_else with nested closures.
- #[expect(clippy::option_if_let_else)]
- let (action, new_pending) = if prefix_action.is_some() {
- (prefix_action, None)
- } else {
- // Use mode keymap (handles both single and multi-key sequences)
- let keymap = self.mode_keymap();
-
- if let Some(pending_char) = pending {
- // We have a pending key from a previous press (e.g., first 'g' of 'gg')
- let pending_single = SingleKey {
- code: KeyCodeValue::Char(pending_char),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- };
- let seq = KeyInput::Sequence(vec![pending_single, single.clone()]);
- let action = keymap
- .resolve(&seq, &ctx)
- .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx));
- (action, None)
- } else if keymap.has_sequence_starting_with(&single)
- && matches!(single.code, KeyCodeValue::Char(_))
- && !single.ctrl
- && !single.alt
- {
- // This key starts a multi-key sequence; wait for next key
- let KeyCodeValue::Char(c) = single.code else {
- unreachable!()
- };
- (Some(Action::Noop), Some(c))
- } else {
- (
- keymap.resolve(&KeyInput::Single(single.clone()), &ctx),
- None,
- )
- }
- };
-
- // --- Phase 2: Apply mutations ---
- self.pending_vim_key = new_pending;
-
- // Reset prefix (before execute, so EnterPrefixMode can re-set it)
- self.prefix = false;
-
- if let Some(action) = action {
- self.execute_action(&action, settings)
- } else {
- // No action matched. In insert-capable modes, insert the character.
- if self.is_insert_mode() && !single.ctrl && !single.alt {
- match single.code {
- KeyCodeValue::Char(c) => {
- self.search.input.insert(c);
- }
- KeyCodeValue::Space => {
- self.search.input.insert(' ');
- }
- _ => {}
- }
- }
- InputAction::Continue
- }
- }
-
- fn scroll_down(&mut self, scroll_len: usize) {
- let i = self.results_state.selected().saturating_sub(scroll_len);
- self.inspecting_state.reset();
- self.results_state.select(i);
- }
-
- fn scroll_up(&mut self, scroll_len: usize) {
- let i = self.results_state.selected() + scroll_len;
- self.results_state
- .select(i.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- }
-
- /// Execute a resolved action, performing all side effects and returning the
- /// appropriate `InputAction` for the event loop.
- ///
- /// This is the "do it" half of the resolve+execute pipeline. The resolver
- /// decides *what* to do (which `Action`), and this function carries it out.
- ///
- /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account
- /// for `settings.invert` so that keybindings are always in "visual" terms —
- /// users never need to think about invert in their keybinding config.
- #[expect(clippy::too_many_lines)]
- pub(crate) fn execute_action(
- &mut self,
- action: &super::keybindings::Action,
- settings: &Settings,
- ) -> InputAction {
- use crate::command::client::search::keybindings::Action;
-
- match action {
- // -- Cursor movement --
- Action::CursorLeft => {
- self.search.input.left();
- InputAction::Continue
- }
- Action::CursorRight => {
- self.search.input.right();
- InputAction::Continue
- }
- Action::CursorWordLeft => {
- self.search
- .input
- .prev_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::CursorWordRight => {
- self.search
- .input
- .next_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::CursorWordEnd => {
- self.search.input.word_end(&settings.word_chars);
- InputAction::Continue
- }
- Action::CursorStart => {
- self.search.input.start();
- InputAction::Continue
- }
- Action::CursorEnd => {
- self.search.input.end();
- InputAction::Continue
- }
-
- // -- Editing --
- Action::DeleteCharBefore => {
- self.search.input.back();
- InputAction::Continue
- }
- Action::DeleteCharAfter => {
- self.search.input.remove();
- InputAction::Continue
- }
- Action::DeleteWordBefore => {
- self.search
- .input
- .remove_prev_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::DeleteWordAfter => {
- self.search
- .input
- .remove_next_word(&settings.word_chars, settings.word_jump_mode);
- InputAction::Continue
- }
- Action::DeleteToWordBoundary => {
- // ctrl-w: remove trailing whitespace, then delete to word boundary
- while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}
- while self.search.input.left() {
- if self.search.input.char().unwrap().is_whitespace() {
- self.search.input.right();
- break;
- }
- self.search.input.remove();
- }
- InputAction::Continue
- }
- Action::ClearLine => {
- self.search.input.clear();
- InputAction::Continue
- }
- Action::ClearToStart => {
- self.search.input.clear_to_start();
- InputAction::Continue
- }
- Action::ClearToEnd => {
- self.search.input.clear_to_end();
- InputAction::Continue
- }
-
- // -- List navigation (invert-aware) --
- Action::SelectNext => {
- if settings.invert {
- self.scroll_up(1);
- } else {
- self.scroll_down(1);
- }
- InputAction::Continue
- }
- Action::SelectPrevious => {
- if settings.invert {
- self.scroll_down(1);
- } else {
- self.scroll_up(1);
- }
- InputAction::Continue
- }
- // -- Page/half-page scroll (invert-aware) --
- Action::ScrollHalfPageUp => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollHalfPageDown => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollPageUp => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- InputAction::Continue
- }
- Action::ScrollPageDown => {
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- InputAction::Continue
- }
-
- // -- Absolute jumps (invert-aware) --
- Action::ScrollToTop => {
- // Visual top of history
- if settings.invert {
- self.results_state.select(0);
- } else {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- }
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToBottom => {
- // Visual bottom of history
- if settings.invert {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- } else {
- self.results_state.select(0);
- }
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenTop => {
- // H — jump to top of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let bottom = top + visible.saturating_sub(1);
- self.results_state
- .select(bottom.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenMiddle => {
- // M — jump to middle of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let middle = top + visible / 2;
- self.results_state
- .select(middle.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- InputAction::Continue
- }
- Action::ScrollToScreenBottom => {
- // L — jump to bottom of visible screen
- let top_visible = self.results_state.offset();
- self.results_state.select(top_visible);
- self.inspecting_state.reset();
- InputAction::Continue
- }
-
- // -- Commands --
- Action::Accept => {
- if self.tab_index == 1 {
- return InputAction::AcceptInspecting;
- }
- self.accept = true;
- InputAction::Accept(self.results_state.selected())
- }
- Action::AcceptNth(n) => {
- self.accept = true;
- InputAction::Accept(self.results_state.selected() + *n as usize)
- }
- Action::ReturnSelection => {
- if self.tab_index == 1 {
- return InputAction::AcceptInspecting;
- }
- InputAction::Accept(self.results_state.selected())
- }
- Action::ReturnSelectionNth(n) => {
- InputAction::Accept(self.results_state.selected() + *n as usize)
- }
- Action::Copy => InputAction::Copy(self.results_state.selected()),
- Action::Delete => InputAction::Delete(self.results_state.selected()),
- Action::DeleteAll => InputAction::DeleteAllMatching(self.results_state.selected()),
- Action::ReturnOriginal => InputAction::ReturnOriginal,
- Action::ReturnQuery => InputAction::ReturnQuery,
- Action::Exit => Self::handle_key_exit(settings),
- Action::Redraw => InputAction::Redraw,
- Action::CycleFilterMode => {
- self.search.rotate_filter_mode(settings, 1);
- InputAction::Continue
- }
- Action::CycleSearchMode => {
- self.switched_search_mode = true;
- self.search_mode = self.search_mode.next(settings);
- self.engine = engines::engine(self.search_mode, settings);
- InputAction::Continue
- }
- Action::SwitchContext => {
- InputAction::SwitchContext(Some(self.results_state.selected()))
- }
- Action::ClearContext => InputAction::SwitchContext(None),
- Action::ToggleTab => {
- self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
- InputAction::Continue
- }
-
- // -- Mode changes --
- Action::VimEnterNormal => {
- self.set_keymap_cursor(settings, "vim_normal");
- self.keymap_mode = KeymapMode::VimNormal;
- InputAction::Continue
- }
- Action::VimEnterInsert => {
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAfter => {
- self.search.input.right();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAtStart => {
- self.search.input.start();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimEnterInsertAtEnd => {
- self.search.input.end();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimSearchInsert => {
- self.search.input.clear();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::VimChangeToEnd => {
- self.search.input.clear_to_end();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- InputAction::Continue
- }
- Action::EnterPrefixMode => {
- self.prefix = true;
- InputAction::Continue
- }
-
- // -- Inspector --
- Action::InspectPrevious => {
- self.inspecting_state.move_to_previous();
- InputAction::Redraw
- }
- Action::InspectNext => {
- self.inspecting_state.move_to_next();
- InputAction::Redraw
- }
-
- // -- Special --
- Action::Noop => InputAction::Continue,
- }
- }
-
- #[expect(clippy::cast_possible_truncation)]
- #[expect(clippy::bool_to_int_with_if)]
- fn calc_preview_height(
- settings: &Settings,
- results: &[History],
- selected: usize,
- tab_index: usize,
- compactness: Compactness,
- border_size: u16,
- preview_width: u16,
- ) -> u16 {
- if settings.show_preview
- && settings.preview.strategy == PreviewStrategy::Auto
- && tab_index == 0
- && !results.is_empty()
- {
- let length_current_cmd = results[selected].command.len() as u16;
- // calculate the number of newlines in the command
- let num_newlines = results[selected]
- .command
- .chars()
- .filter(|&c| c == '\n')
- .count() as u16;
- if num_newlines > 0 {
- std::cmp::min(
- settings.max_preview_height,
- results[selected]
- .command
- .split('\n')
- .map(|line| {
- (line.len() as u16 + preview_width - 1 - border_size)
- / (preview_width - border_size)
- })
- .sum(),
- ) + border_size * 2
- }
- // The '- 19' takes the characters before the command (duration and time) into account
- else if length_current_cmd > preview_width - 19 {
- std::cmp::min(
- settings.max_preview_height,
- (length_current_cmd + preview_width - 1 - border_size)
- / (preview_width - border_size),
- ) + border_size * 2
- } else {
- 1
- }
- } else if settings.show_preview
- && settings.preview.strategy == PreviewStrategy::Static
- && tab_index == 0
- {
- let longest_command = results
- .iter()
- .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
- longest_command.map_or(0, |v| {
- std::cmp::min(
- settings.max_preview_height,
- v.command
- .split('\n')
- .map(|line| {
- (line.len() as u16 + preview_width - 1 - border_size)
- / (preview_width - border_size)
- })
- .sum(),
- )
- }) + border_size * 2
- } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
- settings.max_preview_height + border_size * 2
- } else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
- 0
- } else {
- 1
- }
- }
-
- fn draw(
- &mut self,
- f: &mut Frame<'_>,
- results: &[History],
- stats: Option<HistoryStats>,
- inspecting: Option<&History>,
- settings: &Settings,
-
- popup_mode: bool,
- ) {
- let area = f.area();
- if popup_mode {
- f.render_widget(Clear, area);
- }
- self.draw_inner(f, area, results, stats, inspecting, settings);
- }
-
- #[expect(clippy::too_many_lines)]
- #[expect(clippy::bool_to_int_with_if)]
- fn draw_inner(
- &mut self,
- f: &mut Frame<'_>,
- area: Rect,
- results: &[History],
- stats: Option<HistoryStats>,
- inspecting: Option<&History>,
- settings: &Settings,
- ) {
- let compactness = to_compactness(f, settings);
- let invert = settings.invert;
- let border_size = match compactness {
- Compactness::Full => 1,
- _ => 0,
- };
- let preview_width = area.width.saturating_sub(2);
- let preview_height = Self::calc_preview_height(
- settings,
- results,
- self.results_state.selected(),
- self.tab_index,
- compactness,
- border_size,
- preview_width,
- );
- let show_help =
- settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1);
- // This is an OR, as it seems more likely for someone to wish to override
- // tabs unexpectedly being missed, than unexpectedly present.
- let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
- let chunks = Layout::default()
- .direction(Direction::Vertical)
- .margin(0)
- .horizontal_margin(1)
- .constraints::<&[Constraint]>(
- if invert {
- [
- Constraint::Length(1 + border_size), // input
- Constraint::Min(1), // results list
- Constraint::Length(preview_height), // preview
- Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
- Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
- ]
- } else {
- match compactness {
- Compactness::Ultracompact => [
- Constraint::Length(if show_help { 1 } else { 0 }), // header
- Constraint::Length(0), // tabs
- Constraint::Min(1), // results list
- Constraint::Length(0),
- Constraint::Length(0),
- ],
- _ => [
- Constraint::Length(if show_help { 1 } else { 0 }), // header
- Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
- Constraint::Min(1), // results list
- Constraint::Length(1 + border_size), // input
- Constraint::Length(preview_height), // preview
- ],
- }
- }
- .as_ref(),
- )
- .split(area);
-
- let input_chunk = if invert { chunks[0] } else { chunks[3] };
- let results_list_chunk = if invert { chunks[1] } else { chunks[2] };
- let preview_chunk = if invert { chunks[2] } else { chunks[4] };
- let tabs_chunk = if invert { chunks[3] } else { chunks[1] };
- let header_chunk = if invert { chunks[4] } else { chunks[0] };
-
- // TODO: this should be split so that we have one interactive search container that is
- // EITHER a search box or an inspector. But I'm not doing that now, way too much atm.
- // also allocate less 🙈
- let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect();
-
- if show_tabs {
- let tabs = Tabs::new(titles)
- .block(Block::default().borders(Borders::NONE))
- .select(self.tab_index)
- .style(Style::default())
- .highlight_style(Style::from_crossterm(style_important()));
-
- f.render_widget(tabs, tabs_chunk);
- }
-
- let style = StyleState {
- compactness,
- invert,
- inner_width: input_chunk.width.into(),
- };
-
- let header_chunks = Layout::default()
- .direction(Direction::Horizontal)
- .constraints::<&[Constraint]>(
- [
- Constraint::Ratio(1, 5),
- Constraint::Ratio(3, 5),
- Constraint::Ratio(1, 5),
- ]
- .as_ref(),
- )
- .split(header_chunk);
-
- let title = Self::build_title();
- f.render_widget(title, header_chunks[0]);
-
- let help = self.build_help(settings);
- f.render_widget(help, header_chunks[1]);
-
- let stats_tab = self.build_stats();
- f.render_widget(stats_tab, header_chunks[2]);
-
- let indicator: String = match compactness {
- Compactness::Ultracompact => {
- if self.switched_search_mode {
- format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
- } else if self.search.custom_context.is_some() {
- format!(
- "C{}>",
- self.search.filter_mode.as_str().chars().next().unwrap()
- )
- } else {
- format!(
- "{}> ",
- self.search.filter_mode.as_str().chars().next().unwrap()
- )
- }
- }
- _ => " > ".to_string(),
- };
-
- match self.tab_index {
- 0 => {
- let history_highlighter = HistoryHighlighter {
- engine: self.engine.as_ref(),
- search_input: self.search.input.as_str(),
- };
- let results_list = Self::build_results_list(
- style,
- results,
- self.keymap_mode,
- &self.now,
- indicator.as_str(),
- history_highlighter,
- settings.show_numeric_shortcuts,
- &settings.ui.columns,
- );
- f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
- }
-
- 1 => {
- if results.is_empty() {
- let message = Paragraph::new("Nothing to inspect")
- .block(
- Block::new()
- .title(Line::from(" Info ".to_string()))
- .title_alignment(Alignment::Center)
- .borders(Borders::ALL)
- .padding(Padding::vertical(2)),
- )
- .alignment(Alignment::Center);
- f.render_widget(message, results_list_chunk);
- } else {
- let inspecting = match inspecting {
- Some(inspecting) => inspecting,
- None => &results[self.results_state.selected()],
- };
- super::inspector::draw(
- f,
- results_list_chunk,
- inspecting,
- &stats.expect("Drawing inspector, but no stats"),
- settings,
- settings.timezone,
- );
- }
-
- // HACK: I'm following up with abstracting this into the UI container, with a
- // sub-widget for search + for inspector
- let feedback = Paragraph::new(
- "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh",
- );
- f.render_widget(feedback, input_chunk);
-
- return;
- }
-
- _ => {
- panic!("invalid tab index");
- }
- }
-
- if !matches!(compactness, Compactness::Ultracompact) {
- let preview_width = match compactness {
- Compactness::Full => preview_width - 2,
- _ => preview_width,
- };
- let preview = self.build_preview(
- results,
- compactness,
- preview_width,
- preview_chunk.width.into(),
- );
- #[expect(clippy::cast_possible_truncation)]
- let prefix_width = settings
- .ui
- .columns
- .iter()
- .take_while(|col| !col.expand)
- .map(|col| col.width + 1)
- .sum::<u16>()
- + " > ".len() as u16;
- #[expect(clippy::cast_possible_truncation)]
- let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16;
- self.draw_preview(
- f,
- style,
- input_chunk,
- compactness,
- preview_chunk,
- preview,
- std::cmp::max(prefix_width, min_prefix_width),
- );
- }
- }
-
- #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)]
- fn draw_preview(
- &self,
- f: &mut Frame<'_>,
- style: StyleState,
- input_chunk: Rect,
- compactness: Compactness,
- preview_chunk: Rect,
- preview: Paragraph<'_>,
- prefix_width: u16,
- ) {
- let input = self.build_input(style, prefix_width);
- f.render_widget(input, input_chunk);
-
- f.render_widget(preview, preview_chunk);
-
- let extra_width = UnicodeWidthStr::width(self.search.input.substring());
-
- let cursor_offset = match compactness {
- Compactness::Full => 1,
- _ => 0,
- };
- f.set_cursor_position((
- // Put cursor past the end of the input text
- input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,
- input_chunk.y + cursor_offset,
- ));
- }
-
- fn build_title<'a>() -> Paragraph<'a> {
- let title = {
- let style: Style = Style::from_crossterm(style_base());
- Paragraph::new(Text::from(Span::styled(
- format!("Atuin v{VERSION}"),
- style.add_modifier(Modifier::BOLD),
- )))
- };
- title.alignment(Alignment::Left)
- }
-
- fn build_help(&self, settings: &Settings) -> Paragraph<'_> {
- match self.tab_index {
- // search
- 0 => Paragraph::new(Text::from(Line::from(vec![
- Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": exit"),
- Span::raw(", "),
- Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": edit"),
- Span::raw(", "),
- Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(if settings.enter_accept {
- ": run"
- } else {
- ": edit"
- }),
- Span::raw(", "),
- Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": inspect"),
- ]))),
-
- 1 => Paragraph::new(Text::from(Line::from(vec![
- Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": exit"),
- Span::raw(", "),
- Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": search"),
- Span::raw(", "),
- Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)),
- Span::raw(": delete"),
- ]))),
-
- _ => unreachable!("invalid tab index"),
- }
- .style(Style::from_crossterm(style_annotation()))
- .alignment(Alignment::Center)
- }
-
- fn build_stats(&self) -> Paragraph<'_> {
- Paragraph::new(Text::from(Span::raw(format!(
- "history count: {}",
- self.history_count,
- ))))
- .style(Style::from_crossterm(style_annotation()))
- .alignment(Alignment::Right)
- }
-
- #[expect(clippy::too_many_arguments)]
- fn build_results_list<'a>(
- style: StyleState,
- results: &'a [History],
- keymap_mode: KeymapMode,
- now: &'a dyn Fn() -> OffsetDateTime,
- indicator: &'a str,
-
- history_highlighter: HistoryHighlighter<'a>,
- show_numeric_shortcuts: bool,
- columns: &'a [UiColumn],
- ) -> HistoryList<'a> {
- let results_list = HistoryList::new(
- results,
- style.invert,
- keymap_mode == KeymapMode::VimNormal,
- now,
- indicator,
- history_highlighter,
- show_numeric_shortcuts,
- columns,
- );
-
- match style.compactness {
- Compactness::Full => {
- if style.invert {
- results_list.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
- )
- } else {
- results_list.block(
- Block::default()
- .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded),
- )
- }
- }
- _ => results_list,
- }
- }
-
- fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> {
- let (pref, mode) = if self.switched_search_mode {
- (" SRCH:", self.search_mode.as_str())
- } else if self.search.custom_context.is_some() {
- (" CTX:", self.search.filter_mode.as_str())
- } else {
- ("", self.search.filter_mode.as_str())
- };
- // 3: surrounding "[" "] "
- let mode_width = usize::from(prefix_width) - pref.len() - 3;
- // sanity check to ensure we don't exceed the layout limits
- debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
- let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str());
- let input = Paragraph::new(input);
- match style.compactness {
- Compactness::Full => {
- if style.invert {
- input.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
- .border_type(BorderType::Rounded),
- )
- } else {
- input.block(
- Block::default()
- .borders(Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = style.inner_width - 2)),
- )
- }
- }
- _ => input,
- }
- }
-
- fn build_preview(
- &self,
- results: &[History],
- compactness: Compactness,
- preview_width: u16,
- chunk_width: usize,
- ) -> Paragraph<'_> {
- let selected = self.results_state.selected();
- let command = if results.is_empty() {
- String::new()
- } else {
- let s = &results[selected].command;
- let mut lines = Vec::new();
- for line in s.split('\n') {
- let line = line.escape_control();
- let mut width = 0;
- let mut start = 0;
- for (idx, ch) in line.char_indices() {
- let w = ch.width().unwrap_or(0); // None for control chars which should not happen
- if width + w > preview_width.into() {
- lines.push(line[start..idx].to_owned());
- start = idx;
- width = w;
- } else {
- width += w;
- }
- }
- if width != 0 {
- lines.push(line[start..].to_owned());
- }
- }
- lines.join("\n")
- };
-
- match compactness {
- Compactness::Full => Paragraph::new(command).block(
- Block::default()
- .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
- .border_type(BorderType::Rounded)
- .title(format!("{:─>width$}", "", width = chunk_width - 2)),
- ),
- _ => Paragraph::new(command).style(Style::from_crossterm(style_annotation())),
- }
- }
-}
-
-/// The writer used for terminal output - either stdout or /dev/tty
-enum TerminalWriter {
- Stdout(std::io::Stdout),
- #[cfg(unix)]
- Tty(std::fs::File),
-}
-
-impl TerminalWriter {
- fn new() -> std::io::Result<Self> {
- let stdout = stdout();
- if stdout.is_terminal() {
- return Ok(Self::Stdout(stdout));
- }
-
- // If stdout is not a terminal (e.g., captured by command substitution),
- // fall back to /dev/tty so the TUI can still render.
- // This allows usage like: VAR=$(atuin search -i)
- #[cfg(unix)]
- {
- Ok(Self::Tty(
- std::fs::File::options()
- .read(true)
- .write(true)
- .open("/dev/tty")?,
- ))
- }
- }
-}
-
-impl Write for TerminalWriter {
- fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
- match self {
- Self::Stdout(stdout) => stdout.write(buf),
- #[cfg(unix)]
- Self::Tty(file) => file.write(buf),
- }
- }
-
- fn flush(&mut self) -> std::io::Result<()> {
- match self {
- Self::Stdout(stdout) => stdout.flush(),
- #[cfg(unix)]
- Self::Tty(file) => file.flush(),
- }
- }
-}
-
-/// Screen state captured from atuin pty-proxy's screen server.
-#[cfg(unix)]
-struct SavedScreen {
- rows: u16,
- cols: u16,
- cursor_row: u16,
- cursor_col: u16,
- /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout.
- rows_data: Vec<Vec<u8>>,
-}
-
-/// Connect to atuin pty-proxy's Unix socket and fetch the current screen state.
-///
-/// The wire format is:
-/// ```text
-/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE]
-/// [row_0_len: u32 BE][row_0_bytes...]
-/// [row_1_len: u32 BE][row_1_bytes...]
-/// ...
-/// ```
-#[cfg(unix)]
-fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> {
- use std::os::unix::net::UnixStream;
-
- let mut stream = UnixStream::connect(socket_path).ok()?;
- stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
-
- let mut data = Vec::new();
- stream.read_to_end(&mut data).ok()?;
-
- if data.len() < 8 {
- return None;
- }
-
- let rows = u16::from_be_bytes([data[0], data[1]]);
- let cols = u16::from_be_bytes([data[2], data[3]]);
- let cursor_row = u16::from_be_bytes([data[4], data[5]]);
- let cursor_col = u16::from_be_bytes([data[6], data[7]]);
-
- // Parse length-prefixed rows
- let mut rows_data = Vec::with_capacity(rows as usize);
- let mut offset = 8;
- while offset + 4 <= data.len() {
- let row_len = u32::from_be_bytes([
- data[offset],
- data[offset + 1],
- data[offset + 2],
- data[offset + 3],
- ]) as usize;
- offset += 4;
- if offset + row_len > data.len() {
- break;
- }
- rows_data.push(data[offset..offset + row_len].to_vec());
- offset += row_len;
- }
-
- Some(SavedScreen {
- rows,
- cols,
- cursor_row,
- cursor_col,
- rows_data,
- })
-}
-
-/// Restore the screen area that was covered by the popup.
-///
-/// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy
-/// directly to stdout, which correctly handles wide characters, colors, and
-/// all text attributes without needing a client-side vt100 parser.
-#[cfg(unix)]
-fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) {
- use ratatui::crossterm::cursor::MoveTo;
-
- let mut stdout = stdout();
-
- for dy in 0..popup_rect.height {
- let target_row = popup_rect.y + dy;
- let source_row = (target_row + scroll_offset) as usize;
-
- // Clear only the popup region. The server-side rows_formatted() skips
- // default cells (spaces with default attributes) using cursor jumps, so
- // any popup content at those positions would remain if not cleared
- // beforehand. We write `popup_rect.width` spaces instead of
- // ClearType::CurrentLine so that only the popup area is cleared, not
- // the entire terminal line.
- drop(execute!(
- stdout,
- MoveTo(popup_rect.x, target_row),
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset),
- ));
- drop(write!(
- stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- drop(execute!(stdout, MoveTo(popup_rect.x, target_row)));
-
- if let Some(row_bytes) = saved.rows_data.get(source_row) {
- drop(stdout.write_all(row_bytes));
- }
- }
-
- drop(execute!(
- stdout,
- MoveTo(
- saved.cursor_col,
- saved.cursor_row.saturating_sub(scroll_offset)
- )
- ));
- drop(stdout.flush());
-}
-
-struct Stdout {
- writer: TerminalWriter,
- inline_mode: bool,
- no_mouse: bool,
-}
-
-impl Stdout {
- pub(crate) fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result<Self> {
- terminal::enable_raw_mode()?;
-
- let mut writer = TerminalWriter::new()?;
-
- if !inline_mode {
- execute!(writer, terminal::EnterAlternateScreen)?;
- }
-
- if !no_mouse {
- execute!(writer, event::EnableMouseCapture)?;
- }
-
- execute!(writer, event::EnableBracketedPaste)?;
-
- #[cfg(not(target_os = "windows"))]
- execute!(
- writer,
- PushKeyboardEnhancementFlags(
- KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES
- | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
- ),
- )?;
-
- Ok(Self {
- writer,
- inline_mode,
- no_mouse,
- })
- }
-}
-
-impl Drop for Stdout {
- fn drop(&mut self) {
- #[cfg(not(target_os = "windows"))]
- if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) {
- tracing::error!(?e, "Failed to pop keyboard enhancement flags");
- }
-
- if !self.inline_mode
- && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen)
- {
- tracing::error!(?e, "Failed to leave alt screen mode");
- }
-
- if !self.no_mouse
- && let Err(e) = execute!(self.writer, event::DisableMouseCapture)
- {
- tracing::error!(?e, "Failed to disable mouse capture");
- }
-
- if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) {
- tracing::error!(?e, "Failed to disable bracketed paste");
- }
-
- if let Err(e) = terminal::disable_raw_mode() {
- tracing::error!(?e, "Failed to disable raw mode");
- }
- }
-}
-
-impl Write for Stdout {
- fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
- self.writer.write(buf)
- }
-
- fn flush(&mut self) -> std::io::Result<()> {
- self.writer.flush()
- }
-}
-
-// this is a big blob of horrible! clean it up!
-/// Compute the popup position and any scroll offset needed to make room.
-///
-/// Given the cursor row, terminal dimensions, and desired popup height,
-/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number
-/// of lines the caller should scroll the terminal up before rendering.
-///
-/// This function performs no I/O — it is a pure computation.
-#[cfg(unix)]
-fn compute_popup_placement(
- cursor_row: u16,
- term_rows: u16,
- term_cols: u16,
- inline_height: u16,
-) -> (Rect, u16) {
- let popup_w = term_cols;
- let popup_h = inline_height.min(term_rows);
- let space_below = term_rows.saturating_sub(cursor_row);
-
- let (popup_y, scroll) = if popup_h <= space_below {
- // Fits below cursor
- (cursor_row, 0u16)
- } else if cursor_row >= term_rows / 2 {
- // Bottom half — render above cursor (overlay on existing text)
- (cursor_row.saturating_sub(popup_h), 0u16)
- } else {
- // Top half, not enough space — scroll terminal to make room
- let scroll = popup_h.saturating_sub(space_below);
- let popup_y = cursor_row.saturating_sub(scroll);
- (popup_y, scroll)
- };
-
- (Rect::new(0, popup_y, popup_w, popup_h), scroll)
-}
-
-// for now, it works. But it'd be great if it were more easily readable, and
-// modular. I'd like to add some more stats and stuff at some point
-#[expect(clippy::too_many_lines, clippy::cognitive_complexity)]
-pub(crate) async fn history(
- query: &[String],
- settings: &Settings,
- mut db: ClientSqlite,
- history_store: &HistoryStore,
-) -> Result<String> {
- let inline_height = if settings.shell_up_key_binding {
- settings
- .inline_height_shell_up_key_binding
- .unwrap_or(settings.inline_height)
- } else {
- settings.inline_height
- };
-
- // Use fullscreen mode if the inline height doesn't fit in the terminal,
- // this will preserve the scroll position upon exit.
- // Also force fullscreen when stdout isn't a terminal (e.g., command substitution
- // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force
- // fullscreen mode (inline mode won't work as it requires cursor position queries
- // that don't work when stdout is captured).
- let inline_height = if !stdout().is_terminal() {
- 0
- } else if let Ok(size) = terminal::size()
- && inline_height >= size.1
- {
- 0
- } else {
- inline_height
- };
-
- // Popup mode: if running under atuin pty-proxy and inline mode is requested,
- // fetch the screen state and render as a centered overlay.
- #[cfg(unix)]
- let (saved_screen, popup_rect, popup_scroll_offset) = {
- let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET")
- .or_else(|_| std::env::var("ATUIN_HEX_SOCKET"))
- .ok();
- if let Some(ref path) = socket_path
- && inline_height > 0
- {
- let saved = fetch_screen_state(path);
- if let Some(ref s) = saved {
- let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows));
- let (popup_rect, scroll) =
- compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height);
-
- // Scroll terminal content up to make room if needed
- if scroll > 0 {
- use ratatui::crossterm::cursor::MoveTo;
- let mut stdout = stdout();
- drop(execute!(stdout, MoveTo(0, term_rows - 1)));
- for _ in 0..scroll {
- drop(writeln!(stdout));
- }
- drop(stdout.flush());
- }
-
- (saved, popup_rect, scroll)
- } else {
- (None, Rect::default(), 0u16)
- }
- } else {
- (None, Rect::default(), 0u16)
- }
- };
-
- let popup_mode = saved_screen.is_some();
-
- let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?;
-
- // In popup mode, clear the popup region on the physical terminal before
- // ratatui takes over. Ratatui's diff-based rendering compares against an
- // initially-empty buffer, so cells that remain "empty" (spaces with default
- // style) won't be written — leaving underlying terminal text visible.
- // By pre-clearing with spaces, those cells are already correct on screen.
- if popup_mode {
- use ratatui::crossterm::cursor::MoveTo;
- let mut raw_stdout = std::io::stdout();
- // Queue all commands without flushing so the terminal receives them
- // as a single write — no intermediate cursor positions are visible.
- drop(queue!(
- raw_stdout,
- crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
- ));
- for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
- drop(queue!(raw_stdout, MoveTo(popup_rect.x, row)));
- drop(write!(
- raw_stdout,
- "{:width$}",
- "",
- width = popup_rect.width as usize
- ));
- }
- drop(raw_stdout.flush());
- }
-
- let backend = CrosstermBackend::new(stdout);
- let mut terminal = Terminal::with_options(
- backend,
- TerminalOptions {
- viewport: if popup_mode {
- Viewport::Fixed(popup_rect)
- } else if inline_height > 0 {
- Viewport::Inline(inline_height)
- } else {
- Viewport::Fullscreen
- },
- },
- )?;
-
- let original_query = query.join(" ");
-
- // Check if this is a command chaining scenario
- let is_command_chaining = if settings.command_chaining {
- let trimmed = original_query.trim_end();
- trimmed.ends_with("&&") || trimmed.ends_with('|')
- } else {
- false
- };
-
- // For command chaining, start with empty input to allow searching for new commands
- let search_input = if is_command_chaining {
- String::new()
- } else {
- original_query.clone()
- };
-
- let mut input = Cursor::from(search_input);
- // Put the cursor at the end of the query by default
- input.end();
-
- let initial_context = current_context().await?;
-
- let history_count = db.history_count(false).await?;
- let search_mode = if settings.shell_up_key_binding {
- settings
- .search_mode_shell_up_key_binding
- .unwrap_or(settings.search_mode)
- } else {
- settings.search_mode
- };
- let default_filter_mode = settings
- .filter_mode_shell_up_key_binding
- .filter(|_| settings.shell_up_key_binding)
- .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some()));
- let mut app = State {
- history_count,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode,
- tab_index: 0,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::from_settings(settings),
- search: SearchState {
- input,
- filter_mode: default_filter_mode,
- context: initial_context.clone(),
- custom_context: None,
- },
- engine: engines::engine(search_mode, settings),
- results_len: 0,
- accept: false,
- keymap_mode: match settings.keymap_mode {
- KeymapMode::Auto => KeymapMode::Emacs,
- value => value,
- },
- current_cursor: None,
- now: if settings.prefers_reduced_motion {
- let now = OffsetDateTime::now_utc();
- Box::new(move || now)
- } else {
- Box::new(OffsetDateTime::now_utc)
- },
- prefix: false,
- pending_vim_key: None,
- original_input_empty: original_query.is_empty(),
- };
-
- app.initialize_keymap_cursor(settings);
-
- let mut results = app.query_results(&mut db, settings.smart_sort).await?;
-
- if inline_height > 0 && !popup_mode {
- terminal.clear()?;
- }
-
- let mut stats: Option<HistoryStats> = None;
- let mut inspecting: Option<History> = None;
- let accept;
- let result = 'render: loop {
- terminal.draw(|f| {
- app.draw(
- f,
- &results,
- stats.clone(),
- inspecting.as_ref(),
- settings,
- popup_mode,
- );
- })?;
-
- let initial_input = app.search.input.as_str().to_owned();
- let initial_filter_mode = app.search.filter_mode;
- let initial_search_mode = app.search_mode;
- let initial_custom_context = app.search.custom_context.clone();
-
- let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250)));
-
- tokio::select! {
- event_ready = event_ready => {
- if event_ready?? {
- loop {
- match app.handle_input(settings, &event::read()?) {
- InputAction::Continue => {},
- InputAction::Delete(index) => {
- if results.is_empty() {
- break;
- }
- app.results_len -= 1;
- let selected = app.results_state.selected();
- if selected == app.results_len {
- app.inspecting_state.reset();
- app.results_state.select(selected - 1);
- }
-
- let entry = results.remove(index);
-
- let ids = history_store.delete_entries([entry]).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- app.tab_index = 0;
- },
- InputAction::DeleteAllMatching(index) => {
- if results.is_empty() {
- break;
- }
-
- let command = results[index].command.clone();
-
- // Remove matching entries from the visible results
- results.retain(|e| e.command != command);
-
- // Query the DB for ALL entries with this command and delete them
- let all_matching = db.query_history(
- &format!(
- "select * from history where command = '{}' and deleted_at is null",
- command.replace('\'', "''")
- )
- ).await?;
-
- let ids = history_store.delete_entries(all_matching).await?;
- history_store.incremental_build(&db, &ids).await?;
-
- app.results_len = results.len();
- app.results_state = ListState::default();
- app.inspecting_state.reset();
- app.tab_index = 0;
- },
- InputAction::SwitchContext(index) => {
- if let Some(index) = index && let Some(entry) = results.get(index) {
- app.search.custom_context = Some(entry.id.clone());
- app.search.context = Context::from_history(entry);
- app.search.filter_mode = FilterMode::Session;
- app.search.input = Cursor::from(String::new());
- app.results_state = ListState::default();
- } else {
- app.search.custom_context = None;
- app.search.context = initial_context.clone();
- app.search.filter_mode = default_filter_mode;
- }
- },
- InputAction::Redraw => {
- if !popup_mode {
- terminal.clear()?;
- }
- terminal.draw(|f| {
- app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, popup_mode);
- })?;
- },
- r => {
- accept = app.accept;
- break 'render r;
- },
- }
- if !event::poll(Duration::ZERO)? {
- break;
- }
- }
- }
- }
- }
-
- if initial_input != app.search.input.as_str()
- || initial_filter_mode != app.search.filter_mode
- || initial_search_mode != app.search_mode
- || initial_custom_context != app.search.custom_context
- {
- results = app.query_results(&mut db, settings.smart_sort).await?;
- }
-
- // In custom context mode, when no filter is applied, highlight the entry which was used
- // to enter the context when changing modes. This helps to find your way around.
- if app.search.custom_context.is_some()
- && app.search.input.as_str().is_empty()
- && (initial_custom_context != app.search.custom_context
- || initial_filter_mode != app.search.filter_mode)
- && let Some(history_id) = app.search.custom_context.clone()
- && let Some(pos) = results.iter().position(|entry| entry.id == history_id)
- {
- app.results_state.select(pos);
- }
-
- let inspecting_id = app.inspecting_state.clone().current;
- // If inspecting ID is not the current inspecting History, update it.
- match inspecting_id {
- Some(inspecting_id) => {
- if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
- inspecting = db.load(inspecting_id.0.as_str()).await?;
- }
- }
- _ => {
- inspecting = None;
- }
- }
-
- stats = if app.tab_index == 0 {
- None
- } else if !results.is_empty() {
- // If we have stats, then we can indicate next available IDs. This avoids passing
- // around a database object, or a full stats object.
- let selected = match inspecting.clone() {
- Some(insp) => insp,
- None => results[app.results_state.selected()].clone(),
- };
- let stats = db.stats(&selected).await?;
- app.inspecting_state.current = Some(selected.id);
- app.inspecting_state.previous = match stats.previous.clone() {
- Some(p) => Some(p.id),
- _ => None,
- };
- app.inspecting_state.next = match stats.next.clone() {
- Some(p) => Some(p.id),
- _ => None,
- };
- Some(stats)
- } else {
- None
- };
- };
-
- app.finalize_keymap_cursor(settings);
-
- if popup_mode {
- // In popup mode, restore the screen area that was covered by the popup.
- // This must happen before Stdout is dropped (which disables raw mode).
- #[cfg(unix)]
- if let Some(ref saved) = saved_screen {
- restore_popup_area(saved, popup_rect, popup_scroll_offset);
- }
- } else if inline_height > 0 {
- terminal.clear()?;
- }
-
- let accept = accept
- && matches!(
- Shell::from_env(),
- Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell
- );
-
- let accept_prefix = "__atuin_accept__:";
-
- match result {
- InputAction::AcceptInspecting => {
- match inspecting {
- Some(result) => {
- let mut command = result.command;
-
- if accept {
- command = String::from(accept_prefix) + &command;
- }
-
- // index is in bounds so we return that entry
- Ok(command)
- }
- None => Ok(String::new()),
- }
- }
- InputAction::Accept(index) if index < results.len() => {
- let mut command = results.swap_remove(index).command;
-
- if is_command_chaining {
- command = format!("{} {}", original_query.trim_end(), command);
- } else if accept {
- command = String::from(accept_prefix) + &command;
- }
-
- // index is in bounds so we return that entry
- Ok(command)
- }
- InputAction::ReturnOriginal => Ok(String::new()),
- InputAction::Copy(index) => {
- let cmd = results.swap_remove(index).command;
- set_clipboard(cmd);
- Ok(String::new())
- }
- InputAction::ReturnQuery | InputAction::Accept(_) => {
- // Either:
- // * index == RETURN_QUERY, in which case we should return the input
- // * out of bounds -> usually implies no selected entry so we return the input
- Ok(app.search.input.into_inner())
- }
- InputAction::Continue
- | InputAction::Redraw
- | InputAction::Delete(_)
- | InputAction::DeleteAllMatching(_)
- | InputAction::SwitchContext(_) => {
- unreachable!("should have been handled!")
- }
- }
-}
-
-// cli-clipboard only works on Windows, Mac, and Linux.
-
-#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
-fn set_clipboard(s: String) {
- let mut ctx = arboard::Clipboard::new().unwrap();
- ctx.set_text(s).unwrap();
- // Use the clipboard context to make sure it is saved
- ctx.get_text().unwrap();
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::database::Context;
- use crate::atuin_client::history::History;
- use crate::atuin_client::settings::{
- FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings,
- };
- use time::OffsetDateTime;
-
- use crate::command::client::search::engines::{self, SearchState};
- use crate::command::client::search::history_list::ListState;
-
- use super::{Compactness, InspectingState, KeymapSet, State};
-
- #[test]
- #[expect(clippy::too_many_lines)]
- fn calc_preview_height_test() {
- let settings_preview_auto = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Auto,
- },
- show_preview: true,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_auto_h2 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Auto,
- },
- show_preview: true,
- max_preview_height: 2,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_h4 = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Static,
- },
- show_preview: true,
- max_preview_height: 4,
- ..Settings::new().unwrap()
- };
-
- let settings_preview_fixed = Settings {
- preview: Preview {
- strategy: PreviewStrategy::Fixed,
- },
- show_preview: true,
- max_preview_height: 15,
- ..Settings::new().unwrap()
- };
-
- let cmd_60: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done")
- .cwd("/")
- .build()
- .into();
-
- let cmd_124: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'")
- .cwd("/")
- .build()
- .into();
-
- let cmd_200: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'")
- .cwd("/")
- .build()
- .into();
-
- let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200];
-
- // the selected command does not require a preview
- let no_preview = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 0_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires 2 lines
- let preview_h2 = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires 3 lines
- let preview_h3 = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 2_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width)
- let preview_one_line = State::calc_preview_height(
- &settings_preview_auto,
- &results,
- 0_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 66,
- );
- // the selected command requires 3 lines, but we have a max preview height limit of 2
- let preview_limit_at_2 = State::calc_preview_height(
- &settings_preview_auto_h2,
- &results,
- 2_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the longest command requires 3 lines
- let preview_static_h3 = State::calc_preview_height(
- &settings_preview_h4,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 80,
- );
- // the longest command requires 10 lines, but we have a max preview height limit of 4
- let preview_static_limit_at_4 = State::calc_preview_height(
- &settings_preview_h4,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 20,
- );
- // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy
- let settings_preview_fixed = State::calc_preview_height(
- &settings_preview_fixed,
- &results,
- 1_usize,
- 0_usize,
- Compactness::Full,
- 1,
- 20,
- );
-
- assert_eq!(no_preview, 1);
- // 1 * 2 is the space for the border
- let border_space = 2;
- assert_eq!(preview_h2, 2 + border_space);
- assert_eq!(preview_h3, 3 + border_space);
- assert_eq!(preview_one_line, 1 + border_space);
- assert_eq!(preview_limit_at_2, 2 + border_space);
- assert_eq!(preview_static_h3, 3 + border_space);
- assert_eq!(preview_static_limit_at_4, 4 + border_space);
- assert_eq!(settings_preview_fixed, 15 + border_space);
- }
-
- // Test when there's no results, scrolling up or down doesn't underflow
- #[test]
- fn state_scroll_up_underflow() {
- let settings = Settings::new().unwrap();
- let mut state = State {
- history_count: 0,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 0,
- accept: false,
- keymap_mode: KeymapMode::Auto,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Directory,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.scroll_up(1);
- state.scroll_down(1);
- }
-
- #[test]
- fn test_accept_keybindings() {
- use crate::atuin_client::settings::Keys;
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let mut settings = Settings::new().unwrap();
- settings.keys = Keys {
- scroll_exits: true,
- exit_past_line_start: false,
- accept_past_line_end: true,
- accept_past_line_start: false,
- accept_with_backspace: false,
- prefix: "a".to_string(),
- };
-
- let mut state = State {
- history_count: 1,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 1,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &tab_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Tab should always accept"
- );
-
- // Test left arrow with accept_past_line_start disabled (should continue)
- let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Left arrow should continue when disabled"
- );
-
- // Test left arrow with accept_past_line_start enabled (should accept at start of line)
- settings.keys.accept_past_line_start = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Left arrow should accept at start of line when enabled"
- );
- settings.keys.accept_past_line_start = false;
- state.keymaps = KeymapSet::defaults(&settings);
-
- let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Backspace should continue when disabled"
- );
-
- settings.keys.accept_with_backspace = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Backspace should accept at start of line when enabled"
- );
-
- state.search.input.insert('t');
- state.search.input.insert('e');
- state.search.input.insert('s');
- state.search.input.insert('t');
- state.search.input.end();
-
- let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &right_event);
- assert!(
- matches!(result, super::InputAction::Accept(_)),
- "Right arrow should accept at end of line when enabled"
- );
-
- settings.keys.accept_past_line_start = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &left_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Left arrow should continue and end of line, even when enabled"
- );
- settings.keys.accept_past_line_start = false;
- state.keymaps = KeymapSet::defaults(&settings);
-
- settings.keys.accept_with_backspace = true;
- state.keymaps = KeymapSet::defaults(&settings);
- let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &backspace_event);
- assert!(
- matches!(result, super::InputAction::Continue),
- "Backspace should continue at end of line, even when enabled"
- );
- settings.keys.accept_with_backspace = false;
- state.keymaps = KeymapSet::defaults(&settings);
- }
-
- #[test]
- fn test_vim_gg_multikey_sequence() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- // Start in the middle of the list
- state.results_state.select(50);
-
- // First 'g' should set pending state
- let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, Some('g'));
- assert_eq!(state.results_state.selected(), 50); // Position unchanged
-
- // Second 'g' should jump to end (visual top in non-inverted mode)
- let result = state.handle_key_input(&settings, &g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top)
- }
-
- #[test]
- fn test_vim_g_key_clears_on_other_input() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Press 'g' to set pending state
- let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE);
- state.handle_key_input(&settings, &g_event);
- assert_eq!(state.pending_vim_key, Some('g'));
-
- // Press 'j' - should clear pending state
- let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
- state.handle_key_input(&settings, &j_event);
- assert_eq!(state.pending_vim_key, None);
- }
-
- #[test]
- fn test_vim_big_g_jump_to_bottom() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // 'G' should jump to visual bottom (index 0 in non-inverted mode)
- let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &big_g_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_u_d_half_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Ctrl+d should return Continue and clear pending key
- // (scroll amount depends on max_entries which is 0 in tests)
- state.pending_vim_key = Some('g');
- let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_d_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
-
- // Ctrl+u should return Continue and clear pending key
- state.pending_vim_key = Some('g');
- let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_u_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- }
-
- #[test]
- #[expect(clippy::similar_names)]
- fn test_vim_ctrl_f_b_full_page_scroll() {
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- let settings = Settings::new().unwrap();
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::VimNormal,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- state.results_state.select(50);
-
- // Ctrl+f should return Continue and clear pending key
- // (scroll amount depends on max_entries which is 0 in tests)
- state.pending_vim_key = Some('g');
- let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_f_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
-
- // Ctrl+b should return Continue and clear pending key
- state.pending_vim_key = Some('g');
- let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
- let result = state.handle_key_input(&settings, &ctrl_b_event);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.pending_vim_key, None);
- }
-
- // -----------------------------------------------------------------------
- // Executor tests (execute_action)
- // -----------------------------------------------------------------------
-
- /// Helper to build a State for executor tests.
- fn make_executor_state(results_len: usize, selected: usize) -> State {
- let settings = Settings::new().unwrap();
- let mut state = State {
- history_count: results_len as i64,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::defaults(&settings),
- search: SearchState {
- input: String::new().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
- state.results_state.select(selected);
- state
- }
-
- #[test]
- fn execute_select_next_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SelectNext, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: SelectNext = scroll_down = selected - 1
- assert_eq!(state.results_state.selected(), 49);
- }
-
- #[test]
- fn execute_select_next_with_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let mut settings = Settings::new().unwrap();
- settings.invert = true;
- let result = state.execute_action(&Action::SelectNext, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Inverted: SelectNext = scroll_up = selected + 1
- assert_eq!(state.results_state.selected(), 51);
- }
-
- #[test]
- fn execute_select_previous_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SelectPrevious, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: SelectPrevious = scroll_up = selected + 1
- assert_eq!(state.results_state.selected(), 51);
- }
-
- #[test]
- fn execute_vim_enter_normal() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimEnterNormal, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.keymap_mode, KeymapMode::VimNormal);
- }
-
- #[test]
- fn execute_vim_enter_insert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- state.keymap_mode = KeymapMode::VimNormal;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimEnterInsert, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
- }
-
- #[test]
- fn execute_accept_sets_accept_flag() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let mut settings = Settings::new().unwrap();
- settings.enter_accept = true;
- let result = state.execute_action(&Action::Accept, &settings);
- assert!(matches!(result, super::InputAction::Accept(5)));
- assert!(state.accept);
- }
-
- #[test]
- fn execute_return_selection_does_not_set_accept() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ReturnSelection, &settings);
- assert!(matches!(result, super::InputAction::Accept(5)));
- assert!(!state.accept);
- }
-
- #[test]
- fn execute_accept_nth() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::AcceptNth(3), &settings);
- assert!(matches!(result, super::InputAction::Accept(8)));
- }
-
- #[test]
- fn execute_scroll_to_top_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ScrollToTop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: visual top = highest index
- assert_eq!(state.results_state.selected(), 99);
- }
-
- #[test]
- fn execute_scroll_to_top_with_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let mut settings = Settings::new().unwrap();
- settings.invert = true;
- let result = state.execute_action(&Action::ScrollToTop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Inverted: visual top = index 0
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- fn execute_scroll_to_bottom_no_invert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ScrollToBottom, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Non-inverted: visual bottom = index 0
- assert_eq!(state.results_state.selected(), 0);
- }
-
- #[test]
- fn execute_toggle_tab() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- assert_eq!(state.tab_index, 0);
- state.execute_action(&Action::ToggleTab, &settings);
- assert_eq!(state.tab_index, 1);
- state.execute_action(&Action::ToggleTab, &settings);
- assert_eq!(state.tab_index, 0);
- }
-
- #[test]
- fn execute_enter_prefix_mode() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- assert!(!state.prefix);
- state.execute_action(&Action::EnterPrefixMode, &settings);
- assert!(state.prefix);
- }
-
- #[test]
- fn execute_exit_returns_based_on_exit_mode() {
- use crate::atuin_client::settings::ExitMode;
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let mut settings = Settings::new().unwrap();
-
- settings.exit_mode = ExitMode::ReturnOriginal;
- let result = state.execute_action(&Action::Exit, &settings);
- assert!(matches!(result, super::InputAction::ReturnOriginal));
-
- settings.exit_mode = ExitMode::ReturnQuery;
- let result = state.execute_action(&Action::Exit, &settings);
- assert!(matches!(result, super::InputAction::ReturnQuery));
- }
-
- #[test]
- fn execute_return_original() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ReturnOriginal, &settings);
- assert!(matches!(result, super::InputAction::ReturnOriginal));
- }
-
- #[test]
- fn execute_copy() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Copy, &settings);
- assert!(matches!(result, super::InputAction::Copy(7)));
- }
-
- #[test]
- fn execute_delete() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Delete, &settings);
- assert!(matches!(result, super::InputAction::Delete(7)));
- }
-
- #[test]
- fn execute_switch_context() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::SwitchContext, &settings);
- assert!(matches!(result, super::InputAction::SwitchContext(Some(7))));
- }
-
- #[test]
- fn execute_clear_context() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 7);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::ClearContext, &settings);
- assert!(matches!(result, super::InputAction::SwitchContext(None)));
- }
-
- #[test]
- fn execute_noop() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 50);
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Noop, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert_eq!(state.results_state.selected(), 50);
- }
-
- #[test]
- fn execute_accept_in_inspector_tab() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 5);
- state.tab_index = 1;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::Accept, &settings);
- assert!(matches!(result, super::InputAction::AcceptInspecting));
- }
-
- #[test]
- fn execute_cycle_search_mode() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
- let original_mode = state.search_mode;
- let result = state.execute_action(&Action::CycleSearchMode, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- assert!(state.switched_search_mode);
- assert_ne!(state.search_mode, original_mode);
- }
-
- #[test]
- fn execute_vim_search_insert() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- state.search.input.insert('h');
- state.search.input.insert('i');
- state.keymap_mode = KeymapMode::VimNormal;
- let settings = Settings::new().unwrap();
- let result = state.execute_action(&Action::VimSearchInsert, &settings);
- assert!(matches!(result, super::InputAction::Continue));
- // Should clear input and switch to insert mode
- assert_eq!(state.search.input.as_str(), "");
- assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
- }
-
- #[test]
- fn execute_cursor_movement() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
-
- // Insert some text
- state.search.input.insert('h');
- state.search.input.insert('e');
- state.search.input.insert('l');
- state.search.input.insert('l');
- state.search.input.insert('o');
- // cursor is at end (position 5)
-
- // CursorLeft
- state.execute_action(&Action::CursorLeft, &settings);
- assert_eq!(state.search.input.position(), 4);
-
- // CursorStart
- state.execute_action(&Action::CursorStart, &settings);
- assert_eq!(state.search.input.position(), 0);
-
- // CursorEnd
- state.execute_action(&Action::CursorEnd, &settings);
- assert_eq!(state.search.input.position(), 5);
-
- // CursorRight at end does nothing
- state.execute_action(&Action::CursorRight, &settings);
- assert_eq!(state.search.input.position(), 5);
- }
-
- #[test]
- fn execute_editing() {
- use crate::command::client::search::keybindings::Action;
-
- let mut state = make_executor_state(100, 0);
- let settings = Settings::new().unwrap();
-
- // Insert "hello"
- state.search.input.insert('h');
- state.search.input.insert('e');
- state.search.input.insert('l');
- state.search.input.insert('l');
- state.search.input.insert('o');
-
- // DeleteCharBefore (backspace)
- state.execute_action(&Action::DeleteCharBefore, &settings);
- assert_eq!(state.search.input.as_str(), "hell");
-
- // ClearLine
- state.execute_action(&Action::ClearLine, &settings);
- assert_eq!(state.search.input.as_str(), "");
- }
-
- #[test]
- fn keymap_config_return_query() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
- use std::collections::HashMap;
-
- let mut settings = Settings::new().unwrap();
- // Configure tab to return-query
- settings.keymap.emacs = HashMap::from([(
- "tab".to_string(),
- KeyBindingConfig::Simple("return-query".to_string()),
- )]);
-
- let mut state = State {
- history_count: 100,
- results_state: ListState::default(),
- switched_search_mode: false,
- search_mode: SearchMode::Fuzzy,
- results_len: 100,
- accept: false,
- keymap_mode: KeymapMode::Emacs,
- prefix: false,
- current_cursor: None,
- tab_index: 0,
- pending_vim_key: None,
- original_input_empty: false,
- inspecting_state: InspectingState {
- current: None,
- next: None,
- previous: None,
- },
- keymaps: KeymapSet::from_settings(&settings),
- search: SearchState {
- input: "test query".to_string().into(),
- filter_mode: FilterMode::Global,
- context: Context {
- session: String::new(),
- cwd: String::new(),
- hostname: String::new(),
- host_id: String::new(),
- git_root: None,
- },
- custom_context: None,
- },
- engine: engines::engine(SearchMode::Fuzzy, &settings),
- now: Box::new(OffsetDateTime::now_utc),
- };
-
- let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
- let result = state.handle_key_input(&settings, &tab_event);
- assert!(
- matches!(result, super::InputAction::ReturnQuery),
- "Tab configured as return-query should return InputAction::ReturnQuery"
- );
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/actions.rs b/crates/turtle/src/command/client/search/keybindings/actions.rs
deleted file mode 100644
index 2842d618..00000000
--- a/crates/turtle/src/command/client/search/keybindings/actions.rs
+++ /dev/null
@@ -1,322 +0,0 @@
-use std::fmt;
-
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// All possible actions that can be triggered by a keybinding.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum Action {
- // Cursor movement
- CursorLeft,
- CursorRight,
- CursorWordLeft,
- CursorWordRight,
- CursorWordEnd,
- CursorStart,
- CursorEnd,
-
- // Editing
- DeleteCharBefore,
- DeleteCharAfter,
- DeleteWordBefore,
- DeleteWordAfter,
- DeleteToWordBoundary,
- ClearLine,
- ClearToStart,
- ClearToEnd,
-
- // List navigation
- SelectNext,
- SelectPrevious,
- ScrollHalfPageUp,
- ScrollHalfPageDown,
- ScrollPageUp,
- ScrollPageDown,
- ScrollToTop,
- ScrollToBottom,
- ScrollToScreenTop,
- ScrollToScreenMiddle,
- ScrollToScreenBottom,
-
- // Commands — accept selection and execute immediately
- Accept,
- AcceptNth(u8),
- // Commands — return selection to command line without executing
- ReturnSelection,
- ReturnSelectionNth(u8),
- // Commands — other
- Copy,
- Delete,
- DeleteAll,
- ReturnOriginal,
- ReturnQuery,
- Exit,
- Redraw,
- CycleFilterMode,
- CycleSearchMode,
- SwitchContext,
- ClearContext,
- ToggleTab,
-
- // Mode changes
- VimEnterNormal,
- VimEnterInsert,
- VimEnterInsertAfter,
- VimEnterInsertAtStart,
- VimEnterInsertAtEnd,
- VimSearchInsert,
- VimChangeToEnd,
- EnterPrefixMode,
-
- // Inspector
- InspectPrevious,
- InspectNext,
-
- // Special
- Noop,
-}
-
-impl Action {
- /// Convert from a kebab-case string.
- pub(crate) fn from_str(s: &str) -> Result<Self, String> {
- // Handle accept-N and return-selection-N patterns
- if let Some(rest) = s.strip_prefix("accept-")
- && let Ok(n) = rest.parse::<u8>()
- && (1..=9).contains(&n)
- {
- return Ok(Self::AcceptNth(n));
- }
- if let Some(rest) = s.strip_prefix("return-selection-")
- && let Ok(n) = rest.parse::<u8>()
- && (1..=9).contains(&n)
- {
- return Ok(Self::ReturnSelectionNth(n));
- }
-
- match s {
- "cursor-left" => Ok(Self::CursorLeft),
- "cursor-right" => Ok(Self::CursorRight),
- "cursor-word-left" => Ok(Self::CursorWordLeft),
- "cursor-word-right" => Ok(Self::CursorWordRight),
- "cursor-word-end" => Ok(Self::CursorWordEnd),
- "cursor-start" => Ok(Self::CursorStart),
- "cursor-end" => Ok(Self::CursorEnd),
-
- "delete-char-before" => Ok(Self::DeleteCharBefore),
- "delete-char-after" => Ok(Self::DeleteCharAfter),
- "delete-word-before" => Ok(Self::DeleteWordBefore),
- "delete-word-after" => Ok(Self::DeleteWordAfter),
- "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary),
- "clear-line" => Ok(Self::ClearLine),
- "clear-to-start" => Ok(Self::ClearToStart),
- "clear-to-end" => Ok(Self::ClearToEnd),
-
- "select-next" => Ok(Self::SelectNext),
- "select-previous" => Ok(Self::SelectPrevious),
- "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp),
- "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown),
- "scroll-page-up" => Ok(Self::ScrollPageUp),
- "scroll-page-down" => Ok(Self::ScrollPageDown),
- "scroll-to-top" => Ok(Self::ScrollToTop),
- "scroll-to-bottom" => Ok(Self::ScrollToBottom),
- "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop),
- "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle),
- "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom),
-
- "accept" => Ok(Self::Accept),
- "return-selection" => Ok(Self::ReturnSelection),
- "copy" => Ok(Self::Copy),
- "delete" => Ok(Self::Delete),
- "delete-all" => Ok(Self::DeleteAll),
- "return-original" => Ok(Self::ReturnOriginal),
- "return-query" => Ok(Self::ReturnQuery),
- "exit" => Ok(Self::Exit),
- "redraw" => Ok(Self::Redraw),
- "cycle-filter-mode" => Ok(Self::CycleFilterMode),
- "cycle-search-mode" => Ok(Self::CycleSearchMode),
- "switch-context" => Ok(Self::SwitchContext),
- "clear-context" => Ok(Self::ClearContext),
- "toggle-tab" => Ok(Self::ToggleTab),
-
- "vim-enter-normal" => Ok(Self::VimEnterNormal),
- "vim-enter-insert" => Ok(Self::VimEnterInsert),
- "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter),
- "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart),
- "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd),
- "vim-search-insert" => Ok(Self::VimSearchInsert),
- "vim-change-to-end" => Ok(Self::VimChangeToEnd),
- "enter-prefix-mode" => Ok(Self::EnterPrefixMode),
-
- "inspect-previous" => Ok(Self::InspectPrevious),
- "inspect-next" => Ok(Self::InspectNext),
-
- "noop" => Ok(Self::Noop),
-
- _ => Err(format!("unknown action: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> String {
- match self {
- Self::CursorLeft => "cursor-left".to_string(),
- Self::CursorRight => "cursor-right".to_string(),
- Self::CursorWordLeft => "cursor-word-left".to_string(),
- Self::CursorWordRight => "cursor-word-right".to_string(),
- Self::CursorWordEnd => "cursor-word-end".to_string(),
- Self::CursorStart => "cursor-start".to_string(),
- Self::CursorEnd => "cursor-end".to_string(),
-
- Self::DeleteCharBefore => "delete-char-before".to_string(),
- Self::DeleteCharAfter => "delete-char-after".to_string(),
- Self::DeleteWordBefore => "delete-word-before".to_string(),
- Self::DeleteWordAfter => "delete-word-after".to_string(),
- Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
- Self::ClearLine => "clear-line".to_string(),
- Self::ClearToStart => "clear-to-start".to_string(),
- Self::ClearToEnd => "clear-to-end".to_string(),
-
- Self::SelectNext => "select-next".to_string(),
- Self::SelectPrevious => "select-previous".to_string(),
- Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
- Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
- Self::ScrollPageUp => "scroll-page-up".to_string(),
- Self::ScrollPageDown => "scroll-page-down".to_string(),
- Self::ScrollToTop => "scroll-to-top".to_string(),
- Self::ScrollToBottom => "scroll-to-bottom".to_string(),
- Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
- Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
- Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
-
- Self::Accept => "accept".to_string(),
- Self::AcceptNth(n) => format!("accept-{n}"),
- Self::ReturnSelection => "return-selection".to_string(),
- Self::ReturnSelectionNth(n) => format!("return-selection-{n}"),
- Self::Copy => "copy".to_string(),
- Self::Delete => "delete".to_string(),
- Self::DeleteAll => "delete-all".to_string(),
- Self::ReturnOriginal => "return-original".to_string(),
- Self::ReturnQuery => "return-query".to_string(),
- Self::Exit => "exit".to_string(),
- Self::Redraw => "redraw".to_string(),
- Self::CycleFilterMode => "cycle-filter-mode".to_string(),
- Self::CycleSearchMode => "cycle-search-mode".to_string(),
- Self::SwitchContext => "switch-context".to_string(),
- Self::ClearContext => "clear-context".to_string(),
- Self::ToggleTab => "toggle-tab".to_string(),
-
- Self::VimEnterNormal => "vim-enter-normal".to_string(),
- Self::VimEnterInsert => "vim-enter-insert".to_string(),
- Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
- Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
- Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
- Self::VimSearchInsert => "vim-search-insert".to_string(),
- Self::VimChangeToEnd => "vim-change-to-end".to_string(),
- Self::EnterPrefixMode => "enter-prefix-mode".to_string(),
-
- Self::InspectPrevious => "inspect-previous".to_string(),
- Self::InspectNext => "inspect-next".to_string(),
-
- Self::Noop => "noop".to_string(),
- }
- }
-}
-
-impl fmt::Display for Action {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.as_str())
- }
-}
-
-impl Serialize for Action {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.as_str())
- }
-}
-
-impl<'de> Deserialize<'de> for Action {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::from_str(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::Action;
-
- #[test]
- fn parse_basic_actions() {
- assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft);
- assert_eq!(Action::from_str("accept").unwrap(), Action::Accept);
- assert_eq!(Action::from_str("exit").unwrap(), Action::Exit);
- assert_eq!(Action::from_str("noop").unwrap(), Action::Noop);
- assert_eq!(
- Action::from_str("vim-enter-normal").unwrap(),
- Action::VimEnterNormal
- );
- }
-
- #[test]
- fn parse_accept_nth() {
- assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1));
- assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9));
- }
-
- #[test]
- fn parse_return_selection() {
- assert_eq!(
- Action::from_str("return-selection").unwrap(),
- Action::ReturnSelection
- );
- assert_eq!(
- Action::from_str("return-selection-1").unwrap(),
- Action::ReturnSelectionNth(1)
- );
- assert_eq!(
- Action::from_str("return-selection-9").unwrap(),
- Action::ReturnSelectionNth(9)
- );
- }
-
- #[test]
- fn parse_unknown_action() {
- assert!(Action::from_str("unknown-action").is_err());
- assert!(Action::from_str("accept-0").is_err());
- assert!(Action::from_str("accept-10").is_err());
- assert!(Action::from_str("return-selection-0").is_err());
- assert!(Action::from_str("return-selection-10").is_err());
- }
-
- #[test]
- fn round_trip() {
- let actions = vec![
- Action::CursorLeft,
- Action::Accept,
- Action::AcceptNth(5),
- Action::ReturnSelection,
- Action::ReturnSelectionNth(3),
- Action::VimSearchInsert,
- Action::ScrollToScreenMiddle,
- ];
- for action in actions {
- let s = action.as_str();
- let parsed = Action::from_str(&s).unwrap();
- assert_eq!(action, parsed);
- }
- }
-
- #[test]
- fn serde_round_trip() {
- let action = Action::CursorLeft;
- let json = serde_json::to_string(&action).unwrap();
- assert_eq!(json, "\"cursor-left\"");
- let parsed: Action = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, Action::CursorLeft);
-
- let action = Action::AcceptNth(3);
- let json = serde_json::to_string(&action).unwrap();
- assert_eq!(json, "\"accept-3\"");
- let parsed: Action = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, Action::AcceptNth(3));
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/conditions.rs b/crates/turtle/src/command/client/search/keybindings/conditions.rs
deleted file mode 100644
index fd993f2b..00000000
--- a/crates/turtle/src/command/client/search/keybindings/conditions.rs
+++ /dev/null
@@ -1,801 +0,0 @@
-use std::fmt;
-
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// Atomic (leaf) conditions that can be evaluated against state.
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum ConditionAtom {
- CursorAtStart,
- CursorAtEnd,
- InputEmpty,
- OriginalInputEmpty,
- ListAtEnd,
- ListAtStart,
- NoResults,
- HasResults,
- HasContext,
-}
-
-/// Boolean expression tree over condition atoms.
-///
-/// Supports negation, conjunction, and disjunction with standard precedence:
-/// `!` binds tightest, then `&&`, then `||`.
-///
-/// Examples of valid expression strings:
-/// - `"cursor-at-start"` (bare atom)
-/// - `"!no-results"` (negation)
-/// - `"cursor-at-start && input-empty"` (conjunction)
-/// - `"list-at-start || no-results"` (disjunction)
-/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping)
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(crate) enum ConditionExpr {
- Atom(ConditionAtom),
- Not(Box<Self>),
- And(Box<Self>, Box<Self>),
- Or(Box<Self>, Box<Self>),
-}
-
-/// Context needed to evaluate conditions. This is a pure snapshot of state —
-/// no references to mutable data.
-pub(crate) struct EvalContext {
- /// Current cursor position (unicode width units).
- pub(crate) cursor_position: usize,
- /// Width of the input string in unicode width units.
- pub(crate) input_width: usize,
- /// Byte length of the input string.
- pub(crate) input_byte_len: usize,
- /// Currently selected index in the results list.
- pub(crate) selected_index: usize,
- /// Total number of results.
- pub(crate) results_len: usize,
- /// Whether the original input (query passed to the TUI) was empty.
- pub(crate) original_input_empty: bool,
- /// Whether we use a search context of a command from the history.
- pub(crate) has_context: bool,
-}
-
-// ---------------------------------------------------------------------------
-// ConditionAtom
-// ---------------------------------------------------------------------------
-
-impl ConditionAtom {
- /// Evaluate this atom against the given context.
- pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::CursorAtStart => ctx.cursor_position == 0,
- Self::CursorAtEnd => ctx.cursor_position == ctx.input_width,
- Self::InputEmpty => ctx.input_byte_len == 0,
- Self::OriginalInputEmpty => ctx.original_input_empty,
- Self::ListAtEnd => {
- ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
- }
- Self::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
- Self::NoResults => ctx.results_len == 0,
- Self::HasResults => ctx.results_len > 0,
- Self::HasContext => ctx.has_context,
- }
- }
-
- /// Parse from a kebab-case string.
- pub(crate) fn from_str(s: &str) -> Result<Self, String> {
- match s {
- "cursor-at-start" => Ok(Self::CursorAtStart),
- "cursor-at-end" => Ok(Self::CursorAtEnd),
- "input-empty" => Ok(Self::InputEmpty),
- "original-input-empty" => Ok(Self::OriginalInputEmpty),
- "list-at-end" => Ok(Self::ListAtEnd),
- "list-at-start" => Ok(Self::ListAtStart),
- "no-results" => Ok(Self::NoResults),
- "has-results" => Ok(Self::HasResults),
- "has-context" => Ok(Self::HasContext),
- _ => Err(format!("unknown condition: {s}")),
- }
- }
-
- /// Convert to a kebab-case string.
- pub(crate) fn as_str(&self) -> &'static str {
- match self {
- Self::CursorAtStart => "cursor-at-start",
- Self::CursorAtEnd => "cursor-at-end",
- Self::InputEmpty => "input-empty",
- Self::OriginalInputEmpty => "original-input-empty",
- Self::ListAtEnd => "list-at-end",
- Self::ListAtStart => "list-at-start",
- Self::NoResults => "no-results",
- Self::HasResults => "has-results",
- Self::HasContext => "has-context",
- }
- }
-}
-
-impl fmt::Display for ConditionAtom {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- write!(f, "{}", self.as_str())
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — evaluation
-// ---------------------------------------------------------------------------
-
-impl ConditionExpr {
- /// Evaluate this expression against the given context.
- pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool {
- match self {
- Self::Atom(atom) => atom.evaluate(ctx),
- Self::Not(inner) => !inner.evaluate(ctx),
- Self::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
- Self::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
- }
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — ergonomic builders
-// ---------------------------------------------------------------------------
-
-impl From<ConditionAtom> for ConditionExpr {
- fn from(atom: ConditionAtom) -> Self {
- Self::Atom(atom)
- }
-}
-
-#[expect(dead_code)]
-impl ConditionExpr {
- /// Negate this expression: `!self`.
- pub(crate) fn not(self) -> Self {
- Self::Not(Box::new(self))
- }
-
- /// Conjoin with another expression: `self && other`.
- pub(crate) fn and(self, other: Self) -> Self {
- Self::And(Box::new(self), Box::new(other))
- }
-
- /// Disjoin with another expression: `self || other`.
- pub(crate) fn or(self, other: Self) -> Self {
- Self::Or(Box::new(self), Box::new(other))
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — parser
-// ---------------------------------------------------------------------------
-
-/// Recursive descent parser for boolean condition expressions.
-///
-/// Grammar (standard boolean precedence):
-/// ```text
-/// expr = or_expr
-/// or_expr = and_expr ("||" and_expr)*
-/// and_expr = unary ("&&" unary)*
-/// unary = "!" unary | primary
-/// primary = atom | "(" expr ")"
-/// atom = [a-z][a-z0-9-]*
-/// ```
-struct ExprParser<'a> {
- input: &'a str,
- pos: usize,
-}
-
-impl<'a> ExprParser<'a> {
- fn new(input: &'a str) -> Self {
- Self { input, pos: 0 }
- }
-
- fn skip_whitespace(&mut self) {
- while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {
- self.pos += 1;
- }
- }
-
- fn starts_with(&mut self, s: &str) -> bool {
- self.skip_whitespace();
- self.input[self.pos..].starts_with(s)
- }
-
- fn consume(&mut self, s: &str) -> bool {
- self.skip_whitespace();
- if self.input[self.pos..].starts_with(s) {
- self.pos += s.len();
- true
- } else {
- false
- }
- }
-
- /// Parse a full expression, expecting to consume all input.
- fn parse(mut self) -> Result<ConditionExpr, String> {
- let expr = self.parse_or()?;
- self.skip_whitespace();
- if self.pos < self.input.len() {
- return Err(format!(
- "unexpected input at position {}: {:?}",
- self.pos,
- &self.input[self.pos..]
- ));
- }
- Ok(expr)
- }
-
- /// `or_expr` = `and_expr` ("||" `and_expr`)*
- fn parse_or(&mut self) -> Result<ConditionExpr, String> {
- let mut left = self.parse_and()?;
- while self.starts_with("||") {
- self.consume("||");
- let right = self.parse_and()?;
- left = ConditionExpr::Or(Box::new(left), Box::new(right));
- }
- Ok(left)
- }
-
- /// `and_expr` = unary ("&&" unary)*
- fn parse_and(&mut self) -> Result<ConditionExpr, String> {
- let mut left = self.parse_unary()?;
- while self.starts_with("&&") {
- self.consume("&&");
- let right = self.parse_unary()?;
- left = ConditionExpr::And(Box::new(left), Box::new(right));
- }
- Ok(left)
- }
-
- /// unary = "!" unary | primary
- fn parse_unary(&mut self) -> Result<ConditionExpr, String> {
- if self.consume("!") {
- let inner = self.parse_unary()?;
- Ok(ConditionExpr::Not(Box::new(inner)))
- } else {
- self.parse_primary()
- }
- }
-
- /// primary = "(" expr ")" | atom
- fn parse_primary(&mut self) -> Result<ConditionExpr, String> {
- if self.consume("(") {
- let expr = self.parse_or()?;
- if !self.consume(")") {
- return Err(format!("expected ')' at position {}", self.pos));
- }
- Ok(expr)
- } else {
- self.parse_atom()
- }
- }
-
- /// atom = [a-z][a-z0-9-]*
- fn parse_atom(&mut self) -> Result<ConditionExpr, String> {
- self.skip_whitespace();
- let start = self.pos;
- while self.pos < self.input.len() {
- let b = self.input.as_bytes()[self.pos];
- if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
- self.pos += 1;
- } else {
- break;
- }
- }
- if self.pos == start {
- return Err(format!("expected condition name at position {}", self.pos));
- }
- let name = &self.input[start..self.pos];
- let atom = ConditionAtom::from_str(name)?;
- Ok(ConditionExpr::Atom(atom))
- }
-}
-
-impl ConditionExpr {
- /// Parse a condition expression from a string.
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let parser = ExprParser::new(s);
- parser.parse()
- }
-}
-
-// ---------------------------------------------------------------------------
-// ConditionExpr — Display
-// ---------------------------------------------------------------------------
-
-/// Precedence levels for minimal-parentheses display.
-#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
-enum Prec {
- Or = 0,
- And = 1,
- Not = 2,
- Atom = 3,
-}
-
-impl ConditionExpr {
- fn prec(&self) -> Prec {
- match self {
- Self::Or(..) => Prec::Or,
- Self::And(..) => Prec::And,
- Self::Not(..) => Prec::Not,
- Self::Atom(..) => Prec::Atom,
- }
- }
-
- fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {
- let needs_parens = self.prec() < parent_prec;
- if needs_parens {
- write!(f, "(")?;
- }
- match self {
- Self::Atom(atom) => write!(f, "{atom}")?,
- Self::Not(inner) => {
- write!(f, "!")?;
- inner.fmt_with_prec(f, Prec::Not)?;
- }
- Self::And(lhs, rhs) => {
- lhs.fmt_with_prec(f, Prec::And)?;
- write!(f, " && ")?;
- rhs.fmt_with_prec(f, Prec::And)?;
- }
- Self::Or(lhs, rhs) => {
- lhs.fmt_with_prec(f, Prec::Or)?;
- write!(f, " || ")?;
- rhs.fmt_with_prec(f, Prec::Or)?;
- }
- }
- if needs_parens {
- write!(f, ")")?;
- }
- Ok(())
- }
-}
-
-impl fmt::Display for ConditionExpr {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- self.fmt_with_prec(f, Prec::Or)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Serde
-// ---------------------------------------------------------------------------
-
-impl Serialize for ConditionExpr {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.to_string())
- }
-}
-
-impl<'de> Deserialize<'de> for ConditionExpr {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{ConditionAtom, ConditionExpr, EvalContext};
-
- fn ctx(
- cursor: usize,
- width: usize,
- byte_len: usize,
- selected: usize,
- len: usize,
- ) -> EvalContext {
- ctx_with_original(cursor, width, byte_len, selected, len, false)
- }
-
- fn ctx_with_original(
- cursor: usize,
- width: usize,
- byte_len: usize,
- selected: usize,
- len: usize,
- original_input_empty: bool,
- ) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: byte_len,
- selected_index: selected,
- results_len: len,
- original_input_empty,
- has_context: false,
- }
- }
-
- // -- Atom evaluation (carried over from Phase 0) --
-
- #[test]
- fn atom_cursor_at_start() {
- assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));
- assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));
- }
-
- #[test]
- fn atom_cursor_at_end() {
- assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));
- assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));
- assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));
- }
-
- #[test]
- fn atom_input_empty() {
- assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));
- assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));
- }
-
- #[test]
- fn atom_original_input_empty() {
- // original_input_empty = true
- assert!(
- ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true))
- );
- // original_input_empty = false
- assert!(
- !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false))
- );
- // original_input_empty is independent of current input state
- assert!(
- ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true))
- );
- }
-
- #[test]
- fn atom_list_at_end() {
- assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));
- assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));
- assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_list_at_start() {
- assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));
- assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));
- assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_no_results_and_has_results() {
- assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));
- assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));
- assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));
- assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn atom_has_context() {
- let mut context = ctx(0, 0, 0, 0, 0);
- assert!(!ConditionAtom::HasContext.evaluate(&context));
- context.has_context = true;
- assert!(ConditionAtom::HasContext.evaluate(&context));
- }
-
- #[test]
- fn atom_parse_round_trip() {
- let conditions = [
- "cursor-at-start",
- "cursor-at-end",
- "input-empty",
- "original-input-empty",
- "list-at-end",
- "list-at-start",
- "no-results",
- "has-results",
- ];
- for s in conditions {
- let c = ConditionAtom::from_str(s).unwrap();
- assert_eq!(c.as_str(), s);
- }
- }
-
- #[test]
- fn atom_parse_unknown() {
- assert!(ConditionAtom::from_str("unknown-condition").is_err());
- }
-
- // -- Parser tests --
-
- #[test]
- fn parse_bare_atom() {
- let expr = ConditionExpr::parse("cursor-at-start").unwrap();
- assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
- }
-
- #[test]
- fn parse_negation() {
- let expr = ConditionExpr::parse("!no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))
- );
- }
-
- #[test]
- fn parse_double_negation() {
- let expr = ConditionExpr::parse("!!no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::NoResults
- )))))
- );
- }
-
- #[test]
- fn parse_and() {
- let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- )
- );
- }
-
- #[test]
- fn parse_or() {
- let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_precedence_and_binds_tighter_than_or() {
- // "a || b && c" should parse as "a || (b && c)"
- let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )),
- )
- );
- }
-
- #[test]
- fn parse_parens_override_precedence() {
- // "(a || b) && c"
- let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::And(
- Box::new(ConditionExpr::Or(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
- )),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_complex_nested() {
- // "(a && !b) || c"
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
- assert_eq!(
- expr,
- ConditionExpr::Or(
- Box::new(ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::InputEmpty
- )))),
- )),
- Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
- )
- );
- }
-
- #[test]
- fn parse_whitespace_tolerance() {
- let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap();
- let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap();
- let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap();
- assert_eq!(a, b);
- assert_eq!(b, c);
- }
-
- #[test]
- fn parse_error_unknown_atom() {
- assert!(ConditionExpr::parse("unknown-thing").is_err());
- }
-
- #[test]
- fn parse_error_trailing_input() {
- assert!(ConditionExpr::parse("cursor-at-start blah").is_err());
- }
-
- #[test]
- fn parse_error_unmatched_paren() {
- assert!(ConditionExpr::parse("(cursor-at-start").is_err());
- }
-
- #[test]
- fn parse_error_empty() {
- assert!(ConditionExpr::parse("").is_err());
- }
-
- // -- Expression evaluation --
-
- #[test]
- fn eval_not() {
- let expr = ConditionExpr::parse("!no-results").unwrap();
- // Has results → !no-results is true
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));
- // No results → !no-results is false
- assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));
- }
-
- #[test]
- fn eval_and() {
- let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
- // Both true
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- // First true, second false (non-empty input)
- assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));
- // First false (cursor not at start)
- assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
- }
-
- #[test]
- fn eval_or() {
- let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
- // list at bottom (selected=0)
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- // no results
- assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));
- // neither
- assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));
- }
-
- #[test]
- fn eval_complex_nested() {
- // (cursor-at-start && !input-empty) || no-results
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
-
- // cursor at start, input not empty → true (left branch)
- assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));
- // no results → true (right branch)
- assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));
- // cursor not at start, has results → false
- assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
- // cursor at start, input empty → false (left: && fails; right: has results)
- assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));
- }
-
- // -- Display --
-
- #[test]
- fn display_atom() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
- assert_eq!(expr.to_string(), "cursor-at-start");
- }
-
- #[test]
- fn display_not() {
- let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();
- assert_eq!(expr.to_string(), "!no-results");
- }
-
- #[test]
- fn display_and() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
- .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));
- assert_eq!(expr.to_string(), "cursor-at-start && input-empty");
- }
-
- #[test]
- fn display_or() {
- let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)
- .or(ConditionExpr::Atom(ConditionAtom::NoResults));
- assert_eq!(expr.to_string(), "list-at-start || no-results");
- }
-
- #[test]
- fn display_parens_when_needed() {
- // (a || b) && c — the Or inside And needs parens
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
- .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))
- .and(ConditionExpr::Atom(ConditionAtom::NoResults));
- assert_eq!(
- expr.to_string(),
- "(cursor-at-start || input-empty) && no-results"
- );
- }
-
- #[test]
- fn display_no_parens_when_not_needed() {
- // a || b && c — no parens needed (and binds tighter)
- let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)
- .and(ConditionExpr::Atom(ConditionAtom::NoResults));
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);
- assert_eq!(
- expr.to_string(),
- "cursor-at-start || input-empty && no-results"
- );
- }
-
- // -- Display round-trip --
-
- #[test]
- fn display_round_trip() {
- let cases = [
- "cursor-at-start",
- "!no-results",
- "cursor-at-start && input-empty",
- "list-at-start || no-results",
- "(cursor-at-start || input-empty) && no-results",
- "(cursor-at-start && !input-empty) || no-results",
- ];
- for s in cases {
- let expr = ConditionExpr::parse(s).unwrap();
- let displayed = expr.to_string();
- let reparsed = ConditionExpr::parse(&displayed).unwrap();
- assert_eq!(expr, reparsed, "round-trip failed for: {s}");
- }
- }
-
- // -- Serde --
-
- #[test]
- fn serde_simple_atom() {
- let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
- let json = serde_json::to_string(&expr).unwrap();
- assert_eq!(json, "\"cursor-at-start\"");
- let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
- assert_eq!(parsed, expr);
- }
-
- #[test]
- fn serde_compound_expression() {
- let json = "\"cursor-at-start && !input-empty\"";
- let parsed: ConditionExpr = serde_json::from_str(json).unwrap();
- let expected = ConditionExpr::And(
- Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
- Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
- ConditionAtom::InputEmpty,
- )))),
- );
- assert_eq!(parsed, expected);
- }
-
- #[test]
- fn serde_round_trip() {
- let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
- let json = serde_json::to_string(&expr).unwrap();
- let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
- assert_eq!(expr, parsed);
- }
-
- // -- From<ConditionAtom> --
-
- #[test]
- fn from_atom_into_expr() {
- let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();
- assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
- }
-
- // -- Builder helpers --
-
- #[test]
- fn builder_chain() {
- let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)
- .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())
- .or(ConditionExpr::from(ConditionAtom::NoResults));
- // And binds tighter than Or, so no parens needed around the And
- assert_eq!(
- expr.to_string(),
- "cursor-at-start && !input-empty || no-results"
- );
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/defaults.rs b/crates/turtle/src/command/client/search/keybindings/defaults.rs
deleted file mode 100644
index 4d00b475..00000000
--- a/crates/turtle/src/command/client/search/keybindings/defaults.rs
+++ /dev/null
@@ -1,1274 +0,0 @@
-use std::collections::HashMap;
-
-use crate::atuin_client::settings::{KeyBindingConfig, Settings};
-use tracing::warn;
-
-use super::actions::Action;
-use super::conditions::{ConditionAtom, ConditionExpr};
-use super::key::KeyInput;
-use super::keymap::{KeyBinding, KeyRule, Keymap};
-
-/// Helper to bind a scroll key with optional exit behavior.
-///
-/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest
-/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →
-/// the scroll action.
-///
-/// Whether a key scrolls toward index 0 depends on the `invert` setting:
-/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away
-/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away
-///
-/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind
-/// the key to the plain scroll action (no exit).
-fn bind_scroll_key(
- km: &mut Keymap,
- key_str: &str,
- action: Action,
- toward_index_zero: bool,
- scroll_exits: bool,
-) {
- let k = key(key_str);
- if scroll_exits && toward_index_zero {
- km.bind_conditional(
- k,
- vec![
- KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),
- KeyRule::always(action),
- ],
- );
- } else {
- km.bind(k, action);
- }
-}
-
-/// Helper to parse a key string, panicking on invalid keys (these are all
-/// compile-time-known strings).
-fn key(s: &str) -> KeyInput {
- KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}"))
-}
-
-/// All five keymaps bundled together.
-#[derive(Debug, Clone)]
-pub(crate) struct KeymapSet {
- pub(crate) emacs: Keymap,
- pub(crate) vim_normal: Keymap,
- pub(crate) vim_insert: Keymap,
- pub(crate) inspector: Keymap,
- pub(crate) prefix: Keymap,
-}
-
-// ---------------------------------------------------------------------------
-// Common bindings shared across search-tab keymaps
-// ---------------------------------------------------------------------------
-
-/// Add the bindings that are common to all search-tab keymaps:
-/// ctrl-c, ctrl-g, ctrl-o, and tab.
-///
-/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs
-/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).
-fn add_common_bindings(km: &mut Keymap) {
- km.bind(key("ctrl-c"), Action::ReturnOriginal);
- km.bind(key("ctrl-g"), Action::ReturnOriginal);
- km.bind(key("ctrl-o"), Action::ToggleTab);
-
- // Tab: always returns selection without executing (unlike Enter which respects enter_accept)
- km.bind(key("tab"), Action::ReturnSelection);
-}
-
-/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.
-fn accept_action(settings: &Settings) -> Action {
- if settings.enter_accept {
- Action::Accept
- } else {
- Action::ReturnSelection
- }
-}
-
-// ---------------------------------------------------------------------------
-// Emacs keymap (also base for vim-insert)
-// ---------------------------------------------------------------------------
-
-/// Build the default emacs keymap. This encodes the behavior from
-/// `handle_key_input` common section + `handle_search_input` shared section.
-///
-/// The `settings` parameter is used for:
-/// - `keys.prefix` — which ctrl-key enters prefix mode
-/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior
-/// - `keys.accept_past_line_end` — right arrow at end of line accepts
-/// - `keys.exit_past_line_start` — left arrow at start of line exits
-/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)
-/// - `keys.accept_with_backspace` — backspace at start of line accepts
-/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts
-// Keymap builder that enumerates every default binding; not worth splitting.
-#[expect(clippy::too_many_lines)]
-pub(crate) fn default_emacs_keymap(settings: &Settings) -> Keymap {
- let mut km = Keymap::new();
- add_common_bindings(&mut km);
-
- let accept = accept_action(settings);
-
- // esc / ctrl-[ → exit
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
-
- // Prefix key: ctrl-<prefix_char> → enter prefix mode
- let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
- km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
-
- // --- Accept / navigation edge behaviors (from [keys] settings) ---
-
- // right: behavior at end of line
- if settings.keys.accept_past_line_end {
- km.bind_conditional(
- key("right"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection),
- KeyRule::always(Action::CursorRight),
- ],
- );
- } else {
- km.bind(key("right"), Action::CursorRight);
- }
-
- // left: behavior at start of line
- // accept_past_line_start takes precedence over exit_past_line_start
- if settings.keys.accept_past_line_start {
- km.bind_conditional(
- key("left"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
- KeyRule::always(Action::CursorLeft),
- ],
- );
- } else if settings.keys.exit_past_line_start {
- km.bind_conditional(
- key("left"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
- KeyRule::always(Action::CursorLeft),
- ],
- );
- } else {
- km.bind(key("left"), Action::CursorLeft);
- }
-
- // down/up: scroll with optional exit at boundary.
- // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).
- // Inverted: up moves toward index 0 (can exit); down moves away (no exit).
- let scroll_exits = settings.keys.scroll_exits;
- let invert = settings.invert;
- bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
-
- // backspace: behavior at start of line
- if settings.keys.accept_with_backspace {
- km.bind_conditional(
- key("backspace"),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection),
- KeyRule::always(Action::DeleteCharBefore),
- ],
- );
- } else {
- km.bind(key("backspace"), Action::DeleteCharBefore);
- }
-
- // --- Accept ---
- km.bind(key("enter"), accept.clone());
- km.bind(key("ctrl-m"), accept);
-
- // --- Copy ---
- km.bind(key("ctrl-y"), Action::Copy);
-
- // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---
- // These return the selection without executing, regardless of enter_accept.
- let num_mod = if settings.ctrl_n_shortcuts {
- "ctrl"
- } else {
- "alt"
- };
- for n in 1..=9u8 {
- km.bind(
- key(&format!("{num_mod}-{n}")),
- Action::ReturnSelectionNth(n),
- );
- }
-
- // --- Cursor movement ---
- km.bind(key("ctrl-left"), Action::CursorWordLeft);
- km.bind(key("alt-b"), Action::CursorWordLeft);
- km.bind(key("ctrl-b"), Action::CursorLeft);
- km.bind(key("ctrl-right"), Action::CursorWordRight);
- km.bind(key("alt-f"), Action::CursorWordRight);
- km.bind(key("ctrl-f"), Action::CursorRight);
- km.bind(key("home"), Action::CursorStart);
- // ctrl-a → CursorStart only if prefix char is NOT 'a'
- // (otherwise ctrl-a is already bound to EnterPrefixMode above)
- if prefix_char != 'a' {
- km.bind(key("ctrl-a"), Action::CursorStart);
- }
- km.bind(key("ctrl-e"), Action::CursorEnd);
- km.bind(key("end"), Action::CursorEnd);
-
- // --- Editing ---
- km.bind(key("ctrl-backspace"), Action::DeleteWordBefore);
- km.bind(key("ctrl-h"), Action::DeleteCharBefore);
- km.bind(key("ctrl-?"), Action::DeleteCharBefore);
- km.bind(key("ctrl-delete"), Action::DeleteWordAfter);
- km.bind(key("delete"), Action::DeleteCharAfter);
- // ctrl-d: if input empty → return original, otherwise delete char
- km.bind_conditional(
- key("ctrl-d"),
- vec![
- KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),
- KeyRule::always(Action::DeleteCharAfter),
- ],
- );
- km.bind(key("ctrl-w"), Action::DeleteToWordBoundary);
- km.bind(key("ctrl-u"), Action::ClearLine);
-
- // --- Search mode ---
- km.bind(key("ctrl-r"), Action::CycleFilterMode);
- km.bind(key("ctrl-s"), Action::CycleSearchMode);
-
- // --- Scroll (no exit) ---
- km.bind(key("ctrl-n"), Action::SelectNext);
- km.bind(key("ctrl-j"), Action::SelectNext);
- km.bind(key("ctrl-p"), Action::SelectPrevious);
- km.bind(key("ctrl-k"), Action::SelectPrevious);
-
- // --- Redraw ---
- km.bind(key("ctrl-l"), Action::Redraw);
-
- // --- Page scroll ---
- km.bind(key("pagedown"), Action::ScrollPageDown);
- km.bind(key("pageup"), Action::ScrollPageUp);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Vim Normal keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default vim-normal keymap.
-pub(crate) fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
- let mut km = Keymap::new();
- add_common_bindings(&mut km);
-
- // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
-
- // Prefix key
- let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
- km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
-
- // --- Vim navigation ---
- // j/k: scroll with optional exit at boundary.
- let scroll_exits = settings.keys.scroll_exits;
- let invert = settings.invert;
- bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits);
- km.bind(key("h"), Action::CursorLeft);
- km.bind(key("l"), Action::CursorRight);
-
- // --- Vim cursor movement ---
- km.bind(key("0"), Action::CursorStart);
- km.bind(key("$"), Action::CursorEnd);
- km.bind(key("w"), Action::CursorWordRight);
- km.bind(key("b"), Action::CursorWordLeft);
- km.bind(key("e"), Action::CursorWordEnd);
-
- // --- Vim editing ---
- km.bind(key("x"), Action::DeleteCharAfter);
- km.bind(key("d d"), Action::ClearLine);
- km.bind(key("D"), Action::ClearToEnd);
- km.bind(key("C"), Action::VimChangeToEnd);
-
- // --- Mode switching ---
- km.bind(key("?"), Action::VimSearchInsert);
- km.bind(key("/"), Action::VimSearchInsert);
- km.bind(key("a"), Action::VimEnterInsertAfter);
- km.bind(key("A"), Action::VimEnterInsertAtEnd);
- km.bind(key("i"), Action::VimEnterInsert);
- km.bind(key("I"), Action::VimEnterInsertAtStart);
-
- // --- Numeric shortcuts (return selection without executing) ---
- for n in 1..=9u8 {
- km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));
- }
-
- // --- Half/full page scroll ---
- km.bind(key("ctrl-u"), Action::ScrollHalfPageUp);
- km.bind(key("ctrl-d"), Action::ScrollHalfPageDown);
- km.bind(key("ctrl-b"), Action::ScrollPageUp);
- km.bind(key("ctrl-f"), Action::ScrollPageDown);
-
- // --- Jump ---
- km.bind(key("G"), Action::ScrollToBottom);
- km.bind(key("g g"), Action::ScrollToTop);
- km.bind(key("H"), Action::ScrollToScreenTop);
- km.bind(key("M"), Action::ScrollToScreenMiddle);
- km.bind(key("L"), Action::ScrollToScreenBottom);
-
- // --- Arrow keys (same as emacs for convenience) ---
- bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
- bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
-
- // --- Page scroll ---
- km.bind(key("pagedown"), Action::ScrollPageDown);
- km.bind(key("pageup"), Action::ScrollPageUp);
-
- // --- Accept ---
- let accept = accept_action(settings);
- km.bind(key("enter"), accept);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Vim Insert keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default vim-insert keymap. This clones the emacs keymap and
-/// overlays vim-insert-specific bindings (esc → enter normal mode).
-pub(crate) fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
- let mut km = default_emacs_keymap(settings);
-
- // Override esc and ctrl-[ to enter normal mode instead of exiting
- km.bind(key("esc"), Action::VimEnterNormal);
- km.bind(key("ctrl-["), Action::VimEnterNormal);
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Inspector keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default inspector keymap (tab index 1).
-///
-/// The inspector shows details about the selected history item and has no
-/// text input, so we build a minimal keymap with only inspector-relevant
-/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k
-/// navigation for vim users.
-pub(crate) fn default_inspector_keymap(settings: &Settings) -> Keymap {
- use crate::atuin_client::settings::KeymapMode;
-
- let mut km = Keymap::new();
-
- // Common bindings (same as search tab)
- km.bind(key("ctrl-c"), Action::ReturnOriginal);
- km.bind(key("ctrl-g"), Action::ReturnOriginal);
- km.bind(key("esc"), Action::Exit);
- km.bind(key("ctrl-["), Action::Exit);
- km.bind(key("tab"), Action::ReturnSelection);
- km.bind(key("ctrl-o"), Action::ToggleTab);
-
- // Accept behavior respects enter_accept setting
- let accept = if settings.enter_accept {
- Action::Accept
- } else {
- Action::ReturnSelection
- };
- km.bind(key("enter"), accept);
-
- // Inspector-specific: delete history entry
- km.bind(key("ctrl-d"), Action::Delete);
-
- // Inspector navigation
- km.bind(key("up"), Action::InspectPrevious);
- km.bind(key("down"), Action::InspectNext);
- km.bind(key("pageup"), Action::InspectPrevious);
- km.bind(key("pagedown"), Action::InspectNext);
-
- // For vim users, add j/k navigation
- if matches!(
- settings.keymap_mode,
- KeymapMode::VimNormal | KeymapMode::VimInsert
- ) {
- km.bind(key("j"), Action::InspectNext);
- km.bind(key("k"), Action::InspectPrevious);
- }
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// Prefix keymap
-// ---------------------------------------------------------------------------
-
-/// Build the default prefix keymap (active after ctrl-a prefix).
-pub(crate) fn default_prefix_keymap() -> Keymap {
- let mut km = Keymap::new();
-
- km.bind(key("d"), Action::Delete);
- km.bind(key("D"), Action::DeleteAll);
- km.bind(key("a"), Action::CursorStart);
- km.bind_conditional(
- key("c"),
- vec![
- KeyRule::when(ConditionAtom::HasContext, Action::ClearContext),
- KeyRule::always(Action::SwitchContext),
- ],
- );
-
- km
-}
-
-// ---------------------------------------------------------------------------
-// KeymapSet construction
-// ---------------------------------------------------------------------------
-
-// ---------------------------------------------------------------------------
-// Config → Keymap conversion
-// ---------------------------------------------------------------------------
-
-/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.
-/// Returns `Err` if an action name or condition expression is invalid.
-fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {
- match config {
- KeyBindingConfig::Simple(action_str) => {
- let action = Action::from_str(action_str)?;
- Ok(KeyBinding::simple(action))
- }
- KeyBindingConfig::Rules(rules) => {
- let mut parsed_rules = Vec::with_capacity(rules.len());
- for rule_cfg in rules {
- let action = Action::from_str(&rule_cfg.action)?;
- let rule = match &rule_cfg.when {
- None => KeyRule::always(action),
- Some(cond_str) => {
- let cond = ConditionExpr::parse(cond_str)?;
- KeyRule::when(cond, action)
- }
- };
- parsed_rules.push(rule);
- }
- Ok(KeyBinding::conditional(parsed_rules))
- }
- }
-}
-
-/// Apply a map of key-string → binding-config overrides to a keymap.
-/// Per-key override replaces the entire rule list for that key.
-/// Invalid keys or action names are logged and skipped.
-fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {
- for (key_str, binding_cfg) in overrides {
- let key = match KeyInput::parse(key_str) {
- Ok(k) => k,
- Err(e) => {
- warn!("invalid key in keymap config: {key_str:?}: {e}");
- continue;
- }
- };
- match parse_binding_config(binding_cfg) {
- Ok(binding) => {
- keymap.bindings.insert(key, binding);
- }
- Err(e) => {
- warn!("invalid binding for {key_str:?} in keymap config: {e}");
- }
- }
- }
-}
-
-impl KeymapSet {
- /// Build the complete set of default keymaps from settings.
- pub(crate) fn defaults(settings: &Settings) -> Self {
- Self {
- emacs: default_emacs_keymap(settings),
- vim_normal: default_vim_normal_keymap(settings),
- vim_insert: default_vim_insert_keymap(settings),
- inspector: default_inspector_keymap(settings),
- prefix: default_prefix_keymap(),
- }
- }
-
- /// Build keymaps from settings, applying any user `[keymap]` overrides.
- ///
- /// Precedence rules:
- /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.
- /// Defaults are built with standard `[keys]` values, then `[keymap]`
- /// overrides are applied per-key.
- /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults
- /// (current behavior for backward compatibility).
- pub(crate) fn from_settings(settings: &Settings) -> Self {
- use crate::atuin_client::settings::Keys;
-
- if settings.keymap.is_empty() {
- // No [keymap] section → use [keys] to customize defaults
- Self::defaults(settings)
- } else {
- // [keymap] present → ignore [keys], use standard defaults as base
- let mut base_settings = settings.clone();
- base_settings.keys = Keys::standard_defaults();
- let mut set = Self::defaults(&base_settings);
- set.apply_config(settings);
- set
- }
- }
-
- /// Apply user keymap config overrides to all modes.
- fn apply_config(&mut self, settings: &Settings) {
- let config = &settings.keymap;
- apply_config_to_keymap(&mut self.emacs, &config.emacs);
- apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);
- apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);
- apply_config_to_keymap(&mut self.inspector, &config.inspector);
- apply_config_to_keymap(&mut self.prefix, &config.prefix);
- }
-}
-
-// ---------------------------------------------------------------------------
-// Tests
-// ---------------------------------------------------------------------------
-
-#[cfg(test)]
-mod tests {
- use super::{
- Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap,
- default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key,
- parse_binding_config,
- };
- use crate::command::client::search::keybindings::conditions::EvalContext;
-
- fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: width,
- selected_index: selected,
- results_len: len,
- original_input_empty: false,
- has_context: false,
- }
- }
-
- fn default_settings() -> Settings {
- Settings::new().unwrap()
- }
-
- // -- Emacs keymap tests --
-
- #[test]
- fn emacs_ctrl_c_returns_original() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-c"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- #[test]
- fn emacs_esc_exits() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_tab_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- #[test]
- fn emacs_enter_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn emacs_enter_accept_true_uses_accept() {
- let mut settings = default_settings();
- settings.enter_accept = true;
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- #[test]
- fn emacs_right_at_end_returns_selection() {
- let km = default_emacs_keymap(&default_settings());
- // cursor at end of "hello" (width 5)
- let ctx = make_ctx(5, 5, 0, 10);
- assert_eq!(
- km.resolve(&key("right"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn emacs_right_not_at_end_moves() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(2, 5, 0, 10);
- assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight));
- }
-
- #[test]
- fn emacs_left_at_start_exits() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 5, 0, 10);
- assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_left_not_at_start_moves() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft));
- }
-
- #[test]
- fn emacs_down_at_start_exits() {
- let km = default_emacs_keymap(&default_settings());
- // selected=0 → ListAtStart → Exit
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn emacs_down_not_at_start_selects_next() {
- let km = default_emacs_keymap(&default_settings());
- // selected=5 → not at start → SelectNext
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn emacs_up_selects_previous() {
- let km = default_emacs_keymap(&default_settings());
- // Non-inverted: up never exits (moves away from index 0)
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious));
- }
-
- #[test]
- fn emacs_ctrl_d_empty_returns_original() {
- let km = default_emacs_keymap(&default_settings());
- // input empty (byte_len = 0)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- #[test]
- fn emacs_ctrl_d_nonempty_deletes() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(2, 5, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::DeleteCharAfter)
- );
- }
-
- #[test]
- fn emacs_ctrl_n_selects_next_no_exit_condition() {
- let km = default_emacs_keymap(&default_settings());
- // at start, but ctrl-n should NOT exit (no exit condition bound)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn emacs_prefix_key_enters_prefix() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-a"), &ctx),
- Some(Action::EnterPrefixMode)
- );
- }
-
- #[test]
- fn emacs_home_cursor_start() {
- let km = default_emacs_keymap(&default_settings());
- let ctx = make_ctx(5, 10, 0, 10);
- assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart));
- }
-
- // -- Vim Normal keymap tests --
-
- #[test]
- fn vim_normal_j_at_start_exits() {
- let km = default_vim_normal_keymap(&default_settings());
- // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn vim_normal_j_not_at_start_selects_next() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext));
- }
-
- #[test]
- fn vim_normal_k_selects_previous() {
- let km = default_vim_normal_keymap(&default_settings());
- // Non-inverted: k never exits (moves away from index 0)
- let ctx = make_ctx(0, 0, 5, 10);
- assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious));
- }
-
- #[test]
- fn vim_normal_i_enters_insert() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert));
- }
-
- #[test]
- fn vim_normal_slash_search_insert() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert));
- }
-
- #[test]
- fn vim_normal_gg_scroll_to_top() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop));
- }
-
- #[test]
- fn vim_normal_big_g_scroll_to_bottom() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom));
- }
-
- #[test]
- fn vim_normal_numeric_returns_selection() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("3"), &ctx),
- Some(Action::ReturnSelectionNth(3))
- );
- }
-
- #[test]
- fn vim_normal_ctrl_u_half_page_up() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(
- km.resolve(&key("ctrl-u"), &ctx),
- Some(Action::ScrollHalfPageUp)
- );
- }
-
- #[test]
- fn vim_normal_screen_jumps() {
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 50, 100);
- assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop));
- assert_eq!(
- km.resolve(&key("M"), &ctx),
- Some(Action::ScrollToScreenMiddle)
- );
- assert_eq!(
- km.resolve(&key("L"), &ctx),
- Some(Action::ScrollToScreenBottom)
- );
- }
-
- #[test]
- fn vim_normal_enter_returns_selection() {
- // enter_accept=false in test defaults → ReturnSelection
- let km = default_vim_normal_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn vim_normal_enter_accept_true_uses_accept() {
- let mut settings = default_settings();
- settings.enter_accept = true;
- let km = default_vim_normal_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
- }
-
- // -- Vim Insert keymap tests --
-
- #[test]
- fn vim_insert_inherits_emacs_enter() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- // enter_accept=false → ReturnSelection
- assert_eq!(
- km.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn vim_insert_esc_enters_normal() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal));
- }
-
- #[test]
- fn vim_insert_ctrl_bracket_enters_normal() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- km.resolve(&key("ctrl-["), &ctx),
- Some(Action::VimEnterNormal)
- );
- }
-
- #[test]
- fn vim_insert_inherits_emacs_ctrl_d() {
- let km = default_vim_insert_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- // input empty → return original
- assert_eq!(
- km.resolve(&key("ctrl-d"), &ctx),
- Some(Action::ReturnOriginal)
- );
- }
-
- // -- Inspector keymap tests --
-
- #[test]
- fn inspector_ctrl_d_deletes() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete));
- }
-
- #[test]
- fn inspector_up_inspects_previous() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious));
- }
-
- #[test]
- fn inspector_down_inspects_next() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext));
- }
-
- #[test]
- fn inspector_esc_exits() {
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn inspector_tab_returns_selection() {
- // enter_accept=false → ReturnSelection
- let km = default_inspector_keymap(&default_settings());
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
- }
-
- // -- Prefix keymap tests --
-
- #[test]
- fn prefix_d_deletes() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete));
- }
-
- #[test]
- fn prefix_a_cursor_start() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart));
- }
-
- #[test]
- fn prefix_unknown_key_returns_none() {
- let km = default_prefix_keymap();
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(km.resolve(&key("x"), &ctx), None);
- }
-
- // -- KeymapSet tests --
-
- #[test]
- fn keymap_set_defaults_builds() {
- let settings = default_settings();
- let set = KeymapSet::defaults(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // Sanity check each keymap has bindings
- assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some());
- assert!(set.prefix.resolve(&key("d"), &ctx).is_some());
- }
-
- // -- Settings-dependent behavior --
-
- #[test]
- fn custom_prefix_char() {
- let mut settings = default_settings();
- settings.keys.prefix = "x".to_string();
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-x should be prefix mode
- assert_eq!(
- km.resolve(&key("ctrl-x"), &ctx),
- Some(Action::EnterPrefixMode)
- );
- // ctrl-a should now be CursorStart (not prefix)
- assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart));
- }
-
- #[test]
- fn ctrl_n_shortcuts_changes_numeric_modifier() {
- let mut settings = default_settings();
- settings.ctrl_n_shortcuts = true;
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-1 should work
- assert_eq!(
- km.resolve(&key("ctrl-1"), &ctx),
- Some(Action::ReturnSelectionNth(1))
- );
- // alt-1 should NOT be bound
- assert_eq!(km.resolve(&key("alt-1"), &ctx), None);
- }
-
- #[test]
- fn default_alt_numeric_shortcuts() {
- let settings = default_settings();
- let km = default_emacs_keymap(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // alt-1 should work by default
- assert_eq!(
- km.resolve(&key("alt-1"), &ctx),
- Some(Action::ReturnSelectionNth(1))
- );
- }
-
- // -----------------------------------------------------------------------
- // Config parsing and merging tests
- // -----------------------------------------------------------------------
-
- #[test]
- fn parse_simple_binding_config() {
- use crate::atuin_client::settings::KeyBindingConfig;
- let cfg = KeyBindingConfig::Simple("accept".to_string());
- let binding = parse_binding_config(&cfg).unwrap();
- assert_eq!(binding.rules.len(), 1);
- assert!(binding.rules[0].condition.is_none());
- assert_eq!(binding.rules[0].action, Action::Accept);
- }
-
- #[test]
- fn parse_conditional_binding_config() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- let cfg = KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("cursor-at-start".to_string()),
- action: "exit".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "cursor-left".to_string(),
- },
- ]);
- let binding = parse_binding_config(&cfg).unwrap();
- assert_eq!(binding.rules.len(), 2);
- assert!(binding.rules[0].condition.is_some());
- assert_eq!(binding.rules[0].action, Action::Exit);
- assert!(binding.rules[1].condition.is_none());
- assert_eq!(binding.rules[1].action, Action::CursorLeft);
- }
-
- #[test]
- fn parse_binding_config_invalid_action() {
- use crate::atuin_client::settings::KeyBindingConfig;
- let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string());
- assert!(parse_binding_config(&cfg).is_err());
- }
-
- #[test]
- fn parse_binding_config_invalid_condition() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {
- when: Some("not-a-real-condition".to_string()),
- action: "exit".to_string(),
- }]);
- assert!(parse_binding_config(&cfg).is_err());
- }
-
- #[test]
- fn config_override_replaces_key() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- let set = KeymapSet::defaults(&settings);
-
- // Default: ctrl-c → ReturnOriginal
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set.emacs.resolve(&key("ctrl-c"), &ctx),
- Some(Action::ReturnOriginal)
- );
-
- // Override ctrl-c → Exit via config
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
- assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn config_override_preserves_unoverridden_keys() {
- use crate::atuin_client::settings::KeyBindingConfig;
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Override only ctrl-c; enter should keep its default
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
- let ctx = make_ctx(0, 0, 0, 10);
-
- // ctrl-c overridden
- assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
- // enter still has default (enter_accept=false → ReturnSelection)
- assert_eq!(
- set.emacs.resolve(&key("enter"), &ctx),
- Some(Action::ReturnSelection)
- );
- }
-
- #[test]
- fn config_conditional_override() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Override "up" with a custom conditional
- settings.keymap.emacs = HashMap::from([(
- "up".to_string(),
- KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("no-results".to_string()),
- action: "exit".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "select-previous".to_string(),
- },
- ]),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
-
- // With no results → exit
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit));
-
- // With results → select-previous
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set.emacs.resolve(&key("up"), &ctx),
- Some(Action::SelectPrevious)
- );
- }
-
- #[test]
- fn from_settings_with_empty_config_equals_defaults() {
- let settings = default_settings();
- let defaults = KeymapSet::defaults(&settings);
- let from_settings = KeymapSet::from_settings(&settings);
-
- // Verify a sample of keys produce the same results
- let ctx = make_ctx(0, 0, 0, 10);
- let test_keys = [
- "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right",
- ];
- for k in &test_keys {
- assert_eq!(
- defaults.emacs.resolve(&key(k), &ctx),
- from_settings.emacs.resolve(&key(k), &ctx),
- "mismatch for emacs key {k}"
- );
- }
- }
-
- // -----------------------------------------------------------------------
- // Phase 5: [keys] vs [keymap] backward compatibility
- // -----------------------------------------------------------------------
-
- #[test]
- fn keymap_overrides_ignore_keys_section() {
- use crate::atuin_client::settings::KeyBindingConfig;
-
- // Set up: [keys] disables scroll_exits, but [keymap] is present
- let mut settings = default_settings();
- settings.keys.scroll_exits = false;
-
- // Without [keymap], scroll_exits=false means no exit condition on down
- let set_legacy = KeymapSet::defaults(&settings);
- // At list-at-start (selected=0), down should still be SelectNext (no exit)
- let ctx_at_boundary = make_ctx(0, 0, 0, 10);
- assert_eq!(
- set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary),
- Some(Action::SelectNext),
- "legacy: down at boundary should be SelectNext with scroll_exits=false"
- );
-
- // With [keymap] present (even just one override), [keys] is ignored
- // so the standard defaults (scroll_exits=true) apply
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
- let set_keymap = KeymapSet::from_settings(&settings);
-
- // Not at boundary (selected=5): should SelectNext normally
- let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);
- assert_eq!(
- set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary),
- Some(Action::SelectNext),
- "keymap: down not at boundary should SelectNext"
- );
- // At list-at-start (selected=0): should Exit (standard scroll_exits=true)
- assert_eq!(
- set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary),
- Some(Action::Exit),
- "keymap: down at boundary should Exit (standard defaults restored)"
- );
- }
-
- #[test]
- fn keymap_present_resets_to_standard_keys_defaults() {
- use crate::atuin_client::settings::KeyBindingConfig;
-
- let mut settings = default_settings();
- // Disable all [keys] behaviors
- settings.keys.exit_past_line_start = false;
- settings.keys.accept_past_line_end = false;
-
- // Without [keymap], left should be plain CursorLeft
- let set_legacy = KeymapSet::defaults(&settings);
- let ctx_at_start = make_ctx(0, 5, 0, 10);
- assert_eq!(
- set_legacy.emacs.resolve(&key("left"), &ctx_at_start),
- Some(Action::CursorLeft),
- "legacy: left should be plain CursorLeft without exit_past_line_start"
- );
-
- // Add a [keymap] entry (for a different key)
- settings.keymap.emacs = HashMap::from([(
- "ctrl-c".to_string(),
- KeyBindingConfig::Simple("exit".to_string()),
- )]);
- let set_keymap = KeymapSet::from_settings(&settings);
-
- // Now left should use standard defaults (exit_past_line_start=true)
- // At cursor start → Exit
- assert_eq!(
- set_keymap.emacs.resolve(&key("left"), &ctx_at_start),
- Some(Action::Exit),
- "keymap: left at cursor start should exit (standard defaults)"
- );
-
- // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)
- let ctx_at_end = make_ctx(5, 5, 0, 10);
- assert_eq!(
- set_keymap.emacs.resolve(&key("right"), &ctx_at_end),
- Some(Action::ReturnSelection),
- "keymap: right at cursor end should return selection (standard defaults)"
- );
- }
-
- #[test]
- fn original_input_empty_condition_in_config() {
- use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
- use std::collections::HashMap;
-
- let mut settings = default_settings();
- // Configure esc to: if original-input-empty -> return-query, else return-original
- settings.keymap.emacs = HashMap::from([(
- "esc".to_string(),
- KeyBindingConfig::Rules(vec![
- KeyRuleConfig {
- when: Some("original-input-empty".to_string()),
- action: "return-query".to_string(),
- },
- KeyRuleConfig {
- when: None,
- action: "return-original".to_string(),
- },
- ]),
- )]);
-
- let set = KeymapSet::from_settings(&settings);
-
- // When original input was empty, should return-query
- let ctx_original_empty = EvalContext {
- cursor_position: 0,
- input_width: 5,
- input_byte_len: 5,
- selected_index: 0,
- results_len: 10,
- original_input_empty: true,
- has_context: false,
- };
- assert_eq!(
- set.emacs.resolve(&key("esc"), &ctx_original_empty),
- Some(Action::ReturnQuery),
- "esc with original_input_empty=true should return-query"
- );
-
- // When original input was not empty, should return-original
- let ctx_original_not_empty = EvalContext {
- cursor_position: 0,
- input_width: 5,
- input_byte_len: 5,
- selected_index: 0,
- results_len: 10,
- original_input_empty: false,
- has_context: false,
- };
- assert_eq!(
- set.emacs.resolve(&key("esc"), &ctx_original_not_empty),
- Some(Action::ReturnOriginal),
- "esc with original_input_empty=false should return-original"
- );
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/key.rs b/crates/turtle/src/command/client/search/keybindings/key.rs
deleted file mode 100644
index 5e772238..00000000
--- a/crates/turtle/src/command/client/search/keybindings/key.rs
+++ /dev/null
@@ -1,633 +0,0 @@
-use std::fmt;
-
-use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode};
-use serde::{Deserialize, Deserializer, Serialize, Serializer};
-
-/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct SingleKey {
- pub(crate) code: KeyCodeValue,
- pub(crate) ctrl: bool,
- pub(crate) alt: bool,
- pub(crate) shift: bool,
- pub(crate) super_key: bool,
-}
-
-/// The key code portion of a key press.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
-#[expect(
- variant_size_differences,
- reason = "It's not that much. So should be ok?"
-)]
-pub(crate) enum KeyCodeValue {
- Char(char),
- Enter,
- Esc,
- Tab,
- Backspace,
- Delete,
- Insert,
- Up,
- Down,
- Left,
- Right,
- Home,
- End,
- PageUp,
- PageDown,
- Space,
- F(u8),
- Media(MediaKeyCode),
-}
-
-/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub(crate) enum KeyInput {
- Single(SingleKey),
- Sequence(Vec<SingleKey>),
-}
-
-impl SingleKey {
- /// Convert a crossterm `KeyEvent` into a `SingleKey`.
- pub(crate) fn from_event(event: &KeyEvent) -> Option<Self> {
- let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
- let alt = event.modifiers.contains(KeyModifiers::ALT);
- let shift = event.modifiers.contains(KeyModifiers::SHIFT);
- let super_key = event.modifiers.contains(KeyModifiers::SUPER);
-
- let code = match event.code {
- KeyCode::Char(' ') => KeyCodeValue::Space,
- KeyCode::Char(c) => {
- // If shift is the only modifier and it's an uppercase letter,
- // we store the uppercase char directly and clear the shift flag
- // since the case already encodes it.
- if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {
- return Some(Self {
- code: KeyCodeValue::Char(c),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- });
- }
- KeyCodeValue::Char(c)
- }
- KeyCode::Enter => KeyCodeValue::Enter,
- KeyCode::Esc => KeyCodeValue::Esc,
- KeyCode::Tab => KeyCodeValue::Tab,
- // BackTab is sent by many terminals for Shift+Tab
- KeyCode::BackTab => {
- return Some(Self {
- code: KeyCodeValue::Tab,
- ctrl,
- alt,
- shift: true,
- super_key,
- });
- }
- KeyCode::Backspace => KeyCodeValue::Backspace,
- KeyCode::Delete => KeyCodeValue::Delete,
- KeyCode::Insert => KeyCodeValue::Insert,
- KeyCode::Up => KeyCodeValue::Up,
- KeyCode::Down => KeyCodeValue::Down,
- KeyCode::Left => KeyCodeValue::Left,
- KeyCode::Right => KeyCodeValue::Right,
- KeyCode::Home => KeyCodeValue::Home,
- KeyCode::End => KeyCodeValue::End,
- KeyCode::PageUp => KeyCodeValue::PageUp,
- KeyCode::PageDown => KeyCodeValue::PageDown,
- KeyCode::F(n) => KeyCodeValue::F(n),
- KeyCode::Media(m) => KeyCodeValue::Media(m),
- _ => return None,
- };
-
- Some(Self {
- code,
- ctrl,
- alt,
- shift: if matches!(code, KeyCodeValue::Char(_)) {
- false
- } else {
- shift
- },
- super_key,
- })
- }
-
- /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`.
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let s = s.trim();
- let parts: Vec<&str> = s.split('-').collect();
-
- let mut ctrl = false;
- let mut alt = false;
- let mut shift = false;
- let mut super_key = false;
-
- // All parts except the last are modifiers
- for &part in &parts[..parts.len() - 1] {
- match part.to_lowercase().as_str() {
- "ctrl" => ctrl = true,
- "alt" => alt = true,
- "shift" => shift = true,
- "super" | "cmd" | "win" => super_key = true,
- _ => return Err(format!("unknown modifier: {part}")),
- }
- }
-
- let key_part = parts[parts.len() - 1];
- let code = match key_part.to_lowercase().as_str() {
- "enter" | "return" => KeyCodeValue::Enter,
- "esc" | "escape" => KeyCodeValue::Esc,
- "tab" => KeyCodeValue::Tab,
- "backspace" => KeyCodeValue::Backspace,
- "delete" | "del" => KeyCodeValue::Delete,
- "insert" | "ins" => KeyCodeValue::Insert,
- "up" => KeyCodeValue::Up,
- "down" => KeyCodeValue::Down,
- "left" => KeyCodeValue::Left,
- "right" => KeyCodeValue::Right,
- "home" => KeyCodeValue::Home,
- "end" => KeyCodeValue::End,
- "pageup" => KeyCodeValue::PageUp,
- "pagedown" => KeyCodeValue::PageDown,
- "space" => KeyCodeValue::Space,
- s if s.starts_with('f') && s.len() > 1 => {
- // Parse function keys like "f1", "f12"
- if let Ok(n) = s[1..].parse::<u8>() {
- if (1..=24).contains(&n) {
- KeyCodeValue::F(n)
- } else {
- return Err(format!("function key out of range: {key_part}"));
- }
- } else {
- return Err(format!("unknown key: {key_part}"));
- }
- }
- "[" => KeyCodeValue::Char('['),
- "]" => KeyCodeValue::Char(']'),
- "?" => KeyCodeValue::Char('?'),
- "/" => KeyCodeValue::Char('/'),
- "$" => KeyCodeValue::Char('$'),
- // Media keys (no dashes - the parser splits on dash for modifiers)
- "play" => KeyCodeValue::Media(MediaKeyCode::Play),
- "pause" => KeyCodeValue::Media(MediaKeyCode::Pause),
- "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause),
- "stop" => KeyCodeValue::Media(MediaKeyCode::Stop),
- "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward),
- "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind),
- "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext),
- "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious),
- "record" => KeyCodeValue::Media(MediaKeyCode::Record),
- "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume),
- "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume),
- "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume),
- _ => {
- let chars: Vec<char> = key_part.chars().collect();
- if chars.len() == 1 {
- let c = chars[0];
- // An uppercase letter implies shift (unless shift already specified)
- if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {
- return Ok(Self {
- code: KeyCodeValue::Char(c),
- ctrl: false,
- alt: false,
- shift: false,
- super_key: false,
- });
- }
- KeyCodeValue::Char(c)
- } else {
- return Err(format!("unknown key: {key_part}"));
- }
- }
- };
-
- Ok(Self {
- code,
- ctrl,
- alt,
- shift,
- super_key,
- })
- }
-}
-
-impl fmt::Display for SingleKey {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- if self.super_key {
- write!(f, "super-")?;
- }
- if self.ctrl {
- write!(f, "ctrl-")?;
- }
- if self.alt {
- write!(f, "alt-")?;
- }
- if self.shift {
- write!(f, "shift-")?;
- }
- match &self.code {
- KeyCodeValue::Char(c) => write!(f, "{c}"),
- KeyCodeValue::Enter => write!(f, "enter"),
- KeyCodeValue::Esc => write!(f, "esc"),
- KeyCodeValue::Tab => write!(f, "tab"),
- KeyCodeValue::Backspace => write!(f, "backspace"),
- KeyCodeValue::Delete => write!(f, "delete"),
- KeyCodeValue::Insert => write!(f, "insert"),
- KeyCodeValue::Up => write!(f, "up"),
- KeyCodeValue::Down => write!(f, "down"),
- KeyCodeValue::Left => write!(f, "left"),
- KeyCodeValue::Right => write!(f, "right"),
- KeyCodeValue::Home => write!(f, "home"),
- KeyCodeValue::End => write!(f, "end"),
- KeyCodeValue::PageUp => write!(f, "pageup"),
- KeyCodeValue::PageDown => write!(f, "pagedown"),
- KeyCodeValue::Space => write!(f, "space"),
- KeyCodeValue::F(n) => write!(f, "f{n}"),
- KeyCodeValue::Media(m) => match m {
- MediaKeyCode::Play => write!(f, "play"),
- MediaKeyCode::Pause => write!(f, "media-pause"),
- MediaKeyCode::PlayPause => write!(f, "playpause"),
- MediaKeyCode::Stop => write!(f, "stop"),
- MediaKeyCode::FastForward => write!(f, "fastforward"),
- MediaKeyCode::Rewind => write!(f, "rewind"),
- MediaKeyCode::TrackNext => write!(f, "tracknext"),
- MediaKeyCode::TrackPrevious => write!(f, "trackprevious"),
- MediaKeyCode::Record => write!(f, "record"),
- MediaKeyCode::LowerVolume => write!(f, "lowervolume"),
- MediaKeyCode::RaiseVolume => write!(f, "raisevolume"),
- MediaKeyCode::MuteVolume => write!(f, "mutevolume"),
- MediaKeyCode::Reverse => write!(f, "reverse"),
- },
- }
- }
-}
-
-impl KeyInput {
- /// Parse a key input string. Supports multi-key sequences separated by spaces
- /// (e.g. `"g g"`).
- pub(crate) fn parse(s: &str) -> Result<Self, String> {
- let s = s.trim();
- // Check for space-separated multi-key sequences
- // But don't split "space" or modifier combos like "ctrl-a"
- let parts: Vec<&str> = s.split_whitespace().collect();
- if parts.len() > 1 {
- let keys: Result<Vec<SingleKey>, String> =
- parts.iter().map(|p| SingleKey::parse(p)).collect();
- Ok(Self::Sequence(keys?))
- } else {
- Ok(Self::Single(SingleKey::parse(s)?))
- }
- }
-}
-
-impl fmt::Display for KeyInput {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Single(k) => write!(f, "{k}"),
- Self::Sequence(keys) => {
- for (i, k) in keys.iter().enumerate() {
- if i > 0 {
- write!(f, " ")?;
- }
- write!(f, "{k}")?;
- }
- Ok(())
- }
- }
- }
-}
-
-impl Serialize for KeyInput {
- fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
- serializer.serialize_str(&self.to_string())
- }
-}
-
-impl<'de> Deserialize<'de> for KeyInput {
- fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
- let s = String::deserialize(deserializer)?;
- Self::parse(&s).map_err(serde::de::Error::custom)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{KeyCodeValue, KeyInput, SingleKey};
- use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
- #[test]
- fn parse_simple_keys() {
- let k = SingleKey::parse("a").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("enter").unwrap();
- assert_eq!(k.code, KeyCodeValue::Enter);
-
- let k = SingleKey::parse("esc").unwrap();
- assert_eq!(k.code, KeyCodeValue::Esc);
-
- let k = SingleKey::parse("tab").unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
-
- let k = SingleKey::parse("space").unwrap();
- assert_eq!(k.code, KeyCodeValue::Space);
- }
-
- #[test]
- fn parse_modifiers() {
- let k = SingleKey::parse("ctrl-c").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.ctrl);
- assert!(!k.alt);
-
- let k = SingleKey::parse("alt-f").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('f'));
- assert!(k.alt);
- assert!(!k.ctrl);
-
- let k = SingleKey::parse("ctrl-alt-x").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('x'));
- assert!(k.ctrl && k.alt);
- }
-
- #[test]
- fn parse_uppercase_implies_no_shift_flag() {
- let k = SingleKey::parse("G").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- assert!(!k.shift);
- assert!(!k.ctrl);
- }
-
- #[test]
- fn parse_special_chars() {
- let k = SingleKey::parse("ctrl-[").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('['));
- assert!(k.ctrl);
-
- let k = SingleKey::parse("?").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('?'));
-
- let k = SingleKey::parse("/").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('/'));
- }
-
- #[test]
- fn parse_multi_key_sequence() {
- let ki = KeyInput::parse("g g").unwrap();
- match ki {
- KeyInput::Sequence(keys) => {
- assert_eq!(keys.len(), 2);
- assert_eq!(keys[0].code, KeyCodeValue::Char('g'));
- assert_eq!(keys[1].code, KeyCodeValue::Char('g'));
- }
- _ => panic!("expected sequence"),
- }
- }
-
- #[test]
- fn display_round_trip() {
- let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"];
- for s in cases {
- let k = KeyInput::parse(s).unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for {s}");
- }
-
- let ki = KeyInput::parse("g g").unwrap();
- assert_eq!(ki.to_string(), "g g");
- }
-
- #[test]
- fn from_event_basic() {
- let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.ctrl);
- assert!(!k.alt);
-
- let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Enter);
- }
-
- #[test]
- fn from_event_uppercase() {
- // Crossterm sends uppercase chars with SHIFT modifier
- let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- // shift flag should be cleared since the case encodes it
- assert!(!k.shift);
- }
-
- #[test]
- fn from_event_matches_parsed() {
- // Verify that from_event and parse produce the same SingleKey
- let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("ctrl-c").unwrap();
- assert_eq!(from_event, parsed);
-
- let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("G").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn parse_super_modifier() {
- let k = SingleKey::parse("super-a").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(k.super_key);
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- // "cmd" is an alias for "super"
- let k2 = SingleKey::parse("cmd-a").unwrap();
- assert_eq!(k, k2);
-
- // "win" is an alias for "super"
- let k3 = SingleKey::parse("win-a").unwrap();
- assert_eq!(k, k3);
- }
-
- #[test]
- fn parse_super_with_other_modifiers() {
- let k = SingleKey::parse("super-ctrl-c").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('c'));
- assert!(k.super_key && k.ctrl);
- assert!(!k.alt && !k.shift);
- }
-
- #[test]
- fn display_super_modifier() {
- let k = SingleKey::parse("super-a").unwrap();
- assert_eq!(k.to_string(), "super-a");
-
- let k = SingleKey::parse("super-ctrl-x").unwrap();
- assert_eq!(k.to_string(), "super-ctrl-x");
- }
-
- #[test]
- fn display_round_trip_super() {
- let k = KeyInput::parse("super-a").unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for super-a");
- }
-
- #[test]
- fn from_event_super() {
- let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('a'));
- assert!(k.super_key);
- assert!(!k.ctrl && !k.alt && !k.shift);
- }
-
- #[test]
- fn from_event_super_matches_parsed() {
- let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("super-a").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn super_uppercase_preserves_super() {
- // super-G should keep the super flag (unlike bare "G" which clears shift)
- let k = SingleKey::parse("super-G").unwrap();
- assert_eq!(k.code, KeyCodeValue::Char('G'));
- assert!(k.super_key);
- }
-
- #[test]
- fn parse_errors() {
- assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err());
- assert!(SingleKey::parse("foobar-a").is_err());
- }
-
- #[test]
- fn parse_function_keys() {
- let k = SingleKey::parse("f1").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(1));
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("F12").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(12));
-
- let k = SingleKey::parse("ctrl-f5").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(5));
- assert!(k.ctrl);
-
- // F24 is valid (some keyboards have extended function keys)
- let k = SingleKey::parse("f24").unwrap();
- assert_eq!(k.code, KeyCodeValue::F(24));
-
- // F0 and F25+ are invalid
- assert!(SingleKey::parse("f0").is_err());
- assert!(SingleKey::parse("f25").is_err());
- }
-
- #[test]
- fn from_event_function_keys() {
- let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::F(1));
-
- let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::F(12));
- assert!(k.ctrl);
- }
-
- #[test]
- fn display_function_keys() {
- let k = SingleKey::parse("f1").unwrap();
- assert_eq!(k.to_string(), "f1");
-
- let k = SingleKey::parse("ctrl-f12").unwrap();
- assert_eq!(k.to_string(), "ctrl-f12");
- }
-
- #[test]
- fn function_key_round_trip() {
- let cases = ["f1", "f12", "ctrl-f5", "alt-f10"];
- for s in cases {
- let k = KeyInput::parse(s).unwrap();
- let display = k.to_string();
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2, "round-trip failed for {s}");
- }
- }
-
- #[test]
- fn from_event_function_key_matches_parsed() {
- let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("f12").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn from_event_backtab_becomes_shift_tab() {
- // Many terminals send BackTab for Shift+Tab
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
- assert!(k.shift);
- assert!(!k.ctrl && !k.alt);
- }
-
- #[test]
- fn from_event_backtab_matches_parsed_shift_tab() {
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE);
- let from_event = SingleKey::from_event(&event).unwrap();
- let parsed = SingleKey::parse("shift-tab").unwrap();
- assert_eq!(from_event, parsed);
- }
-
- #[test]
- fn from_event_backtab_with_ctrl() {
- // BackTab with ctrl modifier
- let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Tab);
- assert!(k.shift);
- assert!(k.ctrl);
- }
-
- #[test]
- fn parse_insert_key() {
- let k = SingleKey::parse("insert").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- assert!(!k.ctrl && !k.alt && !k.shift);
-
- let k = SingleKey::parse("ins").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
-
- let k = SingleKey::parse("ctrl-insert").unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- assert!(k.ctrl);
- }
-
- #[test]
- fn from_event_insert_key() {
- let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE);
- let k = SingleKey::from_event(&event).unwrap();
- assert_eq!(k.code, KeyCodeValue::Insert);
- }
-
- #[test]
- fn insert_key_round_trip() {
- let k = KeyInput::parse("insert").unwrap();
- let display = k.to_string();
- assert_eq!(display, "insert");
- let k2 = KeyInput::parse(&display).unwrap();
- assert_eq!(k, k2);
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/keymap.rs b/crates/turtle/src/command/client/search/keybindings/keymap.rs
deleted file mode 100644
index 067d9403..00000000
--- a/crates/turtle/src/command/client/search/keybindings/keymap.rs
+++ /dev/null
@@ -1,233 +0,0 @@
-use std::collections::HashMap;
-
-use super::actions::Action;
-use super::conditions::{ConditionExpr, EvalContext};
-use super::key::{KeyInput, SingleKey};
-
-/// A single rule within a keybinding: an optional condition and an action.
-/// If the condition is `None`, the rule always matches.
-#[derive(Debug, Clone)]
-pub(crate) struct KeyRule {
- pub(crate) condition: Option<ConditionExpr>,
- pub(crate) action: Action,
-}
-
-/// A keybinding is an ordered list of rules. The first rule whose condition
-/// matches (or has no condition) wins.
-#[derive(Debug, Clone)]
-pub(crate) struct KeyBinding {
- pub(crate) rules: Vec<KeyRule>,
-}
-
-/// A keymap is a collection of keybindings indexed by key input.
-#[derive(Debug, Clone)]
-pub(crate) struct Keymap {
- pub(crate) bindings: HashMap<KeyInput, KeyBinding>,
-}
-
-impl KeyRule {
- /// Create an unconditional rule.
- pub(crate) fn always(action: Action) -> Self {
- Self {
- condition: None,
- action,
- }
- }
-
- /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
- /// including bare `ConditionAtom` values.
- pub(crate) fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
- Self {
- condition: Some(condition.into()),
- action,
- }
- }
-}
-
-impl KeyBinding {
- /// Create a simple (unconditional) binding.
- pub(crate) fn simple(action: Action) -> Self {
- Self {
- rules: vec![KeyRule::always(action)],
- }
- }
-
- /// Create a conditional binding from a list of rules.
- pub(crate) fn conditional(rules: Vec<KeyRule>) -> Self {
- Self { rules }
- }
-}
-
-impl Keymap {
- /// Create an empty keymap.
- pub(crate) fn new() -> Self {
- Self {
- bindings: HashMap::new(),
- }
- }
-
- /// Bind a key input to a simple (unconditional) action.
- pub(crate) fn bind(&mut self, key: KeyInput, action: Action) {
- self.bindings.insert(key, KeyBinding::simple(action));
- }
-
- /// Bind a key input to a conditional set of rules.
- pub(crate) fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {
- self.bindings.insert(key, KeyBinding::conditional(rules));
- }
-
- /// Resolve a key input to an action given the current evaluation context.
- /// Returns `None` if the key has no binding or no rule's condition matches.
- pub(crate) fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {
- let binding = self.bindings.get(key)?;
- for rule in &binding.rules {
- match &rule.condition {
- None => return Some(rule.action.clone()),
- Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),
- Some(_) => {}
- }
- }
- None
- }
-
- /// Check if any binding starts with the given single key as the first key
- /// of a multi-key sequence. Used to detect pending multi-key sequences.
- pub(crate) fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {
- self.bindings.keys().any(|ki| match ki {
- KeyInput::Sequence(keys) => keys.first() == Some(prefix),
- KeyInput::Single(_) => false,
- })
- }
-
- /// Merge another keymap into this one. Keys from `other` override keys in `self`.
- #[expect(dead_code)]
- pub(crate) fn merge(&mut self, other: &Self) {
- for (key, binding) in &other.bindings {
- self.bindings.insert(key.clone(), binding.clone());
- }
- }
-}
-
-impl Default for Keymap {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::super::conditions::ConditionAtom;
- use super::{Action, EvalContext, KeyInput, KeyRule, Keymap, SingleKey};
-
- fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
- EvalContext {
- cursor_position: cursor,
- input_width: width,
- input_byte_len: width,
- selected_index: selected,
- results_len: len,
- original_input_empty: false,
- has_context: false,
- }
- }
-
- #[test]
- fn simple_binding_resolves() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- keymap.bind(key.clone(), Action::ReturnOriginal);
-
- let ctx = make_ctx(0, 0, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));
- }
-
- #[test]
- fn conditional_first_match_wins() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("left").unwrap();
- keymap.bind_conditional(
- key.clone(),
- vec![
- KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
- KeyRule::always(Action::CursorLeft),
- ],
- );
-
- // Cursor at start → Exit
- let ctx = make_ctx(0, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));
-
- // Cursor not at start → CursorLeft
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));
- }
-
- #[test]
- fn no_match_returns_none() {
- let keymap = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(keymap.resolve(&key, &ctx), None);
- }
-
- #[test]
- fn conditional_no_condition_matches_returns_none() {
- let mut keymap = Keymap::new();
- let key = KeyInput::parse("left").unwrap();
- // Only one rule with a condition that won't match
- keymap.bind_conditional(
- key.clone(),
- vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],
- );
-
- // Cursor not at start → no match
- let ctx = make_ctx(3, 5, 0, 10);
- assert_eq!(keymap.resolve(&key, &ctx), None);
- }
-
- #[test]
- fn has_sequence_starting_with() {
- let mut keymap = Keymap::new();
- let seq = KeyInput::parse("g g").unwrap();
- keymap.bind(seq, Action::ScrollToTop);
-
- let g = SingleKey::parse("g").unwrap();
- assert!(keymap.has_sequence_starting_with(&g));
-
- let h = SingleKey::parse("h").unwrap();
- assert!(!keymap.has_sequence_starting_with(&h));
- }
-
- #[test]
- fn merge_overrides() {
- let mut base = Keymap::new();
- let key = KeyInput::parse("ctrl-c").unwrap();
- base.bind(key.clone(), Action::ReturnOriginal);
-
- let mut overlay = Keymap::new();
- overlay.bind(key.clone(), Action::Exit);
-
- base.merge(&overlay);
-
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));
- }
-
- #[test]
- fn merge_preserves_unoverridden() {
- let mut base = Keymap::new();
- let key1 = KeyInput::parse("ctrl-c").unwrap();
- let key2 = KeyInput::parse("ctrl-d").unwrap();
- base.bind(key1.clone(), Action::ReturnOriginal);
- base.bind(key2.clone(), Action::DeleteCharAfter);
-
- let mut overlay = Keymap::new();
- overlay.bind(key1.clone(), Action::Exit);
-
- base.merge(&overlay);
-
- let ctx = make_ctx(0, 0, 0, 0);
- assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));
- assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));
- }
-}
diff --git a/crates/turtle/src/command/client/search/keybindings/mod.rs b/crates/turtle/src/command/client/search/keybindings/mod.rs
deleted file mode 100644
index cdca0406..00000000
--- a/crates/turtle/src/command/client/search/keybindings/mod.rs
+++ /dev/null
@@ -1,14 +0,0 @@
-pub(crate) mod actions;
-pub(crate) mod conditions;
-pub(crate) mod defaults;
-pub(crate) mod key;
-pub(crate) mod keymap;
-
-pub(crate) use actions::Action;
-#[expect(unused_imports)]
-pub(crate) use conditions::{ConditionAtom, ConditionExpr, EvalContext};
-pub(crate) use defaults::KeymapSet;
-#[expect(unused_imports)]
-pub(crate) use key::{KeyCodeValue, KeyInput, SingleKey};
-#[expect(unused_imports)]
-pub(crate) use keymap::{KeyBinding, KeyRule, Keymap};
diff --git a/crates/turtle/src/command/client/server.rs b/crates/turtle/src/command/client/server.rs
deleted file mode 100644
index 88b12a7a..00000000
--- a/crates/turtle/src/command/client/server.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-use std::net::SocketAddr;
-
-use crate::atuin_server::{Settings, database::DbType, launch, launch_metrics_server};
-
-use clap::Subcommand;
-use eyre::{Context, Result, eyre};
-
-#[derive(Subcommand, Clone, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Start the server
- Start {
- /// The host address to bind
- #[clap(long)]
- host: Option<String>,
-
- /// The port to bind
- #[clap(long, short)]
- port: Option<u16>,
- },
-
- /// Print server example configuration
- DefaultConfig,
-}
-
-impl Cmd {
- pub(crate) async fn run(self) -> Result<()> {
- match self {
- Self::Start { host, port } => {
- let settings = Settings::new().wrap_err("could not load server settings")?;
- let host = host.as_ref().unwrap_or(&settings.host).clone();
- let port = port.unwrap_or(settings.port);
- let addr = SocketAddr::new(host.parse()?, port);
-
- if settings.metrics.enable {
- tokio::spawn(launch_metrics_server(
- settings.metrics.host.clone(),
- settings.metrics.port,
- ));
- }
-
- match settings.db_settings.db_type() {
- DbType::Postgres => launch(settings, addr).await,
- DbType::Unknown => {
- Err(eyre!("db_uri must start with postgres:// or sqlite://"))
- }
- }
- }
- Self::DefaultConfig => {
- // TODO(@bpeetz): Add this back <2026-06-11>
- println!("TODO");
- Ok(())
- }
- }
- }
-}
diff --git a/crates/turtle/src/command/client/stats.rs b/crates/turtle/src/command/client/stats.rs
deleted file mode 100644
index 9ea5e283..00000000
--- a/crates/turtle/src/command/client/stats.rs
+++ /dev/null
@@ -1,82 +0,0 @@
-use clap::Parser;
-use eyre::Result;
-use interim::parse_date_string;
-use time::{Duration, OffsetDateTime, Time};
-
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_client::{database::current_context, settings::Settings};
-
-use crate::atuin_history::stats::{compute, pretty_print};
-
-fn parse_ngram_size(s: &str) -> Result<usize, String> {
- let value = s
- .parse::<usize>()
- .map_err(|_| format!("'{s}' is not a valid window size"))?;
-
- if value == 0 {
- return Err("ngram window size must be at least 1".to_string());
- }
-
- Ok(value)
-}
-
-#[derive(Parser, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) struct Cmd {
- /// Compute statistics for the specified period, leave blank for statistics since the beginning. See [this](https://docs.atuin.sh/reference/stats/) for more details.
- period: Vec<String>,
-
- /// How many top commands to list
- #[arg(long, short, default_value = "10")]
- count: usize,
-
- /// The number of consecutive commands to consider
- #[arg(long, short, default_value = "1", value_parser = parse_ngram_size)]
- ngram_size: usize,
-}
-
-impl Cmd {
- pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> {
- let context = current_context().await?;
- let words = if self.period.is_empty() {
- String::from("all")
- } else {
- self.period.join(" ")
- };
-
- let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
- let last_night = now.replace_time(Time::MIDNIGHT);
-
- let history = if words.as_str() == "all" {
- db.list(&[], &context, None, false, false).await?
- } else if words.trim() == "today" {
- let start = last_night;
- let end = start + Duration::days(1);
- db.range(start, end).await?
- } else if words.trim() == "month" {
- let end = last_night;
- let start = end - Duration::days(31);
- db.range(start, end).await?
- } else if words.trim() == "week" {
- let end = last_night;
- let start = end - Duration::days(7);
- db.range(start, end).await?
- } else if words.trim() == "year" {
- let end = last_night;
- let start = end - Duration::days(365);
- db.range(start, end).await?
- } else {
- let start = parse_date_string(&words, now, settings.dialect.into())?;
- let end = start + Duration::days(1);
- db.range(start, end).await?
- };
-
- let stats = compute(settings, &history, self.count, self.ngram_size);
-
- if let Some(stats) = stats {
- pretty_print(stats, self.ngram_size);
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store.rs b/crates/turtle/src/command/client/store.rs
deleted file mode 100644
index bc57488d..00000000
--- a/crates/turtle/src/command/client/store.rs
+++ /dev/null
@@ -1,108 +0,0 @@
-use clap::Subcommand;
-use eyre::Result;
-
-use crate::atuin_client::{
- database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings,
-};
-use itertools::Itertools;
-use time::{OffsetDateTime, UtcOffset};
-
-mod pull;
-mod purge;
-mod push;
-mod rebuild;
-mod rekey;
-mod verify;
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Print the current status of the record store
- Status,
-
- /// Rebuild a store (eg atuin store rebuild history)
- Rebuild(rebuild::Rebuild),
-
- /// Re-encrypt the store with a new key (potential for data loss!)
- Rekey(rekey::Rekey),
-
- /// Delete all records in the store that cannot be decrypted with the current key
- Purge(purge::Purge),
-
- /// Verify that all records in the store can be decrypted with the current key
- Verify(verify::Verify),
-
- /// Push all records to the remote sync server (one way sync)
- Push(push::Push),
-
- /// Pull records from the remote sync server (one way sync)
- Pull(pull::Pull),
-}
-
-impl Cmd {
- pub(crate) async fn run(
- &self,
- settings: &Settings,
- database: &ClientSqlite,
- store: SqliteStore,
- ) -> Result<()> {
- match self {
- Self::Status => self.status(store).await,
- Self::Rebuild(rebuild) => rebuild.run(settings, store, database).await,
- Self::Rekey(rekey) => rekey.run(settings, store).await,
- Self::Verify(verify) => verify.run(settings, store).await,
- Self::Purge(purge) => purge.run(settings, store).await,
- Self::Push(push) => push.run(settings, store).await,
- Self::Pull(pull) => pull.run(settings, store, database).await,
- }
- }
-
- pub(crate) async fn status(&self, store: SqliteStore) -> Result<()> {
- let host_id = Settings::host_id().await?;
- let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
-
- let status = store.status().await?;
-
- // TODO: should probs build some data structure and then pretty-print it or smth
- for (host, st) in status.hosts.iter().sorted_by_key(|(h, _)| *h) {
- let host_string = if host == &host_id {
- format!("host: {} <- CURRENT HOST", host.0.as_hyphenated())
- } else {
- format!("host: {}", host.0.as_hyphenated())
- };
-
- println!("{host_string}");
-
- for (tag, idx) in st.iter().sorted_by_key(|(tag, _)| *tag) {
- println!("\tstore: {tag}");
-
- let first = store.first(*host, tag).await?;
- let last = store.last(*host, tag).await?;
-
- println!("\t\tidx: {idx}");
-
- if let Some(first) = first {
- println!("\t\tfirst: {}", first.id.0.as_hyphenated());
-
- let time =
- OffsetDateTime::from_unix_timestamp_nanos(i128::from(first.timestamp))?
- .to_offset(offset);
- println!("\t\t\tcreated: {time}");
- }
-
- if let Some(last) = last {
- println!("\t\tlast: {}", last.id.0.as_hyphenated());
-
- let time =
- OffsetDateTime::from_unix_timestamp_nanos(i128::from(last.timestamp))?
- .to_offset(offset);
- println!("\t\t\tcreated: {time}");
- }
- }
-
- println!();
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/pull.rs b/crates/turtle/src/command/client/store/pull.rs
deleted file mode 100644
index 3a0865be..00000000
--- a/crates/turtle/src/command/client/store/pull.rs
+++ /dev/null
@@ -1,95 +0,0 @@
-use clap::Args;
-use eyre::Result;
-
-use crate::atuin_client::{
- database::ClientSqlite,
- encryption::load_key,
- record::{
- sqlite_store::SqliteStore,
- sync::{self, Operation},
- },
- settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Pull {
- /// The tag to push (eg, 'history'). Defaults to all tags
- #[arg(long, short)]
- pub(crate) tag: Option<String>,
-
- /// Force push records
- /// This will first wipe the local store, and then download all records from the remote
- #[arg(long, default_value = "false")]
- pub(crate) force: bool,
-
- /// Page Size
- /// How many records to download at once. Defaults to 100
- #[arg(long, default_value = "100")]
- pub(crate) page: u64,
-}
-
-impl Pull {
- pub(crate) async fn run(
- &self,
- settings: &Settings,
- store: SqliteStore,
- db: &ClientSqlite,
- ) -> Result<()> {
- if self.force {
- println!("Forcing local overwrite!");
- println!("Clearing local store");
-
- store.delete_all().await?;
- }
-
- // We can actually just use the existing diff/etc to push
- // 1. Diff
- // 2. Get operations
- // 3. Filter operations by
- // a) are they a download op?
- // b) are they for the host/tag we are pushing here?
- let client = sync::build_client(settings)?;
- let (diff, remote_index) = sync::diff(&client, &store).await?;
-
- // Skip on --force: local was already wiped above, mismatch is the user's call.
- if !self.force {
- let key: [u8; 32] = load_key(settings)?.into();
- sync::check_encryption_key(&client, &remote_index, &key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
- }
-
- let operations = sync::operations(diff, &store)?;
-
- let operations = operations
- .into_iter()
- .filter(|op| match op {
- // No noops or downloads thx
- Operation::Noop { .. } | Operation::Upload { .. } => false,
-
- // pull, so yes plz to downloads!
- Operation::Download { tag, .. } => {
- if self.force {
- return true;
- }
-
- if let Some(t) = self.tag.clone()
- && t != *tag
- {
- return false;
- }
-
- true
- }
- })
- .collect();
-
- let (_, downloaded) = sync::sync_remote(&client, operations, &store, self.page).await?;
-
- println!("Downloaded {} records", downloaded.len());
-
- crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/purge.rs b/crates/turtle/src/command/client/store/purge.rs
deleted file mode 100644
index a23f1886..00000000
--- a/crates/turtle/src/command/client/store/purge.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-use clap::Args;
-use eyre::Result;
-
-use crate::atuin_client::{
- encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Purge {}
-
-impl Purge {
- pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
- println!("Purging local records that cannot be decrypted");
-
- let key = load_key(settings)?;
-
- match store.purge(&key.into()).await {
- Ok(()) => println!("Local store purge completed OK"),
- Err(e) => println!("Failed to purge local store: {e:?}"),
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/push.rs b/crates/turtle/src/command/client/store/push.rs
deleted file mode 100644
index 9d66b5b2..00000000
--- a/crates/turtle/src/command/client/store/push.rs
+++ /dev/null
@@ -1,113 +0,0 @@
-use crate::atuin_common::record::HostId;
-use clap::Args;
-use eyre::{OptionExt, Result};
-use uuid::Uuid;
-
-use crate::atuin_client::{
- api_client::Client,
- encryption::load_key,
- record::sync::Operation,
- record::{sqlite_store::SqliteStore, sync},
- settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Push {
- /// The tag to push (eg, 'history'). Defaults to all tags
- #[arg(long, short)]
- pub(crate) tag: Option<String>,
-
- /// The host to push, in the form of a UUID host ID. Defaults to the current host.
- #[arg(long)]
- pub(crate) host: Option<Uuid>,
-
- /// Force push records
- /// This will override both host and tag, to be all hosts and all tags. First clear the remote store, then upload all of the
- /// local store
- #[arg(long, default_value = "false")]
- pub(crate) force: bool,
-
- /// Page Size
- /// How many records to upload at once. Defaults to 100
- #[arg(long, default_value = "100")]
- pub(crate) page: u64,
-}
-
-impl Push {
- pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
- let host_id = Settings::host_id().await?;
-
- if self.force {
- println!("Forcing remote store overwrite!");
- println!("Clearing remote store");
-
- let client = Client::new(
- &settings.sync.address,
- settings.network_connect_timeout,
- // we may be deleting a lot of data... so increase the
- // timeout
- settings.network_timeout * 10,
- settings.sync.user_id()?.ok_or_eyre("no sync user-id")?,
- )
- .expect("failed to create client");
-
- client.delete_store().await?;
- }
-
- // We can actually just use the existing diff/etc to push
- // 1. Diff
- // 2. Get operations
- // 3. Filter operations by
- // a) are they an upload op?
- // b) are they for the host/tag we are pushing here?
- let client = sync::build_client(settings)?;
- let (diff, remote_index) = sync::diff(&client, &store).await?;
-
- // Skip on --force: that path intentionally replaces remote with local.
- if !self.force {
- let key: [u8; 32] = load_key(settings)?.into();
- sync::check_encryption_key(&client, &remote_index, &key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
- }
-
- let operations = sync::operations(diff, &store)?;
-
- let operations = operations
- .into_iter()
- .filter(|op| match op {
- // No noops or downloads thx
- Operation::Noop { .. } | Operation::Download { .. } => false,
-
- // push, so yes plz to uploads!
- Operation::Upload { host, tag, .. } => {
- if self.force {
- return true;
- }
-
- if let Some(h) = self.host {
- if HostId(h) != *host {
- return false;
- }
- } else if *host != host_id {
- return false;
- }
-
- if let Some(t) = self.tag.clone()
- && t != *tag
- {
- return false;
- }
-
- true
- }
- })
- .collect();
-
- let (uploaded, _) = sync::sync_remote(&client, operations, &store, self.page).await?;
-
- println!("Uploaded {uploaded} records");
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/rebuild.rs b/crates/turtle/src/command/client/store/rebuild.rs
deleted file mode 100644
index 6be67cd0..00000000
--- a/crates/turtle/src/command/client/store/rebuild.rs
+++ /dev/null
@@ -1,56 +0,0 @@
-use clap::Args;
-use eyre::{Result, bail};
-
-use crate::command::client::daemon as daemon_cmd;
-
-use crate::atuin_client::{
- database::ClientSqlite, encryption, history::store::HistoryStore,
- record::sqlite_store::SqliteStore, settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Rebuild {
- pub(crate) tag: String,
-}
-
-impl Rebuild {
- pub(crate) async fn run(
- &self,
- settings: &Settings,
- store: SqliteStore,
- database: &ClientSqlite,
- ) -> Result<()> {
- // keep it as a string and not an enum atm
- // would be super cool to build this dynamically in the future
- // eg register handles for rebuilding various tags without having to make this part of the
- // binary big
- match self.tag.as_str() {
- "history" => {
- self.rebuild_history(settings, store.clone(), database)
- .await?;
- }
-
- tag => bail!("unknown tag: {tag}"),
- }
-
- Ok(())
- }
-
- async fn rebuild_history(
- &self,
- settings: &Settings,
- store: SqliteStore,
- database: &ClientSqlite,
- ) -> Result<()> {
- let encryption_key: [u8; 32] = encryption::load_key(settings)?.into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store, host_id, encryption_key);
-
- history_store.build(database).await?;
-
- daemon_cmd::emit_event(settings, crate::atuin_daemon::DaemonEvent::HistoryRebuilt).await;
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/rekey.rs b/crates/turtle/src/command/client/store/rekey.rs
deleted file mode 100644
index 2b379327..00000000
--- a/crates/turtle/src/command/client/store/rekey.rs
+++ /dev/null
@@ -1,46 +0,0 @@
-use clap::Args;
-use eyre::Result;
-use tokio::{fs::File, io::AsyncWriteExt};
-
-use crate::atuin_client::{
- encryption::{decode_key, generate_encoded_key, load_key},
- record::sqlite_store::SqliteStore,
- settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Rekey {
- /// The new key to use for encryption. Omit for a randomly-generated key
- key: Option<String>,
-}
-
-impl Rekey {
- pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
- let key = if let Some(key) = self.key.clone() {
- println!("Re-encrypting store with specified key");
-
- key
- } else {
- println!("Re-encrypting store with freshly-generated key");
- let (_, encoded) = generate_encoded_key()?;
- encoded
- };
-
- let current_key: [u8; 32] = load_key(settings)?.into();
- let new_key: [u8; 32] = decode_key(&key)?.into();
-
- store.re_encrypt(&current_key, &new_key).await?;
-
- if let Some(key_path) = settings.sync.encryption_key_path.as_ref() {
- println!("Store rewritten. Saving new key");
- let mut file = File::create(key_path).await?;
- file.write_all(key.as_bytes()).await?;
- } else {
- println!(
- "No key-path (settings.sync.encryption_key_path) set in config, will not save new key."
- );
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/store/verify.rs b/crates/turtle/src/command/client/store/verify.rs
deleted file mode 100644
index a39227f9..00000000
--- a/crates/turtle/src/command/client/store/verify.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-use clap::Args;
-use eyre::Result;
-
-use crate::atuin_client::{
- encryption::load_key, record::sqlite_store::SqliteStore, settings::Settings,
-};
-
-#[derive(Args, Debug)]
-pub(crate) struct Verify {}
-
-impl Verify {
- pub(crate) async fn run(&self, settings: &Settings, store: SqliteStore) -> Result<()> {
- println!("Verifying local store can be decrypted with the current key");
-
- let key = load_key(settings)?;
-
- match store.verify(&key.into()).await {
- Ok(()) => println!("Local store encryption verified OK"),
- Err(e) => println!("Failed to verify local store encryption: {e:?}"),
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/client/sync.rs b/crates/turtle/src/command/client/sync.rs
deleted file mode 100644
index c29a82fc..00000000
--- a/crates/turtle/src/command/client/sync.rs
+++ /dev/null
@@ -1,120 +0,0 @@
-use clap::Subcommand;
-use eyre::{Result, WrapErr};
-use serde_json::json;
-
-use crate::{
- atuin_client::{
- database::ClientSqlite,
- encryption,
- history::store::HistoryStore,
- record::{sqlite_store::SqliteStore, sync},
- settings::Settings,
- },
- atuin_common::utils,
-};
-
-mod status;
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum Cmd {
- /// Sync with the configured server
- Perform {
- /// Force re-download everything
- #[arg(long, short)]
- force: bool,
- },
-
- /// Print (or generate) the encryption key and user id for transfer to another machine
- KeyAndId {},
-
- /// Display the sync status
- Status,
-}
-
-impl Cmd {
- pub(crate) async fn run(
- self,
- settings: Settings,
- db: &ClientSqlite,
- store: SqliteStore,
- ) -> Result<()> {
- match self {
- Self::Perform { force } => run(&settings, force, db, store).await,
- Self::Status => status::run(&settings).await,
- Self::KeyAndId {} => {
- use crate::atuin_client::encryption::{encode_key, load_key};
-
- let key = load_key(&settings).wrap_err("could not load encryption key")?;
- let user_id = settings
- .sync
- .user_id()
- .wrap_err("Failed to load user-id")?
- .unwrap_or_else(utils::uuid_v7);
-
- let key = encode_key(&key).wrap_err("could not encode encryption key")?;
-
- let json = serde_json::to_string_pretty(&json!({ "key": key, "user_id": user_id }))
- .expect("Will always be formattable");
-
- println!("{json}");
-
- Ok(())
- }
- }
- }
-}
-
-async fn run(
- settings: &Settings,
- force: bool,
- db: &ClientSqlite,
- store: SqliteStore,
-) -> Result<()> {
- let encryption_key: [u8; 32] = encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
-
- let host_id = Settings::host_id().await?;
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
-
- crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
-
- println!("{uploaded}/{} up/down to record store", downloaded.len());
-
- let history_length = db.history_count(true).await?;
- let store_history_length = store.len_tag("history").await?;
-
- #[expect(clippy::cast_sign_loss)]
- if history_length as u64 > store_history_length {
- println!("{history_length} in history index, but {store_history_length} in history store");
- println!("Running automatic history store init...");
-
- // Internally we use the global filter mode, so this context is ignored.
- // don't recurse or loop here.
- history_store.init_store(db).await?;
-
- println!("Re-running sync due to new records locally");
-
- // we'll want to run sync once more, as there will now be stuff to upload
- let (uploaded, downloaded) = sync::sync(settings, &store, &encryption_key)
- .await
- .map_err(crate::print_error::format_sync_error)?;
-
- crate::sync::build(settings, &store, db, Some(&downloaded)).await?;
-
- println!("{uploaded}/{} up/down to record store", downloaded.len());
- }
-
- println!(
- "Sync complete! {} items in history database, force: {}",
- db.history_count(true).await?,
- force
- );
-
- Ok(())
-}
diff --git a/crates/turtle/src/command/client/sync/status.rs b/crates/turtle/src/command/client/sync/status.rs
deleted file mode 100644
index caf3b90f..00000000
--- a/crates/turtle/src/command/client/sync/status.rs
+++ /dev/null
@@ -1,25 +0,0 @@
-use crate::atuin_client::settings::Settings;
-use crate::{SHA, VERSION};
-use colored::Colorize;
-use eyre::{Result, bail};
-
-pub(crate) async fn run(settings: &Settings) -> Result<()> {
- if let Some(me) = settings.sync.user_id()? {
- let last_sync = Settings::last_sync().await?;
-
- println!("Atuin v{VERSION} - Build rev {SHA}\n");
-
- println!("{}", "[Local]".green());
- println!("Sync frequency: {}", settings.sync.frequency);
- println!("Last sync: {}", last_sync.to_offset(settings.timezone.0));
- println!("Auto sync: {}", settings.sync.auto);
-
- println!("{}", "[Remote]".green());
- println!("Address: {}", settings.sync.address);
- println!("User id: {me}");
- } else {
- bail!("You are not logged in to a sync server - cannot show sync status");
- }
-
- Ok(())
-}
diff --git a/crates/turtle/src/command/client/wrapped.rs b/crates/turtle/src/command/client/wrapped.rs
deleted file mode 100644
index 2ce19bf7..00000000
--- a/crates/turtle/src/command/client/wrapped.rs
+++ /dev/null
@@ -1,327 +0,0 @@
-use crossterm::style::{ResetColor, SetAttribute};
-use eyre::Result;
-use std::collections::{HashMap, HashSet};
-use time::{Date, Duration, Month, OffsetDateTime, Time};
-
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_client::settings::Settings;
-
-use crate::atuin_history::stats::{Stats, compute};
-
-#[derive(Debug)]
-struct WrappedStats {
- nav_commands: usize,
- pkg_commands: usize,
- error_rate: f64,
- first_half_commands: Vec<(String, usize)>,
- second_half_commands: Vec<(String, usize)>,
- git_percentage: f64,
- busiest_hour: Option<(String, usize)>,
-}
-
-impl WrappedStats {
- #[expect(clippy::too_many_lines, clippy::cast_precision_loss)]
- fn new(
- settings: &Settings,
- stats: &Stats,
- history: &[crate::atuin_client::history::History],
- ) -> Self {
- let nav_commands = stats
- .top
- .iter()
- .filter(|(cmd, _)| {
- let cmd = &cmd[0];
- cmd == "cd"
- || cmd == "ls"
- || cmd == "ll"
- || cmd == "pwd"
- || cmd == "pushd"
- || cmd == "popd"
- })
- .map(|(_, count)| count)
- .sum();
-
- let pkg_managers = [
- "cargo",
- "npm",
- "pnpm",
- "yarn",
- "pip",
- "pip3",
- "pipenv",
- "poetry",
- "pipx",
- "uv",
- "brew",
- "apt",
- "apt-get",
- "apk",
- "pacman",
- "yay",
- "paru",
- "yum",
- "dnf",
- "dnf5",
- "rpm",
- "rpm-ostree",
- "zypper",
- "pkg",
- "chocolatey",
- "choco",
- "scoop",
- "winget",
- "gem",
- "bundle",
- "shards",
- "composer",
- "gradle",
- "maven",
- "mvn",
- "go get",
- "nuget",
- "dotnet",
- "mix",
- "hex",
- "rebar3",
- "nix",
- "nix-env",
- "cabal",
- "opam",
- ];
-
- let pkg_commands = history
- .iter()
- .filter(|h| {
- let cmd = h.command.clone();
- pkg_managers.iter().any(|pm| cmd.starts_with(pm))
- })
- .count();
-
- // Error analysis
- let mut command_errors: HashMap<String, (usize, usize)> = HashMap::new(); // (total_uses, errors)
- let midyear = history[0].timestamp + Duration::days(182); // Split year in half
-
- let mut first_half_commands: HashMap<String, usize> = HashMap::new();
- let mut second_half_commands: HashMap<String, usize> = HashMap::new();
- let mut hours: HashMap<String, usize> = HashMap::new();
-
- for entry in history {
- let cmd = entry
- .command
- .split_whitespace()
- .next()
- .unwrap_or("")
- .to_string();
- let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0));
- *total += 1;
- if entry.exit != 0 {
- *errors += 1;
- }
-
- // Track command evolution
- if entry.timestamp < midyear {
- *first_half_commands.entry(cmd.clone()).or_default() += 1;
- } else {
- *second_half_commands.entry(cmd).or_default() += 1;
- }
-
- // Track hourly distribution
- let local_time = entry
- .timestamp
- .to_offset(time::UtcOffset::current_local_offset().unwrap_or(settings.timezone.0));
- let hour = format!("{:02}:00", local_time.time().hour());
- *hours.entry(hour).or_default() += 1;
- }
-
- let total_errors: usize = command_errors.values().map(|(_, errors)| errors).sum();
- let total_commands: usize = command_errors.values().map(|(total, _)| total).sum();
- let error_rate = total_errors as f64 / total_commands as f64;
-
- // Process command evolution data
- let mut first_half: Vec<_> = first_half_commands.into_iter().collect();
- let mut second_half: Vec<_> = second_half_commands.into_iter().collect();
- first_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
- second_half.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
- first_half.truncate(5);
- second_half.truncate(5);
-
- // Calculate git percentage
- let git_commands: usize = stats
- .top
- .iter()
- .filter(|(cmd, _)| cmd[0].starts_with("git"))
- .map(|(_, count)| count)
- .sum();
- let git_percentage = git_commands as f64 / stats.total_commands as f64;
-
- // Find busiest hour
- let busiest_hour = hours.into_iter().max_by_key(|(_, count)| *count);
-
- Self {
- nav_commands,
- pkg_commands,
- error_rate,
- first_half_commands: first_half,
- second_half_commands: second_half,
- git_percentage,
- busiest_hour,
- }
- }
-}
-
-pub(crate) fn print_wrapped_header(year: i32) {
- let reset = ResetColor;
- let bold = SetAttribute(crossterm::style::Attribute::Bold);
-
- println!("{bold}╭────────────────────────────────────╮{reset}");
- println!("{bold}│ ATUIN WRAPPED {year} │{reset}");
- println!("{bold}│ Your Year in Shell History │{reset}");
- println!("{bold}╰────────────────────────────────────╯{reset}");
- println!();
-}
-
-#[expect(clippy::cast_precision_loss)]
-fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) {
- let reset = ResetColor;
- let bold = SetAttribute(crossterm::style::Attribute::Bold);
-
- if wrapped_stats.git_percentage > 0.05 {
- println!(
- "{bold}🌟 You're a Git Power User!{reset} {bold}{:.1}%{reset} of your commands were Git operations\n",
- wrapped_stats.git_percentage * 100.0
- );
- }
- // Navigation patterns
- let nav_percentage = wrapped_stats.nav_commands as f64 / stats.total_commands as f64 * 100.0;
- if nav_percentage > 0.05 {
- println!(
- "{bold}🚀 You're a Navigator!{reset} {bold}{nav_percentage:.1}%{reset} of your time was spent navigating directories\n",
- );
- }
-
- // Command vocabulary
- println!(
- "{bold}📚 Command Vocabulary{reset}: You know {bold}{}{reset} unique commands\n",
- stats.unique_commands
- );
-
- // Package management
- println!(
- "{bold}📦 Package Management{reset}: You ran {bold}{}{reset} package-related commands\n",
- wrapped_stats.pkg_commands
- );
-
- // Error patterns
- let error_percentage = wrapped_stats.error_rate * 100.0;
- println!(
- "{bold}🚨 Error Analysis{reset}: Your commands failed {bold}{error_percentage:.1}%{reset} of the time\n",
- );
-
- // Command evolution
- println!("🔍 Command Evolution:");
-
- // print stats for each half and compare
- println!(" {bold}Top Commands{reset} in the first half of {year}:");
- for (cmd, count) in wrapped_stats.first_half_commands.iter().take(3) {
- println!(" {bold}{cmd}{reset} ({count} times)");
- }
-
- println!(" {bold}Top Commands{reset} in the second half of {year}:");
- for (cmd, count) in wrapped_stats.second_half_commands.iter().take(3) {
- println!(" {bold}{cmd}{reset} ({count} times)");
- }
-
- // Find new favorite commands (in top 5 of second half but not in first half)
- let first_half_set: HashSet<_> = wrapped_stats
- .first_half_commands
- .iter()
- .map(|(cmd, _)| cmd)
- .collect();
- let new_favorites: Vec<_> = wrapped_stats
- .second_half_commands
- .iter()
- .filter(|(cmd, _)| !first_half_set.contains(cmd))
- .take(2)
- .collect();
-
- if !new_favorites.is_empty() {
- println!(" {bold}New favorites{reset} in the second half:");
- for (cmd, count) in new_favorites {
- println!(" {bold}{cmd}{reset} ({count} times)");
- }
- }
-
- // Time patterns
- if let Some((hour, count)) = &wrapped_stats.busiest_hour {
- println!("\n🕘 Most Productive Hour: {bold}{hour}{reset} ({count} commands)");
-
- // Night owl or early bird
- let hour_num = hour
- .split(':')
- .next()
- .unwrap_or("0")
- .parse::<u32>()
- .unwrap_or(0);
- if hour_num >= 22 || hour_num <= 4 {
- println!(" You're quite the night owl! 🦉");
- } else if (5..=7).contains(&hour_num) {
- println!(" Early bird gets the worm! 🐦");
- }
- }
-
- println!();
-}
-
-pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> {
- let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0);
- let month = now.month();
-
- // If we're in December, then wrapped is for the current year. If not, it's for the previous year
- let year = year.unwrap_or_else(|| {
- if month == Month::December {
- now.year()
- } else {
- now.year() - 1
- }
- });
-
- let start = OffsetDateTime::new_in_offset(
- Date::from_calendar_date(year, Month::January, 1).unwrap(),
- Time::MIDNIGHT,
- now.offset(),
- );
- let end = OffsetDateTime::new_in_offset(
- Date::from_calendar_date(year, Month::December, 31).unwrap(),
- Time::MIDNIGHT + Duration::days(1) - Duration::nanoseconds(1),
- now.offset(),
- );
-
- let history = db.range(start, end).await?;
- if history.is_empty() {
- println!(
- "Your history for {year} is empty!\nMaybe 'atuin import' could help you import your previous history 🪄"
- );
- return Ok(());
- }
-
- // Compute overall stats using existing functionality
- let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats");
- let wrapped_stats = WrappedStats::new(settings, &stats, &history);
-
- // Print wrapped format
- print_wrapped_header(year);
-
- println!("🎉 In {year}, you typed {} commands!", stats.total_commands);
- println!(
- " That's ~{} commands every day\n",
- stats.total_commands / 365
- );
-
- println!("Your Top Commands:");
- crate::atuin_history::stats::pretty_print(stats.clone(), 1);
- println!();
-
- print_fun_facts(&wrapped_stats, &stats, year);
-
- Ok(())
-}
diff --git a/crates/turtle/src/command/contributors.rs b/crates/turtle/src/command/contributors.rs
deleted file mode 100644
index b2a41522..00000000
--- a/crates/turtle/src/command/contributors.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-static CONTRIBUTORS: &str = include_str!("CONTRIBUTORS");
-
-pub(crate) fn run() {
- println!("\n{CONTRIBUTORS}");
-}
diff --git a/crates/turtle/src/command/gen_completions.rs b/crates/turtle/src/command/gen_completions.rs
deleted file mode 100644
index 9f13bffc..00000000
--- a/crates/turtle/src/command/gen_completions.rs
+++ /dev/null
@@ -1,84 +0,0 @@
-use clap::{CommandFactory, Parser, ValueEnum};
-use clap_complete::{Generator, Shell, generate, generate_to};
-use clap_complete_nushell::Nushell;
-use eyre::Result;
-
-// clap put nushell completions into a separate package due to the maintainers
-// being a little less committed to support them.
-// This means we have to do a tiny bit of legwork to combine these completions
-// into one command.
-#[derive(Debug, Clone, ValueEnum)]
-#[value(rename_all = "lower")]
-pub(crate) enum GenShell {
- Bash,
- Elvish,
- Fish,
- Nushell,
- PowerShell,
- Zsh,
-}
-
-impl Generator for GenShell {
- fn file_name(&self, name: &str) -> String {
- match self {
- // clap_complete
- Self::Bash => Shell::Bash.file_name(name),
- Self::Elvish => Shell::Elvish.file_name(name),
- Self::Fish => Shell::Fish.file_name(name),
- Self::PowerShell => Shell::PowerShell.file_name(name),
- Self::Zsh => Shell::Zsh.file_name(name),
-
- // clap_complete_nushell
- Self::Nushell => Nushell.file_name(name),
- }
- }
-
- fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::prelude::Write) {
- match self {
- // clap_complete
- Self::Bash => Shell::Bash.generate(cmd, buf),
- Self::Elvish => Shell::Elvish.generate(cmd, buf),
- Self::Fish => Shell::Fish.generate(cmd, buf),
- Self::PowerShell => Shell::PowerShell.generate(cmd, buf),
- Self::Zsh => Shell::Zsh.generate(cmd, buf),
-
- // clap_complete_nushell
- Self::Nushell => Nushell.generate(cmd, buf),
- }
- }
-}
-
-#[derive(Debug, Parser)]
-pub(crate) struct Cmd {
- /// Set the shell for generating completions
- #[arg(long, short)]
- shell: GenShell,
-
- /// Set the output directory
- #[arg(long, short)]
- out_dir: Option<String>,
-}
-
-impl Cmd {
- pub(crate) fn run(self) -> Result<()> {
- let Self { shell, out_dir } = self;
-
- let mut cli = crate::Atuin::command();
-
- match out_dir {
- Some(out_dir) => {
- generate_to(shell, &mut cli, env!("CARGO_PKG_NAME"), &out_dir)?;
- }
- None => {
- generate(
- shell,
- &mut cli,
- env!("CARGO_PKG_NAME"),
- &mut std::io::stdout(),
- );
- }
- }
-
- Ok(())
- }
-}
diff --git a/crates/turtle/src/command/mod.rs b/crates/turtle/src/command/mod.rs
deleted file mode 100644
index 9a648254..00000000
--- a/crates/turtle/src/command/mod.rs
+++ /dev/null
@@ -1,141 +0,0 @@
-use clap::Subcommand;
-use eyre::Result;
-
-#[cfg(not(windows))]
-use rustix::{fs::Mode, process::umask};
-
-mod client;
-mod contributors;
-mod gen_completions;
-
-#[derive(Subcommand)]
-#[command(infer_subcommands = true)]
-#[expect(clippy::large_enum_variant)]
-pub(crate) enum AtuinCmd {
- #[command(flatten)]
- Client(client::Cmd),
-
- /// PTY proxy for atuin
- #[command(alias = "hex")]
- PtyProxy(crate::atuin_pty_proxy::PtyProxy),
-
- /// Generate a UUID
- Uuid,
-
- Contributors,
-
- /// Generate shell completions
- GenCompletions(gen_completions::Cmd),
-}
-
-impl AtuinCmd {
- pub(crate) fn run(self) -> Result<()> {
- #[cfg(not(windows))]
- {
- // set umask before we potentially open/create files
- // or in other words, 077. Do not allow any access to any other user
- let mode = Mode::RWXG | Mode::RWXO;
- umask(mode);
- }
-
- match self {
- Self::Client(client) => client.run(),
-
- Self::PtyProxy(proxy) => {
- run_pty_proxy(proxy);
- Ok(())
- }
-
- Self::Contributors => {
- contributors::run();
- Ok(())
- }
- Self::Uuid => {
- println!("{}", crate::atuin_common::utils::uuid_v7().as_simple());
- Ok(())
- }
- Self::GenCompletions(gen_completions) => gen_completions.run(),
- }
- }
-}
-
-#[cfg(unix)]
-fn run_pty_proxy(proxy: crate::atuin_pty_proxy::PtyProxy) {
- proxy.run(semantic_command_capture_sink());
-}
-
-#[cfg(unix)]
-fn semantic_command_capture_sink() -> Option<crate::atuin_pty_proxy::CommandCaptureSink> {
- use std::sync::mpsc;
- use std::time::Duration;
-
- if is_truthy_env("ATUIN_TERMINAL") {
- return None;
- }
-
- let settings = crate::atuin_client::settings::Settings::new().ok()?;
- let (tx, rx) = mpsc::sync_channel::<crate::atuin_pty_proxy::CommandCapture>(128);
-
- std::thread::spawn(move || {
- let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
- .enable_all()
- .build()
- else {
- return;
- };
-
- while let Ok(first) = rx.recv() {
- let mut batch = vec![first];
-
- while batch.len() < 64 {
- match rx.recv_timeout(Duration::from_millis(25)) {
- Ok(capture) => batch.push(capture),
- Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {
- break;
- }
- }
- }
-
- runtime.block_on(send_semantic_command_captures(&settings, batch));
- }
- });
-
- Some(Box::new(move |capture| {
- drop(tx.try_send(capture));
- }))
-}
-
-#[cfg(unix)]
-#[inline]
-fn is_truthy_env(name: &str) -> bool {
- std::env::var(name)
- .ok()
- .as_ref()
- .is_some_and(|value| !value.trim().is_empty() && value.trim() != "false")
-}
-
-#[cfg(unix)]
-async fn send_semantic_command_captures(
- settings: &crate::atuin_client::settings::Settings,
- batch: Vec<crate::atuin_pty_proxy::CommandCapture>,
-) {
- use crate::atuin_daemon::generated;
-
- let captures = batch
- .into_iter()
- .map(|capture| generated::semantic::CommandCapture {
- prompt: capture.prompt,
- command: capture.command,
- output: capture.output,
- exit_code: capture.exit_code,
- history_id: capture.history_id,
- session_id: capture.session_id,
- output_truncated: capture.output_truncated,
- output_observed_bytes: capture.output_observed_bytes,
- })
- .collect();
-
- if let Ok(mut client) = crate::atuin_daemon::SemanticClient::from_settings(settings).await {
- drop(client.record_commands(captures).await);
- }
-}
diff --git a/crates/turtle/src/lib.rs b/crates/turtle/src/lib.rs
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/crates/turtle/src/lib.rs
diff --git a/crates/turtle/src/main.rs b/crates/turtle/src/main.rs
deleted file mode 100644
index 12858140..00000000
--- a/crates/turtle/src/main.rs
+++ /dev/null
@@ -1,76 +0,0 @@
-#![forbid(unsafe_code)]
-#![warn(clippy::pedantic, clippy::nursery, clippy::allow_attributes)]
-#![expect(
- clippy::missing_const_for_fn, // not 100% reliable
- clippy::redundant_pub_crate,
-)]
-#![expect(
- clippy::cast_possible_wrap,
- clippy::cast_sign_loss,
- clippy::cast_possible_truncation,
- reason = "We should remove all of these. But it's just a lot of work in this code-base"
-)]
-
-use clap::Parser;
-use clap::builder::Styles;
-use clap::builder::styling::{AnsiColor, Effects};
-use eyre::Result;
-
-use command::AtuinCmd;
-
-mod command;
-
-pub(crate) mod atuin_client;
-pub(crate) mod atuin_common;
-pub(crate) mod atuin_daemon;
-pub(crate) mod atuin_history;
-pub(crate) mod atuin_pty_proxy;
-pub(crate) mod atuin_server;
-
-mod print_error;
-mod sync;
-
-const VERSION: &str = env!("CARGO_PKG_VERSION");
-const SHA: &str = env!("GIT_HASH");
-
-const LONG_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")");
-
-static HELP_TEMPLATE: &str = "\
-{before-help}{name} {version}
-{author}
-{about}
-
-{usage-heading}
- {usage}
-
-{all-args}{after-help}";
-
-const STYLES: Styles = Styles::styled()
- .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD))
- .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
- .literal(AnsiColor::Green.on_default().effects(Effects::BOLD))
- .placeholder(AnsiColor::Green.on_default());
-
-/// Magical shell history
-#[derive(Parser)]
-#[command(
- author = "Ellie Huxtable <ellie@atuin.sh>",
- version = VERSION,
- long_version = LONG_VERSION,
- help_template(HELP_TEMPLATE),
- styles = STYLES,
-)]
-struct Atuin {
- #[command(subcommand)]
- atuin: AtuinCmd,
-}
-
-impl Atuin {
- fn run(self) -> Result<()> {
- self.atuin.run()
- }
-}
-
-fn main() -> Result<()> {
- Atuin::parse().run()
-}
diff --git a/crates/turtle/src/print_error.rs b/crates/turtle/src/print_error.rs
deleted file mode 100644
index 0a6303dd..00000000
--- a/crates/turtle/src/print_error.rs
+++ /dev/null
@@ -1,123 +0,0 @@
-use std::io::IsTerminal;
-
-use crate::atuin_client::record::sync::SyncError;
-use colored::Colorize;
-use crossterm::terminal;
-
-/// Print a prominent error to stderr. Colored and box-bordered when stderr is
-/// a TTY, plain "Error: ..." header otherwise. The description is word-wrapped
-/// to the terminal width (capped at 100 columns) so the message stays readable.
-pub(crate) fn print_error(title: &str, description: &str) {
- let is_tty = std::io::stderr().is_terminal();
- let width = if is_tty {
- terminal::size().map_or(80, |(w, _)| w as usize)
- } else {
- 80
- }
- .min(100);
-
- eprintln!();
- if is_tty {
- let bar = "━".repeat(width).red().bold().to_string();
- eprintln!("{bar}");
- eprintln!(" {} {}", "✗".red().bold(), title.red().bold());
- eprintln!("{bar}");
- } else {
- eprintln!("Error: {title}");
- eprintln!("{}", "-".repeat(width));
- }
- eprintln!();
-
- for line in wrap_text(description, width.saturating_sub(2)) {
- eprintln!(" {line}");
- }
- eprintln!();
-}
-
-/// Convert a `SyncError` into an `eyre::Report`, exiting on `WrongKey` after
-/// painting the prominent banner.
-pub(crate) fn format_sync_error(e: SyncError) -> eyre::Report {
- if matches!(e, SyncError::WrongKey) {
- print_error(
- "Wrong encryption key",
- "Your local encryption key cannot decrypt the data on the server. \
- This usually means another machine wrote records with a different key.\n\n\
- To fix this, find the correct key by running `atuin key` on a machine that \
- already syncs successfully, then run `atuin store rekey <key>` here.",
- );
- std::process::exit(1);
- }
- e.into()
-}
-
-fn wrap_text(text: &str, width: usize) -> Vec<String> {
- let mut out = Vec::new();
- for paragraph in text.split('\n') {
- let mut line = String::new();
- let mut line_len = 0;
- for word in paragraph.split_whitespace() {
- let word_len = word.chars().count();
- if !line.is_empty() && line_len + 1 + word_len > width {
- out.push(std::mem::take(&mut line));
- line_len = 0;
- }
- if !line.is_empty() {
- line.push(' ');
- line_len += 1;
- }
- line.push_str(word);
- line_len += word_len;
- }
- // Push every paragraph's final line (even empty) so `\n\n` in the
- // input becomes a blank line in the output.
- out.push(line);
- }
- while out.first().is_some_and(String::is_empty) {
- out.remove(0);
- }
- while out.last().is_some_and(String::is_empty) {
- out.pop();
- }
- out
-}
-
-#[cfg(test)]
-mod tests {
- use super::wrap_text;
-
- #[test]
- fn wraps_long_text() {
- let lines = wrap_text("the quick brown fox jumps over the lazy dog", 20);
- for line in &lines {
- assert!(line.chars().count() <= 20, "line too long: {line:?}");
- }
- assert_eq!(
- lines.join(" "),
- "the quick brown fox jumps over the lazy dog"
- );
- }
-
- #[test]
- fn preserves_explicit_newlines() {
- let lines = wrap_text("first line\nsecond line", 80);
- assert_eq!(lines, vec!["first line", "second line"]);
- }
-
- #[test]
- fn handles_word_longer_than_width() {
- let lines = wrap_text("short superlongword more", 5);
- assert_eq!(lines, vec!["short", "superlongword", "more"]);
- }
-
- #[test]
- fn preserves_blank_lines_between_paragraphs() {
- let lines = wrap_text("first paragraph\n\nsecond paragraph", 80);
- assert_eq!(lines, vec!["first paragraph", "", "second paragraph"]);
- }
-
- #[test]
- fn trims_leading_and_trailing_blank_lines() {
- let lines = wrap_text("\n\nbody\n\n", 80);
- assert_eq!(lines, vec!["body"]);
- }
-}
diff --git a/crates/turtle/src/shell/.gitattributes b/crates/turtle/src/shell/.gitattributes
deleted file mode 100644
index fae8897c..00000000
--- a/crates/turtle/src/shell/.gitattributes
+++ /dev/null
@@ -1 +0,0 @@
-* eol=lf
diff --git a/crates/turtle/src/shell/atuin.bash b/crates/turtle/src/shell/atuin.bash
deleted file mode 100644
index 703e8fe2..00000000
--- a/crates/turtle/src/shell/atuin.bash
+++ /dev/null
@@ -1,672 +0,0 @@
-# Include guard
-if [[ ${__atuin_initialized-} == true ]]; then
- false
-elif [[ $- != *i* ]]; then
- # Enable only in interactive shells
- false
-elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then
- # Require bash >= 3.1
- [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\n' >&2
- false
-else # (include guard) beginning of main content
- #------------------------------------------------------------------------------
- __atuin_initialized=true
-
- if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
- ATUIN_SESSION=$(atuin uuid)
- export ATUIN_SESSION
- export ATUIN_SHLVL=$SHLVL
- fi
- ATUIN_STTY=$(stty -g)
- ATUIN_HISTORY_ID=""
-
- __atuin_osc133_command_executed() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
-
- printf '\033]133;C\a'
- }
-
- __atuin_osc133_command_finished() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
- }
-
- __atuin_osc133_prompt_start=$'\001\033]133;A;cl=line\a\002'
- __atuin_osc133_prompt_end=$'\001\033]133;B\a\002'
-
- __atuin_osc133_wrap_prompt() {
- local __atuin_prompt="${PS1-}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
-
- if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
- PS1="${__atuin_osc133_prompt_start}${__atuin_prompt}${__atuin_osc133_prompt_end}"
- else
- PS1="$__atuin_prompt"
- fi
- }
-
- export ATUIN_PREEXEC_BACKEND=$SHLVL:none
- __atuin_update_preexec_backend() {
- if [[ ${BLE_ATTACHED-} ]]; then
- ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-}
- elif [[ ${bash_preexec_imported-} ]]; then
- ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec
- elif [[ ${__bp_imported-} ]]; then
- ATUIN_PREEXEC_BACKEND="$SHLVL:bash-preexec (old)"
- else
- ATUIN_PREEXEC_BACKEND=$SHLVL:unknown
- fi
- }
-
- __atuin_preexec() {
- # Workaround for old versions of bash-preexec
- if [[ ! ${BLE_ATTACHED-} ]]; then
- # In older versions of bash-preexec, the preexec hook may be called
- # even for the commands run by keybindings. There is no general and
- # robust way to detect the command for keybindings, but at least we
- # want to exclude Atuin's keybindings. When the preexec hook is called
- # for a keybinding, the preexec hook for the user command will not
- # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify
- # __atuin_precmd of this failure.
- if [[ $BASH_COMMAND != "$1" ]]; then
- case $BASH_COMMAND in
- '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*)
- ATUIN_HISTORY_ID=__bash_preexec_failure__
- return 0
- ;;
- esac
- fi
- fi
-
- # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's
- # attaching state can dynamically change.
- __atuin_update_preexec_backend
-
- local id
- id=$(atuin history start -- "$1" 2>/dev/null)
- export ATUIN_HISTORY_ID=$id
- [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_executed
- __atuin_preexec_time=${EPOCHREALTIME-}
- }
-
- __atuin_precmd() {
- local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-}
-
- __atuin_osc133_wrap_prompt
-
- [[ ! $ATUIN_HISTORY_ID ]] && return
-
- # If the previous preexec hook failed, we manually call __atuin_preexec
- local __atuin_skip_osc133=""
- if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then
- # This is the command extraction code taken from bash-preexec
- local previous_command
- previous_command=$(
- export LC_ALL=C HISTTIMEFORMAT=''
- builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //'
- )
- __atuin_skip_osc133=1
- __atuin_preexec "$previous_command"
- fi
-
- local duration=""
- # shellcheck disable=SC2154,SC2309
- if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then
- # With ble.sh, we utilize the shell variable `_ble_exec_time_ata`
- # recorded by ble.sh. It is more accurate than the measurements by
- # Atuin, which includes the spawn cost of Atuin. ble.sh uses the
- # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the
- # microsecond resolution, or the builtin `time` in bash < 5.0 with the
- # millisecond resolution.
- duration=${_ble_exec_time_ata}000
- elif ((BASH_VERSINFO[0] >= 5)); then
- # We calculate the high-resolution duration based on EPOCHREALTIME
- # (bash >= 5.0) recorded by precmd/preexec, though it might not be as
- # accurate as `_ble_exec_time_ata` provided by ble.sh because it
- # includes the extra time of the precmd/preexec handling. Since Bash
- # does not offer floating-point arithmetic, we remove the non-digit
- # characters and perform the integral arithmetic. The fraction part of
- # EPOCHREALTIME is fixed to have 6 digits in Bash. We remove all the
- # non-digit characters because the decimal point is not necessarily a
- # period depending on the locale.
- duration=$((${__atuin_precmd_time//[!0-9]/} - ${__atuin_preexec_time//[!0-9]/}))
- if ((duration >= 0)); then
- duration=${duration}000
- else
- duration="" # clear the result on overflow
- fi
- fi
-
- [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_finished "$EXIT"
- (ATUIN_LOG=error atuin history end --exit "$EXIT" ${duration:+"--duration=$duration"} -- "$ATUIN_HISTORY_ID" &) >/dev/null 2>&1
- export ATUIN_HISTORY_ID=""
- }
-
- __atuin_set_ret_value() {
- return ${1:+"$1"}
- }
-
- #------------------------------------------------------------------------------
- # section: __atuin_accept_line
- #
- # The function "__atuin_accept_line" is kept for backward compatibility of the
- # direct use of __atuin_history in keybindings by users.
-
- # The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in
- # $PS1. We switch the implementation of the shell function
- # `__atuin_evaluate_prompt` based on the Bash version because the expansion
- # ${PS1@P} is only available in bash >= 4.4.
- if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then
- __atuin_evaluate_prompt() {
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- __atuin_prompt=${PS1@P}
-
- # Note: Strip the control characters ^A (\001) and ^B (\002), which
- # Bash internally uses to enclose the escape sequences. They are
- # produced by '\[' and '\]', respectively, in $PS1 and used to tell
- # Bash that the strings inbetween do not contribute to the prompt
- # width. After the prompt width calculation, Bash strips those control
- # characters before outputting it to the terminal. We here strip these
- # characters following Bash's behavior.
- __atuin_prompt=${__atuin_prompt//[$'\001\002']/}
-
- # Count the number of newlines contained in $__atuin_prompt
- __atuin_prompt_offset=${__atuin_prompt//[!$'\n']/}
- __atuin_prompt_offset=${#__atuin_prompt_offset}
- }
- else
- __atuin_evaluate_prompt() {
- __atuin_prompt='$ '
- __atuin_prompt_offset=0
- }
- fi
-
- # The shell function `__atuin_clear_prompt N` outputs terminal control
- # sequences to clear the contents of the current and N previous lines. After
- # clearing, the cursor is placed at the beginning of the N-th previous line.
- __atuin_clear_prompt_cache=()
- __atuin_clear_prompt() {
- local offset=$1
- if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then
- if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then
- __atuin_clear_prompt_cache[0]=$'\r'$(tput el 2>/dev/null || tput ce 2>/dev/null)
- fi
- if ((offset > 0)); then
- __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$(
- tput cuu "$offset" 2>/dev/null || tput UP "$offset" 2>/dev/null
- tput dl "$offset" 2>/dev/null || tput DL "$offset" 2>/dev/null
- tput il "$offset" 2>/dev/null || tput AL "$offset" 2>/dev/null
- )
- fi
- fi
- printf '%s' "${__atuin_clear_prompt_cache[offset]}"
- }
-
- __atuin_accept_line() {
- local __atuin_command=$1
-
- # Reprint the prompt, accounting for multiple lines
- local __atuin_prompt __atuin_prompt_offset
- __atuin_evaluate_prompt
- __atuin_clear_prompt "$__atuin_prompt_offset"
- printf '%s\n' "$__atuin_prompt$__atuin_command"
-
- # Add it to the bash history
- history -s "$__atuin_command"
-
- # Assuming bash-preexec
- # Invoke every function in the preexec array
- local __atuin_preexec_function
- local __atuin_preexec_function_ret_value
- local __atuin_preexec_ret_value=0
- for __atuin_preexec_function in "${preexec_functions[@]:-}"; do
- if type -t "$__atuin_preexec_function" 1>/dev/null; then
- __atuin_set_ret_value "${__bp_last_ret_value:-}"
- "$__atuin_preexec_function" "$__atuin_command"
- __atuin_preexec_function_ret_value=$?
- if [[ $__atuin_preexec_function_ret_value != 0 ]]; then
- __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value
- fi
- fi
- done
-
- # If extdebug is turned on and any preexec function returns non-zero
- # exit status, we do not run the user command.
- if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then
- # Note: When a child Bash session is started by enter_accept, if the
- # environment variable READLINE_POINT is present, bash-preexec in the
- # child session does not fire preexec at all because it considers we
- # are inside Atuin's keybinding of the current session. To avoid
- # propagating the environment variable to the child session, we remove
- # the export attribute of READLINE_LINE and READLINE_POINT.
- export -n READLINE_LINE READLINE_POINT
-
- # Juggle the terminal settings so that the command can be interacted
- # with
- local __atuin_stty_backup
- __atuin_stty_backup=$(stty -g)
- stty "$ATUIN_STTY"
-
- # Execute the command. Note: We need to record $? and $_ after the
- # user command within the same call of "eval" because $_ is otherwise
- # overwritten by the last argument of "eval".
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- eval -- "$__atuin_command"$'\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_'
-
- stty "$__atuin_stty_backup"
- fi
-
- # Execute preprompt commands
- local __atuin_prompt_command
- for __atuin_prompt_command in "${PROMPT_COMMAND[@]}"; do
- __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}"
- eval -- "$__atuin_prompt_command"
- done
- # Bash will redraw only the line with the prompt after we finish,
- # so to work for a multiline prompt we need to print it ourselves,
- # then go to the beginning of the last line.
- __atuin_evaluate_prompt
- printf '%s' "$__atuin_prompt"
- __atuin_clear_prompt 0
- }
-
- #------------------------------------------------------------------------------
-
- __atuin_search_cmd() {
- local -a search_args=("$@")
-
- ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
- }
-
- __atuin_history() {
- # Default action of the up key: When this function is called with the first
- # argument `--shell-up-key-binding`, we perform Atuin's history search only
- # when the up key is supposed to cause the history movement in the original
- # binding. We do this only for ble.sh because the up key always invokes
- # the history movement in the plain Bash.
- if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then
- # When the current cursor position is not in the first line, the up key
- # should move the cursor to the previous line. While the selection is
- # performed, the up key should not start the history search.
- # shellcheck disable=SC2154 # Note: these variables are set by ble.sh
- if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\n'* || $_ble_edit_mark_active ]]; then
- ble/widget/@nomarked backward-line
- local status=$?
- READLINE_LINE=$_ble_edit_str
- READLINE_POINT=$_ble_edit_ind
- READLINE_MARK=$_ble_edit_mark
- return "$status"
- fi
- fi
-
- # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or
- # ble.sh. When it is not supported, we clear them to suppress strange
- # behaviors.
- [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) ||
- READLINE_LINE="" READLINE_POINT=0
-
- local __atuin_output
- if ! __atuin_output=$(__atuin_search_cmd "$@"); then
- [[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2
- return 1
- fi
-
- # We do nothing when the search is canceled.
- [[ $__atuin_output ]] || return 0
-
- if [[ $__atuin_output == __atuin_accept__:* ]]; then
- __atuin_output=${__atuin_output#__atuin_accept__:}
-
- if [[ ${BLE_ATTACHED-} ]]; then
- ble-edit/content/reset-and-check-dirty "$__atuin_output"
- ble/widget/accept-line
- READLINE_LINE=""
- elif [[ ${__atuin_macro_chain_keymap-} ]]; then
- READLINE_LINE=$__atuin_output
- bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_accept_line"
- else
- __atuin_accept_line "$__atuin_output"
- READLINE_LINE=""
- fi
-
- READLINE_POINT=${#READLINE_LINE}
- else
- READLINE_LINE=$__atuin_output
- READLINE_POINT=${#READLINE_LINE}
- if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then
- bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_insert_line"
- fi
- fi
- }
-
- __atuin_initialize_blesh() {
- # shellcheck disable=SC2154
- [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0
-
- ble-import contrib/integration/bash-preexec
-
- # Define and register an autosuggestion source for ble.sh's auto-complete.
- # If you'd like to overwrite this, define the same name of shell function
- # after the $(atuin init bash) line in your .bashrc. If you do not need
- # the auto-complete source by Atuin, please add the following code to
- # remove the entry after the $(atuin init bash) line in your .bashrc:
- #
- # ble/util/import/eval-after-load core-complete '
- # ble/array#remove _ble_complete_auto_source atuin-history'
- #
- function ble/complete/auto-complete/source:atuin-history {
- local suggestion
- suggestion=$(ATUIN_QUERY="$_ble_edit_str" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
- [[ $suggestion == "$_ble_edit_str"?* ]] || return 1
- ble/complete/auto-complete/enter h 0 "${suggestion:${#_ble_edit_str}}" '' "$suggestion"
- }
- ble/util/import/eval-after-load core-complete '
- ble/array#unshift _ble_complete_auto_source atuin-history'
-
- # @env BLE_SESSION_ID: `atuin doctor` references the environment variable
- # BLE_SESSION_ID. We explicitly export the variable because it was not
- # exported in older versions of ble.sh.
- [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID
- }
- __atuin_initialize_blesh
- BLE_ONLOAD+=(__atuin_initialize_blesh)
- precmd_functions+=(__atuin_precmd)
- preexec_functions+=(__atuin_preexec)
-
- #------------------------------------------------------------------------------
- # section: atuin-bind
-
- __atuin_widget=()
-
- __atuin_widget_save() {
- local data=$1
- for REPLY in "${!__atuin_widget[@]}"; do
- if [[ ${__atuin_widget[REPLY]} == "$data" ]]; then
- return 0
- fi
- done
- # shellcheck disable=SC2154
- REPLY=${#__atuin_widget[*]}
- __atuin_widget[REPLY]=$data
- }
-
- __atuin_widget_run() {
- local data=${__atuin_widget[$1]}
- local keymap=${data%%:*} widget=${data#*:}
- local __atuin_macro_chain_keymap=$keymap
- bind -m "$keymap" '"'"$__atuin_macro_chain"'": ""'
- builtin eval -- "$widget"
- }
-
- # To realize the enter_accept feature in a robust way, we need to call the
- # readline bindable function `accept-line'. However, there is no way to call
- # `accept-line' from the shell script. To call the bindable function
- # `accept-line', we may utilize string macros of readline. When we bind KEYSEQ
- # to a WIDGET that wants to conditionally call `accept-line' at the end, we
- # perform two-step dispatching:
- #
- # 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two
- # intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros. For
- # example, when we bind `__atuin_history` to \C-r, this step can be set up by
- # `bind '"\C-r": "IKEYSEQ1IKEYSEQ2"'`.
- #
- # 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the
- # binding of IKEYSEQ2 is dynamically determined by WIDGET. For example, when
- # we bind `__atuin_history` to \C-r, this step can be set up by `bind -x
- # '"IKEYSEQ1": WIDGET'`.
- #
- # 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> ""]---To request the execution
- # of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running
- # `bind '"IKEYSEQ2": accept-line''. Otherwise, WIDGET can change the binding
- # of IKEYSEQ2 to no-op by running `bind '"IKEYSEQ2": ""'`.
- #
- # For the choice of the intermediate key sequences, we want to choose key
- # sequences that are unlikely to conflict with others. In addition, we want to
- # avoid a key sequence containing \e because keymap "vi-insert" stops
- # processing key sequences containing \e in older versions of Bash. We have
- # used \e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0
- # for intermediate key sequences, but this contains \e and caused a problem.
- # Instead, we use \C-x\C-_A<n>\a, which starts with \C-x\C-_ (an unlikely
- # two-byte combination) and A (represents the initial letter of Atuin),
- # followed by the payload <n> and the terminator \a (BEL, \C-g).
-
- __atuin_macro_chain='\C-x\C-_A0\a'
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" "\"$__atuin_macro_chain\": \"\""
- done
- unset -v __atuin_keymap
-
- if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then
- # In Bash >= 4.3
-
- __atuin_macro_accept_line=accept-line
-
- __atuin_bind_impl() {
- local keymap=$1 keyseq=$2 command=$3
-
- # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the
- # command is shared by all the keymaps (emacs, vi-insert, and
- # vi-command), so one cannot safely bind different command strings to
- # the same keyseq in different keymaps. Therefore, the command string
- # and the keyseq need to be globally in one-to-one correspondence in
- # all the keymaps.
- local REPLY
- __atuin_widget_save "$keymap:$command"
- local widget=$REPLY
- local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a'
- local ikeyseq2=$__atuin_macro_chain
-
- if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then
- # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an
- # existing "bind -x" keybinding breaks other existing "bind -x"
- # keybindings [1,2]. To work around the problem, we explicitly
- # unbind an existing keybinding before overwriting it.
- #
- # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html
- # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291
- bind -m "$keymap" -r "$keyseq"
- fi
-
- bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\""
- bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget"
- }
-
- __atuin_bind_blesh_onload() {
- # In ble.sh, we need to enable unrecognized CSI sequences like \e[0;0A,
- # which are discarded by ble.sh by default. Note: In Bash <= 4.2, we
- # do not need to unset "decode_error_cseq_discard" because \e[0;<m>A is
- # used only for the macro chaining (which is unused by ble.sh) in Bash
- # <= 4.2.
- bleopt decode_error_cseq_discard=
- }
- if [[ ${BLE_VERSION-} ]]; then
- __atuin_bind_blesh_onload
- fi
- BLE_ONLOAD+=(__atuin_bind_blesh_onload)
- else
- # In Bash <= 4.2, "bind -x" cannot bind a shell command to a keyseq having
- # more than two bytes, so we need to work with only two-byte sequences.
- #
- # However, the number of available combinations of two-byte sequences is
- # limited. To minimize the number of key sequences used by Atuin, instead
- # of specifying a widget by its own intermediate sequence, we specify a
- # widget by a fixed-length sequence of multiple two-byte sequences. More
- # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5]
- # IKSX, where IKS1..IKS5 just stores its information to a global variable,
- # and IKSX collects all the information and determine and call the actual
- # widget based on the stored information. Each of IKn (n=1..5) is one of
- # the two reserved sequences, $__atuin_bash42_code0 and
- # $__atuin_bash42_code1. IKSX is fixed to be $__atuin_bash42_code2.
- #
- # For the choices of the special key sequences, we consider \C-xQ, \C-xR,
- # and \C-xS. In the emacs editing mode of Bash, \C-x is used as a prefix
- # key, i.e., it is used for the beginning key of the keybindings with
- # multiple keys, so \C-x is unlikely to be used for a single-key binding by
- # the user. Also, \C-x is not used in the vi editing mode by default. The
- # combinations \C-xQ..\C-xS are also unlikely be used because we need to
- # switch the modifier keys from Control to Shift to input these sequences,
- # and these are not easy to input.
- __atuin_bash42_code0='\C-xQ'
- __atuin_bash42_code1='\C-xR'
- __atuin_bash42_code2='\C-xS'
-
- __atuin_bash42_encode() {
- REPLY=
- local n=$1 min_width=${2-}
- while
- if ((n % 2 == 0)); then
- REPLY=$__atuin_bash42_code0$REPLY
- else
- REPLY=$__atuin_bash42_code1$REPLY
- fi
- (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width))
- do :; done
- }
-
- __atuin_bash42_bind() {
- local __atuin_keymap
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code0"'": __atuin_bash42_dispatch_selector+=0'
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code1"'": __atuin_bash42_dispatch_selector+=1'
- bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code2"'": __atuin_bash42_dispatch'
- done
- }
- __atuin_bash42_bind
- # In Bash <= 4.2, there is no way to read users' "bind -x" settings, so we
- # need to explicitly perform "bind -x" when ble.sh is loaded.
- BLE_ONLOAD+=(__atuin_bash42_bind)
-
- if ((BASH_VERSINFO[0] >= 4)); then
- __atuin_macro_accept_line=accept-line
- else
- # Note: We rewrite the command line and invoke `accept-line'. In
- # bash <= 3.2, there is no way to rewrite the command line from the
- # shell script, so we rewrite it using a macro and
- # `shell-expand-line'.
- #
- # Note: Concerning the key sequences to invoke bindable functions
- # such as "\C-x\C-_A1\a", another option is to use
- # "\exbegginning-of-line\r", etc. to make it consistent with bash
- # >= 5.3. However, an older Bash configuration can still conflict
- # on [M-x]. The conflict is more likely than \C-x\C-_A1\a.
- for __atuin_keymap in emacs vi-insert vi-command; do
- bind -m "$__atuin_keymap" '"\C-x\C-_A1\a": beginning-of-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A2\a": kill-line'
- # shellcheck disable=SC2016
- bind -m "$__atuin_keymap" '"\C-x\C-_A3\a": "$READLINE_LINE"'
- bind -m "$__atuin_keymap" '"\C-x\C-_A4\a": shell-expand-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A5\a": accept-line'
- bind -m "$__atuin_keymap" '"\C-x\C-_A6\a": end-of-line'
- done
- unset -v __atuin_keymap
-
- bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode'
- bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode'
-
- # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are
- # in the vi-command keymap, we go to vi-insert, input
- # "$READLINE_LINE", and come back to vi-command.
- bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
- bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"'
- bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"'
-
- __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"'
- __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"'
- fi
-
- __atuin_bash42_dispatch_selector=
-
- __atuin_bash42_dispatch() {
- local s=$__atuin_bash42_dispatch_selector
- __atuin_bash42_dispatch_selector=
- __atuin_widget_run "$((2#0$s))"
- }
-
- __atuin_bind_impl() {
- local keymap=$1 keyseq=$2 command=$3
-
- __atuin_widget_save "$keymap:$command"
- __atuin_bash42_encode "$REPLY"
- local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain
-
- bind -m "$keymap" "\"$keyseq\": \"$macro\""
- }
- fi
-
- atuin-bind() {
- local keymap=
- local OPTIND=1 OPTARG="" OPTERR=0 flag
- while getopts ':m:' flag "$@"; do
- case $flag in
- m) keymap=$OPTARG ;;
- *)
- printf '%s\n' "atuin-bind: unrecognized option '-$flag'" >&2
- return 2
- ;;
- esac
- done
- shift "$((OPTIND - 1))"
-
- if (($# != 2)); then
- printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2
- return 2
- fi
-
- local keyseq=$1
- [[ $keymap ]] || keymap=$(bind -v | awk '$2 == "keymap" { print $3 }')
- case $keymap in
- emacs-meta) keymap=emacs keyseq='\e'$keyseq ;;
- emacs-ctlx) keymap=emacs keyseq='\C-x'$keyseq ;;
- emacs*) keymap=emacs ;;
- vi-insert) ;;
- vi*) keymap=vi-command ;;
- *)
- printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2
- return 2
- ;;
- esac
-
- local command=$2 widget=${2%%[[:blank:]]*}
- case $widget in
- atuin-search) command=${2/#"$widget"/__atuin_history} ;;
- atuin-search-emacs) command=${2/#"$widget"/__atuin_history --keymap-mode=emacs} ;;
- atuin-search-viins) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-insert} ;;
- atuin-search-vicmd) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-normal} ;;
- atuin-up-search) command=${2/#"$widget"/__atuin_history --shell-up-key-binding} ;;
- atuin-up-search-emacs) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;;
- atuin-up-search-viins) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;;
- atuin-up-search-vicmd) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;;
- esac
-
- __atuin_bind_impl "$keymap" "$keyseq" "$command"
- }
-
- #------------------------------------------------------------------------------
-
- # shellcheck disable=SC2154
- if [[ $__atuin_bind_ctrl_r == true ]]; then
- # Note: We do not overwrite [C-r] in the vi-command keymap because we do
- # not want to overwrite "redo", which is already bound to [C-r] in the
- # vi_nmap keymap in ble.sh.
- atuin-bind -m emacs '\C-r' atuin-search-emacs
- atuin-bind -m vi-insert '\C-r' atuin-search-viins
- atuin-bind -m vi-command '/' atuin-search-emacs
- fi
-
- # shellcheck disable=SC2154
- if [[ $__atuin_bind_up_arrow == true ]]; then
- atuin-bind -m emacs '\e[A' atuin-up-search-emacs
- atuin-bind -m emacs '\eOA' atuin-up-search-emacs
- atuin-bind -m vi-insert '\e[A' atuin-up-search-viins
- atuin-bind -m vi-insert '\eOA' atuin-up-search-viins
- atuin-bind -m vi-command '\e[A' atuin-up-search-vicmd
- atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd
- atuin-bind -m vi-command 'k' atuin-up-search-vicmd
- fi
-
-#------------------------------------------------------------------------------
-fi # (include guard) end of main content
diff --git a/crates/turtle/src/shell/atuin.fish b/crates/turtle/src/shell/atuin.fish
deleted file mode 100644
index 2b469383..00000000
--- a/crates/turtle/src/shell/atuin.fish
+++ /dev/null
@@ -1,102 +0,0 @@
-if not set -q ATUIN_SESSION; or test "$ATUIN_SHLVL" != "$SHLVL"
- set -gx ATUIN_SESSION (atuin uuid)
- set -gx ATUIN_SHLVL $SHLVL
-end
-set --erase ATUIN_HISTORY_ID
-
-function _atuin_osc133_command_executed
- set -q ATUIN_PTY_PROXY_ACTIVE; or return
- test -n "$ATUIN_HISTORY_ID"; or return
-
- printf '\033]133;C\a'
-end
-
-function _atuin_osc133_command_finished --argument-names exit_code
- set -q ATUIN_PTY_PROXY_ACTIVE; or return
- test -n "$ATUIN_HISTORY_ID"; or return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$exit_code" "$ATUIN_HISTORY_ID" "$ATUIN_SESSION"
-end
-
-function _atuin_preexec --on-event fish_preexec
- if not test -n "$fish_private_mode"
- set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]" 2>/dev/null)
- _atuin_osc133_command_executed
- end
-end
-
-function _atuin_postexec --on-event fish_postexec
- set -l s $status
-
- if test -n "$ATUIN_HISTORY_ID"
- _atuin_osc133_command_finished $s
- ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null &
- disown
- end
-
- set --erase ATUIN_HISTORY_ID
-end
-
-function _atuin_search
- set -l keymap_mode
- switch $fish_key_bindings
- case fish_vi_key_bindings fish_hybrid_key_bindings
- switch $fish_bind_mode
- case default
- set keymap_mode vim-normal
- case insert
- set keymap_mode vim-insert
- end
- case '*'
- set keymap_mode emacs
- end
-
- set -l ATUIN_H
- set -l ATUIN_STATUS 0
-
- # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate;
- # but to support fish 3.3 we need to use `(some command | string collect)`.
- # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes")
- set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 3>&- | string collect)
- set ATUIN_STATUS $pipestatus[1]
-
- if test "$ATUIN_STATUS" -ne 0
- test -n "$ATUIN_H"; and printf '%s\n' "$ATUIN_H" >&2
- commandline -f repaint
- return "$ATUIN_STATUS"
- end
-
- set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace
-
- if test -n "$ATUIN_H"
- if string match --quiet '__atuin_accept__:*' "$ATUIN_H"
- set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect)
- commandline -r "$ATUIN_HIST"
- commandline -f repaint
- commandline -f execute
- return
- else
- commandline -r "$ATUIN_H"
- end
- end
-
- commandline -f repaint
-end
-
-function _atuin_bind_up
- # Fallback to fish's builtin up-or-search if we're in search or paging mode
- if commandline --search-mode; or commandline --paging-mode
- up-or-search
- return
- end
-
- # Only invoke atuin if we're on the top line of the command
- set -l lineno (commandline --line)
-
- switch $lineno
- case 1
- _atuin_search --shell-up-key-binding
- case '*'
- up-or-search
- end
-end
diff --git a/crates/turtle/src/shell/atuin.nu b/crates/turtle/src/shell/atuin.nu
deleted file mode 100644
index d37457e4..00000000
--- a/crates/turtle/src/shell/atuin.nu
+++ /dev/null
@@ -1,121 +0,0 @@
-# Source this in your ~/.config/nushell/config.nu
-# minimum supported version = 0.93.0
-module compat {
- export def --wrapped "random uuid -v 7" [...rest] { atuin uuid }
-}
-use (if not (
- (version).major > 0 or
- (version).minor >= 103
-) { "compat" }) *
-
-if 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default "")) {
- $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a "-" "")
- $env.ATUIN_SHLVL = ($env.SHLVL? | default "")
-}
-hide-env -i ATUIN_HISTORY_ID
-
-def _atuin_osc133_command_executed [] {
- if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
- return
- }
- if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
- return
- }
-
- print -n $"(char esc)]133;C(char bel)"
-}
-
-def _atuin_osc133_command_finished [exit_code: int] {
- if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env {
- return
- }
- if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) {
- return
- }
-
- print -n $"(char esc)]133;D;($exit_code);history_id=($env.ATUIN_HISTORY_ID);session_id=($env.ATUIN_SESSION)(char bel)"
-}
-
-# Magic token to make sure we don't record commands run by keybindings
-let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)"
-
-let _atuin_pre_execution = {||
- if ($nu | get history-enabled?) == false {
- return
- }
- let cmd = (commandline)
- if ($cmd | is-empty) {
- return
- }
- if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) {
- $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd | complete | get stdout | str trim)
- _atuin_osc133_command_executed
- }
-}
-
-let _atuin_pre_prompt = {||
- let last_exit = $env.LAST_EXIT_CODE
- if 'ATUIN_HISTORY_ID' not-in $env {
- return
- }
- _atuin_osc133_command_finished $last_exit
- with-env { ATUIN_LOG: error } {
- if (version).minor >= 104 or (version).major > 0 {
- job spawn {
- ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete
- } | ignore
- } else {
- do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete
- }
-
- }
- hide-env ATUIN_HISTORY_ID
-}
-
-def _atuin_search_cmd [...flags: string] {
- if (version).minor >= 106 or (version).major > 0 {
- [
- $ATUIN_KEYBINDING_TOKEN,
- ([
- `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`,
- ([
- 'let output = (run-external atuin search',
- ($flags | append [--interactive] | each {|e| $'"($e)"'}),
- 'e>| str trim)',
- ] | flatten | str join ' '),
- 'if ($output | str starts-with "__atuin_accept__:") {',
- 'commandline edit --accept ($output | str replace "__atuin_accept__:" "")',
- '} else {',
- 'commandline edit $output',
- '}',
- `}`,
- ] | flatten | str join "\n"),
- ]
- } else {
- [
- $ATUIN_KEYBINDING_TOKEN,
- ([
- `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`,
- 'commandline edit',
- '(run-external atuin search',
- ($flags | append [--interactive] | each {|e| $'"($e)"'}),
- ' e>| str trim)',
- `}`,
- ] | flatten | str join ' '),
- ]
- } | str join "\n"
-}
-
-$env.config = ($env | default {} config).config
-$env.config = ($env.config | default {} hooks)
-$env.config = (
- $env.config | upsert hooks (
- $env.config.hooks
- | upsert pre_execution (
- $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution)
- | upsert pre_prompt (
- $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt)
- )
-)
-
-$env.config = ($env.config | default [] keybindings)
diff --git a/crates/turtle/src/shell/atuin.ps1 b/crates/turtle/src/shell/atuin.ps1
deleted file mode 100644
index 431ee2c3..00000000
--- a/crates/turtle/src/shell/atuin.ps1
+++ /dev/null
@@ -1,240 +0,0 @@
-# Atuin PowerShell module
-#
-# This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux.
-#
-# Usage: atuin init powershell | Out-String | Invoke-Expression
-#
-# Settings:
-# - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search.
-# This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt.
-# It is initialized from the current prompt line count if not set when the first Atuin search is performed.
-
-if (Get-Module Atuin -ErrorAction Ignore) {
- if ($PSVersionTable.PSVersion.Major -ge 7) {
- Write-Warning "The Atuin module is already loaded, replacing it."
- Remove-Module Atuin
- } else {
- Write-Warning "The Atuin module is already loaded, skipping."
- return
- }
-}
-
-if (!(Get-Command atuin -ErrorAction Ignore)) {
- Write-Error "The 'atuin' executable needs to be available in the PATH."
- return
-}
-
-if (!(Get-Module PSReadLine -ErrorAction Ignore)) {
- Write-Error "Atuin requires the PSReadLine module to be installed."
- return
-}
-
-New-Module -Name Atuin -ScriptBlock {
- if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) {
- $env:ATUIN_SESSION = atuin uuid
- $env:ATUIN_PID = $PID
- }
-
- $script:atuinHistoryId = $null
- $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine
-
- # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available.
- $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)")
-
- function Get-CommandLine {
- $commandLine = ""
- [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null)
- return $commandLine
- }
-
- function Set-CommandLine {
- param([string]$Text)
-
- $commandLine = Get-CommandLine
- [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text)
- }
-
- # This function name is called by PSReadLine to read the next command line to execute.
- # We replace it with a custom implementation which adds Atuin support.
- function PSConsoleHostReadLine {
- ## 1. Collect the exit code of the previous command.
-
- # This needs to be done as the first thing because any script run will flush $?.
- $lastRunStatus = $?
-
- # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account.
- $lastNativeExitCode = $global:LASTEXITCODE
- $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 }
-
- ## 2. Report the status of the previous command to Atuin (atuin history end).
-
- if ($script:atuinHistoryId) {
- try {
- # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored.
- $duration = (Get-History -Count 1).Duration.Ticks * 100
- $durationArg = if ($duration) { "--duration=$duration" } else { $null }
-
- # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync.
- $process = New-Object System.Diagnostics.Process
- $process.StartInfo.FileName = "atuin"
- $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId"
- $process.StartInfo.UseShellExecute = $false
- $process.StartInfo.CreateNoWindow = $true
- $process.StartInfo.RedirectStandardInput = $true
- $process.StartInfo.RedirectStandardOutput = $true
- $process.StartInfo.RedirectStandardError = $true
- $process.Start() | Out-Null
- $process.StandardInput.Close()
- $process.BeginOutputReadLine()
- $process.BeginErrorReadLine()
- }
- catch {
- # Ignore errors to avoid breaking the shell.
- # An error would occur if the user removes atuin from the PATH, for instance.
- }
- finally {
- $script:atuinHistoryId = $null
- }
- }
-
- ## 3. Read the next command line to execute.
-
- # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions.
- Microsoft.PowerShell.Core\Set-StrictMode -Off
-
- $line = if ($script:hasExpectedReadLineOverload) {
- # When the overload we expect is available, we can pass $lastRunStatus to it.
- [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus)
- } else {
- # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is.
- & $script:previousPSConsoleHostReadLine
- }
-
- ## 4. Report the next command line to Atuin (atuin history start).
-
- # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version,
- # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes.
- # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions.
- try {
- $env:ATUIN_COMMAND_LINE = $line
- $script:atuinHistoryId = atuin history start --command-from-env
- }
- catch {
- # Ignore errors to avoid breaking the shell, see above.
- }
- finally {
- $env:ATUIN_COMMAND_LINE = $null
- }
-
- $global:LASTEXITCODE = $lastNativeExitCode
- return $line
- }
-
- function Invoke-AtuinSearch {
- param([string]$ExtraArgs = "")
-
- $previousOutputEncoding = [System.Console]::OutputEncoding
- $resultFile = New-TemporaryFile
- $suggestion = ""
- $errorOutput = ""
-
- try {
- [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8
-
- # Start-Process does some crazy stuff, just use the Process class directly to have more control.
- $process = New-Object System.Diagnostics.Process
- $process.StartInfo.FileName = "atuin"
- $process.StartInfo.Arguments = "search -i --result-file ""$($resultFile.FullName)"" $ExtraArgs"
- $process.StartInfo.UseShellExecute = $false
- $process.StartInfo.RedirectStandardError = $true
- $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8
- $process.StartInfo.EnvironmentVariables["ATUIN_SHELL"] = "powershell"
- $process.StartInfo.EnvironmentVariables["ATUIN_QUERY"] = Get-CommandLine
- # PowerShell's Set-Location (cd) doesn't update the process-level working directory, set it explicitly
- $process.StartInfo.WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath
-
- try {
- $process.Start() | Out-Null
-
- # A single stream is redirected, so we can read it synchronously, but we have to start reading it
- # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock.
- $errorOutput = $process.StandardError.ReadToEnd().Trim()
- $process.WaitForExit()
-
- $suggestion = (Get-Content -LiteralPath $resultFile.FullName -Raw -Encoding UTF8 | Out-String).Trim()
- }
- catch {
- $errorOutput = $_
- }
-
- if ($errorOutput) {
- Write-Host -ForegroundColor Red "Atuin error:"
- Write-Host -ForegroundColor DarkRed $errorOutput
- }
-
- # If no shell prompt offset is set, initialize it from the current prompt line count.
- if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) {
- try {
- $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines
- $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1)
- }
- catch {
- $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0
- }
- }
-
- # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode.
- # Fortunately, InvokePrompt can receive a new Y position and reset the internal state.
- $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET
- $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0)
- [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y)
-
- if ($suggestion -eq "") {
- # The previous input was already rendered by InvokePrompt
- return
- }
-
- $acceptPrefix = "__atuin_accept__:"
-
- if ( $suggestion.StartsWith($acceptPrefix)) {
- Set-CommandLine $suggestion.Substring($acceptPrefix.Length)
- [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
- } else {
- Set-CommandLine $suggestion
- }
- }
- finally {
- [System.Console]::OutputEncoding = $previousOutputEncoding
- $resultFile.Delete()
- }
- }
-
- function Enable-AtuinSearchKeys {
- param([bool]$CtrlR = $true, [bool]$UpArrow = $true)
-
- if ($CtrlR) {
- Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock {
- Invoke-AtuinSearch
- }
- }
-
- if ($UpArrow) {
- Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock {
- $line = Get-CommandLine
-
- if (!$line.Contains("`n")) {
- Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding"
- } else {
- [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine()
- }
- }
- }
- }
-
- $ExecutionContext.SessionState.Module.OnRemove += {
- $env:ATUIN_SESSION = $null
- $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine
- }
-
- Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine")
-} | Import-Module -Global
diff --git a/crates/turtle/src/shell/atuin.xsh b/crates/turtle/src/shell/atuin.xsh
deleted file mode 100644
index a0283402..00000000
--- a/crates/turtle/src/shell/atuin.xsh
+++ /dev/null
@@ -1,86 +0,0 @@
-import os
-import subprocess
-
-from prompt_toolkit.application.current import get_app
-from prompt_toolkit.filters import Condition
-from prompt_toolkit.keys import Keys
-
-
-if "ATUIN_SESSION" not in ${...} or ${...}.get("ATUIN_SHLVL", "") != ${...}.get("SHLVL", ""):
- $ATUIN_SESSION=$(atuin uuid).rstrip('\n')
- $ATUIN_SHLVL = ${...}.get("SHLVL", "")
-
-@events.on_precommand
-def _atuin_precommand(cmd: str):
- cmd = cmd.rstrip("\n")
- try:
- $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>@(os.devnull)).rstrip("\n")
- except:
- $ATUIN_HISTORY_ID = ""
-
-
-@events.on_postcommand
-def _atuin_postcommand(cmd: str, rtn: int, out, ts):
- if "ATUIN_HISTORY_ID" not in ${...}:
- return
-
- duration = ts[1] - ts[0]
- # Duration is float representing seconds, but atuin expects integer of nanoseconds
- nanos = round(duration * 10 ** 9)
- with ${...}.swap(ATUIN_LOG="error"):
- # This causes the entire .xonshrc to be re-executed, which is incredibly slow
- # This happens when using a subshell and using output redirection at the same time
- # For more details, see https://github.com/xonsh/xonsh/issues/5224
- # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1
- atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > @(os.devnull) 2>&1
- del $ATUIN_HISTORY_ID
-
-
-def _search(event, extra_args: list[str]):
- buffer = event.current_buffer
- cmd = ["atuin", "search", "--interactive", *extra_args]
- # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters
- env = ${...}.detype()
- env["ATUIN_SHELL"] = "xonsh"
- env["ATUIN_QUERY"] = buffer.text
-
- p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env)
- result = p.stderr.rstrip("\n")
- # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen
- event.cli.renderer.erase()
-
- if not result:
- return
-
- buffer.reset()
- if result.startswith("__atuin_accept__:"):
- buffer.insert_text(result[17:])
- buffer.validate_and_handle()
- else:
- buffer.insert_text(result)
-
-
-@events.on_ptk_create
-def _custom_keybindings(bindings, **kw):
- if _ATUIN_BIND_CTRL_R:
- @bindings.add(Keys.ControlR)
- def r_search(event):
- _search(event, extra_args=[])
-
- if _ATUIN_BIND_UP_ARROW:
- @Condition
- def should_search():
- buffer = get_app().current_buffer
- # disable keybind when there is an active completion, so
- # that up arrow can be used to navigate completion menu
- if buffer.complete_state is not None:
- return False
- # similarly, disable when buffer text contains multiple lines
- if '\n' in buffer.text:
- return False
-
- return True
-
- @bindings.add(Keys.Up, filter=should_search)
- def up_search(event):
- _search(event, extra_args=["--shell-up-key-binding"])
diff --git a/crates/turtle/src/shell/atuin.zsh b/crates/turtle/src/shell/atuin.zsh
deleted file mode 100644
index 7e7fef27..00000000
--- a/crates/turtle/src/shell/atuin.zsh
+++ /dev/null
@@ -1,167 +0,0 @@
-# shellcheck disable=SC2034,SC2153,SC2086,SC2155
-
-# Above line is because shellcheck doesn't support zsh, per
-# https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in
-# ludeeus/action-shellcheck only supports _directories_, not _files_. So
-# instead, we manually add any error the shellcheck step finds in the file to
-# the above line ...
-
-# Source this in your ~/.zshrc
-autoload -U add-zsh-hook
-
-zmodload zsh/datetime 2>/dev/null
-
-# If zsh-autosuggestions is installed, configure it to use Atuin's search. If
-# you'd like to override this, then add your config after the $(atuin init zsh)
-# in your .zshrc
-_zsh_autosuggest_strategy_atuin() {
- # silence errors, since we don't want to spam the terminal prompt while typing.
- suggestion=$(ATUIN_QUERY="$1" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null)
-}
-
-if [ -n "${ZSH_AUTOSUGGEST_STRATEGY:-}" ]; then
- ZSH_AUTOSUGGEST_STRATEGY=("atuin" "${ZSH_AUTOSUGGEST_STRATEGY[@]}")
-else
- ZSH_AUTOSUGGEST_STRATEGY=("atuin")
-fi
-
-if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then
- export ATUIN_SESSION=$(atuin uuid)
- export ATUIN_SHLVL=$SHLVL
-fi
-ATUIN_HISTORY_ID=""
-
-__atuin_osc133_command_executed() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
-
- printf '\033]133;C\a'
-}
-
-__atuin_osc133_command_finished() {
- [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return
- [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return
-
- printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}"
-}
-
-__atuin_osc133_prompt_start=$'%{\033]133;A;cl=line\a%}'
-__atuin_osc133_prompt_end=$'%{\033]133;B\a%}'
-
-__atuin_osc133_wrap_prompt() {
- local __atuin_prompt="${PROMPT-}"
- local __atuin_rprompt="${RPROMPT-}"
-
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}"
- __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}"
- __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_start/}"
- __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_end/}"
-
- if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then
- PROMPT="${__atuin_osc133_prompt_start}${__atuin_prompt}"
- RPROMPT="${__atuin_rprompt}${__atuin_osc133_prompt_end}"
- else
- PROMPT="$__atuin_prompt"
- RPROMPT="$__atuin_rprompt"
- fi
-}
-
-_atuin_preexec() {
- local id
- id=$(atuin history start -- "$1" 2>/dev/null)
- export ATUIN_HISTORY_ID="$id"
- __atuin_osc133_command_executed
- __atuin_preexec_time=${EPOCHREALTIME-}
-}
-
-_atuin_precmd() {
- local EXIT="$?" __atuin_precmd_time=${EPOCHREALTIME-}
-
- __atuin_osc133_wrap_prompt
-
- [[ -z "${ATUIN_HISTORY_ID:-}" ]] && return
-
- local duration=""
- if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then
- printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000))
- fi
-
- __atuin_osc133_command_finished "$EXIT"
- (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1
- export ATUIN_HISTORY_ID=""
-}
-
-__atuin_search_cmd() {
- local -a search_args=("$@")
-
-
- ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
-}
-
-_atuin_search() {
- emulate -L zsh
- zle -I
-
- # swap stderr and stdout, so that the tui stuff works
- # TODO: not this
- local output __atuin_status
- # shellcheck disable=SC2048
- output=$(__atuin_search_cmd $*)
- __atuin_status=$?
-
- zle reset-prompt
- # re-enable bracketed paste
- # shellcheck disable=SC2154
- echo -n ${zle_bracketed_paste[1]} >/dev/tty
-
- if (( __atuin_status != 0 )); then
- [[ -n $output ]] && print -r -- "$output" >/dev/tty
- return $__atuin_status
- fi
-
- if [[ -n $output ]]; then
- RBUFFER=""
- LBUFFER=$output
-
- if [[ $LBUFFER == __atuin_accept__:* ]]
- then
- LBUFFER=${LBUFFER#__atuin_accept__:}
- zle accept-line
- fi
- fi
-}
-_atuin_search_vicmd() {
- _atuin_search --keymap-mode=vim-normal
-}
-_atuin_search_viins() {
- _atuin_search --keymap-mode=vim-insert
-}
-
-_atuin_up_search() {
- # Only trigger if the buffer is a single line
- if [[ ! $BUFFER == *$'\n'* ]]; then
- _atuin_search --shell-up-key-binding "$@"
- else
- zle up-line
- fi
-}
-_atuin_up_search_vicmd() {
- _atuin_up_search --keymap-mode=vim-normal
-}
-_atuin_up_search_viins() {
- _atuin_up_search --keymap-mode=vim-insert
-}
-
-add-zsh-hook preexec _atuin_preexec
-add-zsh-hook precmd _atuin_precmd
-
-zle -N atuin-search _atuin_search
-zle -N atuin-search-vicmd _atuin_search_vicmd
-zle -N atuin-search-viins _atuin_search_viins
-zle -N atuin-up-search _atuin_up_search
-zle -N atuin-up-search-vicmd _atuin_up_search_vicmd
-zle -N atuin-up-search-viins _atuin_up_search_viins
-
-# These are compatibility widget names for "atuin <= 17.2.1" users.
-zle -N _atuin_search_widget _atuin_search
-zle -N _atuin_up_search_widget _atuin_up_search
diff --git a/crates/turtle/src/sync.rs b/crates/turtle/src/sync.rs
deleted file mode 100644
index abe1a201..00000000
--- a/crates/turtle/src/sync.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-use eyre::{Context, Result};
-
-use crate::atuin_client::database::ClientSqlite;
-use crate::atuin_client::{
- history::store::HistoryStore, record::sqlite_store::SqliteStore, settings::Settings,
-};
-use crate::atuin_common::record::RecordId;
-
-// This is the only crate that ties together all other crates.
-// Therefore, it's the only crate where functions tying together all stores can live
-
-/// Rebuild all stores after a sync
-/// Note: for history, this only does an _incremental_ sync. Hence the need to specify downloaded
-/// records.
-pub(crate) async fn build(
- settings: &Settings,
- store: &SqliteStore,
- db: &ClientSqlite,
- downloaded: Option<&[RecordId]>,
-) -> Result<()> {
- let encryption_key: [u8; 32] = crate::atuin_client::encryption::load_key(settings)
- .context("could not load encryption key")?
- .into();
-
- let host_id = Settings::host_id().await?;
-
- let downloaded = downloaded.unwrap_or(&[]);
-
- let history_store = HistoryStore::new(store.clone(), host_id, encryption_key);
-
- history_store.incremental_build(db, downloaded).await?;
-
- Ok(())
-}