//! 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, Serialize}; #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct TerminalConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub font_family: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub font_size: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct AppearanceConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub theme: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub accent: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct Config { /// Shell launched for plain (no-command) panels. When unset, the daemon /// auto-detects the user's login shell. #[serde(default, skip_serializing_if = "Option::is_none")] pub default_shell: Option, #[serde(default)] pub terminal: TerminalConfig, #[serde(default)] pub appearance: AppearanceConfig, } impl Config { /// Resolve to a client-facing view, applying defaults and the shell resolver. pub fn to_view(&self) -> spacesh_proto::config_view::ConfigView { spacesh_proto::config_view::ConfigView { default_shell: self.resolved_shell(), font_family: self.terminal.font_family.clone().unwrap_or_else(|| "JetBrains Mono".into()), font_size: self.terminal.font_size.unwrap_or(13).clamp(10, 20), theme: self.appearance.theme.clone().unwrap_or_else(|| "dark".into()), accent: self.appearance.accent.clone().unwrap_or_else(|| "blue".into()), } } /// Shell for a plain panel using THIS in-memory config /// (env -> config -> passwd -> $SHELL -> /bin/sh). pub fn resolved_shell(&self) -> String { if let Ok(s) = std::env::var("SPACESH_SHELL") { if !s.is_empty() { return s; } } if let Some(s) = &self.default_shell { if !s.is_empty() { return s.clone(); } } 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() } /// 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(), } } /// Persist to `~/.spacesh/config.toml`. pub fn save(&self) -> std::io::Result<()> { let dir = crate::lifecycle::spacesh_dir() .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; self.save_to(&dir.join("config.toml")) } /// Persist to an arbitrary path. Creates the parent directory if needed. pub fn save_to(&self, path: &Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let s = toml::to_string_pretty(self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; std::fs::write(path, s) } } /// 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). // retained for the env-override test and potential startup use #[allow(dead_code)] 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"); } #[test] fn parses_terminal_and_appearance() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-sections-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); std::fs::write(&path, "default_shell = \"/bin/zsh\"\n[terminal]\nfont_family = \"Menlo\"\nfont_size = 15\n[appearance]\ntheme = \"light\"\naccent = \"teal\"\n").unwrap(); let c = Config::from_path(&path); assert_eq!(c.terminal.font_family.as_deref(), Some("Menlo")); assert_eq!(c.terminal.font_size, Some(15)); assert_eq!(c.appearance.theme.as_deref(), Some("light")); assert_eq!(c.appearance.accent.as_deref(), Some("teal")); let _ = std::fs::remove_file(&path); } #[test] fn missing_sections_default() { let c = Config::from_path(Path::new("/no/such/cfg.toml")); assert!(c.terminal.font_family.is_none()); assert!(c.appearance.theme.is_none()); } #[test] fn to_view_applies_defaults_and_clamp() { let mut c = Config::default(); c.terminal.font_size = Some(99); let v = c.to_view(); assert_eq!(v.font_size, 20); assert_eq!(v.theme, "dark"); assert_eq!(v.accent, "blue"); assert!(!v.font_family.is_empty()); } #[test] fn save_then_reload_round_trips() { let dir = std::env::temp_dir().join(format!("spacesh-cfg-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("config.toml"); let mut c = Config::default(); c.terminal.font_size = Some(14); c.appearance.accent = Some("purple".into()); c.save_to(&path).unwrap(); let back = Config::from_path(&path); assert_eq!(back.terminal.font_size, Some(14)); assert_eq!(back.appearance.accent.as_deref(), Some("purple")); let _ = std::fs::remove_file(&path); } }