1use 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#[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 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 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 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 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 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 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 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 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
231fn 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 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}