use std::{
env,
path::{Path, PathBuf},
};
use anyhow::{Context, anyhow};
use serde::Deserialize;
use crate::error::{DaemonError, DaemonResult};
pub const WORKING_SET_MULTIPLIER: f64 = 1.5;
pub const INTERNER_BUILDER_OVERHEAD_RATIO: f64 = 0.25;
pub const ESTIMATE_STAGING_PER_FILE_BYTES: u64 = 4_096;
pub const ESTIMATE_FINAL_PER_FILE_BYTES: u64 = 2_048;
pub const ENV_CONFIG_PATH: &str = "SQRY_DAEMON_CONFIG";
pub const ENV_MEMORY_LIMIT_MB: &str = "SQRY_DAEMON_MEMORY_MB";
pub const ENV_SOCKET_PATH: &str = "SQRY_DAEMON_SOCKET";
pub const ENV_PIPE_NAME: &str = "SQRY_DAEMON_PIPE";
pub const ENV_LOG_LEVEL: &str = "SQRY_DAEMON_LOG_LEVEL";
pub const ENV_LOG_FILE: &str = "SQRY_DAEMON_LOG_FILE";
pub const ENV_STALE_MAX_AGE_HOURS: &str = "SQRY_DAEMON_STALE_MAX_AGE_HOURS";
pub const ENV_TOOL_TIMEOUT_SECS: &str = "SQRY_DAEMON_TOOL_TIMEOUT_SECS";
pub const ENV_MAX_SHIM_CONNECTIONS: &str = "SQRY_DAEMON_MAX_SHIM_CONNECTIONS";
pub const ENV_AUTO_START_READY_TIMEOUT_SECS: &str = "SQRY_DAEMON_AUTO_START_READY_TIMEOUT_SECS";
pub const ENV_LOG_KEEP_ROTATIONS: &str = "SQRY_DAEMON_LOG_KEEP_ROTATIONS";
pub const DEFAULT_MEMORY_LIMIT_MB: u64 = 2_048;
pub const DEFAULT_IDLE_TIMEOUT_MINUTES: u64 = 30;
pub const DEFAULT_DEBOUNCE_MS: u64 = 2_000;
pub const DEFAULT_INCREMENTAL_THRESHOLD: usize = 20;
pub const DEFAULT_CLOSURE_LIMIT_PERCENT: u32 = 30;
pub const DEFAULT_STALE_SERVE_MAX_AGE_HOURS: u32 = 24;
pub const DEFAULT_REBUILD_DRAIN_TIMEOUT_MS: u64 = 5_000;
pub const DEFAULT_INTERNER_COMPACTION_THRESHOLD: f32 = 0.5;
pub const DEFAULT_IPC_SHUTDOWN_DRAIN_SECS: u64 = 5;
pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 60;
pub const DEFAULT_MAX_SHIM_CONNECTIONS: usize = 256;
pub const DEFAULT_LOG_LEVEL: &str = "info";
pub const DEFAULT_LOG_MAX_SIZE_MB: u64 = 50;
pub const DEFAULT_AUTO_START_READY_TIMEOUT_SECS: u64 = 10;
pub const DEFAULT_LOG_KEEP_ROTATIONS: u32 = 5;
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct DaemonConfig {
#[serde(default = "default_memory_limit_mb")]
pub memory_limit_mb: u64,
#[serde(default = "default_idle_timeout_minutes")]
pub idle_timeout_minutes: u64,
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
#[serde(default = "default_incremental_threshold")]
pub incremental_threshold: usize,
#[serde(default = "default_closure_limit_percent")]
pub closure_limit_percent: u32,
#[serde(default = "default_stale_serve_max_age_hours")]
pub stale_serve_max_age_hours: u32,
#[serde(default = "default_rebuild_drain_timeout_ms")]
pub rebuild_drain_timeout_ms: u64,
#[serde(default = "default_ipc_shutdown_drain_secs")]
pub ipc_shutdown_drain_secs: u64,
#[serde(default = "default_tool_timeout_secs")]
pub tool_timeout_secs: u64,
#[serde(default = "default_max_shim_connections")]
pub max_shim_connections: usize,
#[serde(default = "default_interner_compaction_threshold")]
pub interner_compaction_threshold: f32,
#[serde(default)]
pub log_file: Option<PathBuf>,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_log_max_size_mb")]
pub log_max_size_mb: u64,
#[serde(default)]
pub socket: SocketConfig,
#[serde(default)]
pub workspaces: Vec<WorkspaceConfig>,
#[serde(default = "default_auto_start_ready_timeout_secs")]
pub auto_start_ready_timeout_secs: u64,
#[serde(default = "default_log_keep_rotations")]
pub log_keep_rotations: u32,
#[serde(default)]
pub install_user_service: bool,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
memory_limit_mb: DEFAULT_MEMORY_LIMIT_MB,
idle_timeout_minutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
debounce_ms: DEFAULT_DEBOUNCE_MS,
incremental_threshold: DEFAULT_INCREMENTAL_THRESHOLD,
closure_limit_percent: DEFAULT_CLOSURE_LIMIT_PERCENT,
stale_serve_max_age_hours: DEFAULT_STALE_SERVE_MAX_AGE_HOURS,
rebuild_drain_timeout_ms: DEFAULT_REBUILD_DRAIN_TIMEOUT_MS,
ipc_shutdown_drain_secs: DEFAULT_IPC_SHUTDOWN_DRAIN_SECS,
tool_timeout_secs: DEFAULT_TOOL_TIMEOUT_SECS,
max_shim_connections: DEFAULT_MAX_SHIM_CONNECTIONS,
interner_compaction_threshold: DEFAULT_INTERNER_COMPACTION_THRESHOLD,
log_file: None,
log_level: DEFAULT_LOG_LEVEL.to_owned(),
log_max_size_mb: DEFAULT_LOG_MAX_SIZE_MB,
socket: SocketConfig::default(),
workspaces: Vec::new(),
auto_start_ready_timeout_secs: DEFAULT_AUTO_START_READY_TIMEOUT_SECS,
log_keep_rotations: DEFAULT_LOG_KEEP_ROTATIONS,
install_user_service: false,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct SocketConfig {
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub pipe_name: Option<String>,
}
#[derive(Debug, Clone, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
pub path: PathBuf,
#[serde(default)]
pub pinned: bool,
#[serde(default)]
pub exclude: bool,
}
impl DaemonConfig {
pub fn load() -> DaemonResult<Self> {
let path = Self::resolve_config_path()?;
let mut config = if path.exists() {
Self::load_from_path(&path)?
} else {
Self::default()
};
config.apply_env_overrides()?;
config.validate()?;
Ok(config)
}
pub fn load_from_path(path: &Path) -> DaemonResult<Self> {
let text = std::fs::read_to_string(path).map_err(|source| DaemonError::Config {
path: path.to_path_buf(),
source: anyhow::Error::from(source).context("reading daemon config"),
})?;
Self::from_toml_str(&text).map_err(|source| DaemonError::Config {
path: path.to_path_buf(),
source,
})
}
pub fn from_toml_str(text: &str) -> anyhow::Result<Self> {
let cfg: Self = toml::from_str(text).context("parsing daemon config TOML")?;
Ok(cfg)
}
pub fn apply_env_overrides(&mut self) -> DaemonResult<()> {
if let Some(v) = env::var_os(ENV_MEMORY_LIMIT_MB) {
let v = v.to_string_lossy().into_owned();
self.memory_limit_mb = v.parse::<u64>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_MEMORY_LIMIT_MB),
source: anyhow!("{ENV_MEMORY_LIMIT_MB}={v:?} must be an unsigned int: {e}"),
})?;
}
if let Some(v) = env::var_os(ENV_SOCKET_PATH) {
self.socket.path = Some(PathBuf::from(v));
}
if let Some(v) = env::var_os(ENV_PIPE_NAME) {
self.socket.pipe_name = Some(v.to_string_lossy().into_owned());
}
if let Some(v) = env::var_os(ENV_LOG_LEVEL) {
self.log_level = v.to_string_lossy().into_owned();
}
if let Some(v) = env::var_os(ENV_LOG_FILE) {
self.log_file = Some(PathBuf::from(v));
}
if let Some(v) = env::var_os(ENV_STALE_MAX_AGE_HOURS) {
let v = v.to_string_lossy().into_owned();
self.stale_serve_max_age_hours = v.parse::<u32>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_STALE_MAX_AGE_HOURS),
source: anyhow!("{ENV_STALE_MAX_AGE_HOURS}={v:?}: {e}"),
})?;
}
if let Some(v) = env::var_os(ENV_TOOL_TIMEOUT_SECS) {
let v = v.to_string_lossy().into_owned();
self.tool_timeout_secs = v.parse::<u64>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_TOOL_TIMEOUT_SECS),
source: anyhow!("{ENV_TOOL_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"),
})?;
}
if let Some(v) = env::var_os(ENV_MAX_SHIM_CONNECTIONS) {
let v = v.to_string_lossy().into_owned();
self.max_shim_connections = v.parse::<usize>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_MAX_SHIM_CONNECTIONS),
source: anyhow!("{ENV_MAX_SHIM_CONNECTIONS}={v:?} must be an unsigned int: {e}"),
})?;
}
if let Some(v) = env::var_os(ENV_AUTO_START_READY_TIMEOUT_SECS) {
let v = v.to_string_lossy().into_owned();
self.auto_start_ready_timeout_secs =
v.parse::<u64>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_AUTO_START_READY_TIMEOUT_SECS),
source: anyhow!(
"{ENV_AUTO_START_READY_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"
),
})?;
}
if let Some(v) = env::var_os(ENV_LOG_KEEP_ROTATIONS) {
let v = v.to_string_lossy().into_owned();
self.log_keep_rotations = v.parse::<u32>().map_err(|e| DaemonError::Config {
path: PathBuf::from(ENV_LOG_KEEP_ROTATIONS),
source: anyhow!("{ENV_LOG_KEEP_ROTATIONS}={v:?} must be an unsigned int: {e}"),
})?;
}
Ok(())
}
pub fn validate(&self) -> DaemonResult<()> {
let reject = |msg: &str| DaemonError::Config {
path: PathBuf::from("<in-memory>"),
source: anyhow!("{msg}"),
};
if self.memory_limit_mb == 0 {
return Err(reject("memory_limit_mb must be > 0"));
}
if self.closure_limit_percent == 0 || self.closure_limit_percent > 100 {
return Err(reject("closure_limit_percent must be in 1..=100"));
}
if !self.interner_compaction_threshold.is_finite()
|| self.interner_compaction_threshold <= 0.0
|| self.interner_compaction_threshold > 1.0
{
return Err(reject(
"interner_compaction_threshold must be in (0.0, 1.0]",
));
}
if self.debounce_ms == 0 {
return Err(reject("debounce_ms must be > 0"));
}
if self.log_max_size_mb == 0 {
return Err(reject("log_max_size_mb must be > 0"));
}
if self.ipc_shutdown_drain_secs == 0 || self.ipc_shutdown_drain_secs > 3_600 {
return Err(reject("ipc_shutdown_drain_secs must be in 1..=3600"));
}
if self.tool_timeout_secs == 0 || self.tool_timeout_secs > 3_600 {
return Err(reject("tool_timeout_secs must be in 1..=3600"));
}
if self.max_shim_connections == 0 || self.max_shim_connections > 65_536 {
return Err(reject("max_shim_connections must be in 1..=65536"));
}
if self.auto_start_ready_timeout_secs == 0 || self.auto_start_ready_timeout_secs > 60 {
return Err(reject("auto_start_ready_timeout_secs must be in 1..=60"));
}
if self.log_keep_rotations == 0 || self.log_keep_rotations > 100 {
return Err(reject("log_keep_rotations must be in 1..=100"));
}
Ok(())
}
pub fn resolve_config_path() -> DaemonResult<PathBuf> {
if let Some(v) = env::var_os(ENV_CONFIG_PATH) {
return Ok(PathBuf::from(v));
}
let base = dirs::config_dir().ok_or_else(|| DaemonError::Config {
path: PathBuf::from("~/.config"),
source: anyhow!("could not determine user config directory; set {ENV_CONFIG_PATH}"),
})?;
Ok(base.join("sqry").join("daemon.toml"))
}
#[must_use]
pub fn socket_path(&self) -> PathBuf {
if cfg!(windows) {
let name = self
.socket
.pipe_name
.clone()
.unwrap_or_else(|| "sqry".to_string());
return PathBuf::from(format!(r"\\.\pipe\{name}"));
}
if let Some(path) = &self.socket.path {
return path.clone();
}
runtime_dir().join("sqryd.sock")
}
#[must_use]
pub fn pid_path(&self) -> PathBuf {
runtime_dir().join("sqryd.pid")
}
#[must_use]
pub fn lock_path(&self) -> PathBuf {
runtime_dir().join("sqryd.lock")
}
#[must_use]
pub fn runtime_dir(&self) -> PathBuf {
runtime_dir()
}
#[must_use]
pub const fn memory_limit_bytes(&self) -> u64 {
self.memory_limit_mb.saturating_mul(1024 * 1024)
}
}
fn runtime_dir() -> PathBuf {
if cfg!(windows)
&& let Some(local) = env::var_os("LOCALAPPDATA")
{
return PathBuf::from(local).join("sqry");
}
if let Some(xdg) = env::var_os("XDG_RUNTIME_DIR") {
return PathBuf::from(xdg).join("sqry");
}
if let Some(tmp) = env::var_os("TMPDIR") {
return PathBuf::from(tmp).join(user_scoped_dir_name());
}
PathBuf::from("/tmp").join(user_scoped_dir_name())
}
fn user_scoped_dir_name() -> String {
#[cfg(unix)]
{
let uid = unsafe { libc::getuid() };
format!("sqry-{uid}")
}
#[cfg(not(unix))]
{
let user = env::var("USERNAME").unwrap_or_else(|_| "default".to_string());
format!("sqry-{user}")
}
}
const fn default_memory_limit_mb() -> u64 {
DEFAULT_MEMORY_LIMIT_MB
}
const fn default_idle_timeout_minutes() -> u64 {
DEFAULT_IDLE_TIMEOUT_MINUTES
}
const fn default_debounce_ms() -> u64 {
DEFAULT_DEBOUNCE_MS
}
const fn default_incremental_threshold() -> usize {
DEFAULT_INCREMENTAL_THRESHOLD
}
const fn default_closure_limit_percent() -> u32 {
DEFAULT_CLOSURE_LIMIT_PERCENT
}
const fn default_stale_serve_max_age_hours() -> u32 {
DEFAULT_STALE_SERVE_MAX_AGE_HOURS
}
const fn default_rebuild_drain_timeout_ms() -> u64 {
DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
}
const fn default_ipc_shutdown_drain_secs() -> u64 {
DEFAULT_IPC_SHUTDOWN_DRAIN_SECS
}
const fn default_tool_timeout_secs() -> u64 {
DEFAULT_TOOL_TIMEOUT_SECS
}
const fn default_max_shim_connections() -> usize {
DEFAULT_MAX_SHIM_CONNECTIONS
}
const fn default_interner_compaction_threshold() -> f32 {
DEFAULT_INTERNER_COMPACTION_THRESHOLD
}
fn default_log_level() -> String {
DEFAULT_LOG_LEVEL.to_owned()
}
const fn default_log_max_size_mb() -> u64 {
DEFAULT_LOG_MAX_SIZE_MB
}
const fn default_auto_start_ready_timeout_secs() -> u64 {
DEFAULT_AUTO_START_READY_TIMEOUT_SECS
}
const fn default_log_keep_rotations() -> u32 {
DEFAULT_LOG_KEEP_ROTATIONS
}
#[cfg(test)]
mod tests {
use super::*;
use crate::TEST_ENV_LOCK as ENV_LOCK;
#[test]
fn defaults_match_plan_table() {
let cfg = DaemonConfig::default();
assert_eq!(cfg.memory_limit_mb, 2_048);
assert_eq!(cfg.idle_timeout_minutes, 30);
assert_eq!(cfg.debounce_ms, 2_000);
assert_eq!(cfg.incremental_threshold, 20);
assert_eq!(cfg.closure_limit_percent, 30);
assert_eq!(cfg.stale_serve_max_age_hours, 24);
assert_eq!(cfg.rebuild_drain_timeout_ms, 5_000);
assert_eq!(cfg.tool_timeout_secs, 60);
assert_eq!(cfg.max_shim_connections, 256);
assert!((cfg.interner_compaction_threshold - 0.5).abs() < f32::EPSILON);
assert_eq!(cfg.log_level, "info");
assert_eq!(cfg.log_max_size_mb, 50);
assert!(cfg.log_file.is_none());
assert!(cfg.socket.path.is_none());
assert!(cfg.socket.pipe_name.is_none());
assert!(cfg.workspaces.is_empty());
}
#[test]
fn memory_limit_bytes_is_mb_times_megabyte() {
let cfg = DaemonConfig::default();
assert_eq!(cfg.memory_limit_bytes(), 2_048 * 1024 * 1024);
}
#[test]
fn parses_minimal_toml() {
let text = r"
memory_limit_mb = 4096
idle_timeout_minutes = 60
[socket]
path = '/tmp/custom-sqryd.sock'
[[workspaces]]
path = '/repos/main'
pinned = true
[[workspaces]]
path = '/repos/secondary'
";
let cfg = DaemonConfig::from_toml_str(text).expect("parse");
assert_eq!(cfg.memory_limit_mb, 4_096);
assert_eq!(cfg.idle_timeout_minutes, 60);
assert_eq!(
cfg.socket.path.as_deref(),
Some(Path::new("/tmp/custom-sqryd.sock"))
);
assert_eq!(cfg.workspaces.len(), 2);
assert!(cfg.workspaces[0].pinned);
assert!(!cfg.workspaces[0].exclude);
assert!(!cfg.workspaces[1].pinned);
}
#[test]
fn parses_all_knobs_with_defaults_filled_in() {
let cfg = DaemonConfig::from_toml_str("").expect("parse");
assert_eq!(cfg.memory_limit_mb, DEFAULT_MEMORY_LIMIT_MB);
assert_eq!(
cfg.stale_serve_max_age_hours,
DEFAULT_STALE_SERVE_MAX_AGE_HOURS
);
assert_eq!(
cfg.rebuild_drain_timeout_ms,
DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
);
}
#[test]
fn rejects_unknown_fields() {
let text = "totally_bogus_knob = 42";
let err = DaemonConfig::from_toml_str(text).expect_err("unknown field must fail");
let chain = format!("{err:#}");
assert!(
chain.contains("totally_bogus_knob") && chain.contains("unknown field"),
"unexpected error: {chain}"
);
}
#[test]
fn validation_rejects_zero_memory_limit() {
let cfg = DaemonConfig {
memory_limit_mb: 0,
..DaemonConfig::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn validation_rejects_closure_limit_out_of_range() {
let low = DaemonConfig {
closure_limit_percent: 0,
..DaemonConfig::default()
};
assert!(low.validate().is_err());
let high = DaemonConfig {
closure_limit_percent: 101,
..DaemonConfig::default()
};
assert!(high.validate().is_err());
}
#[test]
fn validation_rejects_compaction_threshold_out_of_range() {
let zero = DaemonConfig {
interner_compaction_threshold: 0.0,
..DaemonConfig::default()
};
assert!(zero.validate().is_err());
let over = DaemonConfig {
interner_compaction_threshold: 1.5,
..DaemonConfig::default()
};
assert!(over.validate().is_err());
let nan = DaemonConfig {
interner_compaction_threshold: f32::NAN,
..DaemonConfig::default()
};
assert!(nan.validate().is_err());
}
#[test]
fn validation_rejects_zero_debounce_and_zero_log_size() {
let debounce = DaemonConfig {
debounce_ms: 0,
..DaemonConfig::default()
};
assert!(debounce.validate().is_err());
let log = DaemonConfig {
log_max_size_mb: 0,
..DaemonConfig::default()
};
assert!(log.validate().is_err());
}
#[test]
fn validation_rejects_max_shim_connections_out_of_range() {
let zero = DaemonConfig {
max_shim_connections: 0,
..DaemonConfig::default()
};
assert!(zero.validate().is_err());
let too_large = DaemonConfig {
max_shim_connections: 65_537,
..DaemonConfig::default()
};
assert!(too_large.validate().is_err());
let ok = DaemonConfig {
max_shim_connections: 1_024,
..DaemonConfig::default()
};
assert!(ok.validate().is_ok());
}
#[test]
fn validation_rejects_tool_timeout_out_of_range() {
let zero = DaemonConfig {
tool_timeout_secs: 0,
..DaemonConfig::default()
};
assert!(zero.validate().is_err());
let too_long = DaemonConfig {
tool_timeout_secs: 3_601,
..DaemonConfig::default()
};
assert!(too_long.validate().is_err());
let ok = DaemonConfig {
tool_timeout_secs: 120,
..DaemonConfig::default()
};
assert!(ok.validate().is_ok());
}
#[test]
fn load_from_missing_path_is_an_error() {
let err = DaemonConfig::load_from_path(Path::new("/nonexistent/sqryd.toml"))
.expect_err("missing file is an error for explicit path");
match err {
DaemonError::Config { path, .. } => {
assert_eq!(path, Path::new("/nonexistent/sqryd.toml"));
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn socket_path_uses_runtime_dir_when_unspecified() {
let cfg = DaemonConfig::default();
let p = cfg.socket_path();
if cfg!(unix) {
assert!(p.ends_with("sqryd.sock"), "{p:?}");
} else if cfg!(windows) {
let s = p.to_string_lossy();
assert!(s.starts_with(r"\\.\pipe\"), "{s}");
}
}
#[test]
fn apply_env_overrides_applies_memory_limit_override() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_MEMORY_LIMIT_MB, "8192");
}
let mut cfg = DaemonConfig::default();
let outcome = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_MEMORY_LIMIT_MB);
}
outcome.expect("override ok");
assert_eq!(cfg.memory_limit_mb, 8_192);
}
#[test]
fn apply_env_overrides_rejects_malformed_memory_limit() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_MEMORY_LIMIT_MB, "not-a-number");
}
let mut cfg = DaemonConfig::default();
let err = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_MEMORY_LIMIT_MB);
}
let err = err.expect_err("malformed override must fail");
match err {
DaemonError::Config { path, .. } => {
assert_eq!(path, Path::new(ENV_MEMORY_LIMIT_MB));
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn working_set_multiplier_matches_spec() {
assert!((WORKING_SET_MULTIPLIER - 1.5_f64).abs() < f64::EPSILON);
assert!((INTERNER_BUILDER_OVERHEAD_RATIO - 0.25_f64).abs() < f64::EPSILON);
}
#[test]
#[cfg(unix)]
fn runtime_dir_is_real_uid_scoped_when_user_env_is_unset() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prior_user = env::var_os("USER");
let prior_username = env::var_os("USERNAME");
let prior_xdg = env::var_os("XDG_RUNTIME_DIR");
let prior_tmpdir = env::var_os("TMPDIR");
unsafe {
env::remove_var("USER");
env::remove_var("USERNAME");
env::remove_var("XDG_RUNTIME_DIR");
env::remove_var("TMPDIR");
}
let cfg = DaemonConfig::default();
let socket = cfg.socket_path();
let pid = cfg.pid_path();
let lock = cfg.lock_path();
unsafe {
if let Some(v) = prior_user {
env::set_var("USER", v);
}
if let Some(v) = prior_username {
env::set_var("USERNAME", v);
}
if let Some(v) = prior_xdg {
env::set_var("XDG_RUNTIME_DIR", v);
}
if let Some(v) = prior_tmpdir {
env::set_var("TMPDIR", v);
}
}
let uid = unsafe { libc::getuid() };
let expected = format!("/tmp/sqry-{uid}");
assert_eq!(
socket.parent().and_then(Path::to_str),
Some(expected.as_str()),
"socket_path must be UID-scoped: socket = {socket:?}",
);
assert_eq!(
pid.parent().and_then(Path::to_str),
Some(expected.as_str()),
"pid_path must be UID-scoped: pid = {pid:?}",
);
assert_eq!(
lock.parent().and_then(Path::to_str),
Some(expected.as_str()),
"lock_path must be UID-scoped: lock = {lock:?}",
);
assert!(
!expected.ends_with("sqry-default"),
"runtime dir must never fall back to the shared /tmp/sqry-default path",
);
}
#[test]
fn round_trip_via_toml_preserves_workspace_entries() {
let text = r#"
memory_limit_mb = 1024
[[workspaces]]
path = "/foo"
pinned = true
[[workspaces]]
path = "/bar"
exclude = true
"#;
let cfg = DaemonConfig::from_toml_str(text).unwrap();
assert_eq!(cfg.workspaces.len(), 2);
assert!(cfg.workspaces[0].pinned);
assert!(cfg.workspaces[1].exclude);
}
#[test]
fn u2_defaults_match_spec() {
let cfg = DaemonConfig::default();
assert_eq!(
cfg.auto_start_ready_timeout_secs, 10,
"auto_start_ready_timeout_secs default must be 10"
);
assert_eq!(
cfg.log_keep_rotations, 5,
"log_keep_rotations default must be 5"
);
assert!(
!cfg.install_user_service,
"install_user_service default must be false"
);
}
#[test]
fn u2_auto_start_ready_timeout_env_override() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "30");
}
let mut cfg = DaemonConfig::default();
let result = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
}
result.expect("override ok");
assert_eq!(cfg.auto_start_ready_timeout_secs, 30);
}
#[test]
fn u2_auto_start_ready_timeout_env_override_rejects_malformed() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "not-a-number");
}
let mut cfg = DaemonConfig::default();
let err = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
}
let err = err.expect_err("malformed value must fail");
match err {
DaemonError::Config { path, .. } => {
assert_eq!(path, Path::new(ENV_AUTO_START_READY_TIMEOUT_SECS));
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn u2_log_keep_rotations_env_override() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_LOG_KEEP_ROTATIONS, "20");
}
let mut cfg = DaemonConfig::default();
let result = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_LOG_KEEP_ROTATIONS);
}
result.expect("override ok");
assert_eq!(cfg.log_keep_rotations, 20);
}
#[test]
fn u2_log_keep_rotations_env_override_rejects_malformed() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe {
env::set_var(ENV_LOG_KEEP_ROTATIONS, "bad");
}
let mut cfg = DaemonConfig::default();
let err = cfg.apply_env_overrides();
unsafe {
env::remove_var(ENV_LOG_KEEP_ROTATIONS);
}
let err = err.expect_err("malformed value must fail");
match err {
DaemonError::Config { path, .. } => {
assert_eq!(path, Path::new(ENV_LOG_KEEP_ROTATIONS));
}
other => panic!("expected Config error, got {other:?}"),
}
}
#[test]
fn u2_validate_auto_start_ready_timeout_range() {
let zero = DaemonConfig {
auto_start_ready_timeout_secs: 0,
..DaemonConfig::default()
};
assert!(zero.validate().is_err(), "0 must be rejected");
let over = DaemonConfig {
auto_start_ready_timeout_secs: 61,
..DaemonConfig::default()
};
assert!(over.validate().is_err(), "61 must be rejected");
let min = DaemonConfig {
auto_start_ready_timeout_secs: 1,
..DaemonConfig::default()
};
assert!(min.validate().is_ok(), "1 must be valid");
let max = DaemonConfig {
auto_start_ready_timeout_secs: 60,
..DaemonConfig::default()
};
assert!(max.validate().is_ok(), "60 must be valid");
}
#[test]
fn u2_validate_log_keep_rotations_range() {
let zero = DaemonConfig {
log_keep_rotations: 0,
..DaemonConfig::default()
};
assert!(zero.validate().is_err(), "0 must be rejected");
let over = DaemonConfig {
log_keep_rotations: 101,
..DaemonConfig::default()
};
assert!(over.validate().is_err(), "101 must be rejected");
let min = DaemonConfig {
log_keep_rotations: 1,
..DaemonConfig::default()
};
assert!(min.validate().is_ok(), "1 must be valid");
let max = DaemonConfig {
log_keep_rotations: 100,
..DaemonConfig::default()
};
assert!(max.validate().is_ok(), "100 must be valid");
}
#[test]
fn u2_from_toml_str_round_trip_new_fields() {
let text = r#"
auto_start_ready_timeout_secs = 45
log_keep_rotations = 10
install_user_service = true
"#;
let cfg = DaemonConfig::from_toml_str(text).expect("parse");
assert_eq!(cfg.auto_start_ready_timeout_secs, 45);
assert_eq!(cfg.log_keep_rotations, 10);
assert!(cfg.install_user_service);
}
#[test]
fn u2_from_toml_str_new_fields_default_when_absent() {
let text = r"memory_limit_mb = 1024";
let cfg = DaemonConfig::from_toml_str(text).expect("parse");
assert_eq!(
cfg.auto_start_ready_timeout_secs,
DEFAULT_AUTO_START_READY_TIMEOUT_SECS
);
assert_eq!(cfg.log_keep_rotations, DEFAULT_LOG_KEEP_ROTATIONS);
assert!(!cfg.install_user_service);
}
#[test]
fn u2_install_user_service_defaults_false_and_is_tolerated_by_validate() {
let with_true = DaemonConfig {
install_user_service: true,
..DaemonConfig::default()
};
assert!(
with_true.validate().is_ok(),
"install_user_service=true must pass validate"
);
let with_false = DaemonConfig {
install_user_service: false,
..DaemonConfig::default()
};
assert!(
with_false.validate().is_ok(),
"install_user_service=false must pass validate"
);
}
}