atuin_client/
settings.rs

1use std::{
2    collections::HashMap, convert::TryFrom, fmt, io::prelude::*, path::PathBuf, str::FromStr,
3};
4
5use atuin_common::record::HostId;
6use clap::ValueEnum;
7use config::{
8    builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat,
9};
10use eyre::{bail, eyre, Context, Error, Result};
11use fs_err::{create_dir_all, File};
12use humantime::parse_duration;
13use regex::RegexSet;
14use semver::Version;
15use serde::{Deserialize, Serialize};
16use serde_with::DeserializeFromStr;
17use time::{
18    format_description::{well_known::Rfc3339, FormatItem},
19    macros::format_description,
20    OffsetDateTime, UtcOffset,
21};
22use uuid::Uuid;
23
24pub const HISTORY_PAGE_SIZE: i64 = 100;
25pub const LAST_SYNC_FILENAME: &str = "last_sync_time";
26pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time";
27pub const LATEST_VERSION_FILENAME: &str = "latest_version";
28pub const HOST_ID_FILENAME: &str = "host_id";
29static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
30
31mod dotfiles;
32
33#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
34pub enum SearchMode {
35    #[serde(rename = "prefix")]
36    Prefix,
37
38    #[serde(rename = "fulltext")]
39    #[clap(aliases = &["fulltext"])]
40    FullText,
41
42    #[serde(rename = "fuzzy")]
43    Fuzzy,
44
45    #[serde(rename = "skim")]
46    Skim,
47}
48
49impl SearchMode {
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            SearchMode::Prefix => "PREFIX",
53            SearchMode::FullText => "FULLTXT",
54            SearchMode::Fuzzy => "FUZZY",
55            SearchMode::Skim => "SKIM",
56        }
57    }
58    pub fn next(&self, settings: &Settings) -> Self {
59        match self {
60            SearchMode::Prefix => SearchMode::FullText,
61            // if the user is using skim, we go to skim
62            SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
63            // otherwise fuzzy.
64            SearchMode::FullText => SearchMode::Fuzzy,
65            SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix,
66        }
67    }
68}
69
70#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
71pub enum FilterMode {
72    #[serde(rename = "global")]
73    Global = 0,
74
75    #[serde(rename = "host")]
76    Host = 1,
77
78    #[serde(rename = "session")]
79    Session = 2,
80
81    #[serde(rename = "directory")]
82    Directory = 3,
83
84    #[serde(rename = "workspace")]
85    Workspace = 4,
86}
87
88impl FilterMode {
89    pub fn as_str(&self) -> &'static str {
90        match self {
91            FilterMode::Global => "GLOBAL",
92            FilterMode::Host => "HOST",
93            FilterMode::Session => "SESSION",
94            FilterMode::Directory => "DIRECTORY",
95            FilterMode::Workspace => "WORKSPACE",
96        }
97    }
98}
99
100#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
101pub enum ExitMode {
102    #[serde(rename = "return-original")]
103    ReturnOriginal,
104
105    #[serde(rename = "return-query")]
106    ReturnQuery,
107}
108
109// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
110// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
111#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
112pub enum Dialect {
113    #[serde(rename = "us")]
114    Us,
115
116    #[serde(rename = "uk")]
117    Uk,
118}
119
120impl From<Dialect> for interim::Dialect {
121    fn from(d: Dialect) -> interim::Dialect {
122        match d {
123            Dialect::Uk => interim::Dialect::Uk,
124            Dialect::Us => interim::Dialect::Us,
125        }
126    }
127}
128
129/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
130///
131/// Note that the parsing of this struct needs to be done before starting any
132/// multithreaded runtime, otherwise it will fail on most Unix systems.
133///
134/// See: https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426
135#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
136pub struct Timezone(pub UtcOffset);
137impl fmt::Display for Timezone {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        self.0.fmt(f)
140    }
141}
142/// format: <+|-><hour>[:<minute>[:<second>]]
143static OFFSET_FMT: &[FormatItem<'_>] =
144    format_description!("[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]");
145impl FromStr for Timezone {
146    type Err = Error;
147
148    fn from_str(s: &str) -> Result<Self> {
149        // local timezone
150        if matches!(s.to_lowercase().as_str(), "l" | "local") {
151            // There have been some timezone issues, related to errors fetching it on some
152            // platforms
153            // Rather than fail to start, fallback to UTC. The user should still be able to specify
154            // their timezone manually in the config file.
155            let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
156            return Ok(Self(offset));
157        }
158
159        if matches!(s.to_lowercase().as_str(), "0" | "utc") {
160            let offset = UtcOffset::UTC;
161            return Ok(Self(offset));
162        }
163
164        // offset from UTC
165        if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
166            return Ok(Self(offset));
167        }
168
169        // IDEA: Currently named timezones are not supported, because the well-known crate
170        // for this is `chrono_tz`, which is not really interoperable with the datetime crate
171        // that we currently use - `time`. If ever we migrate to using `chrono`, this would
172        // be a good feature to add.
173
174        bail!(r#""{s}" is not a valid timezone spec"#)
175    }
176}
177
178#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
179pub enum Style {
180    #[serde(rename = "auto")]
181    Auto,
182
183    #[serde(rename = "full")]
184    Full,
185
186    #[serde(rename = "compact")]
187    Compact,
188}
189
190#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
191pub enum WordJumpMode {
192    #[serde(rename = "emacs")]
193    Emacs,
194
195    #[serde(rename = "subl")]
196    Subl,
197}
198
199#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
200pub enum KeymapMode {
201    #[serde(rename = "emacs")]
202    Emacs,
203
204    #[serde(rename = "vim-normal")]
205    VimNormal,
206
207    #[serde(rename = "vim-insert")]
208    VimInsert,
209
210    #[serde(rename = "auto")]
211    Auto,
212}
213
214impl KeymapMode {
215    pub fn as_str(&self) -> &'static str {
216        match self {
217            KeymapMode::Emacs => "EMACS",
218            KeymapMode::VimNormal => "VIMNORMAL",
219            KeymapMode::VimInsert => "VIMINSERT",
220            KeymapMode::Auto => "AUTO",
221        }
222    }
223}
224
225// We want to translate the config to crossterm::cursor::SetCursorStyle, but
226// the original type does not implement trait serde::Deserialize unfortunately.
227// It seems impossible to implement Deserialize for external types when it is
228// used in HashMap (https://stackoverflow.com/questions/67142663).  We instead
229// define an adapter type.
230#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
231pub enum CursorStyle {
232    #[serde(rename = "default")]
233    DefaultUserShape,
234
235    #[serde(rename = "blink-block")]
236    BlinkingBlock,
237
238    #[serde(rename = "steady-block")]
239    SteadyBlock,
240
241    #[serde(rename = "blink-underline")]
242    BlinkingUnderScore,
243
244    #[serde(rename = "steady-underline")]
245    SteadyUnderScore,
246
247    #[serde(rename = "blink-bar")]
248    BlinkingBar,
249
250    #[serde(rename = "steady-bar")]
251    SteadyBar,
252}
253
254impl CursorStyle {
255    pub fn as_str(&self) -> &'static str {
256        match self {
257            CursorStyle::DefaultUserShape => "DEFAULT",
258            CursorStyle::BlinkingBlock => "BLINKBLOCK",
259            CursorStyle::SteadyBlock => "STEADYBLOCK",
260            CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE",
261            CursorStyle::SteadyUnderScore => "STEADYUNDERLINE",
262            CursorStyle::BlinkingBar => "BLINKBAR",
263            CursorStyle::SteadyBar => "STEADYBAR",
264        }
265    }
266}
267
268#[derive(Clone, Debug, Deserialize, Serialize)]
269pub struct Stats {
270    #[serde(default = "Stats::common_prefix_default")]
271    pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
272    #[serde(default = "Stats::common_subcommands_default")]
273    pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
274    #[serde(default = "Stats::ignored_commands_default")]
275    pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
276}
277
278impl Stats {
279    fn common_prefix_default() -> Vec<String> {
280        vec!["sudo", "doas"].into_iter().map(String::from).collect()
281    }
282
283    fn common_subcommands_default() -> Vec<String> {
284        vec![
285            "apt",
286            "cargo",
287            "composer",
288            "dnf",
289            "docker",
290            "git",
291            "go",
292            "ip",
293            "kubectl",
294            "nix",
295            "nmcli",
296            "npm",
297            "pecl",
298            "pnpm",
299            "podman",
300            "port",
301            "systemctl",
302            "tmux",
303            "yarn",
304        ]
305        .into_iter()
306        .map(String::from)
307        .collect()
308    }
309
310    fn ignored_commands_default() -> Vec<String> {
311        vec![]
312    }
313}
314
315impl Default for Stats {
316    fn default() -> Self {
317        Self {
318            common_prefix: Self::common_prefix_default(),
319            common_subcommands: Self::common_subcommands_default(),
320            ignored_commands: Self::ignored_commands_default(),
321        }
322    }
323}
324
325#[derive(Clone, Debug, Deserialize, Default, Serialize)]
326pub struct Sync {
327    pub records: bool,
328}
329
330#[derive(Clone, Debug, Deserialize, Default, Serialize)]
331pub struct Keys {
332    pub scroll_exits: bool,
333    pub prefix: String,
334}
335
336#[derive(Clone, Debug, Deserialize, Serialize)]
337pub struct Preview {
338    pub strategy: PreviewStrategy,
339}
340
341#[derive(Clone, Debug, Deserialize, Serialize)]
342pub struct Theme {
343    /// Name of desired theme ("default" for base)
344    pub name: String,
345
346    /// Whether any available additional theme debug should be shown
347    pub debug: Option<bool>,
348
349    /// How many levels of parenthood will be traversed if needed
350    pub max_depth: Option<u8>,
351}
352
353#[derive(Clone, Debug, Deserialize, Serialize)]
354pub struct Daemon {
355    /// Use the daemon to sync
356    /// If enabled, requires a running daemon with `atuin daemon`
357    #[serde(alias = "enable")]
358    pub enabled: bool,
359
360    /// The daemon will handle sync on an interval. How often to sync, in seconds.
361    pub sync_frequency: u64,
362
363    /// The path to the unix socket used by the daemon
364    pub socket_path: String,
365
366    /// Use a socket passed via systemd's socket activation protocol, instead of the path
367    pub systemd_socket: bool,
368
369    /// The port that should be used for TCP on non unix systems
370    pub tcp_port: u64,
371}
372
373#[derive(Clone, Debug, Deserialize, Serialize)]
374pub struct Search {
375    /// The list of enabled filter modes, in order of priority.
376    pub filters: Vec<FilterMode>,
377}
378
379impl Default for Preview {
380    fn default() -> Self {
381        Self {
382            strategy: PreviewStrategy::Auto,
383        }
384    }
385}
386
387impl Default for Theme {
388    fn default() -> Self {
389        Self {
390            name: "".to_string(),
391            debug: None::<bool>,
392            max_depth: Some(10),
393        }
394    }
395}
396
397impl Default for Daemon {
398    fn default() -> Self {
399        Self {
400            enabled: false,
401            sync_frequency: 300,
402            socket_path: "".to_string(),
403            systemd_socket: false,
404            tcp_port: 8889,
405        }
406    }
407}
408
409impl Default for Search {
410    fn default() -> Self {
411        Self {
412            filters: vec![
413                FilterMode::Global,
414                FilterMode::Host,
415                FilterMode::Session,
416                FilterMode::Workspace,
417                FilterMode::Directory,
418            ],
419        }
420    }
421}
422
423// The preview height strategy also takes max_preview_height into account.
424#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
425pub enum PreviewStrategy {
426    // Preview height is calculated for the length of the selected command.
427    #[serde(rename = "auto")]
428    Auto,
429
430    // Preview height is calculated for the length of the longest command stored in the history.
431    #[serde(rename = "static")]
432    Static,
433
434    // max_preview_height is used as fixed height.
435    #[serde(rename = "fixed")]
436    Fixed,
437}
438
439#[derive(Clone, Debug, Deserialize, Serialize)]
440pub struct Settings {
441    pub dialect: Dialect,
442    pub timezone: Timezone,
443    pub style: Style,
444    pub auto_sync: bool,
445    pub update_check: bool,
446    pub sync_address: String,
447    pub sync_frequency: String,
448    pub db_path: String,
449    pub record_store_path: String,
450    pub key_path: String,
451    pub session_path: String,
452    pub search_mode: SearchMode,
453    pub filter_mode: Option<FilterMode>,
454    pub filter_mode_shell_up_key_binding: Option<FilterMode>,
455    pub search_mode_shell_up_key_binding: Option<SearchMode>,
456    pub shell_up_key_binding: bool,
457    pub inline_height: u16,
458    pub invert: bool,
459    pub show_preview: bool,
460    pub max_preview_height: u16,
461    pub show_help: bool,
462    pub show_tabs: bool,
463    pub auto_hide_height: u16,
464    pub exit_mode: ExitMode,
465    pub keymap_mode: KeymapMode,
466    pub keymap_mode_shell: KeymapMode,
467    pub keymap_cursor: HashMap<String, CursorStyle>,
468    pub word_jump_mode: WordJumpMode,
469    pub word_chars: String,
470    pub scroll_context_lines: usize,
471    pub history_format: String,
472    pub prefers_reduced_motion: bool,
473    pub store_failed: bool,
474
475    #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
476    pub history_filter: RegexSet,
477
478    #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
479    pub cwd_filter: RegexSet,
480
481    pub secrets_filter: bool,
482    pub workspaces: bool,
483    pub ctrl_n_shortcuts: bool,
484
485    pub network_connect_timeout: u64,
486    pub network_timeout: u64,
487    pub local_timeout: f64,
488    pub enter_accept: bool,
489    pub smart_sort: bool,
490
491    #[serde(default)]
492    pub stats: Stats,
493
494    #[serde(default)]
495    pub sync: Sync,
496
497    #[serde(default)]
498    pub keys: Keys,
499
500    #[serde(default)]
501    pub preview: Preview,
502
503    #[serde(default)]
504    pub dotfiles: dotfiles::Settings,
505
506    #[serde(default)]
507    pub daemon: Daemon,
508
509    #[serde(default)]
510    pub search: Search,
511
512    #[serde(default)]
513    pub theme: Theme,
514}
515
516impl Settings {
517    pub fn utc() -> Self {
518        Self::builder()
519            .expect("Could not build default")
520            .set_override("timezone", "0")
521            .expect("failed to override timezone with UTC")
522            .build()
523            .expect("Could not build config")
524            .try_deserialize()
525            .expect("Could not deserialize config")
526    }
527
528    fn save_to_data_dir(filename: &str, value: &str) -> Result<()> {
529        let data_dir = atuin_common::utils::data_dir();
530        let data_dir = data_dir.as_path();
531
532        let path = data_dir.join(filename);
533
534        fs_err::write(path, value)?;
535
536        Ok(())
537    }
538
539    fn read_from_data_dir(filename: &str) -> Option<String> {
540        let data_dir = atuin_common::utils::data_dir();
541        let data_dir = data_dir.as_path();
542
543        let path = data_dir.join(filename);
544
545        if !path.exists() {
546            return None;
547        }
548
549        let value = fs_err::read_to_string(path);
550
551        value.ok()
552    }
553
554    fn save_current_time(filename: &str) -> Result<()> {
555        Settings::save_to_data_dir(
556            filename,
557            OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
558        )?;
559
560        Ok(())
561    }
562
563    fn load_time_from_file(filename: &str) -> Result<OffsetDateTime> {
564        let value = Settings::read_from_data_dir(filename);
565
566        match value {
567            Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
568            None => Ok(OffsetDateTime::UNIX_EPOCH),
569        }
570    }
571
572    pub fn save_sync_time() -> Result<()> {
573        Settings::save_current_time(LAST_SYNC_FILENAME)
574    }
575
576    pub fn save_version_check_time() -> Result<()> {
577        Settings::save_current_time(LAST_VERSION_CHECK_FILENAME)
578    }
579
580    pub fn last_sync() -> Result<OffsetDateTime> {
581        Settings::load_time_from_file(LAST_SYNC_FILENAME)
582    }
583
584    pub fn last_version_check() -> Result<OffsetDateTime> {
585        Settings::load_time_from_file(LAST_VERSION_CHECK_FILENAME)
586    }
587
588    pub fn host_id() -> Option<HostId> {
589        let id = Settings::read_from_data_dir(HOST_ID_FILENAME);
590
591        if let Some(id) = id {
592            let parsed =
593                Uuid::from_str(id.as_str()).expect("failed to parse host ID from local directory");
594            return Some(HostId(parsed));
595        }
596
597        let uuid = atuin_common::utils::uuid_v7();
598
599        Settings::save_to_data_dir(HOST_ID_FILENAME, uuid.as_simple().to_string().as_ref())
600            .expect("Could not write host ID to data dir");
601
602        Some(HostId(uuid))
603    }
604
605    pub fn should_sync(&self) -> Result<bool> {
606        if !self.auto_sync || !PathBuf::from(self.session_path.as_str()).exists() {
607            return Ok(false);
608        }
609
610        if self.sync_frequency == "0" {
611            return Ok(true);
612        }
613
614        match parse_duration(self.sync_frequency.as_str()) {
615            Ok(d) => {
616                let d = time::Duration::try_from(d).unwrap();
617                Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d)
618            }
619            Err(e) => Err(eyre!("failed to check sync: {}", e)),
620        }
621    }
622
623    pub fn logged_in(&self) -> bool {
624        let session_path = self.session_path.as_str();
625
626        PathBuf::from(session_path).exists()
627    }
628
629    pub fn session_token(&self) -> Result<String> {
630        if !self.logged_in() {
631            return Err(eyre!("Tried to load session; not logged in"));
632        }
633
634        let session_path = self.session_path.as_str();
635        Ok(fs_err::read_to_string(session_path)?)
636    }
637
638    #[cfg(feature = "check-update")]
639    fn needs_update_check(&self) -> Result<bool> {
640        let last_check = Settings::last_version_check()?;
641        let diff = OffsetDateTime::now_utc() - last_check;
642
643        // Check a max of once per hour
644        Ok(diff.whole_hours() >= 1)
645    }
646
647    #[cfg(feature = "check-update")]
648    async fn latest_version(&self) -> Result<Version> {
649        // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever
650        // suggest upgrading.
651        let current =
652            Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
653
654        if !self.needs_update_check()? {
655            // Worst case, we don't want Atuin to fail to start because something funky is going on with
656            // version checking.
657            let version = tokio::task::spawn_blocking(|| {
658                Settings::read_from_data_dir(LATEST_VERSION_FILENAME)
659            })
660            .await
661            .expect("file task panicked");
662
663            let version = match version {
664                Some(v) => Version::parse(&v).unwrap_or(current),
665                None => current,
666            };
667
668            return Ok(version);
669        }
670
671        #[cfg(feature = "sync")]
672        let latest = crate::api_client::latest_version().await.unwrap_or(current);
673
674        #[cfg(not(feature = "sync"))]
675        let latest = current;
676
677        let latest_encoded = latest.to_string();
678        tokio::task::spawn_blocking(move || {
679            Settings::save_version_check_time()?;
680            Settings::save_to_data_dir(LATEST_VERSION_FILENAME, &latest_encoded)?;
681            Ok::<(), eyre::Report>(())
682        })
683        .await
684        .expect("file task panicked")?;
685
686        Ok(latest)
687    }
688
689    // Return Some(latest version) if an update is needed. Otherwise, none.
690    #[cfg(feature = "check-update")]
691    pub async fn needs_update(&self) -> Option<Version> {
692        if !self.update_check {
693            return None;
694        }
695
696        let current =
697            Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
698
699        let latest = self.latest_version().await;
700
701        if latest.is_err() {
702            return None;
703        }
704
705        let latest = latest.unwrap();
706
707        if latest > current {
708            return Some(latest);
709        }
710
711        None
712    }
713
714    pub fn default_filter_mode(&self) -> FilterMode {
715        self.filter_mode
716            .filter(|x| self.search.filters.contains(x))
717            .or(self.search.filters.first().copied())
718            .unwrap_or(FilterMode::Global)
719    }
720
721    #[cfg(not(feature = "check-update"))]
722    pub async fn needs_update(&self) -> Option<Version> {
723        None
724    }
725
726    pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
727        let data_dir = atuin_common::utils::data_dir();
728        let db_path = data_dir.join("history.db");
729        let record_store_path = data_dir.join("records.db");
730        let socket_path = atuin_common::utils::runtime_dir().join("atuin.sock");
731
732        let key_path = data_dir.join("key");
733        let session_path = data_dir.join("session");
734
735        Ok(Config::builder()
736            .set_default("history_format", "{time}\t{command}\t{duration}")?
737            .set_default("db_path", db_path.to_str())?
738            .set_default("record_store_path", record_store_path.to_str())?
739            .set_default("key_path", key_path.to_str())?
740            .set_default("session_path", session_path.to_str())?
741            .set_default("dialect", "us")?
742            .set_default("timezone", "local")?
743            .set_default("auto_sync", true)?
744            .set_default("update_check", cfg!(feature = "check-update"))?
745            .set_default("sync_address", "https://api.atuin.sh")?
746            .set_default("sync_frequency", "10m")?
747            .set_default("search_mode", "fuzzy")?
748            .set_default("filter_mode", None::<String>)?
749            .set_default("style", "compact")?
750            .set_default("inline_height", 40)?
751            .set_default("show_preview", true)?
752            .set_default("preview.strategy", "auto")?
753            .set_default("max_preview_height", 4)?
754            .set_default("show_help", true)?
755            .set_default("show_tabs", true)?
756            .set_default("auto_hide_height", 8)?
757            .set_default("invert", false)?
758            .set_default("exit_mode", "return-original")?
759            .set_default("word_jump_mode", "emacs")?
760            .set_default(
761                "word_chars",
762                "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
763            )?
764            .set_default("scroll_context_lines", 1)?
765            .set_default("shell_up_key_binding", false)?
766            .set_default("workspaces", false)?
767            .set_default("ctrl_n_shortcuts", false)?
768            .set_default("secrets_filter", true)?
769            .set_default("network_connect_timeout", 5)?
770            .set_default("network_timeout", 30)?
771            .set_default("local_timeout", 2.0)?
772            // enter_accept defaults to false here, but true in the default config file. The dissonance is
773            // intentional!
774            // Existing users will get the default "False", so we don't mess with any potential
775            // muscle memory.
776            // New users will get the new default, that is more similar to what they are used to.
777            .set_default("enter_accept", false)?
778            .set_default("sync.records", true)?
779            .set_default("keys.scroll_exits", true)?
780            .set_default("keys.prefix", "a")?
781            .set_default("keymap_mode", "emacs")?
782            .set_default("keymap_mode_shell", "auto")?
783            .set_default("keymap_cursor", HashMap::<String, String>::new())?
784            .set_default("smart_sort", false)?
785            .set_default("store_failed", true)?
786            .set_default("daemon.sync_frequency", 300)?
787            .set_default("daemon.enabled", false)?
788            .set_default("daemon.socket_path", socket_path.to_str())?
789            .set_default("daemon.systemd_socket", false)?
790            .set_default("daemon.tcp_port", 8889)?
791            .set_default(
792                "search.filters",
793                vec!["global", "host", "session", "workspace", "directory"],
794            )?
795            .set_default("theme.name", "default")?
796            .set_default("theme.debug", None::<bool>)?
797            .set_default(
798                "prefers_reduced_motion",
799                std::env::var("NO_MOTION")
800                    .ok()
801                    .map(|_| config::Value::new(None, config::ValueKind::Boolean(true)))
802                    .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),
803            )?
804            .add_source(
805                Environment::with_prefix("atuin")
806                    .prefix_separator("_")
807                    .separator("__"),
808            ))
809    }
810
811    pub fn new() -> Result<Self> {
812        let config_dir = atuin_common::utils::config_dir();
813        let data_dir = atuin_common::utils::data_dir();
814
815        create_dir_all(&config_dir)
816            .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?;
817
818        create_dir_all(&data_dir).wrap_err_with(|| format!("could not create dir {data_dir:?}"))?;
819
820        let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
821            PathBuf::from(p)
822        } else {
823            let mut config_file = PathBuf::new();
824            config_file.push(config_dir);
825            config_file
826        };
827
828        config_file.push("config.toml");
829
830        let mut config_builder = Self::builder()?;
831
832        config_builder = if config_file.exists() {
833            config_builder.add_source(ConfigFile::new(
834                config_file.to_str().unwrap(),
835                FileFormat::Toml,
836            ))
837        } else {
838            let mut file = File::create(config_file).wrap_err("could not create config file")?;
839            file.write_all(EXAMPLE_CONFIG.as_bytes())
840                .wrap_err("could not write default config file")?;
841
842            config_builder
843        };
844
845        let config = config_builder.build()?;
846        let mut settings: Settings = config
847            .try_deserialize()
848            .map_err(|e| eyre!("failed to deserialize: {}", e))?;
849
850        // all paths should be expanded
851        let db_path = settings.db_path;
852        let db_path = shellexpand::full(&db_path)?;
853        settings.db_path = db_path.to_string();
854
855        let key_path = settings.key_path;
856        let key_path = shellexpand::full(&key_path)?;
857        settings.key_path = key_path.to_string();
858
859        let session_path = settings.session_path;
860        let session_path = shellexpand::full(&session_path)?;
861        settings.session_path = session_path.to_string();
862
863        Ok(settings)
864    }
865
866    pub fn example_config() -> &'static str {
867        EXAMPLE_CONFIG
868    }
869}
870
871impl Default for Settings {
872    fn default() -> Self {
873        // if this panics something is very wrong, as the default config
874        // does not build or deserialize into the settings struct
875        Self::builder()
876            .expect("Could not build default")
877            .build()
878            .expect("Could not build config")
879            .try_deserialize()
880            .expect("Could not deserialize config")
881    }
882}
883
884#[cfg(test)]
885pub(crate) fn test_local_timeout() -> f64 {
886    std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
887        .ok()
888        .and_then(|x| x.parse().ok())
889        // this hardcoded value should be replaced by a simple way to get the
890        // default local_timeout of Settings if possible
891        .unwrap_or(2.0)
892}
893
894#[cfg(test)]
895mod tests {
896    use std::str::FromStr;
897
898    use eyre::Result;
899
900    use super::Timezone;
901
902    #[test]
903    fn can_parse_offset_timezone_spec() -> Result<()> {
904        assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
905        assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
906        assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
907        assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
908
909        // single digit hours are allowed
910        assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
911        assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
912        assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
913        assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
914
915        // fully qualified form
916        assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
917        assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
918
919        // these offsets don't really exist but are supported anyway
920        assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
921        assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
922        assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
923        assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
924
925        // require a leading sign for clarity
926        assert!(Timezone::from_str("5").is_err());
927        assert!(Timezone::from_str("10:30").is_err());
928
929        Ok(())
930    }
931}