Skip to main content

rgx/
config.rs

1//! User config, loaded from a TOML file.
2//!
3//! Location: `$RGX_CONFIG` (verbatim), else `$XDG_CONFIG_HOME/rgx/config.toml` (`%APPDATA%` on
4//! Windows), else `~/.config/rgx/config.toml`. A missing or unreadable file yields the default
5//! config; a present but malformed (or invalid) file is a hard error so typos don't silently fall
6//! back to defaults.
7
8use serde::Deserialize;
9use std::ffi::OsString;
10use std::path::PathBuf;
11use std::sync::OnceLock;
12use std::time::Duration;
13
14use crate::paths::{non_empty, win_var};
15
16/// A cold build faster than this is cheap to redo, so the index is kept RAM-only (no snapshot).
17pub const DEFAULT_PERSIST_THRESHOLD_MS: u64 = 1000;
18
19/// The daemon exits after this long with no client request, freeing its RAM; the next search
20/// respawns it. Zero or negative disables the timeout (stay resident forever).
21pub const DEFAULT_IDLE_TIMEOUT_SECS: i64 = 3600;
22
23#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
24#[serde(default, deny_unknown_fields)]
25pub struct Config {
26    /// Base directory for the rebuildable cache (index + socket). `$RGX_CACHE_DIR` overrides this.
27    pub cache_dir: Option<PathBuf>,
28    /// Persist the index to disk only if the cold build took at least this many milliseconds; below
29    /// it the index stays RAM-only and is rebuilt on each daemon start. `0` always persists.
30    pub persist_threshold_ms: Option<u64>,
31    /// Exit the daemon after this many seconds with no client request. Zero or negative keeps it
32    /// resident forever.
33    pub idle_timeout_secs: Option<i64>,
34}
35
36impl Config {
37    /// Minimum cold-build time that earns an on-disk snapshot.
38    pub fn persist_threshold(&self) -> Duration {
39        Duration::from_millis(
40            self.persist_threshold_ms
41                .unwrap_or(DEFAULT_PERSIST_THRESHOLD_MS),
42        )
43    }
44
45    /// Idle period after which the daemon exits, or `None` when disabled (zero or negative).
46    pub fn idle_timeout(&self) -> Option<Duration> {
47        match self.idle_timeout_secs.unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS) {
48            secs if secs <= 0 => None,
49            secs => Some(Duration::from_secs(secs as u64)),
50        }
51    }
52
53    /// The process-wide config, loaded once from disk.
54    pub fn get() -> &'static Config {
55        static CONFIG: OnceLock<Config> = OnceLock::new();
56        CONFIG.get_or_init(load)
57    }
58}
59
60fn load() -> Config {
61    let path = config_path(
62        non_empty(std::env::var_os("RGX_CONFIG")),
63        non_empty(std::env::var_os("XDG_CONFIG_HOME").or_else(win_var("APPDATA"))),
64        non_empty(std::env::var_os("HOME").or_else(win_var("USERPROFILE"))),
65    );
66    let Some(path) = path else {
67        return Config::default();
68    };
69    let text = match std::fs::read_to_string(&path) {
70        Ok(text) => text,
71        Err(_) => return Config::default(),
72    };
73    let result = parse(&text).map_err(|e| e.to_string()).and_then(validate);
74    result.unwrap_or_else(|e| {
75        eprintln!("rgx: invalid config at {}: {e}", path.display());
76        std::process::exit(2);
77    })
78}
79
80/// Reject values that would misbehave downstream. `cache_dir` must be absolute: a relative (or
81/// empty) base resolves against the cwd, which would put rgx's state inside the indexed repo.
82fn validate(cfg: Config) -> Result<Config, String> {
83    if let Some(dir) = &cfg.cache_dir
84        && !dir.is_absolute()
85    {
86        return Err(format!("cache_dir must be an absolute path, got {:?}", dir));
87    }
88    Ok(cfg)
89}
90
91/// Where the config file lives, given the relevant environment.
92pub fn config_path(
93    rgx_config: Option<OsString>,
94    xdg_config_home: Option<OsString>,
95    home: Option<OsString>,
96) -> Option<PathBuf> {
97    if let Some(p) = rgx_config {
98        return Some(PathBuf::from(p));
99    }
100    xdg_config_home
101        .map(PathBuf::from)
102        .or_else(|| home.map(|h| PathBuf::from(h).join(".config")))
103        .map(|base| base.join("rgx").join("config.toml"))
104}
105
106/// Parse config text, rejecting unknown keys.
107pub fn parse(text: &str) -> Result<Config, toml::de::Error> {
108    toml::from_str(text)
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn os(s: &str) -> Option<OsString> {
116        Some(OsString::from(s))
117    }
118
119    #[test]
120    fn config_path_precedence() {
121        assert_eq!(
122            config_path(os("/etc/rgx.toml"), os("/xdg"), os("/home/u")),
123            Some(PathBuf::from("/etc/rgx.toml"))
124        );
125        assert_eq!(
126            config_path(None, os("/xdg"), os("/home/u")),
127            Some(PathBuf::from("/xdg/rgx/config.toml"))
128        );
129        assert_eq!(
130            config_path(None, None, os("/home/u")),
131            Some(PathBuf::from("/home/u/.config/rgx/config.toml"))
132        );
133        assert_eq!(config_path(None, None, None), None);
134    }
135
136    #[test]
137    fn parses_cache_dir() {
138        let cfg = parse("cache_dir = \"/tmp/rgx-cache\"").unwrap();
139        assert_eq!(cfg.cache_dir, Some(PathBuf::from("/tmp/rgx-cache")));
140    }
141
142    #[test]
143    fn empty_config_is_default() {
144        assert_eq!(parse("").unwrap(), Config::default());
145    }
146
147    #[test]
148    fn unknown_key_is_error() {
149        assert!(parse("nope = 1").is_err());
150    }
151
152    #[test]
153    fn threshold_and_idle_defaults_and_overrides() {
154        let d = Config::default();
155        assert_eq!(
156            d.persist_threshold(),
157            Duration::from_millis(DEFAULT_PERSIST_THRESHOLD_MS)
158        );
159        assert_eq!(
160            d.idle_timeout(),
161            Some(Duration::from_secs(DEFAULT_IDLE_TIMEOUT_SECS as u64))
162        );
163
164        // Zero and negative both keep the daemon resident forever.
165        assert_eq!(parse("idle_timeout_secs = 0").unwrap().idle_timeout(), None);
166        assert_eq!(
167            parse("idle_timeout_secs = -1").unwrap().idle_timeout(),
168            None
169        );
170
171        let c = parse("persist_threshold_ms = 2500\nidle_timeout_secs = 60").unwrap();
172        assert_eq!(c.persist_threshold(), Duration::from_millis(2500));
173        assert_eq!(c.idle_timeout(), Some(Duration::from_secs(60)));
174    }
175
176    #[test]
177    fn validate_rejects_non_absolute_cache_dir() {
178        let abs_dir = if cfg!(windows) { "C:/tmp/c" } else { "/tmp/c" };
179        let abs = parse(&format!("cache_dir = \"{abs_dir}\"")).unwrap();
180        assert!(validate(abs).is_ok());
181        let rel = parse("cache_dir = \"rel/c\"").unwrap();
182        assert!(validate(rel).is_err());
183        let empty = parse("cache_dir = \"\"").unwrap();
184        assert!(validate(empty).is_err());
185        assert!(validate(Config::default()).is_ok());
186    }
187}