Skip to main content

zsh/extensions/
config.rs

1//! zshrs configuration file — `~/.config/zshrs/config.toml`.
2//!
3//! **zshrs-original infrastructure — no C source counterpart.** C
4//! zsh has no equivalent because every runtime knob lives in shell
5//! options (Src/options.c) or special parameters
6//! (Src/params.c). This file controls the Rust engine — worker-pool
7//! size, completion-cache enablement, async-history writes — none
8//! of which exist in C zsh.
9//!
10//! Runtime settings that don't belong in .zshrc (shell script).
11//! These control the Rust engine, not the shell language.
12//!
13//! Example config:
14//! ```toml
15//! [worker_pool]
16//! size = 8            # number of worker threads (default: num_cpus, clamped [2, 18])
17//!
18//! [completion]
19//! max_matches = 1000  # max completion results to display
20//! fts_enabled = true  # populate the SQLite FTS5 mirror tables (rkyv shards are the authoritative completion cache; this toggle only affects `dbview` / SQL inspection)
21//! ast_cache = true    # pre-parse autoload functions to AST blobs
22//!
23//! [compsys]
24//! backend = "rust"    # "rust" → src/compsys/ported/ ports;
25//!                     # "shell" → upstream Completion/ shell funcs
26//!                     # via autoload (use this if you've patched
27//!                     # any _X in .zshrc).
28//!
29//! [history]
30//! async_writes = true # write history on worker pool (don't block prompt)
31//! max_entries = 100000
32//!
33//! [glob]
34//! parallel_threshold = 32  # min files before parallel metadata prefetch
35//! recursive_parallel = true  # fan out **/ across worker pool
36//!
37//! [log]
38//! level = "info"      # trace, debug, info, warn, error
39//! ```
40
41use serde::Deserialize;
42use std::path::{Path, PathBuf};
43
44/// Top-level config.
45/// zshrs-original — no C counterpart. Each section maps onto a
46/// Rust subsystem that doesn't exist in C zsh (worker pool,
47/// rkyv-mmap'd completion cache with optional SQLite FTS5
48/// mirrors for `dbview`, async history writes, parallel
49/// glob).
50#[derive(Debug, Clone, Deserialize)]
51#[serde(default)]
52#[derive(Default)]
53pub struct ZshrsConfig {
54    /// `worker_pool` field.
55    pub worker_pool: WorkerPoolConfig,
56    /// `completion` field.
57    pub completion: CompletionConfig,
58    /// `compsys` field — backend selector (rust vs shell) for the
59    /// `_main_complete` function tree. See [`CompsysConfig`].
60    pub compsys: CompsysConfig,
61    /// `history` field.
62    pub history: HistoryConfig,
63    /// `glob` field.
64    pub glob: GlobConfig,
65    /// `log` field.
66    pub log: LogConfig,
67    /// `zle` field — native fish-ported editor engines (opt-in;
68    /// `zshrs -f` stays zsh-identical by default). See [`ZleConfig`].
69    pub zle: ZleConfig,
70}
71/// Compsys backend selection — Rust port vs upstream shell functions.
72///
73/// `_main_complete` and the rest of the compsys function tree
74/// (`Completion/Base/Core/*`, `Zsh/Type/*`, `Zsh/Command/*`, ...)
75/// exist in two parallel forms in this repo:
76///
77/// 1. **Rust ports** under `src/compsys/ported/` — JIT-fast, no
78///    fork-exec, deterministic. Used when `backend = "rust"`.
79/// 2. **Upstream shell sources** under `src/zsh/Completion/` —
80///    autoloaded via `fpath` exactly like real zsh. Used when
81///    `backend = "shell"`. Required if you've patched a `_X`
82///    function in `.zshrc` and need the user override to win.
83///
84/// Default `"rust"`. Override per-machine in `~/.config/zshrs/config.toml`:
85/// ```toml
86/// [compsys]
87/// backend = "shell"
88/// ```
89#[derive(Debug, Clone, Deserialize, Default)]
90#[serde(default)]
91pub struct CompsysConfig {
92    /// Either `"rust"` (default) or `"shell"`. Any other value falls
93    /// back to `"rust"` with a one-shot tracing::warn at load time.
94    pub backend: CompsysBackend,
95}
96
97/// Strong-typed backend selector. Maps to the `backend = "..."`
98/// string in the TOML.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
100#[serde(rename_all = "lowercase")]
101pub enum CompsysBackend {
102    /// Route every `_NAME` call through `src/compsys/ported/`.
103    #[default]
104    Rust,
105    /// Route every `_NAME` call through the upstream shell function
106    /// at `Completion/.../$NAME` via the standard shfunc/autoload path.
107    Shell,
108}
109
110/// `WorkerPoolConfig` — see fields for layout.
111#[derive(Debug, Clone, Deserialize)]
112#[serde(default)]
113#[derive(Default)]
114pub struct WorkerPoolConfig {
115    /// Number of worker threads. 0 = auto (num_cpus clamped [2, 18]).
116    pub size: usize,
117}
118/// `CompletionConfig` — see fields for layout.
119#[derive(Debug, Clone, Deserialize)]
120#[serde(default)]
121pub struct CompletionConfig {
122    /// `max_matches` field.
123    pub max_matches: usize,
124    /// `fts_enabled` field.
125    pub fts_enabled: bool,
126    /// `ast_cache` field.
127    pub ast_cache: bool,
128}
129/// `HistoryConfig` — see fields for layout.
130#[derive(Debug, Clone, Deserialize)]
131#[serde(default)]
132pub struct HistoryConfig {
133    /// `async_writes` field.
134    pub async_writes: bool,
135    /// `max_entries` field.
136    pub max_entries: usize,
137}
138/// `GlobConfig` — see fields for layout.
139#[derive(Debug, Clone, Deserialize)]
140#[serde(default)]
141pub struct GlobConfig {
142    /// Minimum file count before parallel metadata prefetch kicks in.
143    pub parallel_threshold: usize,
144    /// Fan out **/ recursive globs across worker pool.
145    pub recursive_parallel: bool,
146}
147/// `LogConfig` — see fields for layout.
148#[derive(Debug, Clone, Deserialize)]
149#[serde(default)]
150pub struct LogConfig {
151    /// `level` field.
152    pub level: String,
153}
154
155/// `[zle]` — the native fish-ported line-editor engines and other
156/// deliberate ZLE deviations. ALL DEFAULT OFF: `zshrs -f` must behave
157/// identically to `zsh -f` for parity purposes; these are opt-in via
158/// `~/.zshrs/zshrs.toml`. `ZSHRS_NATIVE_ZLE_FX=0` still force-disables
159/// the engines regardless of config (emergency kill switch).
160#[derive(Debug, Clone, Deserialize, Default)]
161#[serde(default)]
162pub struct ZleConfig {
163    /// fish-ported history autosuggestions (ghost text).
164    pub autosuggest: bool,
165    /// fish-ported command-line syntax highlighting.
166    pub syntax_highlight: bool,
167    /// fish-ported up-arrow substring/prefix history search.
168    pub history_search: bool,
169    /// zsh-autopair port (bracket/quote auto-pairing).
170    pub autopair: bool,
171    /// viins ^H/DEL bound to unrestricted backward-delete-char
172    /// (vim's backspace=indent,eol,start) instead of the classic-vi
173    /// vi-backward-delete-char.
174    pub vi_backspace_unrestricted: bool,
175}
176
177// ── Defaults ──
178
179impl 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
215// ── Loading ──
216
217/// Config file path: `$ZSHRS_HOME/zshrs.toml` or
218/// `~/.zshrs/zshrs.toml`. Single file for the whole zshrs config
219/// surface — shares the path with `daemon_presence::load_zshrs_toml`
220/// (which parses the orthogonal `[log]/[daemon]/[shell]/[builtins]`
221/// sections). Unknown sections are ignored by serde (`#[serde(default)]`
222/// on every field), so the two loaders coexist in one file.
223/// zshrs-original — C zsh has no analog.
224pub fn config_path() -> PathBuf {
225    crate::daemon_presence::config_file_path().unwrap_or_else(|| PathBuf::from("/tmp/zshrs.toml"))
226}
227
228/// Load config from disk. Returns defaults if the file doesn't
229/// exist or fails to parse.
230/// zshrs-original — no C counterpart.
231pub fn load() -> ZshrsConfig {
232    load_from(&config_path())
233}
234
235/// Process-cached config snapshot. Read once on first access; the
236/// disk file is NOT re-read on each call (no reload-on-write). Hot
237/// paths like `dispatch_function_call` consult this without
238/// stat'ing / re-parsing the TOML.
239pub fn current() -> &'static ZshrsConfig {
240    static CACHED: std::sync::OnceLock<ZshrsConfig> = std::sync::OnceLock::new();
241    CACHED.get_or_init(load)
242}
243
244/// Load config from a specific path.
245/// zshrs-original — no C counterpart. Defaults preserve startup
246/// silently (per the project's "no startup chatter" rule).
247pub 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            // No config file — use defaults silently
265            ZshrsConfig::default()
266        }
267    }
268}
269
270/// Resolve the worker pool size from config.
271/// `0` means auto: `available_parallelism` clamped to `[2, 18]`.
272/// zshrs-original — sizes the thread pool (`src/worker.rs`) that
273/// replaces C zsh's per-task `fork(2)` strategy.
274pub 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        // Unset fields use defaults
322        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    // ========================================================
348    // resolve_pool_size — boundary behavior
349    // ========================================================
350
351    #[test]
352    fn pool_size_one_passes_through() {
353        // Explicit size=1 should NOT trigger the auto-detect branch.
354        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    // ========================================================
378    // Parser — defaults round-trip on empty input
379    // ========================================================
380
381    #[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        // Unknown top-level key is currently rejected when serde sees it
419        // — verify the explicit accepted shape stays accepted.
420        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        // Other fields fall back to defaults.
435        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    // ========================================================
457    // load_from — IO-failure modes
458    // ========================================================
459
460    #[test]
461    fn malformed_toml_returns_defaults_not_panic() {
462        // Write a bogus file then point load_from at it.
463        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        // Defaults survive parse error.
468        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        // Canonical name is `zshrs.toml` per
489        // daemon_presence::config_file_path (line 399) — documented at
490        // daemon_presence.rs:30 as `$ZSHRS_HOME/zshrs.toml` or
491        // `~/.zshrs/zshrs.toml`. Test name says "config_toml" but
492        // pins the actual canonical filename.
493        assert_eq!(
494            p.file_name().and_then(|s| s.to_str()),
495            Some("zshrs.toml"),
496            "{:?}",
497            p
498        );
499        // Parent dir is `.zshrs` (the hidden config home), not `zshrs`.
500        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}