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