Skip to main content

rightkit_qa/
settings.rs

1//! Test settings that survive `rightkit cargo test`.
2//!
3//! Managed cargo (`rightkit cargo ...`) starts the test binary with a scrubbed environment and
4//! refuses nested managed builds, so a Rust integration test cannot rely on `MY_APP_E2E_TIER=paid
5//! rightkit cargo test`. The supported pattern is:
6//!
7//! * **Tiers are cargo features** of the test crate (`--features full,paid`), read at compile time
8//!   with `cfg!(feature = "...")` and handed to [`crate::harness::Harness::with_feature_tiers`].
9//! * **Knobs are a settings file** (`KEY=VALUE` lines) read at run time through [`Settings`].
10//! * **Binaries are built first** by a separate `rightkit cargo build -p <bin>`, then located
11//!   next to the test executable's `deps/` directory (never by building from inside a test).
12//!
13//! # File format
14//!
15//! One `KEY=VALUE` per line. Blank lines and lines starting with `#` are ignored, an optional
16//! leading `export ` is stripped, and a value wrapped in matching single or double quotes is
17//! unquoted. Later lines override earlier ones. Keys are case-sensitive.
18//!
19//! # Search order ([`Settings::discover`])
20//!
21//! The first file that exists wins; files are never merged:
22//!
23//! 1. the path in the `RIGHTKIT_QA_SETTINGS` environment variable (an error if it is set but the
24//!    file does not exist, so a typo never silently runs the defaults),
25//! 2. `<root>/.cache/qa/settings.env`,
26//! 3. `<root>/.cache/e2e/settings.env` (the location GenRight's e2e suite already uses),
27//! 4. `<root>/rightkit-qa.settings.env`.
28//!
29//! # Lookup order ([`Settings::get`])
30//!
31//! A non-empty process environment variable wins over the file, so a direct (unmanaged) run can
32//! still override a single knob; under `rightkit cargo` the file is what reaches the test.
33use crate::util::{err, Error, Result};
34use std::collections::BTreeMap;
35use std::path::{Path, PathBuf};
36use std::str::FromStr;
37
38/// Environment variable naming an explicit settings file (search step 1).
39pub const SETTINGS_ENV: &str = "RIGHTKIT_QA_SETTINGS";
40
41/// Relative locations searched under the app root, in order (search steps 2-4).
42pub const SEARCH_PATHS: &[&str] = &[
43    ".cache/qa/settings.env",
44    ".cache/e2e/settings.env",
45    "rightkit-qa.settings.env",
46];
47
48/// Parsed `KEY=VALUE` settings plus where they came from.
49#[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    /// No file; lookups consult the process environment only.
58    pub fn env_only() -> Self {
59        Self {
60            use_env: true,
61            ..Self::default()
62        }
63    }
64
65    /// Fixed values with no environment fallback (tests, generated configs).
66    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    /// Parse settings text (see the module docs for the format).
80    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    /// Load one file.
108    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    /// The file [`Settings::discover`] would read for `root`, if any.
117    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    /// Load the first settings file in the documented search order; no file is not an error.
135    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    /// The file these settings were read from.
143    pub fn source(&self) -> Option<&Path> {
144        self.source.as_deref()
145    }
146
147    /// Enable or disable the process-environment override.
148    pub fn with_env_override(mut self, on: bool) -> Self {
149        self.use_env = on;
150        self
151    }
152
153    /// Set or replace one value (file-level; the environment still wins when enabled).
154    pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
155        self.values.insert(key.into(), value.into());
156    }
157
158    /// Non-empty value for `key`: process environment first (when enabled), then the file.
159    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    /// Present or an error naming the key and the file.
175    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    /// Parsed value; `Ok(None)` when absent, an error when present but malformed.
188    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    /// `1/true/yes/on` or `0/false/no/off` (case-insensitive); anything else is an error.
202    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    /// Comma-separated list, items trimmed, empties dropped.
222    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    /// A path; a relative value resolves against `base` (usually the app root).
235    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    /// File-level keys (environment overrides are not listed).
247    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        // RIGHTKIT_QA_SETTINGS is not set under managed cargo; a direct run that sets it would
308        // legitimately change the answer, so only assert when it is absent.
309        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}