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}