1use std::env;
4use std::path::{Path, PathBuf};
5use std::sync::OnceLock;
6
7static DOTENV_LOADED: OnceLock<Option<PathBuf>> = OnceLock::new();
12
13pub 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
43fn 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#[derive(Debug, Clone)]
106pub struct DatabaseConfig {
107 pub default: String,
108 pub connections: indexmap::IndexMap<String, ConnectionConfig>,
109}
110
111#[derive(Debug, Clone)]
113pub struct ConnectionConfig {
114 pub driver: ConnectionDriver,
115 pub url: String,
117 pub read_urls: Vec<String>,
119 pub pool_size: u32,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum ConnectionDriver {
124 Postgres,
125 Other(String),
127}
128
129impl DatabaseConfig {
130 pub fn from_env() -> Self {
131 let _ = load_dotenv();
132 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 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 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 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 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 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 assert_eq!(find_project_root(&cwd), Some(anvil.clone()));
408 }
409
410 #[test]
411 fn load_dotenv_is_idempotent_across_calls() {
412 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 let tmp = tempfile::tempdir().unwrap();
429 let _ = find_project_root(tmp.path());
432 }
433}