use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::util::app_config_reader;
use crate::util::multi_level_map::{ConfigValue, MultiLevelMap};
use crate::util::overrides;
use crate::util::resources;
const CLASSPATH: &str = "classpath:";
const FILEPATH: &str = "file:";
const REF_BEGIN: &str = "${";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("{0} not found")]
NotFound(String),
#[error("{0}")]
Invalid(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug, Default)]
pub struct ConfigReader {
map: MultiLevelMap,
is_base: bool,
resolved: bool,
flat_cache: OnceLock<BTreeMap<String, ConfigValue>>,
}
impl ConfigReader {
pub fn load(path: &str) -> Result<Self, ConfigError> {
let mut reader = Self::load_raw(path)?;
reader.resolve_references();
Ok(reader)
}
pub fn load_raw(path: &str) -> Result<Self, ConfigError> {
let mut reader = ConfigReader::default();
reader.load_into(path)?;
Ok(reader)
}
pub fn from_yaml_text(text: &str) -> Result<Self, ConfigError> {
let mut reader = ConfigReader::default();
reader.load_yaml_text(text)?;
reader.resolve_references();
Ok(reader)
}
pub fn from_map(map: BTreeMap<String, ConfigValue>) -> Self {
let mut reader = ConfigReader {
map: MultiLevelMap::from_map(map),
..ConfigReader::default()
};
reader.resolve_references();
reader
}
pub(crate) fn new_base(map: MultiLevelMap) -> Self {
let mut reader = ConfigReader {
map,
is_base: true,
..ConfigReader::default()
};
reader.resolve_references();
reader
}
pub fn get(&self, key: &str) -> Option<ConfigValue> {
let mut visited = Vec::new();
self.get_with(key, None, &mut visited)
}
pub fn get_or(&self, key: &str, default: ConfigValue) -> ConfigValue {
let mut visited = Vec::new();
self.get_with(key, Some(&default), &mut visited)
.unwrap_or(default)
}
pub fn get_property(&self, key: &str) -> Option<String> {
self.get(key).map(|v| v.to_display_string())
}
pub fn get_property_or(&self, key: &str, default: &str) -> String {
self.get_property(key)
.unwrap_or_else(|| default.to_string())
}
pub fn exists(&self, key: &str) -> bool {
if key.is_empty() {
return false;
}
self.map.exists(key)
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn get_map(&self) -> &MultiLevelMap {
&self.map
}
pub fn get_composite_key_values(&self) -> &BTreeMap<String, ConfigValue> {
self.flat_cache.get_or_init(|| {
let flat = self.map.flat_map();
flat.keys()
.map(|k| (k.clone(), self.get(k).unwrap_or(ConfigValue::Null)))
.collect()
})
}
pub fn is_base_config(&self) -> bool {
self.is_base
}
pub(crate) fn get_with(
&self,
key: &str,
default: Option<&ConfigValue>,
visited: &mut Vec<String>,
) -> Option<ConfigValue> {
if key.is_empty() {
return None;
}
if let Some(v) = overrides::get(key) {
return Some(ConfigValue::Text(v));
}
let value = match self.map.get_element(key) {
Some(v) => v.clone(),
None => return default.cloned(),
};
if let ConfigValue::Text(text) = &value {
if text.contains(REF_BEGIN) && self.base_available() {
let segments = extract_segments(text);
if !segments.is_empty() {
return self
.reconstruct(&segments, key, text, default, visited)
.map(ConfigValue::Text);
}
}
}
Some(value)
}
fn base_available(&self) -> bool {
self.is_base || app_config_reader::try_base_reader().is_some()
}
fn base_get(
&self,
name: &str,
default: Option<&ConfigValue>,
visited: &mut Vec<String>,
) -> Option<ConfigValue> {
if self.is_base {
self.get_with(name, default, visited)
} else {
app_config_reader::try_base_reader()
.and_then(|base| base.get_with(name, default, visited))
}
}
fn reconstruct(
&self,
segments: &[(usize, usize)],
key: &str,
text: &str,
default: Option<&ConfigValue>,
visited: &mut Vec<String>,
) -> Option<String> {
let mut sb = String::new();
let mut start = 0;
for &(s, e) in segments {
sb.push_str(&text[start..s]);
let statement = text[s + 2..e - 1].trim();
if let Some(evaluated) = self.substitute_var(key, statement, default, visited) {
sb.push_str(&evaluated);
}
start = e;
}
sb.push_str(&text[start..]);
if sb.is_empty() {
None
} else {
Some(sb)
}
}
fn substitute_var(
&self,
key: &str,
statement: &str,
default: Option<&ConfigValue>,
visited: &mut Vec<String>,
) -> Option<String> {
if statement.is_empty() {
return default.map(|d| d.to_display_string());
}
let (name, middle_default) = match statement.find(':') {
Some(colon) if colon > 0 => (&statement[..colon], Some(&statement[colon + 1..])),
_ => (statement, None),
};
if let Ok(v) = std::env::var(name) {
return Some(v);
}
let from_base = if visited.iter().any(|seen| seen == name) {
log::warn!("Config loop for '{key}' detected");
Some(String::new())
} else {
visited.push(name.to_string());
let resolved = self
.base_get(name, default, visited)
.map(|v| v.to_display_string());
visited.pop();
resolved
};
from_base.or_else(|| middle_default.map(str::to_string))
}
fn resolve_references(&mut self) {
if self.resolved {
return;
}
self.resolved = true;
let flat = self.map.flat_map();
self.map = MultiLevelMap::from_flat_map(&flat);
let has_refs = flat.values().any(|v| match v {
ConfigValue::Text(t) => {
let start = t.find(REF_BEGIN);
let end = t.find('}');
matches!((start, end), (Some(s), Some(e)) if e > s)
}
_ => false,
});
if has_refs {
let mut resolved = MultiLevelMap::new();
for k in flat.keys() {
let mut visited = Vec::new();
let v = self
.get_with(k, None, &mut visited)
.unwrap_or(ConfigValue::Null);
resolved.set_element(k, v);
}
self.map = resolved;
}
}
fn load_into(&mut self, path: &str) -> Result<(), ConfigError> {
if path.contains("../") {
return Err(ConfigError::Invalid(
"Relative parent file path not allowed".to_string(),
));
}
let is_yaml = path.ends_with(".yml") || path.ends_with(".yaml");
let alternative = if is_yaml {
let stem = &path[..path.rfind('.').expect("yaml path has a dot")];
Some(if path.ends_with(".yml") {
format!("{stem}.yaml")
} else {
format!("{stem}.yml")
})
} else {
None
};
let resolved = if path.starts_with(FILEPATH) {
resolve_file(path, alternative.as_deref())
} else {
resolve_classpath_entry(path, alternative.as_deref())
};
let Some(file) = resolved else {
return Err(ConfigError::NotFound(path.to_string()));
};
let data = std::fs::read_to_string(&file)?;
if is_yaml {
self.load_yaml_text(&data)?;
} else if path.ends_with(".json") {
let value: serde_json::Value =
serde_json::from_str(&data).map_err(|e| ConfigError::Invalid(e.to_string()))?;
match ConfigValue::from_json(&value) {
ConfigValue::Map(m) => self.map.reload(m),
ConfigValue::Null => self.map.reload(BTreeMap::new()),
_ => {
return Err(ConfigError::Invalid(format!(
"{path} must contain a JSON object"
)))
}
}
} else if path.ends_with(".properties") {
self.load_properties_text(&data)?;
} else {
return Err(ConfigError::Invalid(format!(
"{path} has an unsupported extension (use .yml, .yaml, .json or .properties)"
)));
}
Ok(())
}
fn load_yaml_text(&mut self, data: &str) -> Result<(), ConfigError> {
let clean = if data.contains('\t') {
data.replace('\t', " ")
} else {
data.to_string()
};
let value: serde_yaml::Value =
serde_yaml::from_str(&clean).map_err(|e| ConfigError::Invalid(e.to_string()))?;
match ConfigValue::from_yaml(&value) {
ConfigValue::Map(m) => self.map.reload(m),
ConfigValue::Null => self.map.reload(BTreeMap::new()),
_ => {
return Err(ConfigError::Invalid(
"YAML root must be a mapping".to_string(),
))
}
}
Ok(())
}
fn load_properties_text(&mut self, data: &str) -> Result<(), ConfigError> {
let mut pairs: Vec<(String, String)> = Vec::new();
let mut lines = data.lines();
while let Some(line) = lines.next() {
let stripped = line.trim_start();
if stripped.is_empty() || stripped.starts_with('#') || stripped.starts_with('!') {
continue;
}
let mut logical = stripped.to_string();
while ends_with_odd_backslashes(&logical) {
logical.pop();
match lines.next() {
Some(next) => logical.push_str(next.trim_start()),
None => break,
}
}
let (key, value) = split_properties_line(&logical).map_err(ConfigError::Invalid)?;
if !key.is_empty() {
pairs.push((key, value));
}
}
pairs.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in pairs {
self.map
.try_set_element(&k, ConfigValue::Text(v))
.map_err(ConfigError::Invalid)?;
}
Ok(())
}
}
fn ends_with_odd_backslashes(line: &str) -> bool {
line.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1
}
fn split_properties_line(line: &str) -> Result<(String, String), String> {
let chars: Vec<char> = line.chars().collect();
let mut key = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\\' {
let (decoded, used) = decode_properties_escape(&chars[i..])?;
key.push(decoded);
i += used;
continue;
}
if c == '=' || c == ':' {
i += 1;
break;
}
if c.is_whitespace() {
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
if i < chars.len() && (chars[i] == '=' || chars[i] == ':') {
i += 1;
}
break;
}
key.push(c);
i += 1;
}
while i < chars.len() && chars[i].is_whitespace() {
i += 1;
}
let mut value = String::new();
while i < chars.len() {
let c = chars[i];
if c == '\\' {
let (decoded, used) = decode_properties_escape(&chars[i..])?;
value.push(decoded);
i += used;
continue;
}
value.push(c);
i += 1;
}
Ok((key, value))
}
fn decode_properties_escape(chars: &[char]) -> Result<(char, usize), String> {
match chars.get(1) {
Some('t') => Ok(('\t', 2)),
Some('n') => Ok(('\n', 2)),
Some('r') => Ok(('\r', 2)),
Some('f') => Ok(('\u{000C}', 2)),
Some('u') => {
let hex: String = chars.iter().skip(2).take(4).collect();
if hex.len() == 4 {
if let Ok(code) = u32::from_str_radix(&hex, 16) {
if let Some(c) = char::from_u32(code) {
return Ok((c, 6));
}
}
}
Err("Malformed \\uxxxx encoding in .properties".to_string())
}
Some(&other) => Ok((other, 2)),
None => Ok(('\\', 1)),
}
}
fn extract_segments(text: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut i = 0;
while let Some(rel) = text[i..].find(REF_BEGIN) {
let start = i + rel;
match text[start + 2..].find('}') {
Some(close) => {
let end = start + 2 + close + 1;
out.push((start, end));
i = end;
}
None => break,
}
}
out
}
fn resolve_file(path: &str, alternative: Option<&str>) -> Option<PathBuf> {
let primary = Path::new(&path[FILEPATH.len()..]);
if primary.is_file() {
return Some(primary.to_path_buf());
}
if let Some(alt) = alternative {
let secondary = Path::new(&alt[FILEPATH.len()..]);
if secondary.is_file() {
return Some(secondary.to_path_buf());
}
}
None
}
fn resolve_classpath_entry(path: &str, alternative: Option<&str>) -> Option<PathBuf> {
let strip = |p: &str| p.strip_prefix(CLASSPATH).unwrap_or(p).to_string();
resources::resolve_classpath(&strip(path))
.or_else(|| alternative.and_then(|alt| resources::resolve_classpath(&strip(alt))))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_segments_finds_refs() {
assert_eq!(extract_segments("no refs"), vec![]);
assert_eq!(extract_segments("${a}"), vec![(0, 4)]);
assert_eq!(extract_segments("x${a}y${b:z}"), vec![(1, 5), (6, 12)]);
assert_eq!(extract_segments("broken ${a"), vec![]);
}
#[test]
fn properties_text_expands_composite_keys() {
let mut reader = ConfigReader::default();
reader
.load_properties_text("# comment\napp.name=mercury\nserver.port=8085\n")
.unwrap();
assert_eq!(
reader.get("app.name"),
Some(ConfigValue::Text("mercury".into()))
);
assert_eq!(
reader.get("server.port"),
Some(ConfigValue::Text("8085".into()))
);
}
#[test]
fn yaml_text_with_tabs_is_tolerated() {
let mut reader = ConfigReader::default();
reader.load_yaml_text("hello:\n\tworld: ok\n").unwrap();
assert_eq!(
reader.get("hello.world"),
Some(ConfigValue::Text("ok".into()))
);
}
}