1use std::path::Path;
13
14#[derive(Debug)]
16pub enum ConfigError {
17 NotFound(String),
19
20 Read(std::io::Error),
22
23 Parse(toml::de::Error),
25
26 Invalid(String),
28
29 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
69pub type ConfigResult<T> = Result<T, ConfigError>;
71
72#[derive(Debug, Clone, Default)]
80pub struct QailConfig {
81 pub project: ProjectConfig,
83
84 pub postgres: PostgresConfig,
86
87 pub qdrant: Option<QdrantConfig>,
89
90 pub gateway: Option<GatewayConfig>,
92
93 pub access: Option<AccessPolicyConfig>,
95
96 pub sync: Vec<SyncRule>,
98}
99
100#[derive(Debug, Clone)]
106pub struct ProjectConfig {
107 pub name: String,
109
110 pub mode: String,
112
113 pub schema: Option<String>,
115
116 pub migrations_dir: Option<String>,
118
119 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#[derive(Debug, Clone)]
148pub struct PostgresConfig {
149 pub url: String,
151
152 pub max_connections: usize,
154
155 pub min_connections: usize,
157
158 pub idle_timeout_secs: u64,
160
161 pub acquire_timeout_secs: u64,
163
164 pub connect_timeout_secs: u64,
166
167 pub test_on_acquire: bool,
169
170 pub io_uring: bool,
175
176 pub rls: Option<RlsConfig>,
178
179 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#[derive(Debug, Clone, Default)]
226pub struct RlsConfig {
227 pub default_role: Option<String>,
229
230 pub super_admin_role: Option<String>,
232}
233
234#[derive(Debug, Clone)]
236pub struct QdrantConfig {
237 pub url: String,
239
240 pub grpc: Option<String>,
242
243 pub max_connections: usize,
245
246 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#[derive(Debug, Clone)]
270pub struct GatewayConfig {
271 pub bind: String,
273
274 pub cors: bool,
276
277 pub cors_allowed_origins: Option<Vec<String>>,
279
280 pub policy: Option<String>,
282
283 pub cache: Option<CacheConfig>,
285
286 pub max_expand_depth: usize,
289
290 pub blocked_tables: Option<Vec<String>>,
295
296 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#[derive(Debug, Clone)]
320pub struct AccessPolicyConfig {
321 pub enabled: bool,
323
324 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#[derive(Debug, Clone)]
351pub struct CacheConfig {
352 pub enabled: bool,
354
355 pub max_entries: usize,
357
358 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#[derive(Debug, Clone)]
382pub struct SyncRule {
383 pub source_table: String,
385 pub target_collection: String,
387
388 pub trigger_column: Option<String>,
390
391 pub embedding_model: Option<String>,
393}
394
395impl QailConfig {
400 pub fn load() -> ConfigResult<Self> {
402 Self::load_from("qail.toml")
403 }
404
405 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 let expanded = expand_env(&raw)?;
417
418 let mut config = Self::from_toml_str(&expanded)?;
420
421 config.apply_env_overrides();
423
424 Ok(config)
425 }
426
427 pub fn postgres_url(&self) -> &str {
429 &self.postgres.url
430 }
431
432 fn apply_env_overrides(&mut self) {
434 if let Ok(url) = std::env::var("DATABASE_URL") {
436 self.postgres.url = url;
437 }
438
439 if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
441 q.url = url;
442 }
443
444 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
740pub 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 chars.next();
759 result.push('$');
760 }
761 Some('{') => {
762 chars.next(); 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 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 result.push('$');
799 }
800 }
801 } else {
802 result.push(ch);
803 }
804 }
805
806 Ok(result)
807}
808
809#[cfg(test)]
814mod tests {
815 use super::*;
816
817 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); 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 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 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}