use std::path::PathBuf;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Mutex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Mode {
Unknown = 0,
Present = 1,
Absent = 2,
Disabled = 3,
}
impl Mode {
#[inline]
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Present,
2 => Self::Absent,
3 => Self::Disabled,
_ => Self::Unknown,
}
}
}
static STATE: AtomicU8 = AtomicU8::new(Mode::Unknown as u8);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSetting {
Auto,
Off,
Require,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum SkipConfigs {
Off = 0,
Auto = 1,
On = 2,
}
impl ConfigSetting {
fn parse(s: &str) -> Option<Self> {
match s {
"auto" | "" => Some(Self::Auto),
"off" | "false" | "no" | "0" => Some(Self::Off),
"require" | "on" | "true" | "yes" | "1" => Some(Self::Require),
_ => None,
}
}
}
impl SkipConfigs {
fn parse(s: &str) -> Option<Self> {
match s {
"off" | "false" | "no" | "0" | "" => Some(Self::Off),
"auto" => Some(Self::Auto),
"on" | "true" | "yes" | "1" => Some(Self::On),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub daemon: ConfigSetting,
pub skip_configs: SkipConfigs,
pub startup_config: Option<PathBuf>,
}
static STARTUP_CONFIG_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
fn resolve_startup_config_path(raw: &str) -> PathBuf {
let s = raw.trim();
if let Some(rest) = s.strip_prefix("~/") {
return dirs::home_dir()
.map(|h| h.join(rest))
.unwrap_or_else(|| PathBuf::from(s));
}
if s == "~" {
return dirs::home_dir().unwrap_or_else(|| PathBuf::from(s));
}
PathBuf::from(s)
}
pub fn read_config_full() -> Config {
let defaults = Config {
daemon: ConfigSetting::Auto,
skip_configs: SkipConfigs::Off,
startup_config: None,
};
let path = match config_file_path() {
Some(p) => p,
None => return defaults,
};
let body = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return defaults,
};
let parsed = match body.parse::<toml::Table>() {
Ok(t) => t,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "zshrs.toml: parse failed; using defaults");
return defaults;
}
};
let daemon = parsed
.get("daemon")
.and_then(|v| v.as_table())
.and_then(|t| t.get("enabled"))
.and_then(|v| v.as_str())
.map(|s| {
ConfigSetting::parse(s).unwrap_or_else(|| {
tracing::warn!(value = s, "zshrs.toml: [daemon].enabled invalid; using auto");
ConfigSetting::Auto
})
})
.unwrap_or(ConfigSetting::Auto);
let skip_configs = parsed
.get("shell")
.and_then(|v| v.as_table())
.and_then(|t| t.get("skip_configs"))
.and_then(|v| v.as_str())
.map(|s| {
SkipConfigs::parse(s).unwrap_or_else(|| {
tracing::warn!(value = s, "zshrs.toml: [shell].skip_configs invalid; using off");
SkipConfigs::Off
})
})
.unwrap_or(SkipConfigs::Off);
let startup_config = parsed
.get("shell")
.and_then(|v| v.as_table())
.and_then(|t| t.get("startup_config"))
.and_then(|v| {
if let Some(s) = v.as_str() {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(resolve_startup_config_path(t))
}
} else {
tracing::warn!(
"zshrs.toml: [shell].startup_config must be a string; ignoring"
);
None
}
});
Config {
daemon,
skip_configs,
startup_config,
}
}
#[inline]
pub fn startup_config_path() -> Option<PathBuf> {
STARTUP_CONFIG_PATH.lock().ok().and_then(|g| g.clone())
}
pub fn read_config() -> ConfigSetting {
read_config_full().daemon
}
pub fn read_log_directive() -> String {
const DEFAULT: &str = "info";
let path = match config_file_path() {
Some(p) => p,
None => return DEFAULT.into(),
};
let body = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return DEFAULT.into(),
};
let parsed = match body.parse::<toml::Table>() {
Ok(t) => t,
Err(_) => return DEFAULT.into(),
};
parsed
.get("log")
.and_then(|v| v.as_table())
.and_then(|t| t.get("level"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| DEFAULT.into())
}
static SKIP_CONFIGS: AtomicU8 = AtomicU8::new(0);
static SHOULD_SKIP_CONFIGS: AtomicU8 = AtomicU8::new(0);
pub fn config_file_path() -> Option<std::path::PathBuf> {
let root = if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
std::path::PathBuf::from(custom)
} else {
std::path::PathBuf::from(std::env::var_os("HOME")?).join(".zshrs")
};
Some(root.join("zshrs.toml"))
}
pub fn probe() -> Mode {
let cfg = read_config_full();
if let Ok(mut slot) = STARTUP_CONFIG_PATH.lock() {
*slot = cfg.startup_config.clone();
}
SKIP_CONFIGS.store(cfg.skip_configs as u8, Ordering::Relaxed);
match cfg.daemon {
ConfigSetting::Off => {
tracing::info!("daemon: disabled in config ([daemon] enabled = \"off\")");
STATE.store(Mode::Disabled as u8, Ordering::Relaxed);
SHOULD_SKIP_CONFIGS.store(0, Ordering::Relaxed);
return Mode::Disabled;
}
ConfigSetting::Auto | ConfigSetting::Require => {}
}
let alive = probe_socket();
let mode = if alive { Mode::Present } else { Mode::Absent };
STATE.store(mode as u8, Ordering::Relaxed);
if alive {
tracing::info!("daemon: present (socket reachable)");
} else {
match cfg.daemon {
ConfigSetting::Require => {
tracing::warn!(
"daemon: absent — config requires it but socket is not reachable. \
Start it via `zshrs-daemon`, `systemctl --user start zshrs-daemon`, \
`launchctl load ~/Library/LaunchAgents/com.menketechnologies.zshrs-daemon.plist`, \
or `brew services start zshrs`. Falling back to vanilla mode."
);
}
_ => {
tracing::info!(
"daemon: absent (socket not reachable) — running in vanilla zsh mode"
);
}
}
}
let should_skip = match cfg.skip_configs {
SkipConfigs::Off => false,
SkipConfigs::On => mode == Mode::Present,
SkipConfigs::Auto => mode == Mode::Present && daemon_has_zshrs_rows(),
};
SHOULD_SKIP_CONFIGS.store(if should_skip { 1 } else { 0 }, Ordering::Relaxed);
if should_skip {
tracing::info!(
"shell: skip_configs active — bypassing /etc/zshenv + ~/.{{zshenv,zprofile,zshrc,zlogin}} and \
applying canonical state from daemon"
);
} else if cfg.skip_configs != SkipConfigs::Off {
tracing::info!(
mode = ?cfg.skip_configs,
daemon = ?mode,
"shell: skip_configs configured but conditions not met — sourcing dotfiles normally"
);
}
mode
}
#[cfg(feature = "daemon")]
fn daemon_has_zshrs_rows() -> bool {
let paths = match crate::daemon::paths::CachePaths::resolve() {
Ok(p) => p,
Err(_) => return false,
};
let entries = match std::fs::read_dir(&paths.images) {
Ok(it) => it,
Err(_) => return false,
};
for entry in entries.flatten() {
if let Some(s) = entry.file_name().to_str() {
if s.ends_with("-recorder.rkyv") {
return true;
}
}
}
false
}
#[cfg(not(feature = "daemon"))]
fn daemon_has_zshrs_rows() -> bool {
false
}
#[inline]
pub fn should_skip_configs() -> bool {
SHOULD_SKIP_CONFIGS.load(Ordering::Relaxed) != 0
}
pub fn skip_configs_setting() -> SkipConfigs {
match SKIP_CONFIGS.load(Ordering::Relaxed) {
1 => SkipConfigs::Auto,
2 => SkipConfigs::On,
_ => SkipConfigs::Off,
}
}
#[cfg(feature = "daemon")]
fn probe_socket() -> bool {
match crate::daemon::paths::CachePaths::resolve() {
Ok(paths) => crate::daemon::client::Client::is_daemon_alive(&paths),
Err(_) => false,
}
}
#[cfg(not(feature = "daemon"))]
fn probe_socket() -> bool {
false
}
#[inline]
pub fn current() -> Mode {
Mode::from_u8(STATE.load(Ordering::Relaxed))
}
#[inline]
pub fn is_present() -> bool {
current() == Mode::Present
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn config_setting_parses_common_aliases() {
assert_eq!(ConfigSetting::parse("auto"), Some(ConfigSetting::Auto));
assert_eq!(ConfigSetting::parse(""), Some(ConfigSetting::Auto));
assert_eq!(ConfigSetting::parse("off"), Some(ConfigSetting::Off));
assert_eq!(ConfigSetting::parse("false"), Some(ConfigSetting::Off));
assert_eq!(ConfigSetting::parse("no"), Some(ConfigSetting::Off));
assert_eq!(ConfigSetting::parse("0"), Some(ConfigSetting::Off));
assert_eq!(ConfigSetting::parse("require"), Some(ConfigSetting::Require));
assert_eq!(ConfigSetting::parse("on"), Some(ConfigSetting::Require));
assert_eq!(ConfigSetting::parse("true"), Some(ConfigSetting::Require));
assert_eq!(ConfigSetting::parse("garbage"), None);
}
#[test]
fn mode_round_trips_through_atomic() {
for m in [Mode::Unknown, Mode::Present, Mode::Absent, Mode::Disabled] {
assert_eq!(Mode::from_u8(m as u8), m);
}
}
#[test]
fn resolve_startup_config_path_absolute_trims() {
assert_eq!(
super::resolve_startup_config_path(" /tmp/x.zsh "),
PathBuf::from("/tmp/x.zsh")
);
}
#[test]
fn resolve_startup_config_path_tilde() {
let home = dirs::home_dir().expect("HOME");
assert_eq!(
super::resolve_startup_config_path("~/init.zsh"),
home.join("init.zsh")
);
}
#[test]
fn read_config_full_startup_config_from_zshrs_home() {
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _lock = ENV_LOCK.lock().expect("env test lock");
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("zshrs.toml"),
"[shell]\nstartup_config = \"/tmp/zshrs-startup-test.zsh\"\n",
)
.expect("write zshrs.toml");
unsafe {
std::env::set_var("ZSHRS_HOME", dir.path());
}
let cfg = read_config_full();
unsafe {
std::env::remove_var("ZSHRS_HOME");
}
assert_eq!(
cfg.startup_config,
Some(PathBuf::from("/tmp/zshrs-startup-test.zsh"))
);
}
}