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.
185            if url.starts_with("postgres://") || url.starts_with("postgresql://") {
186                "postgres".into()
187            } else {
188                "postgres".into()
189            }
190        });
191        let driver = match driver_str.as_str() {
192            "postgres" | "pgsql" | "pg" => ConnectionDriver::Postgres,
193            other => ConnectionDriver::Other(other.to_string()),
194        };
195
196        Self {
197            driver,
198            url,
199            read_urls,
200            pool_size,
201        }
202    }
203}
204
205#[derive(Debug, Clone)]
206pub struct SessionConfig {
207    pub driver: String,
208    pub lifetime_minutes: i64,
209    pub cookie_name: String,
210    pub same_site: String,
211    pub secure: bool,
212}
213
214impl SessionConfig {
215    pub fn from_env() -> Self {
216        Self {
217            driver: env::var("SESSION_DRIVER").unwrap_or_else(|_| "file".to_string()),
218            lifetime_minutes: env::var("SESSION_LIFETIME")
219                .ok()
220                .and_then(|v| v.parse().ok())
221                .unwrap_or(120),
222            cookie_name: env::var("SESSION_COOKIE").unwrap_or_else(|_| "anvil_session".to_string()),
223            same_site: env::var("SESSION_SAME_SITE").unwrap_or_else(|_| "lax".to_string()),
224            secure: env::var("SESSION_SECURE")
225                .ok()
226                .and_then(|v| v.parse().ok())
227                .unwrap_or(false),
228        }
229    }
230}
231
232#[derive(Debug, Clone)]
233pub struct CacheConfig {
234    pub driver: String,
235    pub ttl_seconds: u64,
236}
237
238impl CacheConfig {
239    pub fn from_env() -> Self {
240        Self {
241            driver: env::var("CACHE_DRIVER").unwrap_or_else(|_| "moka".to_string()),
242            ttl_seconds: env::var("CACHE_TTL")
243                .ok()
244                .and_then(|v| v.parse().ok())
245                .unwrap_or(3600),
246        }
247    }
248}
249
250#[derive(Debug, Clone)]
251pub struct QueueConfig {
252    pub driver: String,
253    pub default_queue: String,
254}
255
256impl QueueConfig {
257    pub fn from_env() -> Self {
258        Self {
259            driver: env::var("QUEUE_DRIVER").unwrap_or_else(|_| "database".to_string()),
260            default_queue: env::var("QUEUE_DEFAULT").unwrap_or_else(|_| "default".to_string()),
261        }
262    }
263}
264
265#[derive(Debug, Clone)]
266pub struct MailConfig {
267    pub mailer: String,
268    pub host: String,
269    pub port: u16,
270    pub username: String,
271    pub password: String,
272    pub from_address: String,
273    pub from_name: String,
274}
275
276impl MailConfig {
277    pub fn from_env() -> Self {
278        Self {
279            mailer: env::var("MAIL_MAILER").unwrap_or_else(|_| "smtp".to_string()),
280            host: env::var("MAIL_HOST").unwrap_or_else(|_| "localhost".to_string()),
281            port: env::var("MAIL_PORT")
282                .ok()
283                .and_then(|v| v.parse().ok())
284                .unwrap_or(1025),
285            username: env::var("MAIL_USERNAME").unwrap_or_default(),
286            password: env::var("MAIL_PASSWORD").unwrap_or_default(),
287            from_address: env::var("MAIL_FROM_ADDRESS")
288                .unwrap_or_else(|_| "hello@example.com".to_string()),
289            from_name: env::var("MAIL_FROM_NAME").unwrap_or_else(|_| "Anvil".to_string()),
290        }
291    }
292}
293
294#[derive(Debug, Clone)]
295pub struct FilesystemConfig {
296    pub default_disk: String,
297    pub local_root: String,
298}
299
300impl FilesystemConfig {
301    pub fn from_env() -> Self {
302        Self {
303            default_disk: env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string()),
304            local_root: env::var("FILESYSTEM_LOCAL_ROOT")
305                .unwrap_or_else(|_| "storage/app".to_string()),
306        }
307    }
308}