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.first().cloned().unwrap_or_else(|| "default".to_string())
94        });
95
96        let mut connections = indexmap::IndexMap::new();
97        for name in &names {
98            let cfg = ConnectionConfig::from_env(name);
99            connections.insert(name.clone(), cfg);
100        }
101
102        Self { default, connections }
103    }
104
105    /// Convenience: the URL of the default connection.
106    pub fn default_url(&self) -> &str {
107        self.connections
108            .get(&self.default)
109            .map(|c| c.url.as_str())
110            .unwrap_or("")
111    }
112
113    /// Convenience: the pool size of the default connection.
114    pub fn default_pool_size(&self) -> u32 {
115        self.connections
116            .get(&self.default)
117            .map(|c| c.pool_size)
118            .unwrap_or(10)
119    }
120
121    /// Build a simple single-connection config — useful in tests.
122    pub fn single(url: impl Into<String>, pool_size: u32) -> Self {
123        let mut connections = indexmap::IndexMap::new();
124        connections.insert(
125            "default".to_string(),
126            ConnectionConfig {
127                driver: ConnectionDriver::Postgres,
128                url: url.into(),
129                read_urls: Vec::new(),
130                pool_size,
131            },
132        );
133        Self {
134            default: "default".to_string(),
135            connections,
136        }
137    }
138}
139
140impl ConnectionConfig {
141    pub fn from_env(name: &str) -> Self {
142        let prefix = if name == "default" {
143            String::new()
144        } else {
145            format!("DB_{}_", name.to_ascii_uppercase())
146        };
147        let url = if name == "default" {
148            env::var("DATABASE_URL")
149                .or_else(|_| env::var(format!("{prefix}URL")))
150                .unwrap_or_else(|_| {
151                    "postgres://postgres:postgres@localhost:5432/anvil".to_string()
152                })
153        } else {
154            env::var(format!("{prefix}URL")).unwrap_or_default()
155        };
156
157        let pool_size = if name == "default" {
158            env::var("DB_POOL")
159                .or_else(|_| env::var(format!("{prefix}POOL")))
160                .ok()
161                .and_then(|v| v.parse().ok())
162                .unwrap_or(10)
163        } else {
164            env::var(format!("{prefix}POOL"))
165                .ok()
166                .and_then(|v| v.parse().ok())
167                .unwrap_or(10)
168        };
169
170        let read_urls = env::var(format!("{prefix}READ_URLS"))
171            .map(|s| {
172                s.split(',')
173                    .map(|t| t.trim().to_string())
174                    .filter(|t| !t.is_empty())
175                    .collect()
176            })
177            .unwrap_or_default();
178
179        let driver_str = env::var(format!("{prefix}DRIVER")).unwrap_or_else(|_| {
180            // Infer from URL scheme.
181            if url.starts_with("postgres://") || url.starts_with("postgresql://") {
182                "postgres".into()
183            } else {
184                "postgres".into()
185            }
186        });
187        let driver = match driver_str.as_str() {
188            "postgres" | "pgsql" | "pg" => ConnectionDriver::Postgres,
189            other => ConnectionDriver::Other(other.to_string()),
190        };
191
192        Self {
193            driver,
194            url,
195            read_urls,
196            pool_size,
197        }
198    }
199}
200
201#[derive(Debug, Clone)]
202pub struct SessionConfig {
203    pub driver: String,
204    pub lifetime_minutes: i64,
205    pub cookie_name: String,
206    pub same_site: String,
207    pub secure: bool,
208}
209
210impl SessionConfig {
211    pub fn from_env() -> Self {
212        Self {
213            driver: env::var("SESSION_DRIVER").unwrap_or_else(|_| "file".to_string()),
214            lifetime_minutes: env::var("SESSION_LIFETIME")
215                .ok()
216                .and_then(|v| v.parse().ok())
217                .unwrap_or(120),
218            cookie_name: env::var("SESSION_COOKIE").unwrap_or_else(|_| "anvil_session".to_string()),
219            same_site: env::var("SESSION_SAME_SITE").unwrap_or_else(|_| "lax".to_string()),
220            secure: env::var("SESSION_SECURE")
221                .ok()
222                .and_then(|v| v.parse().ok())
223                .unwrap_or(false),
224        }
225    }
226}
227
228#[derive(Debug, Clone)]
229pub struct CacheConfig {
230    pub driver: String,
231    pub ttl_seconds: u64,
232}
233
234impl CacheConfig {
235    pub fn from_env() -> Self {
236        Self {
237            driver: env::var("CACHE_DRIVER").unwrap_or_else(|_| "moka".to_string()),
238            ttl_seconds: env::var("CACHE_TTL")
239                .ok()
240                .and_then(|v| v.parse().ok())
241                .unwrap_or(3600),
242        }
243    }
244}
245
246#[derive(Debug, Clone)]
247pub struct QueueConfig {
248    pub driver: String,
249    pub default_queue: String,
250}
251
252impl QueueConfig {
253    pub fn from_env() -> Self {
254        Self {
255            driver: env::var("QUEUE_DRIVER").unwrap_or_else(|_| "database".to_string()),
256            default_queue: env::var("QUEUE_DEFAULT").unwrap_or_else(|_| "default".to_string()),
257        }
258    }
259}
260
261#[derive(Debug, Clone)]
262pub struct MailConfig {
263    pub mailer: String,
264    pub host: String,
265    pub port: u16,
266    pub username: String,
267    pub password: String,
268    pub from_address: String,
269    pub from_name: String,
270}
271
272impl MailConfig {
273    pub fn from_env() -> Self {
274        Self {
275            mailer: env::var("MAIL_MAILER").unwrap_or_else(|_| "smtp".to_string()),
276            host: env::var("MAIL_HOST").unwrap_or_else(|_| "localhost".to_string()),
277            port: env::var("MAIL_PORT")
278                .ok()
279                .and_then(|v| v.parse().ok())
280                .unwrap_or(1025),
281            username: env::var("MAIL_USERNAME").unwrap_or_default(),
282            password: env::var("MAIL_PASSWORD").unwrap_or_default(),
283            from_address: env::var("MAIL_FROM_ADDRESS")
284                .unwrap_or_else(|_| "hello@example.com".to_string()),
285            from_name: env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Anvil".to_string()),
286        }
287    }
288}
289
290#[derive(Debug, Clone)]
291pub struct FilesystemConfig {
292    pub default_disk: String,
293    pub local_root: String,
294}
295
296impl FilesystemConfig {
297    pub fn from_env() -> Self {
298        Self {
299            default_disk: env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string()),
300            local_root: env::var("FILESYSTEM_LOCAL_ROOT")
301                .unwrap_or_else(|_| "storage/app".to_string()),
302        }
303    }
304}