1use std::{collections::HashMap, fmt, io::prelude::*, path::PathBuf, str::FromStr, sync::OnceLock};
2use tokio::sync::OnceCell;
3
4use atuin_common::record::HostId;
5use atuin_common::utils;
6use clap::ValueEnum;
7use config::{
8 Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState,
9};
10use eyre::{Context, Error, Result, bail, eyre};
11use fs_err::{File, create_dir_all};
12use humantime::parse_duration;
13use regex::RegexSet;
14use semver::Version;
15use serde::{Deserialize, Serialize};
16use serde_with::DeserializeFromStr;
17use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
18
19pub const HISTORY_PAGE_SIZE: i64 = 100;
20static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
21
22static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
23static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new();
24static META_STORE: OnceCell<crate::meta::MetaStore> = OnceCell::const_new();
25
26mod dotfiles;
27mod kv;
28pub(crate) mod meta;
29mod scripts;
30pub mod watcher;
31
32pub struct HubEndpoint(String);
33
34pub const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh";
36
37pub const DEFAULT_HUB_ENDPOINT: &str = "https://hub.atuin.sh";
39
40impl Default for HubEndpoint {
41 fn default() -> Self {
42 HubEndpoint(DEFAULT_HUB_ENDPOINT.to_string())
43 }
44}
45
46impl AsRef<str> for HubEndpoint {
47 fn as_ref(&self) -> &str {
48 &self.0
49 }
50}
51
52#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
53pub enum SearchMode {
54 #[serde(rename = "prefix")]
55 Prefix,
56
57 #[serde(rename = "fulltext")]
58 #[clap(aliases = &["fulltext"])]
59 FullText,
60
61 #[serde(rename = "fuzzy")]
62 Fuzzy,
63
64 #[serde(rename = "skim")]
65 Skim,
66
67 #[serde(rename = "daemon-fuzzy")]
68 #[clap(aliases = &["daemon-fuzzy"])]
69 DaemonFuzzy,
70}
71
72impl SearchMode {
73 pub fn as_str(&self) -> &'static str {
74 match self {
75 SearchMode::Prefix => "PREFIX",
76 SearchMode::FullText => "FULLTXT",
77 SearchMode::Fuzzy => "FUZZY",
78 SearchMode::Skim => "SKIM",
79 SearchMode::DaemonFuzzy => "DAEMON",
80 }
81 }
82 pub fn next(&self, settings: &Settings) -> Self {
83 match self {
84 SearchMode::Prefix => SearchMode::FullText,
85 SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
87 SearchMode::FullText if settings.search_mode == SearchMode::DaemonFuzzy => {
89 SearchMode::DaemonFuzzy
90 }
91 SearchMode::FullText => SearchMode::Fuzzy,
93 SearchMode::Fuzzy | SearchMode::Skim | SearchMode::DaemonFuzzy => SearchMode::Prefix,
94 }
95 }
96}
97
98#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
99pub enum FilterMode {
100 #[serde(rename = "global")]
101 Global = 0,
102
103 #[serde(rename = "host")]
104 Host = 1,
105
106 #[serde(rename = "session")]
107 Session = 2,
108
109 #[serde(rename = "directory")]
110 Directory = 3,
111
112 #[serde(rename = "workspace")]
113 Workspace = 4,
114
115 #[serde(rename = "session-preload")]
116 SessionPreload = 5,
117}
118
119impl FilterMode {
120 pub fn as_str(&self) -> &'static str {
121 match self {
122 FilterMode::Global => "GLOBAL",
123 FilterMode::Host => "HOST",
124 FilterMode::Session => "SESSION",
125 FilterMode::Directory => "DIRECTORY",
126 FilterMode::Workspace => "WORKSPACE",
127 FilterMode::SessionPreload => "SESSION+",
128 }
129 }
130}
131
132#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
133pub enum ExitMode {
134 #[serde(rename = "return-original")]
135 ReturnOriginal,
136
137 #[serde(rename = "return-query")]
138 ReturnQuery,
139}
140
141#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
144pub enum Dialect {
145 #[serde(rename = "us")]
146 Us,
147
148 #[serde(rename = "uk")]
149 Uk,
150}
151
152impl From<Dialect> for interim::Dialect {
153 fn from(d: Dialect) -> interim::Dialect {
154 match d {
155 Dialect::Uk => interim::Dialect::Uk,
156 Dialect::Us => interim::Dialect::Us,
157 }
158 }
159}
160
161#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr, Serialize)]
168pub struct Timezone(pub UtcOffset);
169impl fmt::Display for Timezone {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 self.0.fmt(f)
172 }
173}
174static OFFSET_FMT: &[FormatItem<'_>] = format_description!(
176 "[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]"
177);
178impl FromStr for Timezone {
179 type Err = Error;
180
181 fn from_str(s: &str) -> Result<Self> {
182 if matches!(s.to_lowercase().as_str(), "l" | "local") {
184 let offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
189 return Ok(Self(offset));
190 }
191
192 if matches!(s.to_lowercase().as_str(), "0" | "utc") {
193 let offset = UtcOffset::UTC;
194 return Ok(Self(offset));
195 }
196
197 if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
199 return Ok(Self(offset));
200 }
201
202 bail!(r#""{s}" is not a valid timezone spec"#)
208 }
209}
210
211#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
212pub enum Style {
213 #[serde(rename = "auto")]
214 Auto,
215
216 #[serde(rename = "full")]
217 Full,
218
219 #[serde(rename = "compact")]
220 Compact,
221}
222
223#[derive(Clone, Debug, Deserialize, Copy, Serialize)]
224pub enum WordJumpMode {
225 #[serde(rename = "emacs")]
226 Emacs,
227
228 #[serde(rename = "subl")]
229 Subl,
230}
231
232#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
233pub enum KeymapMode {
234 #[serde(rename = "emacs")]
235 Emacs,
236
237 #[serde(rename = "vim-normal")]
238 VimNormal,
239
240 #[serde(rename = "vim-insert")]
241 VimInsert,
242
243 #[serde(rename = "auto")]
244 Auto,
245}
246
247impl KeymapMode {
248 pub fn as_str(&self) -> &'static str {
249 match self {
250 KeymapMode::Emacs => "EMACS",
251 KeymapMode::VimNormal => "VIMNORMAL",
252 KeymapMode::VimInsert => "VIMINSERT",
253 KeymapMode::Auto => "AUTO",
254 }
255 }
256}
257
258#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
264pub enum CursorStyle {
265 #[serde(rename = "default")]
266 DefaultUserShape,
267
268 #[serde(rename = "blink-block")]
269 BlinkingBlock,
270
271 #[serde(rename = "steady-block")]
272 SteadyBlock,
273
274 #[serde(rename = "blink-underline")]
275 BlinkingUnderScore,
276
277 #[serde(rename = "steady-underline")]
278 SteadyUnderScore,
279
280 #[serde(rename = "blink-bar")]
281 BlinkingBar,
282
283 #[serde(rename = "steady-bar")]
284 SteadyBar,
285}
286
287impl CursorStyle {
288 pub fn as_str(&self) -> &'static str {
289 match self {
290 CursorStyle::DefaultUserShape => "DEFAULT",
291 CursorStyle::BlinkingBlock => "BLINKBLOCK",
292 CursorStyle::SteadyBlock => "STEADYBLOCK",
293 CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE",
294 CursorStyle::SteadyUnderScore => "STEADYUNDERLINE",
295 CursorStyle::BlinkingBar => "BLINKBAR",
296 CursorStyle::SteadyBar => "STEADYBAR",
297 }
298 }
299}
300
301#[derive(Clone, Debug, Deserialize, Serialize)]
302pub struct Stats {
303 #[serde(default = "Stats::common_prefix_default")]
304 pub common_prefix: Vec<String>, #[serde(default = "Stats::common_subcommands_default")]
306 pub common_subcommands: Vec<String>, #[serde(default = "Stats::ignored_commands_default")]
308 pub ignored_commands: Vec<String>, }
310
311impl Stats {
312 fn common_prefix_default() -> Vec<String> {
313 vec!["sudo", "doas"].into_iter().map(String::from).collect()
314 }
315
316 fn common_subcommands_default() -> Vec<String> {
317 vec![
318 "apt",
319 "cargo",
320 "composer",
321 "dnf",
322 "docker",
323 "dotnet",
324 "git",
325 "go",
326 "ip",
327 "jj",
328 "kubectl",
329 "nix",
330 "nmcli",
331 "npm",
332 "pecl",
333 "pnpm",
334 "podman",
335 "port",
336 "systemctl",
337 "tmux",
338 "yarn",
339 ]
340 .into_iter()
341 .map(String::from)
342 .collect()
343 }
344
345 fn ignored_commands_default() -> Vec<String> {
346 vec![]
347 }
348}
349
350impl Default for Stats {
351 fn default() -> Self {
352 Self {
353 common_prefix: Self::common_prefix_default(),
354 common_subcommands: Self::common_subcommands_default(),
355 ignored_commands: Self::ignored_commands_default(),
356 }
357 }
358}
359
360#[derive(Clone, Debug, Deserialize, Default, Serialize)]
361pub struct Sync {
362 pub records: bool,
363}
364
365#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)]
375#[serde(rename_all = "lowercase")]
376pub enum SyncProtocol {
377 Hub,
379 Legacy,
381 #[default]
383 Auto,
384}
385
386#[cfg(feature = "sync")]
392#[derive(Debug, Clone)]
393pub enum SyncAuth {
394 Legacy { token: String },
397 Hub { token: String },
400 HubViaCli { token: String },
405 NotLoggedIn { reason: String },
407}
408
409#[cfg(feature = "sync")]
410impl SyncAuth {
411 pub fn into_auth_token(self) -> Result<crate::api_client::AuthToken> {
415 use crate::api_client::AuthToken;
416 match self {
417 SyncAuth::Legacy { token } => Ok(AuthToken::Token(token)),
418 SyncAuth::Hub { token } => Ok(AuthToken::Bearer(token)),
419 SyncAuth::HubViaCli { token } => Ok(AuthToken::Token(token)),
420 SyncAuth::NotLoggedIn { reason } => Err(eyre!(reason)),
421 }
422 }
423}
424
425#[derive(Clone, Debug, Deserialize, Default, Serialize)]
426pub struct Keys {
427 pub scroll_exits: bool,
428 pub exit_past_line_start: bool,
429 pub accept_past_line_end: bool,
430 pub accept_past_line_start: bool,
431 pub accept_with_backspace: bool,
432 pub prefix: String,
433}
434
435impl Keys {
436 pub fn standard_defaults() -> Self {
439 Keys {
440 scroll_exits: true,
441 exit_past_line_start: true,
442 accept_past_line_end: true,
443 accept_past_line_start: false,
444 accept_with_backspace: false,
445 prefix: "a".to_string(),
446 }
447 }
448
449 pub fn has_non_default_values(&self) -> bool {
451 let d = Self::standard_defaults();
452 self.scroll_exits != d.scroll_exits
453 || self.exit_past_line_start != d.exit_past_line_start
454 || self.accept_past_line_end != d.accept_past_line_end
455 || self.accept_past_line_start != d.accept_past_line_start
456 || self.accept_with_backspace != d.accept_with_backspace
457 || self.prefix != d.prefix
458 }
459}
460
461#[derive(Clone, Debug, Deserialize, Serialize)]
463pub struct KeyRuleConfig {
464 #[serde(default)]
467 pub when: Option<String>,
468 pub action: String,
470}
471
472#[derive(Clone, Debug, Deserialize, Serialize)]
474#[serde(untagged)]
475pub enum KeyBindingConfig {
476 Simple(String),
478 Rules(Vec<KeyRuleConfig>),
480}
481
482#[derive(Clone, Debug, Deserialize, Serialize, Default)]
485pub struct KeymapConfig {
486 #[serde(default)]
487 pub emacs: HashMap<String, KeyBindingConfig>,
488 #[serde(default, rename = "vim-normal")]
489 pub vim_normal: HashMap<String, KeyBindingConfig>,
490 #[serde(default, rename = "vim-insert")]
491 pub vim_insert: HashMap<String, KeyBindingConfig>,
492 #[serde(default)]
493 pub inspector: HashMap<String, KeyBindingConfig>,
494 #[serde(default)]
495 pub prefix: HashMap<String, KeyBindingConfig>,
496}
497
498impl KeymapConfig {
499 pub fn is_empty(&self) -> bool {
501 self.emacs.is_empty()
502 && self.vim_normal.is_empty()
503 && self.vim_insert.is_empty()
504 && self.inspector.is_empty()
505 && self.prefix.is_empty()
506 }
507}
508
509#[derive(Clone, Debug, Deserialize, Serialize)]
510pub struct Preview {
511 pub strategy: PreviewStrategy,
512}
513
514#[derive(Clone, Debug, Deserialize, Serialize)]
515pub struct Theme {
516 pub name: String,
518
519 pub debug: Option<bool>,
521
522 pub max_depth: Option<u8>,
524}
525
526#[derive(Clone, Debug, Deserialize, Serialize)]
527pub struct Daemon {
528 #[serde(alias = "enable")]
531 pub enabled: bool,
532
533 pub autostart: bool,
535
536 pub sync_frequency: u64,
538
539 pub socket_path: String,
541
542 pub pidfile_path: String,
544
545 pub systemd_socket: bool,
547
548 pub tcp_port: u64,
550}
551
552#[derive(Clone, Debug, Deserialize, Serialize)]
553pub struct Search {
554 pub filters: Vec<FilterMode>,
556
557 pub recency_score_multiplier: f64,
560
561 pub frequency_score_multiplier: f64,
564
565 pub frecency_score_multiplier: f64,
568}
569
570#[derive(Clone, Debug, Deserialize, Serialize)]
571pub struct Tmux {
572 pub enabled: bool,
574
575 pub width: String,
577
578 pub height: String,
580}
581
582#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
584#[serde(rename_all = "lowercase")]
585pub enum LogLevel {
586 Trace,
587 Debug,
588 #[default]
589 Info,
590 Warn,
591 Error,
592}
593
594impl LogLevel {
595 pub fn as_directive(&self) -> &'static str {
597 match self {
598 LogLevel::Trace => "trace",
599 LogLevel::Debug => "debug",
600 LogLevel::Info => "info",
601 LogLevel::Warn => "warn",
602 LogLevel::Error => "error",
603 }
604 }
605}
606
607#[derive(Clone, Debug, Default, Deserialize, Serialize)]
609pub struct LogConfig {
610 pub file: String,
612
613 pub enabled: Option<bool>,
615
616 pub level: Option<LogLevel>,
618
619 pub retention: Option<u64>,
621}
622
623#[derive(Clone, Debug, Deserialize, Serialize)]
624pub struct Logs {
625 #[serde(default = "Logs::default_enabled")]
627 pub enabled: bool,
628
629 pub dir: String,
631
632 #[serde(default)]
635 pub level: LogLevel,
636
637 #[serde(default = "Logs::default_retention")]
639 pub retention: u64,
640
641 #[serde(default)]
643 pub search: LogConfig,
644
645 #[serde(default)]
647 pub daemon: LogConfig,
648
649 #[serde(default)]
651 pub ai: LogConfig,
652}
653
654#[derive(Default, Clone, Debug, Deserialize, Serialize)]
655pub struct Ai {
656 pub enabled: Option<bool>,
658
659 pub endpoint: Option<String>,
662
663 pub api_token: Option<String>,
666
667 pub db_path: String,
669
670 pub session_continue_minutes: i64,
672
673 #[serde(default)]
675 pub send_cwd: Option<bool>,
676
677 #[serde(default)]
679 pub opening: AiOpening,
680
681 #[serde(default)]
683 pub capabilities: AiCapabilities,
684}
685
686#[derive(Default, Clone, Debug, Deserialize, Serialize)]
687pub struct AiCapabilities {
688 pub enable_history_search: Option<bool>,
690 pub enable_file_tools: Option<bool>,
692 pub enable_command_execution: Option<bool>,
694}
695
696#[derive(Default, Clone, Debug, Deserialize, Serialize)]
697pub struct AiOpening {
698 pub send_cwd: Option<bool>,
700
701 pub send_last_command: Option<bool>,
703}
704
705impl Default for Preview {
706 fn default() -> Self {
707 Self {
708 strategy: PreviewStrategy::Auto,
709 }
710 }
711}
712
713impl Default for Theme {
714 fn default() -> Self {
715 Self {
716 name: "".to_string(),
717 debug: None::<bool>,
718 max_depth: Some(10),
719 }
720 }
721}
722
723impl Default for Daemon {
724 fn default() -> Self {
725 Self {
726 enabled: false,
727 autostart: false,
728 sync_frequency: 300,
729 socket_path: "".to_string(),
730 pidfile_path: "".to_string(),
731 systemd_socket: false,
732 tcp_port: 8889,
733 }
734 }
735}
736
737impl Default for Logs {
738 fn default() -> Self {
739 Self {
740 enabled: true,
741 dir: "".to_string(),
742 level: LogLevel::default(),
743 retention: Self::default_retention(),
744 search: LogConfig {
745 file: "search.log".to_string(),
746 ..Default::default()
747 },
748 daemon: LogConfig {
749 file: "daemon.log".to_string(),
750 ..Default::default()
751 },
752 ai: LogConfig {
753 file: "ai.log".to_string(),
754 ..Default::default()
755 },
756 }
757 }
758}
759
760impl Logs {
761 fn default_enabled() -> bool {
762 true
763 }
764
765 fn default_retention() -> u64 {
766 4
767 }
768
769 pub fn search_enabled(&self) -> bool {
772 self.search.enabled.unwrap_or(self.enabled)
773 }
774
775 pub fn daemon_enabled(&self) -> bool {
778 self.daemon.enabled.unwrap_or(self.enabled)
779 }
780
781 pub fn ai_enabled(&self) -> bool {
784 self.ai.enabled.unwrap_or(self.enabled)
785 }
786
787 pub fn search_level(&self) -> LogLevel {
790 self.search.level.unwrap_or(self.level)
791 }
792
793 pub fn daemon_level(&self) -> LogLevel {
796 self.daemon.level.unwrap_or(self.level)
797 }
798
799 pub fn ai_level(&self) -> LogLevel {
802 self.ai.level.unwrap_or(self.level)
803 }
804
805 pub fn search_retention(&self) -> u64 {
808 self.search.retention.unwrap_or(self.retention)
809 }
810
811 pub fn daemon_retention(&self) -> u64 {
814 self.daemon.retention.unwrap_or(self.retention)
815 }
816
817 pub fn ai_retention(&self) -> u64 {
820 self.ai.retention.unwrap_or(self.retention)
821 }
822
823 pub fn search_path(&self) -> PathBuf {
825 let path = PathBuf::from(&self.search.file);
826 PathBuf::from(&self.dir).join(path)
827 }
828
829 pub fn daemon_path(&self) -> PathBuf {
831 let path = PathBuf::from(&self.daemon.file);
832 PathBuf::from(&self.dir).join(path)
833 }
834
835 pub fn ai_path(&self) -> PathBuf {
837 let path = PathBuf::from(&self.ai.file);
838 PathBuf::from(&self.dir).join(path)
839 }
840}
841
842impl Default for Search {
843 fn default() -> Self {
844 Self {
845 filters: vec![
846 FilterMode::Global,
847 FilterMode::Host,
848 FilterMode::Session,
849 FilterMode::SessionPreload,
850 FilterMode::Workspace,
851 FilterMode::Directory,
852 ],
853
854 recency_score_multiplier: 1.0,
855 frequency_score_multiplier: 1.0,
856 frecency_score_multiplier: 1.0,
857 }
858 }
859}
860
861impl Default for Tmux {
862 fn default() -> Self {
863 Self {
864 enabled: false,
865 width: "80%".to_string(),
866 height: "60%".to_string(),
867 }
868 }
869}
870
871#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
873pub enum PreviewStrategy {
874 #[serde(rename = "auto")]
876 Auto,
877
878 #[serde(rename = "static")]
880 Static,
881
882 #[serde(rename = "fixed")]
884 Fixed,
885}
886
887#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
889#[serde(rename_all = "lowercase")]
890pub enum UiColumnType {
891 Duration,
893 Time,
895 Datetime,
897 Directory,
899 Host,
901 User,
903 Exit,
905 Command,
907}
908
909impl UiColumnType {
910 pub fn default_width(&self) -> u16 {
913 match self {
914 UiColumnType::Duration => 5, UiColumnType::Time => 9, UiColumnType::Datetime => 16, UiColumnType::Directory => 20,
918 UiColumnType::Host => 15,
919 UiColumnType::User => 10,
920 UiColumnType::Exit => {
921 if cfg!(windows) {
922 11 } else {
924 3 }
926 }
927 UiColumnType::Command => 0, }
929 }
930}
931
932#[derive(Clone, Debug, Serialize)]
935pub struct UiColumn {
936 pub column_type: UiColumnType,
937 pub width: u16,
938 pub expand: bool,
940}
941
942impl UiColumn {
943 pub fn new(column_type: UiColumnType) -> Self {
944 Self {
945 width: column_type.default_width(),
946 expand: column_type == UiColumnType::Command,
947 column_type,
948 }
949 }
950
951 pub fn with_width(column_type: UiColumnType, width: u16) -> Self {
952 Self {
953 column_type,
954 width,
955 expand: column_type == UiColumnType::Command,
956 }
957 }
958}
959
960impl<'de> serde::Deserialize<'de> for UiColumn {
963 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
964 where
965 D: serde::Deserializer<'de>,
966 {
967 use serde::de::{self, MapAccess, Visitor};
968
969 struct UiColumnVisitor;
970
971 impl<'de> Visitor<'de> for UiColumnVisitor {
972 type Value = UiColumn;
973
974 fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
975 formatter.write_str(
976 "a column type string or an object with 'type' and optional 'width'/'expand'",
977 )
978 }
979
980 fn visit_str<E>(self, value: &str) -> Result<UiColumn, E>
981 where
982 E: de::Error,
983 {
984 let column_type: UiColumnType =
985 serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(value))?;
986 Ok(UiColumn::new(column_type))
987 }
988
989 fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error>
990 where
991 M: MapAccess<'de>,
992 {
993 let mut column_type: Option<UiColumnType> = None;
994 let mut width: Option<u16> = None;
995 let mut expand: Option<bool> = None;
996
997 while let Some(key) = map.next_key::<String>()? {
998 match key.as_str() {
999 "type" => {
1000 column_type = Some(map.next_value()?);
1001 }
1002 "width" => {
1003 width = Some(map.next_value()?);
1004 }
1005 "expand" => {
1006 expand = Some(map.next_value()?);
1007 }
1008 _ => {
1009 let _: serde::de::IgnoredAny = map.next_value()?;
1010 }
1011 }
1012 }
1013
1014 let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?;
1015 let width = width.unwrap_or_else(|| column_type.default_width());
1016 let expand = expand.unwrap_or(column_type == UiColumnType::Command);
1017 Ok(UiColumn {
1018 column_type,
1019 width,
1020 expand,
1021 })
1022 }
1023 }
1024
1025 deserializer.deserialize_any(UiColumnVisitor)
1026 }
1027}
1028
1029#[derive(Clone, Debug, Deserialize, Serialize)]
1031pub struct Ui {
1032 #[serde(default = "Ui::default_columns")]
1037 pub columns: Vec<UiColumn>,
1038}
1039
1040impl Ui {
1041 fn default_columns() -> Vec<UiColumn> {
1042 vec![
1043 UiColumn::new(UiColumnType::Duration),
1044 UiColumn::new(UiColumnType::Time),
1045 UiColumn::new(UiColumnType::Command),
1046 ]
1047 }
1048
1049 pub fn validate(&self) -> Result<()> {
1052 let expand_count = self.columns.iter().filter(|c| c.expand).count();
1053 if expand_count > 1 {
1054 bail!(
1055 "Only one column can have expand = true, but {} columns are set to expand",
1056 expand_count
1057 );
1058 }
1059 Ok(())
1060 }
1061}
1062
1063impl Default for Ui {
1064 fn default() -> Self {
1065 Self {
1066 columns: Self::default_columns(),
1067 }
1068 }
1069}
1070
1071#[derive(Clone, Debug, Deserialize, Serialize)]
1072pub struct Settings {
1073 pub data_dir: Option<String>,
1074 pub dialect: Dialect,
1075 pub timezone: Timezone,
1076 pub style: Style,
1077 pub auto_sync: bool,
1078 pub update_check: bool,
1079
1080 pub sync_address: String,
1082
1083 #[serde(default)]
1087 pub sync_protocol: SyncProtocol,
1088
1089 pub sync_frequency: String,
1090 pub db_path: String,
1091 pub record_store_path: String,
1092 pub key_path: String,
1093 pub search_mode: SearchMode,
1094 pub filter_mode: Option<FilterMode>,
1095 pub filter_mode_shell_up_key_binding: Option<FilterMode>,
1096 pub search_mode_shell_up_key_binding: Option<SearchMode>,
1097 pub shell_up_key_binding: bool,
1098 pub inline_height: u16,
1099 pub inline_height_shell_up_key_binding: Option<u16>,
1100 pub invert: bool,
1101 pub show_preview: bool,
1102 pub max_preview_height: u16,
1103 pub show_help: bool,
1104 pub show_tabs: bool,
1105 pub show_numeric_shortcuts: bool,
1106 pub auto_hide_height: u16,
1107 pub exit_mode: ExitMode,
1108 pub keymap_mode: KeymapMode,
1109 pub keymap_mode_shell: KeymapMode,
1110 pub keymap_cursor: HashMap<String, CursorStyle>,
1111 pub word_jump_mode: WordJumpMode,
1112 pub word_chars: String,
1113 pub scroll_context_lines: usize,
1114 pub history_format: String,
1115 pub strip_trailing_whitespace: bool,
1116 pub prefers_reduced_motion: bool,
1117 pub store_failed: bool,
1118 pub no_mouse: bool,
1119
1120 #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
1121 pub history_filter: RegexSet,
1122
1123 #[serde(with = "serde_regex", default = "RegexSet::empty", skip_serializing)]
1124 pub cwd_filter: RegexSet,
1125
1126 pub secrets_filter: bool,
1127 pub workspaces: bool,
1128 pub ctrl_n_shortcuts: bool,
1129
1130 pub network_connect_timeout: u64,
1131 pub network_timeout: u64,
1132 pub local_timeout: f64,
1133 pub enter_accept: bool,
1134 pub smart_sort: bool,
1135 pub command_chaining: bool,
1136
1137 #[serde(default)]
1138 pub stats: Stats,
1139
1140 #[serde(default)]
1141 pub sync: Sync,
1142
1143 #[serde(default)]
1144 pub keys: Keys,
1145
1146 #[serde(default)]
1147 pub keymap: KeymapConfig,
1148
1149 #[serde(default)]
1150 pub preview: Preview,
1151
1152 #[serde(default)]
1153 pub dotfiles: dotfiles::Settings,
1154
1155 #[serde(default)]
1156 pub daemon: Daemon,
1157
1158 #[serde(default)]
1159 pub search: Search,
1160
1161 #[serde(default)]
1162 pub theme: Theme,
1163
1164 #[serde(default)]
1165 pub ui: Ui,
1166
1167 #[serde(default)]
1168 pub scripts: scripts::Settings,
1169
1170 #[serde(default)]
1171 pub kv: kv::Settings,
1172
1173 #[serde(default)]
1174 pub tmux: Tmux,
1175
1176 #[serde(default)]
1177 pub logs: Logs,
1178
1179 #[serde(default)]
1180 pub meta: meta::Settings,
1181
1182 #[serde(default)]
1183 pub ai: Ai,
1184}
1185
1186impl Settings {
1187 pub fn utc() -> Self {
1188 Self::builder()
1189 .expect("Could not build default")
1190 .set_override("timezone", "0")
1191 .expect("failed to override timezone with UTC")
1192 .build()
1193 .expect("Could not build config")
1194 .try_deserialize()
1195 .expect("Could not deserialize config")
1196 }
1197
1198 pub(crate) fn effective_data_dir() -> PathBuf {
1199 DATA_DIR
1200 .get()
1201 .cloned()
1202 .unwrap_or_else(atuin_common::utils::data_dir)
1203 }
1204
1205 pub async fn meta_store() -> Result<&'static crate::meta::MetaStore> {
1208 META_STORE
1209 .get_or_try_init(|| async {
1210 let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| {
1211 eyre!("meta store config not set — Settings::new() has not been called")
1212 })?;
1213 crate::meta::MetaStore::new(db_path, *timeout).await
1214 })
1215 .await
1216 }
1217
1218 pub async fn host_id() -> Result<HostId> {
1219 Self::meta_store().await?.host_id().await
1220 }
1221
1222 pub async fn last_sync() -> Result<OffsetDateTime> {
1223 Self::meta_store().await?.last_sync().await
1224 }
1225
1226 pub async fn save_sync_time() -> Result<()> {
1227 Self::meta_store().await?.save_sync_time().await
1228 }
1229
1230 pub async fn last_version_check() -> Result<OffsetDateTime> {
1231 Self::meta_store().await?.last_version_check().await
1232 }
1233
1234 pub async fn save_version_check_time() -> Result<()> {
1235 Self::meta_store().await?.save_version_check_time().await
1236 }
1237
1238 pub async fn should_sync(&self) -> Result<bool> {
1239 if !self.auto_sync || !Self::meta_store().await?.logged_in().await? {
1240 return Ok(false);
1241 }
1242
1243 if self.sync_frequency == "0" {
1244 return Ok(true);
1245 }
1246
1247 match parse_duration(self.sync_frequency.as_str()) {
1248 Ok(d) => {
1249 let d = time::Duration::try_from(d)?;
1250 Ok(OffsetDateTime::now_utc() - Settings::last_sync().await? >= d)
1251 }
1252 Err(e) => Err(eyre!("failed to check sync: {}", e)),
1253 }
1254 }
1255
1256 pub async fn logged_in(&self) -> Result<bool> {
1257 Self::meta_store().await?.logged_in().await
1258 }
1259
1260 pub async fn session_token(&self) -> Result<String> {
1261 match Self::meta_store().await?.session_token().await? {
1262 Some(token) => Ok(token),
1263 None => Err(eyre!("Tried to load session; not logged in")),
1264 }
1265 }
1266
1267 pub async fn hub_session_token(&self) -> Result<String> {
1268 match Self::meta_store().await?.hub_session_token().await? {
1269 Some(token) => Ok(token),
1270 None => Err(eyre!("Tried to load hub session; not logged in")),
1271 }
1272 }
1273
1274 fn normalize_url(url: &str) -> &str {
1276 url.trim_end_matches('/')
1277 }
1278
1279 fn is_official_address(url: &str) -> bool {
1281 let normalized = Self::normalize_url(url);
1282 normalized == Self::normalize_url(DEFAULT_SYNC_ADDRESS)
1283 || normalized == Self::normalize_url(DEFAULT_HUB_ENDPOINT)
1284 }
1285
1286 pub fn is_hub_sync(&self) -> bool {
1293 match self.sync_protocol {
1294 SyncProtocol::Hub => true,
1295 SyncProtocol::Legacy => false,
1296 SyncProtocol::Auto => Self::is_official_address(&self.sync_address),
1297 }
1298 }
1299
1300 pub fn active_hub_endpoint(&self) -> Option<HubEndpoint> {
1306 if self.is_hub_sync() {
1307 if Self::is_official_address(&self.sync_address) {
1308 Some(HubEndpoint::default())
1309 } else {
1310 Some(HubEndpoint(self.sync_address.clone()))
1311 }
1312 } else {
1313 None
1314 }
1315 }
1316
1317 #[cfg(feature = "sync")]
1321 pub async fn resolve_sync_auth(&self) -> SyncAuth {
1322 let meta = match Self::meta_store().await {
1323 Ok(m) => m,
1324 Err(e) => {
1325 return SyncAuth::NotLoggedIn {
1326 reason: format!("Failed to open meta store: {e}"),
1327 };
1328 }
1329 };
1330
1331 if !self.is_hub_sync() {
1332 return match meta.session_token().await {
1334 Ok(Some(token)) => SyncAuth::Legacy { token },
1335 _ => SyncAuth::NotLoggedIn {
1336 reason: "Not logged in. Run 'atuin login' to authenticate \
1337 with your sync server."
1338 .into(),
1339 },
1340 };
1341 }
1342
1343 if let Ok(Some(hub_token)) = meta.hub_session_token().await {
1345 if hub_token.starts_with("atapi_") {
1346 return SyncAuth::Hub { token: hub_token };
1347 }
1348
1349 if let Ok(None) = meta.session_token().await {
1354 if meta.save_session(&hub_token).await.is_ok() {
1355 let _ = meta.delete_hub_session().await;
1356 }
1357 } else {
1358 let _ = meta.delete_hub_session().await;
1360 }
1361 }
1363
1364 match meta.session_token().await {
1366 Ok(Some(token)) => SyncAuth::HubViaCli { token },
1367 _ => SyncAuth::NotLoggedIn {
1368 reason: "Not logged in. Run 'atuin login' or 'atuin register' \
1369 to authenticate."
1370 .into(),
1371 },
1372 }
1373 }
1374
1375 #[cfg(feature = "sync")]
1381 pub async fn sync_auth_token(&self) -> Result<crate::api_client::AuthToken> {
1382 self.resolve_sync_auth().await.into_auth_token()
1383 }
1384
1385 #[cfg(feature = "check-update")]
1386 async fn needs_update_check(&self) -> Result<bool> {
1387 let last_check = Settings::last_version_check().await?;
1388 let diff = OffsetDateTime::now_utc() - last_check;
1389
1390 Ok(diff.whole_hours() >= 1)
1392 }
1393
1394 #[cfg(feature = "check-update")]
1395 async fn latest_version(&self) -> Result<Version> {
1396 let current =
1399 Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
1400
1401 if !self.needs_update_check().await? {
1402 let meta = Self::meta_store().await?;
1403 let version = match meta.latest_version().await? {
1404 Some(v) => Version::parse(&v).unwrap_or(current),
1405 None => current,
1406 };
1407
1408 return Ok(version);
1409 }
1410
1411 #[cfg(feature = "sync")]
1412 let latest = crate::api_client::latest_version().await.unwrap_or(current);
1413
1414 #[cfg(not(feature = "sync"))]
1415 let latest = current;
1416
1417 let meta = Self::meta_store().await?;
1418 Settings::save_version_check_time().await?;
1419 meta.save_latest_version(&latest.to_string()).await?;
1420
1421 Ok(latest)
1422 }
1423
1424 #[cfg(feature = "check-update")]
1426 pub async fn needs_update(&self) -> Option<Version> {
1427 if !self.update_check {
1428 return None;
1429 }
1430
1431 let current =
1432 Version::parse(env!("CARGO_PKG_VERSION")).unwrap_or(Version::new(100000, 0, 0));
1433
1434 let latest = self.latest_version().await;
1435
1436 if latest.is_err() {
1437 return None;
1438 }
1439
1440 let latest = latest.unwrap();
1441
1442 if latest > current {
1443 return Some(latest);
1444 }
1445
1446 None
1447 }
1448
1449 pub fn default_filter_mode(&self, git_root: bool) -> FilterMode {
1450 self.filter_mode
1451 .filter(|x| self.search.filters.contains(x))
1452 .or_else(|| {
1453 self.search
1454 .filters
1455 .iter()
1456 .find(|x| match (x, git_root, self.workspaces) {
1457 (FilterMode::Workspace, true, true) => true,
1458 (FilterMode::Workspace, _, _) => false,
1459 (_, _, _) => true,
1460 })
1461 .copied()
1462 })
1463 .unwrap_or(FilterMode::Global)
1464 }
1465
1466 #[cfg(not(feature = "check-update"))]
1467 pub async fn needs_update(&self) -> Option<Version> {
1468 None
1469 }
1470
1471 pub fn builder() -> Result<ConfigBuilder<DefaultState>> {
1472 Self::builder_with_data_dir(&atuin_common::utils::data_dir())
1473 }
1474
1475 fn builder_with_data_dir(data_dir: &std::path::Path) -> Result<ConfigBuilder<DefaultState>> {
1476 let db_path = data_dir.join("history.db");
1477 let record_store_path = data_dir.join("records.db");
1478 let kv_path = data_dir.join("kv.db");
1479 let scripts_path = data_dir.join("scripts.db");
1480 let ai_sessions_path = data_dir.join("ai_sessions.db");
1481 let socket_path = atuin_common::utils::runtime_dir().join("atuin.sock");
1482 let pidfile_path = data_dir.join("atuin-daemon.pid");
1483 let logs_dir = atuin_common::utils::logs_dir();
1484
1485 let key_path = data_dir.join("key");
1486 let meta_path = data_dir.join("meta.db");
1487
1488 Ok(Config::builder()
1489 .set_default("history_format", "{time}\t{command}\t{duration}")?
1490 .set_default("db_path", db_path.to_str())?
1491 .set_default("record_store_path", record_store_path.to_str())?
1492 .set_default("key_path", key_path.to_str())?
1493 .set_default("dialect", "us")?
1494 .set_default("timezone", "local")?
1495 .set_default("auto_sync", true)?
1496 .set_default("update_check", cfg!(feature = "check-update"))?
1497 .set_default("sync_address", "https://api.atuin.sh")?
1498 .set_default("sync_frequency", "5m")?
1499 .set_default("search_mode", "fuzzy")?
1500 .set_default("filter_mode", None::<String>)?
1501 .set_default("style", "compact")?
1502 .set_default("inline_height", 40)?
1503 .set_default("show_preview", true)?
1504 .set_default("preview.strategy", "auto")?
1505 .set_default("max_preview_height", 4)?
1506 .set_default("show_help", true)?
1507 .set_default("show_tabs", true)?
1508 .set_default("show_numeric_shortcuts", true)?
1509 .set_default("auto_hide_height", 8)?
1510 .set_default("invert", false)?
1511 .set_default("exit_mode", "return-original")?
1512 .set_default("word_jump_mode", "emacs")?
1513 .set_default(
1514 "word_chars",
1515 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
1516 )?
1517 .set_default("scroll_context_lines", 1)?
1518 .set_default("shell_up_key_binding", false)?
1519 .set_default("workspaces", false)?
1520 .set_default("ctrl_n_shortcuts", false)?
1521 .set_default("secrets_filter", true)?
1522 .set_default("strip_trailing_whitespace", true)?
1523 .set_default("network_connect_timeout", 5)?
1524 .set_default("network_timeout", 30)?
1525 .set_default("local_timeout", 2.0)?
1526 .set_default("enter_accept", false)?
1532 .set_default("sync.records", true)?
1533 .set_default("keys.scroll_exits", true)?
1534 .set_default("keys.accept_past_line_end", true)?
1535 .set_default("keys.exit_past_line_start", true)?
1536 .set_default("keys.accept_past_line_start", false)?
1537 .set_default("keys.accept_with_backspace", false)?
1538 .set_default("keys.prefix", "a")?
1539 .set_default("keymap_mode", "emacs")?
1540 .set_default("keymap_mode_shell", "auto")?
1541 .set_default("keymap_cursor", HashMap::<String, String>::new())?
1542 .set_default("smart_sort", false)?
1543 .set_default("command_chaining", false)?
1544 .set_default("store_failed", true)?
1545 .set_default("daemon.sync_frequency", 300)?
1546 .set_default("daemon.enabled", false)?
1547 .set_default("daemon.autostart", false)?
1548 .set_default("daemon.socket_path", socket_path.to_str())?
1549 .set_default("daemon.pidfile_path", pidfile_path.to_str())?
1550 .set_default("daemon.systemd_socket", false)?
1551 .set_default("daemon.tcp_port", 8889)?
1552 .set_default("logs.enabled", true)?
1553 .set_default("logs.dir", logs_dir.to_str())?
1554 .set_default("logs.level", "info")?
1555 .set_default("logs.search.file", "search.log")?
1556 .set_default("logs.daemon.file", "daemon.log")?
1557 .set_default("logs.ai.file", "ai.log")?
1558 .set_default("kv.db_path", kv_path.to_str())?
1559 .set_default("scripts.db_path", scripts_path.to_str())?
1560 .set_default("search.recency_score_multiplier", 1.0)?
1561 .set_default("search.frequency_score_multiplier", 1.0)?
1562 .set_default("search.frecency_score_multiplier", 1.0)?
1563 .set_default("meta.db_path", meta_path.to_str())?
1564 .set_default("ai.db_path", ai_sessions_path.to_str())?
1565 .set_default("ai.session_continue_minutes", 60)?
1566 .set_default("ai.send_cwd", false)?
1567 .set_default("ai.opening.send_cwd", false)?
1568 .set_default("ai.opening.send_last_command", false)?
1569 .set_default(
1570 "search.filters",
1571 vec![
1572 "global",
1573 "host",
1574 "session",
1575 "workspace",
1576 "directory",
1577 "session-preload",
1578 ],
1579 )?
1580 .set_default("theme.name", "default")?
1581 .set_default("theme.debug", None::<bool>)?
1582 .set_default("tmux.enabled", false)?
1583 .set_default("tmux.width", "80%")?
1584 .set_default("tmux.height", "60%")?
1585 .set_default(
1586 "prefers_reduced_motion",
1587 std::env::var("NO_MOTION")
1588 .ok()
1589 .map(|_| config::Value::new(None, config::ValueKind::Boolean(true)))
1590 .unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),
1591 )?
1592 .set_default("no_mouse", false)?
1593 .add_source(
1594 Environment::with_prefix("atuin")
1595 .prefix_separator("_")
1596 .separator("__"),
1597 ))
1598 }
1599
1600 pub fn get_config_path() -> Result<PathBuf> {
1601 let config_dir = atuin_common::utils::config_dir();
1602
1603 create_dir_all(&config_dir)
1604 .wrap_err_with(|| format!("could not create dir {config_dir:?}"))?;
1605
1606 let mut config_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
1607 PathBuf::from(p)
1608 } else {
1609 let mut config_file = PathBuf::new();
1610 config_file.push(config_dir);
1611 config_file
1612 };
1613
1614 config_file.push("config.toml");
1615
1616 Ok(config_file)
1617 }
1618
1619 fn build_config() -> Result<Config> {
1625 let config_file = Self::get_config_path()?;
1626
1627 let effective_data_dir = if config_file.exists() {
1629 #[derive(Deserialize, Default)]
1630 struct DataDirOnly {
1631 data_dir: Option<String>,
1632 }
1633
1634 let config_file_str = config_file
1635 .to_str()
1636 .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
1637
1638 let partial_config = Config::builder()
1639 .add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
1640 .add_source(
1641 Environment::with_prefix("atuin")
1642 .prefix_separator("_")
1643 .separator("__"),
1644 )
1645 .build()
1646 .ok();
1647
1648 let custom_data_dir = partial_config
1649 .and_then(|c| c.try_deserialize::<DataDirOnly>().ok())
1650 .and_then(|d| d.data_dir);
1651
1652 match custom_data_dir {
1653 Some(dir) => {
1654 let expanded = shellexpand::full(&dir)
1655 .map_err(|e| eyre!("failed to expand data_dir path: {}", e))?;
1656 PathBuf::from(expanded.as_ref())
1657 }
1658 None => atuin_common::utils::data_dir(),
1659 }
1660 } else {
1661 atuin_common::utils::data_dir()
1662 };
1663
1664 DATA_DIR.set(effective_data_dir.clone()).ok();
1665
1666 create_dir_all(&effective_data_dir)
1667 .wrap_err_with(|| format!("could not create dir {effective_data_dir:?}"))?;
1668
1669 let mut config_builder = Self::builder_with_data_dir(&effective_data_dir)?;
1670
1671 config_builder = if config_file.exists() {
1672 let config_file_str = config_file
1673 .to_str()
1674 .ok_or_else(|| eyre!("config file path is not valid UTF-8"))?;
1675 config_builder.add_source(ConfigFile::new(config_file_str, FileFormat::Toml))
1676 } else {
1677 let mut file = File::create(config_file).wrap_err("could not create config file")?;
1678 file.write_all(EXAMPLE_CONFIG.as_bytes())
1679 .wrap_err("could not write default config file")?;
1680
1681 config_builder
1682 };
1683
1684 let built = config_builder.build_cloned()?;
1686 config_builder = [
1687 "db_path",
1688 "record_store_path",
1689 "key_path",
1690 "daemon.socket_path",
1691 "daemon.pidfile_path",
1692 "logs.dir",
1693 "logs.search.file",
1694 "logs.daemon.file",
1695 ]
1696 .iter()
1697 .map(|key| (key, built.get_string(key).unwrap_or_default()))
1698 .filter_map(|(key, value)| match Self::expand_path(value) {
1699 Ok(expanded) => Some((key, expanded)),
1700 Err(e) => {
1701 log::warn!("failed to expand path for {key}: {e}");
1702 None
1703 }
1704 })
1705 .fold(config_builder, |builder, (key, value)| {
1706 builder
1707 .set_override(key, value)
1708 .unwrap_or_else(|_| panic!("failed to set absolute path override for {key}"))
1709 });
1710
1711 config_builder.build().map_err(Into::into)
1712 }
1713
1714 pub fn get_config_value(key: &str) -> Result<String> {
1720 let config = Self::build_config()?;
1721 let value: config::Value = config
1722 .get(key)
1723 .map_err(|e| eyre!("failed to get config value '{}': {}", key, e))?;
1724 Ok(Self::format_resolved_value(&value, key))
1725 }
1726
1727 fn format_resolved_value(value: &config::Value, prefix: &str) -> String {
1728 use config::ValueKind;
1729
1730 match &value.kind {
1731 ValueKind::Nil => String::new(),
1732 ValueKind::Boolean(b) => b.to_string(),
1733 ValueKind::I64(i) => i.to_string(),
1734 ValueKind::I128(i) => i.to_string(),
1735 ValueKind::U64(u) => u.to_string(),
1736 ValueKind::U128(u) => u.to_string(),
1737 ValueKind::Float(f) => f.to_string(),
1738 ValueKind::String(s) => s.clone(),
1739 ValueKind::Array(arr) => {
1740 let items: Vec<String> = arr
1741 .iter()
1742 .map(|v| Self::format_resolved_value(v, ""))
1743 .collect();
1744 format!("[{}]", items.join(", "))
1745 }
1746 ValueKind::Table(map) => {
1747 let mut lines = Vec::new();
1748 let mut keys: Vec<_> = map.keys().collect();
1749 keys.sort();
1750
1751 for k in keys {
1752 let v = &map[k];
1753 let full_key = if prefix.is_empty() {
1754 k.clone()
1755 } else {
1756 format!("{}.{}", prefix, k)
1757 };
1758
1759 match &v.kind {
1760 ValueKind::Table(_) => {
1761 lines.push(Self::format_resolved_value(v, &full_key));
1762 }
1763 _ => {
1764 lines.push(format!(
1765 "{} = {}",
1766 full_key,
1767 Self::format_resolved_value(v, "")
1768 ));
1769 }
1770 }
1771 }
1772
1773 lines.join("\n")
1774 }
1775 }
1776 }
1777
1778 pub fn new() -> Result<Self> {
1779 let config = Self::build_config()?;
1780 let settings: Settings = config
1781 .try_deserialize()
1782 .map_err(|e| eyre!("failed to deserialize: {}", e))?;
1783
1784 settings.ui.validate()?;
1786
1787 META_CONFIG
1789 .set((settings.meta.db_path.clone(), settings.local_timeout))
1790 .ok();
1791
1792 Ok(settings)
1793 }
1794
1795 fn expand_path(path: String) -> Result<String> {
1796 shellexpand::full(&path)
1797 .map(|p| p.to_string())
1798 .map_err(|e| eyre!("failed to expand path: {}", e))
1799 }
1800
1801 pub fn example_config() -> &'static str {
1802 EXAMPLE_CONFIG
1803 }
1804
1805 pub fn paths_ok(&self) -> bool {
1806 let paths = [
1807 &self.db_path,
1808 &self.record_store_path,
1809 &self.key_path,
1810 &self.meta.db_path,
1811 ];
1812 paths.iter().all(|p| !utils::broken_symlink(p))
1813 }
1814}
1815
1816impl Default for Settings {
1817 fn default() -> Self {
1818 Self::builder()
1821 .expect("Could not build default")
1822 .build()
1823 .expect("Could not build config")
1824 .try_deserialize()
1825 .expect("Could not deserialize config")
1826 }
1827}
1828
1829#[doc(hidden)]
1839pub fn init_meta_config_for_testing(meta_db_path: impl Into<String>, local_timeout: f64) {
1840 META_CONFIG.set((meta_db_path.into(), local_timeout)).ok();
1841}
1842
1843#[cfg(test)]
1844pub(crate) fn test_local_timeout() -> f64 {
1845 std::env::var("ATUIN_TEST_LOCAL_TIMEOUT")
1846 .ok()
1847 .and_then(|x| x.parse().ok())
1848 .unwrap_or(2.0)
1851}
1852
1853#[cfg(test)]
1854mod tests {
1855 use std::str::FromStr;
1856
1857 use eyre::Result;
1858
1859 use super::Timezone;
1860
1861 #[test]
1862 fn can_parse_offset_timezone_spec() -> Result<()> {
1863 assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
1864 assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
1865 assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
1866 assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
1867
1868 assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
1870 assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
1871 assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
1872 assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
1873
1874 assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
1876 assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
1877
1878 assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
1880 assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
1881 assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
1882 assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
1883
1884 assert!(Timezone::from_str("5").is_err());
1886 assert!(Timezone::from_str("10:30").is_err());
1887
1888 Ok(())
1889 }
1890
1891 #[test]
1892 fn can_choose_workspace_filters_when_in_git_context() -> Result<()> {
1893 let mut settings = super::Settings::default();
1894 settings.search.filters = vec![
1895 super::FilterMode::Workspace,
1896 super::FilterMode::Host,
1897 super::FilterMode::Directory,
1898 super::FilterMode::Session,
1899 super::FilterMode::Global,
1900 ];
1901 settings.workspaces = true;
1902
1903 assert_eq!(
1904 settings.default_filter_mode(true),
1905 super::FilterMode::Workspace,
1906 );
1907
1908 Ok(())
1909 }
1910
1911 #[test]
1912 fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> {
1913 let mut settings = super::Settings::default();
1914 settings.search.filters = vec![
1915 super::FilterMode::Workspace,
1916 super::FilterMode::Host,
1917 super::FilterMode::Directory,
1918 super::FilterMode::Session,
1919 super::FilterMode::Global,
1920 ];
1921 settings.workspaces = true;
1922
1923 assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,);
1924
1925 Ok(())
1926 }
1927
1928 #[test]
1929 fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> {
1930 let mut settings = super::Settings::default();
1931 settings.search.filters = vec![
1932 super::FilterMode::Workspace,
1933 super::FilterMode::Host,
1934 super::FilterMode::Directory,
1935 super::FilterMode::Session,
1936 super::FilterMode::Global,
1937 ];
1938 settings.workspaces = false;
1939
1940 assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,);
1941
1942 Ok(())
1943 }
1944
1945 #[test]
1946 fn builder_with_data_dir_uses_custom_paths() -> Result<()> {
1947 use std::path::PathBuf;
1948
1949 let custom_dir = PathBuf::from("/custom/data/dir");
1950 let builder = super::Settings::builder_with_data_dir(&custom_dir)?;
1951 let config = builder.build()?;
1952
1953 let db_path: String = config.get("db_path")?;
1954 let key_path: String = config.get("key_path")?;
1955 let record_store_path: String = config.get("record_store_path")?;
1956 let kv_db_path: String = config.get("kv.db_path")?;
1957 let scripts_db_path: String = config.get("scripts.db_path")?;
1958 let meta_db_path: String = config.get("meta.db_path")?;
1959 let daemon_socket_path: String = config.get("daemon.socket_path")?;
1960 let daemon_pidfile_path: String = config.get("daemon.pidfile_path")?;
1961 let daemon_autostart: bool = config.get("daemon.autostart")?;
1962
1963 assert_eq!(db_path, custom_dir.join("history.db").to_str().unwrap());
1964 assert_eq!(key_path, custom_dir.join("key").to_str().unwrap());
1965 assert_eq!(
1966 record_store_path,
1967 custom_dir.join("records.db").to_str().unwrap()
1968 );
1969 assert_eq!(kv_db_path, custom_dir.join("kv.db").to_str().unwrap());
1970 assert_eq!(
1971 scripts_db_path,
1972 custom_dir.join("scripts.db").to_str().unwrap()
1973 );
1974 assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap());
1975 assert_eq!(
1976 daemon_socket_path,
1977 atuin_common::utils::runtime_dir()
1978 .join("atuin.sock")
1979 .to_str()
1980 .unwrap()
1981 );
1982 assert_eq!(
1983 daemon_pidfile_path,
1984 custom_dir.join("atuin-daemon.pid").to_str().unwrap()
1985 );
1986 assert!(!daemon_autostart);
1987
1988 Ok(())
1989 }
1990
1991 #[test]
1992 fn effective_data_dir_returns_default_when_not_set() {
1993 let effective = super::Settings::effective_data_dir();
1994 let default = atuin_common::utils::data_dir();
1995
1996 assert!(effective.to_str().is_some());
1997 assert!(effective.ends_with("atuin") || effective == default);
1998 }
1999
2000 #[test]
2001 fn keymap_config_deserializes_simple_binding() {
2002 let json = r#"{"emacs": {"ctrl-c": "exit"}}"#;
2003 let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
2004 assert_eq!(config.emacs.len(), 1);
2005 match &config.emacs["ctrl-c"] {
2006 super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"),
2007 _ => panic!("expected Simple variant"),
2008 }
2009 }
2010
2011 #[test]
2012 fn keymap_config_deserializes_conditional_binding() {
2013 let json = r#"{
2014 "emacs": {
2015 "left": [
2016 {"when": "cursor-at-start", "action": "exit"},
2017 {"action": "cursor-left"}
2018 ]
2019 }
2020 }"#;
2021 let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
2022 match &config.emacs["left"] {
2023 super::KeyBindingConfig::Rules(rules) => {
2024 assert_eq!(rules.len(), 2);
2025 assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start"));
2026 assert_eq!(rules[0].action, "exit");
2027 assert!(rules[1].when.is_none());
2028 assert_eq!(rules[1].action, "cursor-left");
2029 }
2030 _ => panic!("expected Rules variant"),
2031 }
2032 }
2033
2034 #[test]
2035 fn keymap_config_deserializes_vim_normal() {
2036 let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#;
2037 let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
2038 assert_eq!(config.vim_normal.len(), 2);
2039 assert!(config.emacs.is_empty());
2040 }
2041
2042 #[test]
2043 fn keymap_config_is_empty_when_default() {
2044 let config = super::KeymapConfig::default();
2045 assert!(config.is_empty());
2046 }
2047
2048 #[test]
2049 fn keymap_config_mixed_modes() {
2050 let json = r#"{
2051 "emacs": {"ctrl-c": "exit"},
2052 "vim-normal": {"q": "exit"},
2053 "inspector": {"d": "delete"}
2054 }"#;
2055 let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
2056 assert!(!config.is_empty());
2057 assert_eq!(config.emacs.len(), 1);
2058 assert_eq!(config.vim_normal.len(), 1);
2059 assert_eq!(config.inspector.len(), 1);
2060 assert!(config.vim_insert.is_empty());
2061 assert!(config.prefix.is_empty());
2062 }
2063}