1use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19#[derive(Clone, Debug, Deserialize, Serialize)]
20#[non_exhaustive]
21pub struct EngineConfig {
22 pub server: ServerConfig,
23 pub backend: BackendConfig,
24 #[serde(default)]
25 pub workflow: WorkflowConfig,
26 #[serde(default)]
27 pub auth: AuthConfig,
28 #[serde(default)]
29 pub dashboard: DashboardConfig,
30 #[serde(default)]
31 pub logging: LoggingConfig,
32 #[serde(default = "default_engine_events_ttl_secs")]
35 pub engine_events_ttl_secs: u64,
36 #[serde(default)]
43 pub auto_enable_modules: Vec<String>,
44}
45
46fn default_engine_events_ttl_secs() -> u64 {
47 3 * 86_400
48}
49
50#[derive(Clone, Debug, Deserialize, Serialize)]
51#[non_exhaustive]
52pub struct ServerConfig {
53 #[serde(default = "default_bind_addr")]
54 pub bind_addr: String,
55 #[serde(default = "default_public_url")]
61 pub public_url: String,
62}
63
64fn default_bind_addr() -> String {
65 "0.0.0.0:3000".to_string()
66}
67
68fn default_public_url() -> String {
69 "http://localhost:3000".to_string()
70}
71
72#[derive(Clone, Debug, Deserialize, Serialize)]
73#[serde(tag = "type", rename_all = "lowercase")]
74#[non_exhaustive]
75pub enum BackendConfig {
76 Postgres {
77 url: String,
80 },
81 Sqlite {
82 #[serde(default = "default_data_dir")]
88 data_dir: String,
89 #[serde(default)]
94 path: Option<String>,
95 },
96}
97
98fn default_data_dir() -> String {
99 "./data".to_string()
100}
101
102impl BackendConfig {
103 pub fn sqlite_data_dir(&self) -> Option<String> {
105 match self {
106 Self::Sqlite { data_dir, path } => {
107 if let Some(p) = path {
111 let parent = std::path::Path::new(p)
112 .parent()
113 .map(|p| p.display().to_string())
114 .filter(|s| !s.is_empty());
115 Some(parent.unwrap_or_else(|| data_dir.clone()))
116 } else {
117 Some(data_dir.clone())
118 }
119 }
120 Self::Postgres { .. } => None,
121 }
122 }
123}
124
125#[derive(Clone, Debug, Default, Deserialize, Serialize)]
126#[non_exhaustive]
127pub struct WorkflowConfig {
128 #[serde(default = "default_true")]
129 pub enabled: bool,
130}
131
132#[derive(Clone, Debug, Default, Deserialize, Serialize)]
136#[non_exhaustive]
137pub struct AuthConfig {
138 pub issuer: Option<String>,
142 #[serde(default)]
145 pub audience: Vec<String>,
146 #[serde(default)]
147 pub session: AuthSessionConfig,
148 #[serde(default)]
149 pub passkey: AuthPasskeyConfig,
150 #[serde(default)]
151 pub oidc_provider: AuthOidcProviderConfig,
152 #[serde(default)]
158 pub admin_api_keys: Vec<String>,
159 #[serde(default)]
177 external_issuers: Vec<ExternalIssuerConfig>,
178}
179
180impl AuthConfig {
181 pub fn external_issuers(&self) -> &[ExternalIssuerConfig] {
183 &self.external_issuers
184 }
185}
186
187#[derive(Clone, Debug, Default, Deserialize, Serialize)]
189#[non_exhaustive]
190pub struct ExternalIssuerConfig {
191 pub issuer_url: String,
195 #[serde(default)]
199 pub audience: Vec<String>,
200 #[serde(default = "default_jwks_refresh_secs")]
204 pub jwks_refresh_secs: u64,
205}
206
207fn default_jwks_refresh_secs() -> u64 {
208 3600
209}
210
211#[derive(Clone, Debug, Default, Deserialize, Serialize)]
213#[non_exhaustive]
214pub struct AuthSessionConfig {
215 pub ttl_seconds: Option<u64>,
218}
219
220#[derive(Clone, Debug, Default, Deserialize, Serialize)]
222#[non_exhaustive]
223pub struct AuthPasskeyConfig {
224 pub rp_id: Option<String>,
227 pub rp_name: Option<String>,
229}
230
231#[derive(Clone, Debug, Default, Deserialize, Serialize)]
233#[non_exhaustive]
234pub struct AuthOidcProviderConfig {
235 #[serde(default = "default_true")]
238 pub enabled: bool,
239 pub issuer_override: Option<String>,
242}
243
244#[derive(Clone, Debug, Default, Deserialize, Serialize)]
245#[non_exhaustive]
246pub struct DashboardConfig {
247 #[serde(default = "default_true")]
248 pub enabled: bool,
249}
250
251#[derive(Clone, Debug, Deserialize, Serialize)]
252#[non_exhaustive]
253pub struct LoggingConfig {
254 #[serde(default = "default_log_level")]
255 pub level: String,
256 #[serde(default = "default_log_format")]
257 pub format: String,
258}
259
260impl Default for LoggingConfig {
261 fn default() -> Self {
262 Self {
263 level: default_log_level(),
264 format: default_log_format(),
265 }
266 }
267}
268
269fn default_true() -> bool {
270 true
271}
272
273fn default_log_level() -> String {
274 "info".to_string()
275}
276
277fn default_log_format() -> String {
278 "pretty".to_string()
279}
280
281impl EngineConfig {
282 pub fn from_file(path: &Path) -> anyhow::Result<Self> {
288 let raw = std::fs::read_to_string(path)
289 .map_err(|e| anyhow::anyhow!("read config {}: {e}", path.display()))?;
290 let expanded = expand_env_vars(&raw, |name| std::env::var(name).ok())
291 .map_err(|e| anyhow::anyhow!("expand env vars in {}: {e}", path.display()))?;
292 let cfg: Self = toml::from_str(&expanded)
293 .map_err(|e| anyhow::anyhow!("parse config {}: {e}", path.display()))?;
294 Ok(cfg)
295 }
296}
297
298fn expand_env_vars<F>(raw: &str, lookup: F) -> anyhow::Result<String>
310where
311 F: Fn(&str) -> Option<String>,
312{
313 let mut out = String::with_capacity(raw.len());
314 let mut rest = raw;
315 while let Some(idx) = rest.find("${") {
316 out.push_str(&rest[..idx]);
317 let after_open = &rest[idx + 2..];
318 let close_idx = after_open
319 .find('}')
320 .ok_or_else(|| anyhow::anyhow!("unclosed `${{` in config"))?;
321 let inner = &after_open[..close_idx];
322 let (var_name, default) = match inner.split_once(":-") {
323 Some((n, d)) => (n, Some(d)),
324 None => (inner, None),
325 };
326 if !is_valid_var_name(var_name) {
327 out.push_str("${");
329 out.push_str(inner);
330 out.push('}');
331 } else {
332 match lookup(var_name) {
333 Some(val) => out.push_str(&val),
334 None => match default {
335 Some(def) => out.push_str(def),
336 None => {
337 return Err(anyhow::anyhow!(
338 "env var `{}` is not set and has no default",
339 var_name
340 ));
341 }
342 },
343 }
344 }
345 rest = &after_open[close_idx + 1..];
346 }
347 out.push_str(rest);
348 Ok(out)
349}
350
351fn is_valid_var_name(s: &str) -> bool {
352 let mut chars = s.chars();
353 match chars.next() {
354 Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
355 _ => return false,
356 }
357 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn lookup_from<'a>(map: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
365 move |name: &str| {
366 map.iter()
367 .find(|(k, _)| *k == name)
368 .map(|(_, v)| (*v).to_string())
369 }
370 }
371
372 #[test]
373 fn no_substitution_passes_through() {
374 let s = "plain string with $literal but no expansion markers";
375 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
376 }
377
378 #[test]
379 fn substitutes_set_var() {
380 let out = expand_env_vars("value=${FOO}", lookup_from(&[("FOO", "hello")])).unwrap();
381 assert_eq!(out, "value=hello");
382 }
383
384 #[test]
385 fn errors_on_unset_var_with_no_default() {
386 let err = expand_env_vars("${MISSING}", lookup_from(&[])).unwrap_err();
387 assert!(err.to_string().contains("MISSING"));
388 }
389
390 #[test]
391 fn falls_back_to_default_when_unset() {
392 let out = expand_env_vars("${MISSING:-fallback}", lookup_from(&[])).unwrap();
393 assert_eq!(out, "fallback");
394 }
395
396 #[test]
397 fn ignores_default_when_var_set() {
398 let out =
399 expand_env_vars("${FOO:-fallback}", lookup_from(&[("FOO", "actual")])).unwrap();
400 assert_eq!(out, "actual");
401 }
402
403 #[test]
404 fn empty_default_yields_empty_string() {
405 let out = expand_env_vars("[${MISSING:-}]", lookup_from(&[])).unwrap();
406 assert_eq!(out, "[]");
407 }
408
409 #[test]
410 fn substitutes_multiple_vars_in_one_string() {
411 let out = expand_env_vars(
412 "postgres://u:p@${HOST}:${PORT}/x",
413 lookup_from(&[("HOST", "db.example.com"), ("PORT", "5432")]),
414 )
415 .unwrap();
416 assert_eq!(out, "postgres://u:p@db.example.com:5432/x");
417 }
418
419 #[test]
420 fn dollar_without_braces_passes_through() {
421 let s = "$HOME and $USER stay literal";
424 let out = expand_env_vars(s, lookup_from(&[])).unwrap();
425 assert_eq!(out, s);
426 }
427
428 #[test]
429 fn invalid_identifier_passes_through_verbatim() {
430 let s = "${1NOT_VALID}";
432 assert_eq!(expand_env_vars(s, lookup_from(&[])).unwrap(), s);
433 }
434
435 #[test]
436 fn unclosed_brace_errors() {
437 let err = expand_env_vars("${UNCLOSED", lookup_from(&[])).unwrap_err();
438 assert!(err.to_string().contains("unclosed"));
439 }
440
441 #[test]
442 fn substitutes_inside_toml_string_values() {
443 let toml_input = r#"
444[backend]
445type = "postgres"
446url = "${DB}"
447"#;
448 let expanded =
449 expand_env_vars(toml_input, lookup_from(&[("DB", "postgres://u:p@h/d")])).unwrap();
450 assert!(expanded.contains(r#"url = "postgres://u:p@h/d""#));
451 }
452
453 #[test]
454 fn is_valid_var_name_accepts_typical_names() {
455 assert!(is_valid_var_name("DATABASE_URL"));
456 assert!(is_valid_var_name("_PRIVATE"));
457 assert!(is_valid_var_name("X"));
458 assert!(is_valid_var_name("X1"));
459 }
460
461 #[test]
462 fn is_valid_var_name_rejects_bad_names() {
463 assert!(!is_valid_var_name(""));
464 assert!(!is_valid_var_name("1LEADING_DIGIT"));
465 assert!(!is_valid_var_name("HAS SPACE"));
466 assert!(!is_valid_var_name("HAS-DASH"));
467 assert!(!is_valid_var_name("HAS.DOT"));
468 }
469
470 #[test]
471 fn from_file_loads_static_toml() {
472 let path = std::env::temp_dir().join("assay-engine-config-from-file-static.toml");
476 std::fs::write(
477 &path,
478 r#"
479[server]
480bind_addr = "127.0.0.1:3000"
481
482[backend]
483type = "sqlite"
484data_dir = "/tmp/assay-engine-test-data-static"
485"#,
486 )
487 .unwrap();
488 let cfg = EngineConfig::from_file(&path).unwrap();
489 let _ = std::fs::remove_file(&path);
490 match cfg.backend {
491 BackendConfig::Sqlite { ref data_dir, .. } => {
492 assert_eq!(data_dir, "/tmp/assay-engine-test-data-static");
493 }
494 _ => panic!("expected sqlite backend"),
495 }
496 }
497}