Skip to main content

trail/config/
mod.rs

1//! Configuration loading and schema definitions.
2//!
3//! TOML config loaded once at startup, resolved by the input handler and
4//! theme module. Strict-mode deserialization rejects unknown keys.
5
6pub mod last_used;
7pub mod persist;
8pub mod schema;
9pub mod terminal;
10
11use std::path::{Path, PathBuf};
12
13use serde::Deserialize;
14use thiserror::Error;
15
16pub use last_used::ConfigSource;
17pub use schema::{
18    GeneralConfig, KeymapConfig, NavigationConfig, PluginsConfig, PreviewConfig, SetConfigError,
19    TerminalConfig, TerminalProfile, ThemeConfig, TrailConfig,
20};
21
22/// Built-in default configuration shipped with the binary.
23pub const DEFAULT_CONFIG_TOML: &str = include_str!("default.toml");
24
25/// Errors produced while loading Trail configuration.
26#[derive(Debug, Error)]
27pub enum ConfigError {
28    /// The built-in default configuration could not be parsed.
29    #[error("built-in default config is invalid: {0}")]
30    DefaultToml(String),
31    /// A user-supplied config file could not be read.
32    #[error("failed to read config {path}: {source}")]
33    Read {
34        /// Config file path.
35        path: PathBuf,
36        /// I/O source error.
37        #[source]
38        source: std::io::Error,
39    },
40    /// TOML deserialization failed.
41    #[error("{source}")]
42    Toml {
43        /// Optional path for a user-supplied config.
44        path: Option<PathBuf>,
45        /// Helpful parse error with line/column context.
46        source: TomlConfigError,
47    },
48    /// Config parsed but failed semantic validation.
49    #[error("config validation error: {0}")]
50    Validation(#[from] SetConfigError),
51}
52
53/// Human-readable TOML parse or schema validation error.
54#[derive(Debug, Error)]
55#[error("{message}")]
56pub struct TomlConfigError {
57    message: String,
58}
59
60/// Loads configuration from built-in defaults and an optional user path.
61///
62/// User config files are partial overrides layered onto the shipped default
63/// config. Unknown keys are still rejected in strict mode.
64///
65/// # Errors
66///
67/// Returns [`ConfigError`] if the default config is malformed, the user file
68/// cannot be read, or TOML validation fails.
69pub fn load(path: Option<&Path>) -> Result<TrailConfig, ConfigError> {
70    let mut config = parse_toml(DEFAULT_CONFIG_TOML, None).map_err(|err| match err {
71        ConfigError::Toml { source, .. } => ConfigError::DefaultToml(source.to_string()),
72        other => other,
73    })?;
74
75    match path {
76        Some(path) => {
77            let content = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
78                path: path.to_owned(),
79                source,
80            })?;
81            let overrides = parse_overrides(&content, path)?;
82            overrides.apply_to(&mut config);
83            config.validate()?;
84            Ok(config)
85        }
86        None => Ok(config),
87    }
88}
89
90/// Parses a TOML string into a strict [`TrailConfig`].
91///
92/// # Errors
93///
94/// Returns [`ConfigError::Toml`] if the document is malformed, has missing
95/// required fields, or includes unknown keys.
96pub fn parse_toml(content: &str, path: Option<&Path>) -> Result<TrailConfig, ConfigError> {
97    let config = toml::from_str::<TrailConfig>(content).map_err(|err| ConfigError::Toml {
98        path: path.map(Path::to_owned),
99        source: toml_error(content, path, err),
100    })?;
101    config.validate()?;
102    Ok(config)
103}
104
105fn parse_overrides(content: &str, path: &Path) -> Result<ConfigOverrides, ConfigError> {
106    toml::from_str::<ConfigOverrides>(content).map_err(|err| ConfigError::Toml {
107        path: Some(path.to_owned()),
108        source: toml_error(content, Some(path), err),
109    })
110}
111
112#[derive(Debug, Default, Deserialize)]
113#[serde(deny_unknown_fields)]
114struct ConfigOverrides {
115    general: Option<GeneralOverrides>,
116    navigation: Option<NavigationOverrides>,
117    preview: Option<PreviewOverrides>,
118    terminal: Option<TerminalOverrides>,
119    theme: Option<ThemeOverrides>,
120    keymap: Option<KeymapOverrides>,
121    plugins: Option<PluginsOverrides>,
122}
123
124impl ConfigOverrides {
125    fn apply_to(self, config: &mut TrailConfig) {
126        if let Some(general) = self.general {
127            general.apply_to(&mut config.general);
128        }
129        if let Some(navigation) = self.navigation {
130            navigation.apply_to(&mut config.navigation);
131        }
132        if let Some(preview) = self.preview {
133            preview.apply_to(&mut config.preview);
134        }
135        if let Some(terminal) = self.terminal {
136            terminal.apply_to(&mut config.terminal);
137        }
138        if let Some(theme) = self.theme {
139            theme.apply_to(&mut config.theme);
140        }
141        if let Some(keymap) = self.keymap {
142            keymap.apply_to(&mut config.keymap);
143        }
144        if let Some(plugins) = self.plugins {
145            plugins.apply_to(&mut config.plugins);
146        }
147    }
148}
149
150#[derive(Debug, Default, Deserialize)]
151#[serde(deny_unknown_fields)]
152struct GeneralOverrides {
153    editor: Option<String>,
154    shell: Option<String>,
155    shell_pause: Option<String>,
156    text_sync_threshold_kb: Option<usize>,
157    delete_mode: Option<String>,
158    show_version: Option<bool>,
159    terminal_title: Option<String>,
160    git_status_enabled: Option<bool>,
161    fs_watch_debounce_ms: Option<u64>,
162}
163
164impl GeneralOverrides {
165    fn apply_to(self, general: &mut GeneralConfig) {
166        if let Some(editor) = self.editor {
167            general.editor = editor;
168        }
169        if let Some(shell) = self.shell {
170            general.shell = shell;
171        }
172        if let Some(shell_pause) = self.shell_pause {
173            general.shell_pause = shell_pause;
174        }
175        if let Some(text_sync_threshold_kb) = self.text_sync_threshold_kb {
176            general.text_sync_threshold_kb = text_sync_threshold_kb;
177        }
178        if let Some(delete_mode) = self.delete_mode {
179            general.delete_mode = delete_mode;
180        }
181        if let Some(show_version) = self.show_version {
182            general.show_version = show_version;
183        }
184        if let Some(terminal_title) = self.terminal_title {
185            general.terminal_title = terminal_title;
186        }
187        if let Some(git_status_enabled) = self.git_status_enabled {
188            general.git_status_enabled = git_status_enabled;
189        }
190        if let Some(fs_watch_debounce_ms) = self.fs_watch_debounce_ms {
191            general.fs_watch_debounce_ms = fs_watch_debounce_ms;
192        }
193    }
194}
195
196#[derive(Debug, Default, Deserialize)]
197#[serde(deny_unknown_fields)]
198struct NavigationOverrides {
199    sort_by: Option<String>,
200    sort_reverse: Option<bool>,
201    dirs_first: Option<bool>,
202    entry_details: Option<String>,
203    scroll_margin: Option<usize>,
204}
205
206impl NavigationOverrides {
207    fn apply_to(self, navigation: &mut NavigationConfig) {
208        if let Some(sort_by) = self.sort_by {
209            navigation.sort_by = sort_by;
210        }
211        if let Some(sort_reverse) = self.sort_reverse {
212            navigation.sort_reverse = sort_reverse;
213        }
214        if let Some(dirs_first) = self.dirs_first {
215            navigation.dirs_first = dirs_first;
216        }
217        if let Some(entry_details) = self.entry_details {
218            navigation.entry_details = entry_details;
219        }
220        if let Some(scroll_margin) = self.scroll_margin {
221            navigation.scroll_margin = scroll_margin;
222        }
223    }
224}
225
226#[derive(Debug, Default, Deserialize)]
227#[serde(deny_unknown_fields)]
228struct PreviewOverrides {
229    image_protocol: Option<String>,
230    image_cell_width: Option<u16>,
231    image_cell_height: Option<u16>,
232    max_lines: Option<usize>,
233}
234
235impl PreviewOverrides {
236    fn apply_to(self, preview: &mut PreviewConfig) {
237        if let Some(image_protocol) = self.image_protocol {
238            preview.image_protocol = image_protocol;
239        }
240        if let Some(image_cell_width) = self.image_cell_width {
241            preview.image_cell_width = image_cell_width;
242        }
243        if let Some(image_cell_height) = self.image_cell_height {
244            preview.image_cell_height = image_cell_height;
245        }
246        if let Some(max_lines) = self.max_lines {
247            preview.max_lines = max_lines;
248        }
249    }
250}
251
252#[derive(Debug, Default, Deserialize)]
253#[serde(deny_unknown_fields)]
254struct TerminalOverrides {
255    default_profile: Option<String>,
256    height: Option<u16>,
257    confirm_quit: Option<String>,
258    confirm_close: Option<String>,
259    profile: Option<Vec<TerminalProfile>>,
260}
261
262impl TerminalOverrides {
263    fn apply_to(self, terminal: &mut TerminalConfig) {
264        if let Some(default_profile) = self.default_profile {
265            terminal.default_profile = default_profile;
266        }
267        if let Some(height) = self.height {
268            terminal.height = height;
269        }
270        if let Some(confirm_quit) = self.confirm_quit {
271            terminal.confirm_quit = confirm_quit;
272        }
273        if let Some(confirm_close) = self.confirm_close {
274            terminal.confirm_close = confirm_close;
275        }
276        // A user's profile list replaces the default (empty) one rather than
277        // extending it, so removing a profile from the file removes it here.
278        if let Some(profile) = self.profile {
279            terminal.profile = profile;
280        }
281    }
282}
283
284#[derive(Debug, Default, Deserialize)]
285#[serde(deny_unknown_fields)]
286struct ThemeOverrides {
287    foreground: Option<String>,
288    background: Option<String>,
289    border: Option<String>,
290    selection_fg: Option<String>,
291    selection_bg: Option<String>,
292    directory: Option<String>,
293    symlink: Option<String>,
294    hidden: Option<String>,
295    status_fg: Option<String>,
296    error: Option<String>,
297    search: Option<String>,
298    command: Option<String>,
299    git_clean: Option<String>,
300    git_dirty: Option<String>,
301}
302
303impl ThemeOverrides {
304    fn apply_to(self, theme: &mut ThemeConfig) {
305        if let Some(value) = self.foreground {
306            theme.foreground = value;
307        }
308        if let Some(value) = self.background {
309            theme.background = value;
310        }
311        if let Some(value) = self.border {
312            theme.border = value;
313        }
314        if let Some(value) = self.selection_fg {
315            theme.selection_fg = value;
316        }
317        if let Some(value) = self.selection_bg {
318            theme.selection_bg = value;
319        }
320        if let Some(value) = self.directory {
321            theme.directory = value;
322        }
323        if let Some(value) = self.symlink {
324            theme.symlink = value;
325        }
326        if let Some(value) = self.hidden {
327            theme.hidden = value;
328        }
329        if let Some(value) = self.status_fg {
330            theme.status_fg = value;
331        }
332        if let Some(value) = self.error {
333            theme.error = value;
334        }
335        if let Some(value) = self.search {
336            theme.search = value;
337        }
338        if let Some(value) = self.command {
339            theme.command = value;
340        }
341        if let Some(value) = self.git_clean {
342            theme.git_clean = value;
343        }
344        if let Some(value) = self.git_dirty {
345            theme.git_dirty = value;
346        }
347    }
348}
349
350#[derive(Debug, Default, Deserialize)]
351#[serde(deny_unknown_fields)]
352struct KeymapOverrides {
353    navigation: Option<std::collections::HashMap<String, String>>,
354    search: Option<std::collections::HashMap<String, String>>,
355    terminal: Option<std::collections::HashMap<String, String>>,
356}
357
358impl KeymapOverrides {
359    fn apply_to(self, keymap: &mut KeymapConfig) {
360        if let Some(navigation) = self.navigation {
361            keymap.navigation.extend(navigation);
362        }
363        if let Some(search) = self.search {
364            keymap.search.extend(search);
365        }
366        if let Some(terminal) = self.terminal {
367            keymap.terminal.extend(terminal);
368        }
369    }
370}
371
372#[derive(Debug, Default, Deserialize)]
373#[serde(deny_unknown_fields)]
374struct PluginsOverrides {
375    enabled: Option<Vec<String>>,
376}
377
378impl PluginsOverrides {
379    fn apply_to(self, plugins: &mut PluginsConfig) {
380        if let Some(enabled) = self.enabled {
381            plugins.enabled = enabled;
382        }
383    }
384}
385
386fn toml_error(content: &str, path: Option<&Path>, err: toml::de::Error) -> TomlConfigError {
387    let location = err
388        .span()
389        .map(|span| line_col(content, span.start))
390        .map(|(line, col)| format!("line {line}, column {col}"))
391        .unwrap_or_else(|| "unknown location".to_owned());
392    let prefix = path
393        .map(|p| format!("{}: ", p.display()))
394        .unwrap_or_default();
395    TomlConfigError {
396        message: format!("{prefix}config error at {location}: {err}"),
397    }
398}
399
400fn line_col(content: &str, byte_idx: usize) -> (usize, usize) {
401    let mut line = 1usize;
402    let mut col = 1usize;
403    for (idx, ch) in content.char_indices() {
404        if idx >= byte_idx {
405            break;
406        }
407        if ch == '\n' {
408            line += 1;
409            col = 1;
410        } else {
411            col += 1;
412        }
413    }
414    (line, col)
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn default_config_loads() {
423        let cfg = load(None).unwrap();
424        assert_eq!(cfg.general.text_sync_threshold_kb, 256);
425        assert!(cfg.general.git_status_enabled);
426    }
427
428    #[test]
429    fn user_config_overrides_defaults_partially() {
430        let dir = tempfile::tempdir().unwrap();
431        let path = dir.path().join("trail.toml");
432        std::fs::write(
433            &path,
434            r##"
435[general]
436text_sync_threshold_kb = 128
437
438[theme]
439directory = "#112233"
440
441[keymap]
442navigation = { move_down = "n" }
443"##,
444        )
445        .unwrap();
446
447        let cfg = load(Some(&path)).unwrap();
448        assert_eq!(cfg.general.text_sync_threshold_kb, 128);
449        assert_eq!(cfg.general.editor, "nvim");
450        assert_eq!(cfg.theme.directory, "#112233");
451        assert_eq!(
452            cfg.keymap.navigation.get("move_down"),
453            Some(&"n".to_owned())
454        );
455        assert_eq!(cfg.keymap.navigation.get("move_up"), Some(&"k".to_owned()));
456    }
457
458    #[test]
459    fn user_config_can_override_part_of_the_navigation_section() {
460        let dir = tempfile::tempdir().unwrap();
461        let path = dir.path().join("trail.toml");
462        std::fs::write(
463            &path,
464            r#"
465[navigation]
466sort_by = "modified"
467entry_details = "size"
468"#,
469        )
470        .unwrap();
471
472        let cfg = load(Some(&path)).unwrap();
473        assert_eq!(cfg.general.editor, "nvim", "other sections are untouched");
474        assert_eq!(cfg.navigation.sort_by, "modified");
475        assert_eq!(cfg.navigation.entry_details, "size");
476        // The keys the user did not write keep their defaults rather than
477        // becoming empty.
478        assert!(!cfg.navigation.sort_reverse);
479        assert!(cfg.navigation.dirs_first);
480        assert_eq!(cfg.navigation.scroll_margin, 3);
481        assert_eq!(
482            cfg.navigation.sort_settings(),
483            crate::app::sort::SortSettings {
484                by: crate::app::sort::SortBy::Modified,
485                reverse: false,
486                dirs_first: true,
487            }
488        );
489    }
490
491    #[test]
492    fn an_unknown_key_in_the_navigation_section_is_rejected() {
493        let dir = tempfile::tempdir().unwrap();
494        let path = dir.path().join("trail.toml");
495        // `deny_unknown_fields` is the invariant: a typo has to be reported,
496        // never silently dropped, or the setting appears not to work.
497        std::fs::write(
498            &path,
499            r#"
500[navigation]
501sort_order = "size"
502"#,
503        )
504        .unwrap();
505
506        assert!(load(Some(&path)).is_err());
507    }
508
509    #[test]
510    fn a_bad_sort_key_is_rejected_with_the_options_named() {
511        let dir = tempfile::tempdir().unwrap();
512        let path = dir.path().join("trail.toml");
513        std::fs::write(
514            &path,
515            r#"
516[navigation]
517sort_by = "created"
518"#,
519        )
520        .unwrap();
521
522        let err = load(Some(&path)).unwrap_err().to_string();
523        assert!(err.contains("name, size, modified or extension"), "{err}");
524    }
525
526    #[test]
527    fn user_config_can_override_the_shell() {
528        let dir = tempfile::tempdir().unwrap();
529        let path = dir.path().join("trail.toml");
530        // A literal TOML string, the way a Windows path has to be written.
531        std::fs::write(
532            &path,
533            r#"
534[general]
535shell = 'pwsh -NoProfile -Command'
536"#,
537        )
538        .unwrap();
539
540        let cfg = load(Some(&path)).unwrap();
541        assert_eq!(cfg.general.shell, "pwsh -NoProfile -Command");
542        // Overriding the shell must not disturb its neighbours in the section.
543        assert_eq!(cfg.general.editor, "nvim");
544    }
545
546    #[test]
547    fn user_config_with_an_unspawnable_shell_is_rejected() {
548        let dir = tempfile::tempdir().unwrap();
549        let path = dir.path().join("trail.toml");
550        std::fs::write(
551            &path,
552            r#"
553[general]
554shell = '"pwsh -Command'
555"#,
556        )
557        .unwrap();
558
559        let err = load(Some(&path)).unwrap_err().to_string();
560        assert!(err.contains("general.shell"), "got: {err}");
561    }
562
563    #[test]
564    fn unknown_key_is_rejected_with_line_context() {
565        let toml = r#"
566[general]
567editor = "vi"
568text_sync_threshold_kb = 256
569git_status_enabled = true
570fs_watch_debounce_ms = 200
571surprise = true
572
573[theme]
574foreground = "white"
575background = "black"
576border = "dark_gray"
577selection_fg = "black"
578selection_bg = "dark_gray"
579directory = "blue"
580symlink = "cyan"
581hidden = "dark_gray"
582status_fg = "dark_gray"
583error = "red"
584search = "yellow"
585command = "cyan"
586git_clean = "green"
587git_dirty = "yellow"
588
589[keymap]
590navigation = {}
591search = {}
592
593[plugins]
594enabled = []
595"#;
596
597        let err = parse_toml(toml, None).unwrap_err().to_string();
598        assert!(err.contains("line"));
599        assert!(err.contains("surprise"));
600    }
601
602    #[test]
603    fn semantic_validation_rejects_bad_color() {
604        let dir = tempfile::tempdir().unwrap();
605        let path = dir.path().join("trail.toml");
606        std::fs::write(
607            &path,
608            r#"
609[theme]
610directory = "not-a-color"
611"#,
612        )
613        .unwrap();
614
615        let err = load(Some(&path)).unwrap_err().to_string();
616        assert!(err.contains("theme.directory"));
617        assert!(err.contains("named color"));
618    }
619
620    #[test]
621    fn semantic_validation_rejects_unknown_keymap_action() {
622        let dir = tempfile::tempdir().unwrap();
623        let path = dir.path().join("trail.toml");
624        std::fs::write(
625            &path,
626            r#"
627[keymap]
628navigation = { fly = "f" }
629"#,
630        )
631        .unwrap();
632
633        let err = load(Some(&path)).unwrap_err().to_string();
634        assert!(err.contains("keymap.navigation.fly"));
635    }
636}