use crate::util::{err, Error, Result};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
pub const SETTINGS_ENV: &str = "RIGHTKIT_QA_SETTINGS";
pub const SEARCH_PATHS: &[&str] = &[
".cache/qa/settings.env",
".cache/e2e/settings.env",
"rightkit-qa.settings.env",
];
#[derive(Debug, Clone, Default)]
pub struct Settings {
values: BTreeMap<String, String>,
source: Option<PathBuf>,
use_env: bool,
}
impl Settings {
pub fn env_only() -> Self {
Self {
use_env: true,
..Self::default()
}
}
pub fn from_pairs<K: Into<String>, V: Into<String>>(
pairs: impl IntoIterator<Item = (K, V)>,
) -> Self {
Self {
values: pairs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
source: None,
use_env: false,
}
}
pub fn parse(text: &str) -> Result<Self> {
let mut values = BTreeMap::new();
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((k, v)) = line.split_once('=') else {
return err(format!(
"settings line {}: expected KEY=VALUE, got '{raw}'",
i + 1
));
};
let key = k.trim();
if key.is_empty() || key.contains(char::is_whitespace) {
return err(format!("settings line {}: invalid key '{k}'", i + 1));
}
values.insert(key.to_string(), unquote(v.trim()).to_string());
}
Ok(Self {
values,
source: None,
use_env: true,
})
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.map_err(|e| Error(format!("settings {}: {e}", path.display())))?;
let mut s = Self::parse(&text).map_err(|e| Error(format!("{}: {e}", path.display())))?;
s.source = Some(path.to_path_buf());
Ok(s)
}
pub fn locate(root: &Path) -> Result<Option<PathBuf>> {
if let Some(p) = std::env::var_os(SETTINGS_ENV).filter(|v| !v.is_empty()) {
let p = PathBuf::from(p);
if !p.is_file() {
return err(format!(
"{SETTINGS_ENV} names {} but no such file exists",
p.display()
));
}
return Ok(Some(p));
}
Ok(SEARCH_PATHS
.iter()
.map(|rel| root.join(rel))
.find(|p| p.is_file()))
}
pub fn discover(root: &Path) -> Result<Self> {
match Self::locate(root)? {
Some(p) => Self::load(&p),
None => Ok(Self::env_only()),
}
}
pub fn source(&self) -> Option<&Path> {
self.source.as_deref()
}
pub fn with_env_override(mut self, on: bool) -> Self {
self.use_env = on;
self
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.values.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<String> {
if self.use_env {
if let Ok(v) = std::env::var(key) {
if !v.is_empty() {
return Some(v);
}
}
}
self.values.get(key).filter(|v| !v.is_empty()).cloned()
}
pub fn get_or(&self, key: &str, default: &str) -> String {
self.get(key).unwrap_or_else(|| default.to_string())
}
pub fn require(&self, key: &str) -> Result<String> {
self.get(key).ok_or_else(|| {
Error(format!(
"missing setting {key} ({})",
self.source
.as_ref()
.map(|p| format!("settings file {}", p.display()))
.unwrap_or_else(|| "no settings file".into())
))
})
}
pub fn parse_value<T: FromStr>(&self, key: &str) -> Result<Option<T>>
where
T::Err: std::fmt::Display,
{
match self.get(key) {
None => Ok(None),
Some(v) => v
.parse::<T>()
.map(Some)
.map_err(|e| Error(format!("setting {key}='{v}': {e}"))),
}
}
pub fn get_bool(&self, key: &str) -> Result<Option<bool>> {
match self.get(key) {
None => Ok(None),
Some(v) => match v.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(Some(true)),
"0" | "false" | "no" | "off" => Ok(Some(false)),
_ => err(format!("setting {key}='{v}' is not a boolean")),
},
}
}
pub fn get_u64(&self, key: &str) -> Result<Option<u64>> {
self.parse_value(key)
}
pub fn get_f64(&self, key: &str) -> Result<Option<f64>> {
self.parse_value(key)
}
pub fn get_list(&self, key: &str) -> Vec<String> {
self.get(key)
.map(|v| {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default()
}
pub fn get_path(&self, key: &str, base: &Path) -> Option<PathBuf> {
self.get(key).map(|v| {
let p = PathBuf::from(v);
if p.is_absolute() {
p
} else {
base.join(p)
}
})
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.values.keys().map(String::as_str)
}
}
fn unquote(v: &str) -> &str {
for q in ['"', '\''] {
if v.len() >= 2 && v.starts_with(q) && v.ends_with(q) {
return &v[1..v.len() - 1];
}
}
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_comments_export_quotes_and_overrides() {
let s = Settings::parse("# knobs\n\nexport A=1\nB = \"two words\"\nC='x=y'\nA=3\nEMPTY=\n")
.unwrap()
.with_env_override(false);
assert_eq!(s.get("A").as_deref(), Some("3"));
assert_eq!(s.get("B").as_deref(), Some("two words"));
assert_eq!(s.get("C").as_deref(), Some("x=y"));
assert_eq!(s.get("EMPTY"), None);
assert!(Settings::parse("no equals sign").is_err());
assert!(Settings::parse("BAD KEY=1").is_err());
}
#[test]
fn typed_getters_reject_malformed_values() {
let s = Settings::from_pairs([
("ON", "Yes"),
("OFF", "0"),
("N", "42"),
("F", "-16.5"),
("BAD", "maybe"),
("L", " fast, paid ,,full "),
("P", "rel/dir"),
]);
assert_eq!(s.get_bool("ON").unwrap(), Some(true));
assert_eq!(s.get_bool("OFF").unwrap(), Some(false));
assert_eq!(s.get_bool("MISSING").unwrap(), None);
assert!(s.get_bool("BAD").is_err());
assert_eq!(s.get_u64("N").unwrap(), Some(42));
assert!(s.get_u64("F").is_err());
assert_eq!(s.get_f64("F").unwrap(), Some(-16.5));
assert_eq!(s.get_list("L"), vec!["fast", "paid", "full"]);
assert_eq!(
s.get_path("P", Path::new("/root")),
Some(PathBuf::from("/root/rel/dir"))
);
assert!(s.require("MISSING").unwrap_err().0.contains("MISSING"));
assert_eq!(s.get_or("MISSING", "d"), "d");
}
#[test]
fn discover_follows_search_order() {
if std::env::var_os(SETTINGS_ENV).is_some() {
return;
}
let root = std::env::temp_dir().join(format!("rkqa-settings-{}", crate::util::new_id()));
std::fs::create_dir_all(root.join(".cache/e2e")).unwrap();
std::fs::create_dir_all(root.join(".cache/qa")).unwrap();
assert!(Settings::discover(&root).unwrap().source().is_none());
std::fs::write(root.join("rightkit-qa.settings.env"), "WHO=root\n").unwrap();
assert_eq!(
Settings::discover(&root).unwrap().get("WHO").as_deref(),
Some("root")
);
std::fs::write(root.join(".cache/e2e/settings.env"), "WHO=e2e\n").unwrap();
assert_eq!(
Settings::discover(&root).unwrap().get("WHO").as_deref(),
Some("e2e")
);
std::fs::write(root.join(".cache/qa/settings.env"), "WHO=qa\n").unwrap();
let s = Settings::discover(&root).unwrap();
assert_eq!(s.get("WHO").as_deref(), Some("qa"));
assert_eq!(
s.source(),
Some(root.join(".cache/qa/settings.env").as_path())
);
let _ = std::fs::remove_dir_all(root);
}
}