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 rls: Option<RlsConfig>,
172
173 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#[derive(Debug, Clone, Default)]
219pub struct RlsConfig {
220 pub default_role: Option<String>,
222
223 pub super_admin_role: Option<String>,
225}
226
227#[derive(Debug, Clone)]
229pub struct QdrantConfig {
230 pub url: String,
232
233 pub grpc: Option<String>,
235
236 pub max_connections: usize,
238
239 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#[derive(Debug, Clone)]
263pub struct GatewayConfig {
264 pub bind: String,
266
267 pub cors: bool,
269
270 pub cors_allowed_origins: Option<Vec<String>>,
272
273 pub policy: Option<String>,
275
276 pub cache: Option<CacheConfig>,
278
279 pub max_expand_depth: usize,
282
283 pub blocked_tables: Option<Vec<String>>,
288
289 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#[derive(Debug, Clone)]
313pub struct AccessPolicyConfig {
314 pub enabled: bool,
316
317 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#[derive(Debug, Clone)]
344pub struct CacheConfig {
345 pub enabled: bool,
347
348 pub max_entries: usize,
350
351 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#[derive(Debug, Clone)]
375pub struct SyncRule {
376 pub source_table: String,
378 pub target_collection: String,
380
381 pub trigger_column: Option<String>,
383
384 pub embedding_model: Option<String>,
386}
387
388impl QailConfig {
393 pub fn load() -> ConfigResult<Self> {
395 Self::load_from("qail.toml")
396 }
397
398 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 let expanded = expand_env(&raw)?;
410
411 let mut config = Self::from_toml_str(&expanded)?;
413
414 config.apply_env_overrides();
416
417 Ok(config)
418 }
419
420 pub fn postgres_url(&self) -> &str {
422 &self.postgres.url
423 }
424
425 fn apply_env_overrides(&mut self) {
427 if let Ok(url) = std::env::var("DATABASE_URL") {
429 self.postgres.url = url;
430 }
431
432 if let (Ok(url), Some(ref mut q)) = (std::env::var("QDRANT_URL"), self.qdrant.as_mut()) {
434 q.url = url;
435 }
436
437 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
730pub 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 chars.next();
749 result.push('$');
750 }
751 Some('{') => {
752 chars.next(); 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 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 result.push('$');
789 }
790 }
791 } else {
792 result.push(ch);
793 }
794 }
795
796 Ok(result)
797}
798
799#[cfg(test)]
804mod tests {
805 use super::*;
806
807 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); 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 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 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}