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 SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
66 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#[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#[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}
149static 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 if matches!(s.to_lowercase().as_str(), "l" | "local") {
159 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 if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
174 return Ok(Self(offset));
175 }
176
177 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#[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>, #[serde(default = "Stats::common_subcommands_default")]
281 pub common_subcommands: Vec<String>, #[serde(default = "Stats::ignored_commands_default")]
283 pub ignored_commands: Vec<String>, }
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 pub name: String,
359
360 pub debug: Option<bool>,
362
363 pub max_depth: Option<u8>,
365}
366
367#[derive(Clone, Debug, Deserialize, Serialize)]
368pub struct Daemon {
369 #[serde(alias = "enable")]
372 pub enabled: bool,
373
374 pub sync_frequency: u64,
376
377 pub socket_path: String,
379
380 pub systemd_socket: bool,
382
383 pub tcp_port: u64,
385}
386
387#[derive(Clone, Debug, Deserialize, Serialize)]
388pub struct Search {
389 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#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
440pub enum PreviewStrategy {
441 #[serde(rename = "auto")]
443 Auto,
444
445 #[serde(rename = "static")]
447 Static,
448
449 #[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 Ok(diff.whole_hours() >= 1)
669 }
670
671 #[cfg(feature = "check-update")]
672 async fn latest_version(&self) -> Result<Version> {
673 let current =
676 Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
677
678 if !self.needs_update_check()? {
679 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 #[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 .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 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 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 .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 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 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 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 assert!(Timezone::from_str("5").is_err());
978 assert!(Timezone::from_str("10:30").is_err());
979
980 Ok(())
981 }
982}