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}