Skip to main content

schema_risk/
env.rs

1//! Environment configuration loader.
2//!
3//! Provides automatic loading of `.env` files and resolution of database URLs
4//! from multiple environment variable sources.
5//!
6//! # Priority Order for Database URL
7//! 1. CLI argument `--db-url` (highest priority)
8//! 2. `DATABASE_URL` environment variable
9//! 3. `DB_URL` environment variable
10//! 4. `POSTGRES_URL` environment variable
11//! 5. `database.url` in `schema-risk.yml` config file (lowest priority)
12
13use std::path::Path;
14
15/// Environment variable names checked for database URL, in priority order.
16pub const DB_URL_ENV_VARS: &[&str] = &["DATABASE_URL", "DB_URL", "POSTGRES_URL"];
17
18/// Result of loading environment configuration.
19#[derive(Debug, Clone, Default)]
20pub struct EnvConfig {
21    /// Database URL resolved from environment (if found).
22    pub database_url: Option<String>,
23    /// Whether a `.env` file was successfully loaded.
24    pub dotenv_loaded: bool,
25    /// Path to the `.env` file that was loaded (if any).
26    pub dotenv_path: Option<String>,
27    /// Which environment variable the database URL came from (if any).
28    pub database_url_source: Option<String>,
29}
30
31impl EnvConfig {
32    /// Load environment configuration.
33    ///
34    /// 1. Attempts to load `.env` file from current directory
35    /// 2. Checks environment variables for database URL
36    ///
37    /// # Example
38    /// ```no_run
39    /// use schema_risk::env::EnvConfig;
40    /// let env = EnvConfig::load();
41    /// if let Some(url) = &env.database_url {
42    ///     println!("Found database URL from {}", env.database_url_source.as_deref().unwrap_or("unknown"));
43    /// }
44    /// ```
45    pub fn load() -> Self {
46        Self::load_from_dir(Path::new("."))
47    }
48
49    /// Load environment configuration from a specific directory.
50    pub fn load_from_dir(dir: &Path) -> Self {
51        let mut config = EnvConfig::default();
52
53        // Try to load .env file
54        let env_path = dir.join(".env");
55        if env_path.exists() {
56            match dotenvy::from_path(&env_path) {
57                Ok(()) => {
58                    config.dotenv_loaded = true;
59                    config.dotenv_path = Some(env_path.to_string_lossy().to_string());
60                }
61                Err(e) => {
62                    // Log but don't fail - .env is optional
63                    tracing::debug!("Failed to load .env file: {}", e);
64                }
65            }
66        } else {
67            // Try the dotenv default behavior (searches parent directories)
68            if dotenvy::dotenv().is_ok() {
69                config.dotenv_loaded = true;
70                config.dotenv_path = dotenvy::var("DOTENV_FILE").ok();
71            }
72        }
73
74        // Resolve database URL from environment variables
75        for var_name in DB_URL_ENV_VARS {
76            if let Ok(url) = std::env::var(var_name) {
77                if !url.is_empty() {
78                    config.database_url = Some(url);
79                    config.database_url_source = Some(var_name.to_string());
80                    break;
81                }
82            }
83        }
84
85        config
86    }
87
88    /// Resolve the final database URL, with CLI argument taking precedence.
89    ///
90    /// # Arguments
91    /// * `cli_url` - URL provided via `--db-url` CLI argument
92    /// * `config_url` - URL from `schema-risk.yml` config file
93    ///
94    /// # Returns
95    /// The database URL to use, or `None` if no URL is configured.
96    pub fn resolve_db_url(
97        &self,
98        cli_url: Option<&str>,
99        config_url: Option<&str>,
100    ) -> Option<String> {
101        // Priority: CLI > env var > config file
102        cli_url
103            .map(String::from)
104            .or_else(|| self.database_url.clone())
105            .or_else(|| config_url.map(String::from))
106    }
107
108    /// Check if a database URL is available from any source.
109    pub fn has_db_url(&self) -> bool {
110        self.database_url.is_some()
111    }
112
113    /// Get a human-readable description of where the database URL came from.
114    pub fn db_url_source_description(&self) -> Option<String> {
115        self.database_url_source.as_ref().map(|source| {
116            if self.dotenv_loaded {
117                format!("{} (from .env)", source)
118            } else {
119                format!("{} (from environment)", source)
120            }
121        })
122    }
123}
124
125// ───────────────────────────────────────────────────────────────────────────────
126// Tests
127// ───────────────────────────────────────────────────────────────────────────────
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::sync::Mutex;
133
134    // Mutex to serialize environment variable tests
135    static ENV_MUTEX: Mutex<()> = Mutex::new(());
136
137    fn clear_db_env_vars() {
138        std::env::remove_var("DATABASE_URL");
139        std::env::remove_var("DB_URL");
140        std::env::remove_var("POSTGRES_URL");
141    }
142
143    #[test]
144    fn test_priority_order() {
145        let _lock = ENV_MUTEX.lock().unwrap();
146        clear_db_env_vars();
147
148        // Set only POSTGRES_URL
149        std::env::set_var("POSTGRES_URL", "postgres://fallback");
150        let config = EnvConfig::load();
151        assert_eq!(config.database_url, Some("postgres://fallback".to_string()));
152        assert_eq!(config.database_url_source, Some("POSTGRES_URL".to_string()));
153
154        // Set DB_URL (higher priority)
155        std::env::set_var("DB_URL", "postgres://medium");
156        let config = EnvConfig::load();
157        assert_eq!(config.database_url, Some("postgres://medium".to_string()));
158        assert_eq!(config.database_url_source, Some("DB_URL".to_string()));
159
160        // Set DATABASE_URL (highest priority)
161        std::env::set_var("DATABASE_URL", "postgres://primary");
162        let config = EnvConfig::load();
163        assert_eq!(config.database_url, Some("postgres://primary".to_string()));
164        assert_eq!(config.database_url_source, Some("DATABASE_URL".to_string()));
165
166        // Cleanup
167        clear_db_env_vars();
168    }
169
170    #[test]
171    fn test_cli_takes_precedence() {
172        let _lock = ENV_MUTEX.lock().unwrap();
173        clear_db_env_vars();
174
175        std::env::set_var("DATABASE_URL", "postgres://from_env");
176        let config = EnvConfig::load();
177
178        // CLI should override environment
179        let resolved = config.resolve_db_url(Some("postgres://from_cli"), None);
180        assert_eq!(resolved, Some("postgres://from_cli".to_string()));
181
182        // Without CLI, should use environment
183        let resolved = config.resolve_db_url(None, None);
184        assert_eq!(resolved, Some("postgres://from_env".to_string()));
185
186        // Config file is lowest priority
187        let resolved = config.resolve_db_url(None, Some("postgres://from_config"));
188        assert_eq!(resolved, Some("postgres://from_env".to_string()));
189
190        clear_db_env_vars();
191    }
192
193    #[test]
194    fn test_empty_vars_ignored() {
195        let _lock = ENV_MUTEX.lock().unwrap();
196        clear_db_env_vars();
197
198        std::env::set_var("POSTGRES_URL", "");
199
200        let config = EnvConfig::load();
201        assert!(config.database_url.is_none());
202
203        clear_db_env_vars();
204    }
205}