//! Daemon configuration loaded from `~/.spacesh/config.toml`. //! //! The file is optional; every field has a sane fallback so a missing or //! partial config never breaks startup. use std::path::Path; use serde::Deserialize; #[derive(Debug, Clone, Default, Deserialize)] pub struct Config { /// Shell launched for plain (no-command) panels. When unset, the daemon /// auto-detects the user's login shell. #[serde(default)] pub default_shell: Option, } impl Config { /// Load `~/.spacesh/config.toml`. Any error (missing file, bad TOML) yields defaults. pub fn load() -> Self { let Ok(dir) = crate::lifecycle::spacesh_dir() else { return Self::default() }; Self::from_path(&dir.join("config.toml")) } pub fn from_path(path: &Path) -> Self { match std::fs::read_to_string(path) { Ok(s) => toml::from_str(&s).unwrap_or_default(), Err(_) => Self::default(), } } } /// Resolve the shell to spawn for a plain panel. /// /// Order: `SPACESH_SHELL` env → `config.toml` `default_shell` → login shell /// from the passwd DB → `$SHELL` → `/bin/sh`. The passwd lookup matters under /// launchd, where `$SHELL` is typically absent (so a bash fallback would win). pub fn default_shell() -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } } if let Some(s) = Config::load().default_shell { if !s.is_empty() { return s; } } if let Some(s) = login_shell() { return s; } if let Ok(s) = std::env::var("SHELL") { if !s.is_empty() { return s; } } "/bin/sh".into() } /// The current user's login shell from the passwd database (`getpwuid`). #[cfg(unix)] fn login_shell() -> Option { use std::ffi::CStr; // SAFETY: getpwuid returns a pointer into a static buffer valid until the // next libc passwd call; we copy the string out immediately on this thread. unsafe { let pw = libc::getpwuid(libc::getuid()); if pw.is_null() { return None; } let shell = (*pw).pw_shell; if shell.is_null() { return None; } let s = CStr::from_ptr(shell).to_str().ok()?; if s.is_empty() { None } else { Some(s.to_string()) } } } #[cfg(not(unix))] fn login_shell() -> Option { None } #[cfg(test)] mod tests { use super::*; #[test] fn missing_file_is_default() { let c = Config::from_path(Path::new("/no/such/spacesh/config.toml")); assert!(c.default_shell.is_none()); } #[test] fn parses_default_shell() { let dir = std::env::temp_dir().join("spacesh-cfg-test"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write(&path, "default_shell = \"/bin/zsh\"\n").unwrap(); let c = Config::from_path(&path); assert_eq!(c.default_shell.as_deref(), Some("/bin/zsh")); let _ = std::fs::remove_file(&path); } #[test] fn env_override_wins() { let _serial = crate::test_support::serial(); std::env::set_var("SPACESH_SHELL", "/tmp/fake-shell"); let s = default_shell(); std::env::remove_var("SPACESH_SHELL"); assert_eq!(s, "/tmp/fake-shell"); } }