Skip to main content

wisp_config/
lib.rs

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}