Skip to main content

anvil_core/
config.rs

1//! Configuration loading. `.env` via dotenvy + typed config structs in `config/*.rs`.
2
3use std::env;
4
5pub fn load_dotenv() {
6    let _ = dotenvy::dotenv();
7}
8
9#[derive(Debug, Clone)]
10pub struct AppConfig {
11    pub name: String,
12    pub env: String,
13    pub key: String,
14    pub debug: bool,
15    pub url: String,
16}
17
18impl AppConfig {
19    pub fn from_env() -> Self {
20        Self {
21            name: env::var("APP_NAME").unwrap_or_else(|_| "Anvil".to_string()),
22            env: env::var("APP_ENV").unwrap_or_else(|_| "production".to_string()),
23            key: env::var("APP_KEY").unwrap_or_default(),
24            debug: env::var("APP_DEBUG")
25                .ok()
26                .and_then(|v| v.parse().ok())
27                .unwrap_or(false),
28            url: env::var("APP_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()),
29        }
30    }
31
32    pub fn is_local(&self) -> bool {
33        self.env == "local" || self.env == "development"
34    }
35}
36
37/// Database configuration. Mirrors Laravel's `config/database.php`:
38///
39/// - A `default` connection name (referenced by models, query builder, migrator).
40/// - A map of named connections — each with its own URL, pool size, optional
41///   read replicas.
42///
43/// The default `from_env()` impl auto-builds a single `default` connection
44/// from `DATABASE_URL` + `DB_POOL`. Apps wanting multiple connections set:
45///
46/// ```text
47/// DB_CONNECTIONS=default,replica,analytics
48/// DATABASE_URL=postgres://...                 # the "default" connection
49/// DB_REPLICA_URL=postgres://replica/...
50/// DB_ANALYTICS_URL=postgres://analytics/...
51/// DB_DEFAULT=default
52/// ```
53#[derive(Debug, Clone)]
54pub struct DatabaseConfig {
55    pub default: String,
56    pub connections: indexmap::IndexMap<String, ConnectionConfig>,
57}
58
59/// A single named connection's config.
60#[derive(Debug, Clone)]
61pub struct ConnectionConfig {
62    pub driver: ConnectionDriver,
63    /// Write URL (or the only URL if read/write splitting is disabled).
64    pub url: String,
65    /// Optional comma-separated read replica URLs. If empty, reads use `url`.
66    pub read_urls: Vec<String>,
67    pub pool_size: u32,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ConnectionDriver {
72    Postgres,
73    /// Reserved for v0.2 (MySQL/SQLite drivers).
74    Other(String),
75}
76
77impl DatabaseConfig {
78    pub fn from_env() -> Self {
79        // Allow a comma-separated list of connection names via `DB_CONNECTIONS`.
80        // Each connection `foo` reads `DB_FOO_URL`, `DB_FOO_POOL`, `DB_FOO_DRIVER`,
81        // and `DB_FOO_READ_URLS` (optional). The "default" connection falls back
82        // to the legacy `DATABASE_URL` / `DB_POOL` envs for backward compat.
83        let names = env::var("DB_CONNECTIONS")
84            .map(|s| {
85                s.split(',')
86                    .map(|t| t.trim().to_string())
87                    .filter(|t| !t.is_empty())
88                    .collect::<Vec<_>>()
89            })
90            .unwrap_or_else(|_| vec!["default".to_string()]);
91
92        let default = env::var("DB_DEFAULT").unwrap_or_else(|_| {
93            names
94                .first()
95                .cloned()
96                .unwrap_or_else(|| "default".to_string())
97        });
98
99        let mut connections = indexmap::IndexMap::new();
100        for name in &names {
101            let cfg = ConnectionConfig::from_env(name);
102            connections.insert(name.clone(), cfg);
103        }
104
105        Self {
106            default,
107            connections,
108        }
109    }
110
111    /// Convenience: the URL of the default connection.
112    pub fn default_url(&self) -> &str {
113        self.connections
114            .get(&self.default)
115            .map(|c| c.url.as_str())
116            .unwrap_or("")
117    }
118
119    /// Convenience: the pool size of the default connection.
120    pub fn default_pool_size(&self) -> u32 {
121        self.connections
122            .get(&self.default)
123            .map(|c| c.pool_size)
124            .unwrap_or(10)
125    }
126
127    /// Build a simple single-connection config — useful in tests.
128    pub fn single(url: impl Into<String>, pool_size: u32) -> Self {
129        let mut connections = indexmap::IndexMap::new();
130        connections.insert(
131            "default".to_string(),
132            ConnectionConfig {
133                driver: ConnectionDriver::Postgres,
134                url: url.into(),
135                read_urls: Vec::new(),
136                pool_size,
137            },
138        );
139        Self {
140            default: "default".to_string(),
141            connections,
142        }
143    }
144}
145
146impl ConnectionConfig {
147    pub fn from_env(name: &str) -> Self {
148        let prefix = if name == "default" {
149            String::new()
150        } else {
151            format!("DB_{}_", name.to_ascii_uppercase())
152        };
153        let url = if name == "default" {
154            env::var("DATABASE_URL")
155                .or_else(|_| env::var(format!("{prefix}URL")))
156                .unwrap_or_else(|_| "postgres://postgres:postgres@localhost:5432/anvil".to_string())
157        } else {
158            env::var(format!("{prefix}URL")).unwrap_or_default()
159        };
160
161        let pool_size = if name == "default" {
162            env::var("DB_POOL")
163                .or_else(|_| env::var(format!("{prefix}POOL")))
164                .ok()
165                .and_then(|v| v.parse().ok())
166                .unwrap_or(10)
167        } else {
168            env::var(format!("{prefix}POOL"))
169                .ok()
170                .and_then(|v| v.parse().ok())
171                .unwrap_or(10)
172        };
173
174        let read_urls = env::var(format!("{prefix}READ_URLS"))
175            .map(|s| {
176                s.split(',')
177                    .map(|t| t.trim().to_string())
178                    .filter(|t| !t.is_empty())
179                    .collect()
180            })
181            .unwrap_or_default();
182
183        let driver_str = env::var(format!("{prefix}DRIVER")).unwrap_or_else(|_| {
184            // Infer from URL scheme. Currently every supported variant
185            // maps to "postgres"; sqlite/mysql detection lives in cast-core.
186            let _ = url.starts_with("postgres://") || url.starts_with("postgresql://");
187            "postgres".into()
188        });
189        let driver = match driver_str.as_str() {
190            "postgres" | "pgsql" | "pg" => ConnectionDriver::Postgres,
191            other => ConnectionDriver::Other(other.to_string()),
192        };
193
194        Self {
195            driver,
196            url,
197            read_urls,
198            pool_size,
199        }
200    }
201}
202
203#[derive(Debug, Clone)]
204pub struct SessionConfig {
205    pub driver: String,
206    pub lifetime_minutes: i64,
207    pub cookie_name: String,
208    pub same_site: String,
209    pub secure: bool,
210}
211
212impl SessionConfig {
213    pub fn from_env() -> Self {
214        Self {
215            driver: env::var("SESSION_DRIVER").unwrap_or_else(|_| "file".to_string()),
216            lifetime_minutes: env::var("SESSION_LIFETIME")
217                .ok()
218                .and_then(|v| v.parse().ok())
219                .unwrap_or(120),
220            cookie_name: env::var("SESSION_COOKIE").unwrap_or_else(|_| "anvil_session".to_string()),
221            same_site: env::var("SESSION_SAME_SITE").unwrap_or_else(|_| "lax".to_string()),
222            secure: env::var("SESSION_SECURE")
223                .ok()
224                .and_then(|v| v.parse().ok())
225                .unwrap_or(false),
226        }
227    }
228}
229
230#[derive(Debug, Clone)]
231pub struct CacheConfig {
232    pub driver: String,
233    pub ttl_seconds: u64,
234}
235
236impl CacheConfig {
237    pub fn from_env() -> Self {
238        Self {
239            driver: env::var("CACHE_DRIVER").unwrap_or_else(|_| "moka".to_string()),
240            ttl_seconds: env::var("CACHE_TTL")
241                .ok()
242                .and_then(|v| v.parse().ok())
243                .unwrap_or(3600),
244        }
245    }
246}
247
248#[derive(Debug, Clone)]
249pub struct QueueConfig {
250    pub driver: String,
251    pub default_queue: String,
252}
253
254impl QueueConfig {
255    pub fn from_env() -> Self {
256        Self {
257            driver: env::var("QUEUE_DRIVER").unwrap_or_else(|_| "database".to_string()),
258            default_queue: env::var("QUEUE_DEFAULT").unwrap_or_else(|_| "default".to_string()),
259        }
260    }
261}
262
263#[derive(Debug, Clone)]
264pub struct MailConfig {
265    pub mailer: String,
266    pub host: String,
267    pub port: u16,
268    pub username: String,
269    pub password: String,
270    pub from_address: String,
271    pub from_name: String,
272}
273
274impl MailConfig {
275    pub fn from_env() -> Self {
276        Self {
277            mailer: env::var("MAIL_MAILER").unwrap_or_else(|_| "smtp".to_string()),
278            host: env::var("MAIL_HOST").unwrap_or_else(|_| "localhost".to_string()),
279            port: env::var("MAIL_PORT")
280                .ok()
281                .and_then(|v| v.parse().ok())
282                .unwrap_or(1025),
283            username: env::var("MAIL_USERNAME").unwrap_or_default(),
284            password: env::var("MAIL_PASSWORD").unwrap_or_default(),
285            from_address: env::var("MAIL_FROM_ADDRESS")
286                .unwrap_or_else(|_| "hello@example.com".to_string()),
287            from_name: env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Anvil".to_string()),
288        }
289    }
290}
291
292#[derive(Debug, Clone)]
293pub struct FilesystemConfig {
294    pub default_disk: String,
295    pub local_root: String,
296}
297
298impl FilesystemConfig {
299    pub fn from_env() -> Self {
300        Self {
301            default_disk: env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string()),
302            local_root: env::var("FILESYSTEM_LOCAL_ROOT")
303                .unwrap_or_else(|_| "storage/app".to_string()),
304        }
305    }
306}