1use crate::util::{err, Error, Result};
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36use std::str::FromStr;
37
38pub const SETTINGS_ENV: &str = "RIGHTKIT_QA_SETTINGS";
40
41pub const SEARCH_PATHS: &[&str] = &[
43 ".cache/qa/settings.env",
44 ".cache/e2e/settings.env",
45 "rightkit-qa.settings.env",
46];
47
48#[derive(Debug, Clone, Default)]
50pub struct Settings {
51 values: BTreeMap<String, String>,
52 source: Option<PathBuf>,
53 use_env: bool,
54}
55
56impl Settings {
57 pub fn env_only() -> Self {
59 Self {
60 use_env: true,
61 ..Self::default()
62 }
63 }
64
65 pub fn from_pairs<K: Into<String>, V: Into<String>>(
67 pairs: impl IntoIterator<Item = (K, V)>,
68 ) -> Self {
69 Self {
70 values: pairs
71 .into_iter()
72 .map(|(k, v)| (k.into(), v.into()))
73 .collect(),
74 source: None,
75 use_env: false,
76 }
77 }
78
79 pub fn parse(text: &str) -> Result<Self> {
81 let mut values = BTreeMap::new();
82 for (i, raw) in text.lines().enumerate() {
83 let line = raw.trim();
84 if line.is_empty() || line.starts_with('#') {
85 continue;
86 }
87 let line = line.strip_prefix("export ").unwrap_or(line);
88 let Some((k, v)) = line.split_once('=') else {
89 return err(format!(
90 "settings line {}: expected KEY=VALUE, got '{raw}'",
91 i + 1
92 ));
93 };
94 let key = k.trim();
95 if key.is_empty() || key.contains(char::is_whitespace) {
96 return err(format!("settings line {}: invalid key '{k}'", i + 1));
97 }
98 values.insert(key.to_string(), unquote(v.trim()).to_string());
99 }
100 Ok(Self {
101 values,
102 source: None,
103 use_env: true,
104 })
105 }
106
107 pub fn load(path: &Path) -> Result<Self> {
109 let text = std::fs::read_to_string(path)
110 .map_err(|e| Error(format!("settings {}: {e}", path.display())))?;
111 let mut s = Self::parse(&text).map_err(|e| Error(format!("{}: {e}", path.display())))?;
112 s.source = Some(path.to_path_buf());
113 Ok(s)
114 }
115
116 pub fn locate(root: &Path) -> Result<Option<PathBuf>> {
118 if let Some(p) = std::env::var_os(SETTINGS_ENV).filter(|v| !v.is_empty()) {
119 let p = PathBuf::from(p);
120 if !p.is_file() {
121 return err(format!(
122 "{SETTINGS_ENV} names {} but no such file exists",
123 p.display()
124 ));
125 }
126 return Ok(Some(p));
127 }
128 Ok(SEARCH_PATHS
129 .iter()
130 .map(|rel| root.join(rel))
131 .find(|p| p.is_file()))
132 }
133
134 pub fn discover(root: &Path) -> Result<Self> {
136 match Self::locate(root)? {
137 Some(p) => Self::load(&p),
138 None => Ok(Self::env_only()),
139 }
140 }
141
142 pub fn source(&self) -> Option<&Path> {
144 self.source.as_deref()
145 }
146
147 pub fn with_env_override(mut self, on: bool) -> Self {
149 self.use_env = on;
150 self
151 }
152
153 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
155 self.values.insert(key.into(), value.into());
156 }
157
158 pub fn get(&self, key: &str) -> Option<String> {
160 if self.use_env {
161 if let Ok(v) = std::env::var(key) {
162 if !v.is_empty() {
163 return Some(v);
164 }
165 }
166 }
167 self.values.get(key).filter(|v| !v.is_empty()).cloned()
168 }
169
170 pub fn get_or(&self, key: &str, default: &str) -> String {
171 self.get(key).unwrap_or_else(|| default.to_string())
172 }
173
174 pub fn require(&self, key: &str) -> Result<String> {
176 self.get(key).ok_or_else(|| {
177 Error(format!(
178 "missing setting {key} ({})",
179 self.source
180 .as_ref()
181 .map(|p| format!("settings file {}", p.display()))
182 .unwrap_or_else(|| "no settings file".into())
183 ))
184 })
185 }
186
187 pub fn parse_value<T: FromStr>(&self, key: &str) -> Result<Option<T>>
189 where
190 T::Err: std::fmt::Display,
191 {
192 match self.get(key) {
193 None => Ok(None),
194 Some(v) => v
195 .parse::<T>()
196 .map(Some)
197 .map_err(|e| Error(format!("setting {key}='{v}': {e}"))),
198 }
199 }
200
201 pub fn get_bool(&self, key: &str) -> Result<Option<bool>> {
203 match self.get(key) {
204 None => Ok(None),
205 Some(v) => match v.to_ascii_lowercase().as_str() {
206 "1" | "true" | "yes" | "on" => Ok(Some(true)),
207 "0" | "false" | "no" | "off" => Ok(Some(false)),
208 _ => err(format!("setting {key}='{v}' is not a boolean")),
209 },
210 }
211 }
212
213 pub fn get_u64(&self, key: &str) -> Result<Option<u64>> {
214 self.parse_value(key)
215 }
216
217 pub fn get_f64(&self, key: &str) -> Result<Option<f64>> {
218 self.parse_value(key)
219 }
220
221 pub fn get_list(&self, key: &str) -> Vec<String> {
223 self.get(key)
224 .map(|v| {
225 v.split(',')
226 .map(str::trim)
227 .filter(|s| !s.is_empty())
228 .map(String::from)
229 .collect()
230 })
231 .unwrap_or_default()
232 }
233
234 pub fn get_path(&self, key: &str, base: &Path) -> Option<PathBuf> {
236 self.get(key).map(|v| {
237 let p = PathBuf::from(v);
238 if p.is_absolute() {
239 p
240 } else {
241 base.join(p)
242 }
243 })
244 }
245
246 pub fn keys(&self) -> impl Iterator<Item = &str> {
248 self.values.keys().map(String::as_str)
249 }
250}
251
252fn unquote(v: &str) -> &str {
253 for q in ['"', '\''] {
254 if v.len() >= 2 && v.starts_with(q) && v.ends_with(q) {
255 return &v[1..v.len() - 1];
256 }
257 }
258 v
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn parses_comments_export_quotes_and_overrides() {
267 let s = Settings::parse("# knobs\n\nexport A=1\nB = \"two words\"\nC='x=y'\nA=3\nEMPTY=\n")
268 .unwrap()
269 .with_env_override(false);
270 assert_eq!(s.get("A").as_deref(), Some("3"));
271 assert_eq!(s.get("B").as_deref(), Some("two words"));
272 assert_eq!(s.get("C").as_deref(), Some("x=y"));
273 assert_eq!(s.get("EMPTY"), None);
274 assert!(Settings::parse("no equals sign").is_err());
275 assert!(Settings::parse("BAD KEY=1").is_err());
276 }
277
278 #[test]
279 fn typed_getters_reject_malformed_values() {
280 let s = Settings::from_pairs([
281 ("ON", "Yes"),
282 ("OFF", "0"),
283 ("N", "42"),
284 ("F", "-16.5"),
285 ("BAD", "maybe"),
286 ("L", " fast, paid ,,full "),
287 ("P", "rel/dir"),
288 ]);
289 assert_eq!(s.get_bool("ON").unwrap(), Some(true));
290 assert_eq!(s.get_bool("OFF").unwrap(), Some(false));
291 assert_eq!(s.get_bool("MISSING").unwrap(), None);
292 assert!(s.get_bool("BAD").is_err());
293 assert_eq!(s.get_u64("N").unwrap(), Some(42));
294 assert!(s.get_u64("F").is_err());
295 assert_eq!(s.get_f64("F").unwrap(), Some(-16.5));
296 assert_eq!(s.get_list("L"), vec!["fast", "paid", "full"]);
297 assert_eq!(
298 s.get_path("P", Path::new("/root")),
299 Some(PathBuf::from("/root/rel/dir"))
300 );
301 assert!(s.require("MISSING").unwrap_err().0.contains("MISSING"));
302 assert_eq!(s.get_or("MISSING", "d"), "d");
303 }
304
305 #[test]
306 fn discover_follows_search_order() {
307 if std::env::var_os(SETTINGS_ENV).is_some() {
310 return;
311 }
312 let root = std::env::temp_dir().join(format!("rkqa-settings-{}", crate::util::new_id()));
313 std::fs::create_dir_all(root.join(".cache/e2e")).unwrap();
314 std::fs::create_dir_all(root.join(".cache/qa")).unwrap();
315 assert!(Settings::discover(&root).unwrap().source().is_none());
316 std::fs::write(root.join("rightkit-qa.settings.env"), "WHO=root\n").unwrap();
317 assert_eq!(
318 Settings::discover(&root).unwrap().get("WHO").as_deref(),
319 Some("root")
320 );
321 std::fs::write(root.join(".cache/e2e/settings.env"), "WHO=e2e\n").unwrap();
322 assert_eq!(
323 Settings::discover(&root).unwrap().get("WHO").as_deref(),
324 Some("e2e")
325 );
326 std::fs::write(root.join(".cache/qa/settings.env"), "WHO=qa\n").unwrap();
327 let s = Settings::discover(&root).unwrap();
328 assert_eq!(s.get("WHO").as_deref(), Some("qa"));
329 assert_eq!(
330 s.source(),
331 Some(root.join(".cache/qa/settings.env").as_path())
332 );
333 let _ = std::fs::remove_dir_all(root);
334 }
335}