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//! [provenance]
41//! enabled = false     # disable the value-lineage engine outright
42//!                     # (default true; the engine still stays inert
43//!                     # until `provenance -m NAME` arms it).
44//!                     # `ZSHRS_PROVENANCE=0` is the env kill switch.
45//! track_all = true    # arm every parameter and every shell function
46//!                     # without any `-m` call (default false).
47//!                     # `ZSHRS_PROVENANCE_ALL=1`/`=0` overrides it.
48//! ```
49
50use serde::Deserialize;
51use std::path::{Path, PathBuf};
52
53/// Top-level config.
54/// zshrs-original — no C counterpart. Each section maps onto a
55/// Rust subsystem that doesn't exist in C zsh (worker pool,
56/// rkyv-mmap'd completion cache with optional SQLite FTS5
57/// mirrors for `dbview`, async history writes, parallel
58/// glob).
59#[derive(Debug, Clone, Deserialize)]
60#[serde(default)]
61#[derive(Default)]
62pub struct ZshrsConfig {
63    /// `worker_pool` field.
64    pub worker_pool: WorkerPoolConfig,
65    /// `completion` field.
66    pub completion: CompletionConfig,
67    /// `compsys` field — backend selector (rust vs shell) for the
68    /// `_main_complete` function tree. See [`CompsysConfig`].
69    pub compsys: CompsysConfig,
70    /// `history` field.
71    pub history: HistoryConfig,
72    /// `glob` field.
73    pub glob: GlobConfig,
74    /// `log` field.
75    pub log: LogConfig,
76    /// `zle` field — native fish-ported editor engines (opt-in;
77    /// `zshrs -f` stays zsh-identical by default). See [`ZleConfig`].
78    pub zle: ZleConfig,
79    /// `provenance` field — value-lineage engine master switch. See
80    /// [`ProvenanceConfig`].
81    pub provenance: ProvenanceConfig,
82    /// `ai` field — provider/model/cost defaults for the `ai` builtin.
83    /// See [`AiConfig`].
84    pub ai: AiConfig,
85}
86
87/// `[ai]` — defaults for the `ai` builtin (`src/extensions/ai.rs`).
88///
89/// Every field here is a DEFAULT: each one has a per-call flag that
90/// overrides it (`-P`, `-m`, `-t`, `-o`, `-n`), so the table only
91/// decides what an unadorned `ai "..."` does. The API key itself is
92/// never stored here — `api_key_env` names the environment variable to
93/// read, so a config file is safe to commit and safe to `cat`.
94///
95/// ```toml
96/// [ai]
97/// provider = "anthropic"
98/// model = "claude-opus-5"
99/// api_key_env = "ANTHROPIC_API_KEY"
100/// cache = true
101/// max_cost_run = 5.0
102/// max_tokens = 4096
103/// timeout = 120
104/// ```
105#[derive(Debug, Clone, Deserialize)]
106#[serde(default)]
107pub struct AiConfig {
108    /// `anthropic` (default), `openai`, `openai_compat` / `compat` /
109    /// `local`, `ollama`, or `gemini` / `google`.
110    pub provider: String,
111    /// Model id passed straight through to the provider.
112    pub model: String,
113    /// Name of the environment variable holding the API key. Only the
114    /// NAME lives in config; the key is read from the environment.
115    pub api_key_env: String,
116    /// In-process response cache, keyed on provider+model+system+prompt.
117    pub cache: bool,
118    /// Hard ceiling in USD on what one shell process may spend. `0`
119    /// disables the ceiling. Refusal is an error, not a prompt.
120    pub max_cost_run: f64,
121    /// Default `max_tokens` for a completion.
122    pub max_tokens: i64,
123    /// Default per-request timeout, in seconds.
124    pub timeout: i64,
125    /// Base URL for the `openai_compat` / `local` and `ollama`
126    /// providers. Empty means "use the provider's own default"
127    /// (`$STRYKE_AI_BASE_URL`-equivalent `$ZSHRS_AI_BASE_URL`, then
128    /// `http://localhost:1234/v1/chat/completions`; `$OLLAMA_HOST`,
129    /// then `http://localhost:11434`).
130    pub base_url: String,
131}
132
133impl Default for AiConfig {
134    fn default() -> Self {
135        Self {
136            provider: "anthropic".to_string(),
137            model: "claude-opus-5".to_string(),
138            api_key_env: "ANTHROPIC_API_KEY".to_string(),
139            cache: true,
140            max_cost_run: 5.0,
141            max_tokens: 4096,
142            timeout: 120,
143            base_url: String::new(),
144        }
145    }
146}
147/// Compsys backend selection — Rust port vs upstream shell functions.
148///
149/// `_main_complete` and the rest of the compsys function tree
150/// (`Completion/Base/Core/*`, `Zsh/Type/*`, `Zsh/Command/*`, ...)
151/// exist in two parallel forms in this repo:
152///
153/// 1. **Rust ports** under `src/compsys/ported/` — JIT-fast, no
154///    fork-exec, deterministic. Used when `backend = "rust"`.
155/// 2. **Upstream shell sources** under `src/zsh/Completion/` —
156///    autoloaded via `fpath` exactly like real zsh. Used when
157///    `backend = "shell"`. Required if you've patched a `_X`
158///    function in `.zshrc` and need the user override to win.
159///
160/// Default `"rust"`. Override per-machine in `~/.config/zshrs/config.toml`:
161/// ```toml
162/// [compsys]
163/// backend = "shell"
164/// ```
165#[derive(Debug, Clone, Deserialize, Default)]
166#[serde(default)]
167pub struct CompsysConfig {
168    /// Either `"rust"` (default) or `"shell"`. Any other value falls
169    /// back to `"rust"` with a one-shot tracing::warn at load time.
170    pub backend: CompsysBackend,
171}
172
173/// Strong-typed backend selector. Maps to the `backend = "..."`
174/// string in the TOML.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
176#[serde(rename_all = "lowercase")]
177pub enum CompsysBackend {
178    /// Route every `_NAME` call through `src/compsys/ported/`.
179    #[default]
180    Rust,
181    /// Route every `_NAME` call through the upstream shell function
182    /// at `Completion/.../$NAME` via the standard shfunc/autoload path.
183    Shell,
184}
185
186/// `WorkerPoolConfig` — see fields for layout.
187#[derive(Debug, Clone, Deserialize)]
188#[serde(default)]
189#[derive(Default)]
190pub struct WorkerPoolConfig {
191    /// Number of worker threads. 0 = auto (num_cpus clamped [2, 18]).
192    pub size: usize,
193}
194/// `CompletionConfig` — see fields for layout.
195#[derive(Debug, Clone, Deserialize)]
196#[serde(default)]
197pub struct CompletionConfig {
198    /// `max_matches` field.
199    pub max_matches: usize,
200    /// `fts_enabled` field.
201    pub fts_enabled: bool,
202    /// `ast_cache` field.
203    pub ast_cache: bool,
204}
205/// `HistoryConfig` — see fields for layout.
206#[derive(Debug, Clone, Deserialize)]
207#[serde(default)]
208pub struct HistoryConfig {
209    /// `async_writes` field.
210    pub async_writes: bool,
211    /// `max_entries` field.
212    pub max_entries: usize,
213}
214/// `GlobConfig` — see fields for layout.
215#[derive(Debug, Clone, Deserialize)]
216#[serde(default)]
217pub struct GlobConfig {
218    /// Minimum file count before parallel metadata prefetch kicks in.
219    pub parallel_threshold: usize,
220    /// Fan out **/ recursive globs across worker pool.
221    pub recursive_parallel: bool,
222}
223/// `LogConfig` — see fields for layout.
224#[derive(Debug, Clone, Deserialize)]
225#[serde(default)]
226pub struct LogConfig {
227    /// `level` field.
228    pub level: String,
229}
230
231/// `[zle]` — the native fish-ported line-editor engines and other
232/// deliberate ZLE deviations.
233///
234/// The four ENGINES default ON. They are the point of the shell, not an
235/// experiment to opt into, and a user who has an rc file is asking for a
236/// configured interactive shell. Set any field `false` in
237/// `~/.zshrs/zshrs.toml` to turn one off; `ZSHRS_NATIVE_ZLE_FX=0` remains
238/// the blanket kill switch.
239///
240/// Parity is preserved at the other end, but by ABSENCE rather than by
241/// `-f`: `zle_fx` refuses every engine when RCS is unset AND the file
242/// carries no `[zle]` table, so a machine that has never configured the
243/// editor — CI, a fresh install — keeps `zshrs -f` byte-identical to
244/// `zsh -f`. Writing a `[zle]` section is a statement about the editor
245/// this shell should be, and `-f` (which is about RC FILES, not about the
246/// shell's own config) does not overrule it.
247///
248/// `vi_backspace_unrestricted` stays OFF by default: it is a keybinding
249/// deviation from classic vi, not one of the engines, so it keeps opt-in
250/// semantics.
251#[derive(Debug, Clone, Deserialize)]
252#[serde(default)]
253pub struct ZleConfig {
254    /// True when `~/.zshrs/zshrs.toml` actually carries a `[zle]` table.
255    ///
256    /// Not a TOML key — `load_from` sets it after parsing, and `#[serde(skip)]`
257    /// keeps a stray `configured = true` in a user's file from forging it. It
258    /// exists so `zle_fx` can tell "the user said nothing about the editor"
259    /// from "the user configured the editor", which is the difference between
260    /// a bare `zshrs -f` and one that honours the config (see `zle_fx::enabled`).
261    #[serde(skip)]
262    pub configured: bool,
263    /// fish-ported history autosuggestions (ghost text).
264    pub autosuggest: bool,
265    /// fish-ported command-line syntax highlighting.
266    pub syntax_highlight: bool,
267    /// fish-ported up-arrow substring/prefix history search.
268    pub history_search: bool,
269    /// zsh-autopair port (bracket/quote auto-pairing).
270    pub autopair: bool,
271    /// viins ^H/DEL bound to unrestricted backward-delete-char
272    /// (vim's backspace=indent,eol,start) instead of the classic-vi
273    /// vi-backward-delete-char.
274    pub vi_backspace_unrestricted: bool,
275}
276
277impl Default for ZleConfig {
278    fn default() -> Self {
279        Self {
280            configured: false,
281            autosuggest: true,
282            syntax_highlight: true,
283            history_search: true,
284            autopair: true,
285            vi_backspace_unrestricted: false,
286        }
287    }
288}
289
290/// `[provenance]` — master switch for the value-lineage engine
291/// (`src/extensions/provenance.rs`).
292///
293/// The engine is already inert until a `provenance -m NAME` call arms
294/// it, so `enabled = true` (the default) costs one relaxed atomic load
295/// per hook site and nothing else. Setting `enabled = false` refuses
296/// arming entirely: `provenance -m` reports the engine as disabled and
297/// no hook can ever fire, which is the setting to use on a machine
298/// where the ledger must not exist at all.
299///
300/// ```toml
301/// [provenance]
302/// enabled = false
303/// ```
304///
305/// `track_all = true` skips the arming step entirely — every parameter
306/// write and every shell function records a chain:
307///
308/// ```toml
309/// [provenance]
310/// track_all = true
311/// ```
312///
313/// `ZSHRS_PROVENANCE=0` in the environment overrides the config and
314/// disables the engine regardless of these fields;
315/// `ZSHRS_PROVENANCE_ALL=1` / `=0` overrides `track_all` alone.
316#[derive(Debug, Clone, Deserialize)]
317#[serde(default)]
318pub struct ProvenanceConfig {
319    /// Allow `provenance -m NAME` to arm the lineage ledger.
320    pub enabled: bool,
321    /// Track everything, with no `-m` call: every parameter the shell
322    /// writes and every shell function it defines or calls arms itself.
323    /// Off by default — with it on, the engine is armed from the first
324    /// line of the first rc file, so every hook does real work for the
325    /// life of the shell. `ZSHRS_PROVENANCE_ALL=1` / `=0` overrides this
326    /// field; `enabled = false` still wins over both.
327    pub track_all: bool,
328}
329
330// ── Defaults ──
331
332impl Default for ProvenanceConfig {
333    fn default() -> Self {
334        Self {
335            enabled: true,
336            track_all: false,
337        }
338    }
339}
340
341impl Default for CompletionConfig {
342    fn default() -> Self {
343        Self {
344            max_matches: 1000,
345            fts_enabled: true,
346            ast_cache: true,
347        }
348    }
349}
350
351impl Default for HistoryConfig {
352    fn default() -> Self {
353        Self {
354            async_writes: true,
355            max_entries: 100_000,
356        }
357    }
358}
359
360impl Default for GlobConfig {
361    fn default() -> Self {
362        Self {
363            parallel_threshold: 32,
364            recursive_parallel: true,
365        }
366    }
367}
368
369impl Default for LogConfig {
370    fn default() -> Self {
371        Self {
372            level: "info".to_string(),
373        }
374    }
375}
376
377// ── Loading ──
378
379/// Config file path: `$ZSHRS_HOME/zshrs.toml` or
380/// `~/.zshrs/zshrs.toml`. Single file for the whole zshrs config
381/// surface — shares the path with `daemon_presence::load_zshrs_toml`
382/// (which parses the orthogonal `[log]/[daemon]/[shell]/[builtins]`
383/// sections). Unknown sections are ignored by serde (`#[serde(default)]`
384/// on every field), so the two loaders coexist in one file.
385/// zshrs-original — C zsh has no analog.
386pub fn config_path() -> PathBuf {
387    crate::daemon_presence::config_file_path().unwrap_or_else(|| PathBuf::from("/tmp/zshrs.toml"))
388}
389
390/// Load config from disk. Returns defaults if the file doesn't
391/// exist or fails to parse.
392/// zshrs-original — no C counterpart.
393pub fn load() -> ZshrsConfig {
394    load_from(&config_path())
395}
396
397/// Process-cached config snapshot. Read once on first access; the
398/// disk file is NOT re-read on each call (no reload-on-write). Hot
399/// paths like `dispatch_function_call` consult this without
400/// stat'ing / re-parsing the TOML.
401pub fn current() -> &'static ZshrsConfig {
402    static CACHED: std::sync::OnceLock<ZshrsConfig> = std::sync::OnceLock::new();
403    CACHED.get_or_init(load)
404}
405
406/// Load config from a specific path.
407/// zshrs-original — no C counterpart. Defaults preserve startup
408/// silently (per the project's "no startup chatter" rule).
409pub fn load_from(path: &Path) -> ZshrsConfig {
410    match std::fs::read_to_string(path) {
411        Ok(content) => match toml::from_str::<ZshrsConfig>(&content) {
412            Ok(mut config) => {
413                // Serde cannot report whether the `[zle]` TABLE was present —
414                // `#[serde(default)]` fills the struct either way — and the
415                // distinction is load-bearing: an explicitly configured editor
416                // survives `zshrs -f`, an unconfigured one does not. Ask the
417                // parsed document instead of the struct.
418                config.zle.configured = content
419                    .parse::<toml::Table>()
420                    .map(|t| t.contains_key("zle"))
421                    .unwrap_or(false);
422                tracing::info!(path = %path.display(), "config loaded");
423                config
424            }
425            Err(e) => {
426                tracing::warn!(
427                    path = %path.display(),
428                    error = %e,
429                    "config parse error, using defaults"
430                );
431                ZshrsConfig::default()
432            }
433        },
434        Err(_) => {
435            // No config file — use defaults silently
436            ZshrsConfig::default()
437        }
438    }
439}
440
441/// Resolve the worker pool size from config.
442/// `0` means auto: `available_parallelism` clamped to `[2, 18]`.
443/// zshrs-original — sizes the thread pool (`src/worker.rs`) that
444/// replaces C zsh's per-task `fork(2)` strategy.
445pub fn resolve_pool_size(config: &WorkerPoolConfig) -> usize {
446    if config.size > 0 {
447        config.size.clamp(1, 64)
448    } else {
449        std::thread::available_parallelism()
450            .map(|n| n.get())
451            .unwrap_or(4)
452            .clamp(2, 18)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn provenance_defaults_to_enabled_and_parses_the_off_switch() {
462        let _g = crate::test_util::global_state_lock();
463        assert!(
464            ZshrsConfig::default().provenance.enabled,
465            "the engine stays inert until armed, so the config default is on"
466        );
467        let off: ZshrsConfig = toml::from_str("[provenance]\nenabled = false\n").expect("parses");
468        assert!(!off.provenance.enabled);
469        // An unrelated section must not disturb the default.
470        let other: ZshrsConfig = toml::from_str("[log]\nlevel = \"debug\"\n").expect("parses");
471        assert!(other.provenance.enabled);
472    }
473
474    /// `[zle]` presence is what lets an explicitly configured editor survive
475    /// `zshrs -f` (zle_fx::engines_allowed), so it has to come from the TOML
476    /// DOCUMENT — serde fills the struct identically either way — and it must
477    /// not be forgeable by a key of the same name.
478    #[test]
479    fn zle_section_presence_is_read_from_the_document() {
480        let _g = crate::test_util::global_state_lock();
481        let dir = std::env::temp_dir().join(format!("zshrs-cfg-{}", std::process::id()));
482        let _ = std::fs::create_dir_all(&dir);
483        let path = dir.join("zshrs.toml");
484
485        // No [zle] table: the engines keep their defaults, but nothing was
486        // configured, so `-f` still gets a bare editor.
487        std::fs::write(&path, "[log]\nlevel = \"info\"\n").expect("write");
488        let none = load_from(&path);
489        assert!(!none.zle.configured, "no [zle] table means unconfigured");
490        assert!(none.zle.autosuggest, "the default is still on");
491
492        // An empty [zle] table is already a statement: the user configured the
493        // editor and accepted the defaults.
494        std::fs::write(&path, "[zle]\n").expect("write");
495        assert!(load_from(&path).zle.configured, "an empty [zle] table counts");
496
497        // Turning one engine off leaves the section configured.
498        std::fs::write(&path, "[zle]\nautosuggest = false\n").expect("write");
499        let off = load_from(&path);
500        assert!(off.zle.configured);
501        assert!(!off.zle.autosuggest);
502
503        // `configured` is #[serde(skip)]: writing it by hand under another
504        // section must not fabricate a configured editor.
505        std::fs::write(&path, "[log]\nlevel = \"info\"\nconfigured = true\n").expect("write");
506        assert!(
507            !load_from(&path).zle.configured,
508            "the marker comes from the [zle] table, not from a key"
509        );
510        let _ = std::fs::remove_file(&path);
511    }
512
513    #[test]
514    fn test_default_config() {
515        let _g = crate::test_util::global_state_lock();
516        let config = ZshrsConfig::default();
517        assert_eq!(config.worker_pool.size, 0);
518        assert_eq!(config.completion.max_matches, 1000);
519        assert!(config.completion.fts_enabled);
520        assert!(config.completion.ast_cache);
521        assert!(config.history.async_writes);
522        assert!(config.glob.recursive_parallel);
523        assert_eq!(config.glob.parallel_threshold, 32);
524    }
525
526    #[test]
527    fn test_parse_toml() {
528        let _g = crate::test_util::global_state_lock();
529        let toml = r#"
530[worker_pool]
531size = 4
532
533[completion]
534max_matches = 500
535ast_cache = false
536
537[glob]
538parallel_threshold = 64
539"#;
540        let config: ZshrsConfig = toml::from_str(toml).unwrap();
541        assert_eq!(config.worker_pool.size, 4);
542        assert_eq!(config.completion.max_matches, 500);
543        assert!(!config.completion.ast_cache);
544        assert_eq!(config.glob.parallel_threshold, 64);
545        // Unset fields use defaults
546        assert!(config.history.async_writes);
547        assert!(config.glob.recursive_parallel);
548    }
549
550    #[test]
551    fn test_resolve_pool_size() {
552        let _g = crate::test_util::global_state_lock();
553        let auto = WorkerPoolConfig { size: 0 };
554        let resolved = resolve_pool_size(&auto);
555        assert!((2..=18).contains(&resolved));
556
557        let explicit = WorkerPoolConfig { size: 4 };
558        assert_eq!(resolve_pool_size(&explicit), 4);
559
560        let clamped = WorkerPoolConfig { size: 999 };
561        assert_eq!(resolve_pool_size(&clamped), 64);
562    }
563
564    #[test]
565    fn test_missing_file_returns_defaults() {
566        let _g = crate::test_util::global_state_lock();
567        let config = load_from(Path::new("/nonexistent/config.toml"));
568        assert_eq!(config.worker_pool.size, 0);
569    }
570
571    // ========================================================
572    // resolve_pool_size — boundary behavior
573    // ========================================================
574
575    #[test]
576    fn pool_size_one_passes_through() {
577        // Explicit size=1 should NOT trigger the auto-detect branch.
578        let _g = crate::test_util::global_state_lock();
579        assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 1 }), 1);
580    }
581
582    #[test]
583    fn pool_size_64_is_upper_cap_for_explicit_request() {
584        let _g = crate::test_util::global_state_lock();
585        assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 64 }), 64);
586        assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 65 }), 64);
587        assert_eq!(resolve_pool_size(&WorkerPoolConfig { size: 100_000 }), 64);
588    }
589
590    #[test]
591    fn pool_size_zero_uses_auto_within_2_to_18_window() {
592        let _g = crate::test_util::global_state_lock();
593        let n = resolve_pool_size(&WorkerPoolConfig { size: 0 });
594        assert!(
595            (2..=18).contains(&n),
596            "auto-detect must clamp to [2,18], got {}",
597            n
598        );
599    }
600
601    // ========================================================
602    // Parser — defaults round-trip on empty input
603    // ========================================================
604
605    #[test]
606    fn empty_toml_parses_to_full_defaults() {
607        let _g = crate::test_util::global_state_lock();
608        let cfg: ZshrsConfig = toml::from_str("").unwrap();
609        let d = ZshrsConfig::default();
610        assert_eq!(cfg.worker_pool.size, d.worker_pool.size);
611        assert_eq!(cfg.completion.max_matches, d.completion.max_matches);
612        assert_eq!(cfg.compsys.backend, d.compsys.backend);
613        assert_eq!(cfg.history.max_entries, d.history.max_entries);
614        assert_eq!(cfg.glob.parallel_threshold, d.glob.parallel_threshold);
615        assert_eq!(cfg.log.level, d.log.level);
616    }
617
618    #[test]
619    fn compsys_backend_defaults_to_rust() {
620        let _g = crate::test_util::global_state_lock();
621        let cfg = ZshrsConfig::default();
622        assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
623    }
624
625    #[test]
626    fn compsys_backend_parses_explicit_shell() {
627        let _g = crate::test_util::global_state_lock();
628        let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"shell\"\n").unwrap();
629        assert_eq!(cfg.compsys.backend, CompsysBackend::Shell);
630    }
631
632    #[test]
633    fn compsys_backend_parses_explicit_rust() {
634        let _g = crate::test_util::global_state_lock();
635        let cfg: ZshrsConfig = toml::from_str("[compsys]\nbackend = \"rust\"\n").unwrap();
636        assert_eq!(cfg.compsys.backend, CompsysBackend::Rust);
637    }
638
639    #[test]
640    fn unknown_section_does_not_error_with_serde_default() {
641        let _g = crate::test_util::global_state_lock();
642        // Unknown top-level key is currently rejected when serde sees it
643        // — verify the explicit accepted shape stays accepted.
644        let toml = "[log]\nlevel = \"debug\"\n";
645        let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
646        assert_eq!(cfg.log.level, "debug");
647    }
648
649    #[test]
650    fn partial_completion_section_fills_other_defaults() {
651        let _g = crate::test_util::global_state_lock();
652        let toml = r#"
653[completion]
654max_matches = 42
655"#;
656        let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
657        assert_eq!(cfg.completion.max_matches, 42);
658        // Other fields fall back to defaults.
659        assert!(cfg.completion.fts_enabled);
660        assert!(cfg.completion.ast_cache);
661    }
662
663    #[test]
664    fn history_async_can_be_disabled() {
665        let _g = crate::test_util::global_state_lock();
666        let toml = "[history]\nasync_writes = false\n";
667        let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
668        assert!(!cfg.history.async_writes);
669        assert_eq!(cfg.history.max_entries, 100_000);
670    }
671
672    #[test]
673    fn glob_recursive_parallel_can_be_disabled() {
674        let _g = crate::test_util::global_state_lock();
675        let toml = "[glob]\nrecursive_parallel = false\n";
676        let cfg: ZshrsConfig = toml::from_str(toml).unwrap();
677        assert!(!cfg.glob.recursive_parallel);
678    }
679
680    // ========================================================
681    // load_from — IO-failure modes
682    // ========================================================
683
684    #[test]
685    fn malformed_toml_returns_defaults_not_panic() {
686        // Write a bogus file then point load_from at it.
687        let _g = crate::test_util::global_state_lock();
688        let tmp = std::env::temp_dir().join("zshrs_config_malformed.toml");
689        std::fs::write(&tmp, "this is not [[ valid toml ===").unwrap();
690        let cfg = load_from(&tmp);
691        // Defaults survive parse error.
692        assert_eq!(cfg.worker_pool.size, 0);
693        assert_eq!(cfg.completion.max_matches, 1000);
694        let _ = std::fs::remove_file(&tmp);
695    }
696
697    #[test]
698    fn load_from_round_trip_via_temp_file() {
699        let _g = crate::test_util::global_state_lock();
700        let tmp = std::env::temp_dir().join("zshrs_config_rt.toml");
701        std::fs::write(&tmp, "[worker_pool]\nsize = 7\n").unwrap();
702        let cfg = load_from(&tmp);
703        assert_eq!(cfg.worker_pool.size, 7);
704        assert_eq!(resolve_pool_size(&cfg.worker_pool), 7);
705        let _ = std::fs::remove_file(&tmp);
706    }
707
708    #[test]
709    fn config_path_ends_in_config_toml() {
710        let _g = crate::test_util::global_state_lock();
711        let p = config_path();
712        // Canonical name is `zshrs.toml` per
713        // daemon_presence::config_file_path (line 399) — documented at
714        // daemon_presence.rs:30 as `$ZSHRS_HOME/zshrs.toml` or
715        // `~/.zshrs/zshrs.toml`. Test name says "config_toml" but
716        // pins the actual canonical filename.
717        assert_eq!(
718            p.file_name().and_then(|s| s.to_str()),
719            Some("zshrs.toml"),
720            "{:?}",
721            p
722        );
723        // Parent dir is `.zshrs` (the hidden config home), not `zshrs`.
724        assert_eq!(
725            p.parent()
726                .and_then(|d| d.file_name())
727                .and_then(|s| s.to_str()),
728            Some(".zshrs"),
729            "{:?}",
730            p
731        );
732    }
733
734    #[test]
735    fn log_level_default_is_info_string() {
736        let _g = crate::test_util::global_state_lock();
737        let cfg = ZshrsConfig::default();
738        assert_eq!(cfg.log.level, "info");
739    }
740}