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/// Environment variable that overrides `cost_gate_node_limit`.
116///
117/// Source: `B_cost_gate.md` §1 + `00_contracts.md` §3.CC-3. The
118/// pre-flight cost gate consumes this as the arena-size cap above
119/// which prohibitive shapes require scope-filter coupling. Below the
120/// cap, prohibitive shapes pass unconditionally so the gate never
121/// fires on small test fixtures.
122pub const ENV_COST_GATE_NODE_LIMIT: &str = "SQRY_COST_GATE_NODE_LIMIT";
123
124/// Environment variable that overrides `cost_gate_min_prefix`.
125///
126/// Source: `B_cost_gate.md` §1 + `00_contracts.md` §3.CC-3.
127/// Minimum literal-prefix length (extracted via
128/// `regex_syntax::hir::literal::Extractor`) that disqualifies an
129/// anchored regex from the prohibitive class.
130pub const ENV_COST_GATE_MIN_PREFIX: &str = "SQRY_COST_GATE_MIN_PREFIX";
131
132/// Environment variable that overrides `cost_gate_min_literal`.
133///
134/// Source: `B_cost_gate.md` §1 + `00_contracts.md` §3.CC-3.
135/// Minimum `Hir::minimum_len` that disqualifies a regex from the
136/// prohibitive class when no usable literal prefix is present.
137pub const ENV_COST_GATE_MIN_LITERAL: &str = "SQRY_COST_GATE_MIN_LITERAL";
138
139// ---------------------------------------------------------------------------
140// Built-in defaults (match plan §5 Step 3 table).
141// ---------------------------------------------------------------------------
142
143/// Default: 2 GiB memory budget for the whole daemon.
144pub const DEFAULT_MEMORY_LIMIT_MB: u64 = 2_048;
145/// Default: idle workspaces eligible for eviction after 30 minutes.
146pub const DEFAULT_IDLE_TIMEOUT_MINUTES: u64 = 30;
147/// Default: 2 s coalescing window for file-system notifications.
148pub const DEFAULT_DEBOUNCE_MS: u64 = 2_000;
149/// Default: > 20 changed files → full rebuild instead of incremental.
150pub const DEFAULT_INCREMENTAL_THRESHOLD: usize = 20;
151/// Default: reverse-dep closure > 30% of file count → full rebuild.
152pub const DEFAULT_CLOSURE_LIMIT_PERCENT: u32 = 30;
153/// Default: 24 h stale-serve cap (`0` disables the cap).
154pub const DEFAULT_STALE_SERVE_MAX_AGE_HOURS: u32 = 24;
155/// Default: retention reaper logs a WARN after 5 s of held-retained state.
156pub const DEFAULT_REBUILD_DRAIN_TIMEOUT_MS: u64 = 5_000;
157/// Default: `live_ratio < 0.5` triggers a mandatory full rebuild at the next
158/// debounce tick (interner compaction housekeeping).
159pub const DEFAULT_INTERNER_COMPACTION_THRESHOLD: f32 = 0.5;
160/// Default: 5 s grace window for the IPC accept loop to drain active
161/// connections during shutdown before the server returns.
162///
163/// Task 8 Phase 8a. Valid range (enforced by [`DaemonConfig::validate`]):
164/// `1..=3600`.
165pub const DEFAULT_IPC_SHUTDOWN_DRAIN_SECS: u64 = 5;
166/// Default: 60 s per-tool invocation timeout — response-latency bound
167/// consumed by
168/// [`crate::ipc::tool_core::classify_and_execute`]. Task 8 Phase 8c U6.
169///
170/// Valid range (enforced by [`DaemonConfig::validate`]): `1..=3600`. A
171/// zero timeout would cause every `spawn_blocking` call to race
172/// `tokio::time::timeout` at 0ms and is therefore rejected.
173pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 60;
174/// Default: cap on the number of concurrently-registered shim
175/// byte-pump connections (`sqry lsp --daemon` / `sqry mcp --daemon`).
176/// Consumed by
177/// [`crate::ipc::shim_registry::ShimRegistry::try_register_bounded`]
178/// from the Phase 8c router (U10). Task 8 Phase 8c U10.
179///
180/// Valid range (enforced by [`DaemonConfig::validate`]): `1..=65_536`.
181/// `256` is comfortably above the realistic fan-out of any single
182/// developer workstation (one IDE + one MCP client per project,
183/// typically ≤ 8 workspaces × 2 protocols = 16) while still bounding
184/// the worst-case memory footprint of the registry
185/// (`HashMap<ShimConnId, ShimConnEntry>`) should a buggy or malicious
186/// client spam `ShimRegister` frames.
187pub const DEFAULT_MAX_SHIM_CONNECTIONS: usize = 256;
188/// Default: `info`.
189pub const DEFAULT_LOG_LEVEL: &str = "info";
190/// Default: rotate daemon log at 50 MiB.
191pub const DEFAULT_LOG_MAX_SIZE_MB: u64 = 50;
192/// Default: poll timeout waiting for the daemon socket to become reachable
193/// after auto-spawn. Used by both the `--detach` parent wait loop and the
194/// `lifecycle::start_detached` helper. Validated range: `1..=60`.
195pub const DEFAULT_AUTO_START_READY_TIMEOUT_SECS: u64 = 10;
196/// Default: number of rotated log files to keep alongside the active log.
197/// A value of 5 means up to 5 `.N` suffixed archive files are retained;
198/// the oldest is deleted when a new rotation creates `.6`. Validated range:
199/// `1..=100`.
200pub const DEFAULT_LOG_KEEP_ROTATIONS: u32 = 5;
201
202/// Default arena-size cap for the pre-flight cost gate
203/// (`B_cost_gate.md` §1, `00_contracts.md` §3.CC-3): below 50_000
204/// nodes, prohibitive regex shapes are allowed unconditionally. Above
205/// that, scope-filter coupling is required.
206pub const DEFAULT_COST_GATE_NODE_LIMIT: usize = 50_000;
207/// Default minimum literal-prefix length that disqualifies an
208/// anchored regex from "prohibitive" (`B_cost_gate.md` §1).
209pub const DEFAULT_COST_GATE_MIN_PREFIX: usize = 3;
210/// Default minimum `Hir::minimum_len` that disqualifies a regex when
211/// no usable prefix exists (`B_cost_gate.md` §1).
212// Cluster-B iter-2: align with `sqry_core::query::cost_gate::CostGateConfig::DEFAULT_MIN_LITERAL_LEN = 4`.
213// Earlier the daemon defaulted to 3, leaving a 1-char drift between
214// the in-process executor and daemon-hosted MCP gates.
215pub const DEFAULT_COST_GATE_MIN_LITERAL: usize = 4;
216
217// ---------------------------------------------------------------------------
218// Config structs.
219// ---------------------------------------------------------------------------
220
221/// Top-level daemon configuration.
222///
223/// Loaded from `~/.config/sqry/daemon.toml` by default. Env-var overrides
224/// (see the `ENV_*` constants) are layered on top by [`DaemonConfig::load`].
225#[derive(Debug, Clone, Deserialize, serde::Serialize)]
226#[serde(deny_unknown_fields)]
227pub struct DaemonConfig {
228 /// Hard cap on total resident graph memory across every loaded workspace.
229 #[serde(default = "default_memory_limit_mb")]
230 pub memory_limit_mb: u64,
231
232 /// Workspace idle-timeout before it becomes eligible for LRU eviction.
233 #[serde(default = "default_idle_timeout_minutes")]
234 pub idle_timeout_minutes: u64,
235
236 /// Filesystem-watcher debounce window (ms) for coalescing bursts of changes.
237 #[serde(default = "default_debounce_ms")]
238 pub debounce_ms: u64,
239
240 /// If > `incremental_threshold` files changed in one window, full-rebuild
241 /// instead of incremental-rebuild.
242 #[serde(default = "default_incremental_threshold")]
243 pub incremental_threshold: usize,
244
245 /// If the reverse-dep closure covers > `closure_limit_percent`% of the
246 /// graph's files, full-rebuild instead of incremental-rebuild.
247 #[serde(default = "default_closure_limit_percent")]
248 pub closure_limit_percent: u32,
249
250 /// Cap on how long a Failed workspace may keep serving its last-good
251 /// snapshot as `stale: true`. `0` disables the cap (serve indefinitely).
252 #[serde(default = "default_stale_serve_max_age_hours")]
253 pub stale_serve_max_age_hours: u32,
254
255 /// Retention-reaper WARN threshold, **not** an accounting deadline.
256 /// Retained bytes are released when `Arc::strong_count` drops to 1 —
257 /// regardless of wall-clock time.
258 #[serde(default = "default_rebuild_drain_timeout_ms")]
259 pub rebuild_drain_timeout_ms: u64,
260
261 /// Grace window (seconds) for the IPC accept loop to drain active
262 /// connections during shutdown. Task 8 Phase 8a.
263 #[serde(default = "default_ipc_shutdown_drain_secs")]
264 pub ipc_shutdown_drain_secs: u64,
265
266 /// Per-tool invocation timeout. Bounds the response latency of
267 /// any single tool call; exceeding this returns
268 /// [`DaemonError::ToolTimeout`] (JSON-RPC `-32000` / MCP
269 /// `internal_error` with `kind = "deadline_exceeded"`).
270 ///
271 /// **Important contract**: this bounds RESPONSE LATENCY, not the
272 /// detached OS-thread lifetime. When the timeout fires, the
273 /// [`tokio::task::spawn_blocking`] [`tokio::task::JoinHandle`] is
274 /// dropped; the OS thread running the tool closure continues
275 /// until the closure itself returns. A buggy/runaway tool closure
276 /// can keep its thread alive past `daemon/stop`. Default 60
277 /// seconds. Task 8 Phase 8c U6.
278 ///
279 /// [`DaemonError::ToolTimeout`]: crate::error::DaemonError::ToolTimeout
280 #[serde(default = "default_tool_timeout_secs")]
281 pub tool_timeout_secs: u64,
282
283 /// Cap on the number of concurrently-registered shim byte-pump
284 /// connections. Every accepted `ShimRegister` frame must pass
285 /// [`crate::ipc::shim_registry::ShimRegistry::try_register_bounded`]
286 /// against this cap under a single mutex-guard — over-cap
287 /// admissions reply `ShimRegisterAck { accepted: false, reason:
288 /// "shim registry full (N / cap)" }` and the connection closes.
289 /// Default `256`. Task 8 Phase 8c U10.
290 #[serde(default = "default_max_shim_connections")]
291 pub max_shim_connections: usize,
292
293 /// Interner housekeeping: if the live-ratio drops below this, the next
294 /// debounce tick schedules a mandatory full rebuild.
295 #[serde(default = "default_interner_compaction_threshold")]
296 pub interner_compaction_threshold: f32,
297
298 /// Structured-log file destination (cluster-G §5.3).
299 ///
300 /// Defaults to `LogFileSetting::Path(<runtime_dir>/sqryd.log)` so a
301 /// fresh install logs to a tailable file under
302 /// `$XDG_RUNTIME_DIR/sqry/sqryd.log` (Linux/macOS) or
303 /// `%LOCALAPPDATA%\sqry\sqryd.log` (Windows). Operators opt out of
304 /// file logging by setting `log_file = "stderr"` or
305 /// `log_file = "-"` in TOML, or `SQRY_DAEMON_LOG_FILE=-` in the
306 /// environment — both produce `LogFileSetting::Special` and
307 /// disable the rolling appender so the daemon writes only to
308 /// stderr.
309 #[serde(default = "default_log_file_setting")]
310 pub log_file: LogFileSetting,
311
312 /// Log verbosity (matches `tracing_subscriber::EnvFilter` syntax).
313 #[serde(default = "default_log_level")]
314 pub log_level: String,
315
316 /// Log-rotation trigger.
317 #[serde(default = "default_log_max_size_mb")]
318 pub log_max_size_mb: u64,
319
320 /// IPC listener binding (UDS on Unix, named pipe on Windows).
321 #[serde(default)]
322 pub socket: SocketConfig,
323
324 /// Pre-declared workspaces — pinned workspaces load at daemon startup.
325 #[serde(default)]
326 pub workspaces: Vec<WorkspaceConfig>,
327
328 /// Timeout (seconds) used in two places:
329 ///
330 /// 1. **`--detach` parent wait loop** (`run_start_detach`): how long the
331 /// launching parent process waits for the grandchild to signal ready via
332 /// the self-pipe before giving up and killing the grandchild.
333 /// 2. **`lifecycle::start_detached`** (Task 10 auto-spawn): how long the
334 /// client helper polls the daemon socket before returning
335 /// [`DaemonError::AutoStartTimeout`].
336 ///
337 /// Valid range (enforced by [`DaemonConfig::validate`]): `1..=60`.
338 #[serde(default = "default_auto_start_ready_timeout_secs")]
339 pub auto_start_ready_timeout_secs: u64,
340
341 /// Number of rotated log archives to keep alongside the live log file.
342 ///
343 /// When [`RollingSizeAppender`] rotates, it shifts existing `.1`–`.N` files
344 /// one position and deletes any file beyond this limit. A value of `5` means
345 /// `.1`–`.5` are retained; `.6` and beyond are removed.
346 ///
347 /// Valid range (enforced by [`DaemonConfig::validate`]): `1..=100`.
348 ///
349 /// [`RollingSizeAppender`]: crate::lifecycle::log_rotate::RollingSizeAppender
350 #[serde(default = "default_log_keep_rotations")]
351 pub log_keep_rotations: u32,
352
353 /// Reserved for future use — will drive automated systemd user-service
354 /// installation on first `sqryd start` when set to `true`. Currently a
355 /// no-op; stored in config to avoid breaking changes when the feature
356 /// lands. Defaults to `false`.
357 #[serde(default)]
358 pub install_user_service: bool,
359
360 /// Pre-flight cost-gate arena-size cap (per
361 /// `B_cost_gate.md` §1.4 + `00_contracts.md` §3.CC-3). When
362 /// `Some(n)`, prohibitive query shapes (unanchored regex with no
363 /// scope coupling) are rejected once the snapshot's node count
364 /// exceeds `n`. When `None` (or `Some(0)`), the cap is disabled
365 /// and all shapes are allowed regardless of arena size — the gate
366 /// degenerates to a shape-only check. Default
367 /// [`DEFAULT_COST_GATE_NODE_LIMIT`] (`50_000`).
368 #[serde(default = "default_cost_gate_node_limit")]
369 pub cost_gate_node_limit: Option<usize>,
370
371 /// Pre-flight cost-gate minimum literal-prefix length. When `Some(n)`,
372 /// an anchored regex passes the gate iff its longest required
373 /// literal prefix has length ≥ `n`. Default
374 /// [`DEFAULT_COST_GATE_MIN_PREFIX`] (`3`).
375 #[serde(default = "default_cost_gate_min_prefix")]
376 pub cost_gate_min_prefix: Option<usize>,
377
378 /// Pre-flight cost-gate minimum `Hir::minimum_len`. When `Some(n)`,
379 /// a regex with no usable prefix passes the gate iff its
380 /// `Hir::properties().minimum_len()` is ≥ `n`. Default
381 /// [`DEFAULT_COST_GATE_MIN_LITERAL`] (`4`).
382 #[serde(default = "default_cost_gate_min_literal")]
383 pub cost_gate_min_literal: Option<usize>,
384}
385
386impl Default for DaemonConfig {
387 fn default() -> Self {
388 Self {
389 memory_limit_mb: DEFAULT_MEMORY_LIMIT_MB,
390 idle_timeout_minutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
391 debounce_ms: DEFAULT_DEBOUNCE_MS,
392 incremental_threshold: DEFAULT_INCREMENTAL_THRESHOLD,
393 closure_limit_percent: DEFAULT_CLOSURE_LIMIT_PERCENT,
394 stale_serve_max_age_hours: DEFAULT_STALE_SERVE_MAX_AGE_HOURS,
395 rebuild_drain_timeout_ms: DEFAULT_REBUILD_DRAIN_TIMEOUT_MS,
396 ipc_shutdown_drain_secs: DEFAULT_IPC_SHUTDOWN_DRAIN_SECS,
397 tool_timeout_secs: DEFAULT_TOOL_TIMEOUT_SECS,
398 max_shim_connections: DEFAULT_MAX_SHIM_CONNECTIONS,
399 interner_compaction_threshold: DEFAULT_INTERNER_COMPACTION_THRESHOLD,
400 log_file: default_log_file_setting(),
401 log_level: DEFAULT_LOG_LEVEL.to_owned(),
402 log_max_size_mb: DEFAULT_LOG_MAX_SIZE_MB,
403 socket: SocketConfig::default(),
404 workspaces: Vec::new(),
405 auto_start_ready_timeout_secs: DEFAULT_AUTO_START_READY_TIMEOUT_SECS,
406 log_keep_rotations: DEFAULT_LOG_KEEP_ROTATIONS,
407 install_user_service: false,
408 cost_gate_node_limit: Some(DEFAULT_COST_GATE_NODE_LIMIT),
409 cost_gate_min_prefix: Some(DEFAULT_COST_GATE_MIN_PREFIX),
410 cost_gate_min_literal: Some(DEFAULT_COST_GATE_MIN_LITERAL),
411 }
412 }
413}
414
415/// IPC binding configuration.
416///
417/// On Unix, [`SocketConfig::path`] takes precedence. On Windows,
418/// [`SocketConfig::pipe_name`] takes precedence. If neither is set the
419/// platform default is used (see [`DaemonConfig::socket_path`]).
420#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
421#[serde(deny_unknown_fields)]
422pub struct SocketConfig {
423 /// Unix-domain socket path.
424 #[serde(default)]
425 pub path: Option<PathBuf>,
426
427 /// Windows named-pipe name (e.g. `sqryd`).
428 #[serde(default)]
429 pub pipe_name: Option<String>,
430}
431
432/// Pre-declared workspace entry.
433///
434/// `pinned = true` keeps the workspace in memory indefinitely (LRU exempt).
435/// `exclude = true` skips the workspace during auto-discovery.
436#[derive(Debug, Clone, Deserialize, serde::Serialize)]
437#[serde(deny_unknown_fields)]
438pub struct WorkspaceConfig {
439 /// Absolute path to the workspace root.
440 pub path: PathBuf,
441
442 /// Whether the workspace is LRU-exempt. Defaults to `false`.
443 #[serde(default)]
444 pub pinned: bool,
445
446 /// Whether the workspace should be skipped entirely. Defaults to `false`.
447 #[serde(default)]
448 pub exclude: bool,
449}
450
451// ---------------------------------------------------------------------------
452// Loader / path helpers.
453// ---------------------------------------------------------------------------
454
455impl DaemonConfig {
456 /// Load the effective config: start from defaults, apply the TOML file at
457 /// the canonical path (or the one named by [`ENV_CONFIG_PATH`]), then
458 /// layer environment-variable overrides.
459 ///
460 /// A missing config file is **not** an error — the defaults plus env-var
461 /// overrides are returned. A malformed file is always an error.
462 pub fn load() -> DaemonResult<Self> {
463 let path = Self::resolve_config_path()?;
464 let mut config = if path.exists() {
465 Self::load_from_path(&path)?
466 } else {
467 Self::default()
468 };
469 config.apply_env_overrides()?;
470 config.validate()?;
471 Ok(config)
472 }
473
474 /// Load a config file from an explicit path, ignoring env overrides.
475 /// Useful for tests and documentation examples.
476 pub fn load_from_path(path: &Path) -> DaemonResult<Self> {
477 let text = std::fs::read_to_string(path).map_err(|source| DaemonError::Config {
478 path: path.to_path_buf(),
479 source: anyhow::Error::from(source).context("reading daemon config"),
480 })?;
481 Self::from_toml_str(&text).map_err(|source| DaemonError::Config {
482 path: path.to_path_buf(),
483 source,
484 })
485 }
486
487 /// Parse a TOML string into a [`DaemonConfig`]. Defaults fill any missing
488 /// fields.
489 pub fn from_toml_str(text: &str) -> anyhow::Result<Self> {
490 let cfg: Self = toml::from_str(text).context("parsing daemon config TOML")?;
491 Ok(cfg)
492 }
493
494 /// Apply `SQRY_DAEMON_*` environment-variable overrides. See the
495 /// `ENV_*` constants for the full list.
496 pub fn apply_env_overrides(&mut self) -> DaemonResult<()> {
497 if let Some(v) = env::var_os(ENV_MEMORY_LIMIT_MB) {
498 let v = v.to_string_lossy().into_owned();
499 self.memory_limit_mb = v.parse::<u64>().map_err(|e| DaemonError::Config {
500 path: PathBuf::from(ENV_MEMORY_LIMIT_MB),
501 source: anyhow!("{ENV_MEMORY_LIMIT_MB}={v:?} must be an unsigned int: {e}"),
502 })?;
503 }
504 if let Some(v) = env::var_os(ENV_SOCKET_PATH) {
505 self.socket.path = Some(PathBuf::from(v));
506 }
507 if let Some(v) = env::var_os(ENV_PIPE_NAME) {
508 self.socket.pipe_name = Some(v.to_string_lossy().into_owned());
509 }
510 if let Some(v) = env::var_os(ENV_LOG_LEVEL) {
511 self.log_level = v.to_string_lossy().into_owned();
512 }
513 if let Some(v) = env::var_os(ENV_LOG_FILE) {
514 // `SQRY_DAEMON_LOG_FILE=-` (or `=stderr`) opts out of file
515 // logging — same wire contract as the TOML setting per
516 // cluster-G §5.3. The conversion is the same as the
517 // `LogFileSetting` deserialize path: a literal `"-"` /
518 // `"stderr"` becomes `Special`, anything else becomes a
519 // file path.
520 let s = v.to_string_lossy().into_owned();
521 self.log_file = match s.as_str() {
522 "stderr" | "-" => LogFileSetting::Special(s),
523 _ => LogFileSetting::Path(PathBuf::from(s)),
524 };
525 }
526 if let Some(v) = env::var_os(ENV_STALE_MAX_AGE_HOURS) {
527 let v = v.to_string_lossy().into_owned();
528 self.stale_serve_max_age_hours = v.parse::<u32>().map_err(|e| DaemonError::Config {
529 path: PathBuf::from(ENV_STALE_MAX_AGE_HOURS),
530 source: anyhow!("{ENV_STALE_MAX_AGE_HOURS}={v:?}: {e}"),
531 })?;
532 }
533 if let Some(v) = env::var_os(ENV_TOOL_TIMEOUT_SECS) {
534 let v = v.to_string_lossy().into_owned();
535 self.tool_timeout_secs = v.parse::<u64>().map_err(|e| DaemonError::Config {
536 path: PathBuf::from(ENV_TOOL_TIMEOUT_SECS),
537 source: anyhow!("{ENV_TOOL_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"),
538 })?;
539 }
540 if let Some(v) = env::var_os(ENV_MAX_SHIM_CONNECTIONS) {
541 let v = v.to_string_lossy().into_owned();
542 self.max_shim_connections = v.parse::<usize>().map_err(|e| DaemonError::Config {
543 path: PathBuf::from(ENV_MAX_SHIM_CONNECTIONS),
544 source: anyhow!("{ENV_MAX_SHIM_CONNECTIONS}={v:?} must be an unsigned int: {e}"),
545 })?;
546 }
547 if let Some(v) = env::var_os(ENV_AUTO_START_READY_TIMEOUT_SECS) {
548 let v = v.to_string_lossy().into_owned();
549 self.auto_start_ready_timeout_secs =
550 v.parse::<u64>().map_err(|e| DaemonError::Config {
551 path: PathBuf::from(ENV_AUTO_START_READY_TIMEOUT_SECS),
552 source: anyhow!(
553 "{ENV_AUTO_START_READY_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"
554 ),
555 })?;
556 }
557 if let Some(v) = env::var_os(ENV_LOG_KEEP_ROTATIONS) {
558 let v = v.to_string_lossy().into_owned();
559 self.log_keep_rotations = v.parse::<u32>().map_err(|e| DaemonError::Config {
560 path: PathBuf::from(ENV_LOG_KEEP_ROTATIONS),
561 source: anyhow!("{ENV_LOG_KEEP_ROTATIONS}={v:?} must be an unsigned int: {e}"),
562 })?;
563 }
564 // Cost-gate config (B_cost_gate.md §1 + 00_contracts.md §3.CC-3).
565 // Each override accepts an unsigned integer; a value of `0` for
566 // `cost_gate_node_limit` is honoured as "cap disabled" via
567 // `Some(0)` (which `cost_gate::check_query` short-circuits on).
568 if let Some(v) = env::var_os(ENV_COST_GATE_NODE_LIMIT) {
569 let v = v.to_string_lossy().into_owned();
570 self.cost_gate_node_limit =
571 Some(v.parse::<usize>().map_err(|e| DaemonError::Config {
572 path: PathBuf::from(ENV_COST_GATE_NODE_LIMIT),
573 source: anyhow!(
574 "{ENV_COST_GATE_NODE_LIMIT}={v:?} must be an unsigned int: {e}"
575 ),
576 })?);
577 }
578 if let Some(v) = env::var_os(ENV_COST_GATE_MIN_PREFIX) {
579 let v = v.to_string_lossy().into_owned();
580 self.cost_gate_min_prefix =
581 Some(v.parse::<usize>().map_err(|e| DaemonError::Config {
582 path: PathBuf::from(ENV_COST_GATE_MIN_PREFIX),
583 source: anyhow!(
584 "{ENV_COST_GATE_MIN_PREFIX}={v:?} must be an unsigned int: {e}"
585 ),
586 })?);
587 }
588 if let Some(v) = env::var_os(ENV_COST_GATE_MIN_LITERAL) {
589 let v = v.to_string_lossy().into_owned();
590 self.cost_gate_min_literal =
591 Some(v.parse::<usize>().map_err(|e| DaemonError::Config {
592 path: PathBuf::from(ENV_COST_GATE_MIN_LITERAL),
593 source: anyhow!(
594 "{ENV_COST_GATE_MIN_LITERAL}={v:?} must be an unsigned int: {e}"
595 ),
596 })?);
597 }
598 Ok(())
599 }
600
601 /// Sanity-check invariants that admission accounting and the rebuild
602 /// dispatcher depend on.
603 pub fn validate(&self) -> DaemonResult<()> {
604 let reject = |msg: &str| DaemonError::Config {
605 path: PathBuf::from("<in-memory>"),
606 source: anyhow!("{msg}"),
607 };
608 if self.memory_limit_mb == 0 {
609 return Err(reject("memory_limit_mb must be > 0"));
610 }
611 if self.closure_limit_percent == 0 || self.closure_limit_percent > 100 {
612 return Err(reject("closure_limit_percent must be in 1..=100"));
613 }
614 if !self.interner_compaction_threshold.is_finite()
615 || self.interner_compaction_threshold <= 0.0
616 || self.interner_compaction_threshold > 1.0
617 {
618 return Err(reject(
619 "interner_compaction_threshold must be in (0.0, 1.0]",
620 ));
621 }
622 if self.debounce_ms == 0 {
623 return Err(reject("debounce_ms must be > 0"));
624 }
625 if self.log_max_size_mb == 0 {
626 return Err(reject("log_max_size_mb must be > 0"));
627 }
628 if self.ipc_shutdown_drain_secs == 0 || self.ipc_shutdown_drain_secs > 3_600 {
629 return Err(reject("ipc_shutdown_drain_secs must be in 1..=3600"));
630 }
631 if self.tool_timeout_secs == 0 || self.tool_timeout_secs > 3_600 {
632 return Err(reject("tool_timeout_secs must be in 1..=3600"));
633 }
634 if self.max_shim_connections == 0 || self.max_shim_connections > 65_536 {
635 return Err(reject("max_shim_connections must be in 1..=65536"));
636 }
637 if self.auto_start_ready_timeout_secs == 0 || self.auto_start_ready_timeout_secs > 60 {
638 return Err(reject("auto_start_ready_timeout_secs must be in 1..=60"));
639 }
640 if self.log_keep_rotations == 0 || self.log_keep_rotations > 100 {
641 return Err(reject("log_keep_rotations must be in 1..=100"));
642 }
643 Ok(())
644 }
645
646 /// Resolve the config-file path, respecting [`ENV_CONFIG_PATH`].
647 ///
648 /// Falls back to `$XDG_CONFIG_HOME/sqry/daemon.toml`, then
649 /// `$HOME/.config/sqry/daemon.toml`.
650 pub fn resolve_config_path() -> DaemonResult<PathBuf> {
651 if let Some(v) = env::var_os(ENV_CONFIG_PATH) {
652 return Ok(PathBuf::from(v));
653 }
654 let base = dirs::config_dir().ok_or_else(|| DaemonError::Config {
655 path: PathBuf::from("~/.config"),
656 source: anyhow!("could not determine user config directory; set {ENV_CONFIG_PATH}"),
657 })?;
658 Ok(base.join("sqry").join("daemon.toml"))
659 }
660
661 /// Path the IPC server binds to.
662 ///
663 /// - Unix: explicit `socket.path`, else `$XDG_RUNTIME_DIR/sqry/sqryd.sock`,
664 /// else `$TMPDIR/sqry-<uid>/sqryd.sock`.
665 /// - Windows: `\\\\.\\pipe\\<socket.pipe_name>` (default `sqry`).
666 #[must_use]
667 pub fn socket_path(&self) -> PathBuf {
668 if cfg!(windows) {
669 let name = self
670 .socket
671 .pipe_name
672 .clone()
673 .unwrap_or_else(|| "sqry".to_string());
674 return PathBuf::from(format!(r"\\.\pipe\{name}"));
675 }
676 if let Some(path) = &self.socket.path {
677 return path.clone();
678 }
679 runtime_dir().join("sqryd.sock")
680 }
681
682 /// Where to write the daemon pidfile. One per user.
683 #[must_use]
684 pub fn pid_path(&self) -> PathBuf {
685 runtime_dir().join("sqryd.pid")
686 }
687
688 /// Flock target — held exclusively by the running daemon, and briefly
689 /// by clients during auto-start to avoid racing two `sqry` processes.
690 #[must_use]
691 pub fn lock_path(&self) -> PathBuf {
692 runtime_dir().join("sqryd.lock")
693 }
694
695 /// Platform-specific per-user runtime directory where the socket, pidfile,
696 /// and lockfile live.
697 ///
698 /// This is the public accessor for the private [`runtime_dir`] free
699 /// function. The return value is the same as `socket_path().parent()`
700 /// when the socket path uses the default (not the explicit `socket.path`
701 /// override).
702 #[must_use]
703 pub fn runtime_dir(&self) -> PathBuf {
704 runtime_dir()
705 }
706
707 /// Memory budget in bytes, derived from [`Self::memory_limit_mb`].
708 #[must_use]
709 pub const fn memory_limit_bytes(&self) -> u64 {
710 self.memory_limit_mb.saturating_mul(1024 * 1024)
711 }
712}
713
714/// Platform-specific per-user runtime directory for socket / pid / lock files.
715///
716/// On Unix, the `/tmp` fallback is *always* suffixed with the real POSIX
717/// UID (via `libc::getuid`) rather than the `USER` env var, so that two
718/// processes running as different users on the same host cannot collide
719/// on `/tmp/sqry-default/sqryd.{sock,pid,lock}` when `USER`/`USERNAME`
720/// are unset (realistic in systemd units without `User=`, Docker
721/// containers, and CI runners). See Codex Task 5 iter-1 review MAJOR
722/// finding (`docs/reviews/sqryd-daemon/2026-04-18/task-5-scaffold_iter1_request_review.md`).
723fn runtime_dir() -> PathBuf {
724 if cfg!(windows)
725 && let Some(local) = env::var_os("LOCALAPPDATA")
726 {
727 return PathBuf::from(local).join("sqry");
728 }
729 if let Some(xdg) = env::var_os("XDG_RUNTIME_DIR") {
730 return PathBuf::from(xdg).join("sqry");
731 }
732 if let Some(tmp) = env::var_os("TMPDIR") {
733 return PathBuf::from(tmp).join(user_scoped_dir_name());
734 }
735 PathBuf::from("/tmp").join(user_scoped_dir_name())
736}
737
738/// Per-user directory name used in the `/tmp`-style fallback.
739///
740/// - On Unix, always `sqry-<uid>` where `<uid>` is the real POSIX UID
741/// via [`libc::getuid`]. Never falls back to a string env-var proxy —
742/// `getuid` cannot fail.
743/// - On Windows the only reachable callers of this function already
744/// bypassed the LOCALAPPDATA branch, so we use `USERNAME` as a
745/// best-effort user scope with a constant-suffix fallback. Windows
746/// UIDs (SIDs) would require a separate dependency just for this
747/// edge case; in practice LOCALAPPDATA is always set in any
748/// Windows configuration sqry supports.
749fn user_scoped_dir_name() -> String {
750 #[cfg(unix)]
751 {
752 // SAFETY: `libc::getuid` is a POSIX call with no preconditions,
753 // no mutable state, and no way to fail. Calling it from a
754 // multi-threaded program is safe per POSIX.
755 let uid = unsafe { libc::getuid() };
756 format!("sqry-{uid}")
757 }
758 #[cfg(not(unix))]
759 {
760 let user = env::var("USERNAME").unwrap_or_else(|_| "default".to_string());
761 format!("sqry-{user}")
762 }
763}
764
765// ---------------------------------------------------------------------------
766// serde default-function helpers.
767// ---------------------------------------------------------------------------
768
769const fn default_memory_limit_mb() -> u64 {
770 DEFAULT_MEMORY_LIMIT_MB
771}
772const fn default_idle_timeout_minutes() -> u64 {
773 DEFAULT_IDLE_TIMEOUT_MINUTES
774}
775const fn default_debounce_ms() -> u64 {
776 DEFAULT_DEBOUNCE_MS
777}
778const fn default_incremental_threshold() -> usize {
779 DEFAULT_INCREMENTAL_THRESHOLD
780}
781const fn default_closure_limit_percent() -> u32 {
782 DEFAULT_CLOSURE_LIMIT_PERCENT
783}
784const fn default_stale_serve_max_age_hours() -> u32 {
785 DEFAULT_STALE_SERVE_MAX_AGE_HOURS
786}
787const fn default_rebuild_drain_timeout_ms() -> u64 {
788 DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
789}
790const fn default_ipc_shutdown_drain_secs() -> u64 {
791 DEFAULT_IPC_SHUTDOWN_DRAIN_SECS
792}
793const fn default_tool_timeout_secs() -> u64 {
794 DEFAULT_TOOL_TIMEOUT_SECS
795}
796const fn default_max_shim_connections() -> usize {
797 DEFAULT_MAX_SHIM_CONNECTIONS
798}
799const fn default_interner_compaction_threshold() -> f32 {
800 DEFAULT_INTERNER_COMPACTION_THRESHOLD
801}
802fn default_log_level() -> String {
803 DEFAULT_LOG_LEVEL.to_owned()
804}
805const fn default_log_max_size_mb() -> u64 {
806 DEFAULT_LOG_MAX_SIZE_MB
807}
808const fn default_auto_start_ready_timeout_secs() -> u64 {
809 DEFAULT_AUTO_START_READY_TIMEOUT_SECS
810}
811const fn default_log_keep_rotations() -> u32 {
812 DEFAULT_LOG_KEEP_ROTATIONS
813}
814const fn default_cost_gate_node_limit() -> Option<usize> {
815 Some(DEFAULT_COST_GATE_NODE_LIMIT)
816}
817const fn default_cost_gate_min_prefix() -> Option<usize> {
818 Some(DEFAULT_COST_GATE_MIN_PREFIX)
819}
820const fn default_cost_gate_min_literal() -> Option<usize> {
821 Some(DEFAULT_COST_GATE_MIN_LITERAL)
822}
823
824/// Per-OS default daemon log file path. Returns
825/// `Some(<runtime_dir>/sqryd.log)` on Linux/macOS and
826/// `Some(%LOCALAPPDATA%\sqry\sqryd.log)` on Windows. Operators may
827/// opt out of file logging by setting `LogFileSetting::Special("stderr")`
828/// (or `"-"`, or `SQRY_DAEMON_LOG_FILE=-`).
829///
830/// Source: `G_daemon_control_plane.md` §5.3 + `00_contracts.md` §3.CC-6.
831///
832/// This helper is exposed publicly so cluster-G's Layer-2 work can
833/// migrate `DaemonConfig::log_file` from `Option<PathBuf>` to
834/// [`LogFileSetting`] without re-deriving the per-OS default in two
835/// places.
836///
837/// Implementation note (Codex Layer-1 iter-1 review CC-6 defect 2):
838/// this delegates to the module-private [`runtime_dir`] free function
839/// so the per-user fallback uses the **real** UID (`libc::getuid`)
840/// matching `DaemonConfig::runtime_dir`, the socket path, the
841/// pidfile, and the lockfile. An earlier draft used a sibling helper
842/// that called the *effective* UID syscall instead, which can diverge
843/// from the real UID under setuid setups — that bespoke helper has
844/// been removed.
845#[must_use]
846pub fn default_log_file() -> Option<PathBuf> {
847 Some(runtime_dir().join("sqryd.log"))
848}
849
850/// Default value for [`DaemonConfig::log_file`] when no TOML / env
851/// override is supplied. Returns
852/// `LogFileSetting::Path(<runtime_dir>/sqryd.log)` so a fresh install
853/// has a tailable log without the operator touching `daemon.toml`
854/// (cluster-G §5.3).
855#[must_use]
856pub fn default_log_file_setting() -> LogFileSetting {
857 match default_log_file() {
858 Some(p) => LogFileSetting::Path(p),
859 // `default_log_file` only returns `None` if `runtime_dir()` is
860 // unable to materialise a path (extremely unusual on real
861 // platforms). Falling back to the legacy stderr-only default is
862 // safe and matches the explicit opt-out semantics.
863 None => LogFileSetting::Special("stderr".to_string()),
864 }
865}
866
867/// Cost-gate config snapshot derived from a [`DaemonConfig`]. Layer-2
868/// (`IMP-B`) consumers read this once when the workspace is loaded
869/// and pass it into `cost_gate::check_query` / `check_plan`. Foundation
870/// only owns the type — the gate body lives in `sqry-core/src/query/cost_gate.rs`
871/// (Layer-2).
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
873pub struct CostGateConfigView {
874 /// Arena-size cap above which prohibitive shapes need scope coupling.
875 /// `None` (or `Some(0)`) disables the cap entirely.
876 pub node_count_threshold: Option<usize>,
877 /// Minimum required literal-prefix length for an anchored regex.
878 pub min_prefix_len: usize,
879 /// Minimum `Hir::minimum_len` for an unanchored regex.
880 pub min_literal_len: usize,
881}
882
883impl CostGateConfigView {
884 /// Build a [`CostGateConfigView`] from the merged daemon config.
885 /// Falls back to the documented defaults when the field is `None`.
886 #[must_use]
887 pub fn from_daemon_config(cfg: &DaemonConfig) -> Self {
888 Self {
889 node_count_threshold: cfg.cost_gate_node_limit,
890 min_prefix_len: cfg
891 .cost_gate_min_prefix
892 .unwrap_or(DEFAULT_COST_GATE_MIN_PREFIX),
893 min_literal_len: cfg
894 .cost_gate_min_literal
895 .unwrap_or(DEFAULT_COST_GATE_MIN_LITERAL),
896 }
897 }
898
899 /// Standalone (non-daemon) default — matches the daemon defaults
900 /// so standalone `sqry-mcp` exhibits identical gate behaviour to
901 /// the daemon-hosted path on a freshly-installed binary.
902 #[must_use]
903 pub const fn standalone_default() -> Self {
904 Self {
905 node_count_threshold: Some(DEFAULT_COST_GATE_NODE_LIMIT),
906 min_prefix_len: DEFAULT_COST_GATE_MIN_PREFIX,
907 min_literal_len: DEFAULT_COST_GATE_MIN_LITERAL,
908 }
909 }
910}
911
912/// Daemon log-file setting: an explicit path **or** the literal
913/// `"stderr"` / `"-"` opt-out token. The opt-out preserves the
914/// pre-G-iter-2 behaviour (logging to stderr only) for operators
915/// who run sqryd under a service manager that captures stderr to
916/// its own journal.
917///
918/// Source: `G_daemon_control_plane.md` §5.3 + `00_contracts.md` §3.CC-6.
919///
920/// This type is added in the Layer-1 foundation pass so consumers
921/// (cluster-G Layer-2) can plumb it through the daemon config without
922/// a forward-reference to a yet-to-land type. The migration of
923/// [`DaemonConfig::log_file`] from `Option<PathBuf>` to this enum
924/// happens in cluster-G Layer-2.
925///
926/// Wire shape: a single TOML string. The deserializer classifies
927/// the canonical opt-out tokens (`"stderr"`, `"-"`) as
928/// [`Self::Special`]; every other string becomes [`Self::Path`].
929/// `#[serde(untagged)]` would silently misclassify the opt-out
930/// tokens as `Path("stderr")` because `PathBuf::from(&str)` always
931/// succeeds — see Codex Layer-1 iter-1 review (CC-6 partially-closed
932/// defect 1). The manual `Deserialize` impl below is the canonical
933/// fix: classify opt-out tokens before falling back to `PathBuf`.
934#[derive(Debug, Clone, PartialEq, Eq)]
935pub enum LogFileSetting {
936 /// Explicit file path. Default:
937 /// `<runtime_dir>/sqryd.log` on Linux/macOS,
938 /// `%LOCALAPPDATA%\sqry\sqryd.log` on Windows.
939 Path(PathBuf),
940 /// Literal `"stderr"` / `"-"`: log to stderr only (the legacy
941 /// behaviour). Any other string never reaches this variant; the
942 /// deserializer routes unknown values to [`Self::Path`] so
943 /// operators can spell ordinary filenames freely.
944 Special(String),
945}
946
947impl LogFileSetting {
948 /// Materialise the configured setting into the file path the
949 /// rolling appender should target. Returns `None` when the
950 /// operator opted out of file logging (`Special("stderr")` or
951 /// `Special("-")`) — the appender is then disabled and stderr
952 /// receives the structured log stream.
953 #[must_use]
954 pub fn resolve(&self) -> Option<PathBuf> {
955 match self {
956 Self::Path(p) => Some(p.clone()),
957 // The deserializer never produces other `Special` values,
958 // but defend in depth for callers that construct
959 // `LogFileSetting` programmatically — any `Special` means
960 // stderr-only.
961 Self::Special(_) => None,
962 }
963 }
964}
965
966impl serde::Serialize for LogFileSetting {
967 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
968 match self {
969 Self::Path(p) => serializer.serialize_str(&p.to_string_lossy()),
970 Self::Special(s) => serializer.serialize_str(s),
971 }
972 }
973}
974
975impl<'de> serde::Deserialize<'de> for LogFileSetting {
976 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
977 // CC-6 deserialization rule: classify the documented opt-out
978 // tokens BEFORE falling back to `PathBuf`. With
979 // `#[serde(untagged)]` and `Path(PathBuf)` listed first, every
980 // string would deserialize as `Path` (because
981 // `PathBuf::from(&str)` is total) and the opt-out semantics
982 // would silently break. This manual impl pins the contract.
983 let s = String::deserialize(deserializer)?;
984 Ok(match s.as_str() {
985 "stderr" | "-" => Self::Special(s),
986 _ => Self::Path(PathBuf::from(s)),
987 })
988 }
989}
990
991// ---------------------------------------------------------------------------
992// Tests.
993// ---------------------------------------------------------------------------
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998
999 // Use the crate-wide TEST_ENV_LOCK to serialise environment-variable
1000 // mutations across ALL test modules in the same binary.
1001 use crate::TEST_ENV_LOCK as ENV_LOCK;
1002
1003 #[test]
1004 fn defaults_match_plan_table() {
1005 let cfg = DaemonConfig::default();
1006 assert_eq!(cfg.memory_limit_mb, 2_048);
1007 assert_eq!(cfg.idle_timeout_minutes, 30);
1008 assert_eq!(cfg.debounce_ms, 2_000);
1009 assert_eq!(cfg.incremental_threshold, 20);
1010 assert_eq!(cfg.closure_limit_percent, 30);
1011 assert_eq!(cfg.stale_serve_max_age_hours, 24);
1012 assert_eq!(cfg.rebuild_drain_timeout_ms, 5_000);
1013 assert_eq!(cfg.tool_timeout_secs, 60);
1014 assert_eq!(cfg.max_shim_connections, 256);
1015 assert!((cfg.interner_compaction_threshold - 0.5).abs() < f32::EPSILON);
1016 assert_eq!(cfg.log_level, "info");
1017 assert_eq!(cfg.log_max_size_mb, 50);
1018 // Cluster-G §5.3: the new default is a file under the runtime
1019 // dir, not `Special` / `None`. Operators opt out via
1020 // `log_file = "stderr"` or `SQRY_DAEMON_LOG_FILE=-`.
1021 assert!(matches!(
1022 cfg.log_file,
1023 crate::config::LogFileSetting::Path(_)
1024 ));
1025 assert!(cfg.socket.path.is_none());
1026 assert!(cfg.socket.pipe_name.is_none());
1027 assert!(cfg.workspaces.is_empty());
1028 }
1029
1030 #[test]
1031 fn memory_limit_bytes_is_mb_times_megabyte() {
1032 let cfg = DaemonConfig::default();
1033 assert_eq!(cfg.memory_limit_bytes(), 2_048 * 1024 * 1024);
1034 }
1035
1036 #[test]
1037 fn parses_minimal_toml() {
1038 let text = r"
1039 memory_limit_mb = 4096
1040 idle_timeout_minutes = 60
1041
1042 [socket]
1043 path = '/tmp/custom-sqryd.sock'
1044
1045 [[workspaces]]
1046 path = '/repos/main'
1047 pinned = true
1048
1049 [[workspaces]]
1050 path = '/repos/secondary'
1051 ";
1052 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1053 assert_eq!(cfg.memory_limit_mb, 4_096);
1054 assert_eq!(cfg.idle_timeout_minutes, 60);
1055 assert_eq!(
1056 cfg.socket.path.as_deref(),
1057 Some(Path::new("/tmp/custom-sqryd.sock"))
1058 );
1059 assert_eq!(cfg.workspaces.len(), 2);
1060 assert!(cfg.workspaces[0].pinned);
1061 assert!(!cfg.workspaces[0].exclude);
1062 assert!(!cfg.workspaces[1].pinned);
1063 }
1064
1065 #[test]
1066 fn parses_all_knobs_with_defaults_filled_in() {
1067 // Empty TOML body — every field defaulted.
1068 let cfg = DaemonConfig::from_toml_str("").expect("parse");
1069 assert_eq!(cfg.memory_limit_mb, DEFAULT_MEMORY_LIMIT_MB);
1070 assert_eq!(
1071 cfg.stale_serve_max_age_hours,
1072 DEFAULT_STALE_SERVE_MAX_AGE_HOURS
1073 );
1074 assert_eq!(
1075 cfg.rebuild_drain_timeout_ms,
1076 DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
1077 );
1078 }
1079
1080 #[test]
1081 fn rejects_unknown_fields() {
1082 let text = "totally_bogus_knob = 42";
1083 let err = DaemonConfig::from_toml_str(text).expect_err("unknown field must fail");
1084 // `anyhow::Error::context` buries the offending field name in the
1085 // source chain; format with the alternate specifier to include it.
1086 let chain = format!("{err:#}");
1087 assert!(
1088 chain.contains("totally_bogus_knob") && chain.contains("unknown field"),
1089 "unexpected error: {chain}"
1090 );
1091 }
1092
1093 #[test]
1094 fn validation_rejects_zero_memory_limit() {
1095 let cfg = DaemonConfig {
1096 memory_limit_mb: 0,
1097 ..DaemonConfig::default()
1098 };
1099 assert!(cfg.validate().is_err());
1100 }
1101
1102 #[test]
1103 fn validation_rejects_closure_limit_out_of_range() {
1104 let low = DaemonConfig {
1105 closure_limit_percent: 0,
1106 ..DaemonConfig::default()
1107 };
1108 assert!(low.validate().is_err());
1109 let high = DaemonConfig {
1110 closure_limit_percent: 101,
1111 ..DaemonConfig::default()
1112 };
1113 assert!(high.validate().is_err());
1114 }
1115
1116 #[test]
1117 fn validation_rejects_compaction_threshold_out_of_range() {
1118 let zero = DaemonConfig {
1119 interner_compaction_threshold: 0.0,
1120 ..DaemonConfig::default()
1121 };
1122 assert!(zero.validate().is_err());
1123 let over = DaemonConfig {
1124 interner_compaction_threshold: 1.5,
1125 ..DaemonConfig::default()
1126 };
1127 assert!(over.validate().is_err());
1128 let nan = DaemonConfig {
1129 interner_compaction_threshold: f32::NAN,
1130 ..DaemonConfig::default()
1131 };
1132 assert!(nan.validate().is_err());
1133 }
1134
1135 #[test]
1136 fn validation_rejects_zero_debounce_and_zero_log_size() {
1137 let debounce = DaemonConfig {
1138 debounce_ms: 0,
1139 ..DaemonConfig::default()
1140 };
1141 assert!(debounce.validate().is_err());
1142 let log = DaemonConfig {
1143 log_max_size_mb: 0,
1144 ..DaemonConfig::default()
1145 };
1146 assert!(log.validate().is_err());
1147 }
1148
1149 #[test]
1150 fn validation_rejects_max_shim_connections_out_of_range() {
1151 let zero = DaemonConfig {
1152 max_shim_connections: 0,
1153 ..DaemonConfig::default()
1154 };
1155 assert!(zero.validate().is_err());
1156 let too_large = DaemonConfig {
1157 max_shim_connections: 65_537,
1158 ..DaemonConfig::default()
1159 };
1160 assert!(too_large.validate().is_err());
1161 let ok = DaemonConfig {
1162 max_shim_connections: 1_024,
1163 ..DaemonConfig::default()
1164 };
1165 assert!(ok.validate().is_ok());
1166 }
1167
1168 #[test]
1169 fn validation_rejects_tool_timeout_out_of_range() {
1170 let zero = DaemonConfig {
1171 tool_timeout_secs: 0,
1172 ..DaemonConfig::default()
1173 };
1174 assert!(zero.validate().is_err());
1175 let too_long = DaemonConfig {
1176 tool_timeout_secs: 3_601,
1177 ..DaemonConfig::default()
1178 };
1179 assert!(too_long.validate().is_err());
1180 let ok = DaemonConfig {
1181 tool_timeout_secs: 120,
1182 ..DaemonConfig::default()
1183 };
1184 assert!(ok.validate().is_ok());
1185 }
1186
1187 #[test]
1188 fn load_from_missing_path_is_an_error() {
1189 let err = DaemonConfig::load_from_path(Path::new("/nonexistent/sqryd.toml"))
1190 .expect_err("missing file is an error for explicit path");
1191 match err {
1192 DaemonError::Config { path, .. } => {
1193 assert_eq!(path, Path::new("/nonexistent/sqryd.toml"));
1194 }
1195 other => panic!("expected Config error, got {other:?}"),
1196 }
1197 }
1198
1199 #[test]
1200 fn socket_path_uses_runtime_dir_when_unspecified() {
1201 let cfg = DaemonConfig::default();
1202 let p = cfg.socket_path();
1203 if cfg!(unix) {
1204 assert!(p.ends_with("sqryd.sock"), "{p:?}");
1205 } else if cfg!(windows) {
1206 let s = p.to_string_lossy();
1207 assert!(s.starts_with(r"\\.\pipe\"), "{s}");
1208 }
1209 }
1210
1211 #[test]
1212 fn apply_env_overrides_applies_memory_limit_override() {
1213 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1214 // SAFETY: guarded by ENV_LOCK so no concurrent env-var reader
1215 // in this module observes the in-flux value.
1216 unsafe {
1217 env::set_var(ENV_MEMORY_LIMIT_MB, "8192");
1218 }
1219 let mut cfg = DaemonConfig::default();
1220 let outcome = cfg.apply_env_overrides();
1221 // Always clean up the env var even if the assertion below would
1222 // fail, so sibling tests do not start in a poisoned state.
1223 // SAFETY: still guarded by ENV_LOCK.
1224 unsafe {
1225 env::remove_var(ENV_MEMORY_LIMIT_MB);
1226 }
1227 outcome.expect("override ok");
1228 assert_eq!(cfg.memory_limit_mb, 8_192);
1229 }
1230
1231 #[test]
1232 fn apply_env_overrides_rejects_malformed_memory_limit() {
1233 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1234 // SAFETY: guarded by ENV_LOCK.
1235 unsafe {
1236 env::set_var(ENV_MEMORY_LIMIT_MB, "not-a-number");
1237 }
1238 let mut cfg = DaemonConfig::default();
1239 let err = cfg.apply_env_overrides();
1240 // SAFETY: guarded by ENV_LOCK.
1241 unsafe {
1242 env::remove_var(ENV_MEMORY_LIMIT_MB);
1243 }
1244 let err = err.expect_err("malformed override must fail");
1245 match err {
1246 DaemonError::Config { path, .. } => {
1247 assert_eq!(path, Path::new(ENV_MEMORY_LIMIT_MB));
1248 }
1249 other => panic!("expected Config error, got {other:?}"),
1250 }
1251 }
1252
1253 #[test]
1254 fn working_set_multiplier_matches_spec() {
1255 // If either of these two constants changes, the Task 6
1256 // reserve_rebuild tests will need to be regenerated — pin them
1257 // here so changes are reviewed together.
1258 assert!((WORKING_SET_MULTIPLIER - 1.5_f64).abs() < f64::EPSILON);
1259 assert!((INTERNER_BUILDER_OVERHEAD_RATIO - 0.25_f64).abs() < f64::EPSILON);
1260 }
1261
1262 #[test]
1263 #[cfg(unix)]
1264 fn runtime_dir_is_real_uid_scoped_when_user_env_is_unset() {
1265 // Regression for Codex Task 5 iter-1 MAJOR finding:
1266 // `/tmp/sqry-default/...` collisions across users when
1267 // `USER`/`USERNAME`/`XDG_RUNTIME_DIR` are all unset. The fix
1268 // switched the fallback to a `libc::getuid()`-derived suffix
1269 // so every user gets their own socket/pid/lock namespace.
1270 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1271
1272 // Stash and clear every env var that the runtime_dir() chain
1273 // would otherwise read ahead of the UID-based fallback.
1274 let prior_user = env::var_os("USER");
1275 let prior_username = env::var_os("USERNAME");
1276 let prior_xdg = env::var_os("XDG_RUNTIME_DIR");
1277 let prior_tmpdir = env::var_os("TMPDIR");
1278 // SAFETY: serialised by ENV_LOCK; restored before the guard drops.
1279 unsafe {
1280 env::remove_var("USER");
1281 env::remove_var("USERNAME");
1282 env::remove_var("XDG_RUNTIME_DIR");
1283 env::remove_var("TMPDIR");
1284 }
1285
1286 let cfg = DaemonConfig::default();
1287 let socket = cfg.socket_path();
1288 let pid = cfg.pid_path();
1289 let lock = cfg.lock_path();
1290
1291 // Restore the prior environment before any assertion so a
1292 // failing assertion does not poison sibling tests.
1293 // SAFETY: guarded by ENV_LOCK.
1294 unsafe {
1295 if let Some(v) = prior_user {
1296 env::set_var("USER", v);
1297 }
1298 if let Some(v) = prior_username {
1299 env::set_var("USERNAME", v);
1300 }
1301 if let Some(v) = prior_xdg {
1302 env::set_var("XDG_RUNTIME_DIR", v);
1303 }
1304 if let Some(v) = prior_tmpdir {
1305 env::set_var("TMPDIR", v);
1306 }
1307 }
1308
1309 // SAFETY: `libc::getuid` is infallible; see the inline comment
1310 // on `user_scoped_dir_name` above.
1311 let uid = unsafe { libc::getuid() };
1312 let expected = format!("/tmp/sqry-{uid}");
1313 assert_eq!(
1314 socket.parent().and_then(Path::to_str),
1315 Some(expected.as_str()),
1316 "socket_path must be UID-scoped: socket = {socket:?}",
1317 );
1318 assert_eq!(
1319 pid.parent().and_then(Path::to_str),
1320 Some(expected.as_str()),
1321 "pid_path must be UID-scoped: pid = {pid:?}",
1322 );
1323 assert_eq!(
1324 lock.parent().and_then(Path::to_str),
1325 Some(expected.as_str()),
1326 "lock_path must be UID-scoped: lock = {lock:?}",
1327 );
1328 // And the directory name is never the literal "default".
1329 assert!(
1330 !expected.ends_with("sqry-default"),
1331 "runtime dir must never fall back to the shared /tmp/sqry-default path",
1332 );
1333 }
1334
1335 #[test]
1336 fn round_trip_via_toml_preserves_workspace_entries() {
1337 // Author a TOML string → parse → re-emit → re-parse — the two
1338 // parses must produce the same workspace list.
1339 let text = r#"
1340 memory_limit_mb = 1024
1341
1342 [[workspaces]]
1343 path = "/foo"
1344 pinned = true
1345 [[workspaces]]
1346 path = "/bar"
1347 exclude = true
1348 "#;
1349 let cfg = DaemonConfig::from_toml_str(text).unwrap();
1350 assert_eq!(cfg.workspaces.len(), 2);
1351 assert!(cfg.workspaces[0].pinned);
1352 assert!(cfg.workspaces[1].exclude);
1353 }
1354
1355 // -----------------------------------------------------------------------
1356 // Task 9 U2 tests.
1357 // -----------------------------------------------------------------------
1358
1359 #[test]
1360 fn u2_defaults_match_spec() {
1361 let cfg = DaemonConfig::default();
1362 assert_eq!(
1363 cfg.auto_start_ready_timeout_secs, 10,
1364 "auto_start_ready_timeout_secs default must be 10"
1365 );
1366 assert_eq!(
1367 cfg.log_keep_rotations, 5,
1368 "log_keep_rotations default must be 5"
1369 );
1370 assert!(
1371 !cfg.install_user_service,
1372 "install_user_service default must be false"
1373 );
1374 }
1375
1376 #[test]
1377 fn u2_auto_start_ready_timeout_env_override() {
1378 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1379 // SAFETY: guarded by ENV_LOCK.
1380 unsafe {
1381 env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "30");
1382 }
1383 let mut cfg = DaemonConfig::default();
1384 let result = cfg.apply_env_overrides();
1385 // SAFETY: guarded by ENV_LOCK.
1386 unsafe {
1387 env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1388 }
1389 result.expect("override ok");
1390 assert_eq!(cfg.auto_start_ready_timeout_secs, 30);
1391 }
1392
1393 #[test]
1394 fn u2_auto_start_ready_timeout_env_override_rejects_malformed() {
1395 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1396 // SAFETY: guarded by ENV_LOCK.
1397 unsafe {
1398 env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "not-a-number");
1399 }
1400 let mut cfg = DaemonConfig::default();
1401 let err = cfg.apply_env_overrides();
1402 // SAFETY: guarded by ENV_LOCK.
1403 unsafe {
1404 env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1405 }
1406 let err = err.expect_err("malformed value must fail");
1407 match err {
1408 DaemonError::Config { path, .. } => {
1409 assert_eq!(path, Path::new(ENV_AUTO_START_READY_TIMEOUT_SECS));
1410 }
1411 other => panic!("expected Config error, got {other:?}"),
1412 }
1413 }
1414
1415 #[test]
1416 fn u2_log_keep_rotations_env_override() {
1417 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1418 // SAFETY: guarded by ENV_LOCK.
1419 unsafe {
1420 env::set_var(ENV_LOG_KEEP_ROTATIONS, "20");
1421 }
1422 let mut cfg = DaemonConfig::default();
1423 let result = cfg.apply_env_overrides();
1424 // SAFETY: guarded by ENV_LOCK.
1425 unsafe {
1426 env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1427 }
1428 result.expect("override ok");
1429 assert_eq!(cfg.log_keep_rotations, 20);
1430 }
1431
1432 #[test]
1433 fn u2_log_keep_rotations_env_override_rejects_malformed() {
1434 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1435 // SAFETY: guarded by ENV_LOCK.
1436 unsafe {
1437 env::set_var(ENV_LOG_KEEP_ROTATIONS, "bad");
1438 }
1439 let mut cfg = DaemonConfig::default();
1440 let err = cfg.apply_env_overrides();
1441 // SAFETY: guarded by ENV_LOCK.
1442 unsafe {
1443 env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1444 }
1445 let err = err.expect_err("malformed value must fail");
1446 match err {
1447 DaemonError::Config { path, .. } => {
1448 assert_eq!(path, Path::new(ENV_LOG_KEEP_ROTATIONS));
1449 }
1450 other => panic!("expected Config error, got {other:?}"),
1451 }
1452 }
1453
1454 #[test]
1455 fn u2_validate_auto_start_ready_timeout_range() {
1456 // Zero is rejected.
1457 let zero = DaemonConfig {
1458 auto_start_ready_timeout_secs: 0,
1459 ..DaemonConfig::default()
1460 };
1461 assert!(zero.validate().is_err(), "0 must be rejected");
1462
1463 // 61 exceeds the max of 60.
1464 let over = DaemonConfig {
1465 auto_start_ready_timeout_secs: 61,
1466 ..DaemonConfig::default()
1467 };
1468 assert!(over.validate().is_err(), "61 must be rejected");
1469
1470 // Boundary values must pass.
1471 let min = DaemonConfig {
1472 auto_start_ready_timeout_secs: 1,
1473 ..DaemonConfig::default()
1474 };
1475 assert!(min.validate().is_ok(), "1 must be valid");
1476
1477 let max = DaemonConfig {
1478 auto_start_ready_timeout_secs: 60,
1479 ..DaemonConfig::default()
1480 };
1481 assert!(max.validate().is_ok(), "60 must be valid");
1482 }
1483
1484 #[test]
1485 fn u2_validate_log_keep_rotations_range() {
1486 // Zero is rejected.
1487 let zero = DaemonConfig {
1488 log_keep_rotations: 0,
1489 ..DaemonConfig::default()
1490 };
1491 assert!(zero.validate().is_err(), "0 must be rejected");
1492
1493 // 101 exceeds the max of 100.
1494 let over = DaemonConfig {
1495 log_keep_rotations: 101,
1496 ..DaemonConfig::default()
1497 };
1498 assert!(over.validate().is_err(), "101 must be rejected");
1499
1500 // Boundary values must pass.
1501 let min = DaemonConfig {
1502 log_keep_rotations: 1,
1503 ..DaemonConfig::default()
1504 };
1505 assert!(min.validate().is_ok(), "1 must be valid");
1506
1507 let max = DaemonConfig {
1508 log_keep_rotations: 100,
1509 ..DaemonConfig::default()
1510 };
1511 assert!(max.validate().is_ok(), "100 must be valid");
1512 }
1513
1514 #[test]
1515 fn u2_from_toml_str_round_trip_new_fields() {
1516 let text = r#"
1517 auto_start_ready_timeout_secs = 45
1518 log_keep_rotations = 10
1519 install_user_service = true
1520 "#;
1521 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1522 assert_eq!(cfg.auto_start_ready_timeout_secs, 45);
1523 assert_eq!(cfg.log_keep_rotations, 10);
1524 assert!(cfg.install_user_service);
1525 }
1526
1527 #[test]
1528 fn u2_from_toml_str_new_fields_default_when_absent() {
1529 // None of the new fields are present — they must fall back to defaults.
1530 let text = r"memory_limit_mb = 1024";
1531 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1532 assert_eq!(
1533 cfg.auto_start_ready_timeout_secs,
1534 DEFAULT_AUTO_START_READY_TIMEOUT_SECS
1535 );
1536 assert_eq!(cfg.log_keep_rotations, DEFAULT_LOG_KEEP_ROTATIONS);
1537 assert!(!cfg.install_user_service);
1538 }
1539
1540 #[test]
1541 fn u2_install_user_service_defaults_false_and_is_tolerated_by_validate() {
1542 // install_user_service is a no-op bool; validate must not reject any
1543 // value for it (both true and false are permanently valid).
1544 let with_true = DaemonConfig {
1545 install_user_service: true,
1546 ..DaemonConfig::default()
1547 };
1548 assert!(
1549 with_true.validate().is_ok(),
1550 "install_user_service=true must pass validate"
1551 );
1552 let with_false = DaemonConfig {
1553 install_user_service: false,
1554 ..DaemonConfig::default()
1555 };
1556 assert!(
1557 with_false.validate().is_ok(),
1558 "install_user_service=false must pass validate"
1559 );
1560 }
1561
1562 // ─── CC-6 LogFileSetting tests (Codex iter-1 defect 1 regression) ───
1563 //
1564 // The deserializer MUST classify the documented opt-out tokens
1565 // (`"stderr"`, `"-"`) as `Special` BEFORE falling back to
1566 // `Path(PathBuf)`. Earlier `#[serde(untagged)]` shape silently
1567 // misclassified them because `PathBuf::from(&str)` is total.
1568
1569 #[test]
1570 fn log_file_setting_classifies_stderr_as_special_not_path() {
1571 let parsed: LogFileSetting = toml::from_str("v = \"stderr\"")
1572 .map(|w: TomlWrapper| w.v)
1573 .unwrap();
1574 assert!(
1575 matches!(parsed, LogFileSetting::Special(ref s) if s == "stderr"),
1576 "TOML \"stderr\" must deserialize to Special, got: {parsed:?}"
1577 );
1578 assert!(
1579 parsed.resolve().is_none(),
1580 "Special(\"stderr\") must resolve to None (file logging disabled)"
1581 );
1582 }
1583
1584 #[test]
1585 fn log_file_setting_classifies_dash_as_special_not_path() {
1586 let parsed: LogFileSetting = toml::from_str("v = \"-\"")
1587 .map(|w: TomlWrapper| w.v)
1588 .unwrap();
1589 assert!(
1590 matches!(parsed, LogFileSetting::Special(ref s) if s == "-"),
1591 "TOML \"-\" must deserialize to Special, got: {parsed:?}"
1592 );
1593 assert!(
1594 parsed.resolve().is_none(),
1595 "Special(\"-\") must resolve to None"
1596 );
1597 }
1598
1599 #[test]
1600 fn log_file_setting_classifies_arbitrary_string_as_path() {
1601 let parsed: LogFileSetting = toml::from_str("v = \"/var/log/sqryd.log\"")
1602 .map(|w: TomlWrapper| w.v)
1603 .unwrap();
1604 assert!(
1605 matches!(parsed, LogFileSetting::Path(ref p) if p == &PathBuf::from("/var/log/sqryd.log")),
1606 "TOML \"/var/log/sqryd.log\" must deserialize to Path, got: {parsed:?}"
1607 );
1608 assert_eq!(parsed.resolve(), Some(PathBuf::from("/var/log/sqryd.log")));
1609 }
1610
1611 #[test]
1612 fn log_file_setting_round_trips_through_serde() {
1613 // Path round-trip
1614 let p = LogFileSetting::Path(PathBuf::from("/tmp/sqryd.log"));
1615 let s = serde_json::to_string(&p).unwrap();
1616 assert_eq!(s, "\"/tmp/sqryd.log\"");
1617 let back: LogFileSetting = serde_json::from_str(&s).unwrap();
1618 assert_eq!(back, p);
1619
1620 // Special round-trip
1621 let sp = LogFileSetting::Special("stderr".to_string());
1622 let s = serde_json::to_string(&sp).unwrap();
1623 assert_eq!(s, "\"stderr\"");
1624 let back: LogFileSetting = serde_json::from_str(&s).unwrap();
1625 assert_eq!(back, sp);
1626 }
1627
1628 // Wire helper for parsing scalar TOML values in the tests above.
1629 // `toml::from_str` requires a top-level table; this tiny wrapper
1630 // keeps the tests self-contained without polluting the public API.
1631 #[derive(serde::Deserialize)]
1632 struct TomlWrapper {
1633 v: LogFileSetting,
1634 }
1635
1636 // ─── CC-6 default_log_file UID consistency (defect 2 regression) ───
1637 //
1638 // `default_log_file()` must derive the per-user fallback from the
1639 // SAME helper as `DaemonConfig::runtime_dir()` — `runtime_dir()`
1640 // free function — so the log path matches the socket / pid / lock
1641 // paths exactly. Pin this by asserting the parent directory of the
1642 // default log file equals the canonical `runtime_dir()` result.
1643 #[test]
1644 fn default_log_file_parent_matches_canonical_runtime_dir() {
1645 let log = default_log_file().expect("default_log_file must return Some");
1646 let parent = log.parent().expect("log path has no parent").to_path_buf();
1647 // Compare against the canonical helper that drives
1648 // socket_path / pid_path / lock_path.
1649 assert_eq!(
1650 parent,
1651 runtime_dir(),
1652 "default_log_file parent must equal canonical runtime_dir() result; \
1653 defect 2 from Codex iter-1 review reintroduced if these diverge"
1654 );
1655 assert_eq!(
1656 log.file_name().and_then(|s| s.to_str()),
1657 Some("sqryd.log"),
1658 "default log filename must be sqryd.log"
1659 );
1660 }
1661}