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