1use std::collections::BTreeMap;
36use std::path::{Path, PathBuf};
37use std::sync::OnceLock;
38
39use crate::util::app_config_reader;
40use crate::util::multi_level_map::{ConfigValue, MultiLevelMap};
41use crate::util::overrides;
42use crate::util::resources;
43
44const CLASSPATH: &str = "classpath:";
45const FILEPATH: &str = "file:";
46const REF_BEGIN: &str = "${";
47
48#[derive(Debug, thiserror::Error)]
49pub enum ConfigError {
50 #[error("{0} not found")]
52 NotFound(String),
53 #[error("{0}")]
55 Invalid(String),
56 #[error(transparent)]
57 Io(#[from] std::io::Error),
58}
59
60#[derive(Debug, Default)]
62pub struct ConfigReader {
63 map: MultiLevelMap,
64 is_base: bool,
67 resolved: bool,
68 flat_cache: OnceLock<BTreeMap<String, ConfigValue>>,
69}
70
71impl ConfigReader {
72 pub fn load(path: &str) -> Result<Self, ConfigError> {
74 let mut reader = Self::load_raw(path)?;
75 reader.resolve_references();
76 Ok(reader)
77 }
78
79 pub fn load_raw(path: &str) -> Result<Self, ConfigError> {
82 let mut reader = ConfigReader::default();
83 reader.load_into(path)?;
84 Ok(reader)
85 }
86
87 pub fn from_yaml_text(text: &str) -> Result<Self, ConfigError> {
93 let mut reader = ConfigReader::default();
94 reader.load_yaml_text(text)?;
95 reader.resolve_references();
96 Ok(reader)
97 }
98
99 pub fn from_map(map: BTreeMap<String, ConfigValue>) -> Self {
102 let mut reader = ConfigReader {
103 map: MultiLevelMap::from_map(map),
104 ..ConfigReader::default()
105 };
106 reader.resolve_references();
107 reader
108 }
109
110 pub(crate) fn new_base(map: MultiLevelMap) -> Self {
113 let mut reader = ConfigReader {
114 map,
115 is_base: true,
116 ..ConfigReader::default()
117 };
118 reader.resolve_references();
119 reader
120 }
121
122 pub fn get(&self, key: &str) -> Option<ConfigValue> {
128 let mut visited = Vec::new();
129 self.get_with(key, None, &mut visited)
130 }
131
132 pub fn get_or(&self, key: &str, default: ConfigValue) -> ConfigValue {
134 let mut visited = Vec::new();
135 self.get_with(key, Some(&default), &mut visited)
136 .unwrap_or(default)
137 }
138
139 pub fn get_property(&self, key: &str) -> Option<String> {
141 self.get(key).map(|v| v.to_display_string())
142 }
143
144 pub fn get_property_or(&self, key: &str, default: &str) -> String {
146 self.get_property(key)
147 .unwrap_or_else(|| default.to_string())
148 }
149
150 pub fn exists(&self, key: &str) -> bool {
152 if key.is_empty() {
153 return false;
154 }
155 self.map.exists(key)
156 }
157
158 pub fn is_empty(&self) -> bool {
159 self.map.is_empty()
160 }
161
162 pub fn get_map(&self) -> &MultiLevelMap {
164 &self.map
165 }
166
167 pub fn get_composite_key_values(&self) -> &BTreeMap<String, ConfigValue> {
170 self.flat_cache.get_or_init(|| {
171 let flat = self.map.flat_map();
172 flat.keys()
173 .map(|k| (k.clone(), self.get(k).unwrap_or(ConfigValue::Null)))
174 .collect()
175 })
176 }
177
178 pub fn is_base_config(&self) -> bool {
179 self.is_base
180 }
181
182 pub(crate) fn get_with(
187 &self,
188 key: &str,
189 default: Option<&ConfigValue>,
190 visited: &mut Vec<String>,
191 ) -> Option<ConfigValue> {
192 if key.is_empty() {
193 return None;
194 }
195 if let Some(v) = overrides::get(key) {
197 return Some(ConfigValue::Text(v));
198 }
199 let value = match self.map.get_element(key) {
201 Some(v) => v.clone(),
202 None => return default.cloned(),
203 };
204 if let ConfigValue::Text(text) = &value {
207 if text.contains(REF_BEGIN) && self.base_available() {
208 let segments = extract_segments(text);
209 if !segments.is_empty() {
210 return self
211 .reconstruct(&segments, key, text, default, visited)
212 .map(ConfigValue::Text);
213 }
214 }
215 }
216 Some(value)
217 }
218
219 fn base_available(&self) -> bool {
220 self.is_base || app_config_reader::try_base_reader().is_some()
221 }
222
223 fn base_get(
224 &self,
225 name: &str,
226 default: Option<&ConfigValue>,
227 visited: &mut Vec<String>,
228 ) -> Option<ConfigValue> {
229 if self.is_base {
230 self.get_with(name, default, visited)
231 } else {
232 app_config_reader::try_base_reader()
233 .and_then(|base| base.get_with(name, default, visited))
234 }
235 }
236
237 fn reconstruct(
240 &self,
241 segments: &[(usize, usize)],
242 key: &str,
243 text: &str,
244 default: Option<&ConfigValue>,
245 visited: &mut Vec<String>,
246 ) -> Option<String> {
247 let mut sb = String::new();
248 let mut start = 0;
249 for &(s, e) in segments {
250 sb.push_str(&text[start..s]);
251 let statement = text[s + 2..e - 1].trim();
252 if let Some(evaluated) = self.substitute_var(key, statement, default, visited) {
253 sb.push_str(&evaluated);
254 }
255 start = e;
256 }
257 sb.push_str(&text[start..]);
258 if sb.is_empty() {
259 None
260 } else {
261 Some(sb)
262 }
263 }
264
265 fn substitute_var(
268 &self,
269 key: &str,
270 statement: &str,
271 default: Option<&ConfigValue>,
272 visited: &mut Vec<String>,
273 ) -> Option<String> {
274 if statement.is_empty() {
275 return default.map(|d| d.to_display_string());
276 }
277 let (name, middle_default) = match statement.find(':') {
278 Some(colon) if colon > 0 => (&statement[..colon], Some(&statement[colon + 1..])),
279 _ => (statement, None),
280 };
281 if let Ok(v) = std::env::var(name) {
282 return Some(v);
283 }
284 let from_base = if visited.iter().any(|seen| seen == name) {
285 log::warn!("Config loop for '{key}' detected");
286 Some(String::new())
287 } else {
288 visited.push(name.to_string());
294 let resolved = self
295 .base_get(name, default, visited)
296 .map(|v| v.to_display_string());
297 visited.pop();
298 resolved
299 };
300 from_base.or_else(|| middle_default.map(str::to_string))
301 }
302
303 fn resolve_references(&mut self) {
306 if self.resolved {
307 return;
308 }
309 self.resolved = true;
310 let flat = self.map.flat_map();
311 self.map = MultiLevelMap::from_flat_map(&flat);
313 let has_refs = flat.values().any(|v| match v {
314 ConfigValue::Text(t) => {
315 let start = t.find(REF_BEGIN);
316 let end = t.find('}');
317 matches!((start, end), (Some(s), Some(e)) if e > s)
318 }
319 _ => false,
320 });
321 if has_refs {
322 let mut resolved = MultiLevelMap::new();
323 for k in flat.keys() {
324 let mut visited = Vec::new();
325 let v = self
326 .get_with(k, None, &mut visited)
327 .unwrap_or(ConfigValue::Null);
328 resolved.set_element(k, v);
329 }
330 self.map = resolved;
331 }
332 }
333
334 fn load_into(&mut self, path: &str) -> Result<(), ConfigError> {
337 if path.contains("../") {
338 return Err(ConfigError::Invalid(
340 "Relative parent file path not allowed".to_string(),
341 ));
342 }
343 let is_yaml = path.ends_with(".yml") || path.ends_with(".yaml");
344 let alternative = if is_yaml {
346 let stem = &path[..path.rfind('.').expect("yaml path has a dot")];
347 Some(if path.ends_with(".yml") {
348 format!("{stem}.yaml")
349 } else {
350 format!("{stem}.yml")
351 })
352 } else {
353 None
354 };
355 let resolved = if path.starts_with(FILEPATH) {
356 resolve_file(path, alternative.as_deref())
357 } else {
358 resolve_classpath_entry(path, alternative.as_deref())
359 };
360 let Some(file) = resolved else {
361 return Err(ConfigError::NotFound(path.to_string()));
362 };
363 let data = std::fs::read_to_string(&file)?;
364 if is_yaml {
365 self.load_yaml_text(&data)?;
366 } else if path.ends_with(".json") {
367 let value: serde_json::Value =
368 serde_json::from_str(&data).map_err(|e| ConfigError::Invalid(e.to_string()))?;
369 match ConfigValue::from_json(&value) {
370 ConfigValue::Map(m) => self.map.reload(m),
371 ConfigValue::Null => self.map.reload(BTreeMap::new()),
372 _ => {
373 return Err(ConfigError::Invalid(format!(
374 "{path} must contain a JSON object"
375 )))
376 }
377 }
378 } else if path.ends_with(".properties") {
379 self.load_properties_text(&data)?;
380 } else {
381 return Err(ConfigError::Invalid(format!(
382 "{path} has an unsupported extension (use .yml, .yaml, .json or .properties)"
383 )));
384 }
385 Ok(())
386 }
387
388 fn load_yaml_text(&mut self, data: &str) -> Result<(), ConfigError> {
390 let clean = if data.contains('\t') {
391 data.replace('\t', " ")
392 } else {
393 data.to_string()
394 };
395 let value: serde_yaml::Value =
396 serde_yaml::from_str(&clean).map_err(|e| ConfigError::Invalid(e.to_string()))?;
397 match ConfigValue::from_yaml(&value) {
398 ConfigValue::Map(m) => self.map.reload(m),
399 ConfigValue::Null => self.map.reload(BTreeMap::new()),
400 _ => {
401 return Err(ConfigError::Invalid(
402 "YAML root must be a mapping".to_string(),
403 ))
404 }
405 }
406 Ok(())
407 }
408
409 fn load_properties_text(&mut self, data: &str) -> Result<(), ConfigError> {
416 let mut pairs: Vec<(String, String)> = Vec::new();
417 let mut lines = data.lines();
418 while let Some(line) = lines.next() {
419 let stripped = line.trim_start();
421 if stripped.is_empty() || stripped.starts_with('#') || stripped.starts_with('!') {
422 continue;
423 }
424 let mut logical = stripped.to_string();
428 while ends_with_odd_backslashes(&logical) {
429 logical.pop();
430 match lines.next() {
431 Some(next) => logical.push_str(next.trim_start()),
432 None => break,
433 }
434 }
435 let (key, value) = split_properties_line(&logical).map_err(ConfigError::Invalid)?;
436 if !key.is_empty() {
437 pairs.push((key, value));
438 }
439 }
440 pairs.sort_by(|a, b| a.0.cmp(&b.0));
441 for (k, v) in pairs {
442 self.map
443 .try_set_element(&k, ConfigValue::Text(v))
444 .map_err(ConfigError::Invalid)?;
445 }
446 Ok(())
447 }
448}
449
450fn ends_with_odd_backslashes(line: &str) -> bool {
454 line.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1
455}
456
457fn split_properties_line(line: &str) -> Result<(String, String), String> {
462 let chars: Vec<char> = line.chars().collect();
463 let mut key = String::new();
464 let mut i = 0;
465 while i < chars.len() {
466 let c = chars[i];
467 if c == '\\' {
468 let (decoded, used) = decode_properties_escape(&chars[i..])?;
469 key.push(decoded);
470 i += used;
471 continue;
472 }
473 if c == '=' || c == ':' {
474 i += 1;
475 break;
476 }
477 if c.is_whitespace() {
478 while i < chars.len() && chars[i].is_whitespace() {
479 i += 1;
480 }
481 if i < chars.len() && (chars[i] == '=' || chars[i] == ':') {
482 i += 1;
483 }
484 break;
485 }
486 key.push(c);
487 i += 1;
488 }
489 while i < chars.len() && chars[i].is_whitespace() {
490 i += 1;
491 }
492 let mut value = String::new();
493 while i < chars.len() {
494 let c = chars[i];
495 if c == '\\' {
496 let (decoded, used) = decode_properties_escape(&chars[i..])?;
497 value.push(decoded);
498 i += used;
499 continue;
500 }
501 value.push(c);
502 i += 1;
503 }
504 Ok((key, value))
505}
506
507fn decode_properties_escape(chars: &[char]) -> Result<(char, usize), String> {
512 match chars.get(1) {
513 Some('t') => Ok(('\t', 2)),
514 Some('n') => Ok(('\n', 2)),
515 Some('r') => Ok(('\r', 2)),
516 Some('f') => Ok(('\u{000C}', 2)),
517 Some('u') => {
518 let hex: String = chars.iter().skip(2).take(4).collect();
519 if hex.len() == 4 {
520 if let Ok(code) = u32::from_str_radix(&hex, 16) {
521 if let Some(c) = char::from_u32(code) {
522 return Ok((c, 6));
523 }
524 }
525 }
526 Err("Malformed \\uxxxx encoding in .properties".to_string())
527 }
528 Some(&other) => Ok((other, 2)),
529 None => Ok(('\\', 1)),
530 }
531}
532
533fn extract_segments(text: &str) -> Vec<(usize, usize)> {
536 let mut out = Vec::new();
537 let mut i = 0;
538 while let Some(rel) = text[i..].find(REF_BEGIN) {
539 let start = i + rel;
540 match text[start + 2..].find('}') {
541 Some(close) => {
542 let end = start + 2 + close + 1;
543 out.push((start, end));
544 i = end;
545 }
546 None => break,
547 }
548 }
549 out
550}
551
552fn resolve_file(path: &str, alternative: Option<&str>) -> Option<PathBuf> {
553 let primary = Path::new(&path[FILEPATH.len()..]);
554 if primary.is_file() {
555 return Some(primary.to_path_buf());
556 }
557 if let Some(alt) = alternative {
558 let secondary = Path::new(&alt[FILEPATH.len()..]);
559 if secondary.is_file() {
560 return Some(secondary.to_path_buf());
561 }
562 }
563 None
564}
565
566fn resolve_classpath_entry(path: &str, alternative: Option<&str>) -> Option<PathBuf> {
567 let strip = |p: &str| p.strip_prefix(CLASSPATH).unwrap_or(p).to_string();
568 resources::resolve_classpath(&strip(path))
569 .or_else(|| alternative.and_then(|alt| resources::resolve_classpath(&strip(alt))))
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn extract_segments_finds_refs() {
578 assert_eq!(extract_segments("no refs"), vec![]);
579 assert_eq!(extract_segments("${a}"), vec![(0, 4)]);
580 assert_eq!(extract_segments("x${a}y${b:z}"), vec![(1, 5), (6, 12)]);
581 assert_eq!(extract_segments("broken ${a"), vec![]);
582 }
583
584 #[test]
585 fn properties_text_expands_composite_keys() {
586 let mut reader = ConfigReader::default();
587 reader
588 .load_properties_text("# comment\napp.name=mercury\nserver.port=8085\n")
589 .unwrap();
590 assert_eq!(
591 reader.get("app.name"),
592 Some(ConfigValue::Text("mercury".into()))
593 );
594 assert_eq!(
596 reader.get("server.port"),
597 Some(ConfigValue::Text("8085".into()))
598 );
599 }
600
601 #[test]
602 fn yaml_text_with_tabs_is_tolerated() {
603 let mut reader = ConfigReader::default();
604 reader.load_yaml_text("hello:\n\tworld: ok\n").unwrap();
605 assert_eq!(
606 reader.get("hello.world"),
607 Some(ConfigValue::Text("ok".into()))
608 );
609 }
610}