use std::collections::HashMap;
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub struct LacsConfig {
pub daemon: Option<DaemonSection>,
pub llm: Option<LlmSection>,
pub policy: Option<PolicySection>,
pub audit: Option<AuditSection>,
pub storage: Option<StorageSection>,
}
#[derive(Debug, Deserialize)]
pub struct DaemonSection {
pub socket: Option<String>,
pub database: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct StorageSection {
#[serde(default = "default_storage_backend")]
pub backend: String,
pub url: Option<String>,
pub pool: Option<StoragePoolSection>,
}
fn default_storage_backend() -> String {
"sqlite".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StorageBackend {
Sqlite,
Postgres {
url: String,
pool: StoragePoolSection,
},
}
impl StorageSection {
pub fn parsed(&self) -> Result<StorageBackend, String> {
match self.backend.to_ascii_lowercase().as_str() {
"sqlite" => Ok(StorageBackend::Sqlite),
"postgres" => {
let url = self
.url
.as_ref()
.ok_or_else(|| {
"[storage] backend = \"postgres\" requires `url = \"postgres://...\"`"
.to_string()
})?
.clone();
let pool = self.pool.clone().unwrap_or_default();
Ok(StorageBackend::Postgres { url, pool })
}
other => Err(format!(
"[storage] unknown backend {other:?}; expected \"sqlite\" or \"postgres\""
)),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
pub struct StoragePoolSection {
pub max_connections: Option<u32>,
pub acquire_timeout_secs: Option<u64>,
pub statement_cache_capacity: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
pub struct AuditSection {
pub forward: Option<AuditForwardSection>,
}
#[derive(Debug, Default, Deserialize)]
pub struct AuditForwardSection {
pub syslog: Option<SyslogForwardSection>,
}
#[derive(Debug, Deserialize)]
pub struct SyslogForwardSection {
pub host: String,
#[serde(default = "default_syslog_facility")]
pub facility: u8,
}
fn default_syslog_facility() -> u8 {
1
}
#[derive(Debug, Default, Deserialize)]
pub struct PolicySection {
pub risk_overrides: Option<HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
pub struct LlmSection {
pub provider: Option<String>,
pub model: Option<String>,
pub ollama_url: Option<String>,
pub anthropic_url: Option<String>,
pub max_turns: Option<u32>,
pub ollama_think: Option<bool>,
}
impl LacsConfig {
pub fn config_path() -> PathBuf {
config_path()
}
pub fn load() -> Self {
let path = config_path();
Self::try_load(&path).unwrap_or_else(|reason| {
eprintln!("[sysknife] FATAL: {reason}");
std::process::exit(1);
})
}
fn try_load(path: &std::path::Path) -> Result<Self, String> {
match std::fs::read_to_string(path) {
Ok(content) => toml::from_str(&content).map_err(|e| {
format!(
"config file {} is present but could not be parsed as TOML: {e}; \
refusing to silently fall back to defaults",
path.display()
)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(format!(
"config file {} exists but could not be read: {e}; refusing to \
silently fall back to defaults",
path.display()
)),
}
}
pub fn apply_defaults_to_env(&self) {
if let Some(daemon) = &self.daemon {
if let Some(socket) = &daemon.socket {
let uri = if socket.starts_with("unix://") {
socket.clone()
} else {
format!("unix://{socket}")
};
set_if_absent("SYSKNIFE_LISTEN_URI", &uri);
}
if let Some(db) = &daemon.database {
set_if_absent("SYSKNIFE_DATABASE_PATH", db);
}
}
if let Some(llm) = &self.llm {
if let Some(provider) = &llm.provider {
set_if_absent("SYSKNIFE_LLM_PROVIDER", provider);
}
if let Some(model) = &llm.model {
set_if_absent("SYSKNIFE_LLM_MODEL", model);
}
if let Some(url) = &llm.ollama_url {
set_if_absent("SYSKNIFE_OLLAMA_URL", url);
}
if let Some(url) = &llm.anthropic_url {
set_if_absent("SYSKNIFE_ANTHROPIC_URL", url);
}
if let Some(turns) = llm.max_turns {
set_if_absent("SYSKNIFE_BRAIN_MAX_TURNS", &turns.to_string());
}
if let Some(think) = llm.ollama_think {
set_if_absent(
"SYSKNIFE_OLLAMA_THINK",
if think { "true" } else { "false" },
);
}
}
}
}
fn config_dir() -> PathBuf {
let xdg_valid = std::env::var("XDG_CONFIG_HOME").ok().and_then(|v| {
let p = PathBuf::from(&v);
if p.is_absolute() && !p.components().any(|c| c == std::path::Component::ParentDir) {
Some(p)
} else {
eprintln!(
"[sysknife] warning: XDG_CONFIG_HOME={v:?} is not a safe absolute path; \
ignoring and using default ~/.config"
);
None
}
});
let base = xdg_valid.unwrap_or_else(|| match std::env::var("HOME") {
Ok(home) => PathBuf::from(home).join(".config"),
Err(_) => panic!(
"[sysknife] FATAL: HOME and XDG_CONFIG_HOME are both unset — refusing \
to fall back to ./.config (relative paths produce ./.config/sysknife/ \
under whatever CWD the daemon was launched in, typically a tempdir or \
worse). Set `HOME` (or `XDG_CONFIG_HOME` to an absolute path) in the \
systemd unit / shell session and try again."
),
});
base.join("sysknife")
}
pub fn config_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn prefs_path() -> PathBuf {
config_dir().join("prefs.md")
}
fn set_if_absent(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
#[allow(unused_unsafe)]
unsafe {
std::env::set_var(key, value);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn storage_section_parses_sqlite_default() {
let s = StorageSection {
backend: "sqlite".to_string(),
url: None,
pool: None,
};
assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
}
#[test]
fn storage_section_parses_sqlite_case_insensitive() {
let s = StorageSection {
backend: "Sqlite".to_string(),
url: None,
pool: None,
};
assert_eq!(s.parsed().unwrap(), StorageBackend::Sqlite);
}
#[test]
fn storage_section_parses_postgres_with_url() {
let s = StorageSection {
backend: "postgres".to_string(),
url: Some("postgres://user@host/db".to_string()),
pool: None,
};
match s.parsed().unwrap() {
StorageBackend::Postgres { url, pool } => {
assert_eq!(url, "postgres://user@host/db");
assert_eq!(pool, StoragePoolSection::default());
}
other => panic!("expected Postgres, got {other:?}"),
}
}
#[test]
fn storage_section_rejects_postgres_without_url() {
let s = StorageSection {
backend: "postgres".to_string(),
url: None,
pool: None,
};
let err = s.parsed().unwrap_err();
assert!(err.contains("requires `url"), "got: {err}");
}
#[test]
fn storage_section_rejects_unknown_backend() {
let s = StorageSection {
backend: "sqlit".to_string(), url: None,
pool: None,
};
let err = s.parsed().unwrap_err();
assert!(err.contains("unknown backend"), "got: {err}");
}
#[test]
fn load_returns_default_when_file_absent() {
let dir = tempfile::tempdir().unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
}
let cfg = LacsConfig::load();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(cfg.daemon.is_none());
assert!(cfg.llm.is_none());
}
#[test]
fn try_load_returns_default_when_file_absent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist").join("config.toml");
let cfg = LacsConfig::try_load(&path).expect("absent file must be Ok(default)");
assert!(cfg.daemon.is_none());
assert!(cfg.llm.is_none());
assert!(cfg.storage.is_none());
assert!(cfg.policy.is_none());
}
#[test]
fn try_load_fails_loud_on_present_but_unparseable_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, "this is [ not valid toml at all").unwrap();
let result = LacsConfig::try_load(&path);
assert!(
result.is_err(),
"a present-but-unparseable config file must error, not silently default"
);
let err = result.unwrap_err();
assert!(
err.contains("could not be parsed"),
"error should explain the parse failure; got: {err}"
);
}
#[test]
fn load_parses_full_config() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[daemon]
socket = "/run/sysknife/daemon.sock"
database = "/var/lib/sysknife/daemon.sqlite"
[llm]
provider = "ollama"
model = "llama3.2"
ollama_url = "http://localhost:11434"
max_turns = 7
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
}
let cfg = LacsConfig::load();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
let daemon = cfg.daemon.expect("daemon section missing");
assert_eq!(daemon.socket.as_deref(), Some("/run/sysknife/daemon.sock"));
assert_eq!(
daemon.database.as_deref(),
Some("/var/lib/sysknife/daemon.sqlite")
);
let llm = cfg.llm.expect("llm section missing");
assert_eq!(llm.provider.as_deref(), Some("ollama"));
assert_eq!(llm.model.as_deref(), Some("llama3.2"));
assert_eq!(llm.max_turns, Some(7));
}
#[test]
fn apply_defaults_does_not_override_existing_env() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[llm]
provider = "anthropic"
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::set_var("SYSKNIFE_LLM_PROVIDER", "ollama");
}
let cfg = LacsConfig::load();
cfg.apply_defaults_to_env();
let provider = std::env::var("SYSKNIFE_LLM_PROVIDER").unwrap();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("SYSKNIFE_LLM_PROVIDER");
}
assert_eq!(provider, "ollama", "env var must win over config file");
}
#[test]
fn socket_path_gets_unix_prefix() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[daemon]
socket = "/tmp/test.sock"
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var("SYSKNIFE_LISTEN_URI");
}
let cfg = LacsConfig::load();
cfg.apply_defaults_to_env();
let uri = std::env::var("SYSKNIFE_LISTEN_URI").unwrap();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("SYSKNIFE_LISTEN_URI");
}
assert_eq!(uri, "unix:///tmp/test.sock");
}
#[test]
fn ollama_think_false_emits_env_var() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[llm]
ollama_think = false
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
}
let cfg = LacsConfig::load();
cfg.apply_defaults_to_env();
let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
}
assert_eq!(think, "false");
}
#[test]
fn ollama_think_true_emits_env_var() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[llm]
ollama_think = true
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
}
let cfg = LacsConfig::load();
cfg.apply_defaults_to_env();
let think = std::env::var("SYSKNIFE_OLLAMA_THINK").unwrap();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
}
assert_eq!(think, "true");
}
#[test]
fn ollama_think_absent_does_not_set_env_var() {
let dir = tempfile::tempdir().unwrap();
let sysknife_dir = dir.path().join("sysknife");
std::fs::create_dir_all(&sysknife_dir).unwrap();
std::fs::write(
sysknife_dir.join("config.toml"),
r#"
[llm]
model = "qwen3:8b"
"#,
)
.unwrap();
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path());
std::env::remove_var("SYSKNIFE_OLLAMA_THINK");
}
let cfg = LacsConfig::load();
cfg.apply_defaults_to_env();
let think_set = std::env::var_os("SYSKNIFE_OLLAMA_THINK").is_some();
unsafe {
std::env::remove_var("XDG_CONFIG_HOME");
}
assert!(!think_set, "absent ollama_think must not set the env var");
}
#[test]
fn prefs_path_lives_alongside_config() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prefs = prefs_path();
let config = config_path();
assert_eq!(prefs.parent(), config.parent());
assert_eq!(prefs.file_name().unwrap(), "prefs.md");
}
#[test]
#[cfg(unix)]
fn xdg_config_home_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
let link = dir.path().join("link");
std::fs::create_dir_all(&real).unwrap();
symlink(&real, &link).unwrap();
let prev = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", &link);
}
let resolved = config_path();
match prev {
Some(v) => unsafe { std::env::set_var("XDG_CONFIG_HOME", v) },
None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
}
assert!(
resolved.starts_with(&link),
"current resolver keeps the symlink path verbatim — got {resolved:?}"
);
}
#[test]
fn config_dir_panics_when_home_and_xdg_config_home_are_both_unset() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_home = std::env::var("HOME").ok();
let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();
unsafe {
std::env::remove_var("HOME");
std::env::remove_var("XDG_CONFIG_HOME");
}
let result = std::panic::catch_unwind(config_dir);
unsafe {
match prev_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
match prev_xdg {
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
}
let panic = result
.expect_err("config_dir() must panic when both HOME and XDG_CONFIG_HOME are unset");
let msg: String = panic
.downcast::<String>()
.map(|b| *b)
.or_else(|p| p.downcast::<&'static str>().map(|b| (*b).to_string()))
.unwrap_or_else(|_| "<non-string panic>".to_string());
assert!(
msg.contains("HOME") && msg.contains("XDG_CONFIG_HOME"),
"panic message must mention both env vars; got: {msg}"
);
}
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
}