Skip to main content

anvil_core/
config.rs

1//! Configuration loading. `.env` via dotenvy + typed config structs in `config/*.rs`.
2
3use std::env;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7/// Cached result of the first `load_dotenv()` call. We do the work exactly
8/// once per process — subsequent calls (from `*Config::from_env()`,
9/// `TestClient::new()`, or main.rs) hand back the cached path without
10/// re-reading the filesystem.
11static DOTENV_LOADED: OnceLock<Option<PathBuf>> = OnceLock::new();
12
13/// Load environment variables from the project's `.env` file.
14///
15/// Walks up from the current working directory looking for a project root
16/// marker (`config/anvil.toml`, then `Cargo.toml`) and loads `.env` from that
17/// directory only — it does NOT walk further up. This avoids accidentally
18/// picking up a parent project's `.env` when the Anvil project is nested
19/// inside another codebase.
20///
21/// **Idempotent.** The first call does the work; subsequent calls return the
22/// same cached `Option<PathBuf>`. That's what makes it safe to invoke
23/// implicitly from `*Config::from_env()` — tests get a `.env`-loaded process
24/// even though they never run `main.rs`.
25///
26/// Returns the path of the `.env` that was loaded, or `None` if no project
27/// root or no `.env` was found. Callers can log this after `tracing_init`.
28pub fn load_dotenv() -> Option<PathBuf> {
29    DOTENV_LOADED.get_or_init(load_dotenv_impl).clone()
30}
31
32fn load_dotenv_impl() -> Option<PathBuf> {
33    let cwd = env::current_dir().ok()?;
34    let root = find_project_root(&cwd)?;
35    let env_path = root.join(".env");
36    if !env_path.exists() {
37        return None;
38    }
39    dotenvy::from_path(&env_path).ok()?;
40    Some(env_path)
41}
42
43/// Walk up from `start` looking for the first directory that contains
44/// either `config/anvil.toml` (preferred — Anvil project marker) or
45/// `Cargo.toml` (workspace root). Stops at the filesystem root if neither
46/// is found.
47fn find_project_root(start: &Path) -> Option<PathBuf> {
48    let mut dir = start;
49    loop {
50        if dir.join("config/anvil.toml").exists() {
51            return Some(dir.to_path_buf());
52        }
53        if dir.join("Cargo.toml").exists() {
54            return Some(dir.to_path_buf());
55        }
56        dir = dir.parent()?;
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct AppConfig {
62    pub name: String,
63    pub env: String,
64    pub key: String,
65    pub debug: bool,
66    pub url: String,
67}
68
69impl AppConfig {
70    pub fn from_env() -> Self {
71        let _ = load_dotenv();
72        Self {
73            name: env::var("APP_NAME").unwrap_or_else(|_| "Anvil".to_string()),
74            env: env::var("APP_ENV").unwrap_or_else(|_| "production".to_string()),
75            key: env::var("APP_KEY").unwrap_or_default(),
76            debug: env::var("APP_DEBUG")
77                .ok()
78                .and_then(|v| v.parse().ok())
79                .unwrap_or(false),
80            url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()),
81        }
82    }
83
84    pub fn is_local(&self) -> bool {
85        self.env == "local" || self.env == "development"
86    }
87}
88
89/// Database configuration. Mirrors Laravel's `config/database.php`:
90///
91/// - A `default` connection name (referenced by models, query builder, migrator).
92/// - A map of named connections — each with its own URL, pool size, optional
93///   read replicas.
94///
95/// The default `from_env()` impl auto-builds a single `default` connection
96/// from `DATABASE_URL` + `DB_POOL`. Apps wanting multiple connections set:
97///
98/// ```text
99/// DB_CONNECTIONS=default,replica,analytics
100/// DATABASE_URL=postgres://...                 # the "default" connection
101/// DB_REPLICA_URL=postgres://replica/...
102/// DB_ANALYTICS_URL=postgres://analytics/...
103/// DB_DEFAULT=default
104/// ```
105#[derive(Debug, Clone)]
106pub struct DatabaseConfig {
107    pub default: String,
108    pub connections: indexmap::IndexMap<String, ConnectionConfig>,
109}
110
111/// A single named connection's config.
112#[derive(Debug, Clone)]
113pub struct ConnectionConfig {
114    pub driver: ConnectionDriver,
115    /// Write URL (or the only URL if read/write splitting is disabled).
116    pub url: String,
117    /// Optional comma-separated read replica URLs. If empty, reads use `url`.
118    pub read_urls: Vec<String>,
119    pub pool_size: u32,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ConnectionDriver {
124    Postgres,
125    /// Reserved for v0.2 (MySQL/SQLite drivers).
126    Other(String),
127}
128
129impl DatabaseConfig {
130    pub fn from_env() -> Self {
131        let _ = load_dotenv();
132        // Allow a comma-separated list of connection names via `DB_CONNECTIONS`.
133        // Each connection `foo` reads `DB_FOO_URL`, `DB_FOO_POOL`, `DB_FOO_DRIVER`,
134        // and `DB_FOO_READ_URLS` (optional). The "default" connection falls back
135        // to the legacy `DATABASE_URL` / `DB_POOL` envs for backward compat.
136        let names = env::var("DB_CONNECTIONS")
137            .map(|s| {
138                s.split(',')
139                    .map(|t| t.trim().to_string())
140                    .filter(|t| !t.is_empty())
141                    .collect::<Vec<_>>()
142            })
143            .unwrap_or_else(|_| vec!["default".to_string()]);
144
145        let default = env::var("DB_DEFAULT").unwrap_or_else(|_| {
146            names
147                .first()
148                .cloned()
149                .unwrap_or_else(|| "default".to_string())
150        });
151
152        let mut connections = indexmap::IndexMap::new();
153        for name in &names {
154            let cfg = ConnectionConfig::from_env(name);
155            connections.insert(name.clone(), cfg);
156        }
157
158        Self {
159            default,
160            connections,
161        }
162    }
163
164    /// Convenience: the URL of the default connection.
165    pub fn default_url(&self) -> &str {
166        self.connections
167            .get(&self.default)
168            .map(|c| c.url.as_str())
169            .unwrap_or("")
170    }
171
172    /// Convenience: the pool size of the default connection.
173    pub fn default_pool_size(&self) -> u32 {
174        self.connections
175            .get(&self.default)
176            .map(|c| c.pool_size)
177            .unwrap_or(10)
178    }
179
180    /// Build a simple single-connection config — useful in tests.
181    pub fn single(url: impl Into<String>, pool_size: u32) -> Self {
182        let mut connections = indexmap::IndexMap::new();
183        connections.insert(
184            "default".to_string(),
185            ConnectionConfig {
186                driver: ConnectionDriver::Postgres,
187                url: url.into(),
188                read_urls: Vec::new(),
189                pool_size,
190            },
191        );
192        Self {
193            default: "default".to_string(),
194            connections,
195        }
196    }
197}
198
199impl ConnectionConfig {
200    pub fn from_env(name: &str) -> Self {
201        let _ = load_dotenv();
202        let prefix = if name == "default" {
203            String::new()
204        } else {
205            format!("DB_{}_", name.to_ascii_uppercase())
206        };
207        let url = if name == "default" {
208            env::var("DATABASE_URL")
209                .or_else(|_| env::var(format!("{prefix}URL")))
210                .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/anvil".to_string())
211        } else {
212            env::var(format!("{prefix}URL")).unwrap_or_default()
213        };
214
215        let pool_size = if name == "default" {
216            env::var("DB_POOL")
217                .or_else(|_| env::var(format!("{prefix}POOL")))
218                .ok()
219                .and_then(|v| v.parse().ok())
220                .unwrap_or(10)
221        } else {
222            env::var(format!("{prefix}POOL"))
223                .ok()
224                .and_then(|v| v.parse().ok())
225                .unwrap_or(10)
226        };
227
228        let read_urls = env::var(format!("{prefix}READ_URLS"))
229            .map(|s| {
230                s.split(',')
231                    .map(|t| t.trim().to_string())
232                    .filter(|t| !t.is_empty())
233                    .collect()
234            })
235            .unwrap_or_default();
236
237        let driver_str = env::var(format!("{prefix}DRIVER")).unwrap_or_else(|_| {
238            // Infer from URL scheme. Currently every supported variant
239            // maps to "postgres"; sqlite/mysql detection lives in cast-core.
240            let _ = url.starts_with("postgres://") || url.starts_with("postgresql://");
241            "postgres".into()
242        });
243        let driver = match driver_str.as_str() {
244            "postgres" | "pgsql" | "pg" => ConnectionDriver::Postgres,
245            other => ConnectionDriver::Other(other.to_string()),
246        };
247
248        Self {
249            driver,
250            url,
251            read_urls,
252            pool_size,
253        }
254    }
255}
256
257#[derive(Debug, Clone)]
258pub struct SessionConfig {
259    pub driver: String,
260    pub lifetime_minutes: i64,
261    pub cookie_name: String,
262    pub same_site: String,
263    pub secure: bool,
264}
265
266impl SessionConfig {
267    pub fn from_env() -> Self {
268        let _ = load_dotenv();
269        Self {
270            driver: env::var("SESSION_DRIVER").unwrap_or_else(|_| "file".to_string()),
271            lifetime_minutes: env::var("SESSION_LIFETIME")
272                .ok()
273                .and_then(|v| v.parse().ok())
274                .unwrap_or(120),
275            cookie_name: env::var("SESSION_COOKIE").unwrap_or_else(|_| "anvil_session".to_string()),
276            same_site: env::var("SESSION_SAME_SITE").unwrap_or_else(|_| "lax".to_string()),
277            secure: env::var("SESSION_SECURE")
278                .ok()
279                .and_then(|v| v.parse().ok())
280                .unwrap_or(false),
281        }
282    }
283}
284
285#[derive(Debug, Clone)]
286pub struct CacheConfig {
287    pub driver: String,
288    pub ttl_seconds: u64,
289}
290
291impl CacheConfig {
292    pub fn from_env() -> Self {
293        let _ = load_dotenv();
294        Self {
295            driver: env::var("CACHE_DRIVER").unwrap_or_else(|_| "moka".to_string()),
296            ttl_seconds: env::var("CACHE_TTL")
297                .ok()
298                .and_then(|v| v.parse().ok())
299                .unwrap_or(3600),
300        }
301    }
302}
303
304#[derive(Debug, Clone)]
305pub struct QueueConfig {
306    pub driver: String,
307    pub default_queue: String,
308}
309
310impl QueueConfig {
311    pub fn from_env() -> Self {
312        let _ = load_dotenv();
313        Self {
314            driver: env::var("QUEUE_DRIVER").unwrap_or_else(|_| "database".to_string()),
315            default_queue: env::var("QUEUE_DEFAULT").unwrap_or_else(|_| "default".to_string()),
316        }
317    }
318}
319
320#[derive(Debug, Clone)]
321pub struct MailConfig {
322    pub mailer: String,
323    pub host: String,
324    pub port: u16,
325    pub username: String,
326    pub password: String,
327    pub from_address: String,
328    pub from_name: String,
329}
330
331impl MailConfig {
332    pub fn from_env() -> Self {
333        let _ = load_dotenv();
334        Self {
335            mailer: env::var("MAIL_MAILER").unwrap_or_else(|_| "smtp".to_string()),
336            host: env::var("MAIL_HOST").unwrap_or_else(|_| "localhost".to_string()),
337            port: env::var("MAIL_PORT")
338                .ok()
339                .and_then(|v| v.parse().ok())
340                .unwrap_or(1025),
341            username: env::var("MAIL_USERNAME").unwrap_or_default(),
342            password: env::var("MAIL_PASSWORD").unwrap_or_default(),
343            from_address: env::var("MAIL_FROM_ADDRESS")
344                .unwrap_or_else(|_| "hello@example.com".to_string()),
345            from_name: env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Anvil".to_string()),
346        }
347    }
348}
349
350#[derive(Debug, Clone)]
351pub struct FilesystemConfig {
352    pub default_disk: String,
353    pub local_root: String,
354}
355
356impl FilesystemConfig {
357    pub fn from_env() -> Self {
358        let _ = load_dotenv();
359        Self {
360            default_disk: env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string()),
361            local_root: env::var("FILESYSTEM_LOCAL_ROOT")
362                .unwrap_or_else(|_| "storage/app".to_string()),
363        }
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::find_project_root;
370    use std::fs;
371
372    #[test]
373    fn finds_root_via_anvil_marker() {
374        let tmp = tempfile::tempdir().unwrap();
375        let root = tmp.path();
376        fs::create_dir_all(root.join("config")).unwrap();
377        fs::write(root.join("config/anvil.toml"), "").unwrap();
378        let nested = root.join("src/foo");
379        fs::create_dir_all(&nested).unwrap();
380        assert_eq!(find_project_root(&nested), Some(root.to_path_buf()));
381    }
382
383    #[test]
384    fn finds_root_via_cargo_toml() {
385        let tmp = tempfile::tempdir().unwrap();
386        let root = tmp.path();
387        fs::write(root.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
388        let nested = root.join("a/b/c");
389        fs::create_dir_all(&nested).unwrap();
390        assert_eq!(find_project_root(&nested), Some(root.to_path_buf()));
391    }
392
393    #[test]
394    fn prefers_anvil_marker_over_outer_cargo_toml() {
395        // Anvil project nested inside a non-Anvil Cargo workspace — we want the
396        // Anvil project root, not the workspace root.
397        let tmp = tempfile::tempdir().unwrap();
398        let outer = tmp.path();
399        fs::write(outer.join("Cargo.toml"), "").unwrap();
400        let anvil = outer.join("apps/web");
401        fs::create_dir_all(anvil.join("config")).unwrap();
402        fs::write(anvil.join("config/anvil.toml"), "").unwrap();
403        fs::write(anvil.join("Cargo.toml"), "").unwrap();
404        let cwd = anvil.join("src");
405        fs::create_dir_all(&cwd).unwrap();
406        // From anvil/src we should hit anvil/ first (it has both markers).
407        assert_eq!(find_project_root(&cwd), Some(anvil.clone()));
408    }
409
410    #[test]
411    fn load_dotenv_is_idempotent_across_calls() {
412        // Hammer it; OnceLock should make every call after the first cheap +
413        // identical. We can't observe "no FS work" directly, but identical
414        // return values across calls is a strong signal.
415        let first = super::load_dotenv();
416        let second = super::load_dotenv();
417        let third = super::load_dotenv();
418        assert_eq!(first, second);
419        assert_eq!(second, third);
420    }
421
422    #[test]
423    fn returns_none_outside_any_project() {
424        // tempdir() has no Cargo.toml or config/anvil.toml; nothing should match
425        // unless an ancestor does. We can't easily isolate ancestors, but we can
426        // at least confirm the function doesn't panic on a path with no markers
427        // at the starting level by walking from a non-existent ancestor.
428        let tmp = tempfile::tempdir().unwrap();
429        // Note: a parent of tmp may be a Cargo project (target/, etc.), so we
430        // can't assert None here. Instead just exercise the path.
431        let _ = find_project_root(tmp.path());
432    }
433}