Skip to main content

atuin_client/
settings.rs

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
34/// Default sync address for Atuin's hosted service
35pub const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh";
36
37/// Default Hub web/API endpoint for Atuin's hosted service
38pub 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            // if the user is using skim, we go to skim
86            SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
87            // if the user is using daemon-fuzzy, we go to daemon-fuzzy
88            SearchMode::FullText if settings.search_mode == SearchMode::DaemonFuzzy => {
89                SearchMode::DaemonFuzzy
90            }
91            // otherwise fuzzy.
92            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// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
142// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
143#[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/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
162///
163/// Note that the parsing of this struct needs to be done before starting any
164/// multithreaded runtime, otherwise it will fail on most Unix systems.
165///
166/// See: <https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426>
167#[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}
174/// format: <+|-><hour>[:<minute>[:<second>]]
175static 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        // local timezone
183        if matches!(s.to_lowercase().as_str(), "l" | "local") {
184            // There have been some timezone issues, related to errors fetching it on some
185            // platforms
186            // Rather than fail to start, fallback to UTC. The user should still be able to specify
187            // their timezone manually in the config file.
188            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        // offset from UTC
198        if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
199            return Ok(Self(offset));
200        }
201
202        // IDEA: Currently named timezones are not supported, because the well-known crate
203        // for this is `chrono_tz`, which is not really interoperable with the datetime crate
204        // that we currently use - `time`. If ever we migrate to using `chrono`, this would
205        // be a good feature to add.
206
207        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// We want to translate the config to crossterm::cursor::SetCursorStyle, but
259// the original type does not implement trait serde::Deserialize unfortunately.
260// It seems impossible to implement Deserialize for external types when it is
261// used in HashMap (https://stackoverflow.com/questions/67142663).  We instead
262// define an adapter type.
263#[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>, // sudo, etc. commands we want to strip off
305    #[serde(default = "Stats::common_subcommands_default")]
306    pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
307    #[serde(default = "Stats::ignored_commands_default")]
308    pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
309}
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/// Sync protocol type for authentication.
366///
367/// This setting is primarily for development/testing. When not explicitly set,
368/// the protocol is inferred from the sync_address:
369/// - Default sync address (api.atuin.sh) → Hub protocol
370/// - Custom sync address → Legacy protocol
371///
372/// Set explicitly to "hub" to use Hub authentication with a custom sync_address
373/// (useful for local development against a Hub instance).
374#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)]
375#[serde(rename_all = "lowercase")]
376pub enum SyncProtocol {
377    /// Use Hub authentication (Bearer token from Hub OAuth flow)
378    Hub,
379    /// Use legacy CLI authentication (Token from CLI register/login)
380    Legacy,
381    /// Infer from sync_address (default behavior)
382    #[default]
383    Auto,
384}
385
386/// Resolved authentication state for sync operations.
387///
388/// Determined at runtime by examining which tokens are available and what
389/// server the client is configured to talk to. Operations use this to pick
390/// the right auth header and endpoint style.
391#[cfg(feature = "sync")]
392#[derive(Debug, Clone)]
393pub enum SyncAuth {
394    /// Self-hosted Rust server. Uses `Authorization: Token <session>` and
395    /// legacy endpoints.
396    Legacy { token: String },
397    /// Hub with a valid Hub API token (`atapi_*`). Uses
398    /// `Authorization: Bearer <token>` and v0 endpoints.
399    Hub { token: String },
400    /// Targeting Hub but only has a CLI session token. Uses
401    /// `Authorization: Token <session>` against compat/record endpoints.
402    /// Sync, password change, and account deletion still work, but the user
403    /// should be nudged to run `atuin login` for full Hub auth.
404    HubViaCli { token: String },
405    /// Not authenticated at all. Contains an actionable user-facing message.
406    NotLoggedIn { reason: String },
407}
408
409#[cfg(feature = "sync")]
410impl SyncAuth {
411    /// Convert into the auth token type used by the API client.
412    ///
413    /// Returns an error with an actionable message for `NotLoggedIn`.
414    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    /// The standard default values for all `[keys]` options.
437    /// These match the config defaults set in `builder_with_data_dir()`.
438    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    /// Returns true if any value differs from the standard defaults.
450    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/// A single rule within a conditional keybinding config.
462#[derive(Clone, Debug, Deserialize, Serialize)]
463pub struct KeyRuleConfig {
464    /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results").
465    /// If absent, the rule always matches.
466    #[serde(default)]
467    pub when: Option<String>,
468    /// The action to perform (e.g. "exit", "cursor-left", "accept").
469    pub action: String,
470}
471
472/// A keybinding config value: either a simple action string or an ordered list of conditional rules.
473#[derive(Clone, Debug, Deserialize, Serialize)]
474#[serde(untagged)]
475pub enum KeyBindingConfig {
476    /// Simple unconditional binding: `"ctrl-c" = "return-original"`
477    Simple(String),
478    /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]`
479    Rules(Vec<KeyRuleConfig>),
480}
481
482/// User-facing keymap configuration. Each mode maps key strings to bindings.
483/// Keys present here override the defaults for that key; unmentioned keys keep defaults.
484#[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    /// Returns true if no keybinding overrides are configured in any mode.
500    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    /// Name of desired theme ("default" for base)
517    pub name: String,
518
519    /// Whether any available additional theme debug should be shown
520    pub debug: Option<bool>,
521
522    /// How many levels of parenthood will be traversed if needed
523    pub max_depth: Option<u8>,
524}
525
526#[derive(Clone, Debug, Deserialize, Serialize)]
527pub struct Daemon {
528    /// Use the daemon to sync
529    /// If enabled, history hooks are routed through the daemon.
530    #[serde(alias = "enable")]
531    pub enabled: bool,
532
533    /// Automatically start and manage a local daemon when needed.
534    pub autostart: bool,
535
536    /// The daemon will handle sync on an interval. How often to sync, in seconds.
537    pub sync_frequency: u64,
538
539    /// The path to the unix socket used by the daemon
540    pub socket_path: String,
541
542    /// Path to the daemon pidfile used for process coordination.
543    pub pidfile_path: String,
544
545    /// Use a socket passed via systemd's socket activation protocol, instead of the path
546    pub systemd_socket: bool,
547
548    /// The port that should be used for TCP on non unix systems
549    pub tcp_port: u64,
550}
551
552#[derive(Clone, Debug, Deserialize, Serialize)]
553pub struct Search {
554    /// The list of enabled filter modes, in order of priority.
555    pub filters: Vec<FilterMode>,
556
557    /// The recency score multiplier for the search index (default: 1.0).
558    /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
559    pub recency_score_multiplier: f64,
560
561    /// The frequency score multiplier for the search index (default: 1.0).
562    /// Values < 1.0 reduce weight, > 1.0 increase weight, 0.0 disables.
563    pub frequency_score_multiplier: f64,
564
565    /// The overall frecency score multiplier for the search index (default: 1.0).
566    /// Applied after combining recency and frequency scores.
567    pub frecency_score_multiplier: f64,
568}
569
570#[derive(Clone, Debug, Deserialize, Serialize)]
571pub struct Tmux {
572    /// Enable using atuin with tmux popup (tmux >= 3.2)
573    pub enabled: bool,
574
575    /// Width of the tmux popup (percentage)
576    pub width: String,
577
578    /// Height of the tmux popup (percentage)
579    pub height: String,
580}
581
582/// Log level for file logging. Maps to tracing's LevelFilter.
583#[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    /// Convert to a tracing directive string for use with EnvFilter.
596    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/// Configuration for a specific log type (search or daemon).
608#[derive(Clone, Debug, Default, Deserialize, Serialize)]
609pub struct LogConfig {
610    /// Log file name (relative to dir) or absolute path.
611    pub file: String,
612
613    /// Override global enabled setting for this log type.
614    pub enabled: Option<bool>,
615
616    /// Override global level setting for this log type.
617    pub level: Option<LogLevel>,
618
619    /// Override global retention days setting for this log type.
620    pub retention: Option<u64>,
621}
622
623#[derive(Clone, Debug, Deserialize, Serialize)]
624pub struct Logs {
625    /// Enable file logging globally. Defaults to true.
626    #[serde(default = "Logs::default_enabled")]
627    pub enabled: bool,
628
629    /// Directory for log files. Defaults to ~/.atuin/logs
630    pub dir: String,
631
632    /// Default log level for file logging. Defaults to "info".
633    /// Note: ATUIN_LOG environment variable overrides this.
634    #[serde(default)]
635    pub level: LogLevel,
636
637    /// Default retention days for log files. Defaults to 4.
638    #[serde(default = "Logs::default_retention")]
639    pub retention: u64,
640
641    /// Search log settings
642    #[serde(default)]
643    pub search: LogConfig,
644
645    /// Daemon log settings
646    #[serde(default)]
647    pub daemon: LogConfig,
648
649    /// AI log settings
650    #[serde(default)]
651    pub ai: LogConfig,
652}
653
654#[derive(Default, Clone, Debug, Deserialize, Serialize)]
655pub struct Ai {
656    /// Whether or not the AI features are enabled.
657    pub enabled: Option<bool>,
658
659    /// The address of the Atuin AI endpoint. Used for AI features like command generation.
660    /// Only necessary for custom AI endpoints.
661    pub endpoint: Option<String>,
662
663    /// The API token for the Atuin AI endpoint. Used for AI features like command generation.
664    /// Only necessary for custom AI endpoints.
665    pub api_token: Option<String>,
666
667    /// Path to the AI sessions database.
668    pub db_path: String,
669
670    /// The maximum time in minutes that an AI session can be automatically resumed.
671    pub session_continue_minutes: i64,
672
673    /// Deprecated: use opening.send_cwd instead. Kept for backwards compatibility.
674    #[serde(default)]
675    pub send_cwd: Option<bool>,
676
677    /// Configuration for what context is sent in the opening AI request.
678    #[serde(default)]
679    pub opening: AiOpening,
680
681    /// Tool capability flags.
682    #[serde(default)]
683    pub capabilities: AiCapabilities,
684}
685
686#[derive(Default, Clone, Debug, Deserialize, Serialize)]
687pub struct AiCapabilities {
688    /// Whether the AI can request to search Atuin history. `None` = unset (defaults to enabled, and the ai will ask for permission).
689    pub enable_history_search: Option<bool>,
690    /// Whether the AI can request to read and write files. `None` = unset (defaults to enabled, and the ai will ask for permission).
691    pub enable_file_tools: Option<bool>,
692    /// Whether the AI can request to execute bash commands. `None` = unset (defaults to enabled, and the ai will ask for permission).
693    pub enable_command_execution: Option<bool>,
694}
695
696#[derive(Default, Clone, Debug, Deserialize, Serialize)]
697pub struct AiOpening {
698    /// Whether or not to send the current working directory to the AI endpoint.
699    pub send_cwd: Option<bool>,
700
701    /// Whether or not to send the last command as context in the opening AI request.
702    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    /// Returns whether search logging is enabled.
770    /// Uses search-specific setting if set, otherwise falls back to global.
771    pub fn search_enabled(&self) -> bool {
772        self.search.enabled.unwrap_or(self.enabled)
773    }
774
775    /// Returns whether daemon logging is enabled.
776    /// Uses daemon-specific setting if set, otherwise falls back to global.
777    pub fn daemon_enabled(&self) -> bool {
778        self.daemon.enabled.unwrap_or(self.enabled)
779    }
780
781    /// Returns whether AI logging is enabled.
782    /// Uses AI-specific setting if set, otherwise falls back to global.
783    pub fn ai_enabled(&self) -> bool {
784        self.ai.enabled.unwrap_or(self.enabled)
785    }
786
787    /// Returns the log level for search logging.
788    /// Uses search-specific setting if set, otherwise falls back to global.
789    pub fn search_level(&self) -> LogLevel {
790        self.search.level.unwrap_or(self.level)
791    }
792
793    /// Returns the log level for daemon logging.
794    /// Uses daemon-specific setting if set, otherwise falls back to global.
795    pub fn daemon_level(&self) -> LogLevel {
796        self.daemon.level.unwrap_or(self.level)
797    }
798
799    /// Returns the log level for AI logging.
800    /// Uses AI-specific setting if set, otherwise falls back to global.
801    pub fn ai_level(&self) -> LogLevel {
802        self.ai.level.unwrap_or(self.level)
803    }
804
805    /// Returns the retention days for search logging.
806    /// Uses search-specific setting if set, otherwise falls back to global.
807    pub fn search_retention(&self) -> u64 {
808        self.search.retention.unwrap_or(self.retention)
809    }
810
811    /// Returns the retention days for daemon logging.
812    /// Uses daemon-specific setting if set, otherwise falls back to global.
813    pub fn daemon_retention(&self) -> u64 {
814        self.daemon.retention.unwrap_or(self.retention)
815    }
816
817    /// Returns the retention days for AI logging.
818    /// Uses AI-specific setting if set, otherwise falls back to global.
819    pub fn ai_retention(&self) -> u64 {
820        self.ai.retention.unwrap_or(self.retention)
821    }
822
823    /// Returns the full path for the search log file.
824    pub fn search_path(&self) -> PathBuf {
825        let path = PathBuf::from(&self.search.file);
826        PathBuf::from(&self.dir).join(path)
827    }
828
829    /// Returns the full path for the daemon log file.
830    pub fn daemon_path(&self) -> PathBuf {
831        let path = PathBuf::from(&self.daemon.file);
832        PathBuf::from(&self.dir).join(path)
833    }
834
835    /// Returns the full path for the AI log file.
836    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// The preview height strategy also takes max_preview_height into account.
872#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
873pub enum PreviewStrategy {
874    // Preview height is calculated for the length of the selected command.
875    #[serde(rename = "auto")]
876    Auto,
877
878    // Preview height is calculated for the length of the longest command stored in the history.
879    #[serde(rename = "static")]
880    Static,
881
882    // max_preview_height is used as fixed height.
883    #[serde(rename = "fixed")]
884    Fixed,
885}
886
887/// Column types available for the interactive search UI.
888#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)]
889#[serde(rename_all = "lowercase")]
890pub enum UiColumnType {
891    /// Command execution duration (e.g., "123ms")
892    Duration,
893    /// Relative time since execution (e.g., "59s ago")
894    Time,
895    /// Absolute timestamp (e.g., "2025-01-22 14:35")
896    Datetime,
897    /// Working directory
898    Directory,
899    /// Hostname
900    Host,
901    /// Username
902    User,
903    /// Exit code
904    Exit,
905    /// The command itself (should be last, expands to fill)
906    Command,
907}
908
909impl UiColumnType {
910    /// Returns the default width for this column type (in characters).
911    /// The Command column returns 0 as it expands to fill remaining space.
912    pub fn default_width(&self) -> u16 {
913        match self {
914            UiColumnType::Duration => 5,  // "814ms"
915            UiColumnType::Time => 9,      // "459ms ago"
916            UiColumnType::Datetime => 16, // "2025-01-22 14:35"
917            UiColumnType::Directory => 20,
918            UiColumnType::Host => 15,
919            UiColumnType::User => 10,
920            UiColumnType::Exit => {
921                if cfg!(windows) {
922                    11 // 32-bit integer on Windows: "-1978335212"
923                } else {
924                    3 // Usually a byte on Unix
925                }
926            }
927            UiColumnType::Command => 0, // Expands to fill
928        }
929    }
930}
931
932/// A column configuration with type and optional custom width.
933/// Can be specified as just a string (uses default width) or as an object with type and width.
934#[derive(Clone, Debug, Serialize)]
935pub struct UiColumn {
936    pub column_type: UiColumnType,
937    pub width: u16,
938    /// If true, this column expands to fill remaining space. Only one column should expand.
939    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
960// Custom deserialize to handle both string and object formats:
961// "duration" or { type = "duration", width = 8, expand = true }
962impl<'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/// UI-specific settings for the interactive search.
1030#[derive(Clone, Debug, Deserialize, Serialize)]
1031pub struct Ui {
1032    /// Columns to display in interactive search, from left to right.
1033    /// The indicator column (" > ") is always shown first implicitly.
1034    /// The "command" column should be last as it expands to fill remaining space.
1035    /// Can be simple strings or objects with type and width.
1036    #[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    /// Validate the UI configuration.
1050    /// Returns an error if more than one column has expand = true.
1051    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    /// The sync address for atuin.
1081    pub sync_address: String,
1082
1083    /// Sync protocol for authentication. When set to "auto" (default), the protocol
1084    /// is inferred from sync_address. Set to "hub" to force Hub auth with a custom
1085    /// sync_address (useful for local development).
1086    #[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    // -- Meta store: lazily initialized on first access --
1206
1207    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    /// Normalize a URL for comparison by trimming trailing slashes
1275    fn normalize_url(url: &str) -> &str {
1276        url.trim_end_matches('/')
1277    }
1278
1279    /// Check if a URL matches one of Atuin's official hosted addresses
1280    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    /// Returns whether this configuration uses Hub-style sync.
1287    ///
1288    /// Hub sync uses Bearer token authentication and is the default for
1289    /// Atuin's hosted service. This returns true when:
1290    /// - `sync_protocol` is explicitly set to `Hub`, OR
1291    /// - `sync_protocol` is `Auto` and `sync_address` is an official Atuin address
1292    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    /// Returns the base URL for the Hub endpoint.
1301    ///
1302    /// For Atuin's official hosted service, this always returns `https://hub.atuin.sh`
1303    /// regardless of whether `sync_address` is `api.atuin.sh` or `hub.atuin.sh`.
1304    /// For self-hosted instances, returns the configured `sync_address`.
1305    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    /// Examines the configured sync target and available tokens to determine
1318    /// the correct auth strategy. Also performs cleanup of mis-stored tokens
1319    /// (e.g. a CLI token incorrectly saved in the Hub session slot).
1320    #[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            // Self-hosted / legacy server
1333            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        // Targeting Hub — check for a valid Hub API token first
1344        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            // A non-atapi_ token in the hub_session slot is a mis-stored CLI
1350            // token (from the migration-fallback bug). Move it to the CLI
1351            // session slot if that slot is empty, then clear hub_session
1352            // only if the move succeeded.
1353            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                // CLI slot already has a token; just clear the bad hub_session
1359                let _ = meta.delete_hub_session().await;
1360            }
1361            // Fall through to check CLI token below
1362        }
1363
1364        // No valid Hub token — check for a CLI session token
1365        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    /// Returns the appropriate auth token for sync operations.
1376    ///
1377    /// Delegates to [`resolve_sync_auth`] and converts the result to an
1378    /// `AuthToken`. Callers that need to distinguish between auth states
1379    /// (e.g. to show different UI) should call `resolve_sync_auth` directly.
1380    #[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        // Check a max of once per hour
1391        Ok(diff.whole_hours() >= 1)
1392    }
1393
1394    #[cfg(feature = "check-update")]
1395    async fn latest_version(&self) -> Result<Version> {
1396        // Default to the current version, and if that doesn't parse, a version so high it's unlikely to ever
1397        // suggest upgrading.
1398        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    // Return Some(latest version) if an update is needed. Otherwise, none.
1425    #[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            // enter_accept defaults to false here, but true in the default config file. The dissonance is
1527            // intentional!
1528            // Existing users will get the default "False", so we don't mess with any potential
1529            // muscle memory.
1530            // New users will get the new default, that is more similar to what they are used to.
1531            .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    /// Build a merged `Config` from defaults, config file, and environment.
1620    ///
1621    /// This resolves `data_dir`, initializes the data directory on disk,
1622    /// and layers defaults → config file → env overrides. Both `new()` and
1623    /// `get_config_value()` use this so the resolution logic lives in one place.
1624    fn build_config() -> Result<Config> {
1625        let config_file = Self::get_config_path()?;
1626
1627        // extract data_dir first so we can use it as the base for other path defaults
1628        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        // all paths should be expanded
1685        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    /// Look up a single config value by dotted key (e.g. `"daemon.sync_frequency"`).
1715    ///
1716    /// Returns the effective value after merging defaults, config file, and
1717    /// environment — without the side-effects of full `Settings` construction
1718    /// (meta store init, path expansion, etc.).
1719    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        // Validate UI settings
1785        settings.ui.validate()?;
1786
1787        // Register meta store config for lazy initialization on first access
1788        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        // if this panics something is very wrong, as the default config
1819        // does not build or deserialize into the settings struct
1820        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/// Initialize the meta store configuration for testing.
1830///
1831/// This should only be used in tests. It allows tests to bypass the normal
1832/// Settings::new() flow while still being able to use Settings::host_id()
1833/// and other meta store dependent functions.
1834///
1835/// # Safety
1836/// This function is not thread-safe with concurrent calls to Settings::new()
1837/// or other meta store initialization. Only call from tests.
1838#[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        // this hardcoded value should be replaced by a simple way to get the
1849        // default local_timeout of Settings if possible
1850        .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        // single digit hours are allowed
1869        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        // fully qualified form
1875        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        // these offsets don't really exist but are supported anyway
1879        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        // require a leading sign for clarity
1885        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}