use brush_interactive::UIOptions;
use etcetera::BaseStrategy;
use std::path::{Path, PathBuf};
use crate::args::CommandLineArgs;
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct Config {
pub ui: UiConfig,
pub experimental: ExperimentalConfig,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct UiConfig {
#[serde(rename = "syntax-highlighting")]
pub syntax_highlighting: Option<bool>,
}
#[derive(Debug, Default, Clone, serde::Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct ExperimentalConfig {
#[serde(rename = "zsh-hooks")]
pub zsh_hooks: Option<bool>,
#[serde(rename = "terminal-shell-integration")]
pub terminal_shell_integration: Option<bool>,
}
impl Config {
#[must_use]
pub fn to_ui_options(&self, args: &CommandLineArgs) -> UIOptions {
let defaults = CommandLineArgs::default_values();
let enable_highlighting = merge_bool_setting(
args.enable_highlighting,
defaults.enable_highlighting,
self.ui.syntax_highlighting,
);
let terminal_shell_integration = merge_bool_setting(
args.terminal_shell_integration,
defaults.terminal_shell_integration,
self.experimental.terminal_shell_integration,
);
let zsh_style_hooks = merge_bool_setting(
args.zsh_style_hooks,
defaults.zsh_style_hooks,
self.experimental.zsh_hooks,
);
UIOptions::builder()
.disable_bracketed_paste(args.disable_bracketed_paste)
.disable_color(args.disable_color)
.disable_highlighting(!enable_highlighting)
.terminal_shell_integration(terminal_shell_integration)
.zsh_style_hooks(zsh_style_hooks)
.build()
}
}
const fn merge_bool_setting(
cli_value: bool,
cli_default: bool,
config_value: Option<bool>,
) -> bool {
if cli_value != cli_default {
cli_value
} else if let Some(config) = config_value {
config
} else {
cli_default
}
}
#[derive(Debug, Default)]
pub struct ConfigLoadResult {
pub config: Config,
pub path: Option<PathBuf>,
pub error: Option<ConfigLoadError>,
pub explicit_path: bool,
}
impl ConfigLoadResult {
pub fn into_config_or_log(self) -> Result<Config, String> {
let Some(err) = self.error else {
return Ok(self.config);
};
let path_display = self
.path
.as_ref()
.map_or_else(|| String::from("<unknown>"), |p| p.display().to_string());
if self.explicit_path {
return Err(format!("failed to load config from {path_display}: {err}"));
}
tracing::warn!("failed to load config from {path_display}: {err}");
Ok(self.config)
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
#[error("failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
}
const CONFIG_SUBDIR_NAME: &str = "brush";
const CONFIG_FILE_NAME: &str = "config.toml";
pub fn default_config_path() -> Option<PathBuf> {
let strategy = etcetera::choose_base_strategy().ok()?;
Some(
strategy
.config_dir()
.join(CONFIG_SUBDIR_NAME)
.join(CONFIG_FILE_NAME),
)
}
pub fn load_from_path(path: &Path) -> ConfigLoadResult {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) => {
return ConfigLoadResult {
path: Some(path.to_path_buf()),
error: Some(ConfigLoadError::Io(e)),
..Default::default()
};
}
};
match toml::from_str(&content) {
Ok(config) => ConfigLoadResult {
config,
path: Some(path.to_path_buf()),
..Default::default()
},
Err(e) => ConfigLoadResult {
path: Some(path.to_path_buf()),
error: Some(ConfigLoadError::Parse(e)),
..Default::default()
},
}
}
pub fn load_config(disabled: bool, explicit_path: Option<&Path>) -> ConfigLoadResult {
if disabled {
return ConfigLoadResult::default();
}
let is_explicit = explicit_path.is_some();
let path = match explicit_path {
Some(p) => p.to_path_buf(),
None => match default_config_path() {
Some(p) => p,
None => {
return ConfigLoadResult::default();
}
},
};
if !is_explicit && !path.exists() {
return ConfigLoadResult {
path: Some(path),
..Default::default()
};
}
let mut result = load_from_path(&path);
result.explicit_path = is_explicit;
result
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn empty_config() {
let config: Config = toml::from_str("").unwrap();
assert!(config.ui.syntax_highlighting.is_none());
assert!(config.experimental.zsh_hooks.is_none());
assert!(config.experimental.terminal_shell_integration.is_none());
}
#[test]
fn full_config() {
let toml = r"
[ui]
syntax-highlighting = true
[experimental]
zsh-hooks = true
terminal-shell-integration = false
";
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.ui.syntax_highlighting, Some(true));
assert_eq!(config.experimental.zsh_hooks, Some(true));
assert_eq!(config.experimental.terminal_shell_integration, Some(false));
}
#[test]
fn partial_config() {
let toml = r"
[ui]
syntax-highlighting = false
";
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.ui.syntax_highlighting, Some(false));
assert!(config.experimental.zsh_hooks.is_none());
}
#[test]
fn unknown_fields_ignored() {
let toml = r#"
[ui]
syntax-highlighting = true
unknown-field = "should be ignored"
another-unknown = 42
[experimental]
zsh-hooks = false
future-feature = true
[unknown-section]
foo = "bar"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.ui.syntax_highlighting, Some(true));
assert_eq!(config.experimental.zsh_hooks, Some(false));
}
#[test]
fn load_config_disabled() {
let result = load_config(true, None);
assert!(result.path.is_none());
assert!(result.error.is_none());
}
#[test]
fn load_config_nonexistent_default() {
let result = load_config(false, None);
assert!(result.error.is_none());
}
#[test]
fn load_config_nonexistent_explicit() {
let path = Path::new("/nonexistent/path/to/config.toml");
let result = load_config(false, Some(path));
assert!(result.error.is_some());
assert!(matches!(result.error, Some(ConfigLoadError::Io(_))));
}
#[test]
fn to_ui_options_defaults_only() {
let config = Config::default();
let args = CommandLineArgs::default_values();
let ui = config.to_ui_options(&args);
assert!(!ui.disable_bracketed_paste);
assert!(!ui.disable_color);
assert!(!ui.terminal_shell_integration);
assert!(!ui.zsh_style_hooks);
}
#[test]
fn to_ui_options_config_overrides_defaults() {
let toml = r"
[ui]
syntax-highlighting = true
[experimental]
zsh-hooks = true
terminal-shell-integration = true
";
let config: Config = toml::from_str(toml).unwrap();
let args = CommandLineArgs::default_values();
let ui = config.to_ui_options(&args);
assert!(!ui.disable_highlighting); assert!(ui.terminal_shell_integration);
assert!(ui.zsh_style_hooks);
}
#[test]
fn to_ui_options_cli_overrides_config() {
let toml = r"
[ui]
syntax-highlighting = false
[experimental]
zsh-hooks = false
";
let config: Config = toml::from_str(toml).unwrap();
let args = CommandLineArgs::try_parse_from([
"brush",
"--enable-highlighting",
"--enable-zsh-hooks",
])
.unwrap();
let ui = config.to_ui_options(&args);
assert!(!ui.disable_highlighting); assert!(ui.zsh_style_hooks); }
#[test]
fn to_ui_options_cli_only_settings() {
let config = Config::default();
let args = CommandLineArgs::try_parse_from([
"brush",
"--disable-bracketed-paste",
"--disable-color",
])
.unwrap();
let ui = config.to_ui_options(&args);
assert!(ui.disable_bracketed_paste);
assert!(ui.disable_color);
}
}