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