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
49// =============================================================================
50// Server Configuration
51// =============================================================================
52
53/// Server-specific configuration.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct ServerConfig {
57    /// Host to bind to.
58    pub host: String,
59
60    /// Port to bind to.
61    pub port: u16,
62
63    /// Number of worker threads (0 = auto).
64    pub workers: usize,
65
66    /// Request body size limit in bytes.
67    pub max_body_size: usize,
68
69    /// Enable request logging.
70    pub request_logging: bool,
71}
72
73impl Default for ServerConfig {
74    fn default() -> Self {
75        Self {
76            host:            "0.0.0.0".to_string(),
77            port:            8000,
78            workers:         0,           // Auto-detect
79            max_body_size:   1024 * 1024, // 1MB
80            request_logging: true,
81        }
82    }
83}
84
85// =============================================================================
86// Database Configuration
87// =============================================================================
88
89/// Database connection configuration.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct DatabaseConfig {
93    /// `PostgreSQL` connection URL.
94    pub url: String,
95
96    /// Maximum connections in pool.
97    pub max_connections: u32,
98
99    /// Minimum connections to maintain.
100    pub min_connections: u32,
101
102    /// Connection timeout in seconds.
103    pub connect_timeout_secs: u64,
104
105    /// Query timeout in seconds.
106    pub query_timeout_secs: u64,
107
108    /// Idle timeout in seconds (0 = no timeout).
109    pub idle_timeout_secs: u64,
110
111    /// Enable SSL for database connections.
112    pub ssl_mode: SslMode,
113}
114
115impl Default for DatabaseConfig {
116    fn default() -> Self {
117        Self {
118            url:                  String::new(),
119            max_connections:      10,
120            min_connections:      1,
121            connect_timeout_secs: 10,
122            query_timeout_secs:   30,
123            idle_timeout_secs:    600,
124            ssl_mode:             SslMode::Prefer,
125        }
126    }
127}
128
129/// SSL mode for database connections.
130#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "kebab-case")]
132pub enum SslMode {
133    /// Disable SSL.
134    Disable,
135    /// Prefer SSL but allow non-SSL.
136    #[default]
137    Prefer,
138    /// Require SSL.
139    Require,
140    /// Require SSL and verify CA.
141    VerifyCa,
142    /// Require SSL and verify full certificate.
143    VerifyFull,
144}
145
146// =============================================================================
147// CORS Configuration
148// =============================================================================
149
150/// Cross-Origin Resource Sharing (CORS) configuration.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct CorsConfig {
154    /// Enabled CORS.
155    pub enabled: bool,
156
157    /// Allowed origins. Empty = allow all, "*" = allow any.
158    pub allowed_origins: Vec<String>,
159
160    /// Allowed HTTP methods.
161    pub allowed_methods: Vec<String>,
162
163    /// Allowed headers.
164    pub allowed_headers: Vec<String>,
165
166    /// Headers to expose to the client.
167    pub expose_headers: Vec<String>,
168
169    /// Allow credentials (cookies, authorization headers).
170    pub allow_credentials: bool,
171
172    /// Preflight cache duration in seconds.
173    pub max_age_secs: u64,
174}
175
176impl Default for CorsConfig {
177    fn default() -> Self {
178        Self {
179            enabled:           true,
180            allowed_origins:   vec![], // Empty = allow all
181            allowed_methods:   vec!["GET".to_string(), "POST".to_string(), "OPTIONS".to_string()],
182            allowed_headers:   vec![
183                "Content-Type".to_string(),
184                "Authorization".to_string(),
185                "X-Request-ID".to_string(),
186            ],
187            expose_headers:    vec![],
188            allow_credentials: false,
189            max_age_secs:      86400, // 24 hours
190        }
191    }
192}
193
194// =============================================================================
195// Authentication Configuration
196// =============================================================================
197
198/// Authentication configuration.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct AuthConfig {
202    /// Enable authentication.
203    pub enabled: bool,
204
205    /// Authentication provider.
206    pub provider: AuthProvider,
207
208    /// JWT secret (for jwt provider).
209    pub jwt_secret: Option<String>,
210
211    /// JWT algorithm (default: HS256).
212    pub jwt_algorithm: String,
213
214    /// Auth0/Clerk domain.
215    pub domain: Option<String>,
216
217    /// Auth0/Clerk audience.
218    pub audience: Option<String>,
219
220    /// Auth0/Clerk client ID.
221    pub client_id: Option<String>,
222
223    /// Header name for auth token.
224    pub header_name: String,
225
226    /// Token prefix (e.g., "Bearer ").
227    pub token_prefix: String,
228
229    /// Paths to exclude from authentication.
230    pub exclude_paths: Vec<String>,
231}
232
233impl Default for AuthConfig {
234    fn default() -> Self {
235        Self {
236            enabled:       false,
237            provider:      AuthProvider::None,
238            jwt_secret:    None,
239            jwt_algorithm: "HS256".to_string(),
240            domain:        None,
241            audience:      None,
242            client_id:     None,
243            header_name:   "Authorization".to_string(),
244            token_prefix:  "Bearer ".to_string(),
245            exclude_paths: vec!["/health".to_string()],
246        }
247    }
248}
249
250/// Authentication provider.
251#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum AuthProvider {
254    /// No authentication.
255    #[default]
256    None,
257    /// Simple JWT authentication.
258    Jwt,
259    /// Auth0 authentication.
260    Auth0,
261    /// Clerk authentication.
262    Clerk,
263    /// Custom webhook-based authentication.
264    Webhook,
265}
266
267// =============================================================================
268// Rate Limiting Configuration
269// =============================================================================
270
271/// Rate limiting configuration.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(default)]
274pub struct RateLimitConfig {
275    /// Enable rate limiting.
276    pub enabled: bool,
277
278    /// Maximum requests per window.
279    pub requests_per_window: u32,
280
281    /// Window duration in seconds.
282    pub window_secs: u64,
283
284    /// Key extractor (ip, user, `api_key`).
285    pub key_by: RateLimitKey,
286
287    /// Paths to exclude from rate limiting.
288    pub exclude_paths: Vec<String>,
289
290    /// Custom limits per path pattern.
291    pub path_limits: Vec<PathRateLimit>,
292}
293
294impl Default for RateLimitConfig {
295    fn default() -> Self {
296        Self {
297            enabled:             false,
298            requests_per_window: 100,
299            window_secs:         60,
300            key_by:              RateLimitKey::Ip,
301            exclude_paths:       vec!["/health".to_string()],
302            path_limits:         vec![],
303        }
304    }
305}
306
307/// Rate limit key extractor.
308#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
309#[serde(rename_all = "snake_case")]
310pub enum RateLimitKey {
311    /// Rate limit by IP address.
312    #[default]
313    Ip,
314    /// Rate limit by authenticated user.
315    User,
316    /// Rate limit by API key.
317    ApiKey,
318}
319
320/// Per-path rate limit override.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PathRateLimit {
323    /// Path pattern (glob).
324    pub path:                String,
325    /// Maximum requests per window for this path.
326    pub requests_per_window: u32,
327}
328
329// =============================================================================
330// Caching Configuration
331// =============================================================================
332
333/// Caching configuration.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(default)]
336pub struct CacheConfig {
337    /// Enable Automatic Persisted Queries (APQ).
338    pub apq_enabled: bool,
339
340    /// APQ cache TTL in seconds.
341    pub apq_ttl_secs: u64,
342
343    /// Maximum APQ cache entries.
344    pub apq_max_entries: usize,
345
346    /// Enable response caching.
347    pub response_cache_enabled: bool,
348
349    /// Response cache TTL in seconds.
350    pub response_cache_ttl_secs: u64,
351
352    /// Maximum response cache entries.
353    pub response_cache_max_entries: usize,
354}
355
356impl Default for CacheConfig {
357    fn default() -> Self {
358        Self {
359            apq_enabled:                true,
360            apq_ttl_secs:               86400, // 24 hours
361            apq_max_entries:            10_000,
362            response_cache_enabled:     false,
363            response_cache_ttl_secs:    60,
364            response_cache_max_entries: 1_000,
365        }
366    }
367}
368
369// =============================================================================
370// Collation Configuration
371// =============================================================================
372
373/// Collation configuration for user-aware sorting.
374///
375/// This configuration enables automatic collation support based on user locale,
376/// adapting to database capabilities.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(default)]
379pub struct CollationConfig {
380    /// Enable automatic user-aware collation.
381    pub enabled: bool,
382
383    /// Fallback locale for unauthenticated users.
384    pub fallback_locale: String,
385
386    /// Allowed locales (whitelist for security).
387    pub allowed_locales: Vec<String>,
388
389    /// Strategy when user locale is not in allowed list.
390    pub on_invalid_locale: InvalidLocaleStrategy,
391
392    /// Database-specific overrides (optional).
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub database_overrides: Option<DatabaseCollationOverrides>,
395}
396
397impl Default for CollationConfig {
398    fn default() -> Self {
399        Self {
400            enabled:            true,
401            fallback_locale:    "en-US".to_string(),
402            allowed_locales:    vec![
403                "en-US".into(),
404                "en-GB".into(),
405                "fr-FR".into(),
406                "de-DE".into(),
407                "es-ES".into(),
408                "ja-JP".into(),
409                "zh-CN".into(),
410                "pt-BR".into(),
411                "it-IT".into(),
412            ],
413            on_invalid_locale:  InvalidLocaleStrategy::Fallback,
414            database_overrides: None,
415        }
416    }
417}
418
419/// Strategy when user locale is not in allowed list.
420#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
421#[serde(rename_all = "snake_case")]
422pub enum InvalidLocaleStrategy {
423    /// Use fallback locale.
424    #[default]
425    Fallback,
426    /// Use database default (no COLLATE clause).
427    DatabaseDefault,
428    /// Return error.
429    Error,
430}
431
432/// Database-specific collation overrides.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct DatabaseCollationOverrides {
435    /// PostgreSQL-specific settings.
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub postgres: Option<PostgresCollationConfig>,
438
439    /// MySQL-specific settings.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub mysql: Option<MySqlCollationConfig>,
442
443    /// SQLite-specific settings.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub sqlite: Option<SqliteCollationConfig>,
446
447    /// SQL Server-specific settings.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub sqlserver: Option<SqlServerCollationConfig>,
450}
451
452/// PostgreSQL-specific collation configuration.
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct PostgresCollationConfig {
455    /// Use ICU collations (recommended).
456    pub use_icu: bool,
457
458    /// Provider: "icu" or "libc".
459    pub provider: String,
460}
461
462impl Default for PostgresCollationConfig {
463    fn default() -> Self {
464        Self {
465            use_icu:  true,
466            provider: "icu".to_string(),
467        }
468    }
469}
470
471/// MySQL-specific collation configuration.
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct MySqlCollationConfig {
474    /// Charset (e.g., "utf8mb4").
475    pub charset: String,
476
477    /// Collation suffix (e.g., "_unicode_ci" or "_0900_ai_ci").
478    pub suffix: String,
479}
480
481impl Default for MySqlCollationConfig {
482    fn default() -> Self {
483        Self {
484            charset: "utf8mb4".to_string(),
485            suffix:  "_unicode_ci".to_string(),
486        }
487    }
488}
489
490/// SQLite-specific collation configuration.
491#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct SqliteCollationConfig {
493    /// Use COLLATE NOCASE for case-insensitive sorting.
494    pub use_nocase: bool,
495}
496
497impl Default for SqliteCollationConfig {
498    fn default() -> Self {
499        Self { use_nocase: true }
500    }
501}
502
503/// SQL Server-specific collation configuration.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct SqlServerCollationConfig {
506    /// Case-insensitive (CI) collations.
507    pub case_insensitive: bool,
508
509    /// Accent-insensitive (AI) collations.
510    pub accent_insensitive: bool,
511}
512
513impl Default for SqlServerCollationConfig {
514    fn default() -> Self {
515        Self {
516            case_insensitive:   true,
517            accent_insensitive: true,
518        }
519    }
520}
521
522// =============================================================================
523// Main Configuration
524// =============================================================================
525
526/// Main configuration structure.
527///
528/// This is the complete configuration for a `FraiseQL` server instance.
529/// It can be loaded from a TOML file, environment variables, or built programmatically.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[serde(default)]
532pub struct FraiseQLConfig {
533    /// Server configuration.
534    pub server: ServerConfig,
535
536    /// Database configuration.
537    pub database: DatabaseConfig,
538
539    /// CORS configuration.
540    pub cors: CorsConfig,
541
542    /// Authentication configuration.
543    pub auth: AuthConfig,
544
545    /// Rate limiting configuration.
546    pub rate_limit: RateLimitConfig,
547
548    /// Caching configuration.
549    pub cache: CacheConfig,
550
551    /// Collation configuration.
552    pub collation: CollationConfig,
553
554    // Legacy fields for backward compatibility
555    #[serde(skip)]
556    database_url_compat: Option<String>,
557
558    /// Database connection URL (legacy, prefer database.url).
559    #[serde(skip_serializing, default)]
560    pub database_url: String,
561
562    /// Server host (legacy, prefer server.host).
563    #[serde(skip_serializing, default)]
564    pub host: String,
565
566    /// Server port (legacy, prefer server.port).
567    #[serde(skip_serializing, default)]
568    pub port: u16,
569
570    /// Maximum connections (legacy, prefer `database.max_connections`).
571    #[serde(skip_serializing, default)]
572    pub max_connections: u32,
573
574    /// Query timeout (legacy, prefer `database.query_timeout_secs`).
575    #[serde(skip_serializing, default)]
576    pub query_timeout_secs: u64,
577}
578
579impl Default for FraiseQLConfig {
580    fn default() -> Self {
581        let server = ServerConfig::default();
582        let database = DatabaseConfig::default();
583
584        Self {
585            // Legacy compat fields
586            database_url: String::new(),
587            host: server.host.clone(),
588            port: server.port,
589            max_connections: database.max_connections,
590            query_timeout_secs: database.query_timeout_secs,
591            database_url_compat: None,
592
593            // New structured config
594            server,
595            database,
596            cors: CorsConfig::default(),
597            auth: AuthConfig::default(),
598            rate_limit: RateLimitConfig::default(),
599            cache: CacheConfig::default(),
600            collation: CollationConfig::default(),
601        }
602    }
603}
604
605impl FraiseQLConfig {
606    /// Create a new configuration builder.
607    #[must_use]
608    pub fn builder() -> ConfigBuilder {
609        ConfigBuilder::default()
610    }
611
612    /// Load configuration from a TOML file.
613    ///
614    /// # Errors
615    ///
616    /// Returns error if the file cannot be read or parsed.
617    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
618        let path = path.as_ref();
619        let content = std::fs::read_to_string(path).map_err(|e| FraiseQLError::Configuration {
620            message: format!("Failed to read config file '{}': {}", path.display(), e),
621        })?;
622
623        Self::from_toml(&content)
624    }
625
626    /// Load configuration from a TOML string.
627    ///
628    /// # Errors
629    ///
630    /// Returns error if the TOML is invalid.
631    pub fn from_toml(content: &str) -> Result<Self> {
632        // Expand environment variables in the content
633        let expanded = expand_env_vars(content);
634
635        let mut config: Self =
636            toml::from_str(&expanded).map_err(|e| FraiseQLError::Configuration {
637                message: format!("Invalid TOML configuration: {e}"),
638            })?;
639
640        // Sync legacy fields
641        config.sync_legacy_fields();
642
643        Ok(config)
644    }
645
646    /// Load configuration from environment variables.
647    ///
648    /// # Errors
649    ///
650    /// Returns error if required environment variables are missing.
651    pub fn from_env() -> Result<Self> {
652        let database_url =
653            std::env::var("DATABASE_URL").map_err(|_| FraiseQLError::Configuration {
654                message: "DATABASE_URL not set".to_string(),
655            })?;
656
657        let host = std::env::var("FRAISEQL_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
658
659        let port = std::env::var("FRAISEQL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8000);
660
661        let max_connections = std::env::var("FRAISEQL_MAX_CONNECTIONS")
662            .ok()
663            .and_then(|s| s.parse().ok())
664            .unwrap_or(10);
665
666        let query_timeout = std::env::var("FRAISEQL_QUERY_TIMEOUT")
667            .ok()
668            .and_then(|s| s.parse().ok())
669            .unwrap_or(30);
670
671        let mut config = Self {
672            server: ServerConfig {
673                host: host.clone(),
674                port,
675                ..Default::default()
676            },
677            database: DatabaseConfig {
678                url: database_url.clone(),
679                max_connections,
680                query_timeout_secs: query_timeout,
681                ..Default::default()
682            },
683            // Legacy compat
684            database_url,
685            host,
686            port,
687            max_connections,
688            query_timeout_secs: query_timeout,
689            ..Default::default()
690        };
691
692        // Load optional auth settings from env
693        if let Ok(provider) = std::env::var("FRAISEQL_AUTH_PROVIDER") {
694            config.auth.enabled = true;
695            config.auth.provider = match provider.to_lowercase().as_str() {
696                "jwt" => AuthProvider::Jwt,
697                "auth0" => AuthProvider::Auth0,
698                "clerk" => AuthProvider::Clerk,
699                "webhook" => AuthProvider::Webhook,
700                _ => AuthProvider::None,
701            };
702        }
703
704        if let Ok(secret) = std::env::var("JWT_SECRET") {
705            config.auth.jwt_secret = Some(secret);
706        }
707
708        if let Ok(domain) = std::env::var("AUTH0_DOMAIN") {
709            config.auth.domain = Some(domain);
710        }
711
712        if let Ok(audience) = std::env::var("AUTH0_AUDIENCE") {
713            config.auth.audience = Some(audience);
714        }
715
716        Ok(config)
717    }
718
719    /// Sync legacy flat fields with new structured fields.
720    fn sync_legacy_fields(&mut self) {
721        // If structured database.url is set, use it for legacy field
722        if !self.database.url.is_empty() {
723            self.database_url = self.database.url.clone();
724        } else if !self.database_url.is_empty() {
725            // If legacy field is set, copy to structured
726            self.database.url = self.database_url.clone();
727        }
728
729        // Sync server fields
730        self.host = self.server.host.clone();
731        self.port = self.server.port;
732        self.max_connections = self.database.max_connections;
733        self.query_timeout_secs = self.database.query_timeout_secs;
734    }
735
736    /// Create a test configuration.
737    #[must_use]
738    pub fn test() -> Self {
739        Self {
740            server: ServerConfig {
741                host: "127.0.0.1".to_string(),
742                port: 0, // Random port
743                ..Default::default()
744            },
745            database: DatabaseConfig {
746                url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
747                max_connections: 2,
748                query_timeout_secs: 5,
749                ..Default::default()
750            },
751            // Legacy compat
752            database_url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
753            host: "127.0.0.1".to_string(),
754            port: 0,
755            max_connections: 2,
756            query_timeout_secs: 5,
757            ..Default::default()
758        }
759    }
760
761    /// Validate the configuration.
762    ///
763    /// # Errors
764    ///
765    /// Returns error if configuration is invalid.
766    pub fn validate(&self) -> Result<()> {
767        // Database URL required
768        if self.database.url.is_empty() && self.database_url.is_empty() {
769            return Err(FraiseQLError::Configuration {
770                message: "database.url is required".to_string(),
771            });
772        }
773
774        // Validate auth config
775        if self.auth.enabled {
776            match self.auth.provider {
777                AuthProvider::Jwt => {
778                    if self.auth.jwt_secret.is_none() {
779                        return Err(FraiseQLError::Configuration {
780                            message: "auth.jwt_secret is required when using JWT provider"
781                                .to_string(),
782                        });
783                    }
784                },
785                AuthProvider::Auth0 | AuthProvider::Clerk => {
786                    if self.auth.domain.is_none() {
787                        return Err(FraiseQLError::Configuration {
788                            message: format!(
789                                "auth.domain is required when using {:?} provider",
790                                self.auth.provider
791                            ),
792                        });
793                    }
794                },
795                AuthProvider::Webhook | AuthProvider::None => {},
796            }
797        }
798
799        Ok(())
800    }
801
802    /// Export configuration to TOML string.
803    #[must_use]
804    pub fn to_toml(&self) -> String {
805        toml::to_string_pretty(self).unwrap_or_default()
806    }
807}
808
809/// Expand environment variables in a string.
810///
811/// Supports `${VAR}` and `$VAR` syntax.
812#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
813fn expand_env_vars(content: &str) -> String {
814    use std::sync::LazyLock;
815
816    // The regex pattern is a compile-time constant and is guaranteed to be valid
817    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
818        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
819    });
820
821    let mut result = content.to_string();
822
823    for cap in ENV_VAR_REGEX.captures_iter(content) {
824        if let Some(full_match) = cap.get(0) {
825            if let Some(var_name_match) = cap.get(1) {
826                let full_match_str = full_match.as_str();
827                let var_name = var_name_match.as_str();
828
829                if let Ok(value) = std::env::var(var_name) {
830                    result = result.replace(full_match_str, &value);
831                }
832            }
833        }
834    }
835
836    result
837}
838
839/// Configuration builder.
840#[derive(Debug, Default)]
841pub struct ConfigBuilder {
842    config: FraiseQLConfig,
843}
844
845impl ConfigBuilder {
846    /// Set the database URL.
847    #[must_use]
848    pub fn database_url(mut self, url: &str) -> Self {
849        self.config.database.url = url.to_string();
850        self.config.database_url = url.to_string();
851        self
852    }
853
854    /// Set the server host.
855    #[must_use]
856    pub fn host(mut self, host: &str) -> Self {
857        self.config.server.host = host.to_string();
858        self.config.host = host.to_string();
859        self
860    }
861
862    /// Set the server port.
863    #[must_use]
864    pub fn port(mut self, port: u16) -> Self {
865        self.config.server.port = port;
866        self.config.port = port;
867        self
868    }
869
870    /// Set maximum database connections.
871    #[must_use]
872    pub fn max_connections(mut self, n: u32) -> Self {
873        self.config.database.max_connections = n;
874        self.config.max_connections = n;
875        self
876    }
877
878    /// Set query timeout.
879    #[must_use]
880    pub fn query_timeout(mut self, secs: u64) -> Self {
881        self.config.database.query_timeout_secs = secs;
882        self.config.query_timeout_secs = secs;
883        self
884    }
885
886    /// Set CORS configuration.
887    #[must_use]
888    pub fn cors(mut self, cors: CorsConfig) -> Self {
889        self.config.cors = cors;
890        self
891    }
892
893    /// Set auth configuration.
894    #[must_use]
895    pub fn auth(mut self, auth: AuthConfig) -> Self {
896        self.config.auth = auth;
897        self
898    }
899
900    /// Set rate limit configuration.
901    #[must_use]
902    pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
903        self.config.rate_limit = rate_limit;
904        self
905    }
906
907    /// Set cache configuration.
908    #[must_use]
909    pub fn cache(mut self, cache: CacheConfig) -> Self {
910        self.config.cache = cache;
911        self
912    }
913
914    /// Set collation configuration.
915    #[must_use]
916    pub fn collation(mut self, collation: CollationConfig) -> Self {
917        self.config.collation = collation;
918        self
919    }
920
921    /// Build the configuration.
922    ///
923    /// # Errors
924    ///
925    /// Returns error if configuration is invalid.
926    pub fn build(mut self) -> Result<FraiseQLConfig> {
927        self.config.sync_legacy_fields();
928        self.config.validate()?;
929        Ok(self.config)
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    use super::*;
936
937    #[test]
938    fn test_default_config() {
939        let config = FraiseQLConfig::default();
940        assert_eq!(config.port, 8000);
941        assert_eq!(config.host, "0.0.0.0");
942        assert_eq!(config.server.port, 8000);
943        assert_eq!(config.server.host, "0.0.0.0");
944    }
945
946    #[test]
947    fn test_builder() {
948        let config = FraiseQLConfig::builder()
949            .database_url("postgresql://localhost/test")
950            .port(9000)
951            .build()
952            .unwrap();
953
954        assert_eq!(config.port, 9000);
955        assert_eq!(config.server.port, 9000);
956        assert!(!config.database_url.is_empty());
957        assert!(!config.database.url.is_empty());
958    }
959
960    #[test]
961    fn test_builder_requires_database_url() {
962        let result = FraiseQLConfig::builder().build();
963        assert!(result.is_err());
964    }
965
966    #[test]
967    fn test_from_toml_minimal() {
968        let toml = r#"
969[database]
970url = "postgresql://localhost/test"
971"#;
972        let config = FraiseQLConfig::from_toml(toml).unwrap();
973        assert_eq!(config.database.url, "postgresql://localhost/test");
974        assert_eq!(config.database_url, "postgresql://localhost/test");
975    }
976
977    #[test]
978    fn test_from_toml_full() {
979        let toml = r#"
980[server]
981host = "127.0.0.1"
982port = 9000
983workers = 4
984max_body_size = 2097152
985request_logging = true
986
987[database]
988url = "postgresql://localhost/mydb"
989max_connections = 20
990min_connections = 2
991connect_timeout_secs = 15
992query_timeout_secs = 60
993idle_timeout_secs = 300
994ssl_mode = "require"
995
996[cors]
997enabled = true
998allowed_origins = ["http://localhost:3000", "https://app.example.com"]
999allow_credentials = true
1000
1001[auth]
1002enabled = true
1003provider = "jwt"
1004jwt_secret = "my-secret-key"
1005jwt_algorithm = "HS256"
1006exclude_paths = ["/health", "/metrics"]
1007
1008[rate_limit]
1009enabled = true
1010requests_per_window = 200
1011window_secs = 120
1012key_by = "user"
1013
1014[cache]
1015apq_enabled = true
1016apq_ttl_secs = 3600
1017response_cache_enabled = true
1018"#;
1019        let config = FraiseQLConfig::from_toml(toml).unwrap();
1020
1021        // Server
1022        assert_eq!(config.server.host, "127.0.0.1");
1023        assert_eq!(config.server.port, 9000);
1024        assert_eq!(config.server.workers, 4);
1025
1026        // Database
1027        assert_eq!(config.database.url, "postgresql://localhost/mydb");
1028        assert_eq!(config.database.max_connections, 20);
1029        assert_eq!(config.database.ssl_mode, SslMode::Require);
1030
1031        // CORS
1032        assert!(config.cors.enabled);
1033        assert_eq!(config.cors.allowed_origins.len(), 2);
1034        assert!(config.cors.allow_credentials);
1035
1036        // Auth
1037        assert!(config.auth.enabled);
1038        assert_eq!(config.auth.provider, AuthProvider::Jwt);
1039        assert_eq!(config.auth.jwt_secret, Some("my-secret-key".to_string()));
1040
1041        // Rate Limit
1042        assert!(config.rate_limit.enabled);
1043        assert_eq!(config.rate_limit.requests_per_window, 200);
1044        assert_eq!(config.rate_limit.key_by, RateLimitKey::User);
1045
1046        // Cache
1047        assert!(config.cache.apq_enabled);
1048        assert!(config.cache.response_cache_enabled);
1049    }
1050
1051    #[test]
1052    fn test_env_var_expansion() {
1053        temp_env::with_vars(
1054            [
1055                ("TEST_DB_URL", Some("postgresql://user:pass@host/db")),
1056                ("TEST_JWT_SECRET", Some("super-secret")),
1057            ],
1058            || {
1059                let toml = r#"
1060[database]
1061url = "${TEST_DB_URL}"
1062
1063[auth]
1064enabled = true
1065provider = "jwt"
1066jwt_secret = "${TEST_JWT_SECRET}"
1067"#;
1068                let config = FraiseQLConfig::from_toml(toml).unwrap();
1069
1070                assert_eq!(config.database.url, "postgresql://user:pass@host/db");
1071                assert_eq!(config.auth.jwt_secret, Some("super-secret".to_string()));
1072            },
1073        );
1074    }
1075
1076    #[test]
1077    fn test_auth_validation_jwt_requires_secret() {
1078        let toml = r#"
1079[database]
1080url = "postgresql://localhost/test"
1081
1082[auth]
1083enabled = true
1084provider = "jwt"
1085"#;
1086        let result = FraiseQLConfig::from_toml(toml);
1087        // from_toml succeeds but validate would fail
1088        let config = result.unwrap();
1089        let validation = config.validate();
1090        assert!(validation.is_err());
1091        assert!(validation.unwrap_err().to_string().contains("jwt_secret is required"));
1092    }
1093
1094    #[test]
1095    fn test_auth_validation_auth0_requires_domain() {
1096        let toml = r#"
1097[database]
1098url = "postgresql://localhost/test"
1099
1100[auth]
1101enabled = true
1102provider = "auth0"
1103"#;
1104        let config = FraiseQLConfig::from_toml(toml).unwrap();
1105        let validation = config.validate();
1106        assert!(validation.is_err());
1107        assert!(validation.unwrap_err().to_string().contains("domain is required"));
1108    }
1109
1110    #[test]
1111    fn test_to_toml() {
1112        let config = FraiseQLConfig::builder()
1113            .database_url("postgresql://localhost/test")
1114            .port(9000)
1115            .build()
1116            .unwrap();
1117
1118        let toml_str = config.to_toml();
1119        assert!(toml_str.contains("[server]"));
1120        assert!(toml_str.contains("[database]"));
1121        assert!(toml_str.contains("port = 9000"));
1122    }
1123
1124    #[test]
1125    fn test_cors_config_defaults() {
1126        let cors = CorsConfig::default();
1127        assert!(cors.enabled);
1128        assert!(cors.allowed_origins.is_empty()); // Empty = allow all
1129        assert!(cors.allowed_methods.contains(&"POST".to_string()));
1130        assert!(cors.allowed_headers.contains(&"Authorization".to_string()));
1131    }
1132
1133    #[test]
1134    fn test_rate_limit_key_variants() {
1135        let toml = r#"
1136[database]
1137url = "postgresql://localhost/test"
1138
1139[rate_limit]
1140key_by = "api_key"
1141"#;
1142        let config = FraiseQLConfig::from_toml(toml).unwrap();
1143        assert_eq!(config.rate_limit.key_by, RateLimitKey::ApiKey);
1144    }
1145
1146    #[test]
1147    fn test_ssl_mode_variants() {
1148        for (ssl_str, expected) in [
1149            ("disable", SslMode::Disable),
1150            ("prefer", SslMode::Prefer),
1151            ("require", SslMode::Require),
1152            ("verify-ca", SslMode::VerifyCa),
1153            ("verify-full", SslMode::VerifyFull),
1154        ] {
1155            let toml = format!(
1156                r#"
1157[database]
1158url = "postgresql://localhost/test"
1159ssl_mode = "{}"
1160"#,
1161                ssl_str
1162            );
1163            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1164            assert_eq!(config.database.ssl_mode, expected);
1165        }
1166    }
1167
1168    #[test]
1169    fn test_legacy_field_sync() {
1170        let config = FraiseQLConfig::builder()
1171            .database_url("postgresql://localhost/test")
1172            .host("192.168.1.1")
1173            .port(4000)
1174            .max_connections(50)
1175            .query_timeout(120)
1176            .build()
1177            .unwrap();
1178
1179        // Both legacy and new fields should match
1180        assert_eq!(config.host, "192.168.1.1");
1181        assert_eq!(config.server.host, "192.168.1.1");
1182        assert_eq!(config.port, 4000);
1183        assert_eq!(config.server.port, 4000);
1184        assert_eq!(config.max_connections, 50);
1185        assert_eq!(config.database.max_connections, 50);
1186        assert_eq!(config.query_timeout_secs, 120);
1187        assert_eq!(config.database.query_timeout_secs, 120);
1188    }
1189
1190    #[test]
1191    fn test_auth_providers() {
1192        for (provider_str, expected) in [
1193            ("none", AuthProvider::None),
1194            ("jwt", AuthProvider::Jwt),
1195            ("auth0", AuthProvider::Auth0),
1196            ("clerk", AuthProvider::Clerk),
1197            ("webhook", AuthProvider::Webhook),
1198        ] {
1199            let toml = format!(
1200                r#"
1201[database]
1202url = "postgresql://localhost/test"
1203
1204[auth]
1205provider = "{}"
1206"#,
1207                provider_str
1208            );
1209            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1210            assert_eq!(config.auth.provider, expected);
1211        }
1212    }
1213
1214    #[test]
1215    fn test_collation_config_default() {
1216        let config = CollationConfig::default();
1217        assert!(config.enabled);
1218        assert_eq!(config.fallback_locale, "en-US");
1219        assert!(config.allowed_locales.contains(&"en-US".to_string()));
1220        assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
1221        assert_eq!(config.on_invalid_locale, InvalidLocaleStrategy::Fallback);
1222        assert!(config.database_overrides.is_none());
1223    }
1224
1225    #[test]
1226    fn test_collation_config_from_toml() {
1227        let toml = r#"
1228[database]
1229url = "postgresql://localhost/test"
1230
1231[collation]
1232enabled = true
1233fallback_locale = "en-GB"
1234on_invalid_locale = "error"
1235allowed_locales = ["en-GB", "fr-FR", "de-DE"]
1236"#;
1237        let config = FraiseQLConfig::from_toml(toml).unwrap();
1238
1239        assert!(config.collation.enabled);
1240        assert_eq!(config.collation.fallback_locale, "en-GB");
1241        assert_eq!(config.collation.on_invalid_locale, InvalidLocaleStrategy::Error);
1242        assert_eq!(config.collation.allowed_locales.len(), 3);
1243        assert!(config.collation.allowed_locales.contains(&"de-DE".to_string()));
1244    }
1245
1246    #[test]
1247    fn test_collation_with_postgres_overrides() {
1248        let toml = r#"
1249[database]
1250url = "postgresql://localhost/test"
1251
1252[collation]
1253enabled = true
1254fallback_locale = "en-US"
1255
1256[collation.database_overrides.postgres]
1257use_icu = false
1258provider = "libc"
1259"#;
1260        let config = FraiseQLConfig::from_toml(toml).unwrap();
1261
1262        let overrides = config.collation.database_overrides.as_ref().unwrap();
1263        let pg_config = overrides.postgres.as_ref().unwrap();
1264        assert!(!pg_config.use_icu);
1265        assert_eq!(pg_config.provider, "libc");
1266    }
1267
1268    #[test]
1269    fn test_collation_with_mysql_overrides() {
1270        let toml = r#"
1271[database]
1272url = "postgresql://localhost/test"
1273
1274[collation]
1275enabled = true
1276
1277[collation.database_overrides.mysql]
1278charset = "utf8mb4"
1279suffix = "_0900_ai_ci"
1280"#;
1281        let config = FraiseQLConfig::from_toml(toml).unwrap();
1282
1283        let overrides = config.collation.database_overrides.as_ref().unwrap();
1284        let mysql_config = overrides.mysql.as_ref().unwrap();
1285        assert_eq!(mysql_config.charset, "utf8mb4");
1286        assert_eq!(mysql_config.suffix, "_0900_ai_ci");
1287    }
1288
1289    #[test]
1290    fn test_collation_with_sqlite_overrides() {
1291        let toml = r#"
1292[database]
1293url = "postgresql://localhost/test"
1294
1295[collation]
1296enabled = true
1297
1298[collation.database_overrides.sqlite]
1299use_nocase = false
1300"#;
1301        let config = FraiseQLConfig::from_toml(toml).unwrap();
1302
1303        let overrides = config.collation.database_overrides.as_ref().unwrap();
1304        let sqlite_config = overrides.sqlite.as_ref().unwrap();
1305        assert!(!sqlite_config.use_nocase);
1306    }
1307
1308    #[test]
1309    fn test_invalid_locale_strategy_variants() {
1310        for (strategy_str, expected) in [
1311            ("fallback", InvalidLocaleStrategy::Fallback),
1312            ("database_default", InvalidLocaleStrategy::DatabaseDefault),
1313            ("error", InvalidLocaleStrategy::Error),
1314        ] {
1315            let toml = format!(
1316                r#"
1317[database]
1318url = "postgresql://localhost/test"
1319
1320[collation]
1321on_invalid_locale = "{}"
1322"#,
1323                strategy_str
1324            );
1325            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1326            assert_eq!(config.collation.on_invalid_locale, expected);
1327        }
1328    }
1329
1330    #[test]
1331    fn test_collation_disabled() {
1332        let toml = r#"
1333[database]
1334url = "postgresql://localhost/test"
1335
1336[collation]
1337enabled = false
1338"#;
1339        let config = FraiseQLConfig::from_toml(toml).unwrap();
1340        assert!(!config.collation.enabled);
1341    }
1342
1343    #[test]
1344    fn test_collation_config_builder() {
1345        let collation = CollationConfig {
1346            enabled: false,
1347            fallback_locale: "de-DE".to_string(),
1348            ..Default::default()
1349        };
1350
1351        let config = FraiseQLConfig::builder()
1352            .database_url("postgresql://localhost/test")
1353            .collation(collation)
1354            .build()
1355            .unwrap();
1356
1357        assert!(!config.collation.enabled);
1358        assert_eq!(config.collation.fallback_locale, "de-DE");
1359    }
1360}