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 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 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 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
202fn 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 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}