use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct EngineConfig {
pub server: ServerConfig,
pub backend: BackendConfig,
#[serde(default)]
pub workflow: WorkflowConfig,
#[serde(default)]
pub auth: AuthConfig,
#[serde(default)]
pub dashboard: DashboardConfig,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default = "default_engine_events_ttl_secs")]
pub engine_events_ttl_secs: u64,
#[serde(default)]
pub auto_enable_modules: Vec<String>,
}
fn default_engine_events_ttl_secs() -> u64 {
3 * 86_400
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ServerConfig {
#[serde(default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default = "default_public_url")]
pub public_url: String,
}
fn default_bind_addr() -> String {
"0.0.0.0:3000".to_string()
}
fn default_public_url() -> String {
"http://localhost:3000".to_string()
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[non_exhaustive]
pub enum BackendConfig {
Postgres {
url: String,
},
Sqlite {
#[serde(default = "default_data_dir")]
data_dir: String,
#[serde(default)]
path: Option<String>,
},
}
fn default_data_dir() -> String {
"./data".to_string()
}
impl BackendConfig {
pub fn sqlite_data_dir(&self) -> Option<String> {
match self {
Self::Sqlite { data_dir, path } => {
if let Some(p) = path {
let parent = std::path::Path::new(p)
.parent()
.map(|p| p.display().to_string())
.filter(|s| !s.is_empty());
Some(parent.unwrap_or_else(|| data_dir.clone()))
} else {
Some(data_dir.clone())
}
}
Self::Postgres { .. } => None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct WorkflowConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthConfig {
pub issuer: Option<String>,
#[serde(default)]
pub audience: Vec<String>,
#[serde(default)]
pub session: AuthSessionConfig,
#[serde(default)]
pub passkey: AuthPasskeyConfig,
#[serde(default)]
pub oidc_provider: AuthOidcProviderConfig,
#[serde(default)]
pub admin_api_keys: Vec<String>,
#[serde(default)]
external_issuers: Vec<ExternalIssuerConfig>,
}
impl AuthConfig {
pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
&self.external_issuers
}
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct ExternalIssuerConfig {
pub issuer_url: String,
#[serde(default)]
pub audience: Vec<String>,
#[serde(default = "default_jwks_refresh_secs")]
pub jwks_refresh_secs: u64,
}
fn default_jwks_refresh_secs() -> u64 {
3600
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthSessionConfig {
pub ttl_seconds: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthPasskeyConfig {
pub rp_id: Option<String>,
pub rp_name: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[non_exhaustive]
pub struct AuthOidcProviderConfig {
#[serde(default = "default_true")]
pub enabled: bool,
pub issuer_override: Option<String>,
#[serde(default = "default_true")]
pub auto_provision: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct DashboardConfig {
#[serde(default = "default_true")]
pub enabled: bool,
}
impl Default for DashboardConfig {
fn default() -> Self {
Self { enabled: true }
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[non_exhaustive]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_log_format")]
pub format: String,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: default_log_format(),
}
}
}
fn default_true() -> bool {
true
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_log_format() -> String {
"pretty".to_string()
}
impl EngineConfig {
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
.map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
let cfg: Self = toml::from_str(&expanded)
.map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
Ok(cfg)
}
}
fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(idx) = rest.find("${") {
out.push_str(&rest[..idx]);
let after_open = &rest[idx + 2..];
let close_idx = after_open
.find('}')
.ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
let inner = &after_open[..close_idx];
let (var_name, default) = match inner.split_once(":-") {
Some((n, d)) => (n, Some(d)),
None => (inner, None),
};
if !is_valid_var_name(var_name) {
out.push_str("${");
out.push_str(inner);
out.push('}');
} else {
match lookup(var_name) {
Some(val) => out.push_str(&val),
None => match default {
Some(def) => out.push_str(def),
None => {
return Err(anyhow::anyhow!(
"env var `{}` is not set and has no default",
var_name
));
}
},
}
}
rest = &after_open[close_idx + 1..];
}
out.push_str(rest);
Ok(out)
}
fn is_valid_var_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
map.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_string())
}
}
#[test]
fn no_substitution_passes_through() {
let s = "plain string with $literal but no expansion markers";
assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
}
#[test]
fn substitutes_set_var() {
let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
assert_eq!(out, "value=hello");
}
#[test]
fn errors_on_unset_var_with_no_default() {
let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
assert!(err.to_string().contains("MISSING"));
}
#[test]
fn falls_back_to_default_when_unset() {
let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
assert_eq!(out, "fallback");
}
#[test]
fn ignores_default_when_var_set() {
let out = expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
assert_eq!(out, "actual");
}
#[test]
fn empty_default_yields_empty_string() {
let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
assert_eq!(out, "[]");
}
#[test]
fn substitutes_multiple_vars_in_one_string() {
let out = expand_env_vars(
"postgres://u:p@${HOST}:${PORT}/x",
lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
)
.unwrap();
assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
}
#[test]
fn dollar_without_braces_passes_through() {
let s = "$HOME and $USER stay literal";
let out = expand_env_vars(s, lookup_from(&[])).unwrap();
assert_eq!(out, s);
}
#[test]
fn invalid_identifier_passes_through_verbatim() {
let s = "${1NOT_VALID}";
assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
}
#[test]
fn unclosed_brace_errors() {
let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
assert!(err.to_string().contains("unclosed"));
}
#[test]
fn substitutes_inside_toml_string_values() {
let toml_input = r#"
[backend]
type = "postgres"
url = "${DB}"
"#;
let expanded =
expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
}
#[test]
fn is_valid_var_name_accepts_typical_names() {
assert!(is_valid_var_name("DATABASE_URL"));
assert!(is_valid_var_name("_PRIVATE"));
assert!(is_valid_var_name("X"));
assert!(is_valid_var_name("X1"));
}
#[test]
fn is_valid_var_name_rejects_bad_names() {
assert!(!is_valid_var_name(""));
assert!(!is_valid_var_name("1LEADING_DIGIT"));
assert!(!is_valid_var_name("HAS SPACE"));
assert!(!is_valid_var_name("HAS-DASH"));
assert!(!is_valid_var_name("HAS.DOT"));
}
#[test]
fn from_file_loads_static_toml() {
let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
std::fs::write(
&path,
r#"
[server]
bind_addr = "127.0.0.1:3000"
[backend]
type = "sqlite"
data_dir = "/tmp/assay-engine-test-data-static"
"#,
)
.unwrap();
let cfg = EngineConfig::from_file(&path).unwrap();
let _ = std::fs::remove_file(&path);
match cfg.backend {
BackendConfig::Sqlite { ref data_dir, .. } => {
assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
}
_ => panic!("expected sqlite backend"),
}
}
}