diff options
Diffstat (limited to '')
32 files changed, 1361 insertions, 4511 deletions
diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 6d26a282..964c9728 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -12,6 +12,8 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] +turtle-daemon = {workspace = true} +turtle-common = {workspace = true} async-trait = "0.1.58" axum = "0.8" base64 = "0.22" diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs index 0678d8b7..fa40d8dd 100644 --- a/crates/client/src/atuin_client/mod.rs +++ b/crates/client/src/atuin_client/mod.rs @@ -1,2 +1,2 @@ pub(crate) mod settings; -pub(crate) mod theme; +// pub(crate) mod theme; diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs index 7993ef6d..e69de29b 100644 --- a/crates/client/src/atuin_client/settings/meta.rs +++ b/crates/client/src/atuin_client/settings/meta.rs @@ -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 = turtle_common::utils::data_dir(); - let path = dir.join("meta.db"); - - Self { - db_path: path.to_string_lossy().to_string(), - } - } -} diff --git a/crates/client/src/atuin_client/settings/mod.rs b/crates/client/src/atuin_client/settings/mod.rs index 0bddc09c..0eb228b9 100644 --- a/crates/client/src/atuin_client/settings/mod.rs +++ b/crates/client/src/atuin_client/settings/mod.rs @@ -1,12 +1,5 @@ -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 std::{collections::HashMap, fmt, path::PathBuf, str::FromStr, sync::OnceLock}; -use crate::aclient::encryption::decode_key; use clap::ValueEnum; use config::{ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, @@ -16,94 +9,11 @@ use fs_err::create_dir_all; use regex::RegexSet; use serde::{Deserialize, Serialize}; use serde_with::DeserializeFromStr; -use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description}; -use turtle_common::record::HostId; +use time::{UtcOffset, format_description::FormatItem, macros::format_description}; +use tracing::info; use turtle_common::utils; static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); -static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new(); -static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new(); - -pub(crate) mod meta; -pub(crate) mod watcher; - -#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)] -pub(crate) enum SearchMode { - #[serde(rename = "prefix")] - Prefix, - - #[serde(rename = "fulltext")] - #[clap(aliases = &["fulltext"])] - FullText, - - #[serde(rename = "fuzzy")] - Fuzzy, - - #[serde(rename = "skim")] - Skim, - - #[serde(rename = "daemon-fuzzy")] - #[clap(aliases = &["daemon-fuzzy"])] - DaemonFuzzy, -} - -impl SearchMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Prefix => "PREFIX", - Self::FullText => "FULLTXT", - Self::Fuzzy => "FUZZY", - Self::Skim => "SKIM", - Self::DaemonFuzzy => "DAEMON", - } - } - pub(crate) fn next(self, settings: &Settings) -> Self { - match self { - Self::Prefix => Self::FullText, - // if the user is using skim, we go to skim - Self::FullText if settings.search_mode == Self::Skim => Self::Skim, - // if the user is using daemon-fuzzy, we go to daemon-fuzzy - Self::FullText if settings.search_mode == Self::DaemonFuzzy => Self::DaemonFuzzy, - // otherwise fuzzy. - Self::FullText => Self::Fuzzy, - Self::Fuzzy | Self::Skim | Self::DaemonFuzzy => Self::Prefix, - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub(crate) enum FilterMode { - #[serde(rename = "global")] - Global = 0, - - #[serde(rename = "host")] - Host = 1, - - #[serde(rename = "session")] - Session = 2, - - #[serde(rename = "directory")] - Directory = 3, - - #[serde(rename = "workspace")] - Workspace = 4, - - #[serde(rename = "session-preload")] - SessionPreload = 5, -} - -impl FilterMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Global => "GLOBAL", - Self::Host => "HOST", - Self::Session => "SESSION", - Self::Directory => "DIRECTORY", - Self::Workspace => "WORKSPACE", - Self::SessionPreload => "SESSION+", - } - } -} #[derive(Clone, Debug, Deserialize, Copy, Serialize)] pub(crate) enum ExitMode { @@ -389,38 +299,11 @@ pub(crate) struct Preview { #[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`]. @@ -481,14 +364,6 @@ pub(crate) struct Logs { /// 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 { @@ -502,11 +377,8 @@ impl Default for Preview { 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, } } } @@ -518,14 +390,6 @@ impl Default for Logs { 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() - }, } } } @@ -538,61 +402,6 @@ impl Logs { 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. @@ -611,246 +420,6 @@ pub(crate) enum PreviewStrategy { Fixed, } -/// Column types available for the interactive search UI. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum UiColumnType { - /// Command execution duration (e.g., "123ms") - Duration, - /// Relative time since execution (e.g., "59s ago") - Time, - /// Absolute timestamp (e.g., "2025-01-22 14:35") - Datetime, - /// Working directory - Directory, - /// Hostname - Host, - /// Username - User, - /// Exit code - Exit, - /// The command itself (should be last, expands to fill) - Command, -} - -impl UiColumnType { - /// Returns the default width for this column type (in characters). - /// The Command column returns 0 as it expands to fill remaining space. - pub(crate) fn default_width(self) -> u16 { - match self { - Self::Duration => 5, // "814ms" - Self::Time => 9, // "459ms ago" - Self::Datetime => 16, // "2025-01-22 14:35" - Self::Directory => 20, - Self::Host => 15, - Self::User => 10, - Self::Exit => { - if cfg!(windows) { - 11 // 32-bit integer on Windows: "-1978335212" - } else { - 3 // Usually a byte on Unix - } - } - Self::Command => 0, // Expands to fill - } - } -} - -/// A column configuration with type and optional custom width. -/// Can be specified as just a string (uses default width) or as an object with type and width. -#[derive(Clone, Debug, Serialize)] -pub(crate) struct UiColumn { - pub(crate) column_type: UiColumnType, - pub(crate) width: u16, - /// If true, this column expands to fill remaining space. Only one column should expand. - pub(crate) expand: bool, -} - -impl UiColumn { - pub(crate) fn new(column_type: UiColumnType) -> Self { - Self { - width: column_type.default_width(), - expand: column_type == UiColumnType::Command, - column_type, - } - } -} - -// Custom deserialize to handle both string and object formats: -// "duration" or { type = "duration", width = 8, expand = true } -impl<'de> Deserialize<'de> for UiColumn { - fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> - where - D: serde::Deserializer<'de>, - { - use serde::de::{self, MapAccess, Visitor}; - - struct UiColumnVisitor; - - impl<'de> Visitor<'de> for UiColumnVisitor { - type Value = UiColumn; - - fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str( - "a column type string or an object with 'type' and optional 'width'/'expand'", - ) - } - - fn visit_str<E>(self, value: &str) -> Result<UiColumn, E> - where - E: de::Error, - { - let column_type: UiColumnType = - Deserialize::deserialize(de::value::StrDeserializer::new(value))?; - Ok(UiColumn::new(column_type)) - } - - fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error> - where - M: MapAccess<'de>, - { - let mut column_type: Option<UiColumnType> = None; - let mut width: Option<u16> = None; - let mut expand: Option<bool> = None; - - while let Some(key) = map.next_key::<String>()? { - match key.as_str() { - "type" => { - column_type = Some(map.next_value()?); - } - "width" => { - width = Some(map.next_value()?); - } - "expand" => { - expand = Some(map.next_value()?); - } - _ => { - let _: de::IgnoredAny = map.next_value()?; - } - } - } - - let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?; - let width = width.unwrap_or_else(|| column_type.default_width()); - let expand = expand.unwrap_or(column_type == UiColumnType::Command); - Ok(UiColumn { - column_type, - width, - expand, - }) - } - } - - deserializer.deserialize_any(UiColumnVisitor) - } -} - -/// UI-specific settings for the interactive search. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub(crate) struct Ui { - /// Columns to display in interactive search, from left to right. - /// The indicator column (" > ") is always shown first implicitly. - /// The "command" column should be last as it expands to fill remaining space. - /// Can be simple strings or objects with type and width. - #[serde(default = "Ui::default_columns")] - pub(crate) columns: Vec<UiColumn>, -} - -impl Ui { - fn default_columns() -> Vec<UiColumn> { - vec![ - UiColumn::new(UiColumnType::Duration), - UiColumn::new(UiColumnType::Time), - UiColumn::new(UiColumnType::Command), - ] - } - - /// Validate the UI configuration. - /// Returns an error if more than one column has expand = true. - pub(crate) fn validate(&self) -> Result<()> { - let expand_count = self.columns.iter().filter(|c| c.expand).count(); - if expand_count > 1 { - bail!( - "Only one column can have expand = true, but {} columns are set to expand", - expand_count - ); - } - Ok(()) - } -} - -impl Default for Ui { - fn default() -> Self { - Self { - columns: Self::default_columns(), - } - } -} - -/// Sync-specific settings. -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub(crate) struct Sync { - /// The sync address for atuin. - pub(crate) address: String, - - #[serde(default)] - pub(crate) frequency: String, - - #[serde(default)] - pub(crate) auto: bool, - - #[serde(default)] - pub(crate) user_id_path: Option<PathBuf>, - - #[serde(default)] - pub(crate) encryption_key_path: Option<PathBuf>, -} - -impl Sync { - fn try_read_file(file: Option<&PathBuf>) -> Result<Option<String>> { - if let Some(path) = file { - if path.try_exists()? { - let user = read_to_string(path)?; - - if user.is_empty() { - Ok(None) - } else { - Ok(Some(user)) - } - } else { - // It's okay that the file doesn't exist. - // The important part is to error out if we can't access it (e.g. Because of missing - // permissions). - Ok(None) - } - } else { - Ok(None) - } - } - - pub(crate) fn have_sync_user(&self) -> Result<bool> { - let sa = self.user_id()?; - Ok(sa.is_some()) - } - - pub(crate) fn user_id(&self) -> Result<Option<Uuid>> { - Self::try_read_file(self.user_id_path.as_ref())? - .map(|file| { - Uuid::parse_str(file.trim()).context( - "Failed to decode user id as UUID, while trying to decode sync user_id", - ) - }) - .transpose() - } - pub(crate) fn encryption_key(&self) -> Result<Option<Key>> { - Self::try_read_file(self.encryption_key_path.as_ref())? - .as_deref() - .map(str::trim) - .map(decode_key) - .transpose() - } -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[expect(clippy::struct_excessive_bools)] pub struct Settings { @@ -859,15 +428,7 @@ pub struct Settings { 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, @@ -906,9 +467,6 @@ pub struct Settings { pub(crate) command_chaining: bool, #[serde(default)] - pub(crate) sync: Sync, - - #[serde(default)] pub(crate) stats: Stats, #[serde(default)] @@ -924,61 +482,10 @@ pub struct Settings { 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::aclient::meta::MetaStore> { - META_STORE - .get_or_try_init(|| async { - let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| { - eyre!("meta store config not set — Settings::new() has not been called") - })?; - crate::aclient::meta::MetaStore::new(db_path, *timeout).await - }) - .await - } - - pub(crate) async fn host_id() -> Result<HostId> { - Self::meta_store().await?.host_id().await - } - - pub(crate) async fn last_sync() -> Result<OffsetDateTime> { - Self::meta_store().await?.last_sync().await - } - - pub(crate) async fn save_sync_time() -> Result<()> { - Self::meta_store().await?.save_sync_time().await - } - - pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode { - self.filter_mode - .filter(|x| self.search.filters.contains(x)) - .or_else(|| { - self.search - .filters - .iter() - .find(|x| match (x, git_root, self.workspaces) { - (FilterMode::Workspace, true, true) => true, - (FilterMode::Workspace, _, _) => false, - (_, _, _) => true, - }) - .copied() - }) - .unwrap_or(FilterMode::Global) - } - pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> { Self::builder_with_data_dir(&utils::data_dir()) } @@ -989,13 +496,11 @@ impl Settings { 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}")? @@ -1068,8 +573,6 @@ impl Settings { .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)? @@ -1295,14 +798,6 @@ impl Settings { .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) } @@ -1311,12 +806,6 @@ impl Settings { .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 { @@ -1381,60 +870,6 @@ mod tests { } #[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; @@ -1447,7 +882,6 @@ mod tests { 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")?; @@ -1462,7 +896,6 @@ mod tests { scripts_db_path, custom_dir.join("scripts.db").to_str().unwrap() ); - assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap()); assert_eq!( daemon_socket_path, turtle_common::utils::runtime_dir() diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs deleted file mode 100644 index 01d20855..00000000 --- a/crates/client/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::aclient::settings::watcher::global_settings_watcher; -//! -//! async fn example() -> eyre::Result<()> { -//! let watcher = global_settings_watcher()?; -//! let mut rx = watcher.subscribe(); -//! -//! // React to settings changes -//! while rx.changed().await.is_ok() { -//! let settings = rx.borrow(); -//! println!("Settings updated!"); -//! } -//! Ok(()) -//! } -//! ``` - -use std::{ - path::{Path, PathBuf}, - sync::{Arc, OnceLock}, - time::Duration, -}; - -use eyre::{Result, WrapErr}; -use log::{debug, error, info, warn}; -use notify::{ - Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher, - event::{EventKind, ModifyKind}, -}; -use tokio::sync::watch; - -use super::Settings; - -/// Global singleton for the settings watcher. -static SETTINGS_WATCHER: OnceLock<Result<SettingsWatcher, String>> = OnceLock::new(); - -/// Get the global settings watcher singleton. -/// -/// Initializes the watcher on first call. Subsequent calls return the same instance. -/// The watcher monitors the config file for changes and broadcasts updates. -pub(crate) fn global_settings_watcher() -> Result<&'static SettingsWatcher> { - let result = SETTINGS_WATCHER.get_or_init(|| SettingsWatcher::new().map_err(|e| e.to_string())); - - match result { - Ok(watcher) => Ok(watcher), - Err(e) => Err(eyre::eyre!("{}", e)), - } -} - -/// Watches the config file for changes and broadcasts updated settings. -/// -/// Uses `notify` for cross-platform file watching and `tokio::sync::watch` -/// for efficient broadcast to multiple subscribers. -pub(crate) struct SettingsWatcher { - /// Receiver for settings updates. Clone this to subscribe. - rx: watch::Receiver<Arc<Settings>>, - /// Keeps the file watcher alive for the lifetime of this struct. - _watcher: RecommendedWatcher, -} - -impl SettingsWatcher { - /// Create a new settings watcher. - /// - /// Loads initial settings and starts watching the config file for changes. - /// Changes are debounced (500ms) to avoid multiple reloads during saves. - pub(crate) fn new() -> Result<Self> { - let initial_settings = Arc::new(Settings::new()?); - let (tx, rx) = watch::channel(initial_settings); - - let config_path = Self::config_path(); - info!("starting config file watcher: {}", config_path.display()); - - let watcher = Self::create_watcher(tx, &config_path)?; - - Ok(Self { - rx, - _watcher: watcher, - }) - } - - /// Subscribe to settings updates. - /// - /// Returns a receiver that will be notified when settings change. - /// Use `changed().await` to wait for the next update, then `borrow()` - /// to access the current settings. - pub(crate) fn subscribe(&self) -> watch::Receiver<Arc<Settings>> { - self.rx.clone() - } - - /// Get the config file path. - fn config_path() -> PathBuf { - let config_dir = std::env::var("ATUIN_CONFIG_DIR") - .map_or_else(|_| turtle_common::utils::config_dir(), PathBuf::from); - config_dir.join("config.toml") - } - - /// Create the file watcher with debouncing. - fn create_watcher( - tx: watch::Sender<Arc<Settings>>, - config_path: &Path, - ) -> Result<RecommendedWatcher> { - // Channel for debouncing file events - let (debounce_tx, debounce_rx) = std::sync::mpsc::channel::<()>(); - - // Spawn debounce thread - let config_path_clone = config_path.to_owned(); - std::thread::spawn(move || { - Self::debounce_loop(&debounce_rx, &tx, &config_path_clone); - }); - - // Clone config_path for use in the watcher callback - let config_path_for_watcher = config_path.to_owned(); - - // Canonicalize config path for reliable comparison on macOS - // (handles symlinks like /var -> /private/var) - let canonical_config_path = config_path_for_watcher - .canonicalize() - .unwrap_or_else(|_| config_path_for_watcher.clone()); - - // Create file watcher - let mut watcher = RecommendedWatcher::new( - move |res: Result<notify::Event, notify::Error>| { - match res { - Ok(event) => { - // Defensive: if paths is empty, we can't filter, so assume - // it might be our config file and trigger a reload to be safe - if event.paths.is_empty() { - warn!( - "config watcher: event has no paths, triggering reload to be safe" - ); - debounce_tx.send(()).expect("should still be active"); - return; - } - - // Only react to events for our specific config file - // (filter out editor temp files, backups, etc.) - let is_config_file = event.paths.iter().any(|path| { - // Canonicalize for reliable comparison (handles macOS symlinks) - let canonical_event_path = - path.canonicalize().unwrap_or_else(|_| path.clone()); - - // Check if this event is for our config file - // (either exact match or the file was renamed to our config) - canonical_event_path == canonical_config_path - || path.file_name() == config_path_for_watcher.file_name() - }); - - if !is_config_file { - return; - } - - // Only react to modify events (content changes) or creates - if matches!( - event.kind, - EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Any) - | EventKind::Create(_) - ) { - debug!("config file event detected: {event:?}"); - // Send to debounce channel (ignore send errors - receiver might be gone) - debounce_tx.send(()).ok(); - } - } - Err(e) => { - error!("file watcher error: {e}"); - } - } - }, - NotifyConfig::default(), - ) - .wrap_err("failed to create file watcher")?; - - // Watch the config file's parent directory (some editors create new files) - let watch_path = config_path.parent().unwrap_or(config_path); - - // Defensive: ensure watch path exists before trying to watch - if !watch_path.exists() { - warn!( - "config directory does not exist, creating it: {}", - watch_path.display() - ); - std::fs::create_dir_all(watch_path).wrap_err_with(|| { - format!( - "failed to create config directory: {}", - watch_path.display() - ) - })?; - } - - watcher - .watch(watch_path, RecursiveMode::NonRecursive) - .wrap_err_with(|| { - format!("failed to watch config directory: {}", watch_path.display()) - })?; - - info!( - "config file watcher initialized for: {}", - watch_path.display() - ); - Ok(watcher) - } - - /// Debounce loop that batches file events and reloads settings. - fn debounce_loop( - rx: &std::sync::mpsc::Receiver<()>, - tx: &watch::Sender<Arc<Settings>>, - config_path: &Path, - ) { - const DEBOUNCE_DURATION: Duration = Duration::from_millis(500); - - loop { - // Wait for first event - if rx.recv().is_err() { - // Channel closed, watcher was dropped - debug!("config watcher debounce loop exiting"); - return; - } - - // Drain any additional events within debounce window - while rx.recv_timeout(DEBOUNCE_DURATION).is_ok() { - // Keep draining - } - - // Defensive: check if config file exists before reloading - // (handles case where file was deleted - we'll get notified when it's recreated) - if !config_path.exists() { - debug!( - "config file does not exist, skipping reload: {}", - config_path.display() - ); - continue; - } - - // Now reload settings - info!( - "config file changed, reloading settings: {}", - config_path.display() - ); - match Settings::new() { - Ok(settings) => { - if tx.send(Arc::new(settings)).is_err() { - // All receivers dropped - debug!("all settings subscribers dropped, exiting"); - return; - } - info!("settings reloaded successfully"); - } - Err(e) => { - warn!("failed to reload settings: {e}"); - // Keep the old settings, don't broadcast the error - } - } - } - } -} diff --git a/crates/client/src/atuin_history/mod.rs b/crates/client/src/atuin_history/mod.rs index 41336a14..b3ca0d2f 100644 --- a/crates/client/src/atuin_history/mod.rs +++ b/crates/client/src/atuin_history/mod.rs @@ -1,2 +1 @@ -pub(crate) mod sort; pub(crate) mod stats; diff --git a/crates/client/src/atuin_history/sort.rs b/crates/client/src/atuin_history/sort.rs deleted file mode 100644 index 3143fa68..00000000 --- a/crates/client/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/client/src/atuin_history/stats.rs b/crates/client/src/atuin_history/stats.rs index c53dafb2..462fe077 100644 --- a/crates/client/src/atuin_history/stats.rs +++ b/crates/client/src/atuin_history/stats.rs @@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet}; use crossterm::style::{Color, ResetColor, SetAttribute, SetForegroundColor}; use serde::{Deserialize, Serialize}; +use turtle_daemon::aclient::history::History; use unicode_segmentation::UnicodeSegmentation; -use crate::atuin_client::{history::History, settings::Settings}; +use crate::atuin_client::settings::Settings; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct Stats { @@ -278,9 +279,9 @@ pub(crate) fn compute( #[cfg(test)] mod tests { - use crate::atuin_client::history::History; use crate::atuin_client::settings::Settings; use time::OffsetDateTime; + use turtle_daemon::aclient::history::History; use super::compute; use super::{interesting_command, split_at_pipe, strip_leading_env_vars}; @@ -301,29 +302,6 @@ mod tests { } #[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(); diff --git a/crates/client/src/atuin_pty_proxy/capture.rs b/crates/client/src/atuin_pty_proxy/capture.rs deleted file mode 100644 index 55bc1788..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/debug.rs b/crates/client/src/atuin_pty_proxy/debug.rs deleted file mode 100644 index c2a1691c..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/mod.rs b/crates/client/src/atuin_pty_proxy/mod.rs deleted file mode 100644 index e1d01c83..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/osc133.rs b/crates/client/src/atuin_pty_proxy/osc133.rs deleted file mode 100644 index f2a796f1..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/pty_proxy.rs b/crates/client/src/atuin_pty_proxy/pty_proxy.rs deleted file mode 100644 index ef4b0c37..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/runtime.rs b/crates/client/src/atuin_pty_proxy/runtime.rs deleted file mode 100644 index 69b3a075..00000000 --- a/crates/client/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/client/src/atuin_pty_proxy/screen.rs b/crates/client/src/atuin_pty_proxy/screen.rs deleted file mode 100644 index 58ebd2eb..00000000 --- a/crates/client/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/client/src/command/client.rs b/crates/client/src/command/client.rs index 42e72e21..9f45f53b 100644 --- a/crates/client/src/command/client.rs +++ b/crates/client/src/command/client.rs @@ -37,11 +37,12 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { } mod config; +mod daemon; mod default_config; mod history; mod info; mod stats; -mod store; +// mod store; mod sync; mod wrapped; @@ -52,14 +53,17 @@ pub(crate) enum Cmd { #[command(subcommand)] History(history::Cmd), + /// Interact with the daemon #[command(subcommand)] - /// Request a sync or view sync status - Sync(sync::Cmd), + Daemon(daemon::Cmd), - /// Manage the atuin data store #[command(subcommand)] - Store(store::Cmd), + /// Request a sync or view sync status + Sync(sync::Cmd), + // /// Manage the atuin data store + // #[command(subcommand)] + // Store(store::Cmd), /// Information about dotfiles locations and ENV vars #[command()] Info, @@ -97,7 +101,7 @@ impl Cmd { res } - async fn run_inner(self, mut settings: Settings) -> Result<()> { + async fn run_inner(self, settings: Settings) -> Result<()> { // ATUIN_LOG env var overrides config file level settings let env_log_set = std::env::var("ATUIN_LOG").is_ok(); @@ -117,14 +121,14 @@ impl Cmd { } match self { - Self::Stats(stats) => stats.run(&db, &settings).await, - Self::Wrapped { year } => wrapped::run(year, &db, &settings).await, - - Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, + Self::Daemon(cmd) => cmd.run(&settings).await, + Self::Sync(sync) => sync.run(settings).await, - Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Stats(stats) => stats.run(&settings).await, + Self::Wrapped { year } => wrapped::run(year, &settings).await, - Self::Info => info::run(&settings), + // Self::Store(store) => store.run(&settings, &db, sqlite_store).await, + Self::Info => info::run(&settings).await, Self::DefaultConfig => { default_config::run(); diff --git a/crates/client/src/command/client/daemon.rs b/crates/client/src/command/client/daemon.rs new file mode 100644 index 00000000..83877cfd --- /dev/null +++ b/crates/client/src/command/client/daemon.rs @@ -0,0 +1,44 @@ +use clap::Subcommand; +use eyre::Result; + +use turtle_daemon::api::client::{Probe, probe}; + +use crate::atuin_client::settings::Settings; + +#[derive(Subcommand, Debug)] +#[command(infer_subcommands = true)] +pub(crate) enum Cmd { + /// Show the daemon's current status + Status, +} + +impl Cmd { + pub(crate) async fn run(self, settings: &Settings) -> Result<()> { + match self { + Cmd::Status => status_cmd(&settings).await, + } + } +} + +async fn status_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).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); + 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(()) +} diff --git a/crates/client/src/command/client/history.rs b/crates/client/src/command/client/history.rs deleted file mode 100644 index 4d633b7d..00000000 --- a/crates/client/src/command/client/history.rs +++ /dev/null @@ -1,1241 +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()); - } -} - -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), - } -} - -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), - } -} - -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/client/src/command/client/history/end.rs b/crates/client/src/command/client/history/end.rs new file mode 100644 index 00000000..bfec0aab --- /dev/null +++ b/crates/client/src/command/client/history/end.rs @@ -0,0 +1,38 @@ +use crate::atuin_client::settings::Settings; + +use eyre::{Result, eyre}; +use turtle_daemon::api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}; + +pub(super) async fn handle( + settings: &Settings, + id: &str, + exit: i64, + duration: Option<u64>, +) -> Result<()> { + end_history(settings, id.to_string(), duration.unwrap_or(0), exit).await?; + + Ok(()) +} + +async fn end_history(settings: &Settings, id: String, duration: u64, exit: i64) -> Result<()> { + match async { + HistoryClient::new(settings.daemon.socket_path.clone()) + .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), + } +} diff --git a/crates/client/src/command/client/history/mod.rs b/crates/client/src/command/client/history/mod.rs new file mode 100644 index 00000000..d71c653d --- /dev/null +++ b/crates/client/src/command/client/history/mod.rs @@ -0,0 +1,753 @@ +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + ops::ControlFlow, + time::Duration, +}; + +use clap::Subcommand; +use eyre::Result; +use runtime_format::{FormatKey, FormatKeyError, ParseSegment, ParsedFmt}; +use time::{OffsetDateTime, macros::format_description}; +use turtle_common::utils::Escapable; +use turtle_daemon::aclient::history::History; + +use crate::atuin_client::settings::{Settings, Timezone}; + +mod end; +mod start; +mod tail; + +#[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, + }, +} + +impl Cmd { + // #[expect(clippy::too_many_arguments)] + // #[expect(clippy::fn_params_excessive_bools)] + // async fn handle_list( + // settings: &Settings, + // 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) = + start::handle(settings, &command, author.as_deref(), intent.as_deref()).await? + { + println!("{id}"); + } + + Ok(()) + } + Self::End { id, exit, duration } => end::handle(settings, &id, exit, duration).await, + Self::Tail => { + return tail::handle(settings).await; + } + cmd => { + todo!() + // 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!(), + // } + } + } + } +} + +#[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, +} +/// 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 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"), + } +} + +#[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]"); + +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 + } +} + +#[cfg(test)] +mod tests { + use super::{Settings, 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()); + } +} diff --git a/crates/client/src/command/client/history/start.rs b/crates/client/src/command/client/history/start.rs new file mode 100644 index 00000000..81aaa904 --- /dev/null +++ b/crates/client/src/command/client/history/start.rs @@ -0,0 +1,76 @@ +use crate::{ + atuin_client::settings::Settings, + command::client::history::{apply_start_metadata, normalize_command_for_storage}, +}; + +use eyre::{Result, eyre}; +use time::OffsetDateTime; +use tracing::debug; +use turtle_common::utils; +use turtle_daemon::{ + aclient::history::{History, SettingsFilter}, + api::client::{HistoryClient, daemon_matches_expected, daemon_mismatch_message}, +}; + +pub(super) async fn handle( + 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(SettingsFilter { + history: &settings.history_filter, + cwd: &settings.cwd_filter, + secrets: settings.secrets_filter, + }) { + 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 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 start_history(settings: &Settings, history: History) -> Result<String> { + match async { + HistoryClient::new(settings.daemon.socket_path.clone()) + .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), + } +} diff --git a/crates/client/src/command/client/history/tail.rs b/crates/client/src/command/client/history/tail.rs new file mode 100644 index 00000000..595fe3a0 --- /dev/null +++ b/crates/client/src/command/client/history/tail.rs @@ -0,0 +1,321 @@ +use crate::{ + atuin_client::settings::{Settings, Timezone}, + command::client::history::{TIME_FMT, format_duration_into}, +}; + +use colored::Colorize; +use eyre::{Context, Result, bail}; +use serde::Serialize; +use time::OffsetDateTime; +use turtle_common::utils::Escapable; +use turtle_daemon::{ + aclient::history::History, + api::client::{ + HistoryClient, HistoryEventKind, Probe, TailHistoryReply, history_entry_to_history, probe, + }, +}; + +use std::{ + fmt::{self, Display}, + io::{self, IsTerminal, Write}, + time::Duration, +}; + +#[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 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_entry_to_history(history), + }) + } + + 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 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 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 format_history_time(timestamp: OffsetDateTime, tz: Timezone) -> Result<String> { + Ok(timestamp.to_offset(tz.0).format(TIME_FMT)?) +} + +async fn tail_client(settings: &Settings) -> Result<HistoryClient> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(_) => HistoryClient::new(settings.daemon.socket_path.clone()).await, + Probe::NeedsRestart(reason) => { + bail!("{reason}. Restart the daemon manually"); + } + Probe::Unreachable(err) => Err(err), + } +} + +pub(super) async fn handle(settings: &Settings) -> Result<()> { + let tty = io::stdout().is_terminal(); + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).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(()) +} diff --git a/crates/client/src/command/client/info.rs b/crates/client/src/command/client/info.rs index 49c92193..77c7064c 100644 --- a/crates/client/src/command/client/info.rs +++ b/crates/client/src/command/client/info.rs @@ -2,21 +2,33 @@ use crate::atuin_client::settings::Settings; use crate::{SHA, VERSION};
use eyre::Result;
+use turtle_daemon::api::client::ControlClient;
+
+pub(crate) async fn run(settings: &Settings) -> Result<()> {
+ let config = turtle_common::utils::config_dir();
+
+ let mut client = ControlClient::new(settings.daemon.socket_path.clone()).await?;
+ let paths = client.paths().await?;
-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 files:
+ client config: {:?}
+ server config: {:?}
+ deamon config: {:?}
+ deamon db path: {:?}
+ deamon socket path: {:?}\
+ ",
config_file.to_string_lossy(),
sever_config.to_string_lossy(),
- settings.db_path,
- settings.sync.encryption_key()?,
- settings.meta.db_path
+ paths.config,
+ paths.db,
+ paths.socket,
);
let env_vars = format!(
diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs index 9b8ebdff..ec235f36 100644 --- a/crates/client/src/command/client/stats.rs +++ b/crates/client/src/command/client/stats.rs @@ -2,10 +2,12 @@ use clap::Parser; use eyre::Result; use interim::parse_date_string; use time::{Duration, OffsetDateTime, Time}; +use turtle_daemon::api::client::{HistoryClient, Range}; use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{compute, pretty_print}; +use crate::command::current_session; fn parse_ngram_size(s: &str) -> Result<usize, String> { let value = s @@ -36,7 +38,9 @@ pub(crate) struct Cmd { impl Cmd { pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { - let context = current_context().await?; + let session = current_session()?; + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let words = if self.period.is_empty() { String::from("all") } else { @@ -47,27 +51,27 @@ impl Cmd { let last_night = now.replace_time(Time::MIDNIGHT); let history = if words.as_str() == "all" { - db.list(&[], &context, None, false, false).await? + client.history(session, None).await? } else if words.trim() == "today" { let start = last_night; let end = start + Duration::days(1); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "month" { let end = last_night; let start = end - Duration::days(31); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "week" { let end = last_night; let start = end - Duration::days(7); - db.range(start, end).await? + client.history(session, Some(Range { start, end })).await? } else if words.trim() == "year" { let end = last_night; let start = end - Duration::days(365); - db.range(start, end).await? + client.history(session, Some(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? + client.history(session, Some(Range { start, end })).await? }; let stats = compute(settings, &history, self.count, self.ngram_size); diff --git a/crates/client/src/command/client/store.rs b/crates/client/src/command/client/store/mod.rs index bc57488d..bc57488d 100644 --- a/crates/client/src/command/client/store.rs +++ b/crates/client/src/command/client/store/mod.rs diff --git a/crates/client/src/command/client/sync.rs b/crates/client/src/command/client/sync.rs index c29a82fc..86228e47 100644 --- a/crates/client/src/command/client/sync.rs +++ b/crates/client/src/command/client/sync.rs @@ -1,29 +1,18 @@ use clap::Subcommand; -use eyre::{Result, WrapErr}; +use colored::Colorize; +use eyre::{Result, WrapErr, bail}; use serde_json::json; -use crate::{ - atuin_client::{ - database::ClientSqlite, - encryption, - history::store::HistoryStore, - record::{sqlite_store::SqliteStore, sync}, - settings::Settings, - }, - atuin_common::utils, -}; +use turtle_common::utils; +use turtle_daemon::api::client::{Probe, probe}; -mod status; +use crate::{SHA, VERSION, atuin_client::settings::Settings}; #[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, - }, + Perform {}, /// Print (or generate) the encryption key and user id for transfer to another machine KeyAndId {}, @@ -33,88 +22,72 @@ pub(crate) enum Cmd { } impl Cmd { - pub(crate) async fn run( - self, - settings: Settings, - db: &ClientSqlite, - store: SqliteStore, - ) -> Result<()> { + pub(crate) async fn run(self, settings: Settings) -> Result<()> { match self { - Self::Perform { force } => run(&settings, force, db, store).await, - Self::Status => status::run(&settings).await, + Self::Perform {} => perform_cmd(&settings).await, + Self::Status => status_cmd(&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(()) + todo!() + // 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?; +async fn status_cmd(settings: &Settings) -> Result<()> { + todo!(); - #[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..."); + // 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"); + // } - // 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?; + Ok(()) +} - println!("{uploaded}/{} up/down to record store", downloaded.len()); +async fn perform_cmd(settings: &Settings) -> Result<()> { + match probe(settings.daemon.socket_path.clone()).await { + Probe::Ready(mut control_client) => { + let reply = control_client.force_sync().await?; + if !reply.accepted { + bail!("Daemon refused to accept sync request"); + } + } + Probe::NeedsRestart(msg) => { + bail!("Daemon version mis-match, needs restart: {msg}"); + } + Probe::Unreachable(report) => { + bail!("Daemon unreachable: {report}"); + } } - println!( - "Sync complete! {} items in history database, force: {}", - db.history_count(true).await?, - force - ); - Ok(()) } diff --git a/crates/client/src/command/client/sync/status.rs b/crates/client/src/command/client/sync/status.rs deleted file mode 100644 index caf3b90f..00000000 --- a/crates/client/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/client/src/command/client/wrapped.rs b/crates/client/src/command/client/wrapped.rs index 2ce19bf7..64e5b718 100644 --- a/crates/client/src/command/client/wrapped.rs +++ b/crates/client/src/command/client/wrapped.rs @@ -2,11 +2,13 @@ use crossterm::style::{ResetColor, SetAttribute}; use eyre::Result; use std::collections::{HashMap, HashSet}; use time::{Date, Duration, Month, OffsetDateTime, Time}; +use turtle_daemon::aclient::history::History; +use turtle_daemon::api::client::{HistoryClient, Range}; -use crate::atuin_client::database::ClientSqlite; use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{Stats, compute}; +use crate::command::current_session; #[derive(Debug)] struct WrappedStats { @@ -21,11 +23,7 @@ struct WrappedStats { impl WrappedStats { #[expect(clippy::too_many_lines, clippy::cast_precision_loss)] - fn new( - settings: &Settings, - stats: &Stats, - history: &[crate::atuin_client::history::History], - ) -> Self { + fn new(settings: &Settings, stats: &Stats, history: &[History]) -> Self { let nav_commands = stats .top .iter() @@ -272,7 +270,10 @@ fn print_fun_facts(wrapped_stats: &WrappedStats, stats: &Stats, year: i32) { println!(); } -pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Settings) -> Result<()> { +pub(crate) async fn run(year: Option<i32>, settings: &Settings) -> Result<()> { + let session = current_session()?; + let mut client = HistoryClient::new(settings.daemon.socket_path.clone()).await?; + let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); let month = now.month(); @@ -296,7 +297,7 @@ pub(crate) async fn run(year: Option<i32>, db: &ClientSqlite, settings: &Setting now.offset(), ); - let history = db.range(start, end).await?; + let history = client.history(session, Some(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 🪄" diff --git a/crates/client/src/command/mod.rs b/crates/client/src/command/mod.rs index 9a648254..2e51a1e2 100644 --- a/crates/client/src/command/mod.rs +++ b/crates/client/src/command/mod.rs @@ -15,10 +15,6 @@ 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, @@ -41,17 +37,12 @@ impl AtuinCmd { 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()); + println!("{}", turtle_common::utils::uuid_v7().as_simple()); Ok(()) } Self::GenCompletions(gen_completions) => gen_completions.run(), @@ -60,52 +51,6 @@ impl AtuinCmd { } #[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) @@ -113,29 +58,8 @@ fn is_truthy_env(name: &str) -> bool { .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); - } +pub(crate) fn current_session() -> Result<String> { + std::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.") + }) } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 2453c0e1..51480a7b 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -4,12 +4,6 @@ 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; @@ -22,10 +16,6 @@ mod command; pub(crate) mod atuin_client; pub(crate) mod atuin_history; -pub(crate) mod atuin_pty_proxy; - -mod print_error; -mod sync; const VERSION: &str = env!("CARGO_PKG_VERSION"); const SHA: &str = env!("GIT_HASH"); diff --git a/crates/client/src/print_error.rs b/crates/client/src/print_error.rs deleted file mode 100644 index 0a6303dd..00000000 --- a/crates/client/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/client/src/sync.rs b/crates/client/src/sync.rs deleted file mode 100644 index abe1a201..00000000 --- a/crates/client/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(()) -} |
