zsh/extensions/daemon_presence.rs
1//! Daemon-presence detection + per-user config knob.
2//!
3//! **zshrs-original infrastructure — no C source counterpart.** C
4//! zsh has no daemon process. Shell state lives in the running
5//! interpreter and re-initializes on every launch. zshrs ships
6//! `zshrs-daemon` as a separate binary that holds canonical
7//! parameter/option/function state in shared memory; the shell
8//! probes its socket at startup and decides whether to use the
9//! cached state or fall back to vanilla full-init mode.
10//!
11//! Per the `zshrs ↔ zshrs-daemon = independent binaries` rule
12//! (docs/DAEMON.md "Daemon lifecycle"), the shell does NOT spawn the
13//! daemon. It probes once at startup and runs in one of three modes:
14//!
15//! | Mode | Trigger |
16//! |------------------|-----------------------------------------------|
17//! | DaemonPresent | socket alive, daemon answered handshake |
18//! | DaemonAbsent | no socket / connect refused; degraded vanilla |
19//! | DaemonDisabled | user set `[daemon] enabled = "off"` in config |
20//!
21//! Without the daemon: zshrs runs as a Rust-fast vanilla zsh — no
22//! cache, no canonical state, every config re-evaluated per shell
23//! launch ("rebuilding your house every morning"). With the daemon:
24//! zshrs uses the cached canonical state for fast cold-start.
25//!
26//! The probe is single-shot at startup. Builtins / hooks check
27//! `is_present()` before calling into the daemon client; on absent,
28//! they noop or fall back to source-interp behavior.
29//!
30//! Config (`$ZSHRS_HOME/zshrs.toml` or `~/.zshrs/zshrs.toml`, all optional):
31//!
32//! ```toml
33//! [daemon]
34//! # "auto" (default) = probe at startup, use if alive
35//! # "off" = never probe; pure vanilla zsh mode
36//! # "require" = probe and warn if absent (no spawn either way)
37//! enabled = "auto"
38//!
39//! [shell]
40//! # "off" (default) = always source .zshenv/.zprofile/.zshrc/.zlogin
41//! # "auto" = if daemon is present + has zshrs rows, skip
42//! # every dotfile and apply canonical state
43//! # from the daemon instead. ~10ms cold-start.
44//! # "on" = always skip dotfiles when the daemon is up;
45//! # don't even check for zshrs rows. Strict mode.
46//! skip_configs = "off"
47//!
48//! # Optional: zsh script sourced once after dotfiles / canonical_apply,
49//! # before compsys + first prompt (omit key for default: none).
50//! # Respects `-f` / `--no-rcs` (not sourced).
51//! # startup_config = "/path/to/init.zsh"
52//! ```
53//!
54//! Lives in `~/.zshrs/` alongside everything else (rkyv shards,
55//! catalog.db, daemon.sock, zshrs-daemon.toml, log, …) — single
56//! directory rule for all zshrs files. Survives OS cache eviction
57//! (this is NOT cache-semantic state). `rm -rf ~/.zshrs/` is the
58//! one-verb total reset.
59
60use std::path::PathBuf;
61use std::sync::atomic::{AtomicU8, Ordering};
62use std::sync::Mutex;
63
64/// Daemon-presence probe result.
65/// zshrs-original — no C counterpart.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67#[repr(u8)]
68pub enum Mode {
69 /// Probe hasn't run yet.
70 Unknown = 0,
71 /// Socket connected; assume daemon is alive.
72 Present = 1,
73 /// Probe ran; daemon was not reachable. Shell runs in vanilla mode.
74 Absent = 2,
75 /// User opted out via `[daemon] enabled = "off"`. No probe attempted.
76 Disabled = 3,
77}
78
79impl Mode {
80 #[inline]
81 fn from_u8(v: u8) -> Self {
82 match v {
83 1 => Self::Present,
84 2 => Self::Absent,
85 3 => Self::Disabled,
86 _ => Self::Unknown,
87 }
88 }
89}
90
91static STATE: AtomicU8 = AtomicU8::new(Mode::Unknown as u8);
92
93/// What the user said in `[daemon].enabled` (or the auto default).
94/// zshrs-original — no C counterpart. C zsh has no daemon to enable.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ConfigSetting {
97 /// Probe at startup; use the daemon if alive (default).
98 Auto,
99 /// Skip the probe entirely; never talk to the daemon.
100 Off,
101 /// Probe at startup; if the daemon isn't alive, log a warning
102 /// (still doesn't spawn — that's the user's responsibility).
103 Require,
104}
105
106/// What the user said in `[shell].skip_configs`.
107/// Explicit discriminants pin the AtomicU8 round-trip in
108/// `skip_configs_setting()`.
109/// zshrs-original — no C counterpart. C zsh always sources every
110/// startup file (Src/init.c `source_home_file()` chain); the
111/// canonical-state path that lets us skip those is unique to zshrs.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113#[repr(u8)]
114pub enum SkipConfigs {
115 /// Always source dotfiles (legacy / vanilla zsh behavior). Default.
116 Off = 0,
117 /// Skip dotfiles iff daemon is present AND has zshrs canonical
118 /// rows. Falls back to dotfile sourcing otherwise. The recommended
119 /// setting once the recorder has populated canonical state.
120 Auto = 1,
121 /// Always skip dotfiles when the daemon is up; don't bother
122 /// checking for zshrs rows. Strict mode for users who know their
123 /// daemon is fully populated.
124 On = 2,
125}
126
127impl ConfigSetting {
128 fn parse(s: &str) -> Option<Self> {
129 match s {
130 "auto" | "" => Some(Self::Auto),
131 "off" | "false" | "no" | "0" => Some(Self::Off),
132 "require" | "on" | "true" | "yes" | "1" => Some(Self::Require),
133 _ => None,
134 }
135 }
136}
137
138impl SkipConfigs {
139 fn parse(s: &str) -> Option<Self> {
140 match s {
141 "off" | "false" | "no" | "0" | "" => Some(Self::Off),
142 "auto" => Some(Self::Auto),
143 "on" | "true" | "yes" | "1" => Some(Self::On),
144 _ => None,
145 }
146 }
147}
148
149/// Knobs from `~/.zshrs/zshrs.toml`. Missing file / section
150/// / key returns the safe defaults (`daemon=auto`,
151/// `skip_configs=off`, no `startup_config`). Unrecognized values
152/// fall back with a log warning.
153/// zshrs-original — no C counterpart.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Config {
156 /// `daemon` field.
157 pub daemon: ConfigSetting,
158 /// `skip_configs` field.
159 pub skip_configs: SkipConfigs,
160 /// Absolute or `~`-prefixed path to a zsh script sourced after
161 /// normal startup when set. `None` if the key is absent or empty.
162 pub startup_config: Option<PathBuf>,
163 /// `[builtins].coreutils_shadows` — whether to use in-process
164 /// coreutils shadow builtins (`cat`/`head`/`tail`/`sort`/`cut`/
165 /// `tr`/`find`/`cp`/...) for the fork-elimination speedup, or
166 /// fall through to the real `/bin/X` binaries.
167 ///
168 /// **Default `off`** — old scripts run against the canonical
169 /// system coreutils out of the box, no edge-case divergence
170 /// surprises. Opt IN to the speedup with the following stanza
171 /// in `~/.zshrs/zshrs.toml`, or via `ZSHRS_COREUTILS_SHADOWS=1`
172 /// env for one-off testing:
173 ///
174 /// ```toml
175 /// [builtins]
176 /// coreutils_shadows = "on"
177 /// ```
178 pub coreutils_shadows: bool,
179}
180
181/// Resolved `[shell].startup_config` from the last `probe()` (same
182/// process). `None` when unset, omitted, or probe has not run.
183static STARTUP_CONFIG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
184
185fn resolve_startup_config_path(raw: &str) -> PathBuf {
186 let s = raw.trim();
187 if let Some(rest) = s.strip_prefix("~/") {
188 return dirs::home_dir()
189 .map(|h| h.join(rest))
190 .unwrap_or_else(|| PathBuf::from(s));
191 }
192 if s == "~" {
193 return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
194 }
195 PathBuf::from(s)
196}
197/// `read_config_full` — see implementation.
198pub fn read_config_full() -> Config {
199 let defaults = Config {
200 daemon: ConfigSetting::Auto,
201 skip_configs: SkipConfigs::Off,
202 startup_config: None,
203 // OFF by default — old scripts run against system /bin/X
204 // out of the box. Opt-in to the in-process speedup via
205 // [builtins].coreutils_shadows = on.
206 coreutils_shadows: false,
207 };
208 let path = match config_file_path() {
209 Some(p) => p,
210 None => return defaults,
211 };
212 let body = match std::fs::read_to_string(&path) {
213 Ok(s) => s,
214 Err(_) => return defaults,
215 };
216 let parsed = match body.parse::<toml::Table>() {
217 Ok(t) => t,
218 Err(e) => {
219 tracing::warn!(path = %path.display(), error = %e, "zshrs.toml: parse failed; using defaults");
220 return defaults;
221 }
222 };
223 let daemon = parsed
224 .get("daemon")
225 .and_then(|v| v.as_table())
226 .and_then(|t| t.get("enabled"))
227 .and_then(|v| v.as_str())
228 .map(|s| {
229 ConfigSetting::parse(s).unwrap_or_else(|| {
230 tracing::warn!(
231 value = s,
232 "zshrs.toml: [daemon].enabled invalid; using auto"
233 );
234 ConfigSetting::Auto
235 })
236 })
237 .unwrap_or(ConfigSetting::Auto);
238 let skip_configs = parsed
239 .get("shell")
240 .and_then(|v| v.as_table())
241 .and_then(|t| t.get("skip_configs"))
242 .and_then(|v| v.as_str())
243 .map(|s| {
244 SkipConfigs::parse(s).unwrap_or_else(|| {
245 tracing::warn!(
246 value = s,
247 "zshrs.toml: [shell].skip_configs invalid; using off"
248 );
249 SkipConfigs::Off
250 })
251 })
252 .unwrap_or(SkipConfigs::Off);
253 let startup_config = parsed
254 .get("shell")
255 .and_then(|v| v.as_table())
256 .and_then(|t| t.get("startup_config"))
257 .and_then(|v| {
258 if let Some(s) = v.as_str() {
259 let t = s.trim();
260 if t.is_empty() {
261 None
262 } else {
263 Some(resolve_startup_config_path(t))
264 }
265 } else {
266 tracing::warn!("zshrs.toml: [shell].startup_config must be a string; ignoring");
267 None
268 }
269 });
270 // `[builtins].coreutils_shadows` — accept booleans (`true`/`false`)
271 // OR string aliases (`on`/`off`/`yes`/`no`). String-aliased
272 // matches how `[daemon].enabled` accepts `auto`/`off`/`require`
273 // so the file stays toml-string-uniform.
274 let coreutils_shadows = parsed
275 .get("builtins")
276 .and_then(|v| v.as_table())
277 .and_then(|t| t.get("coreutils_shadows"))
278 .map(|v| match v {
279 toml::Value::Boolean(b) => *b,
280 toml::Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
281 "off" | "false" | "no" | "0" => false,
282 "on" | "true" | "yes" | "1" => true,
283 other => {
284 tracing::warn!(
285 value = other,
286 "zshrs.toml: [builtins].coreutils_shadows invalid; using off"
287 );
288 false
289 }
290 },
291 _ => {
292 tracing::warn!(
293 "zshrs.toml: [builtins].coreutils_shadows must be bool or string; using off"
294 );
295 false
296 }
297 })
298 .unwrap_or(false);
299 Config {
300 daemon,
301 skip_configs,
302 startup_config,
303 coreutils_shadows,
304 }
305}
306
307/// Snapshot of `[builtins].coreutils_shadows` cached after the first
308/// `coreutils_shadows_enabled()` lookup. `OnceLock` lazy so the toml
309/// read is one-shot, then every subsequent dispatch through
310/// `reg_overridable!` becomes a single atomic load.
311static CORE_SHADOWS_CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
312
313/// Hot-path query for whether to dispatch the in-process coreutils
314/// shadow (`cat`/`cp`/`find`/`sort`/etc) or fall through to the
315/// system binary. Read by the `reg_overridable!` macro in
316/// `fusevm_bridge.rs` on every shadowed-builtin invocation. O(1)
317/// after first call.
318pub fn coreutils_shadows_enabled() -> bool {
319 *CORE_SHADOWS_CACHE.get_or_init(|| {
320 // `--zsh` strict-parity mode NEVER shadows externals — `head`
321 // / `cat` / `sort` must be the system binaries with the
322 // system binaries' exact flag handling and diagnostics
323 // (`head -0` is an error on BSD head, the shadow accepted
324 // it). Unconditional: parity floor outranks the toml knob
325 // and the env override.
326 if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
327 return false;
328 }
329 // Env override wins for one-off testing:
330 // ZSHRS_COREUTILS_SHADOWS=1 — force ON
331 // ZSHRS_COREUTILS_SHADOWS=0 — force OFF
332 // Absent → fall through to toml / default-off.
333 if let Ok(v) = std::env::var("ZSHRS_COREUTILS_SHADOWS") {
334 let v = v.trim().to_ascii_lowercase();
335 if matches!(v.as_str(), "1" | "on" | "true" | "yes") {
336 return true;
337 }
338 if matches!(v.as_str(), "0" | "off" | "false" | "no" | "") {
339 return false;
340 }
341 }
342 read_config_full().coreutils_shadows
343 })
344}
345
346/// Path from `[shell].startup_config` captured during `probe()`.
347#[inline]
348pub fn startup_config_path() -> Option<PathBuf> {
349 STARTUP_CONFIG_PATH.lock().ok().and_then(|g| g.clone())
350}
351
352/// Back-compat wrapper kept for callers that only need the daemon knob.
353pub fn read_config() -> ConfigSetting {
354 read_config_full().daemon
355}
356
357/// `[log] level` from `~/.zshrs/zshrs.toml` for the SHELL side
358/// (zsh::log + zshrs-recorder). Same precedence model the daemon uses
359/// for its own side: `$ZSHRS_LOG` env wins, then this directive, then
360/// `"info"`. Caller hands the returned string straight to
361/// `EnvFilter::try_new`. Errors / missing file / missing key all
362/// resolve to "info" silently — a malformed directive produces a
363/// useful "bad directive" message at the EnvFilter parse layer
364/// instead.
365pub fn read_log_directive() -> String {
366 const DEFAULT: &str = "info";
367 let path = match config_file_path() {
368 Some(p) => p,
369 None => return DEFAULT.into(),
370 };
371 let body = match std::fs::read_to_string(&path) {
372 Ok(s) => s,
373 Err(_) => return DEFAULT.into(),
374 };
375 let parsed = match body.parse::<toml::Table>() {
376 Ok(t) => t,
377 Err(_) => return DEFAULT.into(),
378 };
379 parsed
380 .get("log")
381 .and_then(|v| v.as_table())
382 .and_then(|t| t.get("level"))
383 .and_then(|v| v.as_str())
384 .filter(|s| !s.is_empty())
385 .map(str::to_string)
386 .unwrap_or_else(|| DEFAULT.into())
387}
388
389/// Cached `[shell].skip_configs` value. Set by `probe()`; read by the
390/// shell-init path before sourcing dotfiles.
391static SKIP_CONFIGS: AtomicU8 = AtomicU8::new(0);
392
393/// Have we confirmed the daemon has zshrs canonical rows? Set during
394/// the same probe pass so the `skip_configs` decision is one atomic
395/// load on the hot path.
396static SHOULD_SKIP_CONFIGS: AtomicU8 = AtomicU8::new(0);
397
398/// Resolve `$ZSHRS_HOME/zshrs.toml` or `~/.zshrs/zshrs.toml`.
399/// Single-directory rule: every zshrs file lives under one root.
400/// Returns None if neither $ZSHRS_HOME nor $HOME is set, which is
401/// rare enough to treat as "no config file".
402pub fn config_file_path() -> Option<std::path::PathBuf> {
403 let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
404 std::path::PathBuf::from(custom)
405 } else {
406 std::path::PathBuf::from(std::env::var_os("HOME")?).join(".zshrs")
407 };
408 Some(root.join("zshrs.toml"))
409}
410
411/// Cheap probe — connect-only, no handshake — does the daemon
412/// socket answer?
413/// zshrs-original — no C counterpart. Sets the global state so
414/// subsequent `is_present()` checks are O(1) atomic loads.
415///
416/// Honors `[daemon].enabled`:
417/// - `Off` → state = Disabled, no probe
418/// - `Auto` / `Require` → probe; state = Present or Absent
419///
420/// `Require` additionally logs a warning if the daemon isn't alive —
421/// signal to the user that they configured the shell to expect a
422/// daemon but didn't actually start one.
423pub fn probe() -> Mode {
424 let cfg = read_config_full();
425 if let Ok(mut slot) = STARTUP_CONFIG_PATH.lock() {
426 *slot = cfg.startup_config.clone();
427 }
428 SKIP_CONFIGS.store(cfg.skip_configs as u8, Ordering::Relaxed);
429
430 match cfg.daemon {
431 ConfigSetting::Off => {
432 tracing::info!("daemon: disabled in config ([daemon] enabled = \"off\")");
433 STATE.store(Mode::Disabled as u8, Ordering::Relaxed);
434 // Disabled daemon → no skip; dotfiles always source.
435 SHOULD_SKIP_CONFIGS.store(0, Ordering::Relaxed);
436 return Mode::Disabled;
437 }
438 ConfigSetting::Auto | ConfigSetting::Require => {}
439 }
440
441 let alive = probe_socket();
442 let mode = if alive { Mode::Present } else { Mode::Absent };
443 STATE.store(mode as u8, Ordering::Relaxed);
444
445 if alive {
446 tracing::info!("daemon: present (socket reachable)");
447 } else {
448 match cfg.daemon {
449 ConfigSetting::Require => {
450 tracing::warn!(
451 "daemon: absent — config requires it but socket is not reachable. \
452 Start it via `zshrs-daemon`, `systemctl --user start zshrs-daemon`, \
453 `launchctl load ~/Library/LaunchAgents/com.menketechnologies.zshrs-daemon.plist`, \
454 or `brew services start zshrs`. Falling back to vanilla mode."
455 );
456 }
457 _ => {
458 tracing::info!(
459 "daemon: absent (socket not reachable) — running in vanilla zsh mode"
460 );
461 }
462 }
463 }
464
465 // Resolve [shell].skip_configs against daemon presence + zshrs-row
466 // availability. Three settings collapse to a yes/no decision:
467 // Off → never skip
468 // On → skip iff daemon Present (don't even check rows)
469 // Auto → skip iff daemon Present AND has zshrs canonical rows
470 let should_skip = match cfg.skip_configs {
471 SkipConfigs::Off => false,
472 SkipConfigs::On => mode == Mode::Present,
473 SkipConfigs::Auto => mode == Mode::Present && daemon_has_zshrs_rows(),
474 };
475 SHOULD_SKIP_CONFIGS.store(if should_skip { 1 } else { 0 }, Ordering::Relaxed);
476 if should_skip {
477 tracing::info!(
478 "shell: skip_configs active — bypassing /etc/zshenv + ~/.{{zshenv,zprofile,zshrc,zlogin}} and \
479 applying canonical state from daemon"
480 );
481 } else if cfg.skip_configs != SkipConfigs::Off {
482 tracing::info!(
483 mode = ?cfg.skip_configs,
484 daemon = ?mode,
485 "shell: skip_configs configured but conditions not met — sourcing dotfiles normally"
486 );
487 }
488 mode
489}
490
491/// Cheap probe: does the daemon have a recorder shard on disk for
492/// shell_id "zshrs"? A `*-recorder.rkyv` file in `~/.zshrs/images/`
493/// means the recorder ran at least once and we have canonical state to
494/// apply. **No IPC** — this is a directory listing + filename match,
495/// because the shell cold-start path can afford zero IPC roundtrips
496/// (the architecture's whole speed thesis).
497///
498/// Returns false on any I/O error → caller falls through to vanilla
499/// `source_startup_files()`.
500/// Is the recorded environment STALE relative to the user's rc files?
501///
502/// "Configs are ignored after the recorder" is the speed thesis, but that
503/// makes an edited `.zshrc` invisible until the user re-runs the recorder.
504/// This is the cheap staleness oracle: compare the newest rc-file mtime
505/// against the newest `*-recorder.rkyv` shard mtime. Returns the path of the
506/// offending rc file when any rc is newer than the recording (i.e. the user
507/// edited config and hasn't re-recorded), else `None`.
508///
509/// NO IPC and NO re-sourcing — a directory listing + a handful of `stat`s,
510/// so it stays within the cold-start budget. The caller decides the channel;
511/// per the no-startup-chatter rule this is logged (never printed to the tty)
512/// and surfaced in `--doctor`.
513#[cfg(feature = "daemon")]
514pub fn recording_staleness() -> Option<String> {
515 use std::time::SystemTime;
516 let paths = crate::daemon::paths::CachePaths::resolve().ok()?;
517 // Newest recorder shard mtime.
518 let mut newest_shard: Option<SystemTime> = None;
519 for entry in std::fs::read_dir(&paths.images).ok()?.flatten() {
520 if entry
521 .file_name()
522 .to_str()
523 .map(|s| s.ends_with("-recorder.rkyv"))
524 .unwrap_or(false)
525 {
526 if let Ok(m) = entry.metadata().and_then(|md| md.modified()) {
527 if newest_shard.map(|n| m > n).unwrap_or(true) {
528 newest_shard = Some(m);
529 }
530 }
531 }
532 }
533 let shard_mtime = newest_shard?; // no recording yet → not "stale", just absent
534 // Any rc file newer than the recording?
535 let zdotdir = std::env::var("ZDOTDIR")
536 .or_else(|_| std::env::var("HOME"))
537 .unwrap_or_default();
538 let rc_set = [
539 "/etc/zshenv".to_string(),
540 format!("{}/.zshenv", zdotdir),
541 "/etc/zprofile".to_string(),
542 format!("{}/.zprofile", zdotdir),
543 "/etc/zshrc".to_string(),
544 format!("{}/.zshrc", zdotdir),
545 "/etc/zlogin".to_string(),
546 format!("{}/.zlogin", zdotdir),
547 ];
548 let mut newest_rc: Option<(SystemTime, String)> = None;
549 for path in &rc_set {
550 if let Ok(m) = std::fs::metadata(path).and_then(|md| md.modified()) {
551 if newest_rc.as_ref().map(|(n, _)| m > *n).unwrap_or(true) {
552 newest_rc = Some((m, path.clone()));
553 }
554 }
555 }
556 match newest_rc {
557 Some((rc_mtime, rc_path)) if rc_mtime > shard_mtime => Some(rc_path),
558 _ => None,
559 }
560}
561
562/// Non-daemon builds have no recorder shard — nothing to be stale against.
563#[cfg(not(feature = "daemon"))]
564pub fn recording_staleness() -> Option<String> {
565 None
566}
567
568/// Does a recorder shard exist at all? Distinguishes "no recording" (this
569/// shell sources rc files normally) from "recording present and fresh" — so
570/// `--doctor` doesn't claim "up to date" when nothing was ever recorded.
571#[cfg(feature = "daemon")]
572pub fn recording_present() -> bool {
573 crate::daemon::paths::CachePaths::resolve()
574 .ok()
575 .and_then(|p| std::fs::read_dir(&p.images).ok())
576 .map(|it| {
577 it.flatten().any(|e| {
578 e.file_name()
579 .to_str()
580 .map(|s| s.ends_with("-recorder.rkyv"))
581 .unwrap_or(false)
582 })
583 })
584 .unwrap_or(false)
585}
586
587#[cfg(not(feature = "daemon"))]
588pub fn recording_present() -> bool {
589 false
590}
591
592#[cfg(feature = "daemon")]
593fn daemon_has_zshrs_rows() -> bool {
594 let paths = match crate::daemon::paths::CachePaths::resolve() {
595 Ok(p) => p,
596 Err(_) => return false,
597 };
598 let entries = match std::fs::read_dir(&paths.images) {
599 Ok(it) => it,
600 Err(_) => return false,
601 };
602 for entry in entries.flatten() {
603 if let Some(s) = entry.file_name().to_str() {
604 if s.ends_with("-recorder.rkyv") {
605 return true;
606 }
607 }
608 }
609 false
610}
611
612#[cfg(not(feature = "daemon"))]
613fn daemon_has_zshrs_rows() -> bool {
614 false
615}
616
617/// Should the shell-init path skip every `/etc/zsh*` + `~/.zsh*`
618/// dotfile and apply canonical state from the daemon instead? O(1)
619/// atomic load — set by `probe()` at startup.
620/// zshrs-original — no C counterpart. C zsh's Src/init.c always
621/// sources every startup file unconditionally.
622#[inline]
623pub fn should_skip_configs() -> bool {
624 SHOULD_SKIP_CONFIGS.load(Ordering::Relaxed) != 0
625}
626
627/// Read the cached `[shell].skip_configs` setting (verbatim from
628/// config — independent of whether conditions to skip were met).
629/// Mostly useful for diagnostics / `zshrs --doctor`.
630pub fn skip_configs_setting() -> SkipConfigs {
631 match SKIP_CONFIGS.load(Ordering::Relaxed) {
632 1 => SkipConfigs::Auto,
633 2 => SkipConfigs::On,
634 _ => SkipConfigs::Off,
635 }
636}
637
638/// Run the probe via the daemon-client crate's cheap is-alive helper.
639/// Falls back to a manual socket check if the daemon feature is off
640/// (which is the workspace's stub-mode build path).
641#[cfg(feature = "daemon")]
642fn probe_socket() -> bool {
643 match crate::daemon::paths::CachePaths::resolve() {
644 Ok(paths) => crate::daemon::client::Client::is_daemon_alive(&paths),
645 Err(_) => false,
646 }
647}
648
649#[cfg(not(feature = "daemon"))]
650fn probe_socket() -> bool {
651 false
652}
653
654/// O(1) read of the cached probe result. Returns `Unknown` until
655/// `probe()` has run.
656/// zshrs-original — no C counterpart.
657#[inline]
658pub fn current() -> Mode {
659 Mode::from_u8(STATE.load(Ordering::Relaxed))
660}
661
662/// Did the probe see a live daemon? `false` for any other state
663/// (`Unknown` / `Absent` / `Disabled`).
664/// zshrs-original — no C counterpart.
665#[inline]
666pub fn is_present() -> bool {
667 current() == Mode::Present
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673 use std::path::PathBuf;
674
675 #[test]
676 fn config_setting_parses_common_aliases() {
677 let _g = crate::test_util::global_state_lock();
678 assert_eq!(ConfigSetting::parse("auto"), Some(ConfigSetting::Auto));
679 assert_eq!(ConfigSetting::parse(""), Some(ConfigSetting::Auto));
680 assert_eq!(ConfigSetting::parse("off"), Some(ConfigSetting::Off));
681 assert_eq!(ConfigSetting::parse("false"), Some(ConfigSetting::Off));
682 assert_eq!(ConfigSetting::parse("no"), Some(ConfigSetting::Off));
683 assert_eq!(ConfigSetting::parse("0"), Some(ConfigSetting::Off));
684 assert_eq!(
685 ConfigSetting::parse("require"),
686 Some(ConfigSetting::Require)
687 );
688 assert_eq!(ConfigSetting::parse("on"), Some(ConfigSetting::Require));
689 assert_eq!(ConfigSetting::parse("true"), Some(ConfigSetting::Require));
690 assert_eq!(ConfigSetting::parse("garbage"), None);
691 }
692
693 #[test]
694 fn mode_round_trips_through_atomic() {
695 let _g = crate::test_util::global_state_lock();
696 for m in [Mode::Unknown, Mode::Present, Mode::Absent, Mode::Disabled] {
697 assert_eq!(Mode::from_u8(m as u8), m);
698 }
699 }
700
701 #[test]
702 fn resolve_startup_config_path_absolute_trims() {
703 let _g = crate::test_util::global_state_lock();
704 assert_eq!(
705 super::resolve_startup_config_path(" /tmp/x.zsh "),
706 PathBuf::from("/tmp/x.zsh")
707 );
708 }
709
710 #[test]
711 fn resolve_startup_config_path_tilde() {
712 let _g = crate::test_util::global_state_lock();
713 let home = dirs::home_dir().expect("HOME");
714 assert_eq!(
715 super::resolve_startup_config_path("~/init.zsh"),
716 home.join("init.zsh")
717 );
718 }
719
720 #[test]
721 fn read_config_full_startup_config_from_zshrs_home() {
722 let _g = crate::test_util::global_state_lock();
723 use std::sync::Mutex;
724 static ENV_LOCK: Mutex<()> = Mutex::new(());
725 let _lock = ENV_LOCK.lock().expect("env test lock");
726 let dir = tempfile::tempdir().expect("tempdir");
727 std::fs::write(
728 dir.path().join("zshrs.toml"),
729 "[shell]\nstartup_config = \"/tmp/zshrs-startup-test.zsh\"\n",
730 )
731 .expect("write zshrs.toml");
732 unsafe {
733 std::env::set_var("ZSHRS_HOME", dir.path());
734 }
735 let cfg = read_config_full();
736 unsafe {
737 std::env::remove_var("ZSHRS_HOME");
738 }
739 assert_eq!(
740 cfg.startup_config,
741 Some(PathBuf::from("/tmp/zshrs-startup-test.zsh"))
742 );
743 }
744}