Skip to main content

brush_shell/
config.rs

1//! Configuration file support for the brush shell.
2//!
3//! This module provides TOML-based configuration file loading with the following features:
4//! - Forward-compatible: unknown fields are ignored
5//! - Graceful degradation: parse errors are logged but don't prevent shell startup
6//! - Layered configuration: defaults < config file < command-line arguments
7
8use brush_interactive::UIOptions;
9use etcetera::BaseStrategy;
10use std::path::{Path, PathBuf};
11
12use crate::args::CommandLineArgs;
13
14/// Root configuration structure for the brush shell.
15///
16/// All fields are optional to support forward compatibility and partial configuration.
17/// Unknown fields in the TOML file are silently ignored.
18#[derive(Debug, Default, Clone, serde::Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(default)]
21pub struct Config {
22    /// User interface configuration options.
23    pub ui: UiConfig,
24
25    /// Experimental features configuration.
26    pub experimental: ExperimentalConfig,
27}
28
29/// User interface configuration options.
30#[derive(Debug, Default, Clone, serde::Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(default)]
33pub struct UiConfig {
34    /// Enable syntax highlighting in the input line.
35    #[serde(rename = "syntax-highlighting")]
36    pub syntax_highlighting: Option<bool>,
37}
38
39/// Experimental features configuration.
40///
41/// These options control unstable features that may change or be removed in future versions.
42#[derive(Debug, Default, Clone, serde::Deserialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(default)]
45pub struct ExperimentalConfig {
46    /// Enable zsh-style preexec/precmd hooks.
47    #[serde(rename = "zsh-hooks")]
48    pub zsh_hooks: Option<bool>,
49
50    /// Enable terminal shell integration.
51    #[serde(rename = "terminal-shell-integration")]
52    pub terminal_shell_integration: Option<bool>,
53}
54
55impl Config {
56    /// Converts the configuration to [`UIOptions`], merging with CLI arguments.
57    ///
58    /// Settings are applied with the following priority (highest to lowest):
59    /// 1. CLI arguments (if explicitly set, i.e., different from default)
60    /// 2. Config file values
61    /// 3. Default values
62    ///
63    /// CLI defaults are automatically inferred from clap's parsed defaults.
64    ///
65    /// # Arguments
66    ///
67    /// * `args` - The parsed command-line arguments
68    #[must_use]
69    pub fn to_ui_options(&self, args: &CommandLineArgs) -> UIOptions {
70        // Get clap's defaults by parsing an empty argument list.
71        // This lets us detect which CLI values were explicitly set vs. defaulted.
72        let defaults = CommandLineArgs::default_values();
73
74        let enable_highlighting = merge_bool_setting(
75            args.enable_highlighting,
76            defaults.enable_highlighting,
77            self.ui.syntax_highlighting,
78        );
79        let terminal_shell_integration = merge_bool_setting(
80            args.terminal_shell_integration,
81            defaults.terminal_shell_integration,
82            self.experimental.terminal_shell_integration,
83        );
84        let zsh_style_hooks = merge_bool_setting(
85            args.zsh_style_hooks,
86            defaults.zsh_style_hooks,
87            self.experimental.zsh_hooks,
88        );
89
90        UIOptions::builder()
91            .disable_bracketed_paste(args.disable_bracketed_paste)
92            .disable_color(args.disable_color)
93            .disable_highlighting(!enable_highlighting)
94            .terminal_shell_integration(terminal_shell_integration)
95            .zsh_style_hooks(zsh_style_hooks)
96            .build()
97    }
98}
99
100/// Merges a boolean setting from CLI args, config file, and defaults.
101///
102/// Priority: CLI (if explicitly set) > config file > default.
103///
104/// Since boolean CLI flags can't distinguish between "explicitly set to false" and
105/// "not provided" (both result in `false`), we use a heuristic:
106/// - If the CLI value differs from the default, the user explicitly provided it
107/// - Otherwise, use the config value if present, or fall back to the default
108const fn merge_bool_setting(
109    cli_value: bool,
110    cli_default: bool,
111    config_value: Option<bool>,
112) -> bool {
113    if cli_value != cli_default {
114        // CLI was explicitly set to a non-default value
115        cli_value
116    } else if let Some(config) = config_value {
117        // Use config file value
118        config
119    } else {
120        // Fall back to default
121        cli_default
122    }
123}
124
125/// Result of attempting to load a configuration file.
126#[derive(Debug, Default)]
127pub struct ConfigLoadResult {
128    /// The loaded configuration, or default if loading failed.
129    pub config: Config,
130
131    /// The path that was used (or attempted) for loading.
132    pub path: Option<PathBuf>,
133
134    /// Any error that occurred during loading.
135    pub error: Option<ConfigLoadError>,
136
137    /// Whether the path was explicitly provided by the user (via `--config`).
138    pub explicit_path: bool,
139}
140
141impl ConfigLoadResult {
142    /// Consumes the result and returns the configuration.
143    ///
144    /// If an error occurred:
145    /// - For explicit paths (user-provided via `--config`): returns `Err` with a formatted error
146    /// - For default paths: logs a warning and returns the default configuration
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if an explicit config path was provided and loading failed.
151    pub fn into_config_or_log(self) -> Result<Config, String> {
152        let Some(err) = self.error else {
153            return Ok(self.config);
154        };
155
156        let path_display = self
157            .path
158            .as_ref()
159            .map_or_else(|| String::from("<unknown>"), |p| p.display().to_string());
160
161        if self.explicit_path {
162            // User explicitly provided --config; treat errors as fatal.
163            return Err(format!("failed to load config from {path_display}: {err}"));
164        }
165
166        // Default config path; log warning but continue with defaults.
167        tracing::warn!("failed to load config from {path_display}: {err}");
168        Ok(self.config)
169    }
170}
171
172/// Errors that can occur when loading configuration.
173#[derive(Debug, thiserror::Error)]
174pub enum ConfigLoadError {
175    /// Failed to read the configuration file.
176    #[error("failed to read config file: {0}")]
177    Io(#[from] std::io::Error),
178
179    /// Failed to parse the TOML content.
180    #[error("failed to parse config file: {0}")]
181    Parse(#[from] toml::de::Error),
182}
183
184const CONFIG_SUBDIR_NAME: &str = "brush";
185const CONFIG_FILE_NAME: &str = "config.toml";
186
187/// Returns the default configuration file path for the current platform.
188///
189/// Uses the XDG Base Directory specification on Linux/macOS and appropriate
190/// platform conventions on other systems via the `etcetera` crate.
191///
192/// Returns `None` if the platform's config directory cannot be determined.
193pub fn default_config_path() -> Option<PathBuf> {
194    let strategy = etcetera::choose_base_strategy().ok()?;
195    Some(
196        strategy
197            .config_dir()
198            .join(CONFIG_SUBDIR_NAME)
199            .join(CONFIG_FILE_NAME),
200    )
201}
202
203/// Loads configuration from the specified path.
204///
205/// Returns a `ConfigLoadResult` containing:
206/// - The parsed configuration (or default on error)
207/// - The path that was used
208/// - Any error that occurred
209///
210/// Note: This function sets `explicit_path` to `false`. Use `load_config` for
211/// proper handling of explicit vs. default paths.
212pub fn load_from_path(path: &Path) -> ConfigLoadResult {
213    let content = match std::fs::read_to_string(path) {
214        Ok(content) => content,
215        Err(e) => {
216            return ConfigLoadResult {
217                path: Some(path.to_path_buf()),
218                error: Some(ConfigLoadError::Io(e)),
219                ..Default::default()
220            };
221        }
222    };
223
224    match toml::from_str(&content) {
225        Ok(config) => ConfigLoadResult {
226            config,
227            path: Some(path.to_path_buf()),
228            ..Default::default()
229        },
230        Err(e) => ConfigLoadResult {
231            path: Some(path.to_path_buf()),
232            error: Some(ConfigLoadError::Parse(e)),
233            ..Default::default()
234        },
235    }
236}
237
238/// Loads configuration based on the provided options.
239///
240/// # Arguments
241///
242/// * `disabled` - If true, skip loading and return defaults
243/// * `explicit_path` - If provided, use this path instead of the default
244///
245/// # Returns
246///
247/// A `ConfigLoadResult` containing the configuration and any errors encountered.
248/// If `explicit_path` is provided and loading fails, the result will have
249/// `explicit_path: true` to indicate that the error should be treated as fatal.
250pub fn load_config(disabled: bool, explicit_path: Option<&Path>) -> ConfigLoadResult {
251    if disabled {
252        return ConfigLoadResult::default();
253    }
254
255    let is_explicit = explicit_path.is_some();
256
257    let path = match explicit_path {
258        Some(p) => p.to_path_buf(),
259        None => match default_config_path() {
260            Some(p) => p,
261            None => {
262                // Can't determine config path; use defaults silently
263                return ConfigLoadResult::default();
264            }
265        },
266    };
267
268    // If using default path and file doesn't exist, silently use defaults
269    if !is_explicit && !path.exists() {
270        return ConfigLoadResult {
271            path: Some(path),
272            ..Default::default()
273        };
274    }
275
276    let mut result = load_from_path(&path);
277    result.explicit_path = is_explicit;
278    result
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use clap::Parser;
285
286    #[test]
287    fn empty_config() {
288        let config: Config = toml::from_str("").unwrap();
289        assert!(config.ui.syntax_highlighting.is_none());
290        assert!(config.experimental.zsh_hooks.is_none());
291        assert!(config.experimental.terminal_shell_integration.is_none());
292    }
293
294    #[test]
295    fn full_config() {
296        let toml = r"
297            [ui]
298            syntax-highlighting = true
299
300            [experimental]
301            zsh-hooks = true
302            terminal-shell-integration = false
303        ";
304
305        let config: Config = toml::from_str(toml).unwrap();
306        assert_eq!(config.ui.syntax_highlighting, Some(true));
307        assert_eq!(config.experimental.zsh_hooks, Some(true));
308        assert_eq!(config.experimental.terminal_shell_integration, Some(false));
309    }
310
311    #[test]
312    fn partial_config() {
313        let toml = r"
314            [ui]
315            syntax-highlighting = false
316        ";
317
318        let config: Config = toml::from_str(toml).unwrap();
319        assert_eq!(config.ui.syntax_highlighting, Some(false));
320        assert!(config.experimental.zsh_hooks.is_none());
321    }
322
323    #[test]
324    fn unknown_fields_ignored() {
325        let toml = r#"
326            [ui]
327            syntax-highlighting = true
328            unknown-field = "should be ignored"
329            another-unknown = 42
330
331            [experimental]
332            zsh-hooks = false
333            future-feature = true
334
335            [unknown-section]
336            foo = "bar"
337        "#;
338
339        let config: Config = toml::from_str(toml).unwrap();
340        assert_eq!(config.ui.syntax_highlighting, Some(true));
341        assert_eq!(config.experimental.zsh_hooks, Some(false));
342    }
343
344    #[test]
345    fn load_config_disabled() {
346        let result = load_config(true, None);
347        assert!(result.path.is_none());
348        assert!(result.error.is_none());
349    }
350
351    #[test]
352    fn load_config_nonexistent_default() {
353        // When using default path and file doesn't exist, should return defaults without error
354        let result = load_config(false, None);
355        // We may or may not get a path depending on platform, but shouldn't error
356        assert!(result.error.is_none());
357    }
358
359    #[test]
360    fn load_config_nonexistent_explicit() {
361        let path = Path::new("/nonexistent/path/to/config.toml");
362        let result = load_config(false, Some(path));
363        assert!(result.error.is_some());
364        assert!(matches!(result.error, Some(ConfigLoadError::Io(_))));
365    }
366
367    #[test]
368    fn to_ui_options_defaults_only() {
369        let config = Config::default();
370        let args = CommandLineArgs::default_values();
371        let ui = config.to_ui_options(&args);
372
373        assert!(!ui.disable_bracketed_paste);
374        assert!(!ui.disable_color);
375        // Note: whether highlighting is enabled by default depends on the compile-time
376        // DEFAULT_ENABLE_HIGHLIGHTING constant (true with reedline, false without)
377        assert!(!ui.terminal_shell_integration);
378        assert!(!ui.zsh_style_hooks);
379    }
380
381    #[test]
382    fn to_ui_options_config_overrides_defaults() {
383        let toml = r"
384            [ui]
385            syntax-highlighting = true
386
387            [experimental]
388            zsh-hooks = true
389            terminal-shell-integration = true
390        ";
391        let config: Config = toml::from_str(toml).unwrap();
392        let args = CommandLineArgs::default_values();
393
394        // CLI values match defaults, so config should take effect
395        let ui = config.to_ui_options(&args);
396
397        assert!(!ui.disable_highlighting); // config enabled highlighting
398        assert!(ui.terminal_shell_integration);
399        assert!(ui.zsh_style_hooks);
400    }
401
402    #[test]
403    fn to_ui_options_cli_overrides_config() {
404        let toml = r"
405            [ui]
406            syntax-highlighting = false
407
408            [experimental]
409            zsh-hooks = false
410        ";
411        let config: Config = toml::from_str(toml).unwrap();
412
413        // Simulate CLI explicitly setting values different from defaults
414        // by parsing with the flags enabled
415        let args = CommandLineArgs::try_parse_from([
416            "brush",
417            "--enable-highlighting",
418            "--enable-zsh-hooks",
419        ])
420        .unwrap();
421
422        // CLI explicitly enables highlighting and zsh-hooks (differs from default)
423        let ui = config.to_ui_options(&args);
424
425        assert!(!ui.disable_highlighting); // CLI enabled highlighting
426        assert!(ui.zsh_style_hooks); // CLI enabled
427    }
428
429    #[test]
430    fn to_ui_options_cli_only_settings() {
431        let config = Config::default();
432        let args = CommandLineArgs::try_parse_from([
433            "brush",
434            "--disable-bracketed-paste",
435            "--disable-color",
436        ])
437        .unwrap();
438
439        let ui = config.to_ui_options(&args);
440
441        assert!(ui.disable_bracketed_paste);
442        assert!(ui.disable_color);
443    }
444}