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