Skip to main content

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