Skip to main content

qubit_config/source/
properties_config_source.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! # Properties File Configuration Source
11//!
12//! Loads configuration from Java `.properties` format files.
13//!
14//! # Format
15//!
16//! The `.properties` format supports:
17//! - `key=value` assignments
18//! - `key: value` assignments (colon separator)
19//! - `key value` assignments (whitespace separator)
20//! - `# comment` and `! comment` lines
21//! - Blank lines (ignored)
22//! - Line continuation with an odd number of `\` characters at end of line
23//! - Java properties escape sequences (`\uXXXX`, `\=`, `\:`, `\ `, etc.)
24//!
25
26use std::iter::Peekable;
27use std::path::{
28    Path,
29    PathBuf,
30};
31use std::str::Chars;
32
33use crate::{
34    Config,
35    ConfigError,
36    ConfigResult,
37};
38
39use super::{
40    ConfigSource,
41    config_source::load_transactionally,
42};
43
44/// Configuration source that loads from Java `.properties` format files
45///
46/// # Examples
47///
48/// ```rust
49/// use qubit_config::source::{PropertiesConfigSource, ConfigSource};
50/// use qubit_config::Config;
51///
52/// let temp_dir = tempfile::tempdir().unwrap();
53/// let path = temp_dir.path().join("config.properties");
54/// std::fs::write(&path, "server.port=8080\n").unwrap();
55/// let source = PropertiesConfigSource::from_file(path);
56/// let mut config = Config::new();
57/// source.load(&mut config).unwrap();
58/// let value = config.get::<String>("server.port").unwrap();
59/// assert_eq!(value, "8080");
60/// ```
61///
62#[derive(Debug, Clone)]
63pub struct PropertiesConfigSource {
64    path: PathBuf,
65}
66
67impl PropertiesConfigSource {
68    /// Creates a new `PropertiesConfigSource` from a file path
69    ///
70    /// # Parameters
71    ///
72    /// * `path` - Path to the `.properties` file
73    #[inline]
74    pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
75        Self {
76            path: path.as_ref().to_path_buf(),
77        }
78    }
79
80    /// Parses a `.properties` format string into key-value pairs
81    ///
82    /// # Parameters
83    ///
84    /// * `content` - The content of the `.properties` file
85    ///
86    /// # Returns
87    ///
88    /// Returns a vector of `(key, value)` pairs
89    pub fn parse_content(content: &str) -> Vec<(String, String)> {
90        let mut result = Vec::new();
91        let mut lines = content.lines().peekable();
92
93        while let Some(line) = lines.next() {
94            let trimmed = line.trim_start();
95
96            // Skip blank lines and comments
97            if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
98                continue;
99            }
100
101            // Handle line continuation
102            let mut full_line = trimmed.to_string();
103            while has_line_continuation(&full_line) {
104                full_line.pop(); // remove trailing backslash
105                if let Some(next) = lines.next() {
106                    full_line.push_str(next.trim_start());
107                } else {
108                    break;
109                }
110            }
111
112            // Parse key/value pairs using Java properties separators.
113            if let Some((key, value)) = parse_key_value(&full_line) {
114                let key = unescape_properties(key);
115                let value = unescape_properties(value);
116                result.push((key, value));
117            }
118        }
119
120        result
121    }
122}
123
124/// Parses a single `key=value`, `key: value`, or `key value` line.
125fn parse_key_value(line: &str) -> Option<(&str, &str)> {
126    let line = line.trim_start();
127
128    for (i, ch) in line.char_indices() {
129        if ch == '=' || ch == ':' {
130            // Separator is escaped only if there is an odd number of trailing backslashes.
131            if !is_escaped_separator(line, i) {
132                let value_start = skip_properties_whitespace(line, i + ch.len_utf8());
133                return Some((&line[..i], &line[value_start..]));
134            }
135        }
136        if ch.is_whitespace() && !is_escaped_separator(line, i) {
137            let mut value_start = skip_properties_whitespace(line, i);
138            if let Some((sep, sep_len)) = char_at(line, value_start)
139                && (sep == '=' || sep == ':')
140                && !is_escaped_separator(line, value_start)
141            {
142                value_start = skip_properties_whitespace(line, value_start + sep_len);
143            }
144            return Some((&line[..i], &line[value_start..]));
145        }
146    }
147    // No separator found - treat the whole line as a key with empty value.
148    (!line.is_empty()).then_some((line, ""))
149}
150
151/// Returns the character and byte width at `index`.
152///
153/// # Parameters
154///
155/// * `line` - Source properties line.
156/// * `index` - Byte index to inspect.
157///
158/// # Returns
159///
160/// `Some((ch, len))` if `index` points to a character boundary inside `line`,
161/// otherwise `None`.
162#[inline]
163fn char_at(line: &str, index: usize) -> Option<(char, usize)> {
164    if index == line.len() {
165        return None;
166    }
167    let ch = line[index..]
168        .chars()
169        .next()
170        .expect("index below line length should point to a character");
171    Some((ch, ch.len_utf8()))
172}
173
174/// Skips Java properties whitespace from a byte index.
175///
176/// # Parameters
177///
178/// * `line` - Source properties line.
179/// * `start` - Byte index to start scanning from.
180///
181/// # Returns
182///
183/// The first byte index at or after `start` that is not whitespace, or the end
184/// of `line`.
185fn skip_properties_whitespace(line: &str, start: usize) -> usize {
186    for (offset, ch) in line[start..].char_indices() {
187        if !ch.is_whitespace() {
188            return start + offset;
189        }
190    }
191    line.len()
192}
193
194/// Returns true if the separator at `sep_pos` is escaped by a preceding odd
195/// number of backslashes.
196///
197/// # Parameters
198///
199/// * `line` - Full properties line being parsed.
200/// * `sep_pos` - Byte index of `=` or `:` in `line`.
201///
202/// # Returns
203///
204/// `true` when the separator is escaped and must not split the key/value.
205#[inline]
206fn is_escaped_separator(line: &str, sep_pos: usize) -> bool {
207    let slash_count = line.as_bytes()[..sep_pos]
208        .iter()
209        .rev()
210        .take_while(|&&b| b == b'\\')
211        .count();
212    slash_count % 2 == 1
213}
214
215/// Returns true if a physical line continues on the next line.
216///
217/// Java-style properties only treat an odd number of trailing backslashes as a
218/// continuation marker; an even number represents escaped literal backslashes.
219///
220/// # Parameters
221///
222/// * `line` - Physical properties line after outer whitespace trimming.
223///
224/// # Returns
225///
226/// `true` when the line should be joined with the next physical line.
227#[inline]
228fn has_line_continuation(line: &str) -> bool {
229    count_trailing_backslashes(line) % 2 == 1
230}
231
232/// Counts consecutive trailing backslashes in a string.
233///
234/// # Parameters
235///
236/// * `line` - Source line or key/value segment.
237///
238/// # Returns
239///
240/// Number of trailing `\` bytes.
241#[inline]
242fn count_trailing_backslashes(line: &str) -> usize {
243    line.as_bytes().iter().rev().take_while(|&&b| b == b'\\').count()
244}
245
246/// Processes Java properties escape sequences in a string.
247fn unescape_properties(s: &str) -> String {
248    let mut result = String::with_capacity(s.len());
249    let mut chars = s.chars().peekable();
250
251    while let Some(ch) = chars.next() {
252        if ch == '\\' {
253            let escaped = chars.next().unwrap_or('\\');
254            match escaped {
255                'u' => {
256                    let hex: String = chars.by_ref().take(4).collect();
257                    if hex.len() == 4
258                        && let Ok(code) = u32::from_str_radix(&hex, 16)
259                        && let Some(unicode_char) = decode_unicode_escape(code, &mut chars)
260                    {
261                        result.push(unicode_char);
262                        continue;
263                    }
264                    // If parsing fails, keep original
265                    result.push('\\');
266                    result.push('u');
267                    result.push_str(&hex);
268                }
269                'n' => {
270                    result.push('\n');
271                }
272                't' => {
273                    result.push('\t');
274                }
275                'r' => {
276                    result.push('\r');
277                }
278                'f' => {
279                    result.push('\u{000C}');
280                }
281                '\\' => {
282                    result.push('\\');
283                }
284                '=' | ':' | ' ' | '#' | '!' => {
285                    result.push(escaped);
286                }
287                _ => {
288                    result.push(escaped);
289                }
290            }
291        } else {
292            result.push(ch);
293        }
294    }
295
296    result
297}
298
299/// Decodes a Java properties `\uXXXX` escape, including UTF-16 surrogate pairs.
300fn decode_unicode_escape(code: u32, chars: &mut Peekable<Chars<'_>>) -> Option<char> {
301    if is_high_surrogate(code) {
302        let mut lookahead = chars.clone();
303        if lookahead.next() == Some('\\') && lookahead.next() == Some('u') {
304            let low_hex: String = lookahead.by_ref().take(4).collect();
305            if low_hex.len() == 4
306                && let Ok(low) = u32::from_str_radix(&low_hex, 16)
307                && is_low_surrogate(low)
308            {
309                *chars = lookahead;
310                return decode_surrogate_pair(code, low);
311            }
312        }
313        None
314    } else if is_low_surrogate(code) {
315        None
316    } else {
317        char::from_u32(code)
318    }
319}
320
321/// Returns whether `code` is a UTF-16 high surrogate.
322#[inline]
323fn is_high_surrogate(code: u32) -> bool {
324    (0xD800..=0xDBFF).contains(&code)
325}
326
327/// Returns whether `code` is a UTF-16 low surrogate.
328#[inline]
329fn is_low_surrogate(code: u32) -> bool {
330    (0xDC00..=0xDFFF).contains(&code)
331}
332
333/// Decodes a UTF-16 surrogate pair to a Unicode scalar value.
334fn decode_surrogate_pair(high: u32, low: u32) -> Option<char> {
335    let scalar = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
336    char::from_u32(scalar)
337}
338
339impl ConfigSource for PropertiesConfigSource {
340    fn load(&self, config: &mut Config) -> ConfigResult<()> {
341        load_transactionally(self, config)
342    }
343
344    fn load_into(&self, config: &mut Config) -> ConfigResult<()> {
345        let content = std::fs::read_to_string(&self.path).map_err(|e| {
346            ConfigError::IoError(std::io::Error::new(
347                e.kind(),
348                format!("Failed to read properties file '{}': {}", self.path.display(), e),
349            ))
350        })?;
351
352        for (key, value) in Self::parse_content(&content) {
353            config.set(&key, value)?;
354        }
355
356        Ok(())
357    }
358}