1use std::{
2 collections::BTreeMap,
3 env, fs, io,
4 path::{Path, PathBuf},
5 str::FromStr,
6};
7
8use serde::Deserialize;
9use thiserror::Error;
10
11#[derive(Debug, Clone, PartialEq)]
12pub struct ResolvedConfig {
13 pub ui: UiConfig,
14 pub fuzzy: FuzzyConfig,
15 pub tmux: TmuxConfig,
16 pub status: StatusConfig,
17 pub zoxide: ZoxideConfig,
18 pub preview: PreviewConfig,
19 pub actions: ActionsConfig,
20 pub logging: LoggingConfig,
21}
22
23impl Default for ResolvedConfig {
24 fn default() -> Self {
25 Self {
26 ui: UiConfig {
27 mode: UiMode::Auto,
28 show_help: true,
29 preview_position: PreviewPosition::Right,
30 preview_width: 0.55,
31 border_style: BorderStyle::Rounded,
32 session_sort: SessionSortMode::Recent,
33 },
34 fuzzy: FuzzyConfig {
35 engine: FuzzyEngine::Nucleo,
36 case_mode: CaseMode::Smart,
37 },
38 tmux: TmuxConfig {
39 query_windows: false,
40 prefer_popup: true,
41 popup_width: Dimension::Percent(80),
42 popup_height: Dimension::Percent(85),
43 },
44 status: StatusConfig {
45 line: 2,
46 interactive: true,
47 icon: "".to_string(),
48 max_sessions: None,
49 show_previous: true,
50 },
51 zoxide: ZoxideConfig {
52 enabled: true,
53 mode: ZoxideMode::Query,
54 max_entries: 500,
55 },
56 preview: PreviewConfig {
57 enabled: true,
58 timeout_ms: 120,
59 max_file_bytes: 262_144,
60 syntax_highlighting: true,
61 cache_entries: 512,
62 file: FilePreviewConfig {
63 line_numbers: true,
64 truncate_long_lines: true,
65 },
66 },
67 actions: ActionsConfig {
68 down: KeyAction::MoveDown,
69 up: KeyAction::MoveUp,
70 ctrl_j: KeyAction::MoveDown,
71 ctrl_k: KeyAction::MoveUp,
72 enter: KeyAction::Open,
73 shift_enter: KeyAction::CreateSessionFromQuery,
74 backspace: KeyAction::Backspace,
75 ctrl_r: KeyAction::RenameSession,
76 ctrl_s: KeyAction::ToggleSort,
77 ctrl_x: KeyAction::CloseSession,
78 ctrl_p: KeyAction::TogglePreview,
79 ctrl_d: KeyAction::ToggleDetails,
80 ctrl_m: KeyAction::ToggleCompactSidebar,
81 ctrl_w: KeyAction::ToggleWorktreeMode,
82 esc: KeyAction::Close,
83 ctrl_c: KeyAction::Close,
84 },
85 logging: LoggingConfig {
86 level: LogLevel::Warn,
87 },
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq)]
93pub struct UiConfig {
94 pub mode: UiMode,
95 pub show_help: bool,
96 pub preview_position: PreviewPosition,
97 pub preview_width: f32,
98 pub border_style: BorderStyle,
99 pub session_sort: SessionSortMode,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct FuzzyConfig {
104 pub engine: FuzzyEngine,
105 pub case_mode: CaseMode,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct TmuxConfig {
110 pub query_windows: bool,
111 pub prefer_popup: bool,
112 pub popup_width: Dimension,
113 pub popup_height: Dimension,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct StatusConfig {
118 pub line: usize,
119 pub interactive: bool,
120 pub icon: String,
121 pub max_sessions: Option<usize>,
122 pub show_previous: bool,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ZoxideConfig {
127 pub enabled: bool,
128 pub mode: ZoxideMode,
129 pub max_entries: usize,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct PreviewConfig {
134 pub enabled: bool,
135 pub timeout_ms: u64,
136 pub max_file_bytes: usize,
137 pub syntax_highlighting: bool,
138 pub cache_entries: usize,
139 pub file: FilePreviewConfig,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct FilePreviewConfig {
144 pub line_numbers: bool,
145 pub truncate_long_lines: bool,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ActionsConfig {
150 pub down: KeyAction,
151 pub up: KeyAction,
152 pub ctrl_j: KeyAction,
153 pub ctrl_k: KeyAction,
154 pub enter: KeyAction,
155 pub shift_enter: KeyAction,
156 pub backspace: KeyAction,
157 pub ctrl_r: KeyAction,
158 pub ctrl_s: KeyAction,
159 pub ctrl_x: KeyAction,
160 pub ctrl_p: KeyAction,
161 pub ctrl_d: KeyAction,
162 pub ctrl_m: KeyAction,
163 pub ctrl_w: KeyAction,
164 pub esc: KeyAction,
165 pub ctrl_c: KeyAction,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct LoggingConfig {
170 pub level: LogLevel,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
174pub struct CliOverrides {
175 pub config_path: Option<PathBuf>,
176 pub mode: Option<UiMode>,
177 pub engine: Option<FuzzyEngine>,
178 pub log_level: Option<LogLevel>,
179 pub no_zoxide: bool,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct LoadOptions {
184 pub config_path: Option<PathBuf>,
185 pub strict: bool,
186 pub cli_overrides: CliOverrides,
187 pub env_overrides: BTreeMap<String, String>,
188}
189
190impl Default for LoadOptions {
191 fn default() -> Self {
192 Self {
193 config_path: None,
194 strict: false,
195 cli_overrides: CliOverrides::default(),
196 env_overrides: env::vars().collect(),
197 }
198 }
199}
200
201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
202#[serde(rename_all = "kebab-case")]
203pub enum UiMode {
204 Popup,
205 Fullscreen,
206 #[default]
207 Auto,
208}
209
210#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
211#[serde(rename_all = "kebab-case")]
212pub enum PreviewPosition {
213 #[default]
214 Right,
215 Bottom,
216}
217
218#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
219#[serde(rename_all = "kebab-case")]
220pub enum BorderStyle {
221 Plain,
222 #[default]
223 Rounded,
224 Double,
225 Thick,
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
229#[serde(rename_all = "kebab-case")]
230pub enum FuzzyEngine {
231 #[default]
232 Nucleo,
233 Skim,
234}
235
236#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
237#[serde(rename_all = "kebab-case")]
238pub enum CaseMode {
239 Ignore,
240 Respect,
241 #[default]
242 Smart,
243}
244
245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
246#[serde(rename_all = "kebab-case")]
247pub enum ZoxideMode {
248 #[default]
249 Query,
250 FrecencyList,
251}
252
253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
254#[serde(rename_all = "kebab-case")]
255pub enum SessionSortMode {
256 #[default]
257 Recent,
258 Alphabetical,
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum KeyAction {
264 MoveDown,
265 MoveUp,
266 #[default]
267 Open,
268 CreateSessionFromQuery,
269 Backspace,
270 RenameSession,
271 ToggleSort,
272 CloseSession,
273 TogglePreview,
274 ToggleDetails,
275 ToggleCompactSidebar,
276 ToggleWorktreeMode,
277 Close,
278}
279
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
281#[serde(rename_all = "kebab-case")]
282pub enum LogLevel {
283 Error,
284 #[default]
285 Warn,
286 Info,
287 Debug,
288 Trace,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum Dimension {
293 Percent(u8),
294 Cells(u16),
295}
296
297impl Default for Dimension {
298 fn default() -> Self {
299 Self::Percent(80)
300 }
301}
302
303impl FromStr for Dimension {
304 type Err = &'static str;
305
306 fn from_str(value: &str) -> Result<Self, Self::Err> {
307 if let Some(percent) = value.strip_suffix('%') {
308 let parsed = percent
309 .parse::<u8>()
310 .map_err(|_| "must be a valid percent")?;
311 if (1..=100).contains(&parsed) {
312 Ok(Self::Percent(parsed))
313 } else {
314 Err("percent must be between 1 and 100")
315 }
316 } else {
317 let parsed = value
318 .parse::<u16>()
319 .map_err(|_| "must be a positive cell count")?;
320 if parsed == 0 {
321 Err("cell count must be greater than zero")
322 } else {
323 Ok(Self::Cells(parsed))
324 }
325 }
326 }
327}
328
329macro_rules! impl_from_str_for_enum {
330 ($ty:ty { $($name:literal => $variant:expr),+ $(,)? }) => {
331 impl FromStr for $ty {
332 type Err = String;
333
334 fn from_str(value: &str) -> Result<Self, Self::Err> {
335 match value.trim().to_ascii_lowercase().as_str() {
336 $($name => Ok($variant),)+
337 _ => Err(format!("unsupported value `{value}`")),
338 }
339 }
340 }
341 };
342}
343
344impl_from_str_for_enum!(UiMode {
345 "popup" => UiMode::Popup,
346 "fullscreen" => UiMode::Fullscreen,
347 "auto" => UiMode::Auto,
348});
349impl_from_str_for_enum!(FuzzyEngine {
350 "nucleo" => FuzzyEngine::Nucleo,
351 "skim" => FuzzyEngine::Skim,
352});
353impl_from_str_for_enum!(SessionSortMode {
354 "recent" => SessionSortMode::Recent,
355 "alphabetical" => SessionSortMode::Alphabetical,
356});
357impl_from_str_for_enum!(LogLevel {
358 "error" => LogLevel::Error,
359 "warn" => LogLevel::Warn,
360 "info" => LogLevel::Info,
361 "debug" => LogLevel::Debug,
362 "trace" => LogLevel::Trace,
363});
364
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct ValidationError {
367 pub path: String,
368 pub message: String,
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct ValidationErrors {
373 errors: Vec<ValidationError>,
374}
375
376impl ValidationErrors {
377 #[must_use]
378 pub fn new(errors: Vec<ValidationError>) -> Self {
379 Self { errors }
380 }
381
382 pub fn iter(&self) -> impl Iterator<Item = &ValidationError> {
383 self.errors.iter()
384 }
385}
386
387impl std::fmt::Display for ValidationErrors {
388 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 for (index, error) in self.errors.iter().enumerate() {
390 if index > 0 {
391 writeln!(formatter)?;
392 }
393 write!(formatter, "{}: {}", error.path, error.message)?;
394 }
395
396 Ok(())
397 }
398}
399
400impl std::error::Error for ValidationErrors {}
401
402#[derive(Debug, Error)]
403pub enum ConfigError {
404 #[error("failed to read config from {path}: {source}")]
405 Io {
406 path: PathBuf,
407 #[source]
408 source: io::Error,
409 },
410 #[error("failed to parse config{path_suffix}: {source}")]
411 Parse {
412 path_suffix: String,
413 #[source]
414 source: toml::de::Error,
415 },
416 #[error("unknown config fields: {fields:?}")]
417 UnknownFields { fields: Vec<String> },
418 #[error("invalid environment override {key}: {message}")]
419 InvalidEnvironment { key: String, message: String },
420 #[error("invalid configuration:\n{0}")]
421 Validation(ValidationErrors),
422}
423
424#[must_use]
425pub fn default_config_path() -> Option<PathBuf> {
426 if let Ok(config_home) = env::var("XDG_CONFIG_HOME") {
427 return Some(PathBuf::from(config_home).join("wisp/config.toml"));
428 }
429
430 env::var("HOME")
431 .ok()
432 .map(|home| PathBuf::from(home).join(".config/wisp/config.toml"))
433}
434
435pub fn load_config(options: &LoadOptions) -> Result<ResolvedConfig, ConfigError> {
436 let selected_path = options
437 .config_path
438 .clone()
439 .or_else(|| options.cli_overrides.config_path.clone())
440 .or_else(|| options.env_overrides.get("WISP_CONFIG").map(PathBuf::from))
441 .or_else(default_config_path);
442
443 let is_default_path = options.config_path.is_none()
444 && options.cli_overrides.config_path.is_none()
445 && !options.env_overrides.contains_key("WISP_CONFIG");
446
447 let config_text = match selected_path {
448 Some(path) if path.exists() => Some(read_config(&path)?),
449 Some(_) if is_default_path => None,
450 Some(path) => {
451 return Err(ConfigError::Io {
452 path,
453 source: io::Error::new(io::ErrorKind::NotFound, "config file not found"),
454 });
455 }
456 None => None,
457 };
458
459 resolve_config(
460 config_text.as_deref(),
461 &options.env_overrides,
462 &options.cli_overrides,
463 options.strict,
464 )
465}
466
467pub fn resolve_config(
468 file_toml: Option<&str>,
469 env_overrides: &BTreeMap<String, String>,
470 cli_overrides: &CliOverrides,
471 strict: bool,
472) -> Result<ResolvedConfig, ConfigError> {
473 let mut merged = PartialConfig::default();
474
475 if let Some(input) = file_toml {
476 merged.merge(parse_partial_config(input, strict)?);
477 }
478
479 merged.merge(PartialConfig::from_environment(env_overrides)?);
480 merged.merge(PartialConfig::from_cli(cli_overrides));
481
482 merged.resolve()
483}
484
485fn read_config(path: &Path) -> Result<String, ConfigError> {
486 fs::read_to_string(path).map_err(|source| ConfigError::Io {
487 path: path.to_path_buf(),
488 source,
489 })
490}
491
492fn parse_partial_config(input: &str, strict: bool) -> Result<PartialConfig, ConfigError> {
493 if strict {
494 let mut unknown_fields = Vec::new();
495 let deserializer = toml::Deserializer::new(input);
496 let parsed = serde_ignored::deserialize(deserializer, |path| {
497 unknown_fields.push(path.to_string());
498 })
499 .map_err(|source| ConfigError::Parse {
500 path_suffix: String::new(),
501 source,
502 })?;
503
504 if unknown_fields.is_empty() {
505 Ok(parsed)
506 } else {
507 Err(ConfigError::UnknownFields {
508 fields: unknown_fields,
509 })
510 }
511 } else {
512 toml::from_str(input).map_err(|source| ConfigError::Parse {
513 path_suffix: String::new(),
514 source,
515 })
516 }
517}
518
519#[derive(Debug, Clone, Default, Deserialize)]
520#[serde(default)]
521struct PartialConfig {
522 ui: PartialUiConfig,
523 fuzzy: PartialFuzzyConfig,
524 tmux: PartialTmuxConfig,
525 status: PartialStatusConfig,
526 zoxide: PartialZoxideConfig,
527 preview: PartialPreviewConfig,
528 actions: PartialActionsConfig,
529 logging: PartialLoggingConfig,
530}
531
532impl PartialConfig {
533 fn merge(&mut self, other: Self) {
534 self.ui.merge(other.ui);
535 self.fuzzy.merge(other.fuzzy);
536 self.tmux.merge(other.tmux);
537 self.status.merge(other.status);
538 self.zoxide.merge(other.zoxide);
539 self.preview.merge(other.preview);
540 self.actions.merge(other.actions);
541 self.logging.merge(other.logging);
542 }
543
544 fn from_environment(env_overrides: &BTreeMap<String, String>) -> Result<Self, ConfigError> {
545 let mut config = Self::default();
546
547 if let Some(value) = env_overrides
548 .get("WISP_MODE")
549 .or_else(|| env_overrides.get("WISP_UI_MODE"))
550 {
551 config.ui.mode =
552 Some(
553 value
554 .parse()
555 .map_err(|message| ConfigError::InvalidEnvironment {
556 key: "WISP_MODE".to_string(),
557 message,
558 })?,
559 );
560 }
561
562 if let Some(value) = env_overrides
563 .get("WISP_ENGINE")
564 .or_else(|| env_overrides.get("WISP_FUZZY_ENGINE"))
565 {
566 config.fuzzy.engine =
567 Some(
568 value
569 .parse()
570 .map_err(|message| ConfigError::InvalidEnvironment {
571 key: "WISP_ENGINE".to_string(),
572 message,
573 })?,
574 );
575 }
576
577 if let Some(value) = env_overrides.get("WISP_LOG_LEVEL") {
578 config.logging.level =
579 Some(
580 value
581 .parse()
582 .map_err(|message| ConfigError::InvalidEnvironment {
583 key: "WISP_LOG_LEVEL".to_string(),
584 message,
585 })?,
586 );
587 }
588
589 if let Some(value) = env_overrides.get("WISP_PREVIEW_ENABLED") {
590 config.preview.enabled = Some(parse_bool("WISP_PREVIEW_ENABLED", value)?);
591 }
592
593 if let Some(value) = env_overrides.get("WISP_TMUX_PREFER_POPUP") {
594 config.tmux.prefer_popup = Some(parse_bool("WISP_TMUX_PREFER_POPUP", value)?);
595 }
596
597 if let Some(value) = env_overrides.get("WISP_NO_ZOXIDE") {
598 config.zoxide.enabled = Some(!parse_bool("WISP_NO_ZOXIDE", value)?);
599 }
600
601 Ok(config)
602 }
603
604 fn from_cli(cli_overrides: &CliOverrides) -> Self {
605 let mut config = Self::default();
606 config.ui.mode = cli_overrides.mode;
607 config.fuzzy.engine = cli_overrides.engine;
608 config.logging.level = cli_overrides.log_level;
609 if cli_overrides.no_zoxide {
610 config.zoxide.enabled = Some(false);
611 }
612 config
613 }
614
615 fn resolve(self) -> Result<ResolvedConfig, ConfigError> {
616 let mut config = ResolvedConfig::default();
617 let mut errors = Vec::new();
618
619 if let Some(mode) = self.ui.mode {
620 config.ui.mode = mode;
621 }
622 if let Some(show_help) = self.ui.show_help {
623 config.ui.show_help = show_help;
624 }
625 if let Some(preview_position) = self.ui.preview_position {
626 config.ui.preview_position = preview_position;
627 }
628 if let Some(preview_width) = self.ui.preview_width {
629 config.ui.preview_width = preview_width;
630 }
631 if let Some(border_style) = self.ui.border_style {
632 config.ui.border_style = border_style;
633 }
634 if let Some(session_sort) = self.ui.session_sort {
635 config.ui.session_sort = session_sort;
636 }
637
638 if let Some(engine) = self.fuzzy.engine {
639 config.fuzzy.engine = engine;
640 }
641 if let Some(case_mode) = self.fuzzy.case_mode {
642 config.fuzzy.case_mode = case_mode;
643 }
644
645 if let Some(query_windows) = self.tmux.query_windows {
646 config.tmux.query_windows = query_windows;
647 }
648 if let Some(prefer_popup) = self.tmux.prefer_popup {
649 config.tmux.prefer_popup = prefer_popup;
650 }
651 if let Some(value) = self.tmux.popup_width {
652 match value.parse() {
653 Ok(parsed) => config.tmux.popup_width = parsed,
654 Err(message) => errors.push(ValidationError {
655 path: "tmux.popup_width".to_string(),
656 message: message.to_string(),
657 }),
658 }
659 }
660 if let Some(value) = self.tmux.popup_height {
661 match value.parse() {
662 Ok(parsed) => config.tmux.popup_height = parsed,
663 Err(message) => errors.push(ValidationError {
664 path: "tmux.popup_height".to_string(),
665 message: message.to_string(),
666 }),
667 }
668 }
669
670 if let Some(line) = self.status.line {
671 config.status.line = line;
672 }
673 if let Some(interactive) = self.status.interactive {
674 config.status.interactive = interactive;
675 }
676 if let Some(icon) = self.status.icon {
677 config.status.icon = icon;
678 }
679 config.status.max_sessions = self.status.max_sessions;
680 if let Some(show_previous) = self.status.show_previous {
681 config.status.show_previous = show_previous;
682 }
683
684 if let Some(enabled) = self.zoxide.enabled {
685 config.zoxide.enabled = enabled;
686 }
687 if let Some(mode) = self.zoxide.mode {
688 config.zoxide.mode = mode;
689 }
690 if let Some(max_entries) = self.zoxide.max_entries {
691 config.zoxide.max_entries = max_entries;
692 }
693
694 if let Some(enabled) = self.preview.enabled {
695 config.preview.enabled = enabled;
696 }
697 if let Some(timeout_ms) = self.preview.timeout_ms {
698 config.preview.timeout_ms = timeout_ms;
699 }
700 if let Some(max_file_bytes) = self.preview.max_file_bytes {
701 config.preview.max_file_bytes = max_file_bytes;
702 }
703 if let Some(syntax_highlighting) = self.preview.syntax_highlighting {
704 config.preview.syntax_highlighting = syntax_highlighting;
705 }
706 if let Some(cache_entries) = self.preview.cache_entries {
707 config.preview.cache_entries = cache_entries;
708 }
709 if let Some(line_numbers) = self.preview.file.line_numbers {
710 config.preview.file.line_numbers = line_numbers;
711 }
712 if let Some(truncate_long_lines) = self.preview.file.truncate_long_lines {
713 config.preview.file.truncate_long_lines = truncate_long_lines;
714 }
715
716 if let Some(enter) = self.actions.enter {
717 config.actions.enter = enter;
718 }
719 if let Some(down) = self.actions.down {
720 config.actions.down = down;
721 }
722 if let Some(up) = self.actions.up {
723 config.actions.up = up;
724 }
725 if let Some(ctrl_j) = self.actions.ctrl_j {
726 config.actions.ctrl_j = ctrl_j;
727 }
728 if let Some(ctrl_k) = self.actions.ctrl_k {
729 config.actions.ctrl_k = ctrl_k;
730 }
731 if let Some(shift_enter) = self.actions.shift_enter {
732 config.actions.shift_enter = shift_enter;
733 }
734 if let Some(backspace) = self.actions.backspace {
735 config.actions.backspace = backspace;
736 }
737 if let Some(ctrl_r) = self.actions.ctrl_r {
738 config.actions.ctrl_r = ctrl_r;
739 }
740 if let Some(ctrl_s) = self.actions.ctrl_s {
741 config.actions.ctrl_s = ctrl_s;
742 }
743 if let Some(ctrl_x) = self.actions.ctrl_x {
744 config.actions.ctrl_x = ctrl_x;
745 }
746 if let Some(ctrl_p) = self.actions.ctrl_p {
747 config.actions.ctrl_p = ctrl_p;
748 }
749 if let Some(ctrl_d) = self.actions.ctrl_d {
750 config.actions.ctrl_d = ctrl_d;
751 }
752 if let Some(ctrl_m) = self.actions.ctrl_m {
753 config.actions.ctrl_m = ctrl_m;
754 }
755 if let Some(ctrl_w) = self.actions.ctrl_w {
756 config.actions.ctrl_w = ctrl_w;
757 }
758 if let Some(esc) = self.actions.esc {
759 config.actions.esc = esc;
760 }
761 if let Some(ctrl_c) = self.actions.ctrl_c {
762 config.actions.ctrl_c = ctrl_c;
763 }
764
765 if let Some(level) = self.logging.level {
766 config.logging.level = level;
767 }
768
769 validate_config(&config, &mut errors);
770
771 if errors.is_empty() {
772 Ok(config)
773 } else {
774 Err(ConfigError::Validation(ValidationErrors::new(errors)))
775 }
776 }
777}
778
779#[derive(Debug, Clone, Default, Deserialize)]
780#[serde(default)]
781struct PartialUiConfig {
782 mode: Option<UiMode>,
783 show_help: Option<bool>,
784 preview_position: Option<PreviewPosition>,
785 preview_width: Option<f32>,
786 border_style: Option<BorderStyle>,
787 session_sort: Option<SessionSortMode>,
788}
789
790impl PartialUiConfig {
791 fn merge(&mut self, other: Self) {
792 merge_option(&mut self.mode, other.mode);
793 merge_option(&mut self.show_help, other.show_help);
794 merge_option(&mut self.preview_position, other.preview_position);
795 merge_option(&mut self.preview_width, other.preview_width);
796 merge_option(&mut self.border_style, other.border_style);
797 merge_option(&mut self.session_sort, other.session_sort);
798 }
799}
800
801#[derive(Debug, Clone, Default, Deserialize)]
802#[serde(default)]
803struct PartialFuzzyConfig {
804 engine: Option<FuzzyEngine>,
805 case_mode: Option<CaseMode>,
806}
807
808impl PartialFuzzyConfig {
809 fn merge(&mut self, other: Self) {
810 merge_option(&mut self.engine, other.engine);
811 merge_option(&mut self.case_mode, other.case_mode);
812 }
813}
814
815#[derive(Debug, Clone, Default, Deserialize)]
816#[serde(default)]
817struct PartialTmuxConfig {
818 query_windows: Option<bool>,
819 prefer_popup: Option<bool>,
820 popup_width: Option<String>,
821 popup_height: Option<String>,
822}
823
824impl PartialTmuxConfig {
825 fn merge(&mut self, other: Self) {
826 merge_option(&mut self.query_windows, other.query_windows);
827 merge_option(&mut self.prefer_popup, other.prefer_popup);
828 merge_option(&mut self.popup_width, other.popup_width);
829 merge_option(&mut self.popup_height, other.popup_height);
830 }
831}
832
833#[derive(Debug, Clone, Default, Deserialize)]
834#[serde(default)]
835struct PartialStatusConfig {
836 line: Option<usize>,
837 interactive: Option<bool>,
838 icon: Option<String>,
839 max_sessions: Option<usize>,
840 show_previous: Option<bool>,
841}
842
843impl PartialStatusConfig {
844 fn merge(&mut self, other: Self) {
845 merge_option(&mut self.line, other.line);
846 merge_option(&mut self.interactive, other.interactive);
847 merge_option(&mut self.icon, other.icon);
848 merge_option(&mut self.max_sessions, other.max_sessions);
849 merge_option(&mut self.show_previous, other.show_previous);
850 }
851}
852
853#[derive(Debug, Clone, Default, Deserialize)]
854#[serde(default)]
855struct PartialZoxideConfig {
856 enabled: Option<bool>,
857 mode: Option<ZoxideMode>,
858 max_entries: Option<usize>,
859}
860
861impl PartialZoxideConfig {
862 fn merge(&mut self, other: Self) {
863 merge_option(&mut self.enabled, other.enabled);
864 merge_option(&mut self.mode, other.mode);
865 merge_option(&mut self.max_entries, other.max_entries);
866 }
867}
868
869#[derive(Debug, Clone, Default, Deserialize)]
870#[serde(default)]
871struct PartialPreviewConfig {
872 enabled: Option<bool>,
873 timeout_ms: Option<u64>,
874 max_file_bytes: Option<usize>,
875 syntax_highlighting: Option<bool>,
876 cache_entries: Option<usize>,
877 file: PartialFilePreviewConfig,
878}
879
880impl PartialPreviewConfig {
881 fn merge(&mut self, other: Self) {
882 merge_option(&mut self.enabled, other.enabled);
883 merge_option(&mut self.timeout_ms, other.timeout_ms);
884 merge_option(&mut self.max_file_bytes, other.max_file_bytes);
885 merge_option(&mut self.syntax_highlighting, other.syntax_highlighting);
886 merge_option(&mut self.cache_entries, other.cache_entries);
887 self.file.merge(other.file);
888 }
889}
890
891#[derive(Debug, Clone, Default, Deserialize)]
892#[serde(default)]
893struct PartialFilePreviewConfig {
894 line_numbers: Option<bool>,
895 truncate_long_lines: Option<bool>,
896}
897
898impl PartialFilePreviewConfig {
899 fn merge(&mut self, other: Self) {
900 merge_option(&mut self.line_numbers, other.line_numbers);
901 merge_option(&mut self.truncate_long_lines, other.truncate_long_lines);
902 }
903}
904
905#[derive(Debug, Clone, Default, Deserialize)]
906#[serde(default)]
907struct PartialActionsConfig {
908 down: Option<KeyAction>,
909 up: Option<KeyAction>,
910 ctrl_j: Option<KeyAction>,
911 ctrl_k: Option<KeyAction>,
912 enter: Option<KeyAction>,
913 shift_enter: Option<KeyAction>,
914 backspace: Option<KeyAction>,
915 ctrl_r: Option<KeyAction>,
916 ctrl_s: Option<KeyAction>,
917 ctrl_x: Option<KeyAction>,
918 ctrl_p: Option<KeyAction>,
919 ctrl_d: Option<KeyAction>,
920 ctrl_m: Option<KeyAction>,
921 ctrl_w: Option<KeyAction>,
922 esc: Option<KeyAction>,
923 ctrl_c: Option<KeyAction>,
924}
925
926impl PartialActionsConfig {
927 fn merge(&mut self, other: Self) {
928 merge_option(&mut self.down, other.down);
929 merge_option(&mut self.up, other.up);
930 merge_option(&mut self.ctrl_j, other.ctrl_j);
931 merge_option(&mut self.ctrl_k, other.ctrl_k);
932 merge_option(&mut self.enter, other.enter);
933 merge_option(&mut self.shift_enter, other.shift_enter);
934 merge_option(&mut self.backspace, other.backspace);
935 merge_option(&mut self.ctrl_r, other.ctrl_r);
936 merge_option(&mut self.ctrl_s, other.ctrl_s);
937 merge_option(&mut self.ctrl_x, other.ctrl_x);
938 merge_option(&mut self.ctrl_p, other.ctrl_p);
939 merge_option(&mut self.ctrl_d, other.ctrl_d);
940 merge_option(&mut self.ctrl_m, other.ctrl_m);
941 merge_option(&mut self.ctrl_w, other.ctrl_w);
942 merge_option(&mut self.esc, other.esc);
943 merge_option(&mut self.ctrl_c, other.ctrl_c);
944 }
945}
946
947#[derive(Debug, Clone, Default, Deserialize)]
948#[serde(default)]
949struct PartialLoggingConfig {
950 level: Option<LogLevel>,
951}
952
953impl PartialLoggingConfig {
954 fn merge(&mut self, other: Self) {
955 merge_option(&mut self.level, other.level);
956 }
957}
958
959fn merge_option<T>(slot: &mut Option<T>, incoming: Option<T>) {
960 if let Some(value) = incoming {
961 *slot = Some(value);
962 }
963}
964
965fn parse_bool(key: &str, value: &str) -> Result<bool, ConfigError> {
966 match value.trim().to_ascii_lowercase().as_str() {
967 "1" | "true" | "yes" | "on" => Ok(true),
968 "0" | "false" | "no" | "off" => Ok(false),
969 _ => Err(ConfigError::InvalidEnvironment {
970 key: key.to_string(),
971 message: format!("expected a boolean, got `{value}`"),
972 }),
973 }
974}
975
976fn validate_config(config: &ResolvedConfig, errors: &mut Vec<ValidationError>) {
977 if !(0.2..=0.8).contains(&config.ui.preview_width) {
978 errors.push(ValidationError {
979 path: "ui.preview_width".to_string(),
980 message: "must be between 0.2 and 0.8".to_string(),
981 });
982 }
983
984 if config.preview.timeout_ms == 0 || config.preview.timeout_ms > 5_000 {
985 errors.push(ValidationError {
986 path: "preview.timeout_ms".to_string(),
987 message: "must be between 1 and 5000 milliseconds".to_string(),
988 });
989 }
990
991 if config.zoxide.max_entries == 0 {
992 errors.push(ValidationError {
993 path: "zoxide.max_entries".to_string(),
994 message: "must be greater than zero".to_string(),
995 });
996 }
997
998 if config.status.line == 0 {
999 errors.push(ValidationError {
1000 path: "status.line".to_string(),
1001 message: "must be greater than zero".to_string(),
1002 });
1003 }
1004
1005 if config.status.max_sessions == Some(0) {
1006 errors.push(ValidationError {
1007 path: "status.max_sessions".to_string(),
1008 message: "must be greater than zero".to_string(),
1009 });
1010 }
1011
1012 if config.preview.max_file_bytes == 0 {
1013 errors.push(ValidationError {
1014 path: "preview.max_file_bytes".to_string(),
1015 message: "must be greater than zero".to_string(),
1016 });
1017 }
1018
1019 if config.preview.cache_entries == 0 {
1020 errors.push(ValidationError {
1021 path: "preview.cache_entries".to_string(),
1022 message: "must be greater than zero".to_string(),
1023 });
1024 }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029 use std::collections::BTreeMap;
1030
1031 use super::{
1032 CliOverrides, ConfigError, FuzzyEngine, KeyAction, LogLevel, SessionSortMode, UiMode,
1033 resolve_config,
1034 };
1035
1036 #[test]
1037 fn resolves_default_config_values() {
1038 let config = resolve_config(None, &BTreeMap::new(), &CliOverrides::default(), false)
1039 .expect("default config should resolve");
1040
1041 assert_eq!(config.ui.mode, UiMode::Auto);
1042 assert_eq!(config.fuzzy.engine, FuzzyEngine::Nucleo);
1043 assert!(config.zoxide.enabled);
1044 assert_eq!(config.logging.level, LogLevel::Warn);
1045 assert_eq!(config.ui.session_sort, SessionSortMode::Recent);
1046 assert_eq!(config.status.line, 2);
1047 assert!(config.status.interactive);
1048 assert_eq!(config.status.icon, "");
1049 assert_eq!(config.status.max_sessions, None);
1050 assert_eq!(config.actions.down, KeyAction::MoveDown);
1051 assert_eq!(config.actions.up, KeyAction::MoveUp);
1052 assert_eq!(config.actions.ctrl_j, KeyAction::MoveDown);
1053 assert_eq!(config.actions.ctrl_k, KeyAction::MoveUp);
1054 assert_eq!(
1055 config.actions.shift_enter,
1056 KeyAction::CreateSessionFromQuery
1057 );
1058 assert_eq!(config.actions.backspace, KeyAction::Backspace);
1059 assert_eq!(config.actions.ctrl_s, KeyAction::ToggleSort);
1060 assert_eq!(config.actions.ctrl_w, KeyAction::ToggleWorktreeMode);
1061 }
1062
1063 #[test]
1064 fn parses_toml_config_values() {
1065 let input = r#"
1066 [ui]
1067 mode = "popup"
1068 preview_width = 0.6
1069 session_sort = "alphabetical"
1070
1071 [fuzzy]
1072 engine = "skim"
1073
1074 [tmux]
1075 popup_width = "90%"
1076 popup_height = "40"
1077
1078 [status]
1079 line = 3
1080 icon = "Wisp"
1081 max_sessions = 5
1082 show_previous = false
1083
1084 [actions]
1085 down = "move-down"
1086 up = "move-up"
1087 ctrl_j = "move-down"
1088 ctrl_k = "move-up"
1089 ctrl_r = "rename-session"
1090 ctrl_s = "toggle-sort"
1091 ctrl_x = "close"
1092 ctrl_p = "open"
1093 shift_enter = "create-session-from-query"
1094 backspace = "backspace"
1095 "#;
1096
1097 let config = resolve_config(
1098 Some(input),
1099 &BTreeMap::new(),
1100 &CliOverrides::default(),
1101 false,
1102 )
1103 .expect("toml config should resolve");
1104
1105 assert_eq!(config.ui.mode, UiMode::Popup);
1106 assert_eq!(config.ui.preview_width, 0.6);
1107 assert_eq!(config.ui.session_sort, SessionSortMode::Alphabetical);
1108 assert_eq!(config.fuzzy.engine, FuzzyEngine::Skim);
1109 assert_eq!(config.status.line, 3);
1110 assert_eq!(config.status.icon, "Wisp");
1111 assert_eq!(config.status.max_sessions, Some(5));
1112 assert!(!config.status.show_previous);
1113 assert_eq!(config.actions.down, KeyAction::MoveDown);
1114 assert_eq!(config.actions.up, KeyAction::MoveUp);
1115 assert_eq!(config.actions.ctrl_j, KeyAction::MoveDown);
1116 assert_eq!(config.actions.ctrl_k, KeyAction::MoveUp);
1117 assert_eq!(config.actions.ctrl_r, KeyAction::RenameSession);
1118 assert_eq!(config.actions.ctrl_s, KeyAction::ToggleSort);
1119 assert_eq!(config.actions.ctrl_x, KeyAction::Close);
1120 assert_eq!(config.actions.ctrl_p, KeyAction::Open);
1121 assert_eq!(
1122 config.actions.shift_enter,
1123 KeyAction::CreateSessionFromQuery
1124 );
1125 assert_eq!(config.actions.backspace, KeyAction::Backspace);
1126 }
1127
1128 #[test]
1129 fn applies_file_then_environment_then_cli_precedence() {
1130 let input = r#"
1131 [ui]
1132 mode = "fullscreen"
1133
1134 [fuzzy]
1135 engine = "skim"
1136
1137 [logging]
1138 level = "info"
1139 "#;
1140 let env = BTreeMap::from([
1141 ("WISP_MODE".to_string(), "popup".to_string()),
1142 ("WISP_ENGINE".to_string(), "nucleo".to_string()),
1143 ("WISP_LOG_LEVEL".to_string(), "debug".to_string()),
1144 ]);
1145 let cli = CliOverrides {
1146 mode: Some(UiMode::Auto),
1147 engine: Some(FuzzyEngine::Skim),
1148 log_level: Some(LogLevel::Trace),
1149 no_zoxide: true,
1150 ..CliOverrides::default()
1151 };
1152
1153 let config =
1154 resolve_config(Some(input), &env, &cli, false).expect("merged config should resolve");
1155
1156 assert_eq!(config.ui.mode, UiMode::Auto);
1157 assert_eq!(config.fuzzy.engine, FuzzyEngine::Skim);
1158 assert_eq!(config.logging.level, LogLevel::Trace);
1159 assert!(!config.zoxide.enabled);
1160 }
1161
1162 #[test]
1163 fn returns_validation_errors_with_field_paths() {
1164 let input = r#"
1165 [ui]
1166 preview_width = 0.95
1167
1168 [tmux]
1169 popup_width = "101%"
1170
1171 [preview]
1172 timeout_ms = 0
1173
1174 [status]
1175 line = 0
1176 "#;
1177
1178 let error = resolve_config(
1179 Some(input),
1180 &BTreeMap::new(),
1181 &CliOverrides::default(),
1182 false,
1183 )
1184 .expect_err("invalid config should fail");
1185
1186 match error {
1187 ConfigError::Validation(errors) => {
1188 let paths = errors
1189 .iter()
1190 .map(|error| error.path.as_str())
1191 .collect::<Vec<_>>();
1192 assert!(paths.contains(&"ui.preview_width"));
1193 assert!(paths.contains(&"tmux.popup_width"));
1194 assert!(paths.contains(&"preview.timeout_ms"));
1195 assert!(paths.contains(&"status.line"));
1196 }
1197 other => panic!("expected validation error, got {other:?}"),
1198 }
1199 }
1200
1201 #[test]
1202 fn rejects_unknown_fields_in_strict_mode() {
1203 let input = r#"
1204 [ui]
1205 mode = "popup"
1206 impossible = true
1207 "#;
1208
1209 let error = resolve_config(
1210 Some(input),
1211 &BTreeMap::new(),
1212 &CliOverrides::default(),
1213 true,
1214 )
1215 .expect_err("strict mode should reject unknown fields");
1216
1217 match error {
1218 ConfigError::UnknownFields { fields } => {
1219 assert_eq!(fields, vec!["ui.impossible".to_string()]);
1220 }
1221 other => panic!("expected unknown fields error, got {other:?}"),
1222 }
1223 }
1224}