use std::path::PathBuf;
use serde::Deserialize;
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ConfigFile {
pub engine_url: Option<String>,
pub api_key: Option<String>,
pub api_key_file: Option<PathBuf>,
pub namespace: Option<String>,
pub output: Option<String>,
}
pub fn load(explicit: Option<&str>) -> Result<Option<ConfigFile>, String> {
if let Some(path) = explicit {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("reading config file {path}: {e}"))?;
let cfg: ConfigFile = serde_yml::from_str(&text)
.map_err(|e| format!("parsing {path}: {e}"))?;
return Ok(Some(cfg));
}
for path in discovery_paths() {
if path.exists() {
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("reading {}: {e}", path.display()))?;
let cfg: ConfigFile = serde_yml::from_str(&text)
.map_err(|e| format!("parsing {}: {e}", path.display()))?;
return Ok(Some(cfg));
}
}
Ok(None)
}
fn discovery_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
paths.push(PathBuf::from(xdg).join("assay/config.yaml"));
}
if let Ok(home) = std::env::var("HOME") {
paths.push(PathBuf::from(home).join(".config/assay/config.yaml"));
}
paths.push(PathBuf::from("/etc/assay/config.yaml"));
paths
}
pub fn resolve_api_key(cfg: &ConfigFile) -> Result<Option<String>, String> {
if let Some(ref path) = cfg.api_key_file {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("reading api_key_file {}: {e}", path.display()))?;
return Ok(Some(content.trim().to_string()));
}
Ok(cfg.api_key.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_config_file_is_ok() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nope.yaml");
let err = load(Some(path.to_str().unwrap())).unwrap_err();
assert!(err.contains("reading"));
}
#[test]
fn loads_explicit_path() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.yaml");
std::fs::write(
&path,
"engine_url: https://example.com\nnamespace: custom\noutput: json\n",
)
.unwrap();
let cfg = load(Some(path.to_str().unwrap())).unwrap().unwrap();
assert_eq!(cfg.engine_url.as_deref(), Some("https://example.com"));
assert_eq!(cfg.namespace.as_deref(), Some("custom"));
assert_eq!(cfg.output.as_deref(), Some("json"));
}
#[test]
fn api_key_file_wins_over_literal() {
let tmp = tempfile::tempdir().unwrap();
let key_path = tmp.path().join("key.txt");
std::fs::write(&key_path, " secret-from-file\n").unwrap();
let cfg = ConfigFile {
api_key: Some("from-literal".into()),
api_key_file: Some(key_path),
..ConfigFile::default()
};
let resolved = resolve_api_key(&cfg).unwrap().unwrap();
assert_eq!(resolved, "secret-from-file");
}
#[test]
fn api_key_literal_when_no_file() {
let cfg = ConfigFile {
api_key: Some("only-literal".into()),
..ConfigFile::default()
};
let resolved = resolve_api_key(&cfg).unwrap().unwrap();
assert_eq!(resolved, "only-literal");
}
#[test]
fn no_api_key_returns_none() {
let cfg = ConfigFile::default();
assert!(resolve_api_key(&cfg).unwrap().is_none());
}
#[test]
fn unknown_keys_rejected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.yaml");
std::fs::write(&path, "engine_url: x\nwhat_is_this: 5\n").unwrap();
assert!(load(Some(path.to_str().unwrap())).is_err());
}
}