diff options
Diffstat (limited to 'crates/turtle/src')
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(®ex)); - } - - 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(¤t_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(()) -} |
