prax_schema/config/
mod.rs

1//! Configuration file parsing for `prax.toml`.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::error::{SchemaError, SchemaResult};
8
9/// Main configuration structure for `prax.toml`.
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct PraxConfig {
13    /// Database configuration.
14    #[serde(default)]
15    pub database: DatabaseConfig,
16
17    /// Schema file configuration.
18    #[serde(default)]
19    pub schema: SchemaConfig,
20
21    /// Generator configuration.
22    #[serde(default)]
23    pub generator: GeneratorConfig,
24
25    /// Migration settings.
26    #[serde(default)]
27    pub migrations: MigrationConfig,
28
29    /// Seeding configuration.
30    #[serde(default)]
31    pub seed: SeedConfig,
32
33    /// Debug/logging settings.
34    #[serde(default)]
35    pub debug: DebugConfig,
36
37    /// Environment-specific overrides.
38    #[serde(default)]
39    pub environments: HashMap<String, EnvironmentOverride>,
40}
41
42impl PraxConfig {
43    /// Load configuration from a file path.
44    pub fn from_file(path: impl AsRef<Path>) -> SchemaResult<Self> {
45        let path = path.as_ref();
46        let content = std::fs::read_to_string(path).map_err(|e| SchemaError::IoError {
47            path: path.display().to_string(),
48            source: e,
49        })?;
50
51        Self::from_str(&content)
52    }
53
54    /// Parse configuration from a TOML string.
55    #[allow(clippy::should_implement_trait)]
56    pub fn from_str(content: &str) -> SchemaResult<Self> {
57        // First, expand environment variables
58        let expanded = expand_env_vars(content);
59
60        toml::from_str(&expanded).map_err(|e| SchemaError::TomlError { source: e })
61    }
62
63    /// Get the database URL, resolving environment variables.
64    pub fn database_url(&self) -> Option<&str> {
65        self.database.url.as_deref()
66    }
67
68    /// Apply environment-specific overrides.
69    pub fn with_environment(mut self, env: &str) -> Self {
70        if let Some(overrides) = self.environments.remove(env) {
71            if let Some(db) = overrides.database {
72                if let Some(url) = db.url {
73                    self.database.url = Some(url);
74                }
75                if let Some(pool) = db.pool {
76                    self.database.pool = pool;
77                }
78            }
79            if let Some(debug) = overrides.debug {
80                if let Some(log_queries) = debug.log_queries {
81                    self.debug.log_queries = log_queries;
82                }
83                if let Some(pretty_sql) = debug.pretty_sql {
84                    self.debug.pretty_sql = pretty_sql;
85                }
86                if let Some(threshold) = debug.slow_query_threshold {
87                    self.debug.slow_query_threshold = threshold;
88                }
89            }
90        }
91        self
92    }
93}
94
95/// Database configuration.
96#[derive(Debug, Clone, Deserialize, Serialize)]
97#[serde(deny_unknown_fields)]
98pub struct DatabaseConfig {
99    /// Database provider.
100    #[serde(default = "default_provider")]
101    pub provider: DatabaseProvider,
102
103    /// Connection URL (supports `${ENV_VAR}` interpolation).
104    pub url: Option<String>,
105
106    /// Connection pool settings.
107    #[serde(default)]
108    pub pool: PoolConfig,
109}
110
111impl Default for DatabaseConfig {
112    fn default() -> Self {
113        Self {
114            provider: DatabaseProvider::PostgreSql,
115            url: None,
116            pool: PoolConfig::default(),
117        }
118    }
119}
120
121fn default_provider() -> DatabaseProvider {
122    DatabaseProvider::PostgreSql
123}
124
125/// Supported database providers.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
127#[serde(rename_all = "lowercase")]
128pub enum DatabaseProvider {
129    /// PostgreSQL.
130    #[serde(alias = "postgres")]
131    PostgreSql,
132    /// MySQL / MariaDB.
133    MySql,
134    /// SQLite.
135    #[serde(alias = "sqlite3")]
136    Sqlite,
137    /// MongoDB.
138    #[serde(alias = "mongo")]
139    MongoDb,
140}
141
142impl DatabaseProvider {
143    /// Get the provider name as a string.
144    pub fn as_str(&self) -> &'static str {
145        match self {
146            Self::PostgreSql => "postgresql",
147            Self::MySql => "mysql",
148            Self::Sqlite => "sqlite",
149            Self::MongoDb => "mongodb",
150        }
151    }
152}
153
154/// Connection pool configuration.
155#[derive(Debug, Clone, Deserialize, Serialize)]
156#[serde(deny_unknown_fields)]
157pub struct PoolConfig {
158    /// Minimum number of connections.
159    #[serde(default = "default_min_connections")]
160    pub min_connections: u32,
161
162    /// Maximum number of connections.
163    #[serde(default = "default_max_connections")]
164    pub max_connections: u32,
165
166    /// Connection timeout.
167    #[serde(default = "default_connect_timeout")]
168    pub connect_timeout: String,
169
170    /// Idle connection timeout.
171    #[serde(default = "default_idle_timeout")]
172    pub idle_timeout: String,
173
174    /// Maximum connection lifetime.
175    #[serde(default = "default_max_lifetime")]
176    pub max_lifetime: String,
177}
178
179impl Default for PoolConfig {
180    fn default() -> Self {
181        Self {
182            min_connections: default_min_connections(),
183            max_connections: default_max_connections(),
184            connect_timeout: default_connect_timeout(),
185            idle_timeout: default_idle_timeout(),
186            max_lifetime: default_max_lifetime(),
187        }
188    }
189}
190
191fn default_min_connections() -> u32 {
192    2
193}
194fn default_max_connections() -> u32 {
195    10
196}
197fn default_connect_timeout() -> String {
198    "30s".to_string()
199}
200fn default_idle_timeout() -> String {
201    "10m".to_string()
202}
203fn default_max_lifetime() -> String {
204    "30m".to_string()
205}
206
207/// Schema file configuration.
208#[derive(Debug, Clone, Deserialize, Serialize)]
209#[serde(deny_unknown_fields)]
210pub struct SchemaConfig {
211    /// Path to the schema file.
212    #[serde(default = "default_schema_path")]
213    pub path: String,
214}
215
216impl Default for SchemaConfig {
217    fn default() -> Self {
218        Self {
219            path: default_schema_path(),
220        }
221    }
222}
223
224fn default_schema_path() -> String {
225    "schema.prax".to_string()
226}
227
228/// Generator configuration.
229#[derive(Debug, Clone, Default, Deserialize, Serialize)]
230#[serde(deny_unknown_fields)]
231pub struct GeneratorConfig {
232    /// Client generator settings.
233    #[serde(default)]
234    pub client: ClientGeneratorConfig,
235}
236
237/// Style of model code generation.
238///
239/// Controls whether models are generated as plain Rust structs or with
240/// additional framework-specific derives like async-graphql.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
242#[serde(rename_all = "lowercase")]
243pub enum ModelStyle {
244    /// Generate plain Rust models with Serde derives.
245    /// This is the default and generates the lightest weight models.
246    #[default]
247    Standard,
248
249    /// Generate models with async-graphql derives.
250    /// Adds `#[derive(SimpleObject)]`, `#[derive(InputObject)]`, etc.
251    /// Requires the `async-graphql` crate as a dependency.
252    #[serde(alias = "async-graphql")]
253    GraphQL,
254}
255
256impl ModelStyle {
257    /// Returns true if this style requires GraphQL derives.
258    pub fn is_graphql(&self) -> bool {
259        matches!(self, Self::GraphQL)
260    }
261}
262
263/// Client generator configuration.
264#[derive(Debug, Clone, Deserialize, Serialize)]
265#[serde(deny_unknown_fields)]
266pub struct ClientGeneratorConfig {
267    /// Output directory.
268    #[serde(default = "default_output")]
269    pub output: String,
270
271    /// Generate async client.
272    #[serde(default = "default_true")]
273    pub async_client: bool,
274
275    /// Enable tracing instrumentation.
276    #[serde(default)]
277    pub tracing: bool,
278
279    /// Preview features to enable.
280    #[serde(default)]
281    pub preview_features: Vec<String>,
282
283    /// Model generation style.
284    ///
285    /// Controls the type of derives and attributes added to generated models:
286    /// - `standard`: Plain Rust structs with Serde (default)
287    /// - `graphql`: Adds async-graphql derives (SimpleObject, InputObject, etc.)
288    #[serde(default)]
289    pub model_style: ModelStyle,
290}
291
292impl Default for ClientGeneratorConfig {
293    fn default() -> Self {
294        Self {
295            output: default_output(),
296            async_client: true,
297            tracing: false,
298            preview_features: vec![],
299            model_style: ModelStyle::default(),
300        }
301    }
302}
303
304fn default_output() -> String {
305    "./src/generated".to_string()
306}
307fn default_true() -> bool {
308    true
309}
310
311/// Migration configuration.
312#[derive(Debug, Clone, Deserialize, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct MigrationConfig {
315    /// Migration files directory.
316    #[serde(default = "default_migrations_dir")]
317    pub directory: String,
318
319    /// Auto-apply migrations in development.
320    #[serde(default)]
321    pub auto_migrate: bool,
322
323    /// Migration history table name.
324    #[serde(default = "default_migrations_table")]
325    pub table_name: String,
326}
327
328impl Default for MigrationConfig {
329    fn default() -> Self {
330        Self {
331            directory: default_migrations_dir(),
332            auto_migrate: false,
333            table_name: default_migrations_table(),
334        }
335    }
336}
337
338fn default_migrations_dir() -> String {
339    "./migrations".to_string()
340}
341fn default_migrations_table() -> String {
342    "_prax_migrations".to_string()
343}
344
345/// Seed configuration.
346#[derive(Debug, Clone, Default, Deserialize, Serialize)]
347#[serde(deny_unknown_fields)]
348pub struct SeedConfig {
349    /// Seed script path.
350    pub script: Option<String>,
351
352    /// Run seed after migrations.
353    #[serde(default)]
354    pub auto_seed: bool,
355
356    /// Environment-specific seeding flags.
357    #[serde(default)]
358    pub environments: HashMap<String, bool>,
359}
360
361/// Debug/logging configuration.
362#[derive(Debug, Clone, Deserialize, Serialize)]
363#[serde(deny_unknown_fields)]
364pub struct DebugConfig {
365    /// Log all queries.
366    #[serde(default)]
367    pub log_queries: bool,
368
369    /// Pretty print SQL.
370    #[serde(default = "default_true")]
371    pub pretty_sql: bool,
372
373    /// Slow query threshold in milliseconds.
374    #[serde(default = "default_slow_query_threshold")]
375    pub slow_query_threshold: u64,
376}
377
378impl Default for DebugConfig {
379    fn default() -> Self {
380        Self {
381            log_queries: false,
382            pretty_sql: true,
383            slow_query_threshold: default_slow_query_threshold(),
384        }
385    }
386}
387
388fn default_slow_query_threshold() -> u64 {
389    1000
390}
391
392/// Environment-specific configuration overrides.
393#[derive(Debug, Clone, Default, Deserialize, Serialize)]
394#[serde(deny_unknown_fields)]
395pub struct EnvironmentOverride {
396    /// Database overrides.
397    pub database: Option<DatabaseOverride>,
398
399    /// Debug overrides.
400    pub debug: Option<DebugOverride>,
401}
402
403/// Database configuration overrides.
404#[derive(Debug, Clone, Default, Deserialize, Serialize)]
405#[serde(deny_unknown_fields)]
406pub struct DatabaseOverride {
407    /// Override connection URL.
408    pub url: Option<String>,
409
410    /// Override pool settings.
411    pub pool: Option<PoolConfig>,
412}
413
414/// Debug configuration overrides.
415#[derive(Debug, Clone, Default, Deserialize, Serialize)]
416#[serde(deny_unknown_fields)]
417pub struct DebugOverride {
418    /// Override log_queries.
419    pub log_queries: Option<bool>,
420
421    /// Override pretty_sql.
422    pub pretty_sql: Option<bool>,
423
424    /// Override slow_query_threshold.
425    pub slow_query_threshold: Option<u64>,
426}
427
428/// Expand environment variables in the format `${VAR_NAME}`.
429fn expand_env_vars(content: &str) -> String {
430    let mut result = content.to_string();
431    let re = regex_lite::Regex::new(r"\$\{([^}]+)\}").unwrap();
432
433    for cap in re.captures_iter(content) {
434        let var_name = &cap[1];
435        let full_match = &cap[0];
436
437        if let Ok(value) = std::env::var(var_name) {
438            result = result.replace(full_match, &value);
439        }
440    }
441
442    result
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    // ==================== PraxConfig Tests ====================
450
451    #[test]
452    fn test_default_config() {
453        let config = PraxConfig::default();
454        assert_eq!(config.database.provider, DatabaseProvider::PostgreSql);
455        assert_eq!(config.schema.path, "schema.prax");
456        assert!(config.database.url.is_none());
457        assert!(config.environments.is_empty());
458    }
459
460    #[test]
461    fn test_parse_minimal_config() {
462        let toml = r#"
463            [database]
464            provider = "postgresql"
465            url = "postgres://localhost/test"
466        "#;
467
468        let config = PraxConfig::from_str(toml).unwrap();
469        assert_eq!(
470            config.database.url,
471            Some("postgres://localhost/test".to_string())
472        );
473    }
474
475    #[test]
476    fn test_parse_full_config() {
477        let toml = r#"
478            [database]
479            provider = "postgresql"
480            url = "postgres://user:pass@localhost:5432/db"
481
482            [database.pool]
483            min_connections = 5
484            max_connections = 20
485            connect_timeout = "60s"
486            idle_timeout = "5m"
487            max_lifetime = "1h"
488
489            [schema]
490            path = "prisma/schema.prax"
491
492            [generator.client]
493            output = "./src/db"
494            async_client = true
495            tracing = true
496            preview_features = ["json", "fulltext"]
497
498            [migrations]
499            directory = "./db/migrations"
500            auto_migrate = true
501            table_name = "_migrations"
502
503            [seed]
504            script = "./scripts/seed.sh"
505            auto_seed = true
506
507            [seed.environments]
508            development = true
509            test = true
510            production = false
511
512            [debug]
513            log_queries = true
514            pretty_sql = false
515            slow_query_threshold = 500
516        "#;
517
518        let config = PraxConfig::from_str(toml).unwrap();
519
520        // Database
521        assert_eq!(config.database.provider, DatabaseProvider::PostgreSql);
522        assert!(config.database.url.is_some());
523        assert_eq!(config.database.pool.min_connections, 5);
524        assert_eq!(config.database.pool.max_connections, 20);
525
526        // Schema
527        assert_eq!(config.schema.path, "prisma/schema.prax");
528
529        // Generator
530        assert_eq!(config.generator.client.output, "./src/db");
531        assert!(config.generator.client.async_client);
532        assert!(config.generator.client.tracing);
533        assert_eq!(config.generator.client.preview_features.len(), 2);
534
535        // Migrations
536        assert_eq!(config.migrations.directory, "./db/migrations");
537        assert!(config.migrations.auto_migrate);
538        assert_eq!(config.migrations.table_name, "_migrations");
539
540        // Seed
541        assert_eq!(config.seed.script, Some("./scripts/seed.sh".to_string()));
542        assert!(config.seed.auto_seed);
543        assert!(
544            config
545                .seed
546                .environments
547                .get("development")
548                .copied()
549                .unwrap_or(false)
550        );
551
552        // Debug
553        assert!(config.debug.log_queries);
554        assert!(!config.debug.pretty_sql);
555        assert_eq!(config.debug.slow_query_threshold, 500);
556    }
557
558    #[test]
559    fn test_database_url_method() {
560        let config = PraxConfig {
561            database: DatabaseConfig {
562                url: Some("postgres://localhost/test".to_string()),
563                ..Default::default()
564            },
565            ..Default::default()
566        };
567
568        assert_eq!(config.database_url(), Some("postgres://localhost/test"));
569    }
570
571    #[test]
572    fn test_database_url_method_none() {
573        let config = PraxConfig::default();
574        assert!(config.database_url().is_none());
575    }
576
577    #[test]
578    fn test_with_environment_overrides() {
579        let toml = r#"
580            [database]
581            url = "postgres://localhost/dev"
582
583            [debug]
584            log_queries = false
585
586            [environments.production]
587            [environments.production.database]
588            url = "postgres://prod.server/db"
589
590            [environments.production.debug]
591            log_queries = true
592            slow_query_threshold = 100
593        "#;
594
595        let config = PraxConfig::from_str(toml)
596            .unwrap()
597            .with_environment("production");
598
599        assert_eq!(
600            config.database.url,
601            Some("postgres://prod.server/db".to_string())
602        );
603        assert!(config.debug.log_queries);
604        assert_eq!(config.debug.slow_query_threshold, 100);
605    }
606
607    #[test]
608    fn test_with_environment_nonexistent() {
609        let config = PraxConfig::default().with_environment("nonexistent");
610        // Should not panic and return unchanged config
611        assert_eq!(config.database.provider, DatabaseProvider::PostgreSql);
612    }
613
614    #[test]
615    fn test_parse_invalid_toml() {
616        let toml = "this is not valid [[ toml";
617        let result = PraxConfig::from_str(toml);
618        assert!(result.is_err());
619    }
620
621    // ==================== DatabaseProvider Tests ====================
622
623    #[test]
624    fn test_database_provider_postgresql() {
625        let toml = r#"
626            [database]
627            provider = "postgresql"
628        "#;
629        let config = PraxConfig::from_str(toml).unwrap();
630        assert_eq!(config.database.provider, DatabaseProvider::PostgreSql);
631        assert_eq!(config.database.provider.as_str(), "postgresql");
632    }
633
634    #[test]
635    fn test_database_provider_postgres_alias() {
636        let toml = r#"
637            [database]
638            provider = "postgres"
639        "#;
640        let config = PraxConfig::from_str(toml).unwrap();
641        assert_eq!(config.database.provider, DatabaseProvider::PostgreSql);
642    }
643
644    #[test]
645    fn test_database_provider_mysql() {
646        let toml = r#"
647            [database]
648            provider = "mysql"
649        "#;
650        let config = PraxConfig::from_str(toml).unwrap();
651        assert_eq!(config.database.provider, DatabaseProvider::MySql);
652        assert_eq!(config.database.provider.as_str(), "mysql");
653    }
654
655    #[test]
656    fn test_database_provider_sqlite() {
657        let toml = r#"
658            [database]
659            provider = "sqlite"
660        "#;
661        let config = PraxConfig::from_str(toml).unwrap();
662        assert_eq!(config.database.provider, DatabaseProvider::Sqlite);
663        assert_eq!(config.database.provider.as_str(), "sqlite");
664    }
665
666    #[test]
667    fn test_database_provider_sqlite3_alias() {
668        let toml = r#"
669            [database]
670            provider = "sqlite3"
671        "#;
672        let config = PraxConfig::from_str(toml).unwrap();
673        assert_eq!(config.database.provider, DatabaseProvider::Sqlite);
674    }
675
676    #[test]
677    fn test_database_provider_mongodb() {
678        let toml = r#"
679            [database]
680            provider = "mongodb"
681        "#;
682        let config = PraxConfig::from_str(toml).unwrap();
683        assert_eq!(config.database.provider, DatabaseProvider::MongoDb);
684        assert_eq!(config.database.provider.as_str(), "mongodb");
685    }
686
687    #[test]
688    fn test_database_provider_mongo_alias() {
689        let toml = r#"
690            [database]
691            provider = "mongo"
692        "#;
693        let config = PraxConfig::from_str(toml).unwrap();
694        assert_eq!(config.database.provider, DatabaseProvider::MongoDb);
695    }
696
697    // ==================== PoolConfig Tests ====================
698
699    #[test]
700    fn test_pool_config_defaults() {
701        let config = PoolConfig::default();
702        assert_eq!(config.min_connections, 2);
703        assert_eq!(config.max_connections, 10);
704        assert_eq!(config.connect_timeout, "30s");
705        assert_eq!(config.idle_timeout, "10m");
706        assert_eq!(config.max_lifetime, "30m");
707    }
708
709    #[test]
710    fn test_pool_config_custom() {
711        let toml = r#"
712            [database]
713            provider = "postgresql"
714
715            [database.pool]
716            min_connections = 1
717            max_connections = 50
718            connect_timeout = "10s"
719            idle_timeout = "30m"
720            max_lifetime = "2h"
721        "#;
722
723        let config = PraxConfig::from_str(toml).unwrap();
724        assert_eq!(config.database.pool.min_connections, 1);
725        assert_eq!(config.database.pool.max_connections, 50);
726        assert_eq!(config.database.pool.connect_timeout, "10s");
727    }
728
729    // ==================== SchemaConfig Tests ====================
730
731    #[test]
732    fn test_schema_config_default() {
733        let config = SchemaConfig::default();
734        assert_eq!(config.path, "schema.prax");
735    }
736
737    #[test]
738    fn test_schema_config_custom() {
739        let toml = r#"
740            [schema]
741            path = "db/schema.prax"
742        "#;
743
744        let config = PraxConfig::from_str(toml).unwrap();
745        assert_eq!(config.schema.path, "db/schema.prax");
746    }
747
748    // ==================== GeneratorConfig Tests ====================
749
750    #[test]
751    fn test_generator_config_default() {
752        let config = GeneratorConfig::default();
753        assert_eq!(config.client.output, "./src/generated");
754        assert!(config.client.async_client);
755        assert!(!config.client.tracing);
756        assert!(config.client.preview_features.is_empty());
757        assert_eq!(config.client.model_style, ModelStyle::Standard);
758    }
759
760    #[test]
761    fn test_generator_config_custom() {
762        let toml = r#"
763            [generator.client]
764            output = "./generated"
765            async_client = false
766            tracing = true
767            preview_features = ["feature1", "feature2"]
768        "#;
769
770        let config = PraxConfig::from_str(toml).unwrap();
771        assert_eq!(config.generator.client.output, "./generated");
772        assert!(!config.generator.client.async_client);
773        assert!(config.generator.client.tracing);
774        assert_eq!(config.generator.client.preview_features.len(), 2);
775    }
776
777    #[test]
778    fn test_generator_config_graphql_model_style() {
779        let toml = r#"
780            [generator.client]
781            model_style = "graphql"
782        "#;
783
784        let config = PraxConfig::from_str(toml).unwrap();
785        assert_eq!(config.generator.client.model_style, ModelStyle::GraphQL);
786        assert!(config.generator.client.model_style.is_graphql());
787    }
788
789    #[test]
790    fn test_generator_config_graphql_model_style_alias() {
791        let toml = r#"
792            [generator.client]
793            model_style = "async-graphql"
794        "#;
795
796        let config = PraxConfig::from_str(toml).unwrap();
797        assert_eq!(config.generator.client.model_style, ModelStyle::GraphQL);
798    }
799
800    #[test]
801    fn test_model_style_standard_is_not_graphql() {
802        assert!(!ModelStyle::Standard.is_graphql());
803        assert!(ModelStyle::GraphQL.is_graphql());
804    }
805
806    // ==================== MigrationConfig Tests ====================
807
808    #[test]
809    fn test_migration_config_default() {
810        let config = MigrationConfig::default();
811        assert_eq!(config.directory, "./migrations");
812        assert!(!config.auto_migrate);
813        assert_eq!(config.table_name, "_prax_migrations");
814    }
815
816    #[test]
817    fn test_migration_config_custom() {
818        let toml = r#"
819            [migrations]
820            directory = "./db/migrate"
821            auto_migrate = true
822            table_name = "schema_migrations"
823        "#;
824
825        let config = PraxConfig::from_str(toml).unwrap();
826        assert_eq!(config.migrations.directory, "./db/migrate");
827        assert!(config.migrations.auto_migrate);
828        assert_eq!(config.migrations.table_name, "schema_migrations");
829    }
830
831    // ==================== SeedConfig Tests ====================
832
833    #[test]
834    fn test_seed_config_default() {
835        let config = SeedConfig::default();
836        assert!(config.script.is_none());
837        assert!(!config.auto_seed);
838        assert!(config.environments.is_empty());
839    }
840
841    #[test]
842    fn test_seed_config_custom() {
843        let toml = r#"
844            [seed]
845            script = "seed.rs"
846            auto_seed = true
847
848            [seed.environments]
849            dev = true
850            prod = false
851        "#;
852
853        let config = PraxConfig::from_str(toml).unwrap();
854        assert_eq!(config.seed.script, Some("seed.rs".to_string()));
855        assert!(config.seed.auto_seed);
856        assert_eq!(config.seed.environments.get("dev"), Some(&true));
857        assert_eq!(config.seed.environments.get("prod"), Some(&false));
858    }
859
860    // ==================== DebugConfig Tests ====================
861
862    #[test]
863    fn test_debug_config_default() {
864        let config = DebugConfig::default();
865        assert!(!config.log_queries);
866        assert!(config.pretty_sql);
867        assert_eq!(config.slow_query_threshold, 1000);
868    }
869
870    #[test]
871    fn test_debug_config_custom() {
872        let toml = r#"
873            [debug]
874            log_queries = true
875            pretty_sql = false
876            slow_query_threshold = 200
877        "#;
878
879        let config = PraxConfig::from_str(toml).unwrap();
880        assert!(config.debug.log_queries);
881        assert!(!config.debug.pretty_sql);
882        assert_eq!(config.debug.slow_query_threshold, 200);
883    }
884
885    // ==================== Environment Variable Tests ====================
886
887    #[test]
888    fn test_env_var_expansion() {
889        // SAFETY: This test runs single-threaded and we clean up after
890        unsafe {
891            std::env::set_var("TEST_DB_URL", "postgres://test");
892        }
893        let expanded = expand_env_vars("url = \"${TEST_DB_URL}\"");
894        assert_eq!(expanded, "url = \"postgres://test\"");
895        unsafe {
896            std::env::remove_var("TEST_DB_URL");
897        }
898    }
899
900    #[test]
901    fn test_env_var_expansion_multiple() {
902        unsafe {
903            std::env::set_var("TEST_HOST", "localhost");
904            std::env::set_var("TEST_PORT", "5432");
905        }
906        let content = "host = \"${TEST_HOST}\"\nport = \"${TEST_PORT}\"";
907        let expanded = expand_env_vars(content);
908        assert!(expanded.contains("localhost"));
909        assert!(expanded.contains("5432"));
910        unsafe {
911            std::env::remove_var("TEST_HOST");
912            std::env::remove_var("TEST_PORT");
913        }
914    }
915
916    #[test]
917    fn test_env_var_expansion_missing_var() {
918        let content = "url = \"${DEFINITELY_NOT_SET_VAR_12345}\"";
919        let expanded = expand_env_vars(content);
920        // Should not expand missing variables
921        assert_eq!(expanded, content);
922    }
923
924    #[test]
925    fn test_env_var_expansion_in_config() {
926        unsafe {
927            std::env::set_var("TEST_DATABASE_URL_2", "postgres://user:pass@localhost/db");
928        }
929
930        let toml = r#"
931            [database]
932            url = "${TEST_DATABASE_URL_2}"
933        "#;
934
935        let config = PraxConfig::from_str(toml).unwrap();
936        assert_eq!(
937            config.database.url,
938            Some("postgres://user:pass@localhost/db".to_string())
939        );
940
941        unsafe {
942            std::env::remove_var("TEST_DATABASE_URL_2");
943        }
944    }
945
946    // ==================== Environment Override Tests ====================
947
948    #[test]
949    fn test_environment_override_database_url() {
950        let toml = r#"
951            [database]
952            url = "postgres://localhost/dev"
953
954            [environments.test]
955            [environments.test.database]
956            url = "postgres://localhost/test_db"
957        "#;
958
959        let config = PraxConfig::from_str(toml).unwrap().with_environment("test");
960
961        assert_eq!(
962            config.database.url,
963            Some("postgres://localhost/test_db".to_string())
964        );
965    }
966
967    #[test]
968    fn test_environment_override_pool() {
969        let toml = r#"
970            [database.pool]
971            max_connections = 10
972
973            [environments.production]
974            [environments.production.database.pool]
975            max_connections = 100
976            min_connections = 10
977        "#;
978
979        let config = PraxConfig::from_str(toml)
980            .unwrap()
981            .with_environment("production");
982
983        assert_eq!(config.database.pool.max_connections, 100);
984        assert_eq!(config.database.pool.min_connections, 10);
985    }
986
987    #[test]
988    fn test_environment_override_debug() {
989        let toml = r#"
990            [debug]
991            log_queries = false
992            pretty_sql = true
993
994            [environments.development]
995            [environments.development.debug]
996            log_queries = true
997            pretty_sql = false
998            slow_query_threshold = 50
999        "#;
1000
1001        let config = PraxConfig::from_str(toml)
1002            .unwrap()
1003            .with_environment("development");
1004
1005        assert!(config.debug.log_queries);
1006        assert!(!config.debug.pretty_sql);
1007        assert_eq!(config.debug.slow_query_threshold, 50);
1008    }
1009
1010    // ==================== Serialization Tests ====================
1011
1012    #[test]
1013    fn test_config_serialization() {
1014        let config = PraxConfig::default();
1015        let toml_str = toml::to_string(&config).unwrap();
1016        assert!(toml_str.contains("[database]"));
1017    }
1018
1019    #[test]
1020    fn test_config_roundtrip() {
1021        let original = PraxConfig {
1022            database: DatabaseConfig {
1023                provider: DatabaseProvider::MySql,
1024                url: Some("mysql://localhost/test".to_string()),
1025                pool: PoolConfig::default(),
1026            },
1027            ..Default::default()
1028        };
1029
1030        let toml_str = toml::to_string(&original).unwrap();
1031        let parsed: PraxConfig = toml::from_str(&toml_str).unwrap();
1032
1033        assert_eq!(parsed.database.provider, original.database.provider);
1034        assert_eq!(parsed.database.url, original.database.url);
1035    }
1036
1037    // ==================== Clone and Debug Tests ====================
1038
1039    #[test]
1040    fn test_config_clone() {
1041        let config = PraxConfig::default();
1042        let cloned = config.clone();
1043        assert_eq!(config.database.provider, cloned.database.provider);
1044    }
1045
1046    #[test]
1047    fn test_config_debug() {
1048        let config = PraxConfig::default();
1049        let debug_str = format!("{:?}", config);
1050        assert!(debug_str.contains("PraxConfig"));
1051    }
1052
1053    #[test]
1054    fn test_provider_equality() {
1055        assert_eq!(DatabaseProvider::PostgreSql, DatabaseProvider::PostgreSql);
1056        assert_ne!(DatabaseProvider::PostgreSql, DatabaseProvider::MySql);
1057    }
1058
1059    // ==================== Default Function Tests ====================
1060
1061    #[test]
1062    fn test_default_functions() {
1063        assert_eq!(default_provider(), DatabaseProvider::PostgreSql);
1064        assert_eq!(default_min_connections(), 2);
1065        assert_eq!(default_max_connections(), 10);
1066        assert_eq!(default_connect_timeout(), "30s");
1067        assert_eq!(default_idle_timeout(), "10m");
1068        assert_eq!(default_max_lifetime(), "30m");
1069        assert_eq!(default_schema_path(), "schema.prax");
1070        assert_eq!(default_output(), "./src/generated");
1071        assert!(default_true());
1072        assert_eq!(default_migrations_dir(), "./migrations");
1073        assert_eq!(default_migrations_table(), "_prax_migrations");
1074        assert_eq!(default_slow_query_threshold(), 1000);
1075    }
1076}