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