aboutsummaryrefslogtreecommitdiffstats
path: root/crates/turtle/src/atuin_client/settings.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/turtle/src/atuin_client/settings.rs')
-rw-r--r--crates/turtle/src/atuin_client/settings.rs1543
1 files changed, 0 insertions, 1543 deletions
diff --git a/crates/turtle/src/atuin_client/settings.rs b/crates/turtle/src/atuin_client/settings.rs
deleted file mode 100644
index 9e14c4c8..00000000
--- a/crates/turtle/src/atuin_client/settings.rs
+++ /dev/null
@@ -1,1543 +0,0 @@
-use crypto_secretbox::Key;
-use std::{
- collections::HashMap, fmt, fs::read_to_string, path::PathBuf, str::FromStr, sync::OnceLock,
-};
-use tokio::sync::OnceCell;
-use tracing::info;
-use uuid::Uuid;
-
-use crate::atuin_common::utils;
-use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId};
-use clap::ValueEnum;
-use config::{
- Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
-};
-use eyre::{Context, Error, Result, bail, eyre};
-use fs_err::create_dir_all;
-use regex::RegexSet;
-use serde::{Deserialize, Serialize};
-use serde_with::DeserializeFromStr;
-use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
-
-static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
-static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
-static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::const_new();
-
-pub(crate) mod meta;
-pub(crate) mod watcher;
-
-#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
-pub(crate) enum SearchMode {
- #[serde(rename = "prefix")]
- Prefix,
-
- #[serde(rename = "fulltext")]
- #[clap(aliases = &["fulltext"])]
- FullText,
-
- #[serde(rename = "fuzzy")]
- Fuzzy,
-
- #[serde(rename = "skim")]
- Skim,
-
- #[serde(rename = "daemon-fuzzy")]
- #[clap(aliases = &["daemon-fuzzy"])]
- DaemonFuzzy,
-}
-
-impl SearchMode {
- pub(crate) fn as_str(self) -> &'static str {
- match self {
- Self::Prefix => "PREFIX",
- Self::FullText => "FULLTXT",
- Self::Fuzzy => "FUZZY",
- Self::Skim => "SKIM",
- Self::DaemonFuzzy => "DAEMON",
- }
- }
- pub(crate) fn next(self, settings: &Settings) -> Self {
- match self {
- Self::Prefix => Self::FullText,
- // if the user is using skim, we go to skim
- Self::FullText if settings.search_mode == Self::Skim => Self::Skim,
- // if the user is using daemon-fuzzy, we go to daemon-fuzzy
- Self::FullText if settings.search_mode == Self::DaemonFuzzy => Self::DaemonFuzzy,
- // otherwise fuzzy.
- Self::FullText => Self::Fuzzy,
- Self::Fuzzy | Self::Skim | Self::DaemonFuzzy => Self::Prefix,
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
-pub(crate) enum FilterMode {
- #[serde(rename = "global")]
- Global = 0,
-
- #[serde(rename = "host")]
- Host = 1,
-
- #[serde(rename = "session")]
- Session = 2,
-
- #[serde(rename = "directory")]
- Directory = 3,
-
- #[serde(rename = "workspace")]
- Workspace = 4,
-
- #[serde(rename = "session-preload")]
- SessionPreload = 5,
-}
-
-impl FilterMode {
- pub(crate) fn as_str(self) -> &'static str {
- match self {
- Self::Global => "GLOBAL",
- Self::Host => "HOST",
- Self::Session => "SESSION",
- Self::Directory => "DIRECTORY",
- Self::Workspace => "WORKSPACE",
- Self::SessionPreload => "SESSION+",
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
-pub(crate) enum ExitMode {
- #[serde(rename = "return-original")]
- ReturnOriginal,
-
- #[serde(rename = "return-query")]
- ReturnQuery,
-}
-
-// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
-// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
-#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
-pub(crate) enum Dialect {
- #[serde(rename = "us")]
- Us,
-
- #[serde(rename = "uk")]
- Uk,
-}
-
-impl From<Dialect> for interim::Dialect {
- fn from(d: Dialect) -> Self {
- match d {
- Dialect::Uk => Self::Uk,
- Dialect::Us => Self::Us,
- }
- }
-}
-
-/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
-///
-/// Note that the parsing of this struct needs to be done before starting any
-/// multithreaded runtime, otherwise it will fail on most Unix systems.
-///
-/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>
-#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
-pub(crate) struct Timezone(pub(crate) UtcOffset);
-impl fmt::Display for Timezone {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- self.0.fmt(f)
- }
-}
-/// format: <+|-><hour>[:<minute>[:<second>]]
-static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
- "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
-);
-impl FromStr for Timezone {
- type Err = Error;
-
- fn from_str(s: &str) -> Result<Self> {
- // local timezone
- if matches!(s.to_lowercase().as_str(), "l" | "local") {
- // There have been some timezone issues, related to errors fetching it on some
- // platforms
- // Rather than fail to start, fallback to UTC. The user should still be able to specify
- // their timezone manually in the config file.
- let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
- return Ok(Self(offset));
- }
-
- if matches!(s.to_lowercase().as_str(), "0" | "utc") {
- let offset = UtcOffset::UTC;
- return Ok(Self(offset));
- }
-
- // offset from UTC
- if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
- return Ok(Self(offset));
- }
-
- // IDEA: Currently named timezones are not supported, because the well-known crate
- // for this is `chrono_tz`, which is not really interoperable with the datetime crate
- // that we currently use - `time`. If ever we migrate to using `chrono`, this would
- // be a good feature to add.
-
- bail!(r#""{s}" is not a valid timezone spec"#)
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
-pub(crate) enum Style {
- #[serde(rename = "auto")]
- Auto,
-
- #[serde(rename = "full")]
- Full,
-
- #[serde(rename = "compact")]
- Compact,
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
-pub(crate) enum WordJumpMode {
- #[serde(rename = "emacs")]
- Emacs,
-
- #[serde(rename = "subl")]
- Subl,
-}
-
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
-pub(crate) enum KeymapMode {
- #[serde(rename = "emacs")]
- Emacs,
-
- #[serde(rename = "vim-normal")]
- VimNormal,
-
- #[serde(rename = "vim-insert")]
- VimInsert,
-
- #[serde(rename = "auto")]
- Auto,
-}
-
-// We want to translate the config to crossterm::cursor::SetCursorStyle, but
-// the original type does not implement trait serde::Deserialize unfortunately.
-// It seems impossible to implement Deserialize for external types when it is
-// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
-// define an adapter type.
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
-pub(crate) enum CursorStyle {
- #[serde(rename = "default")]
- DefaultUserShape,
-
- #[serde(rename = "blink-block")]
- BlinkingBlock,
-
- #[serde(rename = "steady-block")]
- SteadyBlock,
-
- #[serde(rename = "blink-underline")]
- BlinkingUnderScore,
-
- #[serde(rename = "steady-underline")]
- SteadyUnderScore,
-
- #[serde(rename = "blink-bar")]
- BlinkingBar,
-
- #[serde(rename = "steady-bar")]
- SteadyBar,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Stats {
- #[serde(default = "Stats::common_prefix_default")]
- pub(crate) common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
- #[serde(default = "Stats::common_subcommands_default")]
- pub(crate) common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
- #[serde(default = "Stats::ignored_commands_default")]
- pub(crate) ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
-}
-
-impl Stats {
- fn common_prefix_default() -> Vec<String> {
- vec!["sudo", "doas"].into_iter().map(String::from).collect()
- }
-
- fn common_subcommands_default() -> Vec<String> {
- vec![
- "apt",
- "cargo",
- "composer",
- "dnf",
- "docker",
- "dotnet",
- "git",
- "go",
- "ip",
- "jj",
- "kubectl",
- "nix",
- "nmcli",
- "npm",
- "pecl",
- "pnpm",
- "podman",
- "port",
- "systemctl",
- "tmux",
- "yarn",
- ]
- .into_iter()
- .map(String::from)
- .collect()
- }
-
- fn ignored_commands_default() -> Vec<String> {
- vec![]
- }
-}
-
-impl Default for Stats {
- fn default() -> Self {
- Self {
- common_prefix: Self::common_prefix_default(),
- common_subcommands: Self::common_subcommands_default(),
- ignored_commands: Self::ignored_commands_default(),
- }
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Default, Serialize)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct Keys {
- pub(crate) scroll_exits: bool,
- pub(crate) exit_past_line_start: bool,
- pub(crate) accept_past_line_end: bool,
- pub(crate) accept_past_line_start: bool,
- pub(crate) accept_with_backspace: bool,
- pub(crate) prefix: String,
-}
-
-impl Keys {
- /// The standard default values for all `[keys]` options.
- /// These match the config defaults set in `builder_with_data_dir()`.
- pub(crate) fn standard_defaults() -> Self {
- Self {
- scroll_exits: true,
- exit_past_line_start: true,
- accept_past_line_end: true,
- accept_past_line_start: false,
- accept_with_backspace: false,
- prefix: "a".to_string(),
- }
- }
-}
-
-/// A single rule within a conditional keybinding config.
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct KeyRuleConfig {
- /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results").
- /// If absent, the rule always matches.
- #[serde(default)]
- pub(crate) when: Option<String>,
- /// The action to perform (e.g. "exit", "cursor-left", "accept").
- pub(crate) action: String,
-}
-
-/// A keybinding config value: either a simple action string or an ordered list of conditional rules.
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[serde(untagged)]
-pub(crate) enum KeyBindingConfig {
- /// Simple unconditional binding: `"ctrl-c" = "return-original"`
- Simple(String),
- /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]`
- Rules(Vec<KeyRuleConfig>),
-}
-
-/// User-facing keymap configuration. Each mode maps key strings to bindings.
-/// Keys present here override the defaults for that key; unmentioned keys keep defaults.
-#[derive(Clone, Debug, Deserialize, Serialize, Default)]
-pub(crate) struct KeymapConfig {
- #[serde(default)]
- pub(crate) emacs: HashMap<String, KeyBindingConfig>,
- #[serde(default, rename = "vim-normal")]
- pub(crate) vim_normal: HashMap<String, KeyBindingConfig>,
- #[serde(default, rename = "vim-insert")]
- pub(crate) vim_insert: HashMap<String, KeyBindingConfig>,
- #[serde(default)]
- pub(crate) inspector: HashMap<String, KeyBindingConfig>,
- #[serde(default)]
- pub(crate) prefix: HashMap<String, KeyBindingConfig>,
-}
-
-impl KeymapConfig {
- /// Returns true if no keybinding overrides are configured in any mode.
- pub(crate) fn is_empty(&self) -> bool {
- self.emacs.is_empty()
- && self.vim_normal.is_empty()
- && self.vim_insert.is_empty()
- && self.inspector.is_empty()
- && self.prefix.is_empty()
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Preview {
- pub(crate) strategy: PreviewStrategy,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Daemon {
- /// The daemon will handle sync on an interval. How often to sync, in seconds.
- pub(crate) sync_frequency: u64,
-
- /// The path to the unix socket used by the daemon
- pub(crate) socket_path: String,
-
- /// Path to the daemon pidfile used for process coordination.
- pub(crate) pidfile_path: String,
-
- /// Use a socket passed via systemd's socket activation protocol, instead of the path
- pub(crate) systemd_socket: bool,
-
- /// The port that should be used for TCP on non unix systems
- pub(crate) tcp_port: u64,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Search {
- /// The list of enabled filter modes, in order of priority.
- pub(crate) filters: Vec<FilterMode>,
-
- /// The recency score multiplier for the search index (default: 1.0).
- /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
- pub(crate) recency_score_multiplier: f64,
-
- /// The frequency score multiplier for the search index (default: 1.0).
- /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
- pub(crate) frequency_score_multiplier: f64,
-
- /// The overall frecency score multiplier for the search index (default: 1.0).
- /// Applied after combining recency and frequency scores.
- pub(crate) frecency_score_multiplier: f64,
-}
-
-/// Log level for file logging. Maps to tracing's [`LevelFilter`].
-#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub(crate) enum LogLevel {
- Trace,
- Debug,
- #[default]
- Info,
- Warn,
- Error,
-}
-
-impl LogLevel {
- /// Convert to a tracing directive string for use with [`EnvFilter`].
- pub(crate) fn as_directive(self) -> &'static str {
- match self {
- Self::Trace => "trace",
- Self::Debug => "debug",
- Self::Info => "info",
- Self::Warn => "warn",
- Self::Error => "error",
- }
- }
-}
-
-/// Configuration for a specific log type (search or daemon).
-#[derive(Clone, Debug, Default, Deserialize, Serialize)]
-pub(crate) struct LogConfig {
- /// Log file name (relative to dir) or absolute path.
- pub(crate) file: String,
-
- /// Override global enabled setting for this log type.
- pub(crate) enabled: Option<bool>,
-
- /// Override global level setting for this log type.
- pub(crate) level: Option<LogLevel>,
-
- /// Override global retention days setting for this log type.
- pub(crate) retention: Option<u64>,
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Logs {
- /// Enable file logging globally. Defaults to true.
- #[serde(default = "Logs::default_enabled")]
- pub(crate) enabled: bool,
-
- /// Directory for log files. Defaults to ~/.atuin/logs
- pub(crate) dir: String,
-
- /// Default log level for file logging. Defaults to "info".
- /// Note: [`ATUIN_LOG`] environment variable overrides this.
- #[serde(default)]
- pub(crate) level: LogLevel,
-
- /// Default retention days for log files. Defaults to 4.
- #[serde(default = "Logs::default_retention")]
- pub(crate) retention: u64,
-
- /// Search log settings
- #[serde(default)]
- pub(crate) search: LogConfig,
-
- /// Daemon log settings
- #[serde(default)]
- pub(crate) daemon: LogConfig,
-}
-
-impl Default for Preview {
- fn default() -> Self {
- Self {
- strategy: PreviewStrategy::Auto,
- }
- }
-}
-
-impl Default for Daemon {
- fn default() -> Self {
- Self {
- sync_frequency: 300,
- socket_path: String::new(),
- pidfile_path: String::new(),
- systemd_socket: false,
- tcp_port: 8889,
- }
- }
-}
-
-impl Default for Logs {
- fn default() -> Self {
- Self {
- enabled: true,
- dir: String::new(),
- level: LogLevel::default(),
- retention: Self::default_retention(),
- search: LogConfig {
- file: "search.log".to_string(),
- ..Default::default()
- },
- daemon: LogConfig {
- file: "daemon.log".to_string(),
- ..Default::default()
- },
- }
- }
-}
-
-impl Logs {
- fn default_enabled() -> bool {
- true
- }
-
- fn default_retention() -> u64 {
- 4
- }
-
- /// Returns whether search logging is enabled.
- /// Uses search-specific setting if set, otherwise falls back to global.
- pub(crate) fn search_enabled(&self) -> bool {
- self.search.enabled.unwrap_or(self.enabled)
- }
-
- /// Returns whether daemon logging is enabled.
- /// Uses daemon-specific setting if set, otherwise falls back to global.
- pub(crate) fn daemon_enabled(&self) -> bool {
- self.daemon.enabled.unwrap_or(self.enabled)
- }
-
- /// Returns the log level for search logging.
- /// Uses search-specific setting if set, otherwise falls back to global.
- pub(crate) fn search_level(&self) -> LogLevel {
- self.search.level.unwrap_or(self.level)
- }
-
- /// Returns the log level for daemon logging.
- /// Uses daemon-specific setting if set, otherwise falls back to global.
- pub(crate) fn daemon_level(&self) -> LogLevel {
- self.daemon.level.unwrap_or(self.level)
- }
-
- /// Returns the retention days for search logging.
- /// Uses search-specific setting if set, otherwise falls back to global.
- pub(crate) fn search_retention(&self) -> u64 {
- self.search.retention.unwrap_or(self.retention)
- }
-
- /// Returns the retention days for daemon logging.
- /// Uses daemon-specific setting if set, otherwise falls back to global.
- pub(crate) fn daemon_retention(&self) -> u64 {
- self.daemon.retention.unwrap_or(self.retention)
- }
-}
-
-impl Default for Search {
- fn default() -> Self {
- Self {
- filters: vec![
- FilterMode::Global,
- FilterMode::Host,
- FilterMode::Session,
- FilterMode::SessionPreload,
- FilterMode::Workspace,
- FilterMode::Directory,
- ],
-
- recency_score_multiplier: 1.0,
- frequency_score_multiplier: 1.0,
- frecency_score_multiplier: 1.0,
- }
- }
-}
-
-// The preview height strategy also takes max_preview_height into account.
-#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
-pub(crate) enum PreviewStrategy {
- // Preview height is calculated for the length of the selected command.
- #[serde(rename = "auto")]
- Auto,
-
- // Preview height is calculated for the length of the longest command stored in the history.
- #[serde(rename = "static")]
- Static,
-
- // max_preview_height is used as fixed height.
- #[serde(rename = "fixed")]
- Fixed,
-}
-
-/// Column types available for the interactive search UI.
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub(crate) enum UiColumnType {
- /// Command execution duration (e.g., "123ms")
- Duration,
- /// Relative time since execution (e.g., "59s ago")
- Time,
- /// Absolute timestamp (e.g., "2025-01-22 14:35")
- Datetime,
- /// Working directory
- Directory,
- /// Hostname
- Host,
- /// Username
- User,
- /// Exit code
- Exit,
- /// The command itself (should be last, expands to fill)
- Command,
-}
-
-impl UiColumnType {
- /// Returns the default width for this column type (in characters).
- /// The Command column returns 0 as it expands to fill remaining space.
- pub(crate) fn default_width(self) -> u16 {
- match self {
- Self::Duration => 5, // "814ms"
- Self::Time => 9, // "459ms ago"
- Self::Datetime => 16, // "2025-01-22 14:35"
- Self::Directory => 20,
- Self::Host => 15,
- Self::User => 10,
- Self::Exit => {
- if cfg!(windows) {
- 11 // 32-bit integer on Windows: "-1978335212"
- } else {
- 3 // Usually a byte on Unix
- }
- }
- Self::Command => 0, // Expands to fill
- }
- }
-}
-
-/// A column configuration with type and optional custom width.
-/// Can be specified as just a string (uses default width) or as an object with type and width.
-#[derive(Clone, Debug, Serialize)]
-pub(crate) struct UiColumn {
- pub(crate) column_type: UiColumnType,
- pub(crate) width: u16,
- /// If true, this column expands to fill remaining space. Only one column should expand.
- pub(crate) expand: bool,
-}
-
-impl UiColumn {
- pub(crate) fn new(column_type: UiColumnType) -> Self {
- Self {
- width: column_type.default_width(),
- expand: column_type == UiColumnType::Command,
- column_type,
- }
- }
-}
-
-// Custom deserialize to handle both string and object formats:
-// "duration" or { type = "duration", width = 8, expand = true }
-impl<'de> Deserialize<'de> for UiColumn {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: serde::Deserializer<'de>,
- {
- use serde::de::{self, MapAccess, Visitor};
-
- struct UiColumnVisitor;
-
- impl<'de> Visitor<'de> for UiColumnVisitor {
- type Value = UiColumn;
-
- fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
- formatter.write_str(
- "a column type string or an object with 'type' and optional 'width'/'expand'",
- )
- }
-
- fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>
- where
- E: de::Error,
- {
- let column_type: UiColumnType =
- Deserialize::deserialize(de::value::StrDeserializer::new(value))?;
- Ok(UiColumn::new(column_type))
- }
-
- fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>
- where
- M: MapAccess<'de>,
- {
- let mut column_type: Option<UiColumnType> = None;
- let mut width: Option<u16> = None;
- let mut expand: Option<bool> = None;
-
- while let Some(key) = map.next_key::<String>()? {
- match key.as_str() {
- "type" => {
- column_type = Some(map.next_value()?);
- }
- "width" => {
- width = Some(map.next_value()?);
- }
- "expand" => {
- expand = Some(map.next_value()?);
- }
- _ => {
- let _: de::IgnoredAny = map.next_value()?;
- }
- }
- }
-
- let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?;
- let width = width.unwrap_or_else(|| column_type.default_width());
- let expand = expand.unwrap_or(column_type == UiColumnType::Command);
- Ok(UiColumn {
- column_type,
- width,
- expand,
- })
- }
- }
-
- deserializer.deserialize_any(UiColumnVisitor)
- }
-}
-
-/// UI-specific settings for the interactive search.
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Ui {
- /// Columns to display in interactive search, from left to right.
- /// The indicator column (" > ") is always shown first implicitly.
- /// The "command" column should be last as it expands to fill remaining space.
- /// Can be simple strings or objects with type and width.
- #[serde(default = "Ui::default_columns")]
- pub(crate) columns: Vec<UiColumn>,
-}
-
-impl Ui {
- fn default_columns() -> Vec<UiColumn> {
- vec![
- UiColumn::new(UiColumnType::Duration),
- UiColumn::new(UiColumnType::Time),
- UiColumn::new(UiColumnType::Command),
- ]
- }
-
- /// Validate the UI configuration.
- /// Returns an error if more than one column has expand = true.
- pub(crate) fn validate(&self) -> Result<()> {
- let expand_count = self.columns.iter().filter(|c| c.expand).count();
- if expand_count > 1 {
- bail!(
- "Only one column can have expand = true, but {} columns are set to expand",
- expand_count
- );
- }
- Ok(())
- }
-}
-
-impl Default for Ui {
- fn default() -> Self {
- Self {
- columns: Self::default_columns(),
- }
- }
-}
-
-/// Sync-specific settings.
-#[derive(Clone, Debug, Default, Deserialize, Serialize)]
-pub(crate) struct Sync {
- /// The sync address for atuin.
- pub(crate) address: String,
-
- #[serde(default)]
- pub(crate) frequency: String,
-
- #[serde(default)]
- pub(crate) auto: bool,
-
- #[serde(default)]
- pub(crate) user_id_path: Option<PathBuf>,
-
- #[serde(default)]
- pub(crate) encryption_key_path: Option<PathBuf>,
-}
-
-impl Sync {
- fn try_read_file(file: Option<&PathBuf>) -> Result<Option<String>> {
- if let Some(path) = file {
- if path.try_exists()? {
- let user = read_to_string(path)?;
-
- if user.is_empty() {
- Ok(None)
- } else {
- Ok(Some(user))
- }
- } else {
- // It's okay that the file doesn't exist.
- // The important part is to error out if we can't access it (e.g. Because of missing
- // permissions).
- Ok(None)
- }
- } else {
- Ok(None)
- }
- }
-
- pub(crate) fn have_sync_user(&self) -> Result<bool> {
- let sa = self.user_id()?;
- Ok(sa.is_some())
- }
-
- pub(crate) fn user_id(&self) -> Result<Option<Uuid>> {
- Self::try_read_file(self.user_id_path.as_ref())?
- .map(|file| {
- Uuid::parse_str(file.trim()).context(
- "Failed to decode user id as UUID, while trying to decode sync user_id",
- )
- })
- .transpose()
- }
- pub(crate) fn encryption_key(&self) -> Result<Option<Key>> {
- Self::try_read_file(self.encryption_key_path.as_ref())?
- .as_deref()
- .map(str::trim)
- .map(decode_key)
- .transpose()
- }
-}
-
-#[derive(Clone, Debug, Deserialize, Serialize)]
-#[expect(clippy::struct_excessive_bools)]
-pub(crate) struct Settings {
- pub(crate) data_dir: Option<String>,
- pub(crate) dialect: Dialect,
- pub(crate) timezone: Timezone,
- pub(crate) style: Style,
-
- pub(crate) db_path: String,
- pub(crate) record_store_path: String,
- pub(crate) search_mode: SearchMode,
- pub(crate) filter_mode: Option<FilterMode>,
- pub(crate) filter_mode_shell_up_key_binding: Option<FilterMode>,
- pub(crate) search_mode_shell_up_key_binding: Option<SearchMode>,
- pub(crate) shell_up_key_binding: bool,
- pub(crate) inline_height: u16,
- pub(crate) inline_height_shell_up_key_binding: Option<u16>,
- pub(crate) invert: bool,
- pub(crate) show_preview: bool,
- pub(crate) max_preview_height: u16,
- pub(crate) show_help: bool,
- pub(crate) show_tabs: bool,
- pub(crate) show_numeric_shortcuts: bool,
- pub(crate) auto_hide_height: u16,
- pub(crate) exit_mode: ExitMode,
- pub(crate) keymap_mode: KeymapMode,
- pub(crate) keymap_mode_shell: KeymapMode,
- pub(crate) keymap_cursor: HashMap<String, CursorStyle>,
- pub(crate) word_jump_mode: WordJumpMode,
- pub(crate) word_chars: String,
- pub(crate) scroll_context_lines: usize,
- pub(crate) history_format: String,
- pub(crate) strip_trailing_whitespace: bool,
- pub(crate) prefers_reduced_motion: bool,
- pub(crate) store_failed: bool,
- pub(crate) no_mouse: bool,
-
- #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
- pub(crate) history_filter: RegexSet,
-
- #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
- pub(crate) cwd_filter: RegexSet,
-
- pub(crate) secrets_filter: bool,
- pub(crate) workspaces: bool,
- pub(crate) ctrl_n_shortcuts: bool,
-
- pub(crate) network_connect_timeout: u64,
- pub(crate) network_timeout: u64,
- pub(crate) local_timeout: f64,
- pub(crate) enter_accept: bool,
- pub(crate) smart_sort: bool,
- pub(crate) command_chaining: bool,
-
- #[serde(default)]
- pub(crate) sync: Sync,
-
- #[serde(default)]
- pub(crate) stats: Stats,
-
- #[serde(default)]
- pub(crate) keys: Keys,
-
- #[serde(default)]
- pub(crate) keymap: KeymapConfig,
-
- #[serde(default)]
- pub(crate) preview: Preview,
-
- #[serde(default)]
- pub(crate) daemon: Daemon,
-
- #[serde(default)]
- pub(crate) search: Search,
-
- #[serde(default)]
- pub(crate) ui: Ui,
-
- #[serde(default)]
- pub(crate) logs: Logs,
-
- #[serde(default)]
- pub(crate) meta: meta::Settings,
-}
-
-impl Settings {
- // -- Meta store: lazily initialized on first access --
-
- pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::meta::MetaStore> {
- META_STORE
- .get_or_try_init(|| async {
- let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
- eyre!("meta store config not set — Settings::new() has not been called")
- })?;
- crate::atuin_client::meta::MetaStore::new(db_path, *timeout).await
- })
- .await
- }
-
- pub(crate) async fn host_id() -> Result<HostId> {
- Self::meta_store().await?.host_id().await
- }
-
- pub(crate) async fn last_sync() -> Result<OffsetDateTime> {
- Self::meta_store().await?.last_sync().await
- }
-
- pub(crate) async fn save_sync_time() -> Result<()> {
- Self::meta_store().await?.save_sync_time().await
- }
-
- pub(crate) fn default_filter_mode(&self, git_root: bool) -> FilterMode {
- self.filter_mode
- .filter(|x| self.search.filters.contains(x))
- .or_else(|| {
- self.search
- .filters
- .iter()
- .find(|x| match (x, git_root, self.workspaces) {
- (FilterMode::Workspace, true, true) => true,
- (FilterMode::Workspace, _, _) => false,
- (_, _, _) => true,
- })
- .copied()
- })
- .unwrap_or(FilterMode::Global)
- }
-
- pub(crate) fn builder() -> Result<ConfigBuilder<DefaultState>> {
- Self::builder_with_data_dir(&utils::data_dir())
- }
-
- #[expect(clippy::too_many_lines)]
- fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {
- let db_path = data_dir.join("history.db");
- let record_store_path = data_dir.join("records.db");
- let kv_path = data_dir.join("kv.db");
- let scripts_path = data_dir.join("scripts.db");
- let ai_sessions_path = data_dir.join("ai_sessions.db");
- let socket_path = utils::runtime_dir().join("atuin.sock");
- let pidfile_path = data_dir.join("atuin-daemon.pid");
- let logs_dir = utils::logs_dir();
-
- let key_path = data_dir.join("key");
- let meta_path = data_dir.join("meta.db");
-
- Ok(Config::builder()
- .set_default("history_format", "{time}\t{command}\t{duration}")?
- .set_default("db_path", db_path.to_str())?
- .set_default("record_store_path", record_store_path.to_str())?
- .set_default("key_path", key_path.to_str())?
- .set_default("dialect", "us")?
- .set_default("timezone", "local")?
- .set_default("auto_sync", true)?
- .set_default("sync.address", "https://api.atuin.sh")?
- .set_default("sync_frequency", "5m")?
- .set_default("search_mode", "fuzzy")?
- .set_default("filter_mode", None::<String>)?
- .set_default("style", "compact")?
- .set_default("inline_height", 40)?
- .set_default("show_preview", true)?
- .set_default("preview.strategy", "auto")?
- .set_default("max_preview_height", 4)?
- .set_default("show_help", true)?
- .set_default("show_tabs", true)?
- .set_default("show_numeric_shortcuts", true)?
- .set_default("auto_hide_height", 8)?
- .set_default("invert", false)?
- .set_default("exit_mode", "return-original")?
- .set_default("word_jump_mode", "emacs")?
- .set_default(
- "word_chars",
- "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
- )?
- .set_default("scroll_context_lines", 1)?
- .set_default("shell_up_key_binding", false)?
- .set_default("workspaces", false)?
- .set_default("ctrl_n_shortcuts", false)?
- .set_default("secrets_filter", true)?
- .set_default("strip_trailing_whitespace", true)?
- .set_default("network_connect_timeout", 5)?
- .set_default("network_timeout", 30)?
- .set_default("local_timeout", 2.0)?
- // enter_accept defaults to false here, but true in the default config file. The dissonance is
- // intentional!
- // Existing users will get the default "False", so we don't mess with any potential
- // muscle memory.
- // New users will get the new default, that is more similar to what they are used to.
- .set_default("enter_accept", false)?
- .set_default("keys.scroll_exits", true)?
- .set_default("keys.accept_past_line_end", true)?
- .set_default("keys.exit_past_line_start", true)?
- .set_default("keys.accept_past_line_start", false)?
- .set_default("keys.accept_with_backspace", false)?
- .set_default("keys.prefix", "a")?
- .set_default("keymap_mode", "emacs")?
- .set_default("keymap_mode_shell", "auto")?
- .set_default("keymap_cursor", HashMap::<String, String>::new())?
- .set_default("smart_sort", false)?
- .set_default("command_chaining", false)?
- .set_default("store_failed", true)?
- .set_default("daemon.sync_frequency", 300)?
- .set_default("daemon.socket_path", socket_path.to_str())?
- .set_default("daemon.pidfile_path", pidfile_path.to_str())?
- .set_default("daemon.systemd_socket", false)?
- .set_default("daemon.tcp_port", 8889)?
- .set_default("logs.enabled", true)?
- .set_default("logs.dir", logs_dir.to_str())?
- .set_default("logs.level", "info")?
- .set_default("logs.search.file", "search.log")?
- .set_default("logs.daemon.file", "daemon.log")?
- .set_default("logs.ai.file", "ai.log")?
- .set_default("kv.db_path", kv_path.to_str())?
- .set_default("scripts.db_path", scripts_path.to_str())?
- .set_default("search.recency_score_multiplier", 1.0)?
- .set_default("search.frequency_score_multiplier", 1.0)?
- .set_default("search.frecency_score_multiplier", 1.0)?
- .set_default("meta.db_path", meta_path.to_str())?
- .set_default("ai.db_path", ai_sessions_path.to_str())?
- .set_default("ai.session_continue_minutes", 60)?
- .set_default("ai.send_cwd", false)?
- .set_default("ai.opening.send_cwd", false)?
- .set_default("ai.opening.send_last_command", false)?
- .set_default(
- "search.filters",
- vec![
- "global",
- "host",
- "session",
- "workspace",
- "directory",
- "session-preload",
- ],
- )?
- .set_default("theme.name", "default")?
- .set_default("theme.debug", None::<bool>)?
- .set_default("tmux.enabled", false)?
- .set_default("tmux.width", "80%")?
- .set_default("tmux.height", "60%")?
- .set_default(
- "prefers_reduced_motion",
- std::env::var("NO_MOTION").ok().map_or_else(
- || config::Value::new(None, config::ValueKind::Boolean(false)),
- |_| config::Value::new(None, config::ValueKind::Boolean(true)),
- ),
- )?
- .set_default("no_mouse", false)?
- .add_source(
- Environment::with_prefix("atuin")
- .prefix_separator("_")
- .separator("__"),
- ))
- }
-
- pub(crate) fn get_config_path() -> Result<PathBuf> {
- let config_dir = utils::config_dir();
-
- create_dir_all(&config_dir)
- .wrap_err_with(|| format!("could not create dir {}", config_dir.display()))?;
-
- let mut config_file = std::env::var("ATUIN_CONFIG_DIR").map_or_else(
- |_| {
- let mut config_file = PathBuf::new();
- config_file.push(config_dir);
- config_file
- },
- PathBuf::from,
- );
-
- config_file.push("config.toml");
-
- Ok(config_file)
- }
-
- /// Build a merged `Config` from defaults, config file, and environment.
- ///
- /// This resolves `data_dir`, initializes the data directory on disk,
- /// and layers defaults → config file → env overrides. Both `new()` and
- /// `get_config_value()` use this so the resolution logic lives in one place.
- fn build_config() -> Result<Config> {
- let config_file = Self::get_config_path()?;
-
- // extract data_dir first so we can use it as the base for other path defaults
- let effective_data_dir = if config_file.exists() {
- #[derive(Deserialize, Default)]
- struct DataDirOnly {
- data_dir: Option<String>,
- }
-
- let config_file_str = config_file
- .to_str()
- .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
-
- let partial_config = Config::builder()
- .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
- .add_source(
- Environment::with_prefix("atuin")
- .prefix_separator("_")
- .separator("__"),
- )
- .build()
- .ok();
-
- let custom_data_dir = partial_config
- .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())
- .and_then(|d| d.data_dir);
-
- match custom_data_dir {
- Some(dir) => {
- let expanded = shellexpand::full(&dir)
- .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
- PathBuf::from(expanded.as_ref())
- }
- None => utils::data_dir(),
- }
- } else {
- utils::data_dir()
- };
-
- DATA_DIR.set(effective_data_dir.clone()).ok();
-
- create_dir_all(&effective_data_dir)
- .wrap_err_with(|| format!("could not create dir {}", effective_data_dir.display()))?;
-
- let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
-
- config_builder = if config_file.exists() {
- let config_file_str = config_file
- .to_str()
- .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
- config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
- } else {
- // TODO(@bpeetz): Rework the config handling, so that we can actually auto-write a
- // file with defaults. <2026-06-13>
- create_dir_all(config_file.parent().unwrap())?;
-
- info!(
- "No config file at: `{}`. Not adding one.",
- config_file.display()
- );
-
- config_builder
- };
-
- // all paths should be expanded
- let built = config_builder.build_cloned()?;
- config_builder = [
- "db_path",
- "record_store_path",
- "key_path",
- "daemon.socket_path",
- "daemon.pidfile_path",
- "logs.dir",
- "logs.search.file",
- "logs.daemon.file",
- ]
- .iter()
- .map(|key| (key, built.get_string(key).unwrap_or_default()))
- .filter_map(|(key, value)| match Self::expand_path(&value) {
- Ok(expanded) => Some((key, expanded)),
- Err(e) => {
- log::warn!("failed to expand path for {key}: {e}");
- None
- }
- })
- .fold(config_builder, |builder, (key, value)| {
- builder
- .set_override(key, value)
- .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
- });
-
- config_builder.build().map_err(Into::into)
- }
-
- /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
- ///
- /// Returns the effective value after merging defaults, config file, and
- /// environment — without the side-effects of full `Settings` construction
- /// (meta store init, path expansion, etc.).
- pub(crate) fn get_config_value(key: &str) -> Result<String> {
- let config = Self::build_config()?;
- let value: config::Value = config
- .get(key)
- .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
- Ok(Self::format_resolved_value(&value, key))
- }
-
- fn format_resolved_value(value: &config::Value, prefix: &str) -> String {
- use config::ValueKind;
-
- match &value.kind {
- ValueKind::Nil => String::new(),
- ValueKind::Boolean(b) => b.to_string(),
- ValueKind::I64(i) => i.to_string(),
- ValueKind::I128(i) => i.to_string(),
- ValueKind::U64(u) => u.to_string(),
- ValueKind::U128(u) => u.to_string(),
- ValueKind::Float(f) => f.to_string(),
- ValueKind::String(s) => s.clone(),
- ValueKind::Array(arr) => {
- let items: Vec<String> = arr
- .iter()
- .map(|v| Self::format_resolved_value(v, ""))
- .collect();
- format!("[{}]", items.join(", "))
- }
- ValueKind::Table(map) => {
- let mut lines = Vec::new();
- let mut keys: Vec<_> = map.keys().collect();
- keys.sort();
-
- for k in keys {
- let v = &map[k];
- let full_key = if prefix.is_empty() {
- k.clone()
- } else {
- format!("{prefix}.{k}")
- };
-
- match &v.kind {
- ValueKind::Table(_) => {
- lines.push(Self::format_resolved_value(v, &full_key));
- }
- _ => {
- lines.push(format!(
- "{} = {}",
- full_key,
- Self::format_resolved_value(v, "")
- ));
- }
- }
- }
-
- lines.join("\n")
- }
- }
- }
-
- pub(crate) fn new() -> Result<Self> {
- let config = Self::build_config()?;
- let settings: Self = config
- .try_deserialize()
- .map_err(|e| eyre!("failed to deserialize: {}", e))?;
-
- // Validate UI settings
- settings.ui.validate()?;
-
- // Register meta store config for lazy initialization on first access
- META_CONFIG
- .set((settings.meta.db_path.clone(), settings.local_timeout))
- .ok();
-
- Ok(settings)
- }
-
- fn expand_path(path: &str) -> Result<String> {
- shellexpand::full(&path)
- .map(|p| p.to_string())
- .map_err(|e| eyre!("failed to expand path: {}", e))
- }
-
- pub(crate) fn paths_ok(&self) -> bool {
- // TODO(@bpeetz): Add the `sync.*` paths <2026-06-11>
- let paths = [&self.db_path, &self.record_store_path, &self.meta.db_path];
- paths.iter().all(|p| !utils::broken_symlink(p))
- }
-}
-
-impl Default for Settings {
- fn default() -> Self {
- // if this panics something is very wrong, as the default config
- // does not build or deserialize into the settings struct
- Self::builder()
- .expect("Could not build default")
- .build()
- .expect("Could not build config")
- .try_deserialize()
- .expect("Could not deserialize config")
- }
-}
-
-#[cfg(test)]
-pub(crate) fn test_local_timeout() -> f64 {
- std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
- .ok()
- .and_then(|x| x.parse().ok())
- // this hardcoded value should be replaced by a simple way to get the
- // default local_timeout of Settings if possible
- .unwrap_or(2.0)
-}
-
-#[cfg(test)]
-mod tests {
- use std::str::FromStr;
-
- use eyre::Result;
-
- use super::Timezone;
-
- #[test]
- fn can_parse_offset_timezone_spec() -> Result<()> {
- assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
- assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
- assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
- assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
-
- // single digit hours are allowed
- assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
- assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
- assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
- assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
-
- // fully qualified form
- assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
- assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
-
- // these offsets don't really exist but are supported anyway
- assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
- assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
- assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
- assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
-
- // require a leading sign for clarity
- assert!(Timezone::from_str("5").is_err());
- assert!(Timezone::from_str("10:30").is_err());
-
- Ok(())
- }
-
- #[test]
- fn can_choose_workspace_filters_when_in_git_context() -> Result<()> {
- let mut settings = super::Settings::default();
- settings.search.filters = vec![
- super::FilterMode::Workspace,
- super::FilterMode::Host,
- super::FilterMode::Directory,
- super::FilterMode::Session,
- super::FilterMode::Global,
- ];
- settings.workspaces = true;
-
- assert_eq!(
- settings.default_filter_mode(true),
- super::FilterMode::Workspace,
- );
-
- Ok(())
- }
-
- #[test]
- fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> {
- let mut settings = super::Settings::default();
- settings.search.filters = vec![
- super::FilterMode::Workspace,
- super::FilterMode::Host,
- super::FilterMode::Directory,
- super::FilterMode::Session,
- super::FilterMode::Global,
- ];
- settings.workspaces = true;
-
- assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,);
-
- Ok(())
- }
-
- #[test]
- fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> {
- let mut settings = super::Settings::default();
- settings.search.filters = vec![
- super::FilterMode::Workspace,
- super::FilterMode::Host,
- super::FilterMode::Directory,
- super::FilterMode::Session,
- super::FilterMode::Global,
- ];
- settings.workspaces = false;
-
- assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,);
-
- Ok(())
- }
-
- #[test]
- fn builder_with_data_dir_uses_custom_paths() -> Result<()> {
- use std::path::PathBuf;
-
- let custom_dir = PathBuf::from("/custom/data/dir");
- let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
- let config = builder.build()?;
-
- let db_path: String = config.get("db_path")?;
- let key_path: String = config.get("key_path")?;
- let record_store_path: String = config.get("record_store_path")?;
- let kv_db_path: String = config.get("kv.db_path")?;
- let scripts_db_path: String = config.get("scripts.db_path")?;
- let meta_db_path: String = config.get("meta.db_path")?;
- let daemon_socket_path: String = config.get("daemon.socket_path")?;
- let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
-
- assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
- assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
- assert_eq!(
- record_store_path,
- custom_dir.join("records.db").to_str().unwrap()
- );
- assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
- assert_eq!(
- scripts_db_path,
- custom_dir.join("scripts.db").to_str().unwrap()
- );
- assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
- assert_eq!(
- daemon_socket_path,
- crate::atuin_common::utils::runtime_dir()
- .join("atuin.sock")
- .to_str()
- .unwrap()
- );
- assert_eq!(
- daemon_pidfile_path,
- custom_dir.join("atuin-daemon.pid").to_str().unwrap()
- );
-
- Ok(())
- }
-
- #[test]
- fn keymap_config_deserializes_simple_binding() {
- let json = r#"{"emacs": {"ctrl-c": "exit"}}"#;
- let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
- assert_eq!(config.emacs.len(), 1);
- match &config.emacs["ctrl-c"] {
- super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"),
- _ => panic!("expected Simple variant"),
- }
- }
-
- #[test]
- fn keymap_config_deserializes_conditional_binding() {
- let json = r#"{
- "emacs": {
- "left": [
- {"when": "cursor-at-start", "action": "exit"},
- {"action": "cursor-left"}
- ]
- }
- }"#;
- let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
- match &config.emacs["left"] {
- super::KeyBindingConfig::Rules(rules) => {
- assert_eq!(rules.len(), 2);
- assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start"));
- assert_eq!(rules[0].action, "exit");
- assert!(rules[1].when.is_none());
- assert_eq!(rules[1].action, "cursor-left");
- }
- _ => panic!("expected Rules variant"),
- }
- }
-
- #[test]
- fn keymap_config_deserializes_vim_normal() {
- let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#;
- let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
- assert_eq!(config.vim_normal.len(), 2);
- assert!(config.emacs.is_empty());
- }
-
- #[test]
- fn keymap_config_is_empty_when_default() {
- let config = super::KeymapConfig::default();
- assert!(config.is_empty());
- }
-
- #[test]
- fn keymap_config_mixed_modes() {
- let json = r#"{
- "emacs": {"ctrl-c": "exit"},
- "vim-normal": {"q": "exit"},
- "inspector": {"d": "delete"}
- }"#;
- let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
- assert!(!config.is_empty());
- assert_eq!(config.emacs.len(), 1);
- assert_eq!(config.vim_normal.len(), 1);
- assert_eq!(config.inspector.len(), 1);
- assert!(config.vim_insert.is_empty());
- assert!(config.prefix.is_empty());
- }
-}