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/// 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}