Files
spaceshell/crates/spaceshd/src/config.rs
T

210 lines
7.7 KiB
Rust

//! 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub font_size: Option<u16>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct AppearanceConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accent: Option<String>,
}
#[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<String>,
#[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<String> {
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<String> { 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);
}
}