use crate::options::{Options, OptionsLayer};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn default_concurrency() -> usize {
8
}
fn default_timeout() -> u64 {
20
}
#[derive(Debug, Deserialize)]
pub struct Config {
#[serde(default = "default_concurrency")]
pub concurrency: usize,
#[serde(default)]
pub macro_paths: Vec<PathBuf>,
pub paths: Vec<PathBuf>,
pub resources: Resources,
#[serde(default)]
pub default_api: Option<String>,
#[serde(default)]
pub default_db: Option<String>,
#[serde(default)]
pub default_srp: Option<String>,
#[serde(default)]
pub options: OptionsLayer,
#[serde(skip)]
pub effective_options: Options,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
#[serde(skip)]
pub plugin_instances: Vec<InstanceSpec>,
}
#[derive(Debug, Deserialize)]
pub struct Resources {
pub api: BTreeMap<String, ApiConfig>,
#[serde(default)]
pub db: BTreeMap<String, Connection>,
#[serde(default)]
pub srp: BTreeMap<String, SrpConfig>,
#[serde(flatten)]
pub groups: BTreeMap<String, BTreeMap<String, serde_yaml_ng::Value>>,
}
#[derive(Debug, Clone)]
pub struct InstanceSpec {
pub group: String,
pub name: String,
pub config: serde_json::Value,
pub options: Options,
}
#[derive(Debug, Deserialize)]
pub struct ApiConfig {
pub base_url: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub default_headers: BTreeMap<String, String>,
#[serde(default)]
pub options: OptionsLayer,
#[serde(skip)]
pub effective_options: Options,
}
#[derive(Debug, Deserialize)]
pub struct Connection {
pub dsn: String,
#[serde(default)]
pub search_path: Vec<String>,
#[serde(default)]
pub options: OptionsLayer,
#[serde(skip)]
pub effective_options: Options,
}
#[derive(Debug, Deserialize)]
pub struct SrpConfig {
pub variant: String,
#[serde(default)]
pub prime: Option<String>,
#[serde(default)]
pub generator: Option<String>,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub options: OptionsLayer,
#[serde(skip)]
pub effective_options: Options,
}
impl SrpConfig {
pub fn to_params(&self) -> Result<crate::srp::SrpParams> {
use crate::srp::{HashAlg, RFC5054_4096_PRIME_HEX, SrpParams, Variant};
use num_bigint::BigUint;
let variant = match self.variant.as_str() {
"hex-string" => Variant::HexString,
"rfc5054" => Variant::Rfc5054,
other => bail!("unknown SRP variant {other:?}: expected hex-string or rfc5054"),
};
let hash = match self.hash.as_deref().unwrap_or("sha-256") {
"sha-1" => HashAlg::Sha1,
"sha-256" => HashAlg::Sha256,
"sha-512" => HashAlg::Sha512,
other => {
bail!("unknown hash algorithm {other:?}: expected sha-1, sha-256, or sha-512")
}
};
let prime_hex = self.prime.as_deref().unwrap_or(RFC5054_4096_PRIME_HEX);
let prime = BigUint::parse_bytes(prime_hex.as_bytes(), 16)
.ok_or_else(|| anyhow::anyhow!("prime is not a hexadecimal number"))?;
let generator_text = self.generator.as_deref().unwrap_or("5");
let generator = BigUint::parse_bytes(generator_text.as_bytes(), 10).ok_or_else(|| {
anyhow::anyhow!("generator is not a decimal number: {generator_text:?}")
})?;
Ok(SrpParams {
variant,
prime,
generator,
hash,
})
}
}
impl Config {
fn resolve_options(&mut self) -> Result<()> {
let global = Options::default()
.apply(&self.options)
.map_err(anyhow::Error::msg)?;
self.effective_options = global.clone();
for (name, resource) in &mut self.resources.api {
resource.effective_options = global
.apply(&resource.options)
.map_err(|error| anyhow::anyhow!("resources.api.{name}: {error}"))?;
}
for (name, resource) in &mut self.resources.db {
resource.effective_options = global
.apply(&resource.options)
.map_err(|error| anyhow::anyhow!("resources.db.{name}: {error}"))?;
}
for (name, resource) in &mut self.resources.srp {
resource.effective_options = global
.apply(&resource.options)
.map_err(|error| anyhow::anyhow!("resources.srp.{name}: {error}"))?;
}
self.plugin_instances.clear();
for (group, instances) in &self.resources.groups {
for (name, body) in instances {
let where_ = format!("resources.{group}.{name}");
let (config, layer) = split_instance_options(body)
.map_err(|error| anyhow::anyhow!("{where_}: {error}"))?;
let options = global
.apply(&layer)
.map_err(|error| anyhow::anyhow!("{where_}: {error}"))?;
self.plugin_instances.push(InstanceSpec {
group: group.clone(),
name: name.clone(),
config,
options,
});
}
}
Ok(())
}
pub fn resolve_default_api(&self) -> Result<Option<String>> {
resolve_default(
"default_api",
"resources.api",
&self.default_api,
self.resources.api.keys(),
)
}
pub fn resolve_default_db(&self) -> Result<Option<String>> {
resolve_default(
"default_db",
"resources.db",
&self.default_db,
self.resources.db.keys(),
)
}
pub fn resolve_default_srp(&self) -> Result<Option<String>> {
resolve_default(
"default_srp",
"resources.srp",
&self.default_srp,
self.resources.srp.keys(),
)
}
pub fn group_names(&self) -> impl Iterator<Item = &String> {
self.resources.groups.keys()
}
pub fn resolve_default_group(&self, group: &str) -> Result<Option<String>> {
let field = format!("default_{group}");
let explicit = match self.extra.get(&field) {
Some(serde_yaml_ng::Value::String(name)) => Some(name.clone()),
Some(other) => bail!("{field} must be a string, got {other:?}"),
None => None,
};
let empty = BTreeMap::new();
let names = self.resources.groups.get(group).unwrap_or(&empty).keys();
resolve_default(&field, &format!("resources.{group}"), &explicit, names)
}
pub fn check_group_defaults(&self) -> Result<()> {
for key in self.extra.keys() {
let Some(group) = key.strip_prefix("default_") else {
continue;
};
if matches!(group, "api" | "db" | "srp") || self.resources.groups.contains_key(group) {
continue;
}
let declared: Vec<&str> = self.resources.groups.keys().map(String::as_str).collect();
bail!(
"{key} names the resource group {group:?}, but this config declares no resources.{group} (groups declared here: {})",
if declared.is_empty() {
"none".to_string()
} else {
declared.join(", ")
}
);
}
Ok(())
}
}
fn resolve_default<'a>(
field: &str,
section: &str,
explicit: &Option<String>,
mut names: impl Iterator<Item = &'a String> + Clone,
) -> Result<Option<String>> {
if let Some(name) = explicit {
if names.clone().any(|n| n == name) {
return Ok(Some(name.clone()));
}
bail!("{field} references an undeclared resource {name:?} (not in {section})");
}
let first = names.next();
match (first, names.next()) {
(None, _) => Ok(None),
(Some(only), None) => Ok(Some(only.clone())),
(Some(_), Some(_)) => bail!(
"{field} is not set, and {section} declares several resources — specify which one is the default"
),
}
}
fn split_instance_options(
body: &serde_yaml_ng::Value,
) -> std::result::Result<(serde_json::Value, OptionsLayer), String> {
let serde_yaml_ng::Value::Mapping(mapping) = body else {
return Err("an instance must be a mapping".to_string());
};
let mut mapping = mapping.clone();
let layer = match mapping.remove("options") {
Some(value) => serde_yaml_ng::from_value::<OptionsLayer>(value)
.map_err(|error| format!("invalid options: {error}"))?,
None => OptionsLayer::default(),
};
let config = serde_json::to_value(serde_yaml_ng::Value::Mapping(mapping))
.map_err(|error| format!("instance config is not representable as JSON: {error}"))?;
Ok((config, layer))
}
fn load_env_file(path: &Path, map: &mut BTreeMap<String, String>) -> Result<()> {
match dotenvy::from_path_iter(path) {
Ok(iter) => {
for item in iter {
let (k, v) =
item.with_context(|| format!("failed to parse {}", path.display()))?;
map.insert(k, v);
}
Ok(())
}
Err(dotenvy::Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
}
}
fn resolve_app_env(cli_env: Option<&str>, base_map: &BTreeMap<String, String>) -> String {
resolve_app_env_with_process_env(cli_env, std::env::var("APP_ENV").ok(), base_map)
}
fn resolve_app_env_with_process_env(
cli_env: Option<&str>,
process_env: Option<String>,
base_map: &BTreeMap<String, String>,
) -> String {
cli_env
.map(str::to_string)
.or(process_env)
.or_else(|| base_map.get("APP_ENV").cloned())
.unwrap_or_else(|| "dev".to_string())
}
fn load_env_layers(config_dir: &Path, app_env: &str) -> Result<BTreeMap<String, String>> {
let mut map = BTreeMap::new();
load_env_file(&config_dir.join(".env"), &mut map)?;
load_env_file(&config_dir.join(".env.local"), &mut map)?;
load_env_file(&config_dir.join(format!(".env.{app_env}")), &mut map)?;
load_env_file(&config_dir.join(format!(".env.{app_env}.local")), &mut map)?;
Ok(map)
}
fn expand_env(src: &str, env_map: &BTreeMap<String, String>) -> Result<String> {
let re = regex::Regex::new(
r"\$\$|\$\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)(?:(?P<op>:-|-|:\?|\?|:\+|\+)(?P<arg>[^}]*))?\}|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)",
)
.expect("constant regex");
let lookup = |name: &str| -> Option<String> {
std::env::var(name)
.ok()
.or_else(|| env_map.get(name).cloned())
};
let mut out = String::with_capacity(src.len());
let mut last = 0usize;
for c in re.captures_iter(src) {
let m = c.get(0).expect("group 0 always exists");
out.push_str(&src[last..m.start()]);
last = m.end();
if m.as_str() == "$$" {
out.push('$');
continue;
}
let name = c
.name("name")
.or_else(|| c.name("bare"))
.expect("either name or bare — otherwise the regex would not have matched")
.as_str();
let op = c.name("op").map(|o| o.as_str());
let arg = c.name("arg").map(|a| a.as_str()).unwrap_or("");
let value = lookup(name);
let resolved = match op {
None => match value {
Some(v) => v,
None => bail!("environment variable {name} is not set but is referenced in the config"),
},
Some(":-") => match value {
Some(v) if !v.is_empty() => v,
_ => arg.to_string(),
},
Some("-") => value.unwrap_or_else(|| arg.to_string()),
Some(":?") => match value {
Some(v) if !v.is_empty() => v,
_ => bail!("{}", default_missing_message(name, arg)),
},
Some("?") => match value {
Some(v) => v,
None => bail!("{}", default_missing_message(name, arg)),
},
Some(":+") => match value {
Some(v) if !v.is_empty() => arg.to_string(),
_ => String::new(),
},
Some("+") => match value {
Some(_) => arg.to_string(),
None => String::new(),
},
Some(other) => {
unreachable!("the regex only captures known operators, got {other:?}")
}
};
out.push_str(&resolved);
}
out.push_str(&src[last..]);
Ok(out)
}
fn default_missing_message(name: &str, custom: &str) -> String {
if custom.is_empty() {
format!("environment variable {name} is not set but is referenced in the config")
} else {
custom.to_string()
}
}
pub fn load(path: &Path, cli_env: Option<&str>) -> Result<Config> {
let config_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut base_map = BTreeMap::new();
load_env_file(&config_dir.join(".env"), &mut base_map)?;
let app_env = resolve_app_env(cli_env, &base_map);
let env_map = load_env_layers(config_dir, &app_env)?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config {}", path.display()))?;
let expanded = expand_env(&raw, &env_map)?;
let mut cfg: Config = serde_yaml_ng::from_str(&expanded).with_context(|| {
format!(
"failed to parse config {}. The format changed: suites were replaced by \
paths + resources, see docs/writing-tests.md",
path.display()
)
})?;
cfg.resolve_default_api()?;
cfg.resolve_default_db()?;
cfg.resolve_default_srp()?;
cfg.resolve_options()?;
Ok(cfg)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
concurrency: 8
paths: [features]
default_api: review
resources:
api:
review:
base_url: http://review.local
billing:
base_url: http://billing.local
timeout_secs: 5
";
fn parse(src: &str) -> Result<Config> {
let expanded = expand_env(src, &BTreeMap::new())?;
let mut cfg: Config = serde_yaml_ng::from_str(&expanded)?;
cfg.resolve_options()?;
Ok(cfg)
}
#[test]
fn a_default_for_a_group_nothing_declares_is_rejected() {
let c = parse(
"paths: [features]\nresources:\n api: {}\n widget:\n main:\n bucket: b\ndefault_widgte: main\n",
)
.expect("config parses");
let error = c
.check_group_defaults()
.expect_err("default_widgte names nothing")
.to_string();
assert!(error.contains("default_widgte"), "{error}");
assert!(error.contains("widget"), "{error}");
}
#[test]
fn a_default_for_a_declared_group_is_accepted() {
let c = parse(
"paths: [features]\nresources:\n api: {}\n widget:\n main:\n bucket: b\ndefault_widget: main\n",
)
.expect("config parses");
c.check_group_defaults().expect("default_widget names its group");
}
#[test]
fn reads_api_resources() {
let c = parse(SAMPLE).expect("config parses");
assert_eq!(c.resources.api["review"].base_url, "http://review.local");
}
#[test]
fn timeout_defaults_to_twenty_seconds() {
let c = parse(SAMPLE).expect("config parses");
assert_eq!(c.resources.api["review"].timeout_secs, 20);
}
#[test]
fn timeout_can_be_set_per_api_resource() {
let c = parse(SAMPLE).expect("config parses");
assert_eq!(c.resources.api["billing"].timeout_secs, 5);
}
#[test]
fn options_cascade_from_root_to_each_resource_kind() {
let c = parse(
"options:\n polling:\n timeout_secs: 10\n interval_ms: 200\nresources:\n api:\n jobs:\n base_url: http://jobs.local\n options:\n polling:\n timeout_secs: 30\n db:\n reporting:\n dsn: postgres://u:p@db/reporting\n options:\n polling:\n interval_ms: 500\n srp:\n login:\n variant: hex-string\n options: {}\npaths: [features]\n",
)
.expect("config parses");
assert_eq!(
c.effective_options.polling.timeout,
std::time::Duration::from_secs(10)
);
assert_eq!(
c.resources.api["jobs"].effective_options.polling.timeout,
std::time::Duration::from_secs(30)
);
assert_eq!(
c.resources.api["jobs"].effective_options.polling.interval,
std::time::Duration::from_millis(200)
);
assert_eq!(
c.resources.db["reporting"]
.effective_options
.polling
.timeout,
std::time::Duration::from_secs(10)
);
assert_eq!(
c.resources.db["reporting"]
.effective_options
.polling
.interval,
std::time::Duration::from_millis(500)
);
assert_eq!(
c.resources.srp["login"].effective_options.polling.timeout,
std::time::Duration::from_secs(10)
);
assert_eq!(
c.resources.srp["login"].effective_options.polling.interval,
std::time::Duration::from_millis(200)
);
}
#[test]
fn unknown_nested_option_fields_are_rejected() {
let error = parse(
"paths: [features]\nresources:\n api:\n jobs:\n base_url: http://jobs.local\n options:\n polling:\n retries: 3\n",
)
.expect_err("unknown option field is rejected");
assert!(error.to_string().contains("retries"), "{error}");
}
#[test]
fn concurrency_defaults_to_eight() {
let c = parse("paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n")
.expect("config parses");
assert_eq!(c.concurrency, 8);
}
#[test]
fn expands_environment_variables() {
unsafe { std::env::set_var("BDDKIT_TEST_HOST", "example.test") };
let c = parse(
"paths: [f]\nresources:\n api:\n a:\n base_url: http://${BDDKIT_TEST_HOST}\n",
)
.expect("config parses");
assert_eq!(c.resources.api["a"].base_url, "http://example.test");
}
#[test]
fn missing_environment_variable_is_an_error() {
let err = parse(
"paths: [f]\nresources:\n api:\n a:\n base_url: ${BDDKIT_ABSENT_VAR}\n",
)
.expect_err("variable is missing");
assert!(err.to_string().contains("BDDKIT_ABSENT_VAR"), "{err}");
}
mod expand_env_operators {
use super::*;
fn expand(src: &str, pairs: &[(&str, &str)]) -> Result<String> {
let map: BTreeMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
expand_env(src, &map)
}
#[test]
fn dollar_dollar_is_a_literal_dollar_sign() {
assert_eq!(
expand("$${NOT_A_VAR}", &[]).expect("parses"),
"${NOT_A_VAR}"
);
}
#[test]
fn bare_dollar_var_without_braces_behaves_like_braced_form() {
let out = expand("$FOO", &[("FOO", "bar")]).expect("FOO is set");
assert_eq!(out, "bar");
}
#[test]
fn bare_dollar_var_without_braces_errors_when_unset() {
let err = expand("$FOO", &[]).expect_err("FOO is not set");
assert!(err.to_string().contains("FOO"), "{err}");
}
#[test]
fn colon_dash_uses_default_when_unset() {
assert_eq!(
expand("${FOO:-fallback}", &[]).expect("parses"),
"fallback"
);
}
#[test]
fn colon_dash_uses_default_when_set_but_empty() {
assert_eq!(
expand("${FOO:-fallback}", &[("FOO", "")]).expect("parses"),
"fallback"
);
}
#[test]
fn colon_dash_uses_value_when_set_and_nonempty() {
assert_eq!(
expand("${FOO:-fallback}", &[("FOO", "real")]).expect("parses"),
"real"
);
}
#[test]
fn dash_uses_default_only_when_unset() {
assert_eq!(
expand("${FOO-fallback}", &[]).expect("parses"),
"fallback"
);
}
#[test]
fn dash_passes_through_empty_value_unchanged() {
assert_eq!(
expand("${FOO-fallback}", &[("FOO", "")]).expect("parses"),
""
);
}
#[test]
fn colon_question_errors_when_unset_with_custom_message() {
let err = expand("${FOO:?custom message}", &[]).expect_err("FOO is not set");
assert_eq!(err.to_string(), "custom message");
}
#[test]
fn colon_question_errors_when_set_but_empty() {
let err = expand("${FOO:?custom message}", &[("FOO", "")]).expect_err("FOO is empty");
assert_eq!(err.to_string(), "custom message");
}
#[test]
fn colon_question_with_empty_message_falls_back_to_default_message() {
let err = expand("${FOO:?}", &[]).expect_err("FOO is not set");
assert_eq!(
err.to_string(),
"environment variable FOO is not set but is referenced in the config"
);
}
#[test]
fn colon_question_passes_through_value_when_set_and_nonempty() {
assert_eq!(
expand("${FOO:?msg}", &[("FOO", "real")]).expect("parses"),
"real"
);
}
#[test]
fn question_errors_only_when_unset() {
let err = expand("${FOO?custom message}", &[]).expect_err("FOO is not set");
assert_eq!(err.to_string(), "custom message");
}
#[test]
fn question_passes_through_empty_value_without_error() {
assert_eq!(
expand("${FOO?custom message}", &[("FOO", "")]).expect("FOO is set to empty"),
""
);
}
#[test]
fn colon_plus_uses_alt_when_set_and_nonempty() {
assert_eq!(
expand("${FOO:+alt}", &[("FOO", "real")]).expect("parses"),
"alt"
);
}
#[test]
fn colon_plus_is_empty_when_unset() {
assert_eq!(expand("${FOO:+alt}", &[]).expect("parses"), "");
}
#[test]
fn colon_plus_is_empty_when_set_but_empty() {
assert_eq!(expand("${FOO:+alt}", &[("FOO", "")]).expect("parses"), "");
}
#[test]
fn plus_uses_alt_when_set_even_if_empty() {
assert_eq!(
expand("${FOO+alt}", &[("FOO", "")]).expect("parses"),
"alt"
);
}
#[test]
fn plus_is_empty_when_unset() {
assert_eq!(expand("${FOO+alt}", &[]).expect("parses"), "");
}
#[test]
fn real_process_env_wins_over_env_map() {
let out = expand("${PATH}", &[("PATH", "definitely_not_the_real_path")])
.expect("PATH is set in the real environment");
assert_ne!(out, "definitely_not_the_real_path");
}
}
mod resolve_default_api {
use super::*;
#[test]
fn a_single_resource_needs_no_explicit_default() {
let c = parse(
"paths: [f]\nresources:\n api:\n only:\n base_url: http://a.local\n",
)
.expect("config parses");
assert_eq!(
c.resolve_default_api().expect("default is inferred"),
Some("only".to_string())
);
}
#[test]
fn an_explicit_default_wins() {
let c = parse(SAMPLE).expect("config parses");
assert_eq!(
c.resolve_default_api().expect("default is set"),
Some("review".to_string())
);
}
#[test]
fn several_resources_without_a_default_is_an_error() {
let src = "paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
b:\n base_url: http://b.local\n";
let c = parse(src).expect("config parses");
let err = c.resolve_default_api().expect_err("default is ambiguous");
assert!(err.to_string().contains("default_api"), "{err}");
}
#[test]
fn a_default_naming_an_undeclared_resource_is_an_error() {
let src = "paths: [f]\ndefault_api: missing\nresources:\n api:\n a:\n base_url: http://a.local\n";
let c = parse(src).expect("config parses");
let err = c.resolve_default_api().expect_err("resource is not declared");
assert!(err.to_string().contains("missing"), "{err}");
}
#[test]
fn no_apis_resolves_to_none() {
let c = parse("paths: [f]\nresources:\n api: {}\n").expect("config parses");
assert_eq!(c.resolve_default_api().expect("no APIs are declared"), None);
}
}
mod resolve_default_db {
use super::*;
#[test]
fn no_databases_resolves_to_none() {
let c = parse(SAMPLE).expect("config parses");
assert_eq!(c.resolve_default_db().expect("no DBs are declared"), None);
}
#[test]
fn a_single_connection_needs_no_explicit_default() {
let src = "paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n only:\n dsn: postgres://u:p@db/x\n";
let c = parse(src).expect("config parses");
assert_eq!(
c.resolve_default_db().expect("default is inferred"),
Some("only".to_string())
);
}
#[test]
fn several_connections_without_a_default_is_an_error() {
let src = "paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n one:\n dsn: postgres://u:p@db/x\n two:\n dsn: postgres://u:p@db/y\n";
let c = parse(src).expect("config parses");
let err = c.resolve_default_db().expect_err("default is ambiguous");
assert!(err.to_string().contains("default_db"), "{err}");
}
#[test]
fn reads_search_path() {
let src = "paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:p@db/x\n search_path: [app, public]\n";
let c = parse(src).expect("config parses");
assert_eq!(c.resources.db["main"].search_path, vec!["app", "public"]);
}
#[test]
fn search_path_defaults_to_empty() {
let src = "paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:p@db/x\n";
let c = parse(src).expect("config parses");
assert!(c.resources.db["main"].search_path.is_empty());
}
}
mod env_layers {
use super::*;
fn unique_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bddkit_env_layers_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp directory");
dir
}
#[test]
fn load_env_file_is_a_noop_when_file_is_missing() {
let dir = unique_dir("missing_file");
let mut map = BTreeMap::new();
load_env_file(&dir.join(".env"), &mut map).expect("a missing file is not an error");
assert!(map.is_empty());
}
#[test]
fn load_env_file_reads_key_value_pairs() {
let dir = unique_dir("basic_read");
std::fs::write(dir.join(".env"), "FOO=bar\nBAZ=qux\n").expect("write .env");
let mut map = BTreeMap::new();
load_env_file(&dir.join(".env"), &mut map).expect(".env reads");
assert_eq!(map.get("FOO"), Some(&"bar".to_string()));
assert_eq!(map.get("BAZ"), Some(&"qux".to_string()));
}
#[test]
fn load_env_file_reports_the_path_on_a_malformed_file() {
let dir = unique_dir("malformed_file");
let path = dir.join(".env");
std::fs::write(&path, "FOO=\"unterminated\n").expect("write invalid .env");
let mut map = BTreeMap::new();
let err = load_env_file(&path, &mut map).expect_err("invalid .env is an error");
assert!(
err.to_string().contains(&path.display().to_string()),
"{err}"
);
}
#[test]
fn later_file_overrides_earlier_for_same_key() {
let dir = unique_dir("override");
let mut map = BTreeMap::new();
std::fs::write(dir.join("a"), "FOO=from_a\n").expect("write a");
std::fs::write(dir.join("b"), "FOO=from_b\n").expect("write b");
load_env_file(&dir.join("a"), &mut map).expect("a reads");
load_env_file(&dir.join("b"), &mut map).expect("b reads");
assert_eq!(map.get("FOO"), Some(&"from_b".to_string()));
}
#[test]
fn load_env_layers_applies_full_precedence_order() {
let dir = unique_dir("full_precedence");
std::fs::write(dir.join(".env"), "A=base\nB=base\nC=base\nD=base\n")
.expect("write .env");
std::fs::write(dir.join(".env.local"), "B=local\nC=local\nD=local\n")
.expect("write .env.local");
std::fs::write(dir.join(".env.dev"), "C=dev\nD=dev\n").expect("write .env.dev");
std::fs::write(dir.join(".env.dev.local"), "D=dev_local\n")
.expect("write .env.dev.local");
let map = load_env_layers(&dir, "dev").expect("layers read");
assert_eq!(map.get("A"), Some(&"base".to_string()), "only in .env");
assert_eq!(
map.get("B"),
Some(&"local".to_string()),
".env.local overrides .env"
);
assert_eq!(
map.get("C"),
Some(&"dev".to_string()),
".env.dev overrides .env.local"
);
assert_eq!(
map.get("D"),
Some(&"dev_local".to_string()),
".env.dev.local is the most specific, it wins"
);
}
#[test]
fn load_env_layers_ignores_missing_optional_files() {
let dir = unique_dir("only_base");
std::fs::write(dir.join(".env"), "A=base\n").expect("write .env");
let map = load_env_layers(&dir, "prod")
.expect("missing .env.local/.env.prod* is not an error");
assert_eq!(map.get("A"), Some(&"base".to_string()));
assert_eq!(map.len(), 1);
}
#[test]
fn resolve_app_env_cli_flag_wins() {
let base = BTreeMap::new();
let resolved = resolve_app_env_with_process_env(
Some("from_cli"),
Some("from_process".into()),
&base,
);
assert_eq!(resolved, "from_cli");
}
#[test]
fn resolve_app_env_falls_back_to_base_map_value() {
let mut base = BTreeMap::new();
base.insert("APP_ENV".to_string(), "from_dotenv".to_string());
let resolved = resolve_app_env_with_process_env(None, None, &base);
assert_eq!(resolved, "from_dotenv");
}
#[test]
fn resolve_app_env_defaults_to_dev() {
let base = BTreeMap::new();
let resolved = resolve_app_env_with_process_env(None, None, &base);
assert_eq!(resolved, "dev");
}
#[test]
fn resolve_app_env_real_env_var_wins_over_base_map() {
let mut base = BTreeMap::new();
base.insert("APP_ENV".to_string(), "from_base_map".to_string());
let resolved =
resolve_app_env_with_process_env(None, Some("from_real_env".into()), &base);
assert_eq!(resolved, "from_real_env");
}
}
#[test]
fn load_reads_a_config_file() {
let path = std::env::temp_dir().join("bddkit_cfg_load_reads_a_config_file.yaml");
std::fs::write(
&path,
"paths: [features]\nresources:\n api:\n review:\n base_url: http://review.local\n",
)
.expect("write config");
let c = load(&path, None).expect("config loads");
assert_eq!(c.paths, vec![PathBuf::from("features")]);
}
#[test]
fn load_rejects_an_ambiguous_default_api() {
let path = std::env::temp_dir().join("bddkit_cfg_load_rejects_ambiguous.yaml");
std::fs::write(
&path,
"paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n b:\n base_url: http://b.local\n",
)
.expect("write config");
let err = load(&path, None).expect_err("default is ambiguous");
assert!(err.to_string().contains("default_api"), "{err}");
}
#[test]
fn srp_defaults_to_the_4096_bit_group_with_sha256() {
let cfg = SrpConfig {
variant: "hex-string".into(),
prime: None,
generator: None,
hash: None,
options: OptionsLayer::default(),
effective_options: Options::default(),
};
let params = cfg.to_params().expect("defaults are valid");
assert_eq!(params.variant, crate::srp::Variant::HexString);
assert_eq!(params.hash, crate::srp::HashAlg::Sha256);
assert_eq!(params.generator, num_bigint::BigUint::from(5u32));
assert_eq!(
crate::srp::hex(¶ms.k()),
"3509477ea9fca66eadb7cf7b1bd0eb508f54d3989a9c988006a7d0b338374dd2"
);
}
#[test]
fn srp_accepts_an_explicit_group_and_hash() {
let cfg = SrpConfig {
variant: "rfc5054".into(),
prime: Some(crate::srp::RFC5054_1024_PRIME_HEX.to_string()),
generator: Some("2".into()),
hash: Some("sha-1".into()),
options: OptionsLayer::default(),
effective_options: Options::default(),
};
let params = cfg.to_params().expect("explicit values are valid");
assert_eq!(
crate::srp::hex(¶ms.k()),
"7556aa045aef2cdd07abaf0f665c3e818913186f"
);
}
#[test]
fn an_unknown_variant_is_rejected_by_name() {
let cfg = SrpConfig {
variant: "srp7".into(),
prime: None,
generator: None,
hash: None,
options: OptionsLayer::default(),
effective_options: Options::default(),
};
let err = cfg.to_params().unwrap_err().to_string();
assert!(err.contains("srp7"), "error must name the value: {err}");
}
mod load_with_env_files {
use super::*;
fn write_config(dir: &Path, yaml: &str) -> PathBuf {
let path = dir.join("config.yaml");
std::fs::write(&path, yaml).expect("write config");
path
}
fn unique_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("bddkit_load_with_env_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp directory");
dir
}
#[test]
fn substitutes_value_from_base_dotenv_file() {
let dir = unique_dir("base_dotenv");
std::fs::write(dir.join(".env"), "DB_PASS=secret\n").expect("write .env");
let path = write_config(
&dir,
"paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:${DB_PASS}@db/x\n",
);
let c = load(&path, None).expect("config loads with .env");
assert_eq!(c.resources.db["main"].dsn, "postgres://u:secret@db/x");
}
#[test]
fn cli_env_flag_selects_the_matching_env_specific_file() {
let dir = unique_dir("cli_env_flag");
std::fs::write(dir.join(".env"), "DB_PASS=base_secret\n").expect("write .env");
std::fs::write(dir.join(".env.prod"), "DB_PASS=prod_secret\n")
.expect("write .env.prod");
let path = write_config(
&dir,
"paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:${DB_PASS}@db/x\n",
);
let c = load(&path, Some("prod")).expect("config loads with --env=prod");
assert_eq!(c.resources.db["main"].dsn, "postgres://u:prod_secret@db/x");
}
#[test]
fn without_cli_env_flag_defaults_to_dev_file() {
let dir = unique_dir("default_dev");
std::fs::write(dir.join(".env"), "DB_PASS=base_secret\n").expect("write .env");
std::fs::write(dir.join(".env.dev"), "DB_PASS=dev_secret\n").expect("write .env.dev");
let path = write_config(
&dir,
"paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:${DB_PASS}@db/x\n",
);
if std::env::var("APP_ENV").is_err() {
let c = load(&path, None).expect("config loads without --env");
assert_eq!(c.resources.db["main"].dsn, "postgres://u:dev_secret@db/x");
}
}
#[test]
fn local_file_overrides_base_env_for_same_key() {
let dir = unique_dir("local_override");
std::fs::write(dir.join(".env"), "DB_PASS=base_secret\n").expect("write .env");
std::fs::write(dir.join(".env.local"), "DB_PASS=local_secret\n")
.expect("write .env.local");
let path = write_config(
&dir,
"paths: [f]\nresources:\n api:\n a:\n base_url: http://a.local\n \
db:\n main:\n dsn: postgres://u:${DB_PASS}@db/x\n",
);
let c = load(&path, None).expect("config loads");
assert_eq!(c.resources.db["main"].dsn, "postgres://u:local_secret@db/x");
}
}
const WITH_GROUP: &str = "\
paths: [features]
options:
polling:
timeout_secs: 10
resources:
api:
review:
base_url: http://review.local
widget:
backups:
endpoint: http://storage.internal:9000
bucket: backups
options:
polling:
timeout_secs: 30
archive:
endpoint: http://storage.internal:9000
bucket: archive
default_widget: backups
";
#[test]
fn an_unknown_resource_group_is_kept_verbatim() {
let cfg = parse(WITH_GROUP).expect("parses");
let names: Vec<&String> = cfg.group_names().collect();
assert_eq!(names, vec!["widget"]);
let instance = cfg
.plugin_instances
.iter()
.find(|i| i.name == "backups")
.expect("backups declared");
assert_eq!(instance.config["bucket"], serde_json::json!("backups"));
assert_eq!(instance.config["endpoint"], serde_json::json!("http://storage.internal:9000"));
assert_eq!(cfg.paths, vec![std::path::PathBuf::from("features")]);
assert_eq!(cfg.concurrency, default_concurrency());
assert!(
cfg.extra.keys().eq(["default_widget".to_string()].iter()),
"only the group default is unclaimed, got {:?}",
cfg.extra.keys().collect::<Vec<_>>()
);
}
#[test]
fn the_reserved_options_key_never_reaches_the_plugin() {
let cfg = parse(WITH_GROUP).expect("parses");
let instance = cfg
.plugin_instances
.iter()
.find(|i| i.name == "backups")
.expect("backups declared");
assert!(
instance.config.get("options").is_none(),
"options is host-managed and must be stripped: {:?}",
instance.config
);
}
#[test]
fn instance_options_inherit_from_the_global_layer() {
let cfg = parse(WITH_GROUP).expect("parses");
let backups = cfg.plugin_instances.iter().find(|i| i.name == "backups").unwrap();
let archive = cfg.plugin_instances.iter().find(|i| i.name == "archive").unwrap();
assert_eq!(backups.options.polling.timeout, std::time::Duration::from_secs(30));
assert_eq!(
archive.options.polling.timeout,
std::time::Duration::from_secs(10),
"an instance with no options layer inherits the global one"
);
}
#[test]
fn an_invalid_host_option_inside_a_group_is_rejected_before_the_plugin_loads() {
let src = "\
paths: [features]
resources:
api: {}
widget:
backups:
options:
polling:
timeout_secs: 0
";
let error = parse(src).expect_err("zero timeout is invalid");
assert!(format!("{error:#}").contains("resources.widget.backups"), "{error:#}");
}
#[test]
fn an_unknown_option_inside_a_group_is_rejected() {
let src = "\
paths: [features]
resources:
api: {}
widget:
backups:
options:
pollng:
timeout_secs: 3
";
let error = parse(src).expect_err("typo in a host option");
assert!(format!("{error:#}").contains("resources.widget.backups"), "{error:#}");
}
#[test]
fn a_group_default_resolves_like_every_other_default() {
let cfg = parse(WITH_GROUP).expect("parses");
assert_eq!(cfg.resolve_default_group("widget").unwrap().as_deref(), Some("backups"));
}
#[test]
fn a_single_instance_becomes_the_group_default_on_its_own() {
let src = "\
paths: [features]
resources:
api: {}
widget:
only:
bucket: b
";
let cfg = parse(src).expect("parses");
assert_eq!(cfg.resolve_default_group("widget").unwrap().as_deref(), Some("only"));
}
#[test]
fn several_instances_without_a_default_is_an_error() {
let src = "\
paths: [features]
resources:
api: {}
widget:
a: {bucket: a}
b: {bucket: b}
";
let cfg = parse(src).expect("parses");
let error = cfg.resolve_default_group("widget").expect_err("ambiguous");
assert!(format!("{error:#}").contains("default_widget"), "{error:#}");
}
#[test]
fn a_group_default_naming_an_undeclared_instance_is_an_error() {
let src = "\
paths: [features]
resources:
api: {}
widget:
a: {bucket: a}
default_widget: nope
";
let cfg = parse(src).expect("parses");
let error = cfg.resolve_default_group("widget").expect_err("undeclared");
assert!(format!("{error:#}").contains("nope"), "{error:#}");
}
}