use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct ZshrsConfig {
pub worker_pool: WorkerPoolConfig,
pub completion: CompletionConfig,
pub compsys: CompsysConfig,
pub history: HistoryConfig,
pub glob: GlobConfig,
pub log: LogConfig,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(default)]
pub struct CompsysConfig {
pub backend: CompsysBackend,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CompsysBackend {
#[default]
Rust,
Shell,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[derive(Default)]
pub struct WorkerPoolConfig {
pub size: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CompletionConfig {
pub max_matches: usize,
pub fts_enabled: bool,
pub ast_cache: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct HistoryConfig {
pub async_writes: bool,
pub max_entries: usize,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct GlobConfig {
pub parallel_threshold: usize,
pub recursive_parallel: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LogConfig {
pub level: String,
}
impl Default for CompletionConfig {
fn default() -> Self {
Self {
max_matches: 1000,
fts_enabled: true,
ast_cache: true,
}
}
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
async_writes: true,
max_entries: 100_000,
}
}
}
impl Default for GlobConfig {
fn default() -> Self {
Self {
parallel_threshold: 32,
recursive_parallel: true,
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: "info".to_string(),
}
}
}
pub fn config_path() -> PathBuf {
crate::daemon_presence::config_file_path().unwrap_or_else(|| PathBuf::from("/tmp/zshrs.toml"))
}
pub fn load() -> ZshrsConfig {
load_from(&config_path())
}
pub fn current() -> &'static ZshrsConfig {
static CACHED: std::sync::OnceLock<ZshrsConfig> = std::sync::OnceLock::new();
CACHED.get_or_init(load)
}
pub fn load_from(path: &Path) -> ZshrsConfig {
match std::fs::read_to_string(path) {
Ok(content) => match toml::from_str(&content) {
Ok(config) => {
tracing::info!(path = %path.display(), "config loaded");
config
}
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"config parse error, using defaults"
);
ZshrsConfig::default()
}
},
Err(_) => {
ZshrsConfig::default()
}
}
}
pub fn resolve_pool_size(config: &WorkerPoolConfig) -> usize {
if config.size > 0 {
config.size.clamp(1, 64)
} else {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
.clamp(2, 18)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let _g = crate::test_util::global_state_lock();
let config = ZshrsConfig::default();
assert_eq!(config.worker_pool.size, 0);
assert_eq!(config.completion.max_matches, 1000);
assert!(config.completion.fts_enabled);
assert!(config.completion.ast_cache);
assert!(config.history.async_writes);
assert!(config.glob.recursive_parallel);
assert_eq!(config.glob.parallel_threshold, 32);
}
#[test]
fn test_parse_toml() {
let _g = crate::test_util::global_state_lock();
let toml = r#"
[worker_pool]
size = 4
[completion]
max_matches = 500
ast_cache = false
[glob]
parallel_threshold = 64
"#;
let config: ZshrsConfig = toml::from_str(toml).unwrap();
assert_eq!(config.worker_pool.size, 4);
assert_eq!(config.completion.max_matches, 500);
assert!(!config.completion.ast_cache);
assert_eq!(config.glob.parallel_threshold, 64);
assert!(config.history.async_writes);
assert!(config.glob.recursive_parallel);
}
#[test]
fn test_resolve_pool_size() {
let _g = crate::test_util::global_state_lock();
let auto = WorkerPoolConfig { size: 0 };
let resolved = resolve_pool_size(&auto);
assert!((2..=18).contains(&resolved));
let explicit = WorkerPoolConfig { size: 4 };
assert_eq!(resolve_pool_size(&explicit), 4);
let clamped = WorkerPoolConfig { size: 999 };
assert_eq!(resolve_pool_size(&clamped), 64);
}
#[test]
fn test_missing_file_returns_defaults() {
let _g = crate::test_util::global_state_lock();
let config = load_from(Path::new("/nonexistent/config.toml"));
assert_eq!(config.worker_pool.size, 0);
}
#[test]
fn pool_size_one_passes_through() {
let _g = crate::test_util::global_state_lock();
assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 1 }), 1);
}
#[test]
fn pool_size_64_is_upper_cap_for_explicit_request() {
let _g = crate::test_util::global_state_lock();
assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 64 }), 64);
assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 65 }), 64);
assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 100_000 }), 64);
}
#[test]
fn pool_size_zero_uses_auto_within_2_to_18_window() {
let _g = crate::test_util::global_state_lock();
let n = resolve_pool_size(&WorkerPoolConfig { size: 0 });
assert!(
(2..=18).contains(&n),
"auto-detect must clamp to [2,18], got {}",
n
);
}
#[test]
fn empty_toml_parses_to_full_defaults() {
let _g = crate::test_util::global_state_lock();
let cfg: ZshrsConfig = toml::from_str("").unwrap();
let d = ZshrsConfig::default();
assert_eq!(cfg.worker_pool.size, d.worker_pool.size);
assert_eq!(cfg.completion.max_matches, d.completion.max_matches);
assert_eq!(cfg.compsys.backend, d.compsys.backend);
assert_eq!(cfg.history.max_entries, d.history.max_entries);
assert_eq!(cfg.glob.parallel_threshold, d.glob.parallel_threshold);
assert_eq!(cfg.log.level, d.log.level);
}
#[test]
fn compsys_backend_defaults_to_rust() {
let _g = crate::test_util::global_state_lock();
let cfg = ZshrsConfig::default();
assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
}
#[test]
fn compsys_backend_parses_explicit_shell() {
let _g = crate::test_util::global_state_lock();
let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"shell\"\n").unwrap();
assert_eq!(cfg.compsys.backend, CompsysBackend::Shell);
}
#[test]
fn compsys_backend_parses_explicit_rust() {
let _g = crate::test_util::global_state_lock();
let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"rust\"\n").unwrap();
assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
}
#[test]
fn unknown_section_does_not_error_with_serde_default() {
let _g = crate::test_util::global_state_lock();
let toml = "[log]\nlevel = \"debug\"\n";
let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.log.level, "debug");
}
#[test]
fn partial_completion_section_fills_other_defaults() {
let _g = crate::test_util::global_state_lock();
let toml = r#"
[completion]
max_matches = 42
"#;
let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.completion.max_matches, 42);
assert!(cfg.completion.fts_enabled);
assert!(cfg.completion.ast_cache);
}
#[test]
fn history_async_can_be_disabled() {
let _g = crate::test_util::global_state_lock();
let toml = "[history]\nasync_writes = false\n";
let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
assert!(!cfg.history.async_writes);
assert_eq!(cfg.history.max_entries, 100_000);
}
#[test]
fn glob_recursive_parallel_can_be_disabled() {
let _g = crate::test_util::global_state_lock();
let toml = "[glob]\nrecursive_parallel = false\n";
let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
assert!(!cfg.glob.recursive_parallel);
}
#[test]
fn malformed_toml_returns_defaults_not_panic() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join("zshrs_config_malformed.toml");
std::fs::write(&tmp, "this is not [[ valid toml ===").unwrap();
let cfg = load_from(&tmp);
assert_eq!(cfg.worker_pool.size, 0);
assert_eq!(cfg.completion.max_matches, 1000);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn load_from_round_trip_via_temp_file() {
let _g = crate::test_util::global_state_lock();
let tmp = std::env::temp_dir().join("zshrs_config_rt.toml");
std::fs::write(&tmp, "[worker_pool]\nsize = 7\n").unwrap();
let cfg = load_from(&tmp);
assert_eq!(cfg.worker_pool.size, 7);
assert_eq!(resolve_pool_size(&cfg.worker_pool), 7);
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn config_path_ends_in_config_toml() {
let _g = crate::test_util::global_state_lock();
let p = config_path();
assert_eq!(
p.file_name().and_then(|s| s.to_str()),
Some("zshrs.toml"),
"{:?}",
p
);
assert_eq!(
p.parent()
.and_then(|d| d.file_name())
.and_then(|s| s.to_str()),
Some(".zshrs"),
"{:?}",
p
);
}
#[test]
fn log_level_default_is_info_string() {
let _g = crate::test_util::global_state_lock();
let cfg = ZshrsConfig::default();
assert_eq!(cfg.log.level, "info");
}
}