Skip to main content

fraiseql_core/config/
mod.rs

1//! Configuration management.
2//!
3//! This module provides comprehensive configuration for FraiseQL servers:
4//!
5//! - **Server**: Host, port, worker threads
6//! - **Database**: Connection URL, pool settings, timeouts
7//! - **CORS**: Allowed origins, methods, headers
8//! - **Auth**: JWT/Auth0/Clerk configuration
9//! - **Rate Limiting**: Request limits per window
10//! - **Caching**: APQ and response caching settings
11//!
12//! # Configuration File Format
13//!
14//! FraiseQL supports TOML configuration files:
15//!
16//! ```toml
17//! [server]
18//! host = "0.0.0.0"
19//! port = 8000
20//!
21//! [database]
22//! url = "postgresql://localhost/mydb"
23//! max_connections = 10
24//! timeout_secs = 30
25//!
26//! [cors]
27//! allowed_origins = ["http://localhost:3000"]
28//! allow_credentials = true
29//!
30//! [auth]
31//! provider = "jwt"
32//! secret = "${JWT_SECRET}"
33//!
34//! [rate_limit]
35//! requests_per_minute = 100
36//! ```
37//!
38//! # Environment Variable Expansion
39//!
40//! Config values can reference environment variables using `${VAR}` syntax.
41//! This is especially useful for secrets that shouldn't be in config files.
42
43use 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};
60// =============================================================================
61// Collation Configuration — re-exported from fraiseql-db
62// =============================================================================
63pub 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// =============================================================================
71// Main Configuration
72// =============================================================================
73
74/// Main configuration structure.
75///
76/// This is the complete configuration for a `FraiseQL` server instance.
77/// It can be loaded from a TOML file, environment variables, or built programmatically.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct FraiseQLConfig {
81    /// Server configuration.
82    pub server: CoreServerConfig,
83
84    /// Database configuration.
85    pub database: DatabaseConfig,
86
87    /// CORS configuration.
88    pub cors: CorsConfig,
89
90    /// Authentication configuration.
91    pub auth: AuthConfig,
92
93    /// Rate limiting configuration.
94    pub rate_limit: RateLimitConfig,
95
96    /// Caching configuration.
97    pub cache: CacheConfig,
98
99    /// Collation configuration.
100    pub collation: CollationConfig,
101
102    // Legacy fields for backward compatibility
103    #[serde(skip)]
104    database_url_compat: Option<String>,
105
106    /// Database connection URL (legacy, prefer database.url).
107    #[serde(skip_serializing, default)]
108    pub database_url: String,
109
110    /// Server host (legacy, prefer server.host).
111    #[serde(skip_serializing, default)]
112    pub host: String,
113
114    /// Server port (legacy, prefer server.port).
115    #[serde(skip_serializing, default)]
116    pub port: u16,
117
118    /// Maximum connections (legacy, prefer `database.max_connections`).
119    #[serde(skip_serializing, default)]
120    pub max_connections: u32,
121
122    /// Query timeout (legacy, prefer `database.query_timeout_secs`).
123    #[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            // Legacy compat fields
134            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            // New structured config
142            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    /// Create a new configuration builder.
155    pub fn builder() -> ConfigBuilder {
156        ConfigBuilder::default()
157    }
158
159    /// Load configuration from a TOML file.
160    ///
161    /// # Errors
162    ///
163    /// Returns error if the file cannot be read or parsed.
164    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    /// Load configuration from a TOML string.
174    ///
175    /// # Errors
176    ///
177    /// Returns error if the TOML is invalid.
178    pub fn from_toml(content: &str) -> Result<Self> {
179        // Expand environment variables in the content
180        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        // Sync legacy fields
188        config.sync_legacy_fields();
189
190        Ok(config)
191    }
192
193    /// Load configuration from environment variables.
194    ///
195    /// # Errors
196    ///
197    /// Returns error if required environment variables are missing.
198    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            // Legacy compat
231            database_url,
232            host,
233            port,
234            max_connections,
235            query_timeout_secs: query_timeout,
236            ..Default::default()
237        };
238
239        // Load optional auth settings from env
240        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    /// Sync legacy flat fields with new structured fields.
267    fn sync_legacy_fields(&mut self) {
268        // If structured database.url is set, use it for legacy field
269        if !self.database.url.is_empty() {
270            self.database_url = self.database.url.clone();
271        } else if !self.database_url.is_empty() {
272            // If legacy field is set, copy to structured
273            self.database.url = self.database_url.clone();
274        }
275
276        // Sync server fields
277        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    /// Create a test configuration.
284    pub fn test() -> Self {
285        Self {
286            server: CoreServerConfig {
287                host: "127.0.0.1".to_string(),
288                port: 0, // Random port
289                ..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            // Legacy compat
298            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    /// Validate the configuration.
308    ///
309    /// # Errors
310    ///
311    /// Returns error if configuration is invalid.
312    pub fn validate(&self) -> Result<()> {
313        // Database URL required
314        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        // Pool invariants
321        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        // Server port (0 means "pick a random OS port" which is valid in tests
336        // but not in production; we only reject it if a non-zero port is expected)
337        // Note: port = 0 is allowed by design (OS-assigned). No check added here.
338
339        // Validate auth config
340        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    /// Export configuration to TOML string.
368    #[must_use]
369    pub fn to_toml(&self) -> String {
370        toml::to_string_pretty(self).unwrap_or_default()
371    }
372}
373
374/// Expand environment variables in a string.
375///
376/// Supports both `${VAR}` and `$VAR` syntax. The `${VAR}` form is matched
377/// first (higher priority) so that `${FOO}BAR` expands the braced form only.
378#[allow(clippy::expect_used)] // Reason: regex patterns are compile-time constants guaranteed to be valid
379fn expand_env_vars(content: &str) -> String {
380    use std::sync::LazyLock;
381
382    // Matches ${VAR} (braced form)
383    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    // Matches $VAR (bare form). Applied after the braced pass so any ${VAR}
388    // patterns have already been resolved and won't be double-matched.
389    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: &regex::Regex| -> String {
394        let mut result = input.to_string();
395        // Collect matches before replacing to avoid offset issues
396        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    // Expand braced form first, then bare form on the result
412    let after_braced = expand(content, &BRACED_REGEX);
413    expand(&after_braced, &BARE_REGEX)
414}
415
416/// Configuration builder.
417#[must_use = "call .build() to construct the final value"]
418#[derive(Debug, Default)]
419pub struct ConfigBuilder {
420    config: FraiseQLConfig,
421}
422
423impl ConfigBuilder {
424    /// Set the database URL.
425    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    /// Set the server host.
432    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    /// Set the server port.
439    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    /// Set maximum database connections.
446    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    /// Set query timeout.
453    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    /// Set CORS configuration.
460    pub fn cors(mut self, cors: CorsConfig) -> Self {
461        self.config.cors = cors;
462        self
463    }
464
465    /// Set auth configuration.
466    pub fn auth(mut self, auth: AuthConfig) -> Self {
467        self.config.auth = auth;
468        self
469    }
470
471    /// Set rate limit configuration.
472    pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
473        self.config.rate_limit = rate_limit;
474        self
475    }
476
477    /// Set cache configuration.
478    pub const fn cache(mut self, cache: CacheConfig) -> Self {
479        self.config.cache = cache;
480        self
481    }
482
483    /// Set collation configuration.
484    pub fn collation(mut self, collation: CollationConfig) -> Self {
485        self.config.collation = collation;
486        self
487    }
488
489    /// Build the configuration.
490    ///
491    /// # Errors
492    ///
493    /// Returns error if configuration is invalid.
494    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)] // Reason: test code, panics are acceptable
504
505    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        // Server
595        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        // Database
600        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        // CORS
605        assert!(config.cors.enabled);
606        assert_eq!(config.cors.allowed_origins.len(), 2);
607        assert!(config.cors.allow_credentials);
608
609        // Auth
610        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        // Rate Limit
615        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        // Cache
620        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        // from_toml succeeds but validate would fail
661        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()); // Empty = allow all
708        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        // Both legacy and new fields should match
759        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}