1use std::{
24 env,
25 path::{Path, PathBuf},
26};
27
28use anyhow::{Context, anyhow};
29use serde::Deserialize;
30
31use crate::error::{DaemonError, DaemonResult};
32
33pub const WORKING_SET_MULTIPLIER: f64 = 1.5;
43
44pub const INTERNER_BUILDER_OVERHEAD_RATIO: f64 = 0.25;
50
51pub const ESTIMATE_STAGING_PER_FILE_BYTES: u64 = 4_096;
65
66pub const ESTIMATE_FINAL_PER_FILE_BYTES: u64 = 2_048;
78
79pub const ENV_CONFIG_PATH: &str = "SQRY_DAEMON_CONFIG";
81
82pub const ENV_MEMORY_LIMIT_MB: &str = "SQRY_DAEMON_MEMORY_MB";
84
85pub const ENV_SOCKET_PATH: &str = "SQRY_DAEMON_SOCKET";
87
88pub const ENV_PIPE_NAME: &str = "SQRY_DAEMON_PIPE";
90
91pub const ENV_LOG_LEVEL: &str = "SQRY_DAEMON_LOG_LEVEL";
93
94pub const ENV_LOG_FILE: &str = "SQRY_DAEMON_LOG_FILE";
96
97pub const ENV_STALE_MAX_AGE_HOURS: &str = "SQRY_DAEMON_STALE_MAX_AGE_HOURS";
99
100pub const ENV_TOOL_TIMEOUT_SECS: &str = "SQRY_DAEMON_TOOL_TIMEOUT_SECS";
103
104pub const ENV_MAX_SHIM_CONNECTIONS: &str = "SQRY_DAEMON_MAX_SHIM_CONNECTIONS";
107
108pub const ENV_AUTO_START_READY_TIMEOUT_SECS: &str = "SQRY_DAEMON_AUTO_START_READY_TIMEOUT_SECS";
111
112pub const ENV_LOG_KEEP_ROTATIONS: &str = "SQRY_DAEMON_LOG_KEEP_ROTATIONS";
114
115pub const DEFAULT_MEMORY_LIMIT_MB: u64 = 2_048;
121pub const DEFAULT_IDLE_TIMEOUT_MINUTES: u64 = 30;
123pub const DEFAULT_DEBOUNCE_MS: u64 = 2_000;
125pub const DEFAULT_INCREMENTAL_THRESHOLD: usize = 20;
127pub const DEFAULT_CLOSURE_LIMIT_PERCENT: u32 = 30;
129pub const DEFAULT_STALE_SERVE_MAX_AGE_HOURS: u32 = 24;
131pub const DEFAULT_REBUILD_DRAIN_TIMEOUT_MS: u64 = 5_000;
133pub const DEFAULT_INTERNER_COMPACTION_THRESHOLD: f32 = 0.5;
136pub const DEFAULT_IPC_SHUTDOWN_DRAIN_SECS: u64 = 5;
142pub const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 60;
150pub const DEFAULT_MAX_SHIM_CONNECTIONS: usize = 256;
164pub const DEFAULT_LOG_LEVEL: &str = "info";
166pub const DEFAULT_LOG_MAX_SIZE_MB: u64 = 50;
168pub const DEFAULT_AUTO_START_READY_TIMEOUT_SECS: u64 = 10;
172pub const DEFAULT_LOG_KEEP_ROTATIONS: u32 = 5;
177
178#[derive(Debug, Clone, Deserialize, serde::Serialize)]
187#[serde(deny_unknown_fields)]
188pub struct DaemonConfig {
189 #[serde(default = "default_memory_limit_mb")]
191 pub memory_limit_mb: u64,
192
193 #[serde(default = "default_idle_timeout_minutes")]
195 pub idle_timeout_minutes: u64,
196
197 #[serde(default = "default_debounce_ms")]
199 pub debounce_ms: u64,
200
201 #[serde(default = "default_incremental_threshold")]
204 pub incremental_threshold: usize,
205
206 #[serde(default = "default_closure_limit_percent")]
209 pub closure_limit_percent: u32,
210
211 #[serde(default = "default_stale_serve_max_age_hours")]
214 pub stale_serve_max_age_hours: u32,
215
216 #[serde(default = "default_rebuild_drain_timeout_ms")]
220 pub rebuild_drain_timeout_ms: u64,
221
222 #[serde(default = "default_ipc_shutdown_drain_secs")]
225 pub ipc_shutdown_drain_secs: u64,
226
227 #[serde(default = "default_tool_timeout_secs")]
242 pub tool_timeout_secs: u64,
243
244 #[serde(default = "default_max_shim_connections")]
252 pub max_shim_connections: usize,
253
254 #[serde(default = "default_interner_compaction_threshold")]
257 pub interner_compaction_threshold: f32,
258
259 #[serde(default)]
261 pub log_file: Option<PathBuf>,
262
263 #[serde(default = "default_log_level")]
265 pub log_level: String,
266
267 #[serde(default = "default_log_max_size_mb")]
269 pub log_max_size_mb: u64,
270
271 #[serde(default)]
273 pub socket: SocketConfig,
274
275 #[serde(default)]
277 pub workspaces: Vec<WorkspaceConfig>,
278
279 #[serde(default = "default_auto_start_ready_timeout_secs")]
290 pub auto_start_ready_timeout_secs: u64,
291
292 #[serde(default = "default_log_keep_rotations")]
302 pub log_keep_rotations: u32,
303
304 #[serde(default)]
309 pub install_user_service: bool,
310}
311
312impl Default for DaemonConfig {
313 fn default() -> Self {
314 Self {
315 memory_limit_mb: DEFAULT_MEMORY_LIMIT_MB,
316 idle_timeout_minutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
317 debounce_ms: DEFAULT_DEBOUNCE_MS,
318 incremental_threshold: DEFAULT_INCREMENTAL_THRESHOLD,
319 closure_limit_percent: DEFAULT_CLOSURE_LIMIT_PERCENT,
320 stale_serve_max_age_hours: DEFAULT_STALE_SERVE_MAX_AGE_HOURS,
321 rebuild_drain_timeout_ms: DEFAULT_REBUILD_DRAIN_TIMEOUT_MS,
322 ipc_shutdown_drain_secs: DEFAULT_IPC_SHUTDOWN_DRAIN_SECS,
323 tool_timeout_secs: DEFAULT_TOOL_TIMEOUT_SECS,
324 max_shim_connections: DEFAULT_MAX_SHIM_CONNECTIONS,
325 interner_compaction_threshold: DEFAULT_INTERNER_COMPACTION_THRESHOLD,
326 log_file: None,
327 log_level: DEFAULT_LOG_LEVEL.to_owned(),
328 log_max_size_mb: DEFAULT_LOG_MAX_SIZE_MB,
329 socket: SocketConfig::default(),
330 workspaces: Vec::new(),
331 auto_start_ready_timeout_secs: DEFAULT_AUTO_START_READY_TIMEOUT_SECS,
332 log_keep_rotations: DEFAULT_LOG_KEEP_ROTATIONS,
333 install_user_service: false,
334 }
335 }
336}
337
338#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
344#[serde(deny_unknown_fields)]
345pub struct SocketConfig {
346 #[serde(default)]
348 pub path: Option<PathBuf>,
349
350 #[serde(default)]
352 pub pipe_name: Option<String>,
353}
354
355#[derive(Debug, Clone, Deserialize, serde::Serialize)]
360#[serde(deny_unknown_fields)]
361pub struct WorkspaceConfig {
362 pub path: PathBuf,
364
365 #[serde(default)]
367 pub pinned: bool,
368
369 #[serde(default)]
371 pub exclude: bool,
372}
373
374impl DaemonConfig {
379 pub fn load() -> DaemonResult<Self> {
386 let path = Self::resolve_config_path()?;
387 let mut config = if path.exists() {
388 Self::load_from_path(&path)?
389 } else {
390 Self::default()
391 };
392 config.apply_env_overrides()?;
393 config.validate()?;
394 Ok(config)
395 }
396
397 pub fn load_from_path(path: &Path) -> DaemonResult<Self> {
400 let text = std::fs::read_to_string(path).map_err(|source| DaemonError::Config {
401 path: path.to_path_buf(),
402 source: anyhow::Error::from(source).context("reading daemon config"),
403 })?;
404 Self::from_toml_str(&text).map_err(|source| DaemonError::Config {
405 path: path.to_path_buf(),
406 source,
407 })
408 }
409
410 pub fn from_toml_str(text: &str) -> anyhow::Result<Self> {
413 let cfg: Self = toml::from_str(text).context("parsing daemon config TOML")?;
414 Ok(cfg)
415 }
416
417 pub fn apply_env_overrides(&mut self) -> DaemonResult<()> {
420 if let Some(v) = env::var_os(ENV_MEMORY_LIMIT_MB) {
421 let v = v.to_string_lossy().into_owned();
422 self.memory_limit_mb = v.parse::<u64>().map_err(|e| DaemonError::Config {
423 path: PathBuf::from(ENV_MEMORY_LIMIT_MB),
424 source: anyhow!("{ENV_MEMORY_LIMIT_MB}={v:?} must be an unsigned int: {e}"),
425 })?;
426 }
427 if let Some(v) = env::var_os(ENV_SOCKET_PATH) {
428 self.socket.path = Some(PathBuf::from(v));
429 }
430 if let Some(v) = env::var_os(ENV_PIPE_NAME) {
431 self.socket.pipe_name = Some(v.to_string_lossy().into_owned());
432 }
433 if let Some(v) = env::var_os(ENV_LOG_LEVEL) {
434 self.log_level = v.to_string_lossy().into_owned();
435 }
436 if let Some(v) = env::var_os(ENV_LOG_FILE) {
437 self.log_file = Some(PathBuf::from(v));
438 }
439 if let Some(v) = env::var_os(ENV_STALE_MAX_AGE_HOURS) {
440 let v = v.to_string_lossy().into_owned();
441 self.stale_serve_max_age_hours = v.parse::<u32>().map_err(|e| DaemonError::Config {
442 path: PathBuf::from(ENV_STALE_MAX_AGE_HOURS),
443 source: anyhow!("{ENV_STALE_MAX_AGE_HOURS}={v:?}: {e}"),
444 })?;
445 }
446 if let Some(v) = env::var_os(ENV_TOOL_TIMEOUT_SECS) {
447 let v = v.to_string_lossy().into_owned();
448 self.tool_timeout_secs = v.parse::<u64>().map_err(|e| DaemonError::Config {
449 path: PathBuf::from(ENV_TOOL_TIMEOUT_SECS),
450 source: anyhow!("{ENV_TOOL_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"),
451 })?;
452 }
453 if let Some(v) = env::var_os(ENV_MAX_SHIM_CONNECTIONS) {
454 let v = v.to_string_lossy().into_owned();
455 self.max_shim_connections = v.parse::<usize>().map_err(|e| DaemonError::Config {
456 path: PathBuf::from(ENV_MAX_SHIM_CONNECTIONS),
457 source: anyhow!("{ENV_MAX_SHIM_CONNECTIONS}={v:?} must be an unsigned int: {e}"),
458 })?;
459 }
460 if let Some(v) = env::var_os(ENV_AUTO_START_READY_TIMEOUT_SECS) {
461 let v = v.to_string_lossy().into_owned();
462 self.auto_start_ready_timeout_secs =
463 v.parse::<u64>().map_err(|e| DaemonError::Config {
464 path: PathBuf::from(ENV_AUTO_START_READY_TIMEOUT_SECS),
465 source: anyhow!(
466 "{ENV_AUTO_START_READY_TIMEOUT_SECS}={v:?} must be an unsigned int: {e}"
467 ),
468 })?;
469 }
470 if let Some(v) = env::var_os(ENV_LOG_KEEP_ROTATIONS) {
471 let v = v.to_string_lossy().into_owned();
472 self.log_keep_rotations = v.parse::<u32>().map_err(|e| DaemonError::Config {
473 path: PathBuf::from(ENV_LOG_KEEP_ROTATIONS),
474 source: anyhow!("{ENV_LOG_KEEP_ROTATIONS}={v:?} must be an unsigned int: {e}"),
475 })?;
476 }
477 Ok(())
478 }
479
480 pub fn validate(&self) -> DaemonResult<()> {
483 let reject = |msg: &str| DaemonError::Config {
484 path: PathBuf::from("<in-memory>"),
485 source: anyhow!("{msg}"),
486 };
487 if self.memory_limit_mb == 0 {
488 return Err(reject("memory_limit_mb must be > 0"));
489 }
490 if self.closure_limit_percent == 0 || self.closure_limit_percent > 100 {
491 return Err(reject("closure_limit_percent must be in 1..=100"));
492 }
493 if !self.interner_compaction_threshold.is_finite()
494 || self.interner_compaction_threshold <= 0.0
495 || self.interner_compaction_threshold > 1.0
496 {
497 return Err(reject(
498 "interner_compaction_threshold must be in (0.0, 1.0]",
499 ));
500 }
501 if self.debounce_ms == 0 {
502 return Err(reject("debounce_ms must be > 0"));
503 }
504 if self.log_max_size_mb == 0 {
505 return Err(reject("log_max_size_mb must be > 0"));
506 }
507 if self.ipc_shutdown_drain_secs == 0 || self.ipc_shutdown_drain_secs > 3_600 {
508 return Err(reject("ipc_shutdown_drain_secs must be in 1..=3600"));
509 }
510 if self.tool_timeout_secs == 0 || self.tool_timeout_secs > 3_600 {
511 return Err(reject("tool_timeout_secs must be in 1..=3600"));
512 }
513 if self.max_shim_connections == 0 || self.max_shim_connections > 65_536 {
514 return Err(reject("max_shim_connections must be in 1..=65536"));
515 }
516 if self.auto_start_ready_timeout_secs == 0 || self.auto_start_ready_timeout_secs > 60 {
517 return Err(reject("auto_start_ready_timeout_secs must be in 1..=60"));
518 }
519 if self.log_keep_rotations == 0 || self.log_keep_rotations > 100 {
520 return Err(reject("log_keep_rotations must be in 1..=100"));
521 }
522 Ok(())
523 }
524
525 pub fn resolve_config_path() -> DaemonResult<PathBuf> {
530 if let Some(v) = env::var_os(ENV_CONFIG_PATH) {
531 return Ok(PathBuf::from(v));
532 }
533 let base = dirs::config_dir().ok_or_else(|| DaemonError::Config {
534 path: PathBuf::from("~/.config"),
535 source: anyhow!("could not determine user config directory; set {ENV_CONFIG_PATH}"),
536 })?;
537 Ok(base.join("sqry").join("daemon.toml"))
538 }
539
540 #[must_use]
546 pub fn socket_path(&self) -> PathBuf {
547 if cfg!(windows) {
548 let name = self
549 .socket
550 .pipe_name
551 .clone()
552 .unwrap_or_else(|| "sqry".to_string());
553 return PathBuf::from(format!(r"\\.\pipe\{name}"));
554 }
555 if let Some(path) = &self.socket.path {
556 return path.clone();
557 }
558 runtime_dir().join("sqryd.sock")
559 }
560
561 #[must_use]
563 pub fn pid_path(&self) -> PathBuf {
564 runtime_dir().join("sqryd.pid")
565 }
566
567 #[must_use]
570 pub fn lock_path(&self) -> PathBuf {
571 runtime_dir().join("sqryd.lock")
572 }
573
574 #[must_use]
582 pub fn runtime_dir(&self) -> PathBuf {
583 runtime_dir()
584 }
585
586 #[must_use]
588 pub const fn memory_limit_bytes(&self) -> u64 {
589 self.memory_limit_mb.saturating_mul(1024 * 1024)
590 }
591}
592
593fn runtime_dir() -> PathBuf {
603 if cfg!(windows)
604 && let Some(local) = env::var_os("LOCALAPPDATA")
605 {
606 return PathBuf::from(local).join("sqry");
607 }
608 if let Some(xdg) = env::var_os("XDG_RUNTIME_DIR") {
609 return PathBuf::from(xdg).join("sqry");
610 }
611 if let Some(tmp) = env::var_os("TMPDIR") {
612 return PathBuf::from(tmp).join(user_scoped_dir_name());
613 }
614 PathBuf::from("/tmp").join(user_scoped_dir_name())
615}
616
617fn user_scoped_dir_name() -> String {
629 #[cfg(unix)]
630 {
631 let uid = unsafe { libc::getuid() };
635 format!("sqry-{uid}")
636 }
637 #[cfg(not(unix))]
638 {
639 let user = env::var("USERNAME").unwrap_or_else(|_| "default".to_string());
640 format!("sqry-{user}")
641 }
642}
643
644const fn default_memory_limit_mb() -> u64 {
649 DEFAULT_MEMORY_LIMIT_MB
650}
651const fn default_idle_timeout_minutes() -> u64 {
652 DEFAULT_IDLE_TIMEOUT_MINUTES
653}
654const fn default_debounce_ms() -> u64 {
655 DEFAULT_DEBOUNCE_MS
656}
657const fn default_incremental_threshold() -> usize {
658 DEFAULT_INCREMENTAL_THRESHOLD
659}
660const fn default_closure_limit_percent() -> u32 {
661 DEFAULT_CLOSURE_LIMIT_PERCENT
662}
663const fn default_stale_serve_max_age_hours() -> u32 {
664 DEFAULT_STALE_SERVE_MAX_AGE_HOURS
665}
666const fn default_rebuild_drain_timeout_ms() -> u64 {
667 DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
668}
669const fn default_ipc_shutdown_drain_secs() -> u64 {
670 DEFAULT_IPC_SHUTDOWN_DRAIN_SECS
671}
672const fn default_tool_timeout_secs() -> u64 {
673 DEFAULT_TOOL_TIMEOUT_SECS
674}
675const fn default_max_shim_connections() -> usize {
676 DEFAULT_MAX_SHIM_CONNECTIONS
677}
678const fn default_interner_compaction_threshold() -> f32 {
679 DEFAULT_INTERNER_COMPACTION_THRESHOLD
680}
681fn default_log_level() -> String {
682 DEFAULT_LOG_LEVEL.to_owned()
683}
684const fn default_log_max_size_mb() -> u64 {
685 DEFAULT_LOG_MAX_SIZE_MB
686}
687const fn default_auto_start_ready_timeout_secs() -> u64 {
688 DEFAULT_AUTO_START_READY_TIMEOUT_SECS
689}
690const fn default_log_keep_rotations() -> u32 {
691 DEFAULT_LOG_KEEP_ROTATIONS
692}
693
694#[cfg(test)]
699mod tests {
700 use super::*;
701
702 use crate::TEST_ENV_LOCK as ENV_LOCK;
705
706 #[test]
707 fn defaults_match_plan_table() {
708 let cfg = DaemonConfig::default();
709 assert_eq!(cfg.memory_limit_mb, 2_048);
710 assert_eq!(cfg.idle_timeout_minutes, 30);
711 assert_eq!(cfg.debounce_ms, 2_000);
712 assert_eq!(cfg.incremental_threshold, 20);
713 assert_eq!(cfg.closure_limit_percent, 30);
714 assert_eq!(cfg.stale_serve_max_age_hours, 24);
715 assert_eq!(cfg.rebuild_drain_timeout_ms, 5_000);
716 assert_eq!(cfg.tool_timeout_secs, 60);
717 assert_eq!(cfg.max_shim_connections, 256);
718 assert!((cfg.interner_compaction_threshold - 0.5).abs() < f32::EPSILON);
719 assert_eq!(cfg.log_level, "info");
720 assert_eq!(cfg.log_max_size_mb, 50);
721 assert!(cfg.log_file.is_none());
722 assert!(cfg.socket.path.is_none());
723 assert!(cfg.socket.pipe_name.is_none());
724 assert!(cfg.workspaces.is_empty());
725 }
726
727 #[test]
728 fn memory_limit_bytes_is_mb_times_megabyte() {
729 let cfg = DaemonConfig::default();
730 assert_eq!(cfg.memory_limit_bytes(), 2_048 * 1024 * 1024);
731 }
732
733 #[test]
734 fn parses_minimal_toml() {
735 let text = r"
736 memory_limit_mb = 4096
737 idle_timeout_minutes = 60
738
739 [socket]
740 path = '/tmp/custom-sqryd.sock'
741
742 [[workspaces]]
743 path = '/repos/main'
744 pinned = true
745
746 [[workspaces]]
747 path = '/repos/secondary'
748 ";
749 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
750 assert_eq!(cfg.memory_limit_mb, 4_096);
751 assert_eq!(cfg.idle_timeout_minutes, 60);
752 assert_eq!(
753 cfg.socket.path.as_deref(),
754 Some(Path::new("/tmp/custom-sqryd.sock"))
755 );
756 assert_eq!(cfg.workspaces.len(), 2);
757 assert!(cfg.workspaces[0].pinned);
758 assert!(!cfg.workspaces[0].exclude);
759 assert!(!cfg.workspaces[1].pinned);
760 }
761
762 #[test]
763 fn parses_all_knobs_with_defaults_filled_in() {
764 let cfg = DaemonConfig::from_toml_str("").expect("parse");
766 assert_eq!(cfg.memory_limit_mb, DEFAULT_MEMORY_LIMIT_MB);
767 assert_eq!(
768 cfg.stale_serve_max_age_hours,
769 DEFAULT_STALE_SERVE_MAX_AGE_HOURS
770 );
771 assert_eq!(
772 cfg.rebuild_drain_timeout_ms,
773 DEFAULT_REBUILD_DRAIN_TIMEOUT_MS
774 );
775 }
776
777 #[test]
778 fn rejects_unknown_fields() {
779 let text = "totally_bogus_knob = 42";
780 let err = DaemonConfig::from_toml_str(text).expect_err("unknown field must fail");
781 let chain = format!("{err:#}");
784 assert!(
785 chain.contains("totally_bogus_knob") && chain.contains("unknown field"),
786 "unexpected error: {chain}"
787 );
788 }
789
790 #[test]
791 fn validation_rejects_zero_memory_limit() {
792 let cfg = DaemonConfig {
793 memory_limit_mb: 0,
794 ..DaemonConfig::default()
795 };
796 assert!(cfg.validate().is_err());
797 }
798
799 #[test]
800 fn validation_rejects_closure_limit_out_of_range() {
801 let low = DaemonConfig {
802 closure_limit_percent: 0,
803 ..DaemonConfig::default()
804 };
805 assert!(low.validate().is_err());
806 let high = DaemonConfig {
807 closure_limit_percent: 101,
808 ..DaemonConfig::default()
809 };
810 assert!(high.validate().is_err());
811 }
812
813 #[test]
814 fn validation_rejects_compaction_threshold_out_of_range() {
815 let zero = DaemonConfig {
816 interner_compaction_threshold: 0.0,
817 ..DaemonConfig::default()
818 };
819 assert!(zero.validate().is_err());
820 let over = DaemonConfig {
821 interner_compaction_threshold: 1.5,
822 ..DaemonConfig::default()
823 };
824 assert!(over.validate().is_err());
825 let nan = DaemonConfig {
826 interner_compaction_threshold: f32::NAN,
827 ..DaemonConfig::default()
828 };
829 assert!(nan.validate().is_err());
830 }
831
832 #[test]
833 fn validation_rejects_zero_debounce_and_zero_log_size() {
834 let debounce = DaemonConfig {
835 debounce_ms: 0,
836 ..DaemonConfig::default()
837 };
838 assert!(debounce.validate().is_err());
839 let log = DaemonConfig {
840 log_max_size_mb: 0,
841 ..DaemonConfig::default()
842 };
843 assert!(log.validate().is_err());
844 }
845
846 #[test]
847 fn validation_rejects_max_shim_connections_out_of_range() {
848 let zero = DaemonConfig {
849 max_shim_connections: 0,
850 ..DaemonConfig::default()
851 };
852 assert!(zero.validate().is_err());
853 let too_large = DaemonConfig {
854 max_shim_connections: 65_537,
855 ..DaemonConfig::default()
856 };
857 assert!(too_large.validate().is_err());
858 let ok = DaemonConfig {
859 max_shim_connections: 1_024,
860 ..DaemonConfig::default()
861 };
862 assert!(ok.validate().is_ok());
863 }
864
865 #[test]
866 fn validation_rejects_tool_timeout_out_of_range() {
867 let zero = DaemonConfig {
868 tool_timeout_secs: 0,
869 ..DaemonConfig::default()
870 };
871 assert!(zero.validate().is_err());
872 let too_long = DaemonConfig {
873 tool_timeout_secs: 3_601,
874 ..DaemonConfig::default()
875 };
876 assert!(too_long.validate().is_err());
877 let ok = DaemonConfig {
878 tool_timeout_secs: 120,
879 ..DaemonConfig::default()
880 };
881 assert!(ok.validate().is_ok());
882 }
883
884 #[test]
885 fn load_from_missing_path_is_an_error() {
886 let err = DaemonConfig::load_from_path(Path::new("/nonexistent/sqryd.toml"))
887 .expect_err("missing file is an error for explicit path");
888 match err {
889 DaemonError::Config { path, .. } => {
890 assert_eq!(path, Path::new("/nonexistent/sqryd.toml"));
891 }
892 other => panic!("expected Config error, got {other:?}"),
893 }
894 }
895
896 #[test]
897 fn socket_path_uses_runtime_dir_when_unspecified() {
898 let cfg = DaemonConfig::default();
899 let p = cfg.socket_path();
900 if cfg!(unix) {
901 assert!(p.ends_with("sqryd.sock"), "{p:?}");
902 } else if cfg!(windows) {
903 let s = p.to_string_lossy();
904 assert!(s.starts_with(r"\\.\pipe\"), "{s}");
905 }
906 }
907
908 #[test]
909 fn apply_env_overrides_applies_memory_limit_override() {
910 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
911 unsafe {
914 env::set_var(ENV_MEMORY_LIMIT_MB, "8192");
915 }
916 let mut cfg = DaemonConfig::default();
917 let outcome = cfg.apply_env_overrides();
918 unsafe {
922 env::remove_var(ENV_MEMORY_LIMIT_MB);
923 }
924 outcome.expect("override ok");
925 assert_eq!(cfg.memory_limit_mb, 8_192);
926 }
927
928 #[test]
929 fn apply_env_overrides_rejects_malformed_memory_limit() {
930 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
931 unsafe {
933 env::set_var(ENV_MEMORY_LIMIT_MB, "not-a-number");
934 }
935 let mut cfg = DaemonConfig::default();
936 let err = cfg.apply_env_overrides();
937 unsafe {
939 env::remove_var(ENV_MEMORY_LIMIT_MB);
940 }
941 let err = err.expect_err("malformed override must fail");
942 match err {
943 DaemonError::Config { path, .. } => {
944 assert_eq!(path, Path::new(ENV_MEMORY_LIMIT_MB));
945 }
946 other => panic!("expected Config error, got {other:?}"),
947 }
948 }
949
950 #[test]
951 fn working_set_multiplier_matches_spec() {
952 assert!((WORKING_SET_MULTIPLIER - 1.5_f64).abs() < f64::EPSILON);
956 assert!((INTERNER_BUILDER_OVERHEAD_RATIO - 0.25_f64).abs() < f64::EPSILON);
957 }
958
959 #[test]
960 #[cfg(unix)]
961 fn runtime_dir_is_real_uid_scoped_when_user_env_is_unset() {
962 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
968
969 let prior_user = env::var_os("USER");
972 let prior_username = env::var_os("USERNAME");
973 let prior_xdg = env::var_os("XDG_RUNTIME_DIR");
974 let prior_tmpdir = env::var_os("TMPDIR");
975 unsafe {
977 env::remove_var("USER");
978 env::remove_var("USERNAME");
979 env::remove_var("XDG_RUNTIME_DIR");
980 env::remove_var("TMPDIR");
981 }
982
983 let cfg = DaemonConfig::default();
984 let socket = cfg.socket_path();
985 let pid = cfg.pid_path();
986 let lock = cfg.lock_path();
987
988 unsafe {
992 if let Some(v) = prior_user {
993 env::set_var("USER", v);
994 }
995 if let Some(v) = prior_username {
996 env::set_var("USERNAME", v);
997 }
998 if let Some(v) = prior_xdg {
999 env::set_var("XDG_RUNTIME_DIR", v);
1000 }
1001 if let Some(v) = prior_tmpdir {
1002 env::set_var("TMPDIR", v);
1003 }
1004 }
1005
1006 let uid = unsafe { libc::getuid() };
1009 let expected = format!("/tmp/sqry-{uid}");
1010 assert_eq!(
1011 socket.parent().and_then(Path::to_str),
1012 Some(expected.as_str()),
1013 "socket_path must be UID-scoped: socket = {socket:?}",
1014 );
1015 assert_eq!(
1016 pid.parent().and_then(Path::to_str),
1017 Some(expected.as_str()),
1018 "pid_path must be UID-scoped: pid = {pid:?}",
1019 );
1020 assert_eq!(
1021 lock.parent().and_then(Path::to_str),
1022 Some(expected.as_str()),
1023 "lock_path must be UID-scoped: lock = {lock:?}",
1024 );
1025 assert!(
1027 !expected.ends_with("sqry-default"),
1028 "runtime dir must never fall back to the shared /tmp/sqry-default path",
1029 );
1030 }
1031
1032 #[test]
1033 fn round_trip_via_toml_preserves_workspace_entries() {
1034 let text = r#"
1037 memory_limit_mb = 1024
1038
1039 [[workspaces]]
1040 path = "/foo"
1041 pinned = true
1042 [[workspaces]]
1043 path = "/bar"
1044 exclude = true
1045 "#;
1046 let cfg = DaemonConfig::from_toml_str(text).unwrap();
1047 assert_eq!(cfg.workspaces.len(), 2);
1048 assert!(cfg.workspaces[0].pinned);
1049 assert!(cfg.workspaces[1].exclude);
1050 }
1051
1052 #[test]
1057 fn u2_defaults_match_spec() {
1058 let cfg = DaemonConfig::default();
1059 assert_eq!(
1060 cfg.auto_start_ready_timeout_secs, 10,
1061 "auto_start_ready_timeout_secs default must be 10"
1062 );
1063 assert_eq!(
1064 cfg.log_keep_rotations, 5,
1065 "log_keep_rotations default must be 5"
1066 );
1067 assert!(
1068 !cfg.install_user_service,
1069 "install_user_service default must be false"
1070 );
1071 }
1072
1073 #[test]
1074 fn u2_auto_start_ready_timeout_env_override() {
1075 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1076 unsafe {
1078 env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "30");
1079 }
1080 let mut cfg = DaemonConfig::default();
1081 let result = cfg.apply_env_overrides();
1082 unsafe {
1084 env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1085 }
1086 result.expect("override ok");
1087 assert_eq!(cfg.auto_start_ready_timeout_secs, 30);
1088 }
1089
1090 #[test]
1091 fn u2_auto_start_ready_timeout_env_override_rejects_malformed() {
1092 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1093 unsafe {
1095 env::set_var(ENV_AUTO_START_READY_TIMEOUT_SECS, "not-a-number");
1096 }
1097 let mut cfg = DaemonConfig::default();
1098 let err = cfg.apply_env_overrides();
1099 unsafe {
1101 env::remove_var(ENV_AUTO_START_READY_TIMEOUT_SECS);
1102 }
1103 let err = err.expect_err("malformed value must fail");
1104 match err {
1105 DaemonError::Config { path, .. } => {
1106 assert_eq!(path, Path::new(ENV_AUTO_START_READY_TIMEOUT_SECS));
1107 }
1108 other => panic!("expected Config error, got {other:?}"),
1109 }
1110 }
1111
1112 #[test]
1113 fn u2_log_keep_rotations_env_override() {
1114 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1115 unsafe {
1117 env::set_var(ENV_LOG_KEEP_ROTATIONS, "20");
1118 }
1119 let mut cfg = DaemonConfig::default();
1120 let result = cfg.apply_env_overrides();
1121 unsafe {
1123 env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1124 }
1125 result.expect("override ok");
1126 assert_eq!(cfg.log_keep_rotations, 20);
1127 }
1128
1129 #[test]
1130 fn u2_log_keep_rotations_env_override_rejects_malformed() {
1131 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1132 unsafe {
1134 env::set_var(ENV_LOG_KEEP_ROTATIONS, "bad");
1135 }
1136 let mut cfg = DaemonConfig::default();
1137 let err = cfg.apply_env_overrides();
1138 unsafe {
1140 env::remove_var(ENV_LOG_KEEP_ROTATIONS);
1141 }
1142 let err = err.expect_err("malformed value must fail");
1143 match err {
1144 DaemonError::Config { path, .. } => {
1145 assert_eq!(path, Path::new(ENV_LOG_KEEP_ROTATIONS));
1146 }
1147 other => panic!("expected Config error, got {other:?}"),
1148 }
1149 }
1150
1151 #[test]
1152 fn u2_validate_auto_start_ready_timeout_range() {
1153 let zero = DaemonConfig {
1155 auto_start_ready_timeout_secs: 0,
1156 ..DaemonConfig::default()
1157 };
1158 assert!(zero.validate().is_err(), "0 must be rejected");
1159
1160 let over = DaemonConfig {
1162 auto_start_ready_timeout_secs: 61,
1163 ..DaemonConfig::default()
1164 };
1165 assert!(over.validate().is_err(), "61 must be rejected");
1166
1167 let min = DaemonConfig {
1169 auto_start_ready_timeout_secs: 1,
1170 ..DaemonConfig::default()
1171 };
1172 assert!(min.validate().is_ok(), "1 must be valid");
1173
1174 let max = DaemonConfig {
1175 auto_start_ready_timeout_secs: 60,
1176 ..DaemonConfig::default()
1177 };
1178 assert!(max.validate().is_ok(), "60 must be valid");
1179 }
1180
1181 #[test]
1182 fn u2_validate_log_keep_rotations_range() {
1183 let zero = DaemonConfig {
1185 log_keep_rotations: 0,
1186 ..DaemonConfig::default()
1187 };
1188 assert!(zero.validate().is_err(), "0 must be rejected");
1189
1190 let over = DaemonConfig {
1192 log_keep_rotations: 101,
1193 ..DaemonConfig::default()
1194 };
1195 assert!(over.validate().is_err(), "101 must be rejected");
1196
1197 let min = DaemonConfig {
1199 log_keep_rotations: 1,
1200 ..DaemonConfig::default()
1201 };
1202 assert!(min.validate().is_ok(), "1 must be valid");
1203
1204 let max = DaemonConfig {
1205 log_keep_rotations: 100,
1206 ..DaemonConfig::default()
1207 };
1208 assert!(max.validate().is_ok(), "100 must be valid");
1209 }
1210
1211 #[test]
1212 fn u2_from_toml_str_round_trip_new_fields() {
1213 let text = r#"
1214 auto_start_ready_timeout_secs = 45
1215 log_keep_rotations = 10
1216 install_user_service = true
1217 "#;
1218 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1219 assert_eq!(cfg.auto_start_ready_timeout_secs, 45);
1220 assert_eq!(cfg.log_keep_rotations, 10);
1221 assert!(cfg.install_user_service);
1222 }
1223
1224 #[test]
1225 fn u2_from_toml_str_new_fields_default_when_absent() {
1226 let text = r"memory_limit_mb = 1024";
1228 let cfg = DaemonConfig::from_toml_str(text).expect("parse");
1229 assert_eq!(
1230 cfg.auto_start_ready_timeout_secs,
1231 DEFAULT_AUTO_START_READY_TIMEOUT_SECS
1232 );
1233 assert_eq!(cfg.log_keep_rotations, DEFAULT_LOG_KEEP_ROTATIONS);
1234 assert!(!cfg.install_user_service);
1235 }
1236
1237 #[test]
1238 fn u2_install_user_service_defaults_false_and_is_tolerated_by_validate() {
1239 let with_true = DaemonConfig {
1242 install_user_service: true,
1243 ..DaemonConfig::default()
1244 };
1245 assert!(
1246 with_true.validate().is_ok(),
1247 "install_user_service=true must pass validate"
1248 );
1249 let with_false = DaemonConfig {
1250 install_user_service: false,
1251 ..DaemonConfig::default()
1252 };
1253 assert!(
1254 with_false.validate().is_ok(),
1255 "install_user_service=false must pass validate"
1256 );
1257 }
1258}