Skip to main content

platform_core/util/
config_reader.rs

1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Rust port of the Java `ConfigReader` (`org.platformlambda.core.util.ConfigReader`).
18//!
19//! Loads `.yml` / `.yaml` (interchangeable), `.json`, and `.properties` files from
20//! `classpath:/` (the resource roots — see [`crate::util::resources`]) or `file:/`
21//! paths, and resolves `${...}` references with the exact Java precedence:
22//!
23//! 1. the process **override registry** (the `System.getProperty` analog) wins for
24//!    any key lookup;
25//! 2. inside `${...}`: **environment variable** → **base-config key reference**
26//!    (recursive, with loop detection) → the **`:default`** fallback.
27//!
28//! Base-config references resolve against the [`AppConfigReader`] singleton once
29//! it is initialized (or against the reader itself for the base config). Before
30//! that, `${...}` values are returned raw — mirroring Java's `baseConfig == null`
31//! behavior.
32//!
33//! [`AppConfigReader`]: crate::util::app_config_reader::AppConfigReader
34
35use 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    /// The configuration file was not found (Java `IllegalArgumentException("<path> not found")`).
51    #[error("{0} not found")]
52    NotFound(String),
53    /// Invalid path or content (Java `IllegalArgumentException`).
54    #[error("{0}")]
55    Invalid(String),
56    #[error(transparent)]
57    Io(#[from] std::io::Error),
58}
59
60/// A configuration reader over a [`MultiLevelMap`], with `${...}` substitution.
61#[derive(Debug, Default)]
62pub struct ConfigReader {
63    map: MultiLevelMap,
64    /// True for the AppConfigReader's inner reader — base refs then resolve
65    /// against `self` (Java: `isBaseConfig()`).
66    is_base: bool,
67    resolved: bool,
68    flat_cache: OnceLock<BTreeMap<String, ConfigValue>>,
69}
70
71impl ConfigReader {
72    /// Load a configuration file and resolve references (Java `new ConfigReader(path)`).
73    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    /// Load a configuration file **without** resolving references
80    /// (Java `load(path, false)` — used while merging base configuration files).
81    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    /// Build a reader from in-memory YAML text and resolve references — used
88    /// for **embedded** built-in templates (e.g. `default-log-context.yaml`),
89    /// the Rust analog of a library-jar classpath resource. The `resources/`
90    /// roots are application-owned in this port, so a library default ships
91    /// compiled-in via `include_str!` instead of shadowing a file name.
92    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    /// Build a reader from an existing nested map and resolve references
100    /// (Java `load(Map)`).
101    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    /// Crate-internal: build the **base** reader (the AppConfigReader's inner
111    /// reader). Self-references resolve against this reader itself.
112    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    // ---- lookup API (Java ConfigBase) ----
123
124    /// Retrieve a value by composite key. `None` for a missing key, an explicit
125    /// null, or an unresolvable `${...}` reference (Java returns `null` in each
126    /// case).
127    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    /// Retrieve a value by composite key with a default (Java `get(key, defaultValue)`).
133    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    /// Retrieve a value enforced as a string (Java `getProperty`).
140    pub fn get_property(&self, key: &str) -> Option<String> {
141        self.get(key).map(|v| v.to_display_string())
142    }
143
144    /// Retrieve a value enforced as a string, with a default (Java `getProperty(key, default)`).
145    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    /// True when the key resolves to a non-null value (Java `exists`).
151    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    /// The raw underlying tree, without substitution (Java `getMap`).
163    pub fn get_map(&self) -> &MultiLevelMap {
164        &self.map
165    }
166
167    /// Flat map of composite key-values with substitution applied, computed once
168    /// and cached (Java `getCompositeKeyValues`).
169    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    // ---- resolution engine ----
183
184    /// Full lookup chain: override registry → own tree → `${...}` substitution.
185    /// `visited` carries the loop-detection state across base-reference recursion.
186    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        // 1. process override (the System.getProperty analog) always wins
196        if let Some(v) = overrides::get(key) {
197            return Some(ConfigValue::Text(v));
198        }
199        // 2. own tree
200        let value = match self.map.get_element(key) {
201            Some(v) => v.clone(),
202            None => return default.cloned(),
203        };
204        // 3. ${...} substitution (only when a base config is reachable — Java:
205        //    `baseConfig != null`)
206        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    /// Rebuild a text value from its `${...}` segments
238    /// (Java `reconstructFromVarSegments`).
239    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    /// Resolve one `${statement}`: env var → base-config reference (loop-guarded)
266    /// → `:default` fallback (Java `performEnvVarSubstitution`).
267    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` is the CURRENT resolution chain, not an ever-seen
289            // set: pop after the segment resolves so a repeated reference
290            // (`${a} ${a}`, or a diamond) is not a false cycle — the Java
291            // resolver keeps a fresh per-segment chain (F11 parity fix,
292            // 2026-07-21); a genuine a→b→a cycle is still on the chain
293            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    /// Normalize the dataset and render `${...}` references
304    /// (Java `resolveReferences`).
305    fn resolve_references(&mut self) {
306        if self.resolved {
307            return;
308        }
309        self.resolved = true;
310        let flat = self.map.flat_map();
311        // normalization pass — rebuild from sorted flat keys
312        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    // ---- file loading ----
335
336    fn load_into(&mut self, path: &str) -> Result<(), ConfigError> {
337        if path.contains("../") {
338            // Java getPath: "Relative parent file path not allowed"
339            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        // ".yaml" and ".yml" can be used interchangeably
345        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    /// Parse YAML text (tabs tolerated — replaced with two spaces, a ported quirk).
389    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    /// `.properties` with `java.util.Properties.load` semantics (increment 55,
410    /// parity F13 — previously only trimmed `key=value` lines parsed):
411    /// `=`/`:`/whitespace separators, backslash line continuations, `\uXXXX`
412    /// and single-character escapes, and the value's trailing whitespace
413    /// PRESERVED. Values are strings; composite keys expand into the nested
414    /// tree via `set_element`, sorted first (Java behavior).
415    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            // leading whitespace never counts; blank + comment lines skipped
420            let stripped = line.trim_start();
421            if stripped.is_empty() || stripped.starts_with('#') || stripped.starts_with('!') {
422                continue;
423            }
424            // fold backslash continuations into one logical line (a line
425            // ending with an ODD number of backslashes continues; the next
426            // line's leading whitespace is stripped)
427            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
450/// True when the line ends with an odd number of backslashes — the
451/// `java.util.Properties` line-continuation rule (an even count is pairs of
452/// escaped backslashes, not a continuation).
453fn ends_with_odd_backslashes(line: &str) -> bool {
454    line.bytes().rev().take_while(|b| *b == b'\\').count() % 2 == 1
455}
456
457/// Split one logical `.properties` line into (key, value) with
458/// `java.util.Properties` rules: the key ends at the first unescaped `=`,
459/// `:` or whitespace (whitespace may be followed by one optional `=`/`:`);
460/// escapes decode in both key and value; the value keeps trailing whitespace.
461fn 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
507/// Decode one backslash escape (`chars[0]` is the backslash):
508/// `\t` `\n` `\r` `\f`, `\uXXXX`, and `\x` → `x` for any other character —
509/// exactly `java.util.Properties.loadConvert` (malformed `\u` is an error,
510/// as in Java).
511fn 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
533/// Find non-nested `${...}` segments; each result is the byte range including
534/// the delimiters (Java `Utility.extractSegments`).
535fn 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        // properties values are strings, mirroring java.util.Properties
595        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}