1use 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
16pub const DEFAULT_PERSIST_THRESHOLD_MS: u64 = 1000;
18
19pub 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 pub cache_dir: Option<PathBuf>,
28 pub persist_threshold_ms: Option<u64>,
31 pub idle_timeout_secs: Option<i64>,
34}
35
36impl Config {
37 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 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 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
80fn 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
91pub 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
106pub 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 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}