use std::{ collections::HashMap, convert::TryFrom, fmt, io::prelude::*, path::{Path, PathBuf}, str::FromStr, }; use atuin_common::record::HostId; use clap::ValueEnum; use config::{ builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, }; use eyre::{bail, eyre, Context, Error, Result}; use fs_err::{create_dir_all, File}; use parse_duration::parse; use regex::RegexSet; use semver::Version; use serde::Deserialize; use serde_with::DeserializeFromStr; use time::{ format_description::{well_known::Rfc3339, FormatItem}, macros::format_description, OffsetDateTime, UtcOffset, }; use uuid::Uuid; pub const HISTORY_PAGE_SIZE: i64 = 100; pub const LAST_SYNC_FILENAME: &str = "last_sync_time"; pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time"; pub const LATEST_VERSION_FILENAME: &str = "latest_version"; pub const HOST_ID_FILENAME: &str = "host_id"; static EXAMPLE_CONFIG: &str = include_str!("../config.toml"); mod dotfiles; #[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq)] pub enum SearchMode { #[serde(rename = "prefix")] Prefix, #[serde(rename = "fulltext")] #[clap(aliases = &["fulltext"])] FullText, #[serde(rename = "fuzzy")] Fuzzy, #[serde(rename = "skim")] Skim, } impl SearchMode { pub fn as_str(&self) -> &'static str { match self { SearchMode::Prefix => "PREFIX", SearchMode::FullText => "FULLTXT", SearchMode::Fuzzy => "FUZZY", SearchMode::Skim => "SKIM", } } 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, // otherwise fuzzy. SearchMode::FullText => SearchMode::Fuzzy, SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix, } } } #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)] 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, } 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", } } } #[derive(Clone, Debug, Deserialize, Copy)] 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)] pub enum Dialect { #[serde(rename = "us")] Us, #[serde(rename = "uk")] Uk, } impl From 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)] pub struct Timezone(pub UtcOffset); impl fmt::Display for Timezone { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.fmt(f) } } /// format: <+|->[:[:]] 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 { // 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)] pub enum Style { #[serde(rename = "auto")] Auto, #[serde(rename = "full")] Full, #[serde(rename = "compact")] Compact, } #[derive(Clone, Debug, Deserialize, Copy)] pub enum WordJumpMode { #[serde(rename = "emacs")] Emacs, #[serde(rename = "subl")] Subl, } #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)] 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)] 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)] pub struct Stats { #[serde(default = "Stats::common_prefix_default")] pub common_prefix: Vec, // sudo, etc. commands we want to strip off #[serde(default = "Stats::common_subcommands_default")] pub common_subcommands: Vec, // kubectl, commands we should consider subcommands for #[serde(default = "Stats::ignored_commands_default")] pub ignored_commands: Vec, // cd, ls, etc. commands we want to completely hide from stats } impl Stats { fn common_prefix_default() -> Vec { vec!["sudo", "doas"].into_iter().map(String::from).collect() } fn common_subcommands_default() -> Vec { vec![ "apt", "cargo", "composer", "dnf", "docker", "git", "go", "ip", "kubectl", "nix", "nmcli", "npm", "pecl", "pnpm", "podman", "port", "systemctl", "tmux", "yarn", ] .into_iter() .map(String::from) .collect() } fn ignored_commands_default() -> Vec { 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)] pub struct Sync { pub records: bool, } #[derive(Clone, Debug, Deserialize, Default)] pub struct Keys { pub scroll_exits: bool, } #[derive(Clone, Debug, Deserialize)] pub struct Settings { pub dialect: Dialect, pub timezone: Timezone, pub style: Style, pub auto_sync: bool, pub update_check: bool, pub sync_address: String, pub sync_frequency: String, pub db_path: String, pub record_store_path: String, pub key_path: String, pub session_path: String, pub search_mode: SearchMode, pub filter_mode: FilterMode, pub filter_mode_shell_up_key_binding: Option, pub search_mode_shell_up_key_binding: Option, pub shell_up_key_binding: bool, pub inline_height: u16, pub invert: bool, pub show_preview: bool, pub max_preview_height: u16, pub show_help: bool, pub show_tabs: bool, pub exit_mode: ExitMode, pub keymap_mode: KeymapMode, pub keymap_mode_shell: KeymapMode, pub keymap_cursor: HashMap, pub word_jump_mode: WordJumpMode, pub word_chars: String, pub scroll_context_lines: usize, pub history_format: String, pub prefers_reduced_motion: bool, #[serde(with = "serde_regex", default = "RegexSet::empty")] pub history_filter: RegexSet, #[serde(with = "serde_regex", default = "RegexSet::empty")] 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, #[serde(default)] pub stats: Stats, #[serde(default)] pub sync: Sync, #[serde(default)] pub keys: Keys, #[serde(default)] pub dotfiles: dotfiles::Settings, // This is automatically loaded when settings is created. Do not set in // config! Keep secrets and settings apart. #[serde(skip)] pub session_token: String, } 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") } fn save_to_data_dir(filename: &str, value: &str) -> Result<()> { let data_dir = atuin_common::utils::data_dir(); let data_dir = data_dir.as_path(); let path = data_dir.join(filename); fs_err::write(path, value)?; Ok(()) } fn read_from_data_dir(filename: &str) -> Option { let data_dir = atuin_common::utils::data_dir(); let data_dir = data_dir.as_path(); let path = data_dir.join(filename); if !path.exists() { return None; } let value = fs_err::read_to_string(path); value.ok() } fn save_current_time(filename: &str) -> Result<()> { Settings::save_to_data_dir( filename, OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(), )?; Ok(()) } fn load_time_from_file(filename: &str) -> Result { let value = Settings::read_from_data_dir(filename); match value { Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?), None => Ok(OffsetDateTime::UNIX_EPOCH), } } pub fn save_sync_time() -> Result<()> { Settings::save_current_time(LAST_SYNC_FILENAME) } pub fn save_version_check_time() -> Result<()> { Settings::save_current_time(LAST_VERSION_CHECK_FILENAME) } pub fn last_sync() -> Result { Settings::load_time_from_file(LAST_SYNC_FILENAME) } pub fn last_version_check() -> Result { Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME) } pub fn host_id() -> Option { let id = Settings::read_from_data_dir(HOST_ID_FILENAME); if let Some(id) = id { let parsed = Uuid::from_str(id.as_str()).expect("failed to parse host ID from local directory"); return Some(HostId(parsed)); } let uuid = atuin_common::utils::uuid_v7(); Settings::save_to_data_dir(HOST_ID_FILENAME, uuid.as_simple().to_string().as_ref()) .expect("Could not write host ID to data dir"); Some(HostId(uuid)) } pub fn should_sync(&self) -> Result { if !self.auto_sync || !PathBuf::from(self.session_path.as_str()).exists() { return Ok(false); } match parse(self.sync_frequency.as_str()) { Ok(d) => { let d = time::Duration::try_from(d).unwrap(); Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d) } Err(e) => Err(eyre!("failed to check sync: {}", e)), } } #[cfg(feature = "check-update")] fn needs_update_check(&self) -> Result { let last_check = Settings::last_version_check()?; let diff = OffsetDateTime::now_utc() - last_check; // Check a max of once per hour Ok(diff.whole_hours() >= 1) } #[cfg(feature = "check-update")] async fn latest_version(&self) -> Result { // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever // suggest upgrading. let current = Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); if !self.needs_update_check()? { // Worst case, we don't want Atuin to fail to start because something funky is going on with // version checking. let version = tokio::task::spawn_blocking(|| { Settings::read_from_data_dir(LATEST_VERSION_FILENAME) }) .await .expect("file task panicked"); let version = match version { Some(v) => Version::parse(&v).unwrap_or(current), None => current, }; return Ok(version); } #[cfg(feature = "sync")] let latest = crate::api_client::latest_version().await.unwrap_or(current); #[cfg(not(feature = "sync"))] let latest = current; let latest_encoded = latest.to_string(); tokio::task::spawn_blocking(move || { Settings::save_version_check_time()?; Settings::save_to_data_dir(LATEST_VERSION_FILENAME, &latest_encoded)?; Ok::<(), eyre::Report>(()) }) .await .expect("file task panicked")?; Ok(latest) } // Return Some(latest version) if an update is needed. Otherwise, none. #[cfg(feature = "check-update")] pub async fn needs_update(&self) -> Option { if !self.update_check { return None; } let current = Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0)); let latest = self.latest_version().await; if latest.is_err() { return None; } let latest = latest.unwrap(); if latest > current { return Some(latest); } None } #[cfg(not(feature = "check-update"))] pub async fn needs_update(&self) -> Option { None } pub fn builder() -> Result> { let data_dir = atuin_common::utils::data_dir(); let db_path = data_dir.join("history.db"); let record_store_path = data_dir.join("records.db"); let key_path = data_dir.join("key"); let session_path = data_dir.join("session"); 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("session_path", session_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", "10m")? .set_default("search_mode", "fuzzy")? .set_default("filter_mode", "global")? .set_default("style", "auto")? .set_default("inline_height", 0)? .set_default("show_preview", false)? .set_default("max_preview_height", 4)? .set_default("show_help", true)? .set_default("show_tabs", true)? .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("session_token", "")? .set_default("workspaces", false)? .set_default("ctrl_n_shortcuts", false)? .set_default("secrets_filter", 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("sync.records", false)? .set_default("keys.scroll_exits", true)? .set_default("keymap_mode", "emacs")? .set_default("keymap_mode_shell", "auto")? .set_default("keymap_cursor", HashMap::::new())? .set_default("smart_sort", false)? .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))), )? .add_source( Environment::with_prefix("atuin") .prefix_separator("_") .separator("__"), )) } pub fn new() -> Result { let config_dir = atuin_common::utils::config_dir(); let data_dir = atuin_common::utils::data_dir(); create_dir_all(&config_dir) .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?; create_dir_all(&data_dir).wrap_err_with(|| format!("could not create dir {data_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"); let mut config_builder = Self::builder()?; config_builder = if config_file.exists() { config_builder.add_source(ConfigFile::new( config_file.to_str().unwrap(), 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 }; let config = config_builder.build()?; let mut settings: Settings = config .try_deserialize() .map_err(|e| eyre!("failed to deserialize: {}", e))?; // all paths should be expanded let db_path = settings.db_path; let db_path = shellexpand::full(&db_path)?; settings.db_path = db_path.to_string(); let key_path = settings.key_path; let key_path = shellexpand::full(&key_path)?; settings.key_path = key_path.to_string(); let session_path = settings.session_path; let session_path = shellexpand::full(&session_path)?; settings.session_path = session_path.to_string(); // Finally, set the auth token if Path::new(session_path.to_string().as_str()).exists() { let token = fs_err::read_to_string(session_path.to_string())?; settings.session_token = token.trim().to_string(); } else { settings.session_token = String::from("not logged in"); } Ok(settings) } pub fn example_config() -> &'static str { EXAMPLE_CONFIG } } 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)] 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(()) } }