1use serde::Deserialize;
42use std::path::{Path, PathBuf};
43
44#[derive(Debug, Clone, Deserialize)]
51#[serde(default)]
52#[derive(Default)]
53pub struct ZshrsConfig {
54 pub worker_pool: WorkerPoolConfig,
56 pub completion: CompletionConfig,
58 pub compsys: CompsysConfig,
61 pub history: HistoryConfig,
63 pub glob: GlobConfig,
65 pub log: LogConfig,
67 pub zle: ZleConfig,
70}
71#[derive(Debug, Clone, Deserialize, Default)]
90#[serde(default)]
91pub struct CompsysConfig {
92 pub backend: CompsysBackend,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
100#[serde(rename_all = "lowercase")]
101pub enum CompsysBackend {
102 #[default]
104 Rust,
105 Shell,
108}
109
110#[derive(Debug, Clone, Deserialize)]
112#[serde(default)]
113#[derive(Default)]
114pub struct WorkerPoolConfig {
115 pub size: usize,
117}
118#[derive(Debug, Clone, Deserialize)]
120#[serde(default)]
121pub struct CompletionConfig {
122 pub max_matches: usize,
124 pub fts_enabled: bool,
126 pub ast_cache: bool,
128}
129#[derive(Debug, Clone, Deserialize)]
131#[serde(default)]
132pub struct HistoryConfig {
133 pub async_writes: bool,
135 pub max_entries: usize,
137}
138#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct GlobConfig {
142 pub parallel_threshold: usize,
144 pub recursive_parallel: bool,
146}
147#[derive(Debug, Clone, Deserialize)]
149#[serde(default)]
150pub struct LogConfig {
151 pub level: String,
153}
154
155#[derive(Debug, Clone, Deserialize, Default)]
161#[serde(default)]
162pub struct ZleConfig {
163 pub autosuggest: bool,
165 pub syntax_highlight: bool,
167 pub history_search: bool,
169 pub autopair: bool,
171 pub vi_backspace_unrestricted: bool,
175}
176
177impl Default for CompletionConfig {
180 fn default() -> Self {
181 Self {
182 max_matches: 1000,
183 fts_enabled: true,
184 ast_cache: true,
185 }
186 }
187}
188
189impl Default for HistoryConfig {
190 fn default() -> Self {
191 Self {
192 async_writes: true,
193 max_entries: 100_000,
194 }
195 }
196}
197
198impl Default for GlobConfig {
199 fn default() -> Self {
200 Self {
201 parallel_threshold: 32,
202 recursive_parallel: true,
203 }
204 }
205}
206
207impl Default for LogConfig {
208 fn default() -> Self {
209 Self {
210 level: "info".to_string(),
211 }
212 }
213}
214
215pub fn config_path() -> PathBuf {
225 crate::daemon_presence::config_file_path().unwrap_or_else(|| PathBuf::from("/tmp/zshrs.toml"))
226}
227
228pub fn load() -> ZshrsConfig {
232 load_from(&config_path())
233}
234
235pub fn current() -> &'static ZshrsConfig {
240 static CACHED: std::sync::OnceLock<ZshrsConfig> = std::sync::OnceLock::new();
241 CACHED.get_or_init(load)
242}
243
244pub fn load_from(path: &Path) -> ZshrsConfig {
248 match std::fs::read_to_string(path) {
249 Ok(content) => match toml::from_str(&content) {
250 Ok(config) => {
251 tracing::info!(path = %path.display(), "config loaded");
252 config
253 }
254 Err(e) => {
255 tracing::warn!(
256 path = %path.display(),
257 error = %e,
258 "config parse error, using defaults"
259 );
260 ZshrsConfig::default()
261 }
262 },
263 Err(_) => {
264 ZshrsConfig::default()
266 }
267 }
268}
269
270pub fn resolve_pool_size(config: &WorkerPoolConfig) -> usize {
275 if config.size > 0 {
276 config.size.clamp(1, 64)
277 } else {
278 std::thread::available_parallelism()
279 .map(|n| n.get())
280 .unwrap_or(4)
281 .clamp(2, 18)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn test_default_config() {
291 let _g = crate::test_util::global_state_lock();
292 let config = ZshrsConfig::default();
293 assert_eq!(config.worker_pool.size, 0);
294 assert_eq!(config.completion.max_matches, 1000);
295 assert!(config.completion.fts_enabled);
296 assert!(config.completion.ast_cache);
297 assert!(config.history.async_writes);
298 assert!(config.glob.recursive_parallel);
299 assert_eq!(config.glob.parallel_threshold, 32);
300 }
301
302 #[test]
303 fn test_parse_toml() {
304 let _g = crate::test_util::global_state_lock();
305 let toml = r#"
306[worker_pool]
307size = 4
308
309[completion]
310max_matches = 500
311ast_cache = false
312
313[glob]
314parallel_threshold = 64
315"#;
316 let config: ZshrsConfig = toml::from_str(toml).unwrap();
317 assert_eq!(config.worker_pool.size, 4);
318 assert_eq!(config.completion.max_matches, 500);
319 assert!(!config.completion.ast_cache);
320 assert_eq!(config.glob.parallel_threshold, 64);
321 assert!(config.history.async_writes);
323 assert!(config.glob.recursive_parallel);
324 }
325
326 #[test]
327 fn test_resolve_pool_size() {
328 let _g = crate::test_util::global_state_lock();
329 let auto = WorkerPoolConfig { size: 0 };
330 let resolved = resolve_pool_size(&auto);
331 assert!((2..=18).contains(&resolved));
332
333 let explicit = WorkerPoolConfig { size: 4 };
334 assert_eq!(resolve_pool_size(&explicit), 4);
335
336 let clamped = WorkerPoolConfig { size: 999 };
337 assert_eq!(resolve_pool_size(&clamped), 64);
338 }
339
340 #[test]
341 fn test_missing_file_returns_defaults() {
342 let _g = crate::test_util::global_state_lock();
343 let config = load_from(Path::new("/nonexistent/config.toml"));
344 assert_eq!(config.worker_pool.size, 0);
345 }
346
347 #[test]
352 fn pool_size_one_passes_through() {
353 let _g = crate::test_util::global_state_lock();
355 assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 1 }), 1);
356 }
357
358 #[test]
359 fn pool_size_64_is_upper_cap_for_explicit_request() {
360 let _g = crate::test_util::global_state_lock();
361 assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 64 }), 64);
362 assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 65 }), 64);
363 assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 100_000 }), 64);
364 }
365
366 #[test]
367 fn pool_size_zero_uses_auto_within_2_to_18_window() {
368 let _g = crate::test_util::global_state_lock();
369 let n = resolve_pool_size(&WorkerPoolConfig { size: 0 });
370 assert!(
371 (2..=18).contains(&n),
372 "auto-detect must clamp to [2,18], got {}",
373 n
374 );
375 }
376
377 #[test]
382 fn empty_toml_parses_to_full_defaults() {
383 let _g = crate::test_util::global_state_lock();
384 let cfg: ZshrsConfig = toml::from_str("").unwrap();
385 let d = ZshrsConfig::default();
386 assert_eq!(cfg.worker_pool.size, d.worker_pool.size);
387 assert_eq!(cfg.completion.max_matches, d.completion.max_matches);
388 assert_eq!(cfg.compsys.backend, d.compsys.backend);
389 assert_eq!(cfg.history.max_entries, d.history.max_entries);
390 assert_eq!(cfg.glob.parallel_threshold, d.glob.parallel_threshold);
391 assert_eq!(cfg.log.level, d.log.level);
392 }
393
394 #[test]
395 fn compsys_backend_defaults_to_rust() {
396 let _g = crate::test_util::global_state_lock();
397 let cfg = ZshrsConfig::default();
398 assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
399 }
400
401 #[test]
402 fn compsys_backend_parses_explicit_shell() {
403 let _g = crate::test_util::global_state_lock();
404 let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"shell\"\n").unwrap();
405 assert_eq!(cfg.compsys.backend, CompsysBackend::Shell);
406 }
407
408 #[test]
409 fn compsys_backend_parses_explicit_rust() {
410 let _g = crate::test_util::global_state_lock();
411 let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"rust\"\n").unwrap();
412 assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
413 }
414
415 #[test]
416 fn unknown_section_does_not_error_with_serde_default() {
417 let _g = crate::test_util::global_state_lock();
418 let toml = "[log]\nlevel = \"debug\"\n";
421 let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
422 assert_eq!(cfg.log.level, "debug");
423 }
424
425 #[test]
426 fn partial_completion_section_fills_other_defaults() {
427 let _g = crate::test_util::global_state_lock();
428 let toml = r#"
429[completion]
430max_matches = 42
431"#;
432 let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
433 assert_eq!(cfg.completion.max_matches, 42);
434 assert!(cfg.completion.fts_enabled);
436 assert!(cfg.completion.ast_cache);
437 }
438
439 #[test]
440 fn history_async_can_be_disabled() {
441 let _g = crate::test_util::global_state_lock();
442 let toml = "[history]\nasync_writes = false\n";
443 let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
444 assert!(!cfg.history.async_writes);
445 assert_eq!(cfg.history.max_entries, 100_000);
446 }
447
448 #[test]
449 fn glob_recursive_parallel_can_be_disabled() {
450 let _g = crate::test_util::global_state_lock();
451 let toml = "[glob]\nrecursive_parallel = false\n";
452 let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
453 assert!(!cfg.glob.recursive_parallel);
454 }
455
456 #[test]
461 fn malformed_toml_returns_defaults_not_panic() {
462 let _g = crate::test_util::global_state_lock();
464 let tmp = std::env::temp_dir().join("zshrs_config_malformed.toml");
465 std::fs::write(&tmp, "this is not [[ valid toml ===").unwrap();
466 let cfg = load_from(&tmp);
467 assert_eq!(cfg.worker_pool.size, 0);
469 assert_eq!(cfg.completion.max_matches, 1000);
470 let _ = std::fs::remove_file(&tmp);
471 }
472
473 #[test]
474 fn load_from_round_trip_via_temp_file() {
475 let _g = crate::test_util::global_state_lock();
476 let tmp = std::env::temp_dir().join("zshrs_config_rt.toml");
477 std::fs::write(&tmp, "[worker_pool]\nsize = 7\n").unwrap();
478 let cfg = load_from(&tmp);
479 assert_eq!(cfg.worker_pool.size, 7);
480 assert_eq!(resolve_pool_size(&cfg.worker_pool), 7);
481 let _ = std::fs::remove_file(&tmp);
482 }
483
484 #[test]
485 fn config_path_ends_in_config_toml() {
486 let _g = crate::test_util::global_state_lock();
487 let p = config_path();
488 assert_eq!(
494 p.file_name().and_then(|s| s.to_str()),
495 Some("zshrs.toml"),
496 "{:?}",
497 p
498 );
499 assert_eq!(
501 p.parent()
502 .and_then(|d| d.file_name())
503 .and_then(|s| s.to_str()),
504 Some(".zshrs"),
505 "{:?}",
506 p
507 );
508 }
509
510 #[test]
511 fn log_level_default_is_info_string() {
512 let _g = crate::test_util::global_state_lock();
513 let cfg = ZshrsConfig::default();
514 assert_eq!(cfg.log.level, "info");
515 }
516}