1use serde::Deserialize;
13use std::path::Path;
14
15#[derive(Debug, thiserror::Error)]
17pub enum ConfigError {
18 #[error("Config file not found: {0}")]
19 NotFound(String),
20
21 #[error("Failed to read config: {0}")]
22 Read(#[from] std::io::Error),
23
24 #[error("Failed to parse TOML: {0}")]
25 Parse(#[from] toml::de::Error),
26
27 #[error("Missing required environment variable: {0}")]
28 MissingEnvVar(String),
29}
30
31pub type ConfigResult<T> = Result<T, ConfigError>;
32
33#[derive(Debug, Clone, Default, Deserialize)]
41pub struct QailConfig {
42 #[serde(default)]
43 pub project: ProjectConfig,
44
45 #[serde(default)]
46 pub postgres: PostgresConfig,
47
48 #[serde(default)]
49 pub redis: Option<RedisConfig>,
50
51 #[serde(default)]
52 pub qdrant: Option<QdrantConfig>,
53
54 #[serde(default)]
55 pub gateway: Option<GatewayConfig>,
56
57 #[serde(default)]
58 pub sync: Vec<SyncRule>,
59}
60
61#[derive(Debug, Clone, Deserialize)]
67pub struct ProjectConfig {
68 #[serde(default = "default_project_name")]
69 pub name: String,
70
71 #[serde(default = "default_mode")]
72 pub mode: String,
73
74 pub schema: Option<String>,
76
77 pub migrations_dir: Option<String>,
79}
80
81impl Default for ProjectConfig {
82 fn default() -> Self {
83 Self {
84 name: default_project_name(),
85 mode: default_mode(),
86 schema: None,
87 migrations_dir: None,
88 }
89 }
90}
91
92fn default_project_name() -> String { "qail-app".to_string() }
93fn default_mode() -> String { "postgres".to_string() }
94
95#[derive(Debug, Clone, Deserialize)]
97pub struct PostgresConfig {
98 #[serde(default = "default_pg_url")]
100 pub url: String,
101
102 #[serde(default = "default_max_connections")]
103 pub max_connections: usize,
104
105 #[serde(default = "default_min_connections")]
106 pub min_connections: usize,
107
108 #[serde(default = "default_idle_timeout")]
109 pub idle_timeout_secs: u64,
110
111 #[serde(default = "default_acquire_timeout")]
112 pub acquire_timeout_secs: u64,
113
114 #[serde(default = "default_connect_timeout")]
115 pub connect_timeout_secs: u64,
116
117 #[serde(default)]
118 pub test_on_acquire: bool,
119
120 #[serde(default)]
122 pub rls: Option<RlsConfig>,
123
124 #[serde(default)]
126 pub ssh: Option<String>,
127}
128
129impl Default for PostgresConfig {
130 fn default() -> Self {
131 Self {
132 url: default_pg_url(),
133 max_connections: default_max_connections(),
134 min_connections: default_min_connections(),
135 idle_timeout_secs: default_idle_timeout(),
136 acquire_timeout_secs: default_acquire_timeout(),
137 connect_timeout_secs: default_connect_timeout(),
138 test_on_acquire: false,
139 rls: None,
140 ssh: None,
141 }
142 }
143}
144
145fn default_pg_url() -> String { "postgres://postgres@localhost:5432/postgres".to_string() }
146fn default_max_connections() -> usize { 10 }
147fn default_min_connections() -> usize { 1 }
148fn default_idle_timeout() -> u64 { 600 }
149fn default_acquire_timeout() -> u64 { 30 }
150fn default_connect_timeout() -> u64 { 10 }
151
152#[derive(Debug, Clone, Default, Deserialize)]
154pub struct RlsConfig {
155 pub default_role: Option<String>,
157
158 pub super_admin_role: Option<String>,
160}
161
162#[derive(Debug, Clone, Deserialize)]
164pub struct RedisConfig {
165 #[serde(default = "default_redis_host")]
166 pub host: String,
167
168 #[serde(default = "default_redis_port")]
169 pub port: u16,
170
171 #[serde(default = "default_max_connections")]
172 pub max_connections: usize,
173
174 pub password: Option<String>,
176}
177
178fn default_redis_host() -> String { "127.0.0.1".to_string() }
179fn default_redis_port() -> u16 { 6379 }
180
181#[derive(Debug, Clone, Deserialize)]
183pub struct QdrantConfig {
184 #[serde(default = "default_qdrant_url")]
185 pub url: String,
186
187 pub grpc: Option<String>,
189
190 #[serde(default = "default_max_connections")]
191 pub max_connections: usize,
192}
193
194fn default_qdrant_url() -> String { "http://localhost:6333".to_string() }
195
196#[derive(Debug, Clone, Deserialize)]
198pub struct GatewayConfig {
199 #[serde(default = "default_bind")]
200 pub bind: String,
201
202 #[serde(default = "default_true")]
203 pub cors: bool,
204
205 pub policy: Option<String>,
207
208 #[serde(default)]
209 pub cache: Option<CacheConfig>,
210}
211
212fn default_bind() -> String { "0.0.0.0:8080".to_string() }
213fn default_true() -> bool { true }
214
215#[derive(Debug, Clone, Deserialize)]
217pub struct CacheConfig {
218 #[serde(default = "default_true")]
219 pub enabled: bool,
220
221 #[serde(default = "default_cache_max")]
222 pub max_entries: usize,
223
224 #[serde(default = "default_cache_ttl")]
225 pub ttl_secs: u64,
226}
227
228fn default_cache_max() -> usize { 1000 }
229fn default_cache_ttl() -> u64 { 60 }
230
231#[derive(Debug, Clone, Deserialize)]
233pub struct SyncRule {
234 pub source_table: String,
235 pub target_collection: String,
236
237 #[serde(default)]
238 pub trigger_column: Option<String>,
239
240 #[serde(default)]
241 pub embedding_model: Option<String>,
242}
243
244impl QailConfig {
249 pub fn load() -> ConfigResult<Self> {
251 Self::load_from("qail.toml")
252 }
253
254 pub fn load_from(path: impl AsRef<Path>) -> ConfigResult<Self> {
256 let path = path.as_ref();
257
258 if !path.exists() {
259 return Err(ConfigError::NotFound(path.display().to_string()));
260 }
261
262 let raw = std::fs::read_to_string(path)?;
263
264 let expanded = expand_env(&raw)?;
266
267 let mut config: QailConfig = toml::from_str(&expanded)?;
269
270 config.apply_env_overrides();
272
273 Ok(config)
274 }
275
276 pub fn postgres_url(&self) -> &str {
278 &self.postgres.url
279 }
280
281 fn apply_env_overrides(&mut self) {
283 if let Ok(url) = std::env::var("DATABASE_URL") {
285 self.postgres.url = url;
286 }
287
288 if let Ok(url) = std::env::var("REDIS_URL") {
290 let redis = self.redis.get_or_insert(RedisConfig {
291 host: default_redis_host(),
292 port: default_redis_port(),
293 max_connections: default_max_connections(),
294 password: None,
295 });
296 if let Some((host, port_str)) = url.rsplit_once(':') {
298 if let Ok(port) = port_str.parse::<u16>() {
299 redis.host = host.to_string();
300 redis.port = port;
301 } else {
302 redis.host = url;
303 }
304 } else {
305 redis.host = url;
306 }
307 }
308
309 if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
311 q.url = url;
312 }
313
314 if let (Ok(bind), Some(ref mut gw)) = (std::env::var("QAIL_BIND"), self.gateway.as_mut()) {
316 gw.bind = bind;
317 }
318 }
319}
320
321pub fn expand_env(input: &str) -> ConfigResult<String> {
331 let mut result = String::with_capacity(input.len());
332 let mut chars = input.chars().peekable();
333
334 while let Some(ch) = chars.next() {
335 if ch == '$' {
336 match chars.peek() {
337 Some('$') => {
338 chars.next();
340 result.push('$');
341 }
342 Some('{') => {
343 chars.next(); let mut var_expr = String::new();
345 let mut depth = 1;
346
347 for c in chars.by_ref() {
348 if c == '{' {
349 depth += 1;
350 } else if c == '}' {
351 depth -= 1;
352 if depth == 0 {
353 break;
354 }
355 }
356 var_expr.push(c);
357 }
358
359 let (var_name, default_val) = if let Some(idx) = var_expr.find(":-") {
361 (&var_expr[..idx], Some(&var_expr[idx + 2..]))
362 } else {
363 (var_expr.as_str(), None)
364 };
365
366 match std::env::var(var_name) {
367 Ok(val) => result.push_str(&val),
368 Err(_) => {
369 if let Some(default) = default_val {
370 result.push_str(default);
371 } else {
372 return Err(ConfigError::MissingEnvVar(var_name.to_string()));
373 }
374 }
375 }
376 }
377 _ => {
378 result.push('$');
380 }
381 }
382 } else {
383 result.push(ch);
384 }
385 }
386
387 Ok(result)
388}
389
390#[cfg(test)]
395mod tests {
396 use super::*;
397
398 unsafe fn set_env(key: &str, val: &str) {
401 unsafe { std::env::set_var(key, val) };
402 }
403
404 unsafe fn unset_env(key: &str) {
405 unsafe { std::env::remove_var(key) };
406 }
407
408 #[test]
409 fn test_expand_env_required_var() {
410 unsafe { set_env("QAIL_TEST_VAR", "hello") };
411 let result = expand_env("prefix_${QAIL_TEST_VAR}_suffix").unwrap();
412 assert_eq!(result, "prefix_hello_suffix");
413 unsafe { unset_env("QAIL_TEST_VAR") };
414 }
415
416 #[test]
417 fn test_expand_env_missing_required() {
418 unsafe { unset_env("QAIL_MISSING_VAR_XYZ") };
419 let result = expand_env("${QAIL_MISSING_VAR_XYZ}");
420 assert!(result.is_err());
421 assert!(
422 matches!(result, Err(ConfigError::MissingEnvVar(ref v)) if v == "QAIL_MISSING_VAR_XYZ")
423 );
424 }
425
426 #[test]
427 fn test_expand_env_default_value() {
428 unsafe { unset_env("QAIL_OPT_VAR") };
429 let result = expand_env("${QAIL_OPT_VAR:-fallback}").unwrap();
430 assert_eq!(result, "fallback");
431 }
432
433 #[test]
434 fn test_expand_env_default_empty() {
435 unsafe { unset_env("QAIL_OPT_EMPTY") };
436 let result = expand_env("${QAIL_OPT_EMPTY:-}").unwrap();
437 assert_eq!(result, "");
438 }
439
440 #[test]
441 fn test_expand_env_set_overrides_default() {
442 unsafe { set_env("QAIL_SET_VAR", "real") };
443 let result = expand_env("${QAIL_SET_VAR:-fallback}").unwrap();
444 assert_eq!(result, "real");
445 unsafe { unset_env("QAIL_SET_VAR") };
446 }
447
448 #[test]
449 fn test_expand_env_escaped_dollar() {
450 let result = expand_env("price: $$100").unwrap();
451 assert_eq!(result, "price: $100");
452 }
453
454 #[test]
455 fn test_expand_env_no_expansion() {
456 let result = expand_env("plain text no vars").unwrap();
457 assert_eq!(result, "plain text no vars");
458 }
459
460 #[test]
461 fn test_expand_env_postgres_url() {
462 unsafe { set_env("QAIL_DB_USER", "admin") };
463 unsafe { set_env("QAIL_DB_PASS", "s3cret") };
464 let result =
465 expand_env("postgres://${QAIL_DB_USER}:${QAIL_DB_PASS}@localhost:5432/mydb").unwrap();
466 assert_eq!(result, "postgres://admin:s3cret@localhost:5432/mydb");
467 unsafe { unset_env("QAIL_DB_USER") };
468 unsafe { unset_env("QAIL_DB_PASS") };
469 }
470
471 #[test]
472 fn test_parse_minimal_toml() {
473 let toml_str = r#"
474[project]
475name = "test"
476mode = "postgres"
477
478[postgres]
479url = "postgres://localhost/test"
480"#;
481 let config: QailConfig = toml::from_str(toml_str).unwrap();
482 assert_eq!(config.project.name, "test");
483 assert_eq!(config.postgres.url, "postgres://localhost/test");
484 assert_eq!(config.postgres.max_connections, 10); assert!(config.redis.is_none());
486 assert!(config.gateway.is_none());
487 }
488
489 #[test]
490 fn test_parse_full_toml() {
491 let toml_str = r#"
492[project]
493name = "fulltest"
494mode = "hybrid"
495schema = "schema.qail"
496migrations_dir = "deltas"
497
498[postgres]
499url = "postgres://localhost/test"
500max_connections = 25
501min_connections = 5
502idle_timeout_secs = 300
503
504[postgres.rls]
505default_role = "app_user"
506super_admin_role = "super_admin"
507
508[redis]
509host = "10.0.0.1"
510port = 6380
511max_connections = 20
512
513[qdrant]
514url = "http://qdrant:6333"
515grpc = "qdrant:6334"
516max_connections = 15
517
518[gateway]
519bind = "0.0.0.0:9090"
520cors = false
521policy = "policies.yaml"
522
523[gateway.cache]
524enabled = true
525max_entries = 5000
526ttl_secs = 120
527
528[[sync]]
529source_table = "products"
530target_collection = "products_search"
531trigger_column = "description"
532embedding_model = "candle:bert-base"
533"#;
534 let config: QailConfig = toml::from_str(toml_str).unwrap();
535 assert_eq!(config.project.name, "fulltest");
536 assert_eq!(config.postgres.max_connections, 25);
537 assert_eq!(config.postgres.min_connections, 5);
538
539 let rls = config.postgres.rls.unwrap();
540 assert_eq!(rls.default_role.unwrap(), "app_user");
541
542 let redis = config.redis.unwrap();
543 assert_eq!(redis.host, "10.0.0.1");
544 assert_eq!(redis.port, 6380);
545
546 let qdrant = config.qdrant.unwrap();
547 assert_eq!(qdrant.max_connections, 15);
548
549 let gw = config.gateway.unwrap();
550 assert_eq!(gw.bind, "0.0.0.0:9090");
551 assert!(!gw.cors);
552
553 let cache = gw.cache.unwrap();
554 assert_eq!(cache.max_entries, 5000);
555
556 assert_eq!(config.sync.len(), 1);
557 assert_eq!(config.sync[0].source_table, "products");
558 }
559
560 #[test]
561 fn test_backward_compat_existing_toml() {
562 let toml_str = r#"
564[project]
565name = "legacy"
566mode = "postgres"
567
568[postgres]
569url = "postgres://localhost/legacy"
570"#;
571 let config: QailConfig = toml::from_str(toml_str).unwrap();
572 assert_eq!(config.project.name, "legacy");
573 assert_eq!(config.postgres.url, "postgres://localhost/legacy");
574 assert_eq!(config.postgres.max_connections, 10);
576 assert!(config.postgres.rls.is_none());
577 assert!(config.redis.is_none());
578 assert!(config.qdrant.is_none());
579 assert!(config.gateway.is_none());
580 }
581}
582