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    /// Config file not found.
19    #[error("Config file not found: {0}")]
20    NotFound(String),
21
22    /// I/O error reading config.
23    #[error("Failed to read config: {0}")]
24    Read(#[from] std::io::Error),
25
26    /// TOML parse error.
27    #[error("Failed to parse TOML: {0}")]
28    Parse(#[from] toml::de::Error),
29
30    /// Required env var not set.
31    #[error("Missing required environment variable: {0}")]
32    MissingEnvVar(String),
33}
34
35/// Result alias for config operations.
36pub type ConfigResult<T> = Result<T, ConfigError>;
37
38// ────────────────────────────────────────────────────────────
39// Top-level config
40// ────────────────────────────────────────────────────────────
41
42/// Root config — deserialized from `qail.toml`.
43///
44/// All sections are optional for backward compatibility.
45#[derive(Debug, Clone, Default, Deserialize)]
46pub struct QailConfig {
47    /// `[project]` section.
48    #[serde(default)]
49    pub project: ProjectConfig,
50
51    /// `[postgres]` section.
52    #[serde(default)]
53    pub postgres: PostgresConfig,
54
55    /// `[qdrant]` section (optional).
56    #[serde(default)]
57    pub qdrant: Option<QdrantConfig>,
58
59    /// `[gateway]` section (optional).
60    #[serde(default)]
61    pub gateway: Option<GatewayConfig>,
62
63    /// `[[sync]]` rules.
64    #[serde(default)]
65    pub sync: Vec<SyncRule>,
66}
67
68// ────────────────────────────────────────────────────────────
69// Section structs
70// ────────────────────────────────────────────────────────────
71
72/// `[project]` — project metadata.
73#[derive(Debug, Clone, Deserialize)]
74pub struct ProjectConfig {
75    /// Project name.
76    #[serde(default = "default_project_name")]
77    pub name: String,
78
79    /// Database mode (`postgres`, `hybrid`).
80    #[serde(default = "default_mode")]
81    pub mode: String,
82
83    /// Default `.qail` schema file path.
84    pub schema: Option<String>,
85
86    /// Migrations directory override (default: `deltas/`).
87    pub migrations_dir: Option<String>,
88
89    /// Enforce strict `_order.qail` manifest coverage by default.
90    ///
91    /// When true, all modules under `schema/` must be explicitly listed
92    /// (directly or via listed directories) in `_order.qail`.
93    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/// `[postgres]` — PostgreSQL connection and pool settings.
116#[derive(Debug, Clone, Deserialize)]
117pub struct PostgresConfig {
118    /// Connection URL. Supports `${VAR}` expansion.
119    #[serde(default = "default_pg_url")]
120    pub url: String,
121
122    /// Maximum pool connections.
123    #[serde(default = "default_max_connections")]
124    pub max_connections: usize,
125
126    /// Minimum idle connections.
127    #[serde(default = "default_min_connections")]
128    pub min_connections: usize,
129
130    /// Idle connection timeout in seconds.
131    #[serde(default = "default_idle_timeout")]
132    pub idle_timeout_secs: u64,
133
134    /// Connection acquire timeout in seconds.
135    #[serde(default = "default_acquire_timeout")]
136    pub acquire_timeout_secs: u64,
137
138    /// TCP connect timeout in seconds.
139    #[serde(default = "default_connect_timeout")]
140    pub connect_timeout_secs: u64,
141
142    /// Whether to test connections on acquire.
143    #[serde(default)]
144    pub test_on_acquire: bool,
145
146    /// RLS defaults.
147    #[serde(default)]
148    pub rls: Option<RlsConfig>,
149
150    /// SSH tunnel host for remote connections (e.g., "myserver" or "user@host").
151    #[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/// `[postgres.rls]` — RLS default settings.
191#[derive(Debug, Clone, Default, Deserialize)]
192pub struct RlsConfig {
193    /// Postgres role used for application connections.
194    pub default_role: Option<String>,
195
196    /// Role name that bypasses RLS.
197    pub super_admin_role: Option<String>,
198}
199
200/// `[qdrant]` — Qdrant connection settings.
201#[derive(Debug, Clone, Deserialize)]
202pub struct QdrantConfig {
203    /// Qdrant HTTP URL.
204    #[serde(default = "default_qdrant_url")]
205    pub url: String,
206
207    /// gRPC endpoint (defaults to port 6334).
208    pub grpc: Option<String>,
209
210    /// Max connections.
211    #[serde(default = "default_max_connections")]
212    pub max_connections: usize,
213
214    /// Use TLS for gRPC connections.
215    /// - `None` (default) → auto-detect from URL scheme (`https://` = TLS)
216    /// - `Some(true)` → force TLS
217    /// - `Some(false)` → force plain TCP
218    #[serde(default)]
219    pub tls: Option<bool>,
220}
221
222fn default_qdrant_url() -> String {
223    "http://localhost:6333".to_string()
224}
225
226/// `[gateway]` — Gateway server settings.
227#[derive(Debug, Clone, Deserialize)]
228pub struct GatewayConfig {
229    /// Bind address.
230    #[serde(default = "default_bind")]
231    pub bind: String,
232
233    /// Enable CORS.
234    #[serde(default = "default_true")]
235    pub cors: bool,
236
237    /// Allowed CORS origins. Empty = allow all.
238    #[serde(default)]
239    pub cors_allowed_origins: Option<Vec<String>>,
240
241    /// Path to policy file.
242    pub policy: Option<String>,
243
244    /// Query cache settings.
245    #[serde(default)]
246    pub cache: Option<CacheConfig>,
247
248    /// Maximum number of relations in `?expand=` (default: 4).
249    /// Prevents query explosion from unbounded LEFT JOINs.
250    #[serde(default = "default_max_expand_depth")]
251    pub max_expand_depth: usize,
252
253    /// Tables to block from auto-REST endpoint generation.
254    /// Blocked tables will not have any CRUD routes, cannot be referenced
255    /// via `?expand=`, and cannot appear as nested route targets.
256    /// Use this to hide sensitive tables (e.g., `users`) from the HTTP API.
257    #[serde(default)]
258    pub blocked_tables: Option<Vec<String>>,
259
260    /// Tables to allow for auto-REST endpoint generation (whitelist mode).
261    /// When set, ONLY these tables are exposed — all others are blocked.
262    /// This is a fail-closed approach: new tables must be explicitly allowed.
263    /// Takes precedence over `blocked_tables` if both are set.
264    #[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/// `[gateway.cache]` — query cache settings.
279#[derive(Debug, Clone, Deserialize)]
280pub struct CacheConfig {
281    /// Whether caching is enabled.
282    #[serde(default = "default_true")]
283    pub enabled: bool,
284
285    /// Maximum cache entries.
286    #[serde(default = "default_cache_max")]
287    pub max_entries: usize,
288
289    /// Default TTL in seconds.
290    #[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/// `[[sync]]` — Qdrant sync rule (unchanged from existing CLI).
302#[derive(Debug, Clone, Deserialize)]
303pub struct SyncRule {
304    /// PostgreSQL source table.
305    pub source_table: String,
306    /// Qdrant target collection.
307    pub target_collection: String,
308
309    /// Column that triggers re-sync.
310    #[serde(default)]
311    pub trigger_column: Option<String>,
312
313    /// Embedding model for sync.
314    #[serde(default)]
315    pub embedding_model: Option<String>,
316}
317
318// ────────────────────────────────────────────────────────────
319// Config loading
320// ────────────────────────────────────────────────────────────
321
322impl QailConfig {
323    /// Load config from `./qail.toml` in the current directory.
324    pub fn load() -> ConfigResult<Self> {
325        Self::load_from("qail.toml")
326    }
327
328    /// Load config from a specific file path.
329    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        // Phase 1: Expand ${VAR} and ${VAR:-default} in raw TOML text
339        let expanded = expand_env(&raw)?;
340
341        // Phase 2: Parse TOML
342        let mut config: QailConfig = toml::from_str(&expanded)?;
343
344        // Phase 3: Apply env var overrides (highest priority)
345        config.apply_env_overrides();
346
347        Ok(config)
348    }
349
350    /// Convenience: get the resolved PostgreSQL URL.
351    pub fn postgres_url(&self) -> &str {
352        &self.postgres.url
353    }
354
355    /// Apply env var overrides (env > TOML > defaults).
356    fn apply_env_overrides(&mut self) {
357        // DATABASE_URL overrides postgres.url
358        if let Ok(url) = std::env::var("DATABASE_URL") {
359            self.postgres.url = url;
360        }
361
362        // QDRANT_URL overrides qdrant.url
363        if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
364            q.url = url;
365        }
366
367        // QAIL_BIND overrides gateway.bind
368        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
374// ────────────────────────────────────────────────────────────
375// Env expansion
376// ────────────────────────────────────────────────────────────
377
378/// Expand `${VAR}` and `${VAR:-default}` patterns in a string.
379///
380/// - `${VAR}` — required, errors if not set
381/// - `${VAR:-default}` — optional, uses `default` if not set
382/// - `$$` — literal `$`
383pub 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                    // Escaped: $$ → $
392                    chars.next();
393                    result.push('$');
394                }
395                Some('{') => {
396                    chars.next(); // consume '{'
397                    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                    // Parse VAR:-default
413                    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                    // Plain `$` not followed by `{` or `$`, keep as-is
432                    result.push('$');
433                }
434            }
435        } else {
436            result.push(ch);
437        }
438    }
439
440    Ok(result)
441}
442
443// ────────────────────────────────────────────────────────────
444// Tests
445// ────────────────────────────────────────────────────────────
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    /// Helper: safely set and remove env vars in tests.
452    /// SAFETY: Tests run with `--test-threads=1` or use unique var names.
453    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); // default
538        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        // Existing qail.toml format must still parse
609        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        // All new fields should have defaults
621        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}