Skip to main content

zsh/
config.rs

1//! zshrs configuration file — `~/.config/zshrs/config.toml`
2//!
3//! Runtime settings that don't belong in .zshrc (shell script).
4//! These control the Rust engine, not the shell language.
5//!
6//! Example config:
7//! ```toml
8//! [worker_pool]
9//! size = 8            # number of worker threads (default: num_cpus, clamped [2, 18])
10//!
11//! [completion]
12//! max_matches = 1000  # max completion results to display
13//! fts_enabled = true  # use SQLite FTS5 for completion search
14//! ast_cache = true    # pre-parse autoload functions to AST blobs
15//!
16//! [history]
17//! async_writes = true # write history on worker pool (don't block prompt)
18//! max_entries = 100000
19//!
20//! [glob]
21//! parallel_threshold = 32  # min files before parallel metadata prefetch
22//! recursive_parallel = true  # fan out **/ across worker pool
23//!
24//! [log]
25//! level = "info"      # trace, debug, info, warn, error
26//! ```
27
28use serde::Deserialize;
29use std::path::{Path, PathBuf};
30
31/// Top-level config
32#[derive(Debug, Clone, Deserialize)]
33#[serde(default)]
34pub struct ZshrsConfig {
35    pub worker_pool: WorkerPoolConfig,
36    pub completion: CompletionConfig,
37    pub history: HistoryConfig,
38    pub glob: GlobConfig,
39    pub log: LogConfig,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(default)]
44pub struct WorkerPoolConfig {
45    /// Number of worker threads. 0 = auto (num_cpus clamped [2, 18]).
46    pub size: usize,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50#[serde(default)]
51pub struct CompletionConfig {
52    pub max_matches: usize,
53    pub fts_enabled: bool,
54    pub ast_cache: bool,
55}
56
57#[derive(Debug, Clone, Deserialize)]
58#[serde(default)]
59pub struct HistoryConfig {
60    pub async_writes: bool,
61    pub max_entries: usize,
62}
63
64#[derive(Debug, Clone, Deserialize)]
65#[serde(default)]
66pub struct GlobConfig {
67    /// Minimum file count before parallel metadata prefetch kicks in.
68    pub parallel_threshold: usize,
69    /// Fan out **/ recursive globs across worker pool.
70    pub recursive_parallel: bool,
71}
72
73#[derive(Debug, Clone, Deserialize)]
74#[serde(default)]
75pub struct LogConfig {
76    pub level: String,
77}
78
79// ── Defaults ──
80
81impl Default for ZshrsConfig {
82    fn default() -> Self {
83        Self {
84            worker_pool: WorkerPoolConfig::default(),
85            completion: CompletionConfig::default(),
86            history: HistoryConfig::default(),
87            glob: GlobConfig::default(),
88            log: LogConfig::default(),
89        }
90    }
91}
92
93impl Default for WorkerPoolConfig {
94    fn default() -> Self {
95        Self { size: 0 } // 0 = auto
96    }
97}
98
99impl Default for CompletionConfig {
100    fn default() -> Self {
101        Self {
102            max_matches: 1000,
103            fts_enabled: true,
104            ast_cache: true,
105        }
106    }
107}
108
109impl Default for HistoryConfig {
110    fn default() -> Self {
111        Self {
112            async_writes: true,
113            max_entries: 100_000,
114        }
115    }
116}
117
118impl Default for GlobConfig {
119    fn default() -> Self {
120        Self {
121            parallel_threshold: 32,
122            recursive_parallel: true,
123        }
124    }
125}
126
127impl Default for LogConfig {
128    fn default() -> Self {
129        Self {
130            level: "info".to_string(),
131        }
132    }
133}
134
135// ── Loading ──
136
137/// Config file path: `~/.config/zshrs/config.toml`
138pub fn config_path() -> PathBuf {
139    dirs::config_dir()
140        .unwrap_or_else(|| PathBuf::from("/tmp"))
141        .join("zshrs")
142        .join("config.toml")
143}
144
145/// Load config from disk. Returns defaults if file doesn't exist or is invalid.
146pub fn load() -> ZshrsConfig {
147    load_from(&config_path())
148}
149
150/// Load config from a specific path.
151pub fn load_from(path: &Path) -> ZshrsConfig {
152    match std::fs::read_to_string(path) {
153        Ok(content) => match toml::from_str(&content) {
154            Ok(config) => {
155                tracing::info!(path = %path.display(), "config loaded");
156                config
157            }
158            Err(e) => {
159                tracing::warn!(
160                    path = %path.display(),
161                    error = %e,
162                    "config parse error, using defaults"
163                );
164                ZshrsConfig::default()
165            }
166        },
167        Err(_) => {
168            // No config file — use defaults silently
169            ZshrsConfig::default()
170        }
171    }
172}
173
174/// Resolve worker pool size from config.
175/// 0 = auto = available_parallelism clamped [2, 18].
176pub fn resolve_pool_size(config: &WorkerPoolConfig) -> usize {
177    if config.size > 0 {
178        config.size.clamp(1, 64)
179    } else {
180        std::thread::available_parallelism()
181            .map(|n| n.get())
182            .unwrap_or(4)
183            .clamp(2, 18)
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn test_default_config() {
193        let config = ZshrsConfig::default();
194        assert_eq!(config.worker_pool.size, 0);
195        assert_eq!(config.completion.max_matches, 1000);
196        assert!(config.completion.fts_enabled);
197        assert!(config.completion.ast_cache);
198        assert!(config.history.async_writes);
199        assert!(config.glob.recursive_parallel);
200        assert_eq!(config.glob.parallel_threshold, 32);
201    }
202
203    #[test]
204    fn test_parse_toml() {
205        let toml = r#"
206[worker_pool]
207size = 4
208
209[completion]
210max_matches = 500
211ast_cache = false
212
213[glob]
214parallel_threshold = 64
215"#;
216        let config: ZshrsConfig = toml::from_str(toml).unwrap();
217        assert_eq!(config.worker_pool.size, 4);
218        assert_eq!(config.completion.max_matches, 500);
219        assert!(!config.completion.ast_cache);
220        assert_eq!(config.glob.parallel_threshold, 64);
221        // Unset fields use defaults
222        assert!(config.history.async_writes);
223        assert!(config.glob.recursive_parallel);
224    }
225
226    #[test]
227    fn test_resolve_pool_size() {
228        let auto = WorkerPoolConfig { size: 0 };
229        let resolved = resolve_pool_size(&auto);
230        assert!(resolved >= 2 && resolved <= 18);
231
232        let explicit = WorkerPoolConfig { size: 4 };
233        assert_eq!(resolve_pool_size(&explicit), 4);
234
235        let clamped = WorkerPoolConfig { size: 999 };
236        assert_eq!(resolve_pool_size(&clamped), 64);
237    }
238
239    #[test]
240    fn test_missing_file_returns_defaults() {
241        let config = load_from(Path::new("/nonexistent/config.toml"));
242        assert_eq!(config.worker_pool.size, 0);
243    }
244}