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    /// A list, from either a JSON array or one comma-separated string.
137    ///
138    /// The string form exists because of `.env`: a variable can only hold a
139    /// string, so `CORS_ALLOWED_ORIGINS=https://a.example,https://b.example`
140    /// has to mean the same as the array it would be in JSON. Items are
141    /// trimmed and empty ones dropped, so a trailing comma is not an entry.
142    pub fn list(&self, path: &str) -> Vec<String> {
143        match self.get(path) {
144            Some(Json::Array(items)) => items
145                .iter()
146                .filter_map(|item| match item {
147                    Json::String(s) => Some(s.clone()),
148                    Json::Number(n) => Some(n.to_string()),
149                    Json::Bool(b) => Some(b.to_string()),
150                    _ => None,
151                })
152                .map(|s| s.trim().to_string())
153                .filter(|s| !s.is_empty())
154                .collect(),
155            Some(Json::String(text)) => text
156                .split(',')
157                .map(str::trim)
158                .filter(|s| !s.is_empty())
159                .map(str::to_string)
160                .collect(),
161            _ => Vec::new(),
162        }
163    }
164
165    pub fn bool(&self, path: &str, default: bool) -> bool {
166        self.get(path)
167            .and_then(|v| match v {
168                Json::Bool(b) => Some(b),
169                Json::String(s) => match s.as_str() {
170                    "true" | "1" | "yes" | "on" => Some(true),
171                    "false" | "0" | "no" | "off" => Some(false),
172                    _ => None,
173                },
174                _ => None,
175            })
176            .unwrap_or(default)
177    }
178
179    /// The current environment name: `local`, `production`, `testing`.
180    pub fn environment(&self) -> String {
181        self.string("app.env", "local")
182    }
183
184    pub fn is_local(&self) -> bool {
185        self.environment() == "local"
186    }
187
188    pub fn is_production(&self) -> bool {
189        self.environment() == "production"
190    }
191
192    /// Whether to show the detailed error page. Never true in production.
193    pub fn debug(&self) -> bool {
194        self.bool("app.debug", true) && !self.is_production()
195    }
196}
197
198fn set_nested(target: &mut Json, path: &str, value: Json) {
199    if !matches!(target, Json::Object(_)) {
200        *target = Json::Object(BTreeMap::new());
201    }
202    let Json::Object(map) = target else { unreachable!() };
203
204    match path.split_once('.') {
205        None => {
206            map.insert(path.to_string(), value);
207        }
208        Some((head, rest)) => {
209            let entry = map.entry(head.to_string()).or_insert_with(|| Json::Object(BTreeMap::new()));
210            set_nested(entry, rest, value);
211        }
212    }
213}
214
215fn merge_into(target: &mut Json, incoming: Json) {
216    match (target, incoming) {
217        (Json::Object(existing), Json::Object(new)) => {
218            for (key, value) in new {
219                match existing.get_mut(&key) {
220                    Some(slot) => merge_into(slot, value),
221                    None => {
222                        existing.insert(key, value);
223                    }
224                }
225            }
226        }
227        (slot, value) => *slot = value,
228    }
229}
230
231/// Replace `${VAR}` inside every string of a loaded config document.
232fn expand_env(value: Json) -> Json {
233    match value {
234        Json::String(s) if s.contains("${") => Json::String(expand_str(&s)),
235        Json::Array(items) => Json::Array(items.into_iter().map(expand_env).collect()),
236        Json::Object(map) => Json::Object(map.into_iter().map(|(k, v)| (k, expand_env(v))).collect()),
237        other => other,
238    }
239}
240
241fn expand_str(value: &str) -> String {
242    let mut out = String::with_capacity(value.len());
243    let mut rest = value;
244    while let Some(start) = rest.find("${") {
245        out.push_str(&rest[..start]);
246        let after = &rest[start + 2..];
247        match after.find('}') {
248            Some(end) => {
249                let (name, default) = match after[..end].split_once(':') {
250                    Some((name, default)) => (name, default),
251                    None => (&after[..end], ""),
252                };
253                out.push_str(&env::env_or(name.trim(), default));
254                rest = &after[end + 1..];
255            }
256            None => {
257                out.push_str("${");
258                rest = after;
259            }
260        }
261    }
262    out.push_str(rest);
263    out
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn a_list_comes_from_an_array_or_a_comma_separated_string() {
272        let config = Config::new();
273        config.set("a.items", Json::from(vec!["x", " y ", ""]));
274        config.set("a.csv", "x, y,,z ,");
275        config.set("a.one", "solo");
276        assert_eq!(config.list("a.items"), vec!["x", "y"]);
277        assert_eq!(config.list("a.csv"), vec!["x", "y", "z"]);
278        assert_eq!(config.list("a.one"), vec!["solo"]);
279        assert!(config.list("a.missing").is_empty());
280    }
281
282    #[test]
283    fn sets_and_reads_nested_paths() {
284        let config = Config::new();
285        config.set("app.name", "Rustlavel");
286        config.set("app.nested.deep", 7);
287
288        assert_eq!(config.string("app.name", ""), "Rustlavel");
289        assert_eq!(config.int("app.nested.deep", 0), 7);
290        assert_eq!(config.string("app.missing", "fallback"), "fallback");
291    }
292
293    #[test]
294    fn merge_keeps_untouched_keys() {
295        let config = Config::new();
296        config.set("app", Json::object([("name", "Old".into()), ("env", "local".into())]));
297        config.merge("app", Json::object([("name", "New".into())]));
298
299        assert_eq!(config.string("app.name", ""), "New");
300        assert_eq!(config.string("app.env", ""), "local");
301    }
302
303    #[test]
304    fn debug_is_forced_off_in_production() {
305        let config = Config::new();
306        config.set("app.debug", true);
307        config.set("app.env", "production");
308        assert!(!config.debug());
309    }
310
311    #[test]
312    fn config_strings_expand_environment_variables() {
313        // SAFETY: single-threaded test setup.
314        unsafe { std::env::set_var("RUSTLAVEL_TEST_HOST", "db.internal") };
315        let expanded = expand_env(Json::from("${RUSTLAVEL_TEST_HOST}:5432"));
316        assert_eq!(expanded.as_str(), Some("db.internal:5432"));
317
318        let with_default = expand_env(Json::from("${RUSTLAVEL_TEST_ABSENT:fallback}"));
319        assert_eq!(with_default.as_str(), Some("fallback"));
320    }
321}