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}