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