mirror of
https://github.com/atuinsh/atuin.git
synced 2025-08-04 18:58:13 +00:00

I'd like to tidy up the root a little, and it's nice to have all the rust crates in one place
784 lines
24 KiB
Rust
784 lines
24 KiB
Rust
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<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)]
|
|
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)]
|
|
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<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",
|
|
"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<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)]
|
|
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<FilterMode>,
|
|
pub search_mode_shell_up_key_binding: Option<SearchMode>,
|
|
pub shell_up_key_binding: bool,
|
|
pub inline_height: u16,
|
|
pub invert: bool,
|
|
pub show_preview: bool,
|
|
pub show_preview_auto: 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<String, CursorStyle>,
|
|
pub word_jump_mode: WordJumpMode,
|
|
pub word_chars: String,
|
|
pub scroll_context_lines: usize,
|
|
pub history_format: String,
|
|
pub prefers_reduced_motion: bool,
|
|
pub store_failed: 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<String> {
|
|
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<OffsetDateTime> {
|
|
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<OffsetDateTime> {
|
|
Settings::load_time_from_file(LAST_SYNC_FILENAME)
|
|
}
|
|
|
|
pub fn last_version_check() -> Result<OffsetDateTime> {
|
|
Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME)
|
|
}
|
|
|
|
pub fn host_id() -> Option<HostId> {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<Version> {
|
|
// 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<Version> {
|
|
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<Version> {
|
|
None
|
|
}
|
|
|
|
pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
|
|
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("show_preview_auto", true)?
|
|
.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::<String, String>::new())?
|
|
.set_default("smart_sort", false)?
|
|
.set_default("store_failed", true)?
|
|
.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<Self> {
|
|
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(())
|
|
}
|
|
}
|