1use std::path::Path;
44
45use serde::{Deserialize, Serialize};
46
47use crate::error::{FraiseQLError, Result};
48
49mod auth;
50mod cache;
51mod cors;
52mod database;
53mod rate_limit;
54mod server;
55
56pub use auth::{AuthConfig, AuthProvider};
57pub use cache::CacheConfig;
58pub use cors::CorsConfig;
59pub use database::{DatabaseConfig, MutationTimingConfig, SslMode};
60pub use fraiseql_db::{
64 CollationConfig, DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
65 PostgresCollationConfig, SqlServerCollationConfig, SqliteCollationConfig,
66};
67pub use rate_limit::{PathRateLimit, RateLimitConfig, RateLimitKey};
68pub use server::CoreServerConfig;
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct FraiseQLConfig {
81 pub server: CoreServerConfig,
83
84 pub database: DatabaseConfig,
86
87 pub cors: CorsConfig,
89
90 pub auth: AuthConfig,
92
93 pub rate_limit: RateLimitConfig,
95
96 pub cache: CacheConfig,
98
99 pub collation: CollationConfig,
101
102 #[serde(skip)]
104 database_url_compat: Option<String>,
105
106 #[serde(skip_serializing, default)]
108 pub database_url: String,
109
110 #[serde(skip_serializing, default)]
112 pub host: String,
113
114 #[serde(skip_serializing, default)]
116 pub port: u16,
117
118 #[serde(skip_serializing, default)]
120 pub max_connections: u32,
121
122 #[serde(skip_serializing, default)]
124 pub query_timeout_secs: u64,
125}
126
127impl Default for FraiseQLConfig {
128 fn default() -> Self {
129 let server = CoreServerConfig::default();
130 let database = DatabaseConfig::default();
131
132 Self {
133 database_url: String::new(),
135 host: server.host.clone(),
136 port: server.port,
137 max_connections: database.max_connections,
138 query_timeout_secs: database.query_timeout_secs,
139 database_url_compat: None,
140
141 server,
143 database,
144 cors: CorsConfig::default(),
145 auth: AuthConfig::default(),
146 rate_limit: RateLimitConfig::default(),
147 cache: CacheConfig::default(),
148 collation: CollationConfig::default(),
149 }
150 }
151}
152
153impl FraiseQLConfig {
154 pub fn builder() -> ConfigBuilder {
156 ConfigBuilder::default()
157 }
158
159 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
165 let path = path.as_ref();
166 let content = std::fs::read_to_string(path).map_err(|e| FraiseQLError::Configuration {
167 message: format!("Failed to read config file '{}': {}", path.display(), e),
168 })?;
169
170 Self::from_toml(&content)
171 }
172
173 pub fn from_toml(content: &str) -> Result<Self> {
179 let expanded = expand_env_vars(content);
181
182 let mut config: Self =
183 toml::from_str(&expanded).map_err(|e| FraiseQLError::Configuration {
184 message: format!("Invalid TOML configuration: {e}"),
185 })?;
186
187 config.sync_legacy_fields();
189
190 Ok(config)
191 }
192
193 pub fn from_env() -> Result<Self> {
199 let database_url =
200 std::env::var("DATABASE_URL").map_err(|_| FraiseQLError::Configuration {
201 message: "DATABASE_URL not set".to_string(),
202 })?;
203
204 let host = std::env::var("FRAISEQL_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
205
206 let port = std::env::var("FRAISEQL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8000);
207
208 let max_connections = std::env::var("FRAISEQL_MAX_CONNECTIONS")
209 .ok()
210 .and_then(|s| s.parse().ok())
211 .unwrap_or(10);
212
213 let query_timeout = std::env::var("FRAISEQL_QUERY_TIMEOUT")
214 .ok()
215 .and_then(|s| s.parse().ok())
216 .unwrap_or(30);
217
218 let mut config = Self {
219 server: CoreServerConfig {
220 host: host.clone(),
221 port,
222 ..Default::default()
223 },
224 database: DatabaseConfig {
225 url: database_url.clone(),
226 max_connections,
227 query_timeout_secs: query_timeout,
228 ..Default::default()
229 },
230 database_url,
232 host,
233 port,
234 max_connections,
235 query_timeout_secs: query_timeout,
236 ..Default::default()
237 };
238
239 if let Ok(provider) = std::env::var("FRAISEQL_AUTH_PROVIDER") {
241 config.auth.enabled = true;
242 config.auth.provider = match provider.to_lowercase().as_str() {
243 "jwt" => AuthProvider::Jwt,
244 "auth0" => AuthProvider::Auth0,
245 "clerk" => AuthProvider::Clerk,
246 "webhook" => AuthProvider::Webhook,
247 _ => AuthProvider::None,
248 };
249 }
250
251 if let Ok(secret) = std::env::var("JWT_SECRET") {
252 config.auth.jwt_secret = Some(secret);
253 }
254
255 if let Ok(domain) = std::env::var("AUTH0_DOMAIN") {
256 config.auth.domain = Some(domain);
257 }
258
259 if let Ok(audience) = std::env::var("AUTH0_AUDIENCE") {
260 config.auth.audience = Some(audience);
261 }
262
263 Ok(config)
264 }
265
266 fn sync_legacy_fields(&mut self) {
268 if !self.database.url.is_empty() {
270 self.database_url = self.database.url.clone();
271 } else if !self.database_url.is_empty() {
272 self.database.url = self.database_url.clone();
274 }
275
276 self.host = self.server.host.clone();
278 self.port = self.server.port;
279 self.max_connections = self.database.max_connections;
280 self.query_timeout_secs = self.database.query_timeout_secs;
281 }
282
283 pub fn test() -> Self {
285 Self {
286 server: CoreServerConfig {
287 host: "127.0.0.1".to_string(),
288 port: 0, ..Default::default()
290 },
291 database: DatabaseConfig {
292 url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
293 max_connections: 2,
294 query_timeout_secs: 5,
295 ..Default::default()
296 },
297 database_url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
299 host: "127.0.0.1".to_string(),
300 port: 0,
301 max_connections: 2,
302 query_timeout_secs: 5,
303 ..Default::default()
304 }
305 }
306
307 pub fn validate(&self) -> Result<()> {
313 if self.database.url.is_empty() && self.database_url.is_empty() {
315 return Err(FraiseQLError::Configuration {
316 message: "database.url is required".to_string(),
317 });
318 }
319
320 if self.database.max_connections == 0 {
322 return Err(FraiseQLError::Configuration {
323 message: "database.max_connections must be at least 1".to_string(),
324 });
325 }
326 if self.database.min_connections > self.database.max_connections {
327 return Err(FraiseQLError::Configuration {
328 message: format!(
329 "database.min_connections ({}) must not exceed max_connections ({})",
330 self.database.min_connections, self.database.max_connections
331 ),
332 });
333 }
334
335 if self.auth.enabled {
341 match self.auth.provider {
342 AuthProvider::Jwt => {
343 if self.auth.jwt_secret.is_none() {
344 return Err(FraiseQLError::Configuration {
345 message: "auth.jwt_secret is required when using JWT provider"
346 .to_string(),
347 });
348 }
349 },
350 AuthProvider::Auth0 | AuthProvider::Clerk => {
351 if self.auth.domain.is_none() {
352 return Err(FraiseQLError::Configuration {
353 message: format!(
354 "auth.domain is required when using {:?} provider",
355 self.auth.provider
356 ),
357 });
358 }
359 },
360 AuthProvider::Webhook | AuthProvider::None => {},
361 }
362 }
363
364 Ok(())
365 }
366
367 #[must_use]
369 pub fn to_toml(&self) -> String {
370 toml::to_string_pretty(self).unwrap_or_default()
371 }
372}
373
374#[allow(clippy::expect_used)] fn expand_env_vars(content: &str) -> String {
380 use std::sync::LazyLock;
381
382 static BRACED_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
384 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("braced env var regex is valid")
385 });
386
387 static BARE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
390 regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").expect("bare env var regex is valid")
391 });
392
393 let expand = |input: &str, re: ®ex::Regex| -> String {
394 let mut result = input.to_string();
395 let replacements: Vec<(String, String)> = re
397 .captures_iter(input)
398 .filter_map(|cap| {
399 let full = cap.get(0)?.as_str().to_string();
400 let var_name = cap.get(1)?.as_str();
401 let value = std::env::var(var_name).ok()?;
402 Some((full, value))
403 })
404 .collect();
405 for (pattern, value) in replacements {
406 result = result.replace(&pattern, &value);
407 }
408 result
409 };
410
411 let after_braced = expand(content, &BRACED_REGEX);
413 expand(&after_braced, &BARE_REGEX)
414}
415
416#[must_use = "call .build() to construct the final value"]
418#[derive(Debug, Default)]
419pub struct ConfigBuilder {
420 config: FraiseQLConfig,
421}
422
423impl ConfigBuilder {
424 pub fn database_url(mut self, url: &str) -> Self {
426 self.config.database.url = url.to_string();
427 self.config.database_url = url.to_string();
428 self
429 }
430
431 pub fn host(mut self, host: &str) -> Self {
433 self.config.server.host = host.to_string();
434 self.config.host = host.to_string();
435 self
436 }
437
438 pub const fn port(mut self, port: u16) -> Self {
440 self.config.server.port = port;
441 self.config.port = port;
442 self
443 }
444
445 pub const fn max_connections(mut self, n: u32) -> Self {
447 self.config.database.max_connections = n;
448 self.config.max_connections = n;
449 self
450 }
451
452 pub const fn query_timeout(mut self, secs: u64) -> Self {
454 self.config.database.query_timeout_secs = secs;
455 self.config.query_timeout_secs = secs;
456 self
457 }
458
459 pub fn cors(mut self, cors: CorsConfig) -> Self {
461 self.config.cors = cors;
462 self
463 }
464
465 pub fn auth(mut self, auth: AuthConfig) -> Self {
467 self.config.auth = auth;
468 self
469 }
470
471 pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
473 self.config.rate_limit = rate_limit;
474 self
475 }
476
477 pub const fn cache(mut self, cache: CacheConfig) -> Self {
479 self.config.cache = cache;
480 self
481 }
482
483 pub fn collation(mut self, collation: CollationConfig) -> Self {
485 self.config.collation = collation;
486 self
487 }
488
489 pub fn build(mut self) -> Result<FraiseQLConfig> {
495 self.config.sync_legacy_fields();
496 self.config.validate()?;
497 Ok(self.config)
498 }
499}
500
501#[cfg(test)]
502mod tests {
503 #![allow(clippy::unwrap_used)] use super::*;
506
507 #[test]
508 fn test_default_config() {
509 let config = FraiseQLConfig::default();
510 assert_eq!(config.port, 8000);
511 assert_eq!(config.host, "0.0.0.0");
512 assert_eq!(config.server.port, 8000);
513 assert_eq!(config.server.host, "0.0.0.0");
514 }
515
516 #[test]
517 fn test_builder() {
518 let config = FraiseQLConfig::builder()
519 .database_url("postgresql://localhost/test")
520 .port(9000)
521 .build()
522 .unwrap();
523
524 assert_eq!(config.port, 9000);
525 assert_eq!(config.server.port, 9000);
526 assert!(!config.database_url.is_empty());
527 assert!(!config.database.url.is_empty());
528 }
529
530 #[test]
531 fn test_builder_requires_database_url() {
532 let result = FraiseQLConfig::builder().build();
533 assert!(
534 matches!(result, Err(FraiseQLError::Configuration { .. })),
535 "expected Configuration error when database URL is absent, got: {result:?}"
536 );
537 }
538
539 #[test]
540 fn test_from_toml_minimal() {
541 let toml = r#"
542[database]
543url = "postgresql://localhost/test"
544"#;
545 let config = FraiseQLConfig::from_toml(toml).unwrap();
546 assert_eq!(config.database.url, "postgresql://localhost/test");
547 assert_eq!(config.database_url, "postgresql://localhost/test");
548 }
549
550 #[test]
551 fn test_from_toml_full() {
552 let toml = r#"
553[server]
554host = "127.0.0.1"
555port = 9000
556workers = 4
557max_body_size = 2097152
558request_logging = true
559
560[database]
561url = "postgresql://localhost/mydb"
562max_connections = 20
563min_connections = 2
564connect_timeout_secs = 15
565query_timeout_secs = 60
566idle_timeout_secs = 300
567ssl_mode = "require"
568
569[cors]
570enabled = true
571allowed_origins = ["http://localhost:3000", "https://app.example.com"]
572allow_credentials = true
573
574[auth]
575enabled = true
576provider = "jwt"
577jwt_secret = "my-secret-key"
578jwt_algorithm = "HS256"
579exclude_paths = ["/health", "/metrics"]
580
581[rate_limit]
582enabled = true
583requests_per_window = 200
584window_secs = 120
585key_by = "user"
586
587[cache]
588apq_enabled = true
589apq_ttl_secs = 3600
590response_cache_enabled = true
591"#;
592 let config = FraiseQLConfig::from_toml(toml).unwrap();
593
594 assert_eq!(config.server.host, "127.0.0.1");
596 assert_eq!(config.server.port, 9000);
597 assert_eq!(config.server.workers, 4);
598
599 assert_eq!(config.database.url, "postgresql://localhost/mydb");
601 assert_eq!(config.database.max_connections, 20);
602 assert_eq!(config.database.ssl_mode, SslMode::Require);
603
604 assert!(config.cors.enabled);
606 assert_eq!(config.cors.allowed_origins.len(), 2);
607 assert!(config.cors.allow_credentials);
608
609 assert!(config.auth.enabled);
611 assert_eq!(config.auth.provider, AuthProvider::Jwt);
612 assert_eq!(config.auth.jwt_secret, Some("my-secret-key".to_string()));
613
614 assert!(config.rate_limit.enabled);
616 assert_eq!(config.rate_limit.requests_per_window, 200);
617 assert_eq!(config.rate_limit.key_by, RateLimitKey::User);
618
619 assert!(config.cache.apq_enabled);
621 assert!(config.cache.response_cache_enabled);
622 }
623
624 #[test]
625 fn test_env_var_expansion() {
626 temp_env::with_vars(
627 [
628 ("TEST_DB_URL", Some("postgresql://user:pass@host/db")),
629 ("TEST_JWT_SECRET", Some("super-secret")),
630 ],
631 || {
632 let toml = r#"
633[database]
634url = "${TEST_DB_URL}"
635
636[auth]
637enabled = true
638provider = "jwt"
639jwt_secret = "${TEST_JWT_SECRET}"
640"#;
641 let config = FraiseQLConfig::from_toml(toml).unwrap();
642
643 assert_eq!(config.database.url, "postgresql://user:pass@host/db");
644 assert_eq!(config.auth.jwt_secret, Some("super-secret".to_string()));
645 },
646 );
647 }
648
649 #[test]
650 fn test_auth_validation_jwt_requires_secret() {
651 let toml = r#"
652[database]
653url = "postgresql://localhost/test"
654
655[auth]
656enabled = true
657provider = "jwt"
658"#;
659 let result = FraiseQLConfig::from_toml(toml);
660 let config = result.unwrap();
662 let validation = config.validate();
663 assert!(
664 matches!(validation, Err(FraiseQLError::Configuration { .. })),
665 "expected Configuration error for missing jwt_secret, got: {validation:?}"
666 );
667 assert!(validation.unwrap_err().to_string().contains("jwt_secret is required"));
668 }
669
670 #[test]
671 fn test_auth_validation_auth0_requires_domain() {
672 let toml = r#"
673[database]
674url = "postgresql://localhost/test"
675
676[auth]
677enabled = true
678provider = "auth0"
679"#;
680 let config = FraiseQLConfig::from_toml(toml).unwrap();
681 let validation = config.validate();
682 assert!(
683 matches!(validation, Err(FraiseQLError::Configuration { .. })),
684 "expected Configuration error for missing auth0 domain, got: {validation:?}"
685 );
686 assert!(validation.unwrap_err().to_string().contains("domain is required"));
687 }
688
689 #[test]
690 fn test_to_toml() {
691 let config = FraiseQLConfig::builder()
692 .database_url("postgresql://localhost/test")
693 .port(9000)
694 .build()
695 .unwrap();
696
697 let toml_str = config.to_toml();
698 assert!(toml_str.contains("[server]"));
699 assert!(toml_str.contains("[database]"));
700 assert!(toml_str.contains("port = 9000"));
701 }
702
703 #[test]
704 fn test_cors_config_defaults() {
705 let cors = CorsConfig::default();
706 assert!(cors.enabled);
707 assert!(cors.allowed_origins.is_empty()); assert!(cors.allowed_methods.contains(&"POST".to_string()));
709 assert!(cors.allowed_headers.contains(&"Authorization".to_string()));
710 }
711
712 #[test]
713 fn test_rate_limit_key_variants() {
714 let toml = r#"
715[database]
716url = "postgresql://localhost/test"
717
718[rate_limit]
719key_by = "api_key"
720"#;
721 let config = FraiseQLConfig::from_toml(toml).unwrap();
722 assert_eq!(config.rate_limit.key_by, RateLimitKey::ApiKey);
723 }
724
725 #[test]
726 fn test_ssl_mode_variants() {
727 for (ssl_str, expected) in [
728 ("disable", SslMode::Disable),
729 ("prefer", SslMode::Prefer),
730 ("require", SslMode::Require),
731 ("verify-ca", SslMode::VerifyCa),
732 ("verify-full", SslMode::VerifyFull),
733 ] {
734 let toml = format!(
735 r#"
736[database]
737url = "postgresql://localhost/test"
738ssl_mode = "{}"
739"#,
740 ssl_str
741 );
742 let config = FraiseQLConfig::from_toml(&toml).unwrap();
743 assert_eq!(config.database.ssl_mode, expected);
744 }
745 }
746
747 #[test]
748 fn test_legacy_field_sync() {
749 let config = FraiseQLConfig::builder()
750 .database_url("postgresql://localhost/test")
751 .host("192.168.1.1")
752 .port(4000)
753 .max_connections(50)
754 .query_timeout(120)
755 .build()
756 .unwrap();
757
758 assert_eq!(config.host, "192.168.1.1");
760 assert_eq!(config.server.host, "192.168.1.1");
761 assert_eq!(config.port, 4000);
762 assert_eq!(config.server.port, 4000);
763 assert_eq!(config.max_connections, 50);
764 assert_eq!(config.database.max_connections, 50);
765 assert_eq!(config.query_timeout_secs, 120);
766 assert_eq!(config.database.query_timeout_secs, 120);
767 }
768
769 #[test]
770 fn test_auth_providers() {
771 for (provider_str, expected) in [
772 ("none", AuthProvider::None),
773 ("jwt", AuthProvider::Jwt),
774 ("auth0", AuthProvider::Auth0),
775 ("clerk", AuthProvider::Clerk),
776 ("webhook", AuthProvider::Webhook),
777 ] {
778 let toml = format!(
779 r#"
780[database]
781url = "postgresql://localhost/test"
782
783[auth]
784provider = "{}"
785"#,
786 provider_str
787 );
788 let config = FraiseQLConfig::from_toml(&toml).unwrap();
789 assert_eq!(config.auth.provider, expected);
790 }
791 }
792
793 #[test]
794 fn test_collation_config_default() {
795 let config = CollationConfig::default();
796 assert!(config.enabled);
797 assert_eq!(config.fallback_locale, "en-US");
798 assert!(config.allowed_locales.contains(&"en-US".to_string()));
799 assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
800 assert_eq!(config.on_invalid_locale, InvalidLocaleStrategy::Fallback);
801 assert!(config.database_overrides.is_none());
802 }
803
804 #[test]
805 fn test_collation_config_from_toml() {
806 let toml = r#"
807[database]
808url = "postgresql://localhost/test"
809
810[collation]
811enabled = true
812fallback_locale = "en-GB"
813on_invalid_locale = "error"
814allowed_locales = ["en-GB", "fr-FR", "de-DE"]
815"#;
816 let config = FraiseQLConfig::from_toml(toml).unwrap();
817
818 assert!(config.collation.enabled);
819 assert_eq!(config.collation.fallback_locale, "en-GB");
820 assert_eq!(config.collation.on_invalid_locale, InvalidLocaleStrategy::Error);
821 assert_eq!(config.collation.allowed_locales.len(), 3);
822 assert!(config.collation.allowed_locales.contains(&"de-DE".to_string()));
823 }
824
825 #[test]
826 fn test_collation_with_postgres_overrides() {
827 let toml = r#"
828[database]
829url = "postgresql://localhost/test"
830
831[collation]
832enabled = true
833fallback_locale = "en-US"
834
835[collation.database_overrides.postgres]
836use_icu = false
837provider = "libc"
838"#;
839 let config = FraiseQLConfig::from_toml(toml).unwrap();
840
841 let overrides = config.collation.database_overrides.as_ref().unwrap();
842 let pg_config = overrides.postgres.as_ref().unwrap();
843 assert!(!pg_config.use_icu);
844 assert_eq!(pg_config.provider, "libc");
845 }
846
847 #[test]
848 fn test_collation_with_mysql_overrides() {
849 let toml = r#"
850[database]
851url = "postgresql://localhost/test"
852
853[collation]
854enabled = true
855
856[collation.database_overrides.mysql]
857charset = "utf8mb4"
858suffix = "_0900_ai_ci"
859"#;
860 let config = FraiseQLConfig::from_toml(toml).unwrap();
861
862 let overrides = config.collation.database_overrides.as_ref().unwrap();
863 let mysql_config = overrides.mysql.as_ref().unwrap();
864 assert_eq!(mysql_config.charset, "utf8mb4");
865 assert_eq!(mysql_config.suffix, "_0900_ai_ci");
866 }
867
868 #[test]
869 fn test_collation_with_sqlite_overrides() {
870 let toml = r#"
871[database]
872url = "postgresql://localhost/test"
873
874[collation]
875enabled = true
876
877[collation.database_overrides.sqlite]
878use_nocase = false
879"#;
880 let config = FraiseQLConfig::from_toml(toml).unwrap();
881
882 let overrides = config.collation.database_overrides.as_ref().unwrap();
883 let sqlite_config = overrides.sqlite.as_ref().unwrap();
884 assert!(!sqlite_config.use_nocase);
885 }
886
887 #[test]
888 fn test_invalid_locale_strategy_variants() {
889 for (strategy_str, expected) in [
890 ("fallback", InvalidLocaleStrategy::Fallback),
891 ("database_default", InvalidLocaleStrategy::DatabaseDefault),
892 ("error", InvalidLocaleStrategy::Error),
893 ] {
894 let toml = format!(
895 r#"
896[database]
897url = "postgresql://localhost/test"
898
899[collation]
900on_invalid_locale = "{}"
901"#,
902 strategy_str
903 );
904 let config = FraiseQLConfig::from_toml(&toml).unwrap();
905 assert_eq!(config.collation.on_invalid_locale, expected);
906 }
907 }
908
909 #[test]
910 fn test_mutation_timing_default_disabled() {
911 let config = FraiseQLConfig::default();
912 assert!(!config.database.mutation_timing.enabled);
913 assert_eq!(config.database.mutation_timing.variable_name, "fraiseql.started_at");
914 }
915
916 #[test]
917 fn test_mutation_timing_from_toml() {
918 let toml = r#"
919[database]
920url = "postgresql://localhost/test"
921
922[database.mutation_timing]
923enabled = true
924variable_name = "app.started_at"
925"#;
926 let config = FraiseQLConfig::from_toml(toml).unwrap();
927 assert!(config.database.mutation_timing.enabled);
928 assert_eq!(config.database.mutation_timing.variable_name, "app.started_at");
929 }
930
931 #[test]
932 fn test_mutation_timing_from_toml_default_variable() {
933 let toml = r#"
934[database]
935url = "postgresql://localhost/test"
936
937[database.mutation_timing]
938enabled = true
939"#;
940 let config = FraiseQLConfig::from_toml(toml).unwrap();
941 assert!(config.database.mutation_timing.enabled);
942 assert_eq!(config.database.mutation_timing.variable_name, "fraiseql.started_at");
943 }
944
945 #[test]
946 fn test_mutation_timing_absent_uses_defaults() {
947 let toml = r#"
948[database]
949url = "postgresql://localhost/test"
950"#;
951 let config = FraiseQLConfig::from_toml(toml).unwrap();
952 assert!(!config.database.mutation_timing.enabled);
953 assert_eq!(config.database.mutation_timing.variable_name, "fraiseql.started_at");
954 }
955
956 #[test]
957 fn test_collation_disabled() {
958 let toml = r#"
959[database]
960url = "postgresql://localhost/test"
961
962[collation]
963enabled = false
964"#;
965 let config = FraiseQLConfig::from_toml(toml).unwrap();
966 assert!(!config.collation.enabled);
967 }
968
969 #[test]
970 fn test_collation_config_builder() {
971 let collation = CollationConfig {
972 enabled: false,
973 fallback_locale: "de-DE".to_string(),
974 ..Default::default()
975 };
976
977 let config = FraiseQLConfig::builder()
978 .database_url("postgresql://localhost/test")
979 .collation(collation)
980 .build()
981 .unwrap();
982
983 assert!(!config.collation.enabled);
984 assert_eq!(config.collation.fallback_locale, "de-DE");
985 }
986}