Skip to main content

qubit_config/source/
properties_config_source.rs

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