Skip to main content

rustlavel_core/
config.rs

1//! Configuration: `config.get("app.name")`.
2//!
3//! Values are held as [`Json`] so a config tree can be built in Rust, loaded
4//! from a `.json` file under `config/`, or overridden from the environment —
5//! all through one lookup path.
6
7use crate::env;
8use crate::error::Result;
9use crate::json::Json;
10use std::collections::BTreeMap;
11use std::path::Path;
12use std::sync::{Arc, RwLock};
13
14/// The application's configuration tree.
15///
16/// Cloning is cheap: every clone shares one store, so a handler can hold a
17/// `Config` without copying the tree.
18#[derive(Clone, Default)]
19pub struct Config {
20    values: Arc<RwLock<BTreeMap<String, Json>>>,
21}
22
23impl Config {
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Seed the tree with the framework's defaults, honouring `.env`.
29    pub fn with_defaults() -> Self {
30        let config = Config::new();
31        config.set(
32            "app",
33            Json::object([
34                ("name", Json::from(env::env_or("APP_NAME", "Rustlavel"))),
35                ("env", Json::from(env::env_or("APP_ENV", "local"))),
36                ("debug", Json::from(env::env_or("APP_DEBUG", "true") == "true")),
37                ("url", Json::from(env::env_or("APP_URL", "http://localhost:8000"))),
38                ("key", Json::from(env::env_or("APP_KEY", ""))),
39            ]),
40        );
41        config.set(
42            "server",
43            Json::object([
44                ("host", Json::from(env::env_or("SERVER_HOST", "127.0.0.1"))),
45                ("port", Json::from(env::env_or("SERVER_PORT", "8000").parse::<u16>().unwrap_or(8000))),
46            ]),
47        );
48        config
49    }
50
51    /// Load every `config/*.json` file, keyed by file stem.
52    ///
53    /// `config/app.json` becomes the `app.*` namespace, mirroring Laravel's
54    /// `config/app.php`. String values support `${VAR}` interpolation so a
55    /// config file can defer to `.env`.
56    pub fn load_dir(&self, dir: impl AsRef<Path>) -> Result<()> {
57        let dir = dir.as_ref();
58        if !dir.is_dir() {
59            return Ok(());
60        }
61
62        let mut entries: Vec<_> = std::fs::read_dir(dir)?
63            .filter_map(|entry| entry.ok())
64            .map(|entry| entry.path())
65            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
66            .collect();
67        entries.sort();
68
69        for path in entries {
70            let Some(namespace) = path.file_stem().and_then(|s| s.to_str()) else {
71                continue;
72            };
73            let source = std::fs::read_to_string(&path)?;
74            let parsed = Json::parse(&source)?;
75            self.merge(namespace, expand_env(parsed));
76        }
77        Ok(())
78    }
79
80    /// Set (or replace) a value at a dotted path.
81    pub fn set(&self, path: &str, value: impl Into<Json>) {
82        let value = value.into();
83        let mut values = self.values.write().expect("config lock poisoned");
84        match path.split_once('.') {
85            None => {
86                values.insert(path.to_string(), value);
87            }
88            Some((root, rest)) => {
89                let entry = values.entry(root.to_string()).or_insert_with(|| Json::Object(BTreeMap::new()));
90                set_nested(entry, rest, value);
91            }
92        }
93    }
94
95    /// Merge an object into a namespace, keeping keys that are not overridden.
96    pub fn merge(&self, namespace: &str, value: Json) {
97        let mut values = self.values.write().expect("config lock poisoned");
98        match values.get_mut(namespace) {
99            Some(existing) => merge_into(existing, value),
100            None => {
101                values.insert(namespace.to_string(), value);
102            }
103        }
104    }
105
106    /// Look up a dotted path: `config.get("app.name")`.
107    pub fn get(&self, path: &str) -> Option<Json> {
108        let values = self.values.read().expect("config lock poisoned");
109        match path.split_once('.') {
110            None => values.get(path).cloned(),
111            Some((root, rest)) => values.get(root)?.get(rest).cloned(),
112        }
113    }
114
115    pub fn string(&self, path: &str, default: &str) -> String {
116        self.get(path)
117            .and_then(|v| match v {
118                Json::String(s) => Some(s),
119                Json::Number(n) => Some(n.to_string()),
120                Json::Bool(b) => Some(b.to_string()),
121                _ => None,
122            })
123            .unwrap_or_else(|| default.to_string())
124    }
125
126    pub fn int(&self, path: &str, default: i64) -> i64 {
127        self.get(path)
128            .and_then(|v| match v {
129                Json::Number(n) => Some(n as i64),
130                Json::String(s) => s.parse().ok(),
131                _ => None,
132            })
133            .unwrap_or(default)
134    }
135
136    pub fn bool(&self, path: &str, default: bool) -> bool {
137        self.get(path)
138            .and_then(|v| match v {
139                Json::Bool(b) => Some(b),
140                Json::String(s) => match s.as_str() {
141                    "true" | "1" | "yes" | "on" => Some(true),
142                    "false" | "0" | "no" | "off" => Some(false),
143                    _ => None,
144                },
145                _ => None,
146            })
147            .unwrap_or(default)
148    }
149
150    /// The current environment name: `local`, `production`, `testing`.
151    pub fn environment(&self) -> String {
152        self.string("app.env", "local")
153    }
154
155    pub fn is_local(&self) -> bool {
156        self.environment() == "local"
157    }
158
159    pub fn is_production(&self) -> bool {
160        self.environment() == "production"
161    }
162
163    /// Whether to show the detailed error page. Never true in production.
164    pub fn debug(&self) -> bool {
165        self.bool("app.debug", true) && !self.is_production()
166    }
167}
168
169fn set_nested(target: &mut Json, path: &str, value: Json) {
170    if !matches!(target, Json::Object(_)) {
171        *target = Json::Object(BTreeMap::new());
172    }
173    let Json::Object(map) = target else { unreachable!() };
174
175    match path.split_once('.') {
176        None => {
177            map.insert(path.to_string(), value);
178        }
179        Some((head, rest)) => {
180            let entry = map.entry(head.to_string()).or_insert_with(|| Json::Object(BTreeMap::new()));
181            set_nested(entry, rest, value);
182        }
183    }
184}
185
186fn merge_into(target: &mut Json, incoming: Json) {
187    match (target, incoming) {
188        (Json::Object(existing), Json::Object(new)) => {
189            for (key, value) in new {
190                match existing.get_mut(&key) {
191                    Some(slot) => merge_into(slot, value),
192                    None => {
193                        existing.insert(key, value);
194                    }
195                }
196            }
197        }
198        (slot, value) => *slot = value,
199    }
200}
201
202/// Replace `${VAR}` inside every string of a loaded config document.
203fn expand_env(value: Json) -> Json {
204    match value {
205        Json::String(s) if s.contains("${") => Json::String(expand_str(&s)),
206        Json::Array(items) => Json::Array(items.into_iter().map(expand_env).collect()),
207        Json::Object(map) => Json::Object(map.into_iter().map(|(k, v)| (k, expand_env(v))).collect()),
208        other => other,
209    }
210}
211
212fn expand_str(value: &str) -> String {
213    let mut out = String::with_capacity(value.len());
214    let mut rest = value;
215    while let Some(start) = rest.find("${") {
216        out.push_str(&rest[..start]);
217        let after = &rest[start + 2..];
218        match after.find('}') {
219            Some(end) => {
220                let (name, default) = match after[..end].split_once(':') {
221                    Some((name, default)) => (name, default),
222                    None => (&after[..end], ""),
223                };
224                out.push_str(&env::env_or(name.trim(), default));
225                rest = &after[end + 1..];
226            }
227            None => {
228                out.push_str("${");
229                rest = after;
230            }
231        }
232    }
233    out.push_str(rest);
234    out
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn sets_and_reads_nested_paths() {
243        let config = Config::new();
244        config.set("app.name", "Rustlavel");
245        config.set("app.nested.deep", 7);
246
247        assert_eq!(config.string("app.name", ""), "Rustlavel");
248        assert_eq!(config.int("app.nested.deep", 0), 7);
249        assert_eq!(config.string("app.missing", "fallback"), "fallback");
250    }
251
252    #[test]
253    fn merge_keeps_untouched_keys() {
254        let config = Config::new();
255        config.set("app", Json::object([("name", "Old".into()), ("env", "local".into())]));
256        config.merge("app", Json::object([("name", "New".into())]));
257
258        assert_eq!(config.string("app.name", ""), "New");
259        assert_eq!(config.string("app.env", ""), "local");
260    }
261
262    #[test]
263    fn debug_is_forced_off_in_production() {
264        let config = Config::new();
265        config.set("app.debug", true);
266        config.set("app.env", "production");
267        assert!(!config.debug());
268    }
269
270    #[test]
271    fn config_strings_expand_environment_variables() {
272        // SAFETY: single-threaded test setup.
273        unsafe { std::env::set_var("RUSTLAVEL_TEST_HOST", "db.internal") };
274        let expanded = expand_env(Json::from("${RUSTLAVEL_TEST_HOST}:5432"));
275        assert_eq!(expanded.as_str(), Some("db.internal:5432"));
276
277        let with_default = expand_env(Json::from("${RUSTLAVEL_TEST_ABSENT:fallback}"));
278        assert_eq!(with_default.as_str(), Some("fallback"));
279    }
280}