1use std::env;
4use std::path::{Path, PathBuf};
5
6pub 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
27fn 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#[derive(Debug, Clone)]
89pub struct DatabaseConfig {
90 pub default: String,
91 pub connections: indexmap::IndexMap<String, ConnectionConfig>,
92}
93
94#[derive(Debug, Clone)]
96pub struct ConnectionConfig {
97 pub driver: ConnectionDriver,
98 pub url: String,
100 pub read_urls: Vec<String>,
102 pub pool_size: u32,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ConnectionDriver {
107 Postgres,
108 Other(String),
110}
111
112impl DatabaseConfig {
113 pub fn from_env() -> Self {
114 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 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 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 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 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 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 assert_eq!(find_project_root(&cwd), Some(anvil.clone()));
384 }
385
386 #[test]
387 fn returns_none_outside_any_project() {
388 let tmp = tempfile::tempdir().unwrap();
393 let _ = find_project_root(tmp.path());
396 }
397}