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)]
813fn expand_env_vars(content: &str) -> String {
814    use once_cell::sync::Lazy;
815
816    // The regex pattern is a compile-time constant and is guaranteed to be valid
817    static ENV_VAR_REGEX: Lazy<regex::Regex> =
818        Lazy::new(|| regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("Invalid regex"));
819
820    let mut result = content.to_string();
821
822    for cap in ENV_VAR_REGEX.captures_iter(content) {
823        if let Some(full_match) = cap.get(0) {
824            if let Some(var_name_match) = cap.get(1) {
825                let full_match_str = full_match.as_str();
826                let var_name = var_name_match.as_str();
827
828                if let Ok(value) = std::env::var(var_name) {
829                    result = result.replace(full_match_str, &value);
830                }
831            }
832        }
833    }
834
835    result
836}
837
838/// Configuration builder.
839#[derive(Debug, Default)]
840pub struct ConfigBuilder {
841    config: FraiseQLConfig,
842}
843
844impl ConfigBuilder {
845    /// Set the database URL.
846    #[must_use]
847    pub fn database_url(mut self, url: &str) -> Self {
848        self.config.database.url = url.to_string();
849        self.config.database_url = url.to_string();
850        self
851    }
852
853    /// Set the server host.
854    #[must_use]
855    pub fn host(mut self, host: &str) -> Self {
856        self.config.server.host = host.to_string();
857        self.config.host = host.to_string();
858        self
859    }
860
861    /// Set the server port.
862    #[must_use]
863    pub fn port(mut self, port: u16) -> Self {
864        self.config.server.port = port;
865        self.config.port = port;
866        self
867    }
868
869    /// Set maximum database connections.
870    #[must_use]
871    pub fn max_connections(mut self, n: u32) -> Self {
872        self.config.database.max_connections = n;
873        self.config.max_connections = n;
874        self
875    }
876
877    /// Set query timeout.
878    #[must_use]
879    pub fn query_timeout(mut self, secs: u64) -> Self {
880        self.config.database.query_timeout_secs = secs;
881        self.config.query_timeout_secs = secs;
882        self
883    }
884
885    /// Set CORS configuration.
886    #[must_use]
887    pub fn cors(mut self, cors: CorsConfig) -> Self {
888        self.config.cors = cors;
889        self
890    }
891
892    /// Set auth configuration.
893    #[must_use]
894    pub fn auth(mut self, auth: AuthConfig) -> Self {
895        self.config.auth = auth;
896        self
897    }
898
899    /// Set rate limit configuration.
900    #[must_use]
901    pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
902        self.config.rate_limit = rate_limit;
903        self
904    }
905
906    /// Set cache configuration.
907    #[must_use]
908    pub fn cache(mut self, cache: CacheConfig) -> Self {
909        self.config.cache = cache;
910        self
911    }
912
913    /// Set collation configuration.
914    #[must_use]
915    pub fn collation(mut self, collation: CollationConfig) -> Self {
916        self.config.collation = collation;
917        self
918    }
919
920    /// Build the configuration.
921    ///
922    /// # Errors
923    ///
924    /// Returns error if configuration is invalid.
925    pub fn build(mut self) -> Result<FraiseQLConfig> {
926        self.config.sync_legacy_fields();
927        self.config.validate()?;
928        Ok(self.config)
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    #[test]
937    fn test_default_config() {
938        let config = FraiseQLConfig::default();
939        assert_eq!(config.port, 8000);
940        assert_eq!(config.host, "0.0.0.0");
941        assert_eq!(config.server.port, 8000);
942        assert_eq!(config.server.host, "0.0.0.0");
943    }
944
945    #[test]
946    fn test_builder() {
947        let config = FraiseQLConfig::builder()
948            .database_url("postgresql://localhost/test")
949            .port(9000)
950            .build()
951            .unwrap();
952
953        assert_eq!(config.port, 9000);
954        assert_eq!(config.server.port, 9000);
955        assert!(!config.database_url.is_empty());
956        assert!(!config.database.url.is_empty());
957    }
958
959    #[test]
960    fn test_builder_requires_database_url() {
961        let result = FraiseQLConfig::builder().build();
962        assert!(result.is_err());
963    }
964
965    #[test]
966    fn test_from_toml_minimal() {
967        let toml = r#"
968[database]
969url = "postgresql://localhost/test"
970"#;
971        let config = FraiseQLConfig::from_toml(toml).unwrap();
972        assert_eq!(config.database.url, "postgresql://localhost/test");
973        assert_eq!(config.database_url, "postgresql://localhost/test");
974    }
975
976    #[test]
977    fn test_from_toml_full() {
978        let toml = r#"
979[server]
980host = "127.0.0.1"
981port = 9000
982workers = 4
983max_body_size = 2097152
984request_logging = true
985
986[database]
987url = "postgresql://localhost/mydb"
988max_connections = 20
989min_connections = 2
990connect_timeout_secs = 15
991query_timeout_secs = 60
992idle_timeout_secs = 300
993ssl_mode = "require"
994
995[cors]
996enabled = true
997allowed_origins = ["http://localhost:3000", "https://app.example.com"]
998allow_credentials = true
999
1000[auth]
1001enabled = true
1002provider = "jwt"
1003jwt_secret = "my-secret-key"
1004jwt_algorithm = "HS256"
1005exclude_paths = ["/health", "/metrics"]
1006
1007[rate_limit]
1008enabled = true
1009requests_per_window = 200
1010window_secs = 120
1011key_by = "user"
1012
1013[cache]
1014apq_enabled = true
1015apq_ttl_secs = 3600
1016response_cache_enabled = true
1017"#;
1018        let config = FraiseQLConfig::from_toml(toml).unwrap();
1019
1020        // Server
1021        assert_eq!(config.server.host, "127.0.0.1");
1022        assert_eq!(config.server.port, 9000);
1023        assert_eq!(config.server.workers, 4);
1024
1025        // Database
1026        assert_eq!(config.database.url, "postgresql://localhost/mydb");
1027        assert_eq!(config.database.max_connections, 20);
1028        assert_eq!(config.database.ssl_mode, SslMode::Require);
1029
1030        // CORS
1031        assert!(config.cors.enabled);
1032        assert_eq!(config.cors.allowed_origins.len(), 2);
1033        assert!(config.cors.allow_credentials);
1034
1035        // Auth
1036        assert!(config.auth.enabled);
1037        assert_eq!(config.auth.provider, AuthProvider::Jwt);
1038        assert_eq!(config.auth.jwt_secret, Some("my-secret-key".to_string()));
1039
1040        // Rate Limit
1041        assert!(config.rate_limit.enabled);
1042        assert_eq!(config.rate_limit.requests_per_window, 200);
1043        assert_eq!(config.rate_limit.key_by, RateLimitKey::User);
1044
1045        // Cache
1046        assert!(config.cache.apq_enabled);
1047        assert!(config.cache.response_cache_enabled);
1048    }
1049
1050    #[test]
1051    fn test_env_var_expansion() {
1052        std::env::set_var("TEST_DB_URL", "postgresql://user:pass@host/db");
1053        std::env::set_var("TEST_JWT_SECRET", "super-secret");
1054
1055        let toml = r#"
1056[database]
1057url = "${TEST_DB_URL}"
1058
1059[auth]
1060enabled = true
1061provider = "jwt"
1062jwt_secret = "${TEST_JWT_SECRET}"
1063"#;
1064        let config = FraiseQLConfig::from_toml(toml).unwrap();
1065
1066        assert_eq!(config.database.url, "postgresql://user:pass@host/db");
1067        assert_eq!(config.auth.jwt_secret, Some("super-secret".to_string()));
1068
1069        std::env::remove_var("TEST_DB_URL");
1070        std::env::remove_var("TEST_JWT_SECRET");
1071    }
1072
1073    #[test]
1074    fn test_auth_validation_jwt_requires_secret() {
1075        let toml = r#"
1076[database]
1077url = "postgresql://localhost/test"
1078
1079[auth]
1080enabled = true
1081provider = "jwt"
1082"#;
1083        let result = FraiseQLConfig::from_toml(toml);
1084        // from_toml succeeds but validate would fail
1085        let config = result.unwrap();
1086        let validation = config.validate();
1087        assert!(validation.is_err());
1088        assert!(validation.unwrap_err().to_string().contains("jwt_secret is required"));
1089    }
1090
1091    #[test]
1092    fn test_auth_validation_auth0_requires_domain() {
1093        let toml = r#"
1094[database]
1095url = "postgresql://localhost/test"
1096
1097[auth]
1098enabled = true
1099provider = "auth0"
1100"#;
1101        let config = FraiseQLConfig::from_toml(toml).unwrap();
1102        let validation = config.validate();
1103        assert!(validation.is_err());
1104        assert!(validation.unwrap_err().to_string().contains("domain is required"));
1105    }
1106
1107    #[test]
1108    fn test_to_toml() {
1109        let config = FraiseQLConfig::builder()
1110            .database_url("postgresql://localhost/test")
1111            .port(9000)
1112            .build()
1113            .unwrap();
1114
1115        let toml_str = config.to_toml();
1116        assert!(toml_str.contains("[server]"));
1117        assert!(toml_str.contains("[database]"));
1118        assert!(toml_str.contains("port = 9000"));
1119    }
1120
1121    #[test]
1122    fn test_cors_config_defaults() {
1123        let cors = CorsConfig::default();
1124        assert!(cors.enabled);
1125        assert!(cors.allowed_origins.is_empty()); // Empty = allow all
1126        assert!(cors.allowed_methods.contains(&"POST".to_string()));
1127        assert!(cors.allowed_headers.contains(&"Authorization".to_string()));
1128    }
1129
1130    #[test]
1131    fn test_rate_limit_key_variants() {
1132        let toml = r#"
1133[database]
1134url = "postgresql://localhost/test"
1135
1136[rate_limit]
1137key_by = "api_key"
1138"#;
1139        let config = FraiseQLConfig::from_toml(toml).unwrap();
1140        assert_eq!(config.rate_limit.key_by, RateLimitKey::ApiKey);
1141    }
1142
1143    #[test]
1144    fn test_ssl_mode_variants() {
1145        for (ssl_str, expected) in [
1146            ("disable", SslMode::Disable),
1147            ("prefer", SslMode::Prefer),
1148            ("require", SslMode::Require),
1149            ("verify-ca", SslMode::VerifyCa),
1150            ("verify-full", SslMode::VerifyFull),
1151        ] {
1152            let toml = format!(
1153                r#"
1154[database]
1155url = "postgresql://localhost/test"
1156ssl_mode = "{}"
1157"#,
1158                ssl_str
1159            );
1160            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1161            assert_eq!(config.database.ssl_mode, expected);
1162        }
1163    }
1164
1165    #[test]
1166    fn test_legacy_field_sync() {
1167        let config = FraiseQLConfig::builder()
1168            .database_url("postgresql://localhost/test")
1169            .host("192.168.1.1")
1170            .port(4000)
1171            .max_connections(50)
1172            .query_timeout(120)
1173            .build()
1174            .unwrap();
1175
1176        // Both legacy and new fields should match
1177        assert_eq!(config.host, "192.168.1.1");
1178        assert_eq!(config.server.host, "192.168.1.1");
1179        assert_eq!(config.port, 4000);
1180        assert_eq!(config.server.port, 4000);
1181        assert_eq!(config.max_connections, 50);
1182        assert_eq!(config.database.max_connections, 50);
1183        assert_eq!(config.query_timeout_secs, 120);
1184        assert_eq!(config.database.query_timeout_secs, 120);
1185    }
1186
1187    #[test]
1188    fn test_auth_providers() {
1189        for (provider_str, expected) in [
1190            ("none", AuthProvider::None),
1191            ("jwt", AuthProvider::Jwt),
1192            ("auth0", AuthProvider::Auth0),
1193            ("clerk", AuthProvider::Clerk),
1194            ("webhook", AuthProvider::Webhook),
1195        ] {
1196            let toml = format!(
1197                r#"
1198[database]
1199url = "postgresql://localhost/test"
1200
1201[auth]
1202provider = "{}"
1203"#,
1204                provider_str
1205            );
1206            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1207            assert_eq!(config.auth.provider, expected);
1208        }
1209    }
1210
1211    #[test]
1212    fn test_collation_config_default() {
1213        let config = CollationConfig::default();
1214        assert!(config.enabled);
1215        assert_eq!(config.fallback_locale, "en-US");
1216        assert!(config.allowed_locales.contains(&"en-US".to_string()));
1217        assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
1218        assert_eq!(config.on_invalid_locale, InvalidLocaleStrategy::Fallback);
1219        assert!(config.database_overrides.is_none());
1220    }
1221
1222    #[test]
1223    fn test_collation_config_from_toml() {
1224        let toml = r#"
1225[database]
1226url = "postgresql://localhost/test"
1227
1228[collation]
1229enabled = true
1230fallback_locale = "en-GB"
1231on_invalid_locale = "error"
1232allowed_locales = ["en-GB", "fr-FR", "de-DE"]
1233"#;
1234        let config = FraiseQLConfig::from_toml(toml).unwrap();
1235
1236        assert!(config.collation.enabled);
1237        assert_eq!(config.collation.fallback_locale, "en-GB");
1238        assert_eq!(config.collation.on_invalid_locale, InvalidLocaleStrategy::Error);
1239        assert_eq!(config.collation.allowed_locales.len(), 3);
1240        assert!(config.collation.allowed_locales.contains(&"de-DE".to_string()));
1241    }
1242
1243    #[test]
1244    fn test_collation_with_postgres_overrides() {
1245        let toml = r#"
1246[database]
1247url = "postgresql://localhost/test"
1248
1249[collation]
1250enabled = true
1251fallback_locale = "en-US"
1252
1253[collation.database_overrides.postgres]
1254use_icu = false
1255provider = "libc"
1256"#;
1257        let config = FraiseQLConfig::from_toml(toml).unwrap();
1258
1259        let overrides = config.collation.database_overrides.as_ref().unwrap();
1260        let pg_config = overrides.postgres.as_ref().unwrap();
1261        assert!(!pg_config.use_icu);
1262        assert_eq!(pg_config.provider, "libc");
1263    }
1264
1265    #[test]
1266    fn test_collation_with_mysql_overrides() {
1267        let toml = r#"
1268[database]
1269url = "postgresql://localhost/test"
1270
1271[collation]
1272enabled = true
1273
1274[collation.database_overrides.mysql]
1275charset = "utf8mb4"
1276suffix = "_0900_ai_ci"
1277"#;
1278        let config = FraiseQLConfig::from_toml(toml).unwrap();
1279
1280        let overrides = config.collation.database_overrides.as_ref().unwrap();
1281        let mysql_config = overrides.mysql.as_ref().unwrap();
1282        assert_eq!(mysql_config.charset, "utf8mb4");
1283        assert_eq!(mysql_config.suffix, "_0900_ai_ci");
1284    }
1285
1286    #[test]
1287    fn test_collation_with_sqlite_overrides() {
1288        let toml = r#"
1289[database]
1290url = "postgresql://localhost/test"
1291
1292[collation]
1293enabled = true
1294
1295[collation.database_overrides.sqlite]
1296use_nocase = false
1297"#;
1298        let config = FraiseQLConfig::from_toml(toml).unwrap();
1299
1300        let overrides = config.collation.database_overrides.as_ref().unwrap();
1301        let sqlite_config = overrides.sqlite.as_ref().unwrap();
1302        assert!(!sqlite_config.use_nocase);
1303    }
1304
1305    #[test]
1306    fn test_invalid_locale_strategy_variants() {
1307        for (strategy_str, expected) in [
1308            ("fallback", InvalidLocaleStrategy::Fallback),
1309            ("database_default", InvalidLocaleStrategy::DatabaseDefault),
1310            ("error", InvalidLocaleStrategy::Error),
1311        ] {
1312            let toml = format!(
1313                r#"
1314[database]
1315url = "postgresql://localhost/test"
1316
1317[collation]
1318on_invalid_locale = "{}"
1319"#,
1320                strategy_str
1321            );
1322            let config = FraiseQLConfig::from_toml(&toml).unwrap();
1323            assert_eq!(config.collation.on_invalid_locale, expected);
1324        }
1325    }
1326
1327    #[test]
1328    fn test_collation_disabled() {
1329        let toml = r#"
1330[database]
1331url = "postgresql://localhost/test"
1332
1333[collation]
1334enabled = false
1335"#;
1336        let config = FraiseQLConfig::from_toml(toml).unwrap();
1337        assert!(!config.collation.enabled);
1338    }
1339
1340    #[test]
1341    fn test_collation_config_builder() {
1342        let collation = CollationConfig {
1343            enabled: false,
1344            fallback_locale: "de-DE".to_string(),
1345            ..Default::default()
1346        };
1347
1348        let config = FraiseQLConfig::builder()
1349            .database_url("postgresql://localhost/test")
1350            .collation(collation)
1351            .build()
1352            .unwrap();
1353
1354        assert!(!config.collation.enabled);
1355        assert_eq!(config.collation.fallback_locale, "de-DE");
1356    }
1357}