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 SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
65 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#[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#[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}
144static 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 if matches!(s.to_lowercase().as_str(), "l" | "local") {
154 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 if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
169 return Ok(Self(offset));
170 }
171
172 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#[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>, #[serde(default = "Stats::common_subcommands_default")]
276 pub common_subcommands: Vec<String>, #[serde(default = "Stats::ignored_commands_default")]
278 pub ignored_commands: Vec<String>, }
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 pub name: String,
350
351 pub debug: Option<bool>,
353
354 pub max_depth: Option<u8>,
356}
357
358#[derive(Clone, Debug, Deserialize, Serialize)]
359pub struct Daemon {
360 #[serde(alias = "enable")]
363 pub enabled: bool,
364
365 pub sync_frequency: u64,
367
368 pub socket_path: String,
370
371 pub systemd_socket: bool,
373
374 pub tcp_port: u64,
376}
377
378#[derive(Clone, Debug, Deserialize, Serialize)]
379pub struct Search {
380 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#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
430pub enum PreviewStrategy {
431 #[serde(rename = "auto")]
433 Auto,
434
435 #[serde(rename = "static")]
437 Static,
438
439 #[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 Ok(diff.whole_hours() >= 1)
653 }
654
655 #[cfg(feature = "check-update")]
656 async fn latest_version(&self) -> Result<Version> {
657 let current =
660 Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
661
662 if !self.needs_update_check()? {
663 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 #[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 .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 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 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 .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 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 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 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 assert!(Timezone::from_str("5").is_err());
946 assert!(Timezone::from_str("10:30").is_err());
947
948 Ok(())
949 }
950}