1pub 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
22pub const DEFAULT_CONFIG_TOML: &str = include_str!("default.toml");
24
25#[derive(Debug, Error)]
27pub enum ConfigError {
28 #[error("built-in default config is invalid: {0}")]
30 DefaultToml(String),
31 #[error("failed to read config {path}: {source}")]
33 Read {
34 path: PathBuf,
36 #[source]
38 source: std::io::Error,
39 },
40 #[error("{source}")]
42 Toml {
43 path: Option<PathBuf>,
45 source: TomlConfigError,
47 },
48 #[error("config validation error: {0}")]
50 Validation(#[from] SetConfigError),
51}
52
53#[derive(Debug, Error)]
55#[error("{message}")]
56pub struct TomlConfigError {
57 message: String,
58}
59
60pub 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
90pub 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 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 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 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 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 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}