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 std::path::Path;
13
14/// Error type for configuration loading.
15#[derive(Debug)]
16pub enum ConfigError {
17    /// Config file not found.
18    NotFound(String),
19
20    /// I/O error reading config.
21    Read(std::io::Error),
22
23    /// TOML parse error.
24    Parse(toml::de::Error),
25
26    /// Invalid config structure or type.
27    Invalid(String),
28
29    /// Required env var not set.
30    MissingEnvVar(String),
31}
32
33impl std::fmt::Display for ConfigError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::NotFound(path) => write!(f, "Config file not found: {path}"),
37            Self::Read(err) => write!(f, "Failed to read config: {err}"),
38            Self::Parse(err) => write!(f, "Failed to parse TOML: {err}"),
39            Self::Invalid(msg) => write!(f, "Invalid qail.toml: {msg}"),
40            Self::MissingEnvVar(var) => {
41                write!(f, "Missing required environment variable: {var}")
42            }
43        }
44    }
45}
46
47impl std::error::Error for ConfigError {
48    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
49        match self {
50            Self::Read(err) => Some(err),
51            Self::Parse(err) => Some(err),
52            _ => None,
53        }
54    }
55}
56
57impl From<std::io::Error> for ConfigError {
58    fn from(value: std::io::Error) -> Self {
59        Self::Read(value)
60    }
61}
62
63impl From<toml::de::Error> for ConfigError {
64    fn from(value: toml::de::Error) -> Self {
65        Self::Parse(value)
66    }
67}
68
69/// Result alias for config operations.
70pub type ConfigResult<T> = Result<T, ConfigError>;
71
72// ────────────────────────────────────────────────────────────
73// Top-level config
74// ────────────────────────────────────────────────────────────
75
76/// Root config — deserialized from `qail.toml`.
77///
78/// All sections are optional for backward compatibility.
79#[derive(Debug, Clone, Default)]
80pub struct QailConfig {
81    /// `[project]` section.
82    pub project: ProjectConfig,
83
84    /// `[postgres]` section.
85    pub postgres: PostgresConfig,
86
87    /// `[qdrant]` section (optional).
88    pub qdrant: Option<QdrantConfig>,
89
90    /// `[gateway]` section (optional).
91    pub gateway: Option<GatewayConfig>,
92
93    /// `[[sync]]` rules.
94    pub sync: Vec<SyncRule>,
95}
96
97// ────────────────────────────────────────────────────────────
98// Section structs
99// ────────────────────────────────────────────────────────────
100
101/// `[project]` — project metadata.
102#[derive(Debug, Clone)]
103pub struct ProjectConfig {
104    /// Project name.
105    pub name: String,
106
107    /// Database mode (`postgres`, `hybrid`).
108    pub mode: String,
109
110    /// Default `.qail` schema file path.
111    pub schema: Option<String>,
112
113    /// Migrations directory override (default: `deltas/`).
114    pub migrations_dir: Option<String>,
115
116    /// Enforce strict `_order.qail` manifest coverage by default.
117    ///
118    /// When true, all modules under `schema/` must be explicitly listed
119    /// (directly or via listed directories) in `_order.qail`.
120    pub schema_strict_manifest: Option<bool>,
121}
122
123impl Default for ProjectConfig {
124    fn default() -> Self {
125        Self {
126            name: default_project_name(),
127            mode: default_mode(),
128            schema: None,
129            migrations_dir: None,
130            schema_strict_manifest: None,
131        }
132    }
133}
134
135fn default_project_name() -> String {
136    "qail-app".to_string()
137}
138
139fn default_mode() -> String {
140    "postgres".to_string()
141}
142
143/// `[postgres]` — PostgreSQL connection and pool settings.
144#[derive(Debug, Clone)]
145pub struct PostgresConfig {
146    /// Connection URL. Supports `${VAR}` expansion.
147    pub url: String,
148
149    /// Maximum pool connections.
150    pub max_connections: usize,
151
152    /// Minimum idle connections.
153    pub min_connections: usize,
154
155    /// Idle connection timeout in seconds.
156    pub idle_timeout_secs: u64,
157
158    /// Connection acquire timeout in seconds.
159    pub acquire_timeout_secs: u64,
160
161    /// TCP connect timeout in seconds.
162    pub connect_timeout_secs: u64,
163
164    /// Whether to test connections on acquire.
165    pub test_on_acquire: bool,
166
167    /// RLS defaults.
168    pub rls: Option<RlsConfig>,
169
170    /// SSH tunnel host for remote connections (e.g., "myserver" or "user@host").
171    pub ssh: Option<String>,
172}
173
174impl Default for PostgresConfig {
175    fn default() -> Self {
176        Self {
177            url: default_pg_url(),
178            max_connections: default_max_connections(),
179            min_connections: default_min_connections(),
180            idle_timeout_secs: default_idle_timeout(),
181            acquire_timeout_secs: default_acquire_timeout(),
182            connect_timeout_secs: default_connect_timeout(),
183            test_on_acquire: false,
184            rls: None,
185            ssh: None,
186        }
187    }
188}
189
190fn default_pg_url() -> String {
191    "postgres://postgres@localhost:5432/postgres".to_string()
192}
193
194fn default_max_connections() -> usize {
195    10
196}
197
198fn default_min_connections() -> usize {
199    1
200}
201
202fn default_idle_timeout() -> u64 {
203    600
204}
205
206fn default_acquire_timeout() -> u64 {
207    30
208}
209
210fn default_connect_timeout() -> u64 {
211    10
212}
213
214/// `[postgres.rls]` — RLS default settings.
215#[derive(Debug, Clone, Default)]
216pub struct RlsConfig {
217    /// Postgres role used for application connections.
218    pub default_role: Option<String>,
219
220    /// Role name that bypasses RLS.
221    pub super_admin_role: Option<String>,
222}
223
224/// `[qdrant]` — Qdrant connection settings.
225#[derive(Debug, Clone)]
226pub struct QdrantConfig {
227    /// Qdrant HTTP URL.
228    pub url: String,
229
230    /// gRPC endpoint (defaults to port 6334).
231    pub grpc: Option<String>,
232
233    /// Max connections.
234    pub max_connections: usize,
235
236    /// Use TLS for gRPC connections.
237    /// - `None` (default) → auto-detect from URL scheme (`https://` = TLS)
238    /// - `Some(true)` → force TLS
239    /// - `Some(false)` → force plain TCP
240    pub tls: Option<bool>,
241}
242
243impl Default for QdrantConfig {
244    fn default() -> Self {
245        Self {
246            url: default_qdrant_url(),
247            grpc: None,
248            max_connections: default_max_connections(),
249            tls: None,
250        }
251    }
252}
253
254fn default_qdrant_url() -> String {
255    "http://localhost:6333".to_string()
256}
257
258/// `[gateway]` — Gateway server settings.
259#[derive(Debug, Clone)]
260pub struct GatewayConfig {
261    /// Bind address.
262    pub bind: String,
263
264    /// Enable CORS.
265    pub cors: bool,
266
267    /// Allowed CORS origins. Empty = allow all.
268    pub cors_allowed_origins: Option<Vec<String>>,
269
270    /// Path to policy file.
271    pub policy: Option<String>,
272
273    /// Query cache settings.
274    pub cache: Option<CacheConfig>,
275
276    /// Maximum number of relations in `?expand=` (default: 4).
277    /// Prevents query explosion from unbounded LEFT JOINs.
278    pub max_expand_depth: usize,
279
280    /// Tables to block from auto-REST endpoint generation.
281    /// Blocked tables will not have any CRUD routes, cannot be referenced
282    /// via `?expand=`, and cannot appear as nested route targets.
283    /// Use this to hide sensitive tables (e.g., `users`) from the HTTP API.
284    pub blocked_tables: Option<Vec<String>>,
285
286    /// Tables to allow for auto-REST endpoint generation (whitelist mode).
287    /// When set, ONLY these tables are exposed — all others are blocked.
288    /// This is a fail-closed approach: new tables must be explicitly allowed.
289    /// Takes precedence over `blocked_tables` if both are set.
290    pub allowed_tables: Option<Vec<String>>,
291}
292
293impl Default for GatewayConfig {
294    fn default() -> Self {
295        Self {
296            bind: default_bind(),
297            cors: default_true(),
298            cors_allowed_origins: None,
299            policy: None,
300            cache: None,
301            max_expand_depth: default_max_expand_depth(),
302            blocked_tables: None,
303            allowed_tables: None,
304        }
305    }
306}
307
308fn default_bind() -> String {
309    "0.0.0.0:8080".to_string()
310}
311
312fn default_true() -> bool {
313    true
314}
315
316fn default_max_expand_depth() -> usize {
317    4
318}
319
320/// `[gateway.cache]` — query cache settings.
321#[derive(Debug, Clone)]
322pub struct CacheConfig {
323    /// Whether caching is enabled.
324    pub enabled: bool,
325
326    /// Maximum cache entries.
327    pub max_entries: usize,
328
329    /// Default TTL in seconds.
330    pub ttl_secs: u64,
331}
332
333impl Default for CacheConfig {
334    fn default() -> Self {
335        Self {
336            enabled: default_true(),
337            max_entries: default_cache_max(),
338            ttl_secs: default_cache_ttl(),
339        }
340    }
341}
342
343fn default_cache_max() -> usize {
344    1000
345}
346
347fn default_cache_ttl() -> u64 {
348    60
349}
350
351/// `[[sync]]` — Qdrant sync rule (unchanged from existing CLI).
352#[derive(Debug, Clone)]
353pub struct SyncRule {
354    /// PostgreSQL source table.
355    pub source_table: String,
356    /// Qdrant target collection.
357    pub target_collection: String,
358
359    /// Column that triggers re-sync.
360    pub trigger_column: Option<String>,
361
362    /// Embedding model for sync.
363    pub embedding_model: Option<String>,
364}
365
366// ────────────────────────────────────────────────────────────
367// Config loading
368// ────────────────────────────────────────────────────────────
369
370impl QailConfig {
371    /// Load config from `./qail.toml` in the current directory.
372    pub fn load() -> ConfigResult<Self> {
373        Self::load_from("qail.toml")
374    }
375
376    /// Load config from a specific file path.
377    pub fn load_from(path: impl AsRef<Path>) -> ConfigResult<Self> {
378        let path = path.as_ref();
379
380        if !path.exists() {
381            return Err(ConfigError::NotFound(path.display().to_string()));
382        }
383
384        let raw = std::fs::read_to_string(path)?;
385
386        // Phase 1: Expand ${VAR} and ${VAR:-default} in raw TOML text
387        let expanded = expand_env(&raw)?;
388
389        // Phase 2: Parse TOML table manually (serde-free)
390        let mut config = Self::from_toml_str(&expanded)?;
391
392        // Phase 3: Apply env var overrides (highest priority)
393        config.apply_env_overrides();
394
395        Ok(config)
396    }
397
398    /// Convenience: get the resolved PostgreSQL URL.
399    pub fn postgres_url(&self) -> &str {
400        &self.postgres.url
401    }
402
403    /// Apply env var overrides (env > TOML > defaults).
404    fn apply_env_overrides(&mut self) {
405        // DATABASE_URL overrides postgres.url
406        if let Ok(url) = std::env::var("DATABASE_URL") {
407            self.postgres.url = url;
408        }
409
410        // QDRANT_URL overrides qdrant.url
411        if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
412            q.url = url;
413        }
414
415        // QAIL_BIND overrides gateway.bind
416        if let (Ok(bind), Some(ref mut gw)) = (std::env::var("QAIL_BIND"), self.gateway.as_mut()) {
417            gw.bind = bind;
418        }
419    }
420
421    fn from_toml_str(input: &str) -> ConfigResult<Self> {
422        let value: toml::Value = toml::from_str(input)?;
423        let root = value
424            .as_table()
425            .ok_or_else(|| ConfigError::Invalid("root must be a TOML table".to_string()))?;
426
427        Ok(Self {
428            project: parse_project(root)?,
429            postgres: parse_postgres(root)?,
430            qdrant: parse_qdrant(root)?,
431            gateway: parse_gateway(root)?,
432            sync: parse_sync(root)?,
433        })
434    }
435}
436
437fn parse_project(root: &toml::Table) -> ConfigResult<ProjectConfig> {
438    let mut cfg = ProjectConfig::default();
439    let Some(tbl) = subtable(root, "project")? else {
440        return Ok(cfg);
441    };
442
443    if let Some(v) = opt_string(tbl, "project", "name")? {
444        cfg.name = v;
445    }
446    if let Some(v) = opt_string(tbl, "project", "mode")? {
447        cfg.mode = v;
448    }
449    cfg.schema = opt_string(tbl, "project", "schema")?;
450    cfg.migrations_dir = opt_string(tbl, "project", "migrations_dir")?;
451    cfg.schema_strict_manifest = opt_bool(tbl, "project", "schema_strict_manifest")?;
452
453    Ok(cfg)
454}
455
456fn parse_postgres(root: &toml::Table) -> ConfigResult<PostgresConfig> {
457    let mut cfg = PostgresConfig::default();
458    let Some(tbl) = subtable(root, "postgres")? else {
459        return Ok(cfg);
460    };
461
462    if let Some(v) = opt_string(tbl, "postgres", "url")? {
463        cfg.url = v;
464    }
465    if let Some(v) = opt_usize(tbl, "postgres", "max_connections")? {
466        cfg.max_connections = v;
467    }
468    if let Some(v) = opt_usize(tbl, "postgres", "min_connections")? {
469        cfg.min_connections = v;
470    }
471    if let Some(v) = opt_u64(tbl, "postgres", "idle_timeout_secs")? {
472        cfg.idle_timeout_secs = v;
473    }
474    if let Some(v) = opt_u64(tbl, "postgres", "acquire_timeout_secs")? {
475        cfg.acquire_timeout_secs = v;
476    }
477    if let Some(v) = opt_u64(tbl, "postgres", "connect_timeout_secs")? {
478        cfg.connect_timeout_secs = v;
479    }
480    if let Some(v) = opt_bool(tbl, "postgres", "test_on_acquire")? {
481        cfg.test_on_acquire = v;
482    }
483    cfg.ssh = opt_string(tbl, "postgres", "ssh")?;
484
485    cfg.rls = if let Some(rls_tbl) = nested_table(tbl, "postgres", "rls")? {
486        Some(RlsConfig {
487            default_role: opt_string(rls_tbl, "postgres.rls", "default_role")?,
488            super_admin_role: opt_string(rls_tbl, "postgres.rls", "super_admin_role")?,
489        })
490    } else {
491        None
492    };
493
494    Ok(cfg)
495}
496
497fn parse_qdrant(root: &toml::Table) -> ConfigResult<Option<QdrantConfig>> {
498    let Some(tbl) = subtable(root, "qdrant")? else {
499        return Ok(None);
500    };
501
502    let mut cfg = QdrantConfig::default();
503
504    if let Some(v) = opt_string(tbl, "qdrant", "url")? {
505        cfg.url = v;
506    }
507    cfg.grpc = opt_string(tbl, "qdrant", "grpc")?;
508    if let Some(v) = opt_usize(tbl, "qdrant", "max_connections")? {
509        cfg.max_connections = v;
510    }
511    cfg.tls = opt_bool(tbl, "qdrant", "tls")?;
512
513    Ok(Some(cfg))
514}
515
516fn parse_gateway(root: &toml::Table) -> ConfigResult<Option<GatewayConfig>> {
517    let Some(tbl) = subtable(root, "gateway")? else {
518        return Ok(None);
519    };
520
521    let mut cfg = GatewayConfig::default();
522
523    if let Some(v) = opt_string(tbl, "gateway", "bind")? {
524        cfg.bind = v;
525    }
526    if let Some(v) = opt_bool(tbl, "gateway", "cors")? {
527        cfg.cors = v;
528    }
529    cfg.cors_allowed_origins = opt_string_vec(tbl, "gateway", "cors_allowed_origins")?;
530    cfg.policy = opt_string(tbl, "gateway", "policy")?;
531    if let Some(v) = opt_usize(tbl, "gateway", "max_expand_depth")? {
532        cfg.max_expand_depth = v;
533    }
534    cfg.blocked_tables = opt_string_vec(tbl, "gateway", "blocked_tables")?;
535    cfg.allowed_tables = opt_string_vec(tbl, "gateway", "allowed_tables")?;
536
537    cfg.cache = if let Some(cache_tbl) = nested_table(tbl, "gateway", "cache")? {
538        let mut cache = CacheConfig::default();
539        if let Some(v) = opt_bool(cache_tbl, "gateway.cache", "enabled")? {
540            cache.enabled = v;
541        }
542        if let Some(v) = opt_usize(cache_tbl, "gateway.cache", "max_entries")? {
543            cache.max_entries = v;
544        }
545        if let Some(v) = opt_u64(cache_tbl, "gateway.cache", "ttl_secs")? {
546            cache.ttl_secs = v;
547        }
548        Some(cache)
549    } else {
550        None
551    };
552
553    Ok(Some(cfg))
554}
555
556fn parse_sync(root: &toml::Table) -> ConfigResult<Vec<SyncRule>> {
557    let Some(value) = root.get("sync") else {
558        return Ok(Vec::new());
559    };
560
561    let arr = value
562        .as_array()
563        .ok_or_else(|| ConfigError::Invalid("sync must be an array of tables".to_string()))?;
564
565    let mut out = Vec::with_capacity(arr.len());
566    for (idx, item) in arr.iter().enumerate() {
567        let path = format!("sync[{idx}]");
568        let tbl = item
569            .as_table()
570            .ok_or_else(|| ConfigError::Invalid(format!("{path} must be a table")))?;
571
572        out.push(SyncRule {
573            source_table: required_string(tbl, &path, "source_table")?,
574            target_collection: required_string(tbl, &path, "target_collection")?,
575            trigger_column: opt_string(tbl, &path, "trigger_column")?,
576            embedding_model: opt_string(tbl, &path, "embedding_model")?,
577        });
578    }
579
580    Ok(out)
581}
582
583fn subtable<'a>(root: &'a toml::Table, section: &str) -> ConfigResult<Option<&'a toml::Table>> {
584    match root.get(section) {
585        None => Ok(None),
586        Some(value) => value.as_table().map(Some).ok_or_else(|| {
587            ConfigError::Invalid(format!("{section} must be a table (e.g. [{section}])"))
588        }),
589    }
590}
591
592fn nested_table<'a>(
593    table: &'a toml::Table,
594    parent: &str,
595    key: &str,
596) -> ConfigResult<Option<&'a toml::Table>> {
597    match table.get(key) {
598        None => Ok(None),
599        Some(value) => value
600            .as_table()
601            .map(Some)
602            .ok_or_else(|| ConfigError::Invalid(format!("{parent}.{key} must be a table"))),
603    }
604}
605
606fn required_string(table: &toml::Table, section: &str, key: &str) -> ConfigResult<String> {
607    opt_string(table, section, key)?
608        .ok_or_else(|| ConfigError::Invalid(format!("{section}.{key} is required")))
609}
610
611fn opt_string(table: &toml::Table, section: &str, key: &str) -> ConfigResult<Option<String>> {
612    match table.get(key) {
613        None => Ok(None),
614        Some(value) => value
615            .as_str()
616            .map(|s| Some(s.to_string()))
617            .ok_or_else(|| ConfigError::Invalid(format!("{section}.{key} must be a string"))),
618    }
619}
620
621fn opt_bool(table: &toml::Table, section: &str, key: &str) -> ConfigResult<Option<bool>> {
622    match table.get(key) {
623        None => Ok(None),
624        Some(value) => value
625            .as_bool()
626            .map(Some)
627            .ok_or_else(|| ConfigError::Invalid(format!("{section}.{key} must be a boolean"))),
628    }
629}
630
631fn opt_usize(table: &toml::Table, section: &str, key: &str) -> ConfigResult<Option<usize>> {
632    match table.get(key) {
633        None => Ok(None),
634        Some(value) => {
635            let raw = value.as_integer().ok_or_else(|| {
636                ConfigError::Invalid(format!("{section}.{key} must be a non-negative integer"))
637            })?;
638            let converted = usize::try_from(raw).map_err(|_| {
639                ConfigError::Invalid(format!("{section}.{key} must be a non-negative integer"))
640            })?;
641            Ok(Some(converted))
642        }
643    }
644}
645
646fn opt_u64(table: &toml::Table, section: &str, key: &str) -> ConfigResult<Option<u64>> {
647    match table.get(key) {
648        None => Ok(None),
649        Some(value) => {
650            let raw = value.as_integer().ok_or_else(|| {
651                ConfigError::Invalid(format!("{section}.{key} must be a non-negative integer"))
652            })?;
653            let converted = u64::try_from(raw).map_err(|_| {
654                ConfigError::Invalid(format!("{section}.{key} must be a non-negative integer"))
655            })?;
656            Ok(Some(converted))
657        }
658    }
659}
660
661fn opt_string_vec(
662    table: &toml::Table,
663    section: &str,
664    key: &str,
665) -> ConfigResult<Option<Vec<String>>> {
666    let Some(value) = table.get(key) else {
667        return Ok(None);
668    };
669
670    let arr = value
671        .as_array()
672        .ok_or_else(|| ConfigError::Invalid(format!("{section}.{key} must be an array")))?;
673
674    let mut out = Vec::with_capacity(arr.len());
675    for (idx, item) in arr.iter().enumerate() {
676        let Some(s) = item.as_str() else {
677            return Err(ConfigError::Invalid(format!(
678                "{section}.{key}[{idx}] must be a string"
679            )));
680        };
681        out.push(s.to_string());
682    }
683
684    Ok(Some(out))
685}
686
687// ────────────────────────────────────────────────────────────
688// Env expansion
689// ────────────────────────────────────────────────────────────
690
691/// Expand `${VAR}` and `${VAR:-default}` patterns in a string.
692///
693/// - `${VAR}` — required, errors if not set
694/// - `${VAR:-default}` — optional, uses `default` if not set
695/// - `$$` — literal `$`
696pub fn expand_env(input: &str) -> ConfigResult<String> {
697    let mut result = String::with_capacity(input.len());
698    let mut chars = input.chars().peekable();
699
700    while let Some(ch) = chars.next() {
701        if ch == '$' {
702            match chars.peek() {
703                Some('$') => {
704                    // Escaped: $$ → $
705                    chars.next();
706                    result.push('$');
707                }
708                Some('{') => {
709                    chars.next(); // consume '{'
710                    let mut var_expr = String::new();
711                    let mut depth = 1;
712
713                    for c in chars.by_ref() {
714                        if c == '{' {
715                            depth += 1;
716                        } else if c == '}' {
717                            depth -= 1;
718                            if depth == 0 {
719                                break;
720                            }
721                        }
722                        var_expr.push(c);
723                    }
724
725                    // Parse VAR:-default
726                    let (var_name, default_val) = if let Some(idx) = var_expr.find(":-") {
727                        (&var_expr[..idx], Some(&var_expr[idx + 2..]))
728                    } else {
729                        (var_expr.as_str(), None)
730                    };
731
732                    match std::env::var(var_name) {
733                        Ok(val) => result.push_str(&val),
734                        Err(_) => {
735                            if let Some(default) = default_val {
736                                result.push_str(default);
737                            } else {
738                                return Err(ConfigError::MissingEnvVar(var_name.to_string()));
739                            }
740                        }
741                    }
742                }
743                _ => {
744                    // Plain `$` not followed by `{` or `$`, keep as-is
745                    result.push('$');
746                }
747            }
748        } else {
749            result.push(ch);
750        }
751    }
752
753    Ok(result)
754}
755
756// ────────────────────────────────────────────────────────────
757// Tests
758// ────────────────────────────────────────────────────────────
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763
764    /// Helper: safely set and remove env vars in tests.
765    /// SAFETY: Tests run with `--test-threads=1` or use unique var names.
766    unsafe fn set_env(key: &str, val: &str) {
767        unsafe { std::env::set_var(key, val) };
768    }
769
770    unsafe fn unset_env(key: &str) {
771        unsafe { std::env::remove_var(key) };
772    }
773
774    #[test]
775    fn test_expand_env_required_var() {
776        unsafe { set_env("QAIL_TEST_VAR", "hello") };
777        let result = expand_env("prefix_${QAIL_TEST_VAR}_suffix").unwrap();
778        assert_eq!(result, "prefix_hello_suffix");
779        unsafe { unset_env("QAIL_TEST_VAR") };
780    }
781
782    #[test]
783    fn test_expand_env_missing_required() {
784        unsafe { unset_env("QAIL_MISSING_VAR_XYZ") };
785        let result = expand_env("${QAIL_MISSING_VAR_XYZ}");
786        assert!(result.is_err());
787        assert!(
788            matches!(result, Err(ConfigError::MissingEnvVar(ref v)) if v == "QAIL_MISSING_VAR_XYZ")
789        );
790    }
791
792    #[test]
793    fn test_expand_env_default_value() {
794        unsafe { unset_env("QAIL_OPT_VAR") };
795        let result = expand_env("${QAIL_OPT_VAR:-fallback}").unwrap();
796        assert_eq!(result, "fallback");
797    }
798
799    #[test]
800    fn test_expand_env_default_empty() {
801        unsafe { unset_env("QAIL_OPT_EMPTY") };
802        let result = expand_env("${QAIL_OPT_EMPTY:-}").unwrap();
803        assert_eq!(result, "");
804    }
805
806    #[test]
807    fn test_expand_env_set_overrides_default() {
808        unsafe { set_env("QAIL_SET_VAR", "real") };
809        let result = expand_env("${QAIL_SET_VAR:-fallback}").unwrap();
810        assert_eq!(result, "real");
811        unsafe { unset_env("QAIL_SET_VAR") };
812    }
813
814    #[test]
815    fn test_expand_env_escaped_dollar() {
816        let result = expand_env("price: $$100").unwrap();
817        assert_eq!(result, "price: $100");
818    }
819
820    #[test]
821    fn test_expand_env_no_expansion() {
822        let result = expand_env("plain text no vars").unwrap();
823        assert_eq!(result, "plain text no vars");
824    }
825
826    #[test]
827    fn test_expand_env_postgres_url() {
828        unsafe { set_env("QAIL_DB_USER", "admin") };
829        unsafe { set_env("QAIL_DB_PASS", "s3cret") };
830        let result =
831            expand_env("postgres://${QAIL_DB_USER}:${QAIL_DB_PASS}@localhost:5432/mydb").unwrap();
832        assert_eq!(result, "postgres://admin:s3cret@localhost:5432/mydb");
833        unsafe { unset_env("QAIL_DB_USER") };
834        unsafe { unset_env("QAIL_DB_PASS") };
835    }
836
837    #[test]
838    fn test_parse_minimal_toml() {
839        let toml_str = r#"
840[project]
841name = "test"
842mode = "postgres"
843
844[postgres]
845url = "postgres://localhost/test"
846"#;
847        let config = QailConfig::from_toml_str(toml_str).unwrap();
848        assert_eq!(config.project.name, "test");
849        assert_eq!(config.postgres.url, "postgres://localhost/test");
850        assert_eq!(config.postgres.max_connections, 10); // default
851        assert!(config.qdrant.is_none());
852        assert!(config.gateway.is_none());
853    }
854
855    #[test]
856    fn test_parse_full_toml() {
857        let toml_str = r#"
858[project]
859name = "fulltest"
860mode = "hybrid"
861schema = "schema.qail"
862migrations_dir = "deltas"
863schema_strict_manifest = true
864
865[postgres]
866url = "postgres://localhost/test"
867max_connections = 25
868min_connections = 5
869idle_timeout_secs = 300
870
871[postgres.rls]
872default_role = "app_user"
873super_admin_role = "super_admin"
874
875[qdrant]
876url = "http://qdrant:6333"
877grpc = "qdrant:6334"
878max_connections = 15
879
880[gateway]
881bind = "0.0.0.0:9090"
882cors = false
883policy = "policies.yaml"
884
885[gateway.cache]
886enabled = true
887max_entries = 5000
888ttl_secs = 120
889
890[[sync]]
891source_table = "products"
892target_collection = "products_search"
893trigger_column = "description"
894embedding_model = "candle:bert-base"
895"#;
896        let config = QailConfig::from_toml_str(toml_str).unwrap();
897        assert_eq!(config.project.name, "fulltest");
898        assert_eq!(config.project.schema_strict_manifest, Some(true));
899        assert_eq!(config.postgres.max_connections, 25);
900        assert_eq!(config.postgres.min_connections, 5);
901
902        let rls = config.postgres.rls.unwrap();
903        assert_eq!(rls.default_role.unwrap(), "app_user");
904
905        let qdrant = config.qdrant.unwrap();
906        assert_eq!(qdrant.max_connections, 15);
907
908        let gw = config.gateway.unwrap();
909        assert_eq!(gw.bind, "0.0.0.0:9090");
910        assert!(!gw.cors);
911
912        let cache = gw.cache.unwrap();
913        assert_eq!(cache.max_entries, 5000);
914
915        assert_eq!(config.sync.len(), 1);
916        assert_eq!(config.sync[0].source_table, "products");
917    }
918
919    #[test]
920    fn test_backward_compat_existing_toml() {
921        // Existing qail.toml format must still parse
922        let toml_str = r#"
923[project]
924name = "legacy"
925mode = "postgres"
926
927[postgres]
928url = "postgres://localhost/legacy"
929"#;
930        let config = QailConfig::from_toml_str(toml_str).unwrap();
931        assert_eq!(config.project.name, "legacy");
932        assert_eq!(config.postgres.url, "postgres://localhost/legacy");
933        // All new fields should have defaults
934        assert_eq!(config.postgres.max_connections, 10);
935        assert!(config.postgres.rls.is_none());
936        assert!(config.qdrant.is_none());
937        assert!(config.gateway.is_none());
938    }
939}