1use brush_interactive::UIOptions;
9use etcetera::BaseStrategy;
10use std::path::{Path, PathBuf};
11
12use crate::args::CommandLineArgs;
13
14#[derive(Debug, Default, Clone, serde::Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(default)]
21pub struct Config {
22 pub ui: UiConfig,
24
25 pub experimental: ExperimentalConfig,
27}
28
29#[derive(Debug, Default, Clone, serde::Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[serde(default)]
33pub struct UiConfig {
34 #[serde(rename = "syntax-highlighting")]
36 pub syntax_highlighting: Option<bool>,
37}
38
39#[derive(Debug, Default, Clone, serde::Deserialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(default)]
45pub struct ExperimentalConfig {
46 #[serde(rename = "zsh-hooks")]
48 pub zsh_hooks: Option<bool>,
49
50 #[serde(rename = "terminal-shell-integration")]
52 pub terminal_shell_integration: Option<bool>,
53}
54
55impl Config {
56 #[must_use]
69 pub fn to_ui_options(&self, args: &CommandLineArgs) -> UIOptions {
70 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
100const 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_value
116 } else if let Some(config) = config_value {
117 config
119 } else {
120 cli_default
122 }
123}
124
125#[derive(Debug, Default)]
127pub struct ConfigLoadResult {
128 pub config: Config,
130
131 pub path: Option<PathBuf>,
133
134 pub error: Option<ConfigLoadError>,
136
137 pub explicit_path: bool,
139}
140
141impl ConfigLoadResult {
142 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 return Err(format!("failed to load config from {path_display}: {err}"));
164 }
165
166 tracing::warn!("failed to load config from {path_display}: {err}");
168 Ok(self.config)
169 }
170}
171
172#[derive(Debug, thiserror::Error)]
174pub enum ConfigLoadError {
175 #[error("failed to read config file: {0}")]
177 Io(#[from] std::io::Error),
178
179 #[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
187pub 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
203pub 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
238pub 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 return ConfigLoadResult::default();
264 }
265 },
266 };
267
268 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 let result = load_config(false, None);
355 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 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 let ui = config.to_ui_options(&args);
396
397 assert!(!ui.disable_highlighting); 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 let args = CommandLineArgs::try_parse_from([
416 "brush",
417 "--enable-highlighting",
418 "--enable-zsh-hooks",
419 ])
420 .unwrap();
421
422 let ui = config.to_ui_options(&args);
424
425 assert!(!ui.disable_highlighting); assert!(ui.zsh_style_hooks); }
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}