1use serde::Deserialize;
13use std::path::Path;
14
15#[derive(Debug, thiserror::Error)]
17pub enum ConfigError {
18 #[error("Config file not found: {0}")]
20 NotFound(String),
21
22 #[error("Failed to read config: {0}")]
24 Read(#[from] std::io::Error),
25
26 #[error("Failed to parse TOML: {0}")]
28 Parse(#[from] toml::de::Error),
29
30 #[error("Missing required environment variable: {0}")]
32 MissingEnvVar(String),
33}
34
35pub type ConfigResult<T> = Result<T, ConfigError>;
37
38#[derive(Debug, Clone, Default, Deserialize)]
46pub struct QailConfig {
47 #[serde(default)]
49 pub project: ProjectConfig,
50
51 #[serde(default)]
53 pub postgres: PostgresConfig,
54
55 #[serde(default)]
57 pub qdrant: Option<QdrantConfig>,
58
59 #[serde(default)]
61 pub gateway: Option<GatewayConfig>,
62
63 #[serde(default)]
65 pub sync: Vec<SyncRule>,
66}
67
68#[derive(Debug, Clone, Deserialize)]
74pub struct ProjectConfig {
75 #[serde(default = "default_project_name")]
77 pub name: String,
78
79 #[serde(default = "default_mode")]
81 pub mode: String,
82
83 pub schema: Option<String>,
85
86 pub migrations_dir: Option<String>,
88
89 pub schema_strict_manifest: Option<bool>,
94}
95
96impl Default for ProjectConfig {
97 fn default() -> Self {
98 Self {
99 name: default_project_name(),
100 mode: default_mode(),
101 schema: None,
102 migrations_dir: None,
103 schema_strict_manifest: None,
104 }
105 }
106}
107
108fn default_project_name() -> String {
109 "qail-app".to_string()
110}
111fn default_mode() -> String {
112 "postgres".to_string()
113}
114
115#[derive(Debug, Clone, Deserialize)]
117pub struct PostgresConfig {
118 #[serde(default = "default_pg_url")]
120 pub url: String,
121
122 #[serde(default = "default_max_connections")]
124 pub max_connections: usize,
125
126 #[serde(default = "default_min_connections")]
128 pub min_connections: usize,
129
130 #[serde(default = "default_idle_timeout")]
132 pub idle_timeout_secs: u64,
133
134 #[serde(default = "default_acquire_timeout")]
136 pub acquire_timeout_secs: u64,
137
138 #[serde(default = "default_connect_timeout")]
140 pub connect_timeout_secs: u64,
141
142 #[serde(default)]
144 pub test_on_acquire: bool,
145
146 #[serde(default)]
148 pub rls: Option<RlsConfig>,
149
150 #[serde(default)]
152 pub ssh: Option<String>,
153}
154
155impl Default for PostgresConfig {
156 fn default() -> Self {
157 Self {
158 url: default_pg_url(),
159 max_connections: default_max_connections(),
160 min_connections: default_min_connections(),
161 idle_timeout_secs: default_idle_timeout(),
162 acquire_timeout_secs: default_acquire_timeout(),
163 connect_timeout_secs: default_connect_timeout(),
164 test_on_acquire: false,
165 rls: None,
166 ssh: None,
167 }
168 }
169}
170
171fn default_pg_url() -> String {
172 "postgres://postgres@localhost:5432/postgres".to_string()
173}
174fn default_max_connections() -> usize {
175 10
176}
177fn default_min_connections() -> usize {
178 1
179}
180fn default_idle_timeout() -> u64 {
181 600
182}
183fn default_acquire_timeout() -> u64 {
184 30
185}
186fn default_connect_timeout() -> u64 {
187 10
188}
189
190#[derive(Debug, Clone, Default, Deserialize)]
192pub struct RlsConfig {
193 pub default_role: Option<String>,
195
196 pub super_admin_role: Option<String>,
198}
199
200#[derive(Debug, Clone, Deserialize)]
202pub struct QdrantConfig {
203 #[serde(default = "default_qdrant_url")]
205 pub url: String,
206
207 pub grpc: Option<String>,
209
210 #[serde(default = "default_max_connections")]
212 pub max_connections: usize,
213
214 #[serde(default)]
219 pub tls: Option<bool>,
220}
221
222fn default_qdrant_url() -> String {
223 "http://localhost:6333".to_string()
224}
225
226#[derive(Debug, Clone, Deserialize)]
228pub struct GatewayConfig {
229 #[serde(default = "default_bind")]
231 pub bind: String,
232
233 #[serde(default = "default_true")]
235 pub cors: bool,
236
237 #[serde(default)]
239 pub cors_allowed_origins: Option<Vec<String>>,
240
241 pub policy: Option<String>,
243
244 #[serde(default)]
246 pub cache: Option<CacheConfig>,
247
248 #[serde(default = "default_max_expand_depth")]
251 pub max_expand_depth: usize,
252
253 #[serde(default)]
258 pub blocked_tables: Option<Vec<String>>,
259
260 #[serde(default)]
265 pub allowed_tables: Option<Vec<String>>,
266}
267
268fn default_bind() -> String {
269 "0.0.0.0:8080".to_string()
270}
271fn default_true() -> bool {
272 true
273}
274fn default_max_expand_depth() -> usize {
275 4
276}
277
278#[derive(Debug, Clone, Deserialize)]
280pub struct CacheConfig {
281 #[serde(default = "default_true")]
283 pub enabled: bool,
284
285 #[serde(default = "default_cache_max")]
287 pub max_entries: usize,
288
289 #[serde(default = "default_cache_ttl")]
291 pub ttl_secs: u64,
292}
293
294fn default_cache_max() -> usize {
295 1000
296}
297fn default_cache_ttl() -> u64 {
298 60
299}
300
301#[derive(Debug, Clone, Deserialize)]
303pub struct SyncRule {
304 pub source_table: String,
306 pub target_collection: String,
308
309 #[serde(default)]
311 pub trigger_column: Option<String>,
312
313 #[serde(default)]
315 pub embedding_model: Option<String>,
316}
317
318impl QailConfig {
323 pub fn load() -> ConfigResult<Self> {
325 Self::load_from("qail.toml")
326 }
327
328 pub fn load_from(path: impl AsRef<Path>) -> ConfigResult<Self> {
330 let path = path.as_ref();
331
332 if !path.exists() {
333 return Err(ConfigError::NotFound(path.display().to_string()));
334 }
335
336 let raw = std::fs::read_to_string(path)?;
337
338 let expanded = expand_env(&raw)?;
340
341 let mut config: QailConfig = toml::from_str(&expanded)?;
343
344 config.apply_env_overrides();
346
347 Ok(config)
348 }
349
350 pub fn postgres_url(&self) -> &str {
352 &self.postgres.url
353 }
354
355 fn apply_env_overrides(&mut self) {
357 if let Ok(url) = std::env::var("DATABASE_URL") {
359 self.postgres.url = url;
360 }
361
362 if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
364 q.url = url;
365 }
366
367 if let (Ok(bind), Some(ref mut gw)) = (std::env::var("QAIL_BIND"), self.gateway.as_mut()) {
369 gw.bind = bind;
370 }
371 }
372}
373
374pub fn expand_env(input: &str) -> ConfigResult<String> {
384 let mut result = String::with_capacity(input.len());
385 let mut chars = input.chars().peekable();
386
387 while let Some(ch) = chars.next() {
388 if ch == '$' {
389 match chars.peek() {
390 Some('$') => {
391 chars.next();
393 result.push('$');
394 }
395 Some('{') => {
396 chars.next(); let mut var_expr = String::new();
398 let mut depth = 1;
399
400 for c in chars.by_ref() {
401 if c == '{' {
402 depth += 1;
403 } else if c == '}' {
404 depth -= 1;
405 if depth == 0 {
406 break;
407 }
408 }
409 var_expr.push(c);
410 }
411
412 let (var_name, default_val) = if let Some(idx) = var_expr.find(":-") {
414 (&var_expr[..idx], Some(&var_expr[idx + 2..]))
415 } else {
416 (var_expr.as_str(), None)
417 };
418
419 match std::env::var(var_name) {
420 Ok(val) => result.push_str(&val),
421 Err(_) => {
422 if let Some(default) = default_val {
423 result.push_str(default);
424 } else {
425 return Err(ConfigError::MissingEnvVar(var_name.to_string()));
426 }
427 }
428 }
429 }
430 _ => {
431 result.push('$');
433 }
434 }
435 } else {
436 result.push(ch);
437 }
438 }
439
440 Ok(result)
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450
451 unsafe fn set_env(key: &str, val: &str) {
454 unsafe { std::env::set_var(key, val) };
455 }
456
457 unsafe fn unset_env(key: &str) {
458 unsafe { std::env::remove_var(key) };
459 }
460
461 #[test]
462 fn test_expand_env_required_var() {
463 unsafe { set_env("QAIL_TEST_VAR", "hello") };
464 let result = expand_env("prefix_${QAIL_TEST_VAR}_suffix").unwrap();
465 assert_eq!(result, "prefix_hello_suffix");
466 unsafe { unset_env("QAIL_TEST_VAR") };
467 }
468
469 #[test]
470 fn test_expand_env_missing_required() {
471 unsafe { unset_env("QAIL_MISSING_VAR_XYZ") };
472 let result = expand_env("${QAIL_MISSING_VAR_XYZ}");
473 assert!(result.is_err());
474 assert!(
475 matches!(result, Err(ConfigError::MissingEnvVar(ref v)) if v == "QAIL_MISSING_VAR_XYZ")
476 );
477 }
478
479 #[test]
480 fn test_expand_env_default_value() {
481 unsafe { unset_env("QAIL_OPT_VAR") };
482 let result = expand_env("${QAIL_OPT_VAR:-fallback}").unwrap();
483 assert_eq!(result, "fallback");
484 }
485
486 #[test]
487 fn test_expand_env_default_empty() {
488 unsafe { unset_env("QAIL_OPT_EMPTY") };
489 let result = expand_env("${QAIL_OPT_EMPTY:-}").unwrap();
490 assert_eq!(result, "");
491 }
492
493 #[test]
494 fn test_expand_env_set_overrides_default() {
495 unsafe { set_env("QAIL_SET_VAR", "real") };
496 let result = expand_env("${QAIL_SET_VAR:-fallback}").unwrap();
497 assert_eq!(result, "real");
498 unsafe { unset_env("QAIL_SET_VAR") };
499 }
500
501 #[test]
502 fn test_expand_env_escaped_dollar() {
503 let result = expand_env("price: $$100").unwrap();
504 assert_eq!(result, "price: $100");
505 }
506
507 #[test]
508 fn test_expand_env_no_expansion() {
509 let result = expand_env("plain text no vars").unwrap();
510 assert_eq!(result, "plain text no vars");
511 }
512
513 #[test]
514 fn test_expand_env_postgres_url() {
515 unsafe { set_env("QAIL_DB_USER", "admin") };
516 unsafe { set_env("QAIL_DB_PASS", "s3cret") };
517 let result =
518 expand_env("postgres://${QAIL_DB_USER}:${QAIL_DB_PASS}@localhost:5432/mydb").unwrap();
519 assert_eq!(result, "postgres://admin:s3cret@localhost:5432/mydb");
520 unsafe { unset_env("QAIL_DB_USER") };
521 unsafe { unset_env("QAIL_DB_PASS") };
522 }
523
524 #[test]
525 fn test_parse_minimal_toml() {
526 let toml_str = r#"
527[project]
528name = "test"
529mode = "postgres"
530
531[postgres]
532url = "postgres://localhost/test"
533"#;
534 let config: QailConfig = toml::from_str(toml_str).unwrap();
535 assert_eq!(config.project.name, "test");
536 assert_eq!(config.postgres.url, "postgres://localhost/test");
537 assert_eq!(config.postgres.max_connections, 10); assert!(config.qdrant.is_none());
539 assert!(config.gateway.is_none());
540 }
541
542 #[test]
543 fn test_parse_full_toml() {
544 let toml_str = r#"
545[project]
546name = "fulltest"
547mode = "hybrid"
548schema = "schema.qail"
549migrations_dir = "deltas"
550schema_strict_manifest = true
551
552[postgres]
553url = "postgres://localhost/test"
554max_connections = 25
555min_connections = 5
556idle_timeout_secs = 300
557
558[postgres.rls]
559default_role = "app_user"
560super_admin_role = "super_admin"
561
562[qdrant]
563url = "http://qdrant:6333"
564grpc = "qdrant:6334"
565max_connections = 15
566
567[gateway]
568bind = "0.0.0.0:9090"
569cors = false
570policy = "policies.yaml"
571
572[gateway.cache]
573enabled = true
574max_entries = 5000
575ttl_secs = 120
576
577[[sync]]
578source_table = "products"
579target_collection = "products_search"
580trigger_column = "description"
581embedding_model = "candle:bert-base"
582"#;
583 let config: QailConfig = toml::from_str(toml_str).unwrap();
584 assert_eq!(config.project.name, "fulltest");
585 assert_eq!(config.project.schema_strict_manifest, Some(true));
586 assert_eq!(config.postgres.max_connections, 25);
587 assert_eq!(config.postgres.min_connections, 5);
588
589 let rls = config.postgres.rls.unwrap();
590 assert_eq!(rls.default_role.unwrap(), "app_user");
591
592 let qdrant = config.qdrant.unwrap();
593 assert_eq!(qdrant.max_connections, 15);
594
595 let gw = config.gateway.unwrap();
596 assert_eq!(gw.bind, "0.0.0.0:9090");
597 assert!(!gw.cors);
598
599 let cache = gw.cache.unwrap();
600 assert_eq!(cache.max_entries, 5000);
601
602 assert_eq!(config.sync.len(), 1);
603 assert_eq!(config.sync[0].source_table, "products");
604 }
605
606 #[test]
607 fn test_backward_compat_existing_toml() {
608 let toml_str = r#"
610[project]
611name = "legacy"
612mode = "postgres"
613
614[postgres]
615url = "postgres://localhost/legacy"
616"#;
617 let config: QailConfig = toml::from_str(toml_str).unwrap();
618 assert_eq!(config.project.name, "legacy");
619 assert_eq!(config.postgres.url, "postgres://localhost/legacy");
620 assert_eq!(config.postgres.max_connections, 10);
622 assert!(config.postgres.rls.is_none());
623 assert!(config.qdrant.is_none());
624 assert!(config.gateway.is_none());
625 }
626}