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}