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}