use crypto_secretbox::Key; use std::{collections::HashMap, fs::read_to_string, path::PathBuf, sync::OnceLock}; use tokio::sync::OnceCell; use tracing::info; use uuid::Uuid; use crate::aclient::encryption::decode_key; use clap::ValueEnum; use config::{ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, }; use eyre::{Context, Result, eyre}; use fs_err::create_dir_all; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use turtle_common::record::HostId; use turtle_common::utils; static DATA_DIR: OnceLock = OnceLock::new(); static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new(); static META_STORE: OnceCell = OnceCell::const_new(); mod meta; // 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)] enum Dialect { #[serde(rename = "us")] Us, #[serde(rename = "uk")] Uk, } impl From for interim::Dialect { fn from(d: Dialect) -> Self { match d { Dialect::Uk => Self::Uk, Dialect::Us => Self::Us, } } } #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] 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)] 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 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 tcp_port: u64, } 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, } } } // The preview height strategy also takes max_preview_height into account. #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)] 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, } /// Sync-specific settings. #[derive(Clone, Debug, Default, Deserialize, Serialize)] pub(crate) struct Sync { /// The sync address for atuin. pub(crate) address: String, #[serde(default)] frequency: String, #[serde(default)] pub(crate) auto: bool, #[serde(default)] user_id_path: Option, #[serde(default)] pub(crate) encryption_key_path: Option, } impl Sync { fn try_read_file(file: Option<&PathBuf>) -> Result> { 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 { let sa = self.user_id()?; Ok(sa.is_some()) } pub(crate) fn user_id(&self) -> Result> { 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> { Self::try_read_file(self.encryption_key_path.as_ref())? .as_deref() .map(str::trim) .map(decode_key) .transpose() } } #[derive(Clone, Debug, Deserialize, Serialize)] pub(crate) struct Settings { pub(crate) db_path: String, pub(crate) record_store_path: String, pub(crate) network_connect_timeout: u64, pub(crate) network_timeout: u64, pub(crate) local_timeout: f64, #[serde(default)] pub(crate) sync: Sync, #[serde(default)] pub(crate) daemon: Daemon, #[serde(default)] meta: meta::Settings, } impl Settings { // -- Meta store: lazily initialized on first access -- async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> { META_STORE .get_or_try_init(|| async { let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| { eyre!("meta store config not set — Settings::new() has not been called") })?; crate::aclient::meta::MetaStore::new(db_path, *timeout).await }) .await } pub(crate) async fn host_id() -> Result { Self::meta_store().await?.host_id().await } async fn last_sync() -> Result { Self::meta_store().await?.last_sync().await } pub(crate) async fn save_sync_time() -> Result<()> { Self::meta_store().await?.save_sync_time().await } fn builder() -> Result> { Self::builder_with_data_dir(&utils::data_dir()) } #[expect(clippy::too_many_lines)] fn builder_with_data_dir(data_dir: &std::path::Path) -> Result> { 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::)? .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::::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::)? .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 { 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 { 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, } 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::().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) } pub(crate) fn new() -> Result { let config = Self::build_config()?; let settings: Self = config .try_deserialize() .map_err(|e| eyre!("failed to deserialize: {}", e))?; // 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 { shellexpand::full(&path) .map(|p| p.to_string()) .map_err(|e| eyre!("failed to expand path: {}", e)) } pub(crate) fn paths_ok(&self) -> bool { let mut paths: Vec<&str> = vec![ &self.db_path, &self.record_store_path, &self.meta.db_path, &self.daemon.socket_path, ]; if let Some(path) = &self.sync.encryption_key_path { paths.push(path.to_str().unwrap()); } if let Some(path) = &self.sync.user_id_path { paths.push(path.to_str().unwrap()); } 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 eyre::Result; #[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, turtle_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(()) } }