diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 00:54:30 +0200 |
| commit | 5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8 (patch) | |
| tree | c64baa8d5866c8e339eaf660dd3f94f30a3f7d8a /crates/atuin-client/src/settings.rs | |
| parent | chore: Somewhat simplify sync code (diff) | |
| download | atuin-5c39e7cf284a1f6e9a1657f2deb44e359fc47eb8.zip | |
chore: Move everything into one big crate
That helps remove duplicated code and rustc/cargo will now also show
dead code correctly.
Diffstat (limited to 'crates/atuin-client/src/settings.rs')
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 1855 |
1 files changed, 0 insertions, 1855 deletions
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs deleted file mode 100644 index 5fb65c17..00000000 --- a/crates/atuin-client/src/settings.rs +++ /dev/null @@ -1,1855 +0,0 @@ -use std::{collections::HashMap, fmt, io::prelude::*, path::PathBuf, str::FromStr, sync::OnceLock}; -use tokio::sync::OnceCell; - -use atuin_common::record::HostId; -use atuin_common::utils; -use clap::ValueEnum; -use config::{ - Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, -}; -use eyre::{Context, Error, Result, bail, eyre}; -use fs_err::{File, create_dir_all}; -use humantime::parse_duration; -use regex::RegexSet; -use serde::{Deserialize, Serialize}; -use serde_with::DeserializeFromStr; -use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description}; - -pub const HISTORY_PAGE_SIZE: i64 = 100; -static EXAMPLE_CONFIG: &str = include_str!("../config.toml"); - -static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); -static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new(); -static META_STORE: OnceCell<crate::meta::MetaStore> = OnceCell::const_new(); - -pub(crate) mod meta; -pub mod watcher; - -/// Default sync address for Atuin's hosted service -pub const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh"; - -#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)] -pub 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 fn as_str(&self) -> &'static str { - match self { - SearchMode::Prefix => "PREFIX", - SearchMode::FullText => "FULLTXT", - SearchMode::Fuzzy => "FUZZY", - SearchMode::Skim => "SKIM", - SearchMode::DaemonFuzzy => "DAEMON", - } - } - pub fn next(&self, settings: &Settings) -> Self { - match self { - SearchMode::Prefix => SearchMode::FullText, - // if the user is using skim, we go to skim - SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim, - // if the user is using daemon-fuzzy, we go to daemon-fuzzy - SearchMode::FullText if settings.search_mode == SearchMode::DaemonFuzzy => { - SearchMode::DaemonFuzzy - } - // otherwise fuzzy. - SearchMode::FullText => SearchMode::Fuzzy, - SearchMode::Fuzzy | SearchMode::Skim | SearchMode::DaemonFuzzy => SearchMode::Prefix, - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub 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 fn as_str(&self) -> &'static str { - match self { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - FilterMode::Workspace => "WORKSPACE", - FilterMode::SessionPreload => "SESSION+", - } - } -} - -#[derive(Clone, Debug, Deserialize, Copy, Serialize)] -pub enum ExitMode { - #[serde(rename = "return-original")] - ReturnOriginal, - - #[serde(rename = "return-query")] - ReturnQuery, -} - -// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged -// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim -#[derive(Clone, Debug, Deserialize, Copy, Serialize)] -pub enum Dialect { - #[serde(rename = "us")] - Us, - - #[serde(rename = "uk")] - Uk, -} - -impl From<Dialect> for interim::Dialect { - fn from(d: Dialect) -> interim::Dialect { - match d { - Dialect::Uk => interim::Dialect::Uk, - Dialect::Us => interim::Dialect::Us, - } - } -} - -/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats. -/// -/// Note that the parsing of this struct needs to be done before starting any -/// multithreaded runtime, otherwise it will fail on most Unix systems. -/// -/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426> -#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)] -pub struct Timezone(pub UtcOffset); -impl fmt::Display for Timezone { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} -/// format: <+|-><hour>[:<minute>[:<second>]] -static OFFSET_FMT: &[FormatItem<'_>] = format_description!( - "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]" -); -impl FromStr for Timezone { - type Err = Error; - - fn from_str(s: &str) -> Result<Self> { - // local timezone - if matches!(s.to_lowercase().as_str(), "l" | "local") { - // There have been some timezone issues, related to errors fetching it on some - // platforms - // Rather than fail to start, fallback to UTC. The user should still be able to specify - // their timezone manually in the config file. - let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); - return Ok(Self(offset)); - } - - if matches!(s.to_lowercase().as_str(), "0" | "utc") { - let offset = UtcOffset::UTC; - return Ok(Self(offset)); - } - - // offset from UTC - if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) { - return Ok(Self(offset)); - } - - // IDEA: Currently named timezones are not supported, because the well-known crate - // for this is `chrono_tz`, which is not really interoperable with the datetime crate - // that we currently use - `time`. If ever we migrate to using `chrono`, this would - // be a good feature to add. - - bail!(r#""{s}" is not a valid timezone spec"#) - } -} - -#[derive(Clone, Debug, Deserialize, Copy, Serialize)] -pub enum Style { - #[serde(rename = "auto")] - Auto, - - #[serde(rename = "full")] - Full, - - #[serde(rename = "compact")] - Compact, -} - -#[derive(Clone, Debug, Deserialize, Copy, Serialize)] -pub enum WordJumpMode { - #[serde(rename = "emacs")] - Emacs, - - #[serde(rename = "subl")] - Subl, -} - -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub enum KeymapMode { - #[serde(rename = "emacs")] - Emacs, - - #[serde(rename = "vim-normal")] - VimNormal, - - #[serde(rename = "vim-insert")] - VimInsert, - - #[serde(rename = "auto")] - Auto, -} - -impl KeymapMode { - pub fn as_str(&self) -> &'static str { - match self { - KeymapMode::Emacs => "EMACS", - KeymapMode::VimNormal => "VIMNORMAL", - KeymapMode::VimInsert => "VIMINSERT", - KeymapMode::Auto => "AUTO", - } - } -} - -// We want to translate the config to crossterm::cursor::SetCursorStyle, but -// the original type does not implement trait serde::Deserialize unfortunately. -// It seems impossible to implement Deserialize for external types when it is -// used in HashMap (https://stackoverflow.com/questions/67142663). We instead -// define an adapter type. -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub enum CursorStyle { - #[serde(rename = "default")] - DefaultUserShape, - - #[serde(rename = "blink-block")] - BlinkingBlock, - - #[serde(rename = "steady-block")] - SteadyBlock, - - #[serde(rename = "blink-underline")] - BlinkingUnderScore, - - #[serde(rename = "steady-underline")] - SteadyUnderScore, - - #[serde(rename = "blink-bar")] - BlinkingBar, - - #[serde(rename = "steady-bar")] - SteadyBar, -} - -impl CursorStyle { - pub fn as_str(&self) -> &'static str { - match self { - CursorStyle::DefaultUserShape => "DEFAULT", - CursorStyle::BlinkingBlock => "BLINKBLOCK", - CursorStyle::SteadyBlock => "STEADYBLOCK", - CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE", - CursorStyle::SteadyUnderScore => "STEADYUNDERLINE", - CursorStyle::BlinkingBar => "BLINKBAR", - CursorStyle::SteadyBar => "STEADYBAR", - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Stats { - #[serde(default = "Stats::common_prefix_default")] - pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off - #[serde(default = "Stats::common_subcommands_default")] - pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for - #[serde(default = "Stats::ignored_commands_default")] - pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats -} - -impl Stats { - fn common_prefix_default() -> Vec<String> { - vec!["sudo", "doas"].into_iter().map(String::from).collect() - } - - fn common_subcommands_default() -> Vec<String> { - vec![ - "apt", - "cargo", - "composer", - "dnf", - "docker", - "dotnet", - "git", - "go", - "ip", - "jj", - "kubectl", - "nix", - "nmcli", - "npm", - "pecl", - "pnpm", - "podman", - "port", - "systemctl", - "tmux", - "yarn", - ] - .into_iter() - .map(String::from) - .collect() - } - - fn ignored_commands_default() -> Vec<String> { - vec![] - } -} - -impl Default for Stats { - fn default() -> Self { - Self { - common_prefix: Self::common_prefix_default(), - common_subcommands: Self::common_subcommands_default(), - ignored_commands: Self::ignored_commands_default(), - } - } -} - -/// Sync protocol type for authentication. -/// -/// This setting is primarily for development/testing. When not explicitly set, -/// the protocol is inferred from the sync_address: -/// - Default sync address (api.atuin.sh) → Hub protocol -/// - Custom sync address → Legacy protocol -/// -/// Set explicitly to "hub" to use Hub authentication with a custom sync_address -/// (useful for local development against a Hub instance). -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum SyncProtocol { - /// Use legacy CLI authentication (Token from CLI register/login) - #[default] - Legacy, -} - -/// Resolved authentication state for sync operations. -/// -/// Determined at runtime by examining which tokens are available and what -/// server the client is configured to talk to. Operations use this to pick -/// the right auth header and endpoint style. -#[cfg(feature = "sync")] -#[derive(Debug, Clone)] -pub enum SyncAuth { - /// Self-hosted Rust server. Uses `Authorization: Token <session>` and - /// legacy endpoints. - Legacy { token: String }, - - /// Not authenticated at all. Contains an actionable user-facing message. - NotLoggedIn { reason: String }, -} - -#[cfg(feature = "sync")] -impl SyncAuth { - /// Convert into the auth token type used by the API client. - /// - /// Returns an error with an actionable message for `NotLoggedIn`. - pub fn into_auth_token(self) -> Result<crate::api_client::AuthToken> { - use crate::api_client::AuthToken; - match self { - SyncAuth::Legacy { token } => Ok(AuthToken::Token(token)), - SyncAuth::NotLoggedIn { reason } => Err(eyre!(reason)), - } - } -} - -#[derive(Clone, Debug, Deserialize, Default, Serialize)] -pub struct Keys { - pub scroll_exits: bool, - pub exit_past_line_start: bool, - pub accept_past_line_end: bool, - pub accept_past_line_start: bool, - pub accept_with_backspace: bool, - pub prefix: String, -} - -impl Keys { - /// The standard default values for all `[keys]` options. - /// These match the config defaults set in `builder_with_data_dir()`. - pub fn standard_defaults() -> Self { - Keys { - scroll_exits: true, - exit_past_line_start: true, - accept_past_line_end: true, - accept_past_line_start: false, - accept_with_backspace: false, - prefix: "a".to_string(), - } - } - - /// Returns true if any value differs from the standard defaults. - pub fn has_non_default_values(&self) -> bool { - let d = Self::standard_defaults(); - self.scroll_exits != d.scroll_exits - || self.exit_past_line_start != d.exit_past_line_start - || self.accept_past_line_end != d.accept_past_line_end - || self.accept_past_line_start != d.accept_past_line_start - || self.accept_with_backspace != d.accept_with_backspace - || self.prefix != d.prefix - } -} - -/// A single rule within a conditional keybinding config. -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct KeyRuleConfig { - /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results"). - /// If absent, the rule always matches. - #[serde(default)] - pub when: Option<String>, - /// The action to perform (e.g. "exit", "cursor-left", "accept"). - pub action: String, -} - -/// A keybinding config value: either a simple action string or an ordered list of conditional rules. -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(untagged)] -pub enum KeyBindingConfig { - /// Simple unconditional binding: `"ctrl-c" = "return-original"` - Simple(String), - /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]` - Rules(Vec<KeyRuleConfig>), -} - -/// User-facing keymap configuration. Each mode maps key strings to bindings. -/// Keys present here override the defaults for that key; unmentioned keys keep defaults. -#[derive(Clone, Debug, Deserialize, Serialize, Default)] -pub struct KeymapConfig { - #[serde(default)] - pub emacs: HashMap<String, KeyBindingConfig>, - #[serde(default, rename = "vim-normal")] - pub vim_normal: HashMap<String, KeyBindingConfig>, - #[serde(default, rename = "vim-insert")] - pub vim_insert: HashMap<String, KeyBindingConfig>, - #[serde(default)] - pub inspector: HashMap<String, KeyBindingConfig>, - #[serde(default)] - pub prefix: HashMap<String, KeyBindingConfig>, -} - -impl KeymapConfig { - /// Returns true if no keybinding overrides are configured in any mode. - pub fn is_empty(&self) -> bool { - self.emacs.is_empty() - && self.vim_normal.is_empty() - && self.vim_insert.is_empty() - && self.inspector.is_empty() - && self.prefix.is_empty() - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Preview { - pub strategy: PreviewStrategy, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Theme { - /// Name of desired theme ("default" for base) - pub name: String, - - /// Whether any available additional theme debug should be shown - pub debug: Option<bool>, - - /// How many levels of parenthood will be traversed if needed - pub max_depth: Option<u8>, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Daemon { - /// Use the daemon to sync - /// If enabled, history hooks are routed through the daemon. - #[serde(alias = "enable")] - pub enabled: bool, - - /// Automatically start and manage a local daemon when needed. - pub autostart: bool, - - /// The daemon will handle sync on an interval. How often to sync, in seconds. - pub sync_frequency: u64, - - /// The path to the unix socket used by the daemon - pub socket_path: String, - - /// Path to the daemon pidfile used for process coordination. - pub pidfile_path: String, - - /// Use a socket passed via systemd's socket activation protocol, instead of the path - pub systemd_socket: bool, - - /// The port that should be used for TCP on non unix systems - pub tcp_port: u64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Search { - /// The list of enabled filter modes, in order of priority. - pub 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 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 frequency_score_multiplier: f64, - - /// The overall frecency score multiplier for the search index (default: 1.0). - /// Applied after combining recency and frequency scores. - pub frecency_score_multiplier: f64, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Tmux { - /// Enable using atuin with tmux popup (tmux >= 3.2) - pub enabled: bool, - - /// Width of the tmux popup (percentage) - pub width: String, - - /// Height of the tmux popup (percentage) - pub height: String, -} - -/// Log level for file logging. Maps to tracing's LevelFilter. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum LogLevel { - Trace, - Debug, - #[default] - Info, - Warn, - Error, -} - -impl LogLevel { - /// Convert to a tracing directive string for use with EnvFilter. - pub fn as_directive(&self) -> &'static str { - match self { - LogLevel::Trace => "trace", - LogLevel::Debug => "debug", - LogLevel::Info => "info", - LogLevel::Warn => "warn", - LogLevel::Error => "error", - } - } -} - -/// Configuration for a specific log type (search or daemon). -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct LogConfig { - /// Log file name (relative to dir) or absolute path. - pub file: String, - - /// Override global enabled setting for this log type. - pub enabled: Option<bool>, - - /// Override global level setting for this log type. - pub level: Option<LogLevel>, - - /// Override global retention days setting for this log type. - pub retention: Option<u64>, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Logs { - /// Enable file logging globally. Defaults to true. - #[serde(default = "Logs::default_enabled")] - pub enabled: bool, - - /// Directory for log files. Defaults to ~/.atuin/logs - pub dir: String, - - /// Default log level for file logging. Defaults to "info". - /// Note: ATUIN_LOG environment variable overrides this. - #[serde(default)] - pub level: LogLevel, - - /// Default retention days for log files. Defaults to 4. - #[serde(default = "Logs::default_retention")] - pub retention: u64, - - /// Search log settings - #[serde(default)] - pub search: LogConfig, - - /// Daemon log settings - #[serde(default)] - pub daemon: LogConfig, - - /// AI log settings - #[serde(default)] - pub ai: LogConfig, -} - -#[derive(Default, Clone, Debug, Deserialize, Serialize)] -pub struct Ai { - /// Whether or not the AI features are enabled. - pub enabled: Option<bool>, - - /// The address of the Atuin AI endpoint. Used for AI features like command generation. - /// Only necessary for custom AI endpoints. - pub endpoint: Option<String>, - - /// The API token for the Atuin AI endpoint. Used for AI features like command generation. - /// Only necessary for custom AI endpoints. - pub api_token: Option<String>, - - /// Path to the AI sessions database. - pub db_path: String, - - /// The maximum time in minutes that an AI session can be automatically resumed. - pub session_continue_minutes: i64, - - /// Deprecated: use opening.send_cwd instead. Kept for backwards compatibility. - #[serde(default)] - pub send_cwd: Option<bool>, - - /// Configuration for what context is sent in the opening AI request. - #[serde(default)] - pub opening: AiOpening, - - /// Tool capability flags. - #[serde(default)] - pub capabilities: AiCapabilities, -} - -#[derive(Default, Clone, Debug, Deserialize, Serialize)] -pub struct AiCapabilities { - /// Whether the AI can request to search Atuin history. `None` = unset (defaults to enabled, and the ai will ask for permission). - pub enable_history_search: Option<bool>, - /// Whether the AI can request to view the stored output, if any, for Atuin history entries. `None` = unset (defaults to enabled, and the ai will ask for permission). - pub enable_history_output: Option<bool>, - /// Whether the AI can request to read and write files. `None` = unset (defaults to enabled, and the ai will ask for permission). - pub enable_file_tools: Option<bool>, - /// Whether the AI can request to execute bash commands. `None` = unset (defaults to enabled, and the ai will ask for permission). - pub enable_command_execution: Option<bool>, -} - -#[derive(Default, Clone, Debug, Deserialize, Serialize)] -pub struct AiOpening { - /// Whether or not to send the current working directory to the AI endpoint. - pub send_cwd: Option<bool>, - - /// Whether or not to send the last command as context in the opening AI request. - pub send_last_command: Option<bool>, -} - -impl Default for Preview { - fn default() -> Self { - Self { - strategy: PreviewStrategy::Auto, - } - } -} - -impl Default for Theme { - fn default() -> Self { - Self { - name: "".to_string(), - debug: None::<bool>, - max_depth: Some(10), - } - } -} - -impl Default for Daemon { - fn default() -> Self { - Self { - enabled: false, - autostart: false, - sync_frequency: 300, - socket_path: "".to_string(), - pidfile_path: "".to_string(), - systemd_socket: false, - tcp_port: 8889, - } - } -} - -impl Default for Logs { - fn default() -> Self { - Self { - enabled: true, - dir: "".to_string(), - 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() - }, - ai: LogConfig { - file: "ai.log".to_string(), - ..Default::default() - }, - } - } -} - -impl Logs { - fn default_enabled() -> bool { - true - } - - fn default_retention() -> u64 { - 4 - } - - /// Returns whether search logging is enabled. - /// Uses search-specific setting if set, otherwise falls back to global. - pub 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 fn daemon_enabled(&self) -> bool { - self.daemon.enabled.unwrap_or(self.enabled) - } - - /// Returns whether AI logging is enabled. - /// Uses AI-specific setting if set, otherwise falls back to global. - pub fn ai_enabled(&self) -> bool { - self.ai.enabled.unwrap_or(self.enabled) - } - - /// Returns the log level for search logging. - /// Uses search-specific setting if set, otherwise falls back to global. - pub 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 fn daemon_level(&self) -> LogLevel { - self.daemon.level.unwrap_or(self.level) - } - - /// Returns the log level for AI logging. - /// Uses AI-specific setting if set, otherwise falls back to global. - pub fn ai_level(&self) -> LogLevel { - self.ai.level.unwrap_or(self.level) - } - - /// Returns the retention days for search logging. - /// Uses search-specific setting if set, otherwise falls back to global. - pub 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 fn daemon_retention(&self) -> u64 { - self.daemon.retention.unwrap_or(self.retention) - } - - /// Returns the retention days for AI logging. - /// Uses AI-specific setting if set, otherwise falls back to global. - pub fn ai_retention(&self) -> u64 { - self.ai.retention.unwrap_or(self.retention) - } - - /// Returns the full path for the search log file. - pub fn search_path(&self) -> PathBuf { - let path = PathBuf::from(&self.search.file); - PathBuf::from(&self.dir).join(path) - } - - /// Returns the full path for the daemon log file. - pub fn daemon_path(&self) -> PathBuf { - let path = PathBuf::from(&self.daemon.file); - PathBuf::from(&self.dir).join(path) - } - - /// Returns the full path for the AI log file. - pub fn ai_path(&self) -> PathBuf { - let path = PathBuf::from(&self.ai.file); - PathBuf::from(&self.dir).join(path) - } -} - -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, - } - } -} - -impl Default for Tmux { - fn default() -> Self { - Self { - enabled: false, - width: "80%".to_string(), - height: "60%".to_string(), - } - } -} - -// The preview height strategy also takes max_preview_height into account. -#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] -pub enum PreviewStrategy { - // Preview height is calculated for the length of the selected command. - #[serde(rename = "auto")] - Auto, - - // Preview height is calculated for the length of the longest command stored in the history. - #[serde(rename = "static")] - Static, - - // max_preview_height is used as fixed height. - #[serde(rename = "fixed")] - Fixed, -} - -/// Column types available for the interactive search UI. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub 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 fn default_width(&self) -> u16 { - match self { - UiColumnType::Duration => 5, // "814ms" - UiColumnType::Time => 9, // "459ms ago" - UiColumnType::Datetime => 16, // "2025-01-22 14:35" - UiColumnType::Directory => 20, - UiColumnType::Host => 15, - UiColumnType::User => 10, - UiColumnType::Exit => { - if cfg!(windows) { - 11 // 32-bit integer on Windows: "-1978335212" - } else { - 3 // Usually a byte on Unix - } - } - UiColumnType::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 struct UiColumn { - pub column_type: UiColumnType, - pub width: u16, - /// If true, this column expands to fill remaining space. Only one column should expand. - pub expand: bool, -} - -impl UiColumn { - pub fn new(column_type: UiColumnType) -> Self { - Self { - width: column_type.default_width(), - expand: column_type == UiColumnType::Command, - column_type, - } - } - - pub fn with_width(column_type: UiColumnType, width: u16) -> Self { - Self { - column_type, - width, - expand: column_type == UiColumnType::Command, - } - } -} - -// Custom deserialize to handle both string and object formats: -// "duration" or { type = "duration", width = 8, expand = true } -impl<'de> serde::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 std::fmt::Formatter) -> std::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 = - serde::Deserialize::deserialize(serde::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 _: serde::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 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 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 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(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct Settings { - pub data_dir: Option<String>, - pub dialect: Dialect, - pub timezone: Timezone, - pub style: Style, - pub auto_sync: bool, - pub update_check: bool, - - /// The sync address for atuin. - pub sync_address: String, - - #[serde(default)] - pub sync_protocol: SyncProtocol, - - pub sync_frequency: String, - pub db_path: String, - pub record_store_path: String, - pub key_path: String, - pub search_mode: SearchMode, - pub filter_mode: Option<FilterMode>, - pub filter_mode_shell_up_key_binding: Option<FilterMode>, - pub search_mode_shell_up_key_binding: Option<SearchMode>, - pub shell_up_key_binding: bool, - pub inline_height: u16, - pub inline_height_shell_up_key_binding: Option<u16>, - pub invert: bool, - pub show_preview: bool, - pub max_preview_height: u16, - pub show_help: bool, - pub show_tabs: bool, - pub show_numeric_shortcuts: bool, - pub auto_hide_height: u16, - pub exit_mode: ExitMode, - pub keymap_mode: KeymapMode, - pub keymap_mode_shell: KeymapMode, - pub keymap_cursor: HashMap<String, CursorStyle>, - pub word_jump_mode: WordJumpMode, - pub word_chars: String, - pub scroll_context_lines: usize, - pub history_format: String, - pub strip_trailing_whitespace: bool, - pub prefers_reduced_motion: bool, - pub store_failed: bool, - pub no_mouse: bool, - - #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] - pub history_filter: RegexSet, - - #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)] - pub cwd_filter: RegexSet, - - pub secrets_filter: bool, - pub workspaces: bool, - pub ctrl_n_shortcuts: bool, - - pub network_connect_timeout: u64, - pub network_timeout: u64, - pub local_timeout: f64, - pub enter_accept: bool, - pub smart_sort: bool, - pub command_chaining: bool, - - #[serde(default)] - pub stats: Stats, - - #[serde(default)] - pub keys: Keys, - - #[serde(default)] - pub keymap: KeymapConfig, - - #[serde(default)] - pub preview: Preview, - - #[serde(default)] - pub daemon: Daemon, - - #[serde(default)] - pub search: Search, - - #[serde(default)] - pub theme: Theme, - - #[serde(default)] - pub ui: Ui, - - #[serde(default)] - pub tmux: Tmux, - - #[serde(default)] - pub logs: Logs, - - #[serde(default)] - pub meta: meta::Settings, -} - -impl Settings { - pub fn utc() -> Self { - Self::builder() - .expect("Could not build default") - .set_override("timezone", "0") - .expect("failed to override timezone with UTC") - .build() - .expect("Could not build config") - .try_deserialize() - .expect("Could not deserialize config") - } - - pub(crate) fn effective_data_dir() -> PathBuf { - DATA_DIR - .get() - .cloned() - .unwrap_or_else(atuin_common::utils::data_dir) - } - - // -- Meta store: lazily initialized on first access -- - - pub async fn meta_store() -> Result<&'static crate::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::meta::MetaStore::new(db_path, *timeout).await - }) - .await - } - - pub async fn host_id() -> Result<HostId> { - Self::meta_store().await?.host_id().await - } - - pub async fn last_sync() -> Result<OffsetDateTime> { - Self::meta_store().await?.last_sync().await - } - - pub async fn save_sync_time() -> Result<()> { - Self::meta_store().await?.save_sync_time().await - } - - pub async fn last_version_check() -> Result<OffsetDateTime> { - Self::meta_store().await?.last_version_check().await - } - - pub async fn save_version_check_time() -> Result<()> { - Self::meta_store().await?.save_version_check_time().await - } - - pub async fn should_sync(&self) -> Result<bool> { - if !self.auto_sync || !Self::meta_store().await?.logged_in().await? { - return Ok(false); - } - - if self.sync_frequency == "0" { - return Ok(true); - } - - match parse_duration(self.sync_frequency.as_str()) { - Ok(d) => { - let d = time::Duration::try_from(d)?; - Ok(OffsetDateTime::now_utc() - Settings::last_sync().await? >= d) - } - Err(e) => Err(eyre!("failed to check sync: {}", e)), - } - } - - pub async fn logged_in(&self) -> Result<bool> { - Self::meta_store().await?.logged_in().await - } - - pub async fn session_token(&self) -> Result<String> { - match Self::meta_store().await?.session_token().await? { - Some(token) => Ok(token), - None => Err(eyre!("Tried to load session; not logged in")), - } - } - - /// Examines the configured sync target and available tokens to determine - /// the correct auth strategy. Also performs cleanup of mis-stored tokens - /// (e.g. a CLI token incorrectly saved in the Hub session slot). - #[cfg(feature = "sync")] - pub async fn resolve_sync_auth(&self) -> SyncAuth { - let meta = match Self::meta_store().await { - Ok(m) => m, - Err(e) => { - return SyncAuth::NotLoggedIn { - reason: format!("Failed to open meta store: {e}"), - }; - } - }; - - // Self-hosted / legacy server - match meta.session_token().await { - Ok(Some(token)) => SyncAuth::Legacy { token }, - _ => SyncAuth::NotLoggedIn { - reason: "Not logged in. Run 'atuin login' to authenticate \ - with your sync server." - .into(), - }, - } - } - - /// Returns the appropriate auth token for sync operations. - /// - /// Delegates to [`resolve_sync_auth`] and converts the result to an - /// `AuthToken`. Callers that need to distinguish between auth states - /// (e.g. to show different UI) should call `resolve_sync_auth` directly. - #[cfg(feature = "sync")] - pub async fn sync_auth_token(&self) -> Result<crate::api_client::AuthToken> { - self.resolve_sync_auth().await.into_auth_token() - } - - pub 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 fn builder() -> Result<ConfigBuilder<DefaultState>> { - Self::builder_with_data_dir(&atuin_common::utils::data_dir()) - } - - fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> { - let db_path = data_dir.join("history.db"); - let record_store_path = data_dir.join("records.db"); - let kv_path = data_dir.join("kv.db"); - let scripts_path = data_dir.join("scripts.db"); - let ai_sessions_path = data_dir.join("ai_sessions.db"); - let socket_path = atuin_common::utils::runtime_dir().join("atuin.sock"); - let pidfile_path = data_dir.join("atuin-daemon.pid"); - let logs_dir = atuin_common::utils::logs_dir(); - - let key_path = data_dir.join("key"); - let meta_path = data_dir.join("meta.db"); - - Ok(Config::builder() - .set_default("history_format", "{time}\t{command}\t{duration}")? - .set_default("db_path", db_path.to_str())? - .set_default("record_store_path", record_store_path.to_str())? - .set_default("key_path", key_path.to_str())? - .set_default("dialect", "us")? - .set_default("timezone", "local")? - .set_default("auto_sync", true)? - .set_default("update_check", cfg!(feature = "check-update"))? - .set_default("sync_address", "https://api.atuin.sh")? - .set_default("sync_frequency", "5m")? - .set_default("search_mode", "fuzzy")? - .set_default("filter_mode", None::<String>)? - .set_default("style", "compact")? - .set_default("inline_height", 40)? - .set_default("show_preview", true)? - .set_default("preview.strategy", "auto")? - .set_default("max_preview_height", 4)? - .set_default("show_help", true)? - .set_default("show_tabs", true)? - .set_default("show_numeric_shortcuts", true)? - .set_default("auto_hide_height", 8)? - .set_default("invert", false)? - .set_default("exit_mode", "return-original")? - .set_default("word_jump_mode", "emacs")? - .set_default( - "word_chars", - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - )? - .set_default("scroll_context_lines", 1)? - .set_default("shell_up_key_binding", false)? - .set_default("workspaces", false)? - .set_default("ctrl_n_shortcuts", false)? - .set_default("secrets_filter", true)? - .set_default("strip_trailing_whitespace", true)? - .set_default("network_connect_timeout", 5)? - .set_default("network_timeout", 30)? - .set_default("local_timeout", 2.0)? - // enter_accept defaults to false here, but true in the default config file. The dissonance is - // intentional! - // Existing users will get the default "False", so we don't mess with any potential - // muscle memory. - // New users will get the new default, that is more similar to what they are used to. - .set_default("enter_accept", false)? - .set_default("keys.scroll_exits", true)? - .set_default("keys.accept_past_line_end", true)? - .set_default("keys.exit_past_line_start", true)? - .set_default("keys.accept_past_line_start", false)? - .set_default("keys.accept_with_backspace", false)? - .set_default("keys.prefix", "a")? - .set_default("keymap_mode", "emacs")? - .set_default("keymap_mode_shell", "auto")? - .set_default("keymap_cursor", HashMap::<String, String>::new())? - .set_default("smart_sort", false)? - .set_default("command_chaining", false)? - .set_default("store_failed", true)? - .set_default("daemon.sync_frequency", 300)? - .set_default("daemon.enabled", false)? - .set_default("daemon.autostart", false)? - .set_default("daemon.socket_path", socket_path.to_str())? - .set_default("daemon.pidfile_path", pidfile_path.to_str())? - .set_default("daemon.systemd_socket", false)? - .set_default("daemon.tcp_port", 8889)? - .set_default("logs.enabled", true)? - .set_default("logs.dir", logs_dir.to_str())? - .set_default("logs.level", "info")? - .set_default("logs.search.file", "search.log")? - .set_default("logs.daemon.file", "daemon.log")? - .set_default("logs.ai.file", "ai.log")? - .set_default("kv.db_path", kv_path.to_str())? - .set_default("scripts.db_path", scripts_path.to_str())? - .set_default("search.recency_score_multiplier", 1.0)? - .set_default("search.frequency_score_multiplier", 1.0)? - .set_default("search.frecency_score_multiplier", 1.0)? - .set_default("meta.db_path", meta_path.to_str())? - .set_default("ai.db_path", ai_sessions_path.to_str())? - .set_default("ai.session_continue_minutes", 60)? - .set_default("ai.send_cwd", false)? - .set_default("ai.opening.send_cwd", false)? - .set_default("ai.opening.send_last_command", false)? - .set_default( - "search.filters", - vec![ - "global", - "host", - "session", - "workspace", - "directory", - "session-preload", - ], - )? - .set_default("theme.name", "default")? - .set_default("theme.debug", None::<bool>)? - .set_default("tmux.enabled", false)? - .set_default("tmux.width", "80%")? - .set_default("tmux.height", "60%")? - .set_default( - "prefers_reduced_motion", - std::env::var("NO_MOTION") - .ok() - .map(|_| config::Value::new(None, config::ValueKind::Boolean(true))) - .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))), - )? - .set_default("no_mouse", false)? - .add_source( - Environment::with_prefix("atuin") - .prefix_separator("_") - .separator("__"), - )) - } - - pub fn get_config_path() -> Result<PathBuf> { - let config_dir = atuin_common::utils::config_dir(); - - create_dir_all(&config_dir) - .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?; - - let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") { - PathBuf::from(p) - } else { - let mut config_file = PathBuf::new(); - config_file.push(config_dir); - config_file - }; - - config_file.push("config.toml"); - - Ok(config_file) - } - - /// Build a merged `Config` from defaults, config file, and environment. - /// - /// This resolves `data_dir`, initializes the data directory on disk, - /// and layers defaults → config file → env overrides. Both `new()` and - /// `get_config_value()` use this so the resolution logic lives in one place. - fn build_config() -> Result<Config> { - let config_file = Self::get_config_path()?; - - // extract data_dir first so we can use it as the base for other path defaults - let effective_data_dir = if config_file.exists() { - #[derive(Deserialize, Default)] - struct DataDirOnly { - data_dir: Option<String>, - } - - let config_file_str = config_file - .to_str() - .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; - - let partial_config = Config::builder() - .add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) - .add_source( - Environment::with_prefix("atuin") - .prefix_separator("_") - .separator("__"), - ) - .build() - .ok(); - - let custom_data_dir = partial_config - .and_then(|c| c.try_deserialize::<DataDirOnly>().ok()) - .and_then(|d| d.data_dir); - - match custom_data_dir { - Some(dir) => { - let expanded = shellexpand::full(&dir) - .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?; - PathBuf::from(expanded.as_ref()) - } - None => atuin_common::utils::data_dir(), - } - } else { - atuin_common::utils::data_dir() - }; - - DATA_DIR.set(effective_data_dir.clone()).ok(); - - create_dir_all(&effective_data_dir) - .wrap_err_with(|| format!("could not create dir {effective_data_dir:?}"))?; - - let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?; - - config_builder = if config_file.exists() { - let config_file_str = config_file - .to_str() - .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?; - config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml)) - } else { - let mut file = File::create(config_file).wrap_err("could not create config file")?; - file.write_all(EXAMPLE_CONFIG.as_bytes()) - .wrap_err("could not write default config file")?; - - config_builder - }; - - // all paths should be expanded - let built = config_builder.build_cloned()?; - config_builder = [ - "db_path", - "record_store_path", - "key_path", - "daemon.socket_path", - "daemon.pidfile_path", - "logs.dir", - "logs.search.file", - "logs.daemon.file", - ] - .iter() - .map(|key| (key, built.get_string(key).unwrap_or_default())) - .filter_map(|(key, value)| match Self::expand_path(value) { - Ok(expanded) => Some((key, expanded)), - Err(e) => { - log::warn!("failed to expand path for {key}: {e}"); - None - } - }) - .fold(config_builder, |builder, (key, value)| { - builder - .set_override(key, value) - .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}")) - }); - - config_builder.build().map_err(Into::into) - } - - /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`). - /// - /// Returns the effective value after merging defaults, config file, and - /// environment — without the side-effects of full `Settings` construction - /// (meta store init, path expansion, etc.). - pub fn get_config_value(key: &str) -> Result<String> { - let config = Self::build_config()?; - let value: config::Value = config - .get(key) - .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?; - Ok(Self::format_resolved_value(&value, key)) - } - - fn format_resolved_value(value: &config::Value, prefix: &str) -> String { - use config::ValueKind; - - match &value.kind { - ValueKind::Nil => String::new(), - ValueKind::Boolean(b) => b.to_string(), - ValueKind::I64(i) => i.to_string(), - ValueKind::I128(i) => i.to_string(), - ValueKind::U64(u) => u.to_string(), - ValueKind::U128(u) => u.to_string(), - ValueKind::Float(f) => f.to_string(), - ValueKind::String(s) => s.clone(), - ValueKind::Array(arr) => { - let items: Vec<String> = arr - .iter() - .map(|v| Self::format_resolved_value(v, "")) - .collect(); - format!("[{}]", items.join(", ")) - } - ValueKind::Table(map) => { - let mut lines = Vec::new(); - let mut keys: Vec<_> = map.keys().collect(); - keys.sort(); - - for k in keys { - let v = &map[k]; - let full_key = if prefix.is_empty() { - k.clone() - } else { - format!("{}.{}", prefix, k) - }; - - match &v.kind { - ValueKind::Table(_) => { - lines.push(Self::format_resolved_value(v, &full_key)); - } - _ => { - lines.push(format!( - "{} = {}", - full_key, - Self::format_resolved_value(v, "") - )); - } - } - } - - lines.join("\n") - } - } - } - - pub fn new() -> Result<Self> { - let config = Self::build_config()?; - let settings: Settings = config - .try_deserialize() - .map_err(|e| eyre!("failed to deserialize: {}", e))?; - - // Validate UI settings - settings.ui.validate()?; - - // Register meta store config for lazy initialization on first access - META_CONFIG - .set((settings.meta.db_path.clone(), settings.local_timeout)) - .ok(); - - Ok(settings) - } - - fn expand_path(path: String) -> Result<String> { - shellexpand::full(&path) - .map(|p| p.to_string()) - .map_err(|e| eyre!("failed to expand path: {}", e)) - } - - pub fn example_config() -> &'static str { - EXAMPLE_CONFIG - } - - pub fn paths_ok(&self) -> bool { - let paths = [ - &self.db_path, - &self.record_store_path, - &self.key_path, - &self.meta.db_path, - ]; - paths.iter().all(|p| !utils::broken_symlink(p)) - } -} - -impl Default for Settings { - fn default() -> Self { - // if this panics something is very wrong, as the default config - // does not build or deserialize into the settings struct - Self::builder() - .expect("Could not build default") - .build() - .expect("Could not build config") - .try_deserialize() - .expect("Could not deserialize config") - } -} - -/// Initialize the meta store configuration for testing. -/// -/// This should only be used in tests. It allows tests to bypass the normal -/// Settings::new() flow while still being able to use Settings::host_id() -/// and other meta store dependent functions. -/// -/// # Safety -/// This function is not thread-safe with concurrent calls to Settings::new() -/// or other meta store initialization. Only call from tests. -#[doc(hidden)] -pub fn init_meta_config_for_testing(meta_db_path: impl Into<String>, local_timeout: f64) { - META_CONFIG.set((meta_db_path.into(), local_timeout)).ok(); -} - -#[cfg(test)] -pub(crate) fn test_local_timeout() -> f64 { - std::env::var("ATUIN_TEST_LOCAL_TIMEOUT") - .ok() - .and_then(|x| x.parse().ok()) - // this hardcoded value should be replaced by a simple way to get the - // default local_timeout of Settings if possible - .unwrap_or(2.0) -} - -#[cfg(test)] -mod tests { - use std::str::FromStr; - - use eyre::Result; - - use super::Timezone; - - #[test] - fn can_parse_offset_timezone_spec() -> Result<()> { - assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0)); - assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0)); - assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0)); - assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0)); - - // single digit hours are allowed - assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0)); - assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0)); - assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0)); - assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0)); - - // fully qualified form - assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0)); - assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0)); - - // these offsets don't really exist but are supported anyway - assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0)); - assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0)); - assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45)); - assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45)); - - // require a leading sign for clarity - assert!(Timezone::from_str("5").is_err()); - assert!(Timezone::from_str("10:30").is_err()); - - Ok(()) - } - - #[test] - fn can_choose_workspace_filters_when_in_git_context() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = true; - - assert_eq!( - settings.default_filter_mode(true), - super::FilterMode::Workspace, - ); - - Ok(()) - } - - #[test] - fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = true; - - assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,); - - Ok(()) - } - - #[test] - fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> { - let mut settings = super::Settings::default(); - settings.search.filters = vec![ - super::FilterMode::Workspace, - super::FilterMode::Host, - super::FilterMode::Directory, - super::FilterMode::Session, - super::FilterMode::Global, - ]; - settings.workspaces = false; - - assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,); - - Ok(()) - } - - #[test] - fn builder_with_data_dir_uses_custom_paths() -> Result<()> { - use std::path::PathBuf; - - let custom_dir = PathBuf::from("/custom/data/dir"); - let builder = super::Settings::builder_with_data_dir(&custom_dir)?; - let config = builder.build()?; - - let db_path: String = config.get("db_path")?; - let key_path: String = config.get("key_path")?; - let record_store_path: String = config.get("record_store_path")?; - let kv_db_path: String = config.get("kv.db_path")?; - let scripts_db_path: String = config.get("scripts.db_path")?; - let meta_db_path: String = config.get("meta.db_path")?; - let daemon_socket_path: String = config.get("daemon.socket_path")?; - let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?; - let daemon_autostart: bool = config.get("daemon.autostart")?; - - assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap()); - assert_eq!(key_path, custom_dir.join("key").to_str().unwrap()); - assert_eq!( - record_store_path, - custom_dir.join("records.db").to_str().unwrap() - ); - assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap()); - assert_eq!( - scripts_db_path, - custom_dir.join("scripts.db").to_str().unwrap() - ); - assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap()); - assert_eq!( - daemon_socket_path, - atuin_common::utils::runtime_dir() - .join("atuin.sock") - .to_str() - .unwrap() - ); - assert_eq!( - daemon_pidfile_path, - custom_dir.join("atuin-daemon.pid").to_str().unwrap() - ); - assert!(!daemon_autostart); - - Ok(()) - } - - #[test] - fn effective_data_dir_returns_default_when_not_set() { - let effective = super::Settings::effective_data_dir(); - let default = atuin_common::utils::data_dir(); - - assert!(effective.to_str().is_some()); - assert!(effective.ends_with("atuin") || effective == default); - } - - #[test] - fn keymap_config_deserializes_simple_binding() { - let json = r#"{"emacs": {"ctrl-c": "exit"}}"#; - let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.emacs.len(), 1); - match &config.emacs["ctrl-c"] { - super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"), - _ => panic!("expected Simple variant"), - } - } - - #[test] - fn keymap_config_deserializes_conditional_binding() { - let json = r#"{ - "emacs": { - "left": [ - {"when": "cursor-at-start", "action": "exit"}, - {"action": "cursor-left"} - ] - } - }"#; - let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); - match &config.emacs["left"] { - super::KeyBindingConfig::Rules(rules) => { - assert_eq!(rules.len(), 2); - assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start")); - assert_eq!(rules[0].action, "exit"); - assert!(rules[1].when.is_none()); - assert_eq!(rules[1].action, "cursor-left"); - } - _ => panic!("expected Rules variant"), - } - } - - #[test] - fn keymap_config_deserializes_vim_normal() { - let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#; - let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.vim_normal.len(), 2); - assert!(config.emacs.is_empty()); - } - - #[test] - fn keymap_config_is_empty_when_default() { - let config = super::KeymapConfig::default(); - assert!(config.is_empty()); - } - - #[test] - fn keymap_config_mixed_modes() { - let json = r#"{ - "emacs": {"ctrl-c": "exit"}, - "vim-normal": {"q": "exit"}, - "inspector": {"d": "delete"} - }"#; - let config: super::KeymapConfig = serde_json::from_str(json).unwrap(); - assert!(!config.is_empty()); - assert_eq!(config.emacs.len(), 1); - assert_eq!(config.vim_normal.len(), 1); - assert_eq!(config.inspector.len(), 1); - assert!(config.vim_insert.is_empty()); - assert!(config.prefix.is_empty()); - } -} |
