Skip to main content

qail_core/
config.rs

1//! Centralized configuration for the Qail ecosystem.
2//!
3//! Reads `qail.toml` with env-expansion (`${VAR}`, `${VAR:-default}`)
4//! and layered priority: Env > TOML > Defaults.
5//!
6//! # Example
7//! ```ignore
8//! let config = QailConfig::load()?;
9//! let pg_url = config.postgres_url();
10//! ```
11
12use serde::Deserialize;
13use std::path::Path;
14
15/// Error type for configuration loading.
16#[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// ────────────────────────────────────────────────────────────
34// Top-level config
35// ────────────────────────────────────────────────────────────
36
37/// Root config — deserialized from `qail.toml`.
38///
39/// All sections are optional for backward compatibility.
40#[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// ────────────────────────────────────────────────────────────
62// Section structs
63// ────────────────────────────────────────────────────────────
64
65/// `[project]` — project metadata.
66#[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    /// Default `.qail` schema file path.
75    pub schema: Option<String>,
76
77    /// Migrations directory override (default: `deltas/`).
78    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/// `[postgres]` — PostgreSQL connection and pool settings.
96#[derive(Debug, Clone, Deserialize)]
97pub struct PostgresConfig {
98    /// Connection URL. Supports `${VAR}` expansion.
99    #[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    /// RLS defaults.
121    #[serde(default)]
122    pub rls: Option<RlsConfig>,
123
124    /// SSH tunnel host for remote connections (e.g., "sailtix" or "user@host").
125    #[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/// `[postgres.rls]` — RLS default settings.
153#[derive(Debug, Clone, Default, Deserialize)]
154pub struct RlsConfig {
155    /// Postgres role used for application connections.
156    pub default_role: Option<String>,
157
158    /// Role name that bypasses RLS.
159    pub super_admin_role: Option<String>,
160}
161
162/// `[redis]` — Redis connection settings.
163#[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    /// Optional password (supports `${VAR:-}` expansion).
175    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/// `[qdrant]` — Qdrant connection settings.
182#[derive(Debug, Clone, Deserialize)]
183pub struct QdrantConfig {
184    #[serde(default = "default_qdrant_url")]
185    pub url: String,
186
187    /// gRPC endpoint (defaults to port 6334).
188    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/// `[gateway]` — Gateway server settings.
197#[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    /// Path to policy file.
206    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/// `[gateway.cache]` — query cache settings.
216#[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/// `[[sync]]` — Qdrant sync rule (unchanged from existing CLI).
232#[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
244// ────────────────────────────────────────────────────────────
245// Config loading
246// ────────────────────────────────────────────────────────────
247
248impl QailConfig {
249    /// Load config from `./qail.toml` in the current directory.
250    pub fn load() -> ConfigResult<Self> {
251        Self::load_from("qail.toml")
252    }
253
254    /// Load config from a specific file path.
255    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        // Phase 1: Expand ${VAR} and ${VAR:-default} in raw TOML text
265        let expanded = expand_env(&raw)?;
266
267        // Phase 2: Parse TOML
268        let mut config: QailConfig = toml::from_str(&expanded)?;
269
270        // Phase 3: Apply env var overrides (highest priority)
271        config.apply_env_overrides();
272
273        Ok(config)
274    }
275
276    /// Convenience: get the resolved PostgreSQL URL.
277    pub fn postgres_url(&self) -> &str {
278        &self.postgres.url
279    }
280
281    /// Apply env var overrides (env > TOML > defaults).
282    fn apply_env_overrides(&mut self) {
283        // DATABASE_URL overrides postgres.url
284        if let Ok(url) = std::env::var("DATABASE_URL") {
285            self.postgres.url = url;
286        }
287
288        // REDIS_URL overrides redis host+port (format: host:port)
289        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            // Parse host:port or just host
297            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        // QDRANT_URL overrides qdrant.url
310        if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
311            q.url = url;
312        }
313
314        // QAIL_BIND overrides gateway.bind
315        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
321// ────────────────────────────────────────────────────────────
322// Env expansion
323// ────────────────────────────────────────────────────────────
324
325/// Expand `${VAR}` and `${VAR:-default}` patterns in a string.
326///
327/// - `${VAR}` — required, errors if not set
328/// - `${VAR:-default}` — optional, uses `default` if not set
329/// - `$$` — literal `$`
330pub 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                    // Escaped: $$ → $
339                    chars.next();
340                    result.push('$');
341                }
342                Some('{') => {
343                    chars.next(); // consume '{'
344                    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                    // Parse VAR:-default
360                    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                    // Plain `$` not followed by `{` or `$`, keep as-is
379                    result.push('$');
380                }
381            }
382        } else {
383            result.push(ch);
384        }
385    }
386
387    Ok(result)
388}
389
390// ────────────────────────────────────────────────────────────
391// Tests
392// ────────────────────────────────────────────────────────────
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// Helper: safely set and remove env vars in tests.
399    /// SAFETY: Tests run with `--test-threads=1` or use unique var names.
400    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); // default
485        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        // Existing qail.toml format must still parse
563        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        // All new fields should have defaults
575        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