Skip to main content

sqry_daemon/
config.rs

1//! Daemon configuration.
2//!
3//! Loads `~/.config/sqry/daemon.toml` (path overridable via `SQRY_DAEMON_CONFIG`)
4//! into a [`DaemonConfig`] and layers environment-variable overrides on top.
5//! Every field from the Amendment-2 design is represented — memory budgeting,
6//! admission working-set multipliers, staleness window, debounce / incremental
7//! thresholds, interner compaction trigger, socket path, log rotation.
8//!
9//! Design notes per plan Task 5 Step 4b (Amendment 2 §G.6):
10//!
11//! - [`WORKING_SET_MULTIPLIER`] and [`INTERNER_BUILDER_OVERHEAD_RATIO`] are
12//!   `const` and *not* user-tuneable. They are derived from benchmarking on
13//!   the reference 384 k-node / 1.3 M-edge graph and must stay in sync with
14//!   the `WorkspaceManager::reserve_rebuild` accounting in Task 6.
15//! - Runtime-tuneable knobs live on [`DaemonConfig`] and flow through admission
16//!   accounting, the retention reaper, the rebuild dispatcher, and the stale-
17//!   serving router.
18//! - Every knob that users can legitimately want to override without editing a
19//!   config file has an `SQRY_DAEMON_*` env-var override. Env-var overrides
20//!   take precedence over the TOML file so operators can run one-off daemons
21//!   with bumped memory limits without munging user configs.
22
23use std::{
24    env,
25    path::{Path, PathBuf},
26};
27
28use anyhow::{Context, anyhow};
29use serde::Deserialize;
30
31use crate::error::{DaemonError, DaemonResult};
32
33// ---------------------------------------------------------------------------
34// Public constants (Amendment 2 §G.6 — admission working-set rule).
35// ---------------------------------------------------------------------------
36
37/// Covers duplicated index/edge structures held during rebuild before finalize.
38///
39/// Source: Amendment 2 §G.6. `working_set_estimate = new_graph_final_estimate *
40/// WORKING_SET_MULTIPLIER + staging_overhead + interner_builder_overhead`.
41/// Conservative by design — err high.
42pub const WORKING_SET_MULTIPLIER: f64 = 1.5;
43
44/// Bounded growth headroom for the rebuild-local interner builder, expressed
45/// as a fraction of the seed snapshot's bytes.
46///
47/// Source: Amendment 2 §G.6. Used by
48/// `WorkspaceManager::reserve_rebuild` in Task 6.
49pub const INTERNER_BUILDER_OVERHEAD_RATIO: f64 = 0.25;
50
51/// Heuristic per-file byte estimate for rebuild staging overhead.
52///
53/// Consumed by the Task 7 [`crate::rebuild::RebuildDispatcher`] when
54/// populating [`crate::workspace::WorkingSetInputs::staging_overhead`]
55/// before calling `reserve_rebuild`. 4 KiB ≈ one memory page of
56/// per-file staging state (`StagingGraph` + per-plugin buffers).
57///
58/// This value is **heuristic**, not empirically measured. Large
59/// symbol-dense files may exceed it; admission is permitted to
60/// over-reserve because the reservation is refunded on failure and
61/// the excess bytes return to the pool after publish's
62/// `saturating_sub` in `publish_and_retain`. Per-fixture calibration
63/// is deferred to Task 14 tuning, not a 7a correctness concern.
64pub const ESTIMATE_STAGING_PER_FILE_BYTES: u64 = 4_096;
65
66/// Heuristic per-file byte estimate for the committed graph's final
67/// heap cost.
68///
69/// Consumed by the Task 7 [`crate::rebuild::RebuildDispatcher`] when
70/// populating [`crate::workspace::WorkingSetInputs::new_graph_final_estimate`]
71/// for incremental rebuilds — the final-size estimate is
72/// `prior.heap_bytes() + closure.len() * ESTIMATE_FINAL_PER_FILE_BYTES`.
73///
74/// Like [`ESTIMATE_STAGING_PER_FILE_BYTES`], this is a heuristic
75/// starting value rather than a fixture-tuned constant. Calibration
76/// is a Task 14 concern.
77pub const ESTIMATE_FINAL_PER_FILE_BYTES: u64 = 2_048;
78
79/// Environment variable that overrides the daemon config file path.
80pub const ENV_CONFIG_PATH: &str = "SQRY_DAEMON_CONFIG";
81
82/// Environment variable that overrides `memory_limit_mb`.
83pub const ENV_MEMORY_LIMIT_MB: &str = "SQRY_DAEMON_MEMORY_MB";
84
85/// Environment variable that overrides the IPC socket path.
86pub const ENV_SOCKET_PATH: &str = "SQRY_DAEMON_SOCKET";
87
88/// Environment variable that overrides the Windows named pipe name.
89pub const ENV_PIPE_NAME: &str = "SQRY_DAEMON_PIPE";
90
91/// Environment variable that overrides `log_level`.
92pub const ENV_LOG_LEVEL: &str = "SQRY_DAEMON_LOG_LEVEL";
93
94/// Environment variable that overrides `log_file`.
95pub const ENV_LOG_FILE: &str = "SQRY_DAEMON_LOG_FILE";
96
97/// Environment variable that overrides `stale_serve_max_age_hours`.
98pub const ENV_STALE_MAX_AGE_HOURS: &str = "SQRY_DAEMON_STALE_MAX_AGE_HOURS";
99
100/// Environment variable that overrides `tool_timeout_secs`. Task 8
101/// Phase 8c U6.
102pub const ENV_TOOL_TIMEOUT_SECS: &str = "SQRY_DAEMON_TOOL_TIMEOUT_SECS";
103
104/// Environment variable that overrides `max_shim_connections`. Task 8
105/// Phase 8c U10.
106pub const ENV_MAX_SHIM_CONNECTIONS: &str = "SQRY_DAEMON_MAX_SHIM_CONNECTIONS";
107
108/// Environment variable that overrides `auto_start_ready_timeout_secs`. Task 9
109/// U2.
110pub const ENV_AUTO_START_READY_TIMEOUT_SECS: &str = "SQRY_DAEMON_AUTO_START_READY_TIMEOUT_SECS";
111
112/// Environment variable that overrides `log_keep_rotations`. Task 9 U2.
113pub const ENV_LOG_KEEP_ROTATIONS: &str = "SQRY_DAEMON_LOG_KEEP_ROTATIONS";
114
115// ---------------------------------------------------------------------------
116// Built-in defaults (match plan §5 Step 3 table).
117// ---------------------------------------------------------------------------
118
119/// Default: 2 GiB memory budget for the whole daemon.
120pub const DEFAULT_MEMORY_LIMIT_MB: u64 = 2_048;
121/// Default: idle workspaces eligible for eviction after 30 minutes.
122pub const DEFAULT_IDLE_TIMEOUT_MINUTES: u64 = 30;
123/// Default: 2 s coalescing window for file-system notifications.
124pub const DEFAULT_DEBOUNCE_MS: u64 = 2_000;
125/// Default: > 20 changed files → full rebuild instead of incremental.
126pub const DEFAULT_INCREMENTAL_THRESHOLD: usize = 20;
127/// Default: reverse-dep closure > 30% of file count → full rebuild.
128pub const DEFAULT_CLOSURE_LIMIT_PERCENT: u32 = 30;
129/// Default: 24 h stale-serve cap (`0` disables the cap).
130pub const DEFAULT_STALE_SERVE_MAX_AGE_HOURS: u32 = 24;
131/// Default: retention reaper logs a WARN after 5 s of held-retained state.
132pub const DEFAULT_REBUILD_DRAIN_TIMEOUT_MS: u64 = 5_000;
133/// Default: `live_ratio < 0.5` triggers a mandatory full rebuild at the next
134/// debounce tick (interner compaction housekeeping).
135pub const DEFAULT_INTERNER_COMPACTION_THRESHOLD: f32 = 0.5;
136/// Default: 5 s grace window for the IPC accept loop to drain active
137/// connections during shutdown before the server returns.
138///
139/// Task 8 Phase 8a. Valid range (enforced by [`DaemonConfig::validate`]):
140/// `1..=3600`.
141pub const DEFAULT_IPC_SHUTDOWN_DRAIN_SECS: u64 = 5;
142/// Default: 60 s per-tool invocation timeout — response-latency bound
143/// consumed by
144/// [`crate::ipc::tool_core::classify_and_execute`]. Task 8 Phase 8c U6.
145///
146/// Valid range (enforced by [`DaemonConfig::validate`]): `1..=3600`. A
147/// zero timeout would cause every `spawn_blocking` call to race
148/// `tokio::time::timeout` at 0ms and is therefore rejected.
149pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 60;
150/// Default: cap on the number of concurrently-registered shim
151/// byte-pump connections (`sqry lsp --daemon` / `sqry mcp --daemon`).
152/// Consumed by
153/// [`crate::ipc::shim_registry::ShimRegistry::try_register_bounded`]
154/// from the Phase 8c router (U10). Task 8 Phase 8c U10.
155///
156/// Valid range (enforced by [`DaemonConfig::validate`]): `1..=65_536`.
157/// `256` is comfortably above the realistic fan-out of any single
158/// developer workstation (one IDE + one MCP client per project,
159/// typically ≤ 8 workspaces × 2 protocols = 16) while still bounding
160/// the worst-case memory footprint of the registry
161/// (`HashMap<ShimConnId, ShimConnEntry>`) should a buggy or malicious
162/// client spam `ShimRegister` frames.
163pub const DEFAULT_MAX_SHIM_CONNECTIONS: usize = 256;
164/// Default: `info`.
165pub const DEFAULT_LOG_LEVEL: &str = "info";
166/// Default: rotate daemon log at 50 MiB.
167pub const DEFAULT_LOG_MAX_SIZE_MB: u64 = 50;
168/// Default: poll timeout waiting for the daemon socket to become reachable
169/// after auto-spawn. Used by both the `--detach` parent wait loop and the
170/// `lifecycle::start_detached` helper. Validated range: `1..=60`.
171pub const DEFAULT_AUTO_START_READY_TIMEOUT_SECS: u64 = 10;
172/// Default: number of rotated log files to keep alongside the active log.
173/// A value of 5 means up to 5 `.N` suffixed archive files are retained;
174/// the oldest is deleted when a new rotation creates `.6`. Validated range:
175/// `1..=100`.
176pub const DEFAULT_LOG_KEEP_ROTATIONS: u32 = 5;
177
178// ---------------------------------------------------------------------------
179// Config structs.
180// ---------------------------------------------------------------------------
181
182/// Top-level daemon configuration.
183///
184/// Loaded from `~/.config/sqry/daemon.toml` by default. Env-var overrides
185/// (see the `ENV_*` constants) are layered on top by [`DaemonConfig::load`].
186#[derive(Debug, Clone, Deserialize, serde::Serialize)]
187#[serde(deny_unknown_fields)]
188pub struct DaemonConfig {
189    /// Hard cap on total resident graph memory across every loaded workspace.
190    #[serde(default = "default_memory_limit_mb")]
191    pub memory_limit_mb: u64,
192
193    /// Workspace idle-timeout before it becomes eligible for LRU eviction.
194    #[serde(default = "default_idle_timeout_minutes")]
195    pub idle_timeout_minutes: u64,
196
197    /// Filesystem-watcher debounce window (ms) for coalescing bursts of changes.
198    #[serde(default = "default_debounce_ms")]
199    pub debounce_ms: u64,
200
201    /// If > `incremental_threshold` files changed in one window, full-rebuild
202    /// instead of incremental-rebuild.
203    #[serde(default = "default_incremental_threshold")]
204    pub incremental_threshold: usize,
205
206    /// If the reverse-dep closure covers > `closure_limit_percent`% of the
207    /// graph's files, full-rebuild instead of incremental-rebuild.
208    #[serde(default = "default_closure_limit_percent")]
209    pub closure_limit_percent: u32,
210
211    /// Cap on how long a Failed workspace may keep serving its last-good
212    /// snapshot as `stale: true`. `0` disables the cap (serve indefinitely).
213    #[serde(default = "default_stale_serve_max_age_hours")]
214    pub stale_serve_max_age_hours: u32,
215
216    /// Retention-reaper WARN threshold, **not** an accounting deadline.
217    /// Retained bytes are released when `Arc::strong_count` drops to 1 —
218    /// regardless of wall-clock time.
219    #[serde(default = "default_rebuild_drain_timeout_ms")]
220    pub rebuild_drain_timeout_ms: u64,
221
222    /// Grace window (seconds) for the IPC accept loop to drain active
223    /// connections during shutdown. Task 8 Phase 8a.
224    #[serde(default = "default_ipc_shutdown_drain_secs")]
225    pub ipc_shutdown_drain_secs: u64,
226
227    /// Per-tool invocation timeout. Bounds the response latency of
228    /// any single tool call; exceeding this returns
229    /// [`DaemonError::ToolTimeout`] (JSON-RPC `-32000` / MCP
230    /// `internal_error` with `kind = "deadline_exceeded"`).
231    ///
232    /// **Important contract**: this bounds RESPONSE LATENCY, not the
233    /// detached OS-thread lifetime. When the timeout fires, the
234    /// [`tokio::task::spawn_blocking`] [`tokio::task::JoinHandle`] is
235    /// dropped; the OS thread running the tool closure continues
236    /// until the closure itself returns. A buggy/runaway tool closure
237    /// can keep its thread alive past `daemon/stop`. Default 60
238    /// seconds. Task 8 Phase 8c U6.
239    ///
240    /// [`DaemonError::ToolTimeout`]: crate::error::DaemonError::ToolTimeout
241    #[serde(default = "default_tool_timeout_secs")]
242    pub tool_timeout_secs: u64,
243
244    /// Cap on the number of concurrently-registered shim byte-pump
245    /// connections. Every accepted `ShimRegister` frame must pass
246    /// [`crate::ipc::shim_registry::ShimRegistry::try_register_bounded`]
247    /// against this cap under a single mutex-guard — over-cap
248    /// admissions reply `ShimRegisterAck { accepted: false, reason:
249    /// "shim registry full (N / cap)" }` and the connection closes.
250    /// Default `256`. Task 8 Phase 8c U10.
251    #[serde(default = "default_max_shim_connections")]
252    pub max_shim_connections: usize,
253
254    /// Interner housekeeping: if the live-ratio drops below this, the next
255    /// debounce tick schedules a mandatory full rebuild.
256    #[serde(default = "default_interner_compaction_threshold")]
257    pub interner_compaction_threshold: f32,
258
259    /// Optional structured-log file path.
260    #[serde(default)]
261    pub log_file: Option<PathBuf>,
262
263    /// Log verbosity (matches `tracing_subscriber::EnvFilter` syntax).
264    #[serde(default = "default_log_level")]
265    pub log_level: String,
266
267    /// Log-rotation trigger.
268    #[serde(default = "default_log_max_size_mb")]
269    pub log_max_size_mb: u64,
270
271    /// IPC listener binding (UDS on Unix, named pipe on Windows).
272    #[serde(default)]
273    pub socket: SocketConfig,
274
275    /// Pre-declared workspaces — pinned workspaces load at daemon startup.
276    #[serde(default)]
277    pub workspaces: Vec<WorkspaceConfig>,
278
279    /// Timeout (seconds) used in two places:
280    ///
281    /// 1. **`--detach` parent wait loop** (`run_start_detach`): how long the
282    ///    launching parent process waits for the grandchild to signal ready via
283    ///    the self-pipe before giving up and killing the grandchild.
284    /// 2. **`lifecycle::start_detached`** (Task 10 auto-spawn): how long the
285    ///    client helper polls the daemon socket before returning
286    ///    [`DaemonError::AutoStartTimeout`].
287    ///
288    /// Valid range (enforced by [`DaemonConfig::validate`]): `1..=60`.
289    #[serde(default = "default_auto_start_ready_timeout_secs")]
290    pub auto_start_ready_timeout_secs: u64,
291
292    /// Number of rotated log archives to keep alongside the live log file.
293    ///
294    /// When [`RollingSizeAppender`] rotates, it shifts existing `.1`–`.N` files
295    /// one position and deletes any file beyond this limit. A value of `5` means
296    /// `.1`–`.5` are retained; `.6` and beyond are removed.
297    ///
298    /// Valid range (enforced by [`DaemonConfig::validate`]): `1..=100`.
299    ///
300    /// [`RollingSizeAppender`]: crate::lifecycle::log_rotate::RollingSizeAppender
301    #[serde(default = "default_log_keep_rotations")]
302    pub log_keep_rotations: u32,
303
304    /// Reserved for future use — will drive automated systemd user-service
305    /// installation on first `sqryd start` when set to `true`. Currently a
306    /// no-op; stored in config to avoid breaking changes when the feature
307    /// lands. Defaults to `false`.
308    #[serde(default)]
309    pub install_user_service: bool,
310}
311
312impl Default for DaemonConfig {
313    fn default() -> Self {
314        Self {
315            memory_limit_mb: DEFAULT_MEMORY_LIMIT_MB,
316            idle_timeout_minutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
317            debounce_ms: DEFAULT_DEBOUNCE_MS,
318            incremental_threshold: DEFAULT_INCREMENTAL_THRESHOLD,
319            closure_limit_percent: DEFAULT_CLOSURE_LIMIT_PERCENT,
320            stale_serve_max_age_hours: DEFAULT_STALE_SERVE_MAX_AGE_HOURS,
321            rebuild_drain_timeout_ms: DEFAULT_REBUILD_DRAIN_TIMEOUT_MS,
322            ipc_shutdown_drain_secs: DEFAULT_IPC_SHUTDOWN_DRAIN_SECS,
323            tool_timeout_secs: DEFAULT_TOOL_TIMEOUT_SECS,
324            max_shim_connections: DEFAULT_MAX_SHIM_CONNECTIONS,
325            interner_compaction_threshold: DEFAULT_INTERNER_COMPACTION_THRESHOLD,
326            log_file: None,
327            log_level: DEFAULT_LOG_LEVEL.to_owned(),
328            log_max_size_mb: DEFAULT_LOG_MAX_SIZE_MB,
329            socket: SocketConfig::default(),
330            workspaces: Vec::new(),
331            auto_start_ready_timeout_secs: DEFAULT_AUTO_START_READY_TIMEOUT_SECS,
332            log_keep_rotations: DEFAULT_LOG_KEEP_ROTATIONS,
333            install_user_service: false,
334        }
335    }
336}
337
338/// IPC binding configuration.
339///
340/// On Unix, [`SocketConfig::path`] takes precedence. On Windows,
341/// [`SocketConfig::pipe_name`] takes precedence. If neither is set the
342/// platform default is used (see [`DaemonConfig::socket_path`]).
343#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
344#[serde(deny_unknown_fields)]
345pub struct SocketConfig {
346    /// Unix-domain socket path.
347    #[serde(default)]
348    pub path: Option<PathBuf>,
349
350    /// Windows named-pipe name (e.g. `sqryd`).
351    #[serde(default)]
352    pub pipe_name: Option<String>,
353}
354
355/// Pre-declared workspace entry.
356///
357/// `pinned = true` keeps the workspace in memory indefinitely (LRU exempt).
358/// `exclude = true` skips the workspace during auto-discovery.
359#[derive(Debug, Clone, Deserialize, serde::Serialize)]
360#[serde(deny_unknown_fields)]
361pub struct WorkspaceConfig {
362    /// Absolute path to the workspace root.
363    pub path: PathBuf,
364
365    /// Whether the workspace is LRU-exempt. Defaults to `false`.
366    #[serde(default)]
367    pub pinned: bool,
368
369    /// Whether the workspace should be skipped entirely. Defaults to `false`.
370    #[serde(default)]
371    pub exclude: bool,
372}
373
374// ---------------------------------------------------------------------------
375// Loader / path helpers.
376// ---------------------------------------------------------------------------
377
378impl DaemonConfig {
379    /// Load the effective config: start from defaults, apply the TOML file at
380    /// the canonical path (or the one named by [`ENV_CONFIG_PATH`]), then
381    /// layer environment-variable overrides.
382    ///
383    /// A missing config file is **not** an error — the defaults plus env-var
384    /// overrides are returned. A malformed file is always an error.
385    pub fn load() -> DaemonResult<Self> {
386        let path = Self::resolve_config_path()?;
387        let mut config = if path.exists() {
388            Self::load_from_path(&path)?
389        } else {
390            Self::default()
391        };
392        config.apply_env_overrides()?;
393        config.validate()?;
394        Ok(config)
395    }
396
397    /// Load a config file from an explicit path, ignoring env overrides.
398    /// Useful for tests and documentation examples.
399    pub fn load_from_path(path: &Path) -> DaemonResult<Self> {
400        let text = std::fs::read_to_string(path).map_err(|source| DaemonError::Config {
401            path: path.to_path_buf(),
402            source: anyhow::Error::from(source).context("reading daemon config"),
403        })?;
404        Self::from_toml_str(&text).map_err(|source| DaemonError::Config {
405            path: path.to_path_buf(),
406            source,
407        })
408    }
409
410    /// Parse a TOML string into a [`DaemonConfig`]. Defaults fill any missing
411    /// fields.
412    pub fn from_toml_str(text: &str) -> anyhow::Result<Self> {
413        let cfg: Self = toml::from_str(text).context("parsing daemon config TOML")?;
414        Ok(cfg)
415    }
416
417    /// Apply `SQRY_DAEMON_*` environment-variable overrides. See the
418    /// `ENV_*` constants for the full list.
419    pub fn apply_env_overrides(&mut self) -> DaemonResult<()> {
420        if let Some(v) = env::var_os(ENV_MEMORY_LIMIT_MB) {
421            let v = v.to_string_lossy().into_owned();
422            self.memory_limit_mb = v.parse::<u64>().map_err(|e| DaemonError::Config {
423                path: PathBuf::from(ENV_MEMORY_LIMIT_MB),
424                source: anyhow!("{ENV_MEMORY_LIMIT_MB}={v:?} must be an unsigned int: {e}"),
425            })?;
426        }
427        if let Some(v) = env::var_os(ENV_SOCKET_PATH) {
428            self.socket.path = Some(PathBuf::from(v));
429        }
430        if let Some(v) = env::var_os(ENV_PIPE_NAME) {
431            self.socket.pipe_name = Some(v.to_string_lossy().into_owned());
432        }
433        if let Some(v) = env::var_os(ENV_LOG_LEVEL) {
434            self.log_level = v.to_string_lossy().into_owned();
435        }
436        if let Some(v) = env::var_os(ENV_LOG_FILE) {
437            self.log_file = Some(PathBuf::from(v));
438        }
439        if let Some(v) = env::var_os(ENV_STALE_MAX_AGE_HOURS) {
440            let v = v.to_string_lossy().into_owned();
441            self.stale_serve_max_age_hours = v.parse::<u32>().map_err(|e| DaemonError::Config {
442                path: PathBuf::from(ENV_STALE_MAX_AGE_HOURS),
443                source: anyhow!("{ENV_STALE_MAX_AGE_HOURS}={v:?}: {e}"),
444            })?;
445        }
446        if let Some(v) = env::var_os(ENV_TOOL_TIMEOUT_SECS) {
447            let v = v.to_string_lossy().into_owned();
448            self.tool_timeout_secs = v.parse::<u64>().map_err(|e| DaemonError::Config {
449                path: PathBuf::from(ENV_TOOL_TIMEOUT_SECS),
450                source: anyhow!("{ENV_TOOL_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"),
451            })?;
452        }
453        if let Some(v) = env::var_os(ENV_MAX_SHIM_CONNECTIONS) {
454            let v = v.to_string_lossy().into_owned();
455            self.max_shim_connections = v.parse::<usize>().map_err(|e| DaemonError::Config {
456                path: PathBuf::from(ENV_MAX_SHIM_CONNECTIONS),
457                source: anyhow!("{ENV_MAX_SHIM_CONNECTIONS}={v:?} must be an unsigned int: {e}"),
458            })?;
459        }
460        if let Some(v) = env::var_os(ENV_AUTO_START_READY_TIMEOUT_SECS) {
461            let v = v.to_string_lossy().into_owned();
462            self.auto_start_ready_timeout_secs =
463                v.parse::<u64>().map_err(|e| DaemonError::Config {
464                    path: PathBuf::from(ENV_AUTO_START_READY_TIMEOUT_SECS),
465                    source: anyhow!(
466                        "{ENV_AUTO_START_READY_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"
467                    ),
468                })?;
469        }
470        if let Some(v) = env::var_os(ENV_LOG_KEEP_ROTATIONS) {
471            let v = v.to_string_lossy().into_owned();
472            self.log_keep_rotations = v.parse::<u32>().map_err(|e| DaemonError::Config {
473                path: PathBuf::from(ENV_LOG_KEEP_ROTATIONS),
474                source: anyhow!("{ENV_LOG_KEEP_ROTATIONS}={v:?} must be an unsigned int: {e}"),
475            })?;
476        }
477        Ok(())
478    }
479
480    /// Sanity-check invariants that admission accounting and the rebuild
481    /// dispatcher depend on.
482    pub fn validate(&self) -> DaemonResult<()> {
483        let reject = |msg: &str| DaemonError::Config {
484            path: PathBuf::from("<in-memory>"),
485            source: anyhow!("{msg}"),
486        };
487        if self.memory_limit_mb == 0 {
488            return Err(reject("memory_limit_mb must be > 0"));
489        }
490        if self.closure_limit_percent == 0 || self.closure_limit_percent > 100 {
491            return Err(reject("closure_limit_percent must be in 1..=100"));
492        }
493        if !self.interner_compaction_threshold.is_finite()
494            || self.interner_compaction_threshold <= 0.0
495            || self.interner_compaction_threshold > 1.0
496        {
497            return Err(reject(
498                "interner_compaction_threshold must be in (0.0, 1.0]",
499            ));
500        }
501        if self.debounce_ms == 0 {
502            return Err(reject("debounce_ms must be > 0"));
503        }
504        if self.log_max_size_mb == 0 {
505            return Err(reject("log_max_size_mb must be > 0"));
506        }
507        if self.ipc_shutdown_drain_secs == 0 || self.ipc_shutdown_drain_secs > 3_600 {
508            return Err(reject("ipc_shutdown_drain_secs must be in 1..=3600"));
509        }
510        if self.tool_timeout_secs == 0 || self.tool_timeout_secs > 3_600 {
511            return Err(reject("tool_timeout_secs must be in 1..=3600"));
512        }
513        if self.max_shim_connections == 0 || self.max_shim_connections > 65_536 {
514            return Err(reject("max_shim_connections must be in 1..=65536"));
515        }
516        if self.auto_start_ready_timeout_secs == 0 || self.auto_start_ready_timeout_secs > 60 {
517            return Err(reject("auto_start_ready_timeout_secs must be in 1..=60"));
518        }
519        if self.log_keep_rotations == 0 || self.log_keep_rotations > 100 {
520            return Err(reject("log_keep_rotations must be in 1..=100"));
521        }
522        Ok(())
523    }
524
525    /// Resolve the config-file path, respecting [`ENV_CONFIG_PATH`].
526    ///
527    /// Falls back to `$XDG_CONFIG_HOME/sqry/daemon.toml`, then
528    /// `$HOME/.config/sqry/daemon.toml`.
529    pub fn resolve_config_path() -> DaemonResult<PathBuf> {
530        if let Some(v) = env::var_os(ENV_CONFIG_PATH) {
531            return Ok(PathBuf::from(v));
532        }
533        let base = dirs::config_dir().ok_or_else(|| DaemonError::Config {
534            path: PathBuf::from("~/.config"),
535            source: anyhow!("could not determine user config directory; set {ENV_CONFIG_PATH}"),
536        })?;
537        Ok(base.join("sqry").join("daemon.toml"))
538    }
539
540    /// Path the IPC server binds to.
541    ///
542    /// - Unix: explicit `socket.path`, else `$XDG_RUNTIME_DIR/sqry/sqryd.sock`,
543    ///   else `$TMPDIR/sqry-<uid>/sqryd.sock`.
544    /// - Windows: `\\\\.\\pipe\\<socket.pipe_name>` (default `sqry`).
545    #[must_use]
546    pub fn socket_path(&self) -> PathBuf {
547        if cfg!(windows) {
548            let name = self
549                .socket
550                .pipe_name
551                .clone()
552                .unwrap_or_else(|| "sqry".to_string());
553            return PathBuf::from(format!(r"\\.\pipe\{name}"));
554        }
555        if let Some(path) = &self.socket.path {
556            return path.clone();
557        }
558        runtime_dir().join("sqryd.sock")
559    }
560
561    /// Where to write the daemon pidfile. One per user.
562    #[must_use]
563    pub fn pid_path(&self) -> PathBuf {
564        runtime_dir().join("sqryd.pid")
565    }
566
567    /// Flock target — held exclusively by the running daemon, and briefly
568    /// by clients during auto-start to avoid racing two `sqry` processes.
569    #[must_use]
570    pub fn lock_path(&self) -> PathBuf {
571        runtime_dir().join("sqryd.lock")
572    }
573
574    /// Platform-specific per-user runtime directory where the socket, pidfile,
575    /// and lockfile live.
576    ///
577    /// This is the public accessor for the private [`runtime_dir`] free
578    /// function.  The return value is the same as `socket_path().parent()`
579    /// when the socket path uses the default (not the explicit `socket.path`
580    /// override).
581    #[must_use]
582    pub fn runtime_dir(&self) -> PathBuf {
583        runtime_dir()
584    }
585
586    /// Memory budget in bytes, derived from [`Self::memory_limit_mb`].
587    #[must_use]
588    pub const fn memory_limit_bytes(&self) -> u64 {
589        self.memory_limit_mb.saturating_mul(1024 * 1024)
590    }
591}
592
593/// Platform-specific per-user runtime directory for socket / pid / lock files.
594///
595/// On Unix, the `/tmp` fallback is *always* suffixed with the real POSIX
596/// UID (via `libc::getuid`) rather than the `USER` env var, so that two
597/// processes running as different users on the same host cannot collide
598/// on `/tmp/sqry-default/sqryd.{sock,pid,lock}` when `USER`/`USERNAME`
599/// are unset (realistic in systemd units without `User=`, Docker
600/// containers, and CI runners). See Codex Task 5 iter-1 review MAJOR
601/// finding (`docs/reviews/sqryd-daemon/2026-04-18/task-5-scaffold_iter1_request_review.md`).
602fn runtime_dir() -> PathBuf {
603    if cfg!(windows)
604        && let Some(local) = env::var_os("LOCALAPPDATA")
605    {
606        return PathBuf::from(local).join("sqry");
607    }
608    if let Some(xdg) = env::var_os("XDG_RUNTIME_DIR") {
609        return PathBuf::from(xdg).join("sqry");
610    }
611    if let Some(tmp) = env::var_os("TMPDIR") {
612        return PathBuf::from(tmp).join(user_scoped_dir_name());
613    }
614    PathBuf::from("/tmp").join(user_scoped_dir_name())
615}
616
617/// Per-user directory name used in the `/tmp`-style fallback.
618///
619/// - On Unix, always `sqry-<uid>` where `<uid>` is the real POSIX UID
620///   via [`libc::getuid`]. Never falls back to a string env-var proxy —
621///   `getuid` cannot fail.
622/// - On Windows the only reachable callers of this function already
623///   bypassed the LOCALAPPDATA branch, so we use `USERNAME` as a
624///   best-effort user scope with a constant-suffix fallback. Windows
625///   UIDs (SIDs) would require a separate dependency just for this
626///   edge case; in practice LOCALAPPDATA is always set in any
627///   Windows configuration sqry supports.
628fn user_scoped_dir_name() -> String {
629    #[cfg(unix)]
630    {
631        // SAFETY: `libc::getuid` is a POSIX call with no preconditions,
632        // no mutable state, and no way to fail. Calling it from a
633        // multi-threaded program is safe per POSIX.
634        let uid = unsafe { libc::getuid() };
635        format!("sqry-{uid}")
636    }
637    #[cfg(not(unix))]
638    {
639        let user = env::var("USERNAME").unwrap_or_else(|_| "default".to_string());
640        format!("sqry-{user}")
641    }
642}
643
644// ---------------------------------------------------------------------------
645// serde default-function helpers.
646// ---------------------------------------------------------------------------
647
648const fn default_memory_limit_mb() -> u64 {
649    DEFAULT_MEMORY_LIMIT_MB
650}
651const fn default_idle_timeout_minutes() -> u64 {
652    DEFAULT_IDLE_TIMEOUT_MINUTES
653}
654const fn default_debounce_ms() -> u64 {
655    DEFAULT_DEBOUNCE_MS
656}
657const fn default_incremental_threshold() -> usize {
658    DEFAULT_INCREMENTAL_THRESHOLD
659}
660const fn default_closure_limit_percent() -> u32 {
661    DEFAULT_CLOSURE_LIMIT_PERCENT
662}
663const fn default_stale_serve_max_age_hours() -> u32 {
664    DEFAULT_STALE_SERVE_MAX_AGE_HOURS
665}
666const fn default_rebuild_drain_timeout_ms() -> u64 {
667    DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
668}
669const fn default_ipc_shutdown_drain_secs() -> u64 {
670    DEFAULT_IPC_SHUTDOWN_DRAIN_SECS
671}
672const fn default_tool_timeout_secs() -> u64 {
673    DEFAULT_TOOL_TIMEOUT_SECS
674}
675const fn default_max_shim_connections() -> usize {
676    DEFAULT_MAX_SHIM_CONNECTIONS
677}
678const fn default_interner_compaction_threshold() -> f32 {
679    DEFAULT_INTERNER_COMPACTION_THRESHOLD
680}
681fn default_log_level() -> String {
682    DEFAULT_LOG_LEVEL.to_owned()
683}
684const fn default_log_max_size_mb() -> u64 {
685    DEFAULT_LOG_MAX_SIZE_MB
686}
687const fn default_auto_start_ready_timeout_secs() -> u64 {
688    DEFAULT_AUTO_START_READY_TIMEOUT_SECS
689}
690const fn default_log_keep_rotations() -> u32 {
691    DEFAULT_LOG_KEEP_ROTATIONS
692}
693
694// ---------------------------------------------------------------------------
695// Tests.
696// ---------------------------------------------------------------------------
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    // Use the crate-wide TEST_ENV_LOCK to serialise environment-variable
703    // mutations across ALL test modules in the same binary.
704    use crate::TEST_ENV_LOCK as ENV_LOCK;
705
706    #[test]
707    fn defaults_match_plan_table() {
708        let cfg = DaemonConfig::default();
709        assert_eq!(cfg.memory_limit_mb, 2_048);
710        assert_eq!(cfg.idle_timeout_minutes, 30);
711        assert_eq!(cfg.debounce_ms, 2_000);
712        assert_eq!(cfg.incremental_threshold, 20);
713        assert_eq!(cfg.closure_limit_percent, 30);
714        assert_eq!(cfg.stale_serve_max_age_hours, 24);
715        assert_eq!(cfg.rebuild_drain_timeout_ms, 5_000);
716        assert_eq!(cfg.tool_timeout_secs, 60);
717        assert_eq!(cfg.max_shim_connections, 256);
718        assert!((cfg.interner_compaction_threshold - 0.5).abs() < f32::EPSILON);
719        assert_eq!(cfg.log_level, "info");
720        assert_eq!(cfg.log_max_size_mb, 50);
721        assert!(cfg.log_file.is_none());
722        assert!(cfg.socket.path.is_none());
723        assert!(cfg.socket.pipe_name.is_none());
724        assert!(cfg.workspaces.is_empty());
725    }
726
727    #[test]
728    fn memory_limit_bytes_is_mb_times_megabyte() {
729        let cfg = DaemonConfig::default();
730        assert_eq!(cfg.memory_limit_bytes(), 2_048 * 1024 * 1024);
731    }
732
733    #[test]
734    fn parses_minimal_toml() {
735        let text = r"
736            memory_limit_mb = 4096
737            idle_timeout_minutes = 60
738
739            [socket]
740            path = '/tmp/custom-sqryd.sock'
741
742            [[workspaces]]
743            path = '/repos/main'
744            pinned = true
745
746            [[workspaces]]
747            path = '/repos/secondary'
748        ";
749        let cfg = DaemonConfig::from_toml_str(text).expect("parse");
750        assert_eq!(cfg.memory_limit_mb, 4_096);
751        assert_eq!(cfg.idle_timeout_minutes, 60);
752        assert_eq!(
753            cfg.socket.path.as_deref(),
754            Some(Path::new("/tmp/custom-sqryd.sock"))
755        );
756        assert_eq!(cfg.workspaces.len(), 2);
757        assert!(cfg.workspaces[0].pinned);
758        assert!(!cfg.workspaces[0].exclude);
759        assert!(!cfg.workspaces[1].pinned);
760    }
761
762    #[test]
763    fn parses_all_knobs_with_defaults_filled_in() {
764        // Empty TOML body — every field defaulted.
765        let cfg = DaemonConfig::from_toml_str("").expect("parse");
766        assert_eq!(cfg.memory_limit_mb, DEFAULT_MEMORY_LIMIT_MB);
767        assert_eq!(
768            cfg.stale_serve_max_age_hours,
769            DEFAULT_STALE_SERVE_MAX_AGE_HOURS
770        );
771        assert_eq!(
772            cfg.rebuild_drain_timeout_ms,
773            DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
774        );
775    }
776
777    #[test]
778    fn rejects_unknown_fields() {
779        let text = "totally_bogus_knob = 42";
780        let err = DaemonConfig::from_toml_str(text).expect_err("unknown field must fail");
781        // `anyhow::Error::context` buries the offending field name in the
782        // source chain; format with the alternate specifier to include it.
783        let chain = format!("{err:#}");
784        assert!(
785            chain.contains("totally_bogus_knob") && chain.contains("unknown field"),
786            "unexpected error: {chain}"
787        );
788    }
789
790    #[test]
791    fn validation_rejects_zero_memory_limit() {
792        let cfg = DaemonConfig {
793            memory_limit_mb: 0,
794            ..DaemonConfig::default()
795        };
796        assert!(cfg.validate().is_err());
797    }
798
799    #[test]
800    fn validation_rejects_closure_limit_out_of_range() {
801        let low = DaemonConfig {
802            closure_limit_percent: 0,
803            ..DaemonConfig::default()
804        };
805        assert!(low.validate().is_err());
806        let high = DaemonConfig {
807            closure_limit_percent: 101,
808            ..DaemonConfig::default()
809        };
810        assert!(high.validate().is_err());
811    }
812
813    #[test]
814    fn validation_rejects_compaction_threshold_out_of_range() {
815        let zero = DaemonConfig {
816            interner_compaction_threshold: 0.0,
817            ..DaemonConfig::default()
818        };
819        assert!(zero.validate().is_err());
820        let over = DaemonConfig {
821            interner_compaction_threshold: 1.5,
822            ..DaemonConfig::default()
823        };
824        assert!(over.validate().is_err());
825        let nan = DaemonConfig {
826            interner_compaction_threshold: f32::NAN,
827            ..DaemonConfig::default()
828        };
829        assert!(nan.validate().is_err());
830    }
831
832    #[test]
833    fn validation_rejects_zero_debounce_and_zero_log_size() {
834        let debounce = DaemonConfig {
835            debounce_ms: 0,
836            ..DaemonConfig::default()
837        };
838        assert!(debounce.validate().is_err());
839        let log = DaemonConfig {
840            log_max_size_mb: 0,
841            ..DaemonConfig::default()
842        };
843        assert!(log.validate().is_err());
844    }
845
846    #[test]
847    fn validation_rejects_max_shim_connections_out_of_range() {
848        let zero = DaemonConfig {
849            max_shim_connections: 0,
850            ..DaemonConfig::default()
851        };
852        assert!(zero.validate().is_err());
853        let too_large = DaemonConfig {
854            max_shim_connections: 65_537,
855            ..DaemonConfig::default()
856        };
857        assert!(too_large.validate().is_err());
858        let ok = DaemonConfig {
859            max_shim_connections: 1_024,
860            ..DaemonConfig::default()
861        };
862        assert!(ok.validate().is_ok());
863    }
864
865    #[test]
866    fn validation_rejects_tool_timeout_out_of_range() {
867        let zero = DaemonConfig {
868            tool_timeout_secs: 0,
869            ..DaemonConfig::default()
870        };
871        assert!(zero.validate().is_err());
872        let too_long = DaemonConfig {
873            tool_timeout_secs: 3_601,
874            ..DaemonConfig::default()
875        };
876        assert!(too_long.validate().is_err());
877        let ok = DaemonConfig {
878            tool_timeout_secs: 120,
879            ..DaemonConfig::default()
880        };
881        assert!(ok.validate().is_ok());
882    }
883
884    #[test]
885    fn load_from_missing_path_is_an_error() {
886        let err = DaemonConfig::load_from_path(Path::new("/nonexistent/sqryd.toml"))
887            .expect_err("missing file is an error for explicit path");
888        match err {
889            DaemonError::Config { path, .. } => {
890                assert_eq!(path, Path::new("/nonexistent/sqryd.toml"));
891            }
892            other => panic!("expected Config error, got {other:?}"),
893        }
894    }
895
896    #[test]
897    fn socket_path_uses_runtime_dir_when_unspecified() {
898        let cfg = DaemonConfig::default();
899        let p = cfg.socket_path();
900        if cfg!(unix) {
901            assert!(p.ends_with("sqryd.sock"), "{p:?}");
902        } else if cfg!(windows) {
903            let s = p.to_string_lossy();
904            assert!(s.starts_with(r"\\.\pipe\"), "{s}");
905        }
906    }
907
908    #[test]
909    fn apply_env_overrides_applies_memory_limit_override() {
910        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
911        // SAFETY: guarded by ENV_LOCK so no concurrent env-var reader
912        // in this module observes the in-flux value.
913        unsafe {
914            env::set_var(ENV_MEMORY_LIMIT_MB, "8192");
915        }
916        let mut cfg = DaemonConfig::default();
917        let outcome = cfg.apply_env_overrides();
918        // Always clean up the env var even if the assertion below would
919        // fail, so sibling tests do not start in a poisoned state.
920        // SAFETY: still guarded by ENV_LOCK.
921        unsafe {
922            env::remove_var(ENV_MEMORY_LIMIT_MB);
923        }
924        outcome.expect("override ok");
925        assert_eq!(cfg.memory_limit_mb, 8_192);
926    }
927
928    #[test]
929    fn apply_env_overrides_rejects_malformed_memory_limit() {
930        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
931        // SAFETY: guarded by ENV_LOCK.
932        unsafe {
933            env::set_var(ENV_MEMORY_LIMIT_MB, "not-a-number");
934        }
935        let mut cfg = DaemonConfig::default();
936        let err = cfg.apply_env_overrides();
937        // SAFETY: guarded by ENV_LOCK.
938        unsafe {
939            env::remove_var(ENV_MEMORY_LIMIT_MB);
940        }
941        let err = err.expect_err("malformed override must fail");
942        match err {
943            DaemonError::Config { path, .. } => {
944                assert_eq!(path, Path::new(ENV_MEMORY_LIMIT_MB));
945            }
946            other => panic!("expected Config error, got {other:?}"),
947        }
948    }
949
950    #[test]
951    fn working_set_multiplier_matches_spec() {
952        // If either of these two constants changes, the Task 6
953        // reserve_rebuild tests will need to be regenerated — pin them
954        // here so changes are reviewed together.
955        assert!((WORKING_SET_MULTIPLIER - 1.5_f64).abs() < f64::EPSILON);
956        assert!((INTERNER_BUILDER_OVERHEAD_RATIO - 0.25_f64).abs() < f64::EPSILON);
957    }
958
959    #[test]
960    #[cfg(unix)]
961    fn runtime_dir_is_real_uid_scoped_when_user_env_is_unset() {
962        // Regression for Codex Task 5 iter-1 MAJOR finding:
963        // `/tmp/sqry-default/...` collisions across users when
964        // `USER`/`USERNAME`/`XDG_RUNTIME_DIR` are all unset. The fix
965        // switched the fallback to a `libc::getuid()`-derived suffix
966        // so every user gets their own socket/pid/lock namespace.
967        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
968
969        // Stash and clear every env var that the runtime_dir() chain
970        // would otherwise read ahead of the UID-based fallback.
971        let prior_user = env::var_os("USER");
972        let prior_username = env::var_os("USERNAME");
973        let prior_xdg = env::var_os("XDG_RUNTIME_DIR");
974        let prior_tmpdir = env::var_os("TMPDIR");
975        // SAFETY: serialised by ENV_LOCK; restored before the guard drops.
976        unsafe {
977            env::remove_var("USER");
978            env::remove_var("USERNAME");
979            env::remove_var("XDG_RUNTIME_DIR");
980            env::remove_var("TMPDIR");
981        }
982
983        let cfg = DaemonConfig::default();
984        let socket = cfg.socket_path();
985        let pid = cfg.pid_path();
986        let lock = cfg.lock_path();
987
988        // Restore the prior environment before any assertion so a
989        // failing assertion does not poison sibling tests.
990        // SAFETY: guarded by ENV_LOCK.
991        unsafe {
992            if let Some(v) = prior_user {
993                env::set_var("USER", v);
994            }
995            if let Some(v) = prior_username {
996                env::set_var("USERNAME", v);
997            }
998            if let Some(v) = prior_xdg {
999                env::set_var("XDG_RUNTIME_DIR", v);
1000            }
1001            if let Some(v) = prior_tmpdir {
1002                env::set_var("TMPDIR", v);
1003            }
1004        }
1005
1006        // SAFETY: `libc::getuid` is infallible; see the inline comment
1007        // on `user_scoped_dir_name` above.
1008        let uid = unsafe { libc::getuid() };
1009        let expected = format!("/tmp/sqry-{uid}");
1010        assert_eq!(
1011            socket.parent().and_then(Path::to_str),
1012            Some(expected.as_str()),
1013            "socket_path must be UID-scoped: socket = {socket:?}",
1014        );
1015        assert_eq!(
1016            pid.parent().and_then(Path::to_str),
1017            Some(expected.as_str()),
1018            "pid_path must be UID-scoped: pid = {pid:?}",
1019        );
1020        assert_eq!(
1021            lock.parent().and_then(Path::to_str),
1022            Some(expected.as_str()),
1023            "lock_path must be UID-scoped: lock = {lock:?}",
1024        );
1025        // And the directory name is never the literal "default".
1026        assert!(
1027            !expected.ends_with("sqry-default"),
1028            "runtime dir must never fall back to the shared /tmp/sqry-default path",
1029        );
1030    }
1031
1032    #[test]
1033    fn round_trip_via_toml_preserves_workspace_entries() {
1034        // Author a TOML string → parse → re-emit → re-parse — the two
1035        // parses must produce the same workspace list.
1036        let text = r#"
1037            memory_limit_mb = 1024
1038
1039            [[workspaces]]
1040            path = "/foo"
1041            pinned = true
1042            [[workspaces]]
1043            path = "/bar"
1044            exclude = true
1045        "#;
1046        let cfg = DaemonConfig::from_toml_str(text).unwrap();
1047        assert_eq!(cfg.workspaces.len(), 2);
1048        assert!(cfg.workspaces[0].pinned);
1049        assert!(cfg.workspaces[1].exclude);
1050    }
1051
1052    // -----------------------------------------------------------------------
1053    // Task 9 U2 tests.
1054    // -----------------------------------------------------------------------
1055
1056    #[test]
1057    fn u2_defaults_match_spec() {
1058        let cfg = DaemonConfig::default();
1059        assert_eq!(
1060            cfg.auto_start_ready_timeout_secs, 10,
1061            "auto_start_ready_timeout_secs default must be 10"
1062        );
1063        assert_eq!(
1064            cfg.log_keep_rotations, 5,
1065            "log_keep_rotations default must be 5"
1066        );
1067        assert!(
1068            !cfg.install_user_service,
1069            "install_user_service default must be false"
1070        );
1071    }
1072
1073    #[test]
1074    fn u2_auto_start_ready_timeout_env_override() {
1075        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1076        // SAFETY: guarded by ENV_LOCK.
1077        unsafe {
1078            env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "30");
1079        }
1080        let mut cfg = DaemonConfig::default();
1081        let result = cfg.apply_env_overrides();
1082        // SAFETY: guarded by ENV_LOCK.
1083        unsafe {
1084            env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1085        }
1086        result.expect("override ok");
1087        assert_eq!(cfg.auto_start_ready_timeout_secs, 30);
1088    }
1089
1090    #[test]
1091    fn u2_auto_start_ready_timeout_env_override_rejects_malformed() {
1092        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1093        // SAFETY: guarded by ENV_LOCK.
1094        unsafe {
1095            env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "not-a-number");
1096        }
1097        let mut cfg = DaemonConfig::default();
1098        let err = cfg.apply_env_overrides();
1099        // SAFETY: guarded by ENV_LOCK.
1100        unsafe {
1101            env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1102        }
1103        let err = err.expect_err("malformed value must fail");
1104        match err {
1105            DaemonError::Config { path, .. } => {
1106                assert_eq!(path, Path::new(ENV_AUTO_START_READY_TIMEOUT_SECS));
1107            }
1108            other => panic!("expected Config error, got {other:?}"),
1109        }
1110    }
1111
1112    #[test]
1113    fn u2_log_keep_rotations_env_override() {
1114        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1115        // SAFETY: guarded by ENV_LOCK.
1116        unsafe {
1117            env::set_var(ENV_LOG_KEEP_ROTATIONS, "20");
1118        }
1119        let mut cfg = DaemonConfig::default();
1120        let result = cfg.apply_env_overrides();
1121        // SAFETY: guarded by ENV_LOCK.
1122        unsafe {
1123            env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1124        }
1125        result.expect("override ok");
1126        assert_eq!(cfg.log_keep_rotations, 20);
1127    }
1128
1129    #[test]
1130    fn u2_log_keep_rotations_env_override_rejects_malformed() {
1131        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1132        // SAFETY: guarded by ENV_LOCK.
1133        unsafe {
1134            env::set_var(ENV_LOG_KEEP_ROTATIONS, "bad");
1135        }
1136        let mut cfg = DaemonConfig::default();
1137        let err = cfg.apply_env_overrides();
1138        // SAFETY: guarded by ENV_LOCK.
1139        unsafe {
1140            env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1141        }
1142        let err = err.expect_err("malformed value must fail");
1143        match err {
1144            DaemonError::Config { path, .. } => {
1145                assert_eq!(path, Path::new(ENV_LOG_KEEP_ROTATIONS));
1146            }
1147            other => panic!("expected Config error, got {other:?}"),
1148        }
1149    }
1150
1151    #[test]
1152    fn u2_validate_auto_start_ready_timeout_range() {
1153        // Zero is rejected.
1154        let zero = DaemonConfig {
1155            auto_start_ready_timeout_secs: 0,
1156            ..DaemonConfig::default()
1157        };
1158        assert!(zero.validate().is_err(), "0 must be rejected");
1159
1160        // 61 exceeds the max of 60.
1161        let over = DaemonConfig {
1162            auto_start_ready_timeout_secs: 61,
1163            ..DaemonConfig::default()
1164        };
1165        assert!(over.validate().is_err(), "61 must be rejected");
1166
1167        // Boundary values must pass.
1168        let min = DaemonConfig {
1169            auto_start_ready_timeout_secs: 1,
1170            ..DaemonConfig::default()
1171        };
1172        assert!(min.validate().is_ok(), "1 must be valid");
1173
1174        let max = DaemonConfig {
1175            auto_start_ready_timeout_secs: 60,
1176            ..DaemonConfig::default()
1177        };
1178        assert!(max.validate().is_ok(), "60 must be valid");
1179    }
1180
1181    #[test]
1182    fn u2_validate_log_keep_rotations_range() {
1183        // Zero is rejected.
1184        let zero = DaemonConfig {
1185            log_keep_rotations: 0,
1186            ..DaemonConfig::default()
1187        };
1188        assert!(zero.validate().is_err(), "0 must be rejected");
1189
1190        // 101 exceeds the max of 100.
1191        let over = DaemonConfig {
1192            log_keep_rotations: 101,
1193            ..DaemonConfig::default()
1194        };
1195        assert!(over.validate().is_err(), "101 must be rejected");
1196
1197        // Boundary values must pass.
1198        let min = DaemonConfig {
1199            log_keep_rotations: 1,
1200            ..DaemonConfig::default()
1201        };
1202        assert!(min.validate().is_ok(), "1 must be valid");
1203
1204        let max = DaemonConfig {
1205            log_keep_rotations: 100,
1206            ..DaemonConfig::default()
1207        };
1208        assert!(max.validate().is_ok(), "100 must be valid");
1209    }
1210
1211    #[test]
1212    fn u2_from_toml_str_round_trip_new_fields() {
1213        let text = r#"
1214            auto_start_ready_timeout_secs = 45
1215            log_keep_rotations = 10
1216            install_user_service = true
1217        "#;
1218        let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1219        assert_eq!(cfg.auto_start_ready_timeout_secs, 45);
1220        assert_eq!(cfg.log_keep_rotations, 10);
1221        assert!(cfg.install_user_service);
1222    }
1223
1224    #[test]
1225    fn u2_from_toml_str_new_fields_default_when_absent() {
1226        // None of the new fields are present — they must fall back to defaults.
1227        let text = r"memory_limit_mb = 1024";
1228        let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1229        assert_eq!(
1230            cfg.auto_start_ready_timeout_secs,
1231            DEFAULT_AUTO_START_READY_TIMEOUT_SECS
1232        );
1233        assert_eq!(cfg.log_keep_rotations, DEFAULT_LOG_KEEP_ROTATIONS);
1234        assert!(!cfg.install_user_service);
1235    }
1236
1237    #[test]
1238    fn u2_install_user_service_defaults_false_and_is_tolerated_by_validate() {
1239        // install_user_service is a no-op bool; validate must not reject any
1240        // value for it (both true and false are permanently valid).
1241        let with_true = DaemonConfig {
1242            install_user_service: true,
1243            ..DaemonConfig::default()
1244        };
1245        assert!(
1246            with_true.validate().is_ok(),
1247            "install_user_service=true must pass validate"
1248        );
1249        let with_false = DaemonConfig {
1250            install_user_service: false,
1251            ..DaemonConfig::default()
1252        };
1253        assert!(
1254            with_false.validate().is_ok(),
1255            "install_user_service=false must pass validate"
1256        );
1257    }
1258}