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//! - `# comment` and `! comment` lines
19//! - Blank lines (ignored)
20//! - Line continuation with `\` at end of line
21//! - Unicode escape sequences (`\uXXXX`)
22//!
23//! # Author
24//!
25//! Haixing Hu
26
27use std::path::{Path, PathBuf};
28
29use crate::{Config, ConfigError, ConfigResult};
30
31use super::ConfigSource;
32
33/// Configuration source that loads from Java `.properties` format files
34///
35/// # Examples
36///
37/// ```rust,ignore
38/// use qubit_config::source::{PropertiesConfigSource, ConfigSource};
39/// use qubit_config::Config;
40///
41/// let source = PropertiesConfigSource::from_file("config.properties");
42/// let mut config = Config::new();
43/// source.load(&mut config).unwrap();
44/// ```
45///
46/// # Author
47///
48/// Haixing Hu
49#[derive(Debug, Clone)]
50pub struct PropertiesConfigSource {
51 path: PathBuf,
52}
53
54impl PropertiesConfigSource {
55 /// Creates a new `PropertiesConfigSource` from a file path
56 ///
57 /// # Parameters
58 ///
59 /// * `path` - Path to the `.properties` file
60 #[inline]
61 pub fn from_file<P: AsRef<Path>>(path: P) -> Self {
62 Self {
63 path: path.as_ref().to_path_buf(),
64 }
65 }
66
67 /// Parses a `.properties` format string into key-value pairs
68 ///
69 /// # Parameters
70 ///
71 /// * `content` - The content of the `.properties` file
72 ///
73 /// # Returns
74 ///
75 /// Returns a vector of `(key, value)` pairs
76 pub fn parse_content(content: &str) -> Vec<(String, String)> {
77 let mut result = Vec::new();
78 let mut lines = content.lines().peekable();
79
80 while let Some(line) = lines.next() {
81 let trimmed = line.trim();
82
83 // Skip blank lines and comments
84 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('!') {
85 continue;
86 }
87
88 // Handle line continuation
89 let mut full_line = trimmed.to_string();
90 while full_line.ends_with('\\') {
91 full_line.pop(); // remove trailing backslash
92 if let Some(next) = lines.next() {
93 full_line.push_str(next.trim());
94 } else {
95 break;
96 }
97 }
98
99 // Parse key=value or key: value
100 if let Some((key, value)) = parse_key_value(&full_line) {
101 let key = unescape_unicode(key.trim());
102 let value = unescape_unicode(value.trim());
103 result.push((key, value));
104 }
105 }
106
107 result
108 }
109}
110
111/// Parses a single `key=value` or `key: value` line
112fn parse_key_value(line: &str) -> Option<(&str, &str)> {
113 // Find the first '=' or ':' that is not preceded by '\'
114 let chars = line.char_indices();
115 for (i, ch) in chars {
116 if ch == '=' || ch == ':' {
117 // Separator is escaped only if there is an odd number of trailing backslashes.
118 if !is_escaped_separator(line, i) {
119 return Some((&line[..i], &line[i + ch.len_utf8()..]));
120 }
121 }
122 }
123 // No separator found - treat the whole line as a key with empty value
124 if !line.is_empty() {
125 Some((line, ""))
126 } else {
127 None
128 }
129}
130
131/// Returns true if the separator at `sep_pos` is escaped by a preceding odd
132/// number of backslashes.
133///
134/// # Parameters
135///
136/// * `line` - Full properties line being parsed.
137/// * `sep_pos` - Byte index of `=` or `:` in `line`.
138///
139/// # Returns
140///
141/// `true` when the separator is escaped and must not split the key/value.
142#[inline]
143fn is_escaped_separator(line: &str, sep_pos: usize) -> bool {
144 let slash_count = line.as_bytes()[..sep_pos]
145 .iter()
146 .rev()
147 .take_while(|&&b| b == b'\\')
148 .count();
149 slash_count % 2 == 1
150}
151
152/// Processes Unicode escape sequences (`\uXXXX`) in a string
153fn unescape_unicode(s: &str) -> String {
154 let mut result = String::with_capacity(s.len());
155 let mut chars = s.chars().peekable();
156
157 while let Some(ch) = chars.next() {
158 if ch == '\\' {
159 match chars.peek() {
160 Some('u') => {
161 chars.next(); // consume 'u'
162 let hex: String = chars.by_ref().take(4).collect();
163 if hex.len() == 4 {
164 if let Ok(code) = u32::from_str_radix(&hex, 16) {
165 if let Some(unicode_char) = char::from_u32(code) {
166 result.push(unicode_char);
167 continue;
168 }
169 }
170 }
171 // If parsing fails, keep original
172 result.push('\\');
173 result.push('u');
174 result.push_str(&hex);
175 }
176 Some('n') => {
177 chars.next();
178 result.push('\n');
179 }
180 Some('t') => {
181 chars.next();
182 result.push('\t');
183 }
184 Some('r') => {
185 chars.next();
186 result.push('\r');
187 }
188 Some('\\') => {
189 chars.next();
190 result.push('\\');
191 }
192 _ => {
193 result.push(ch);
194 }
195 }
196 } else {
197 result.push(ch);
198 }
199 }
200
201 result
202}
203
204impl ConfigSource for PropertiesConfigSource {
205 fn load(&self, config: &mut Config) -> ConfigResult<()> {
206 let content = std::fs::read_to_string(&self.path).map_err(|e| {
207 ConfigError::IoError(std::io::Error::new(
208 e.kind(),
209 format!(
210 "Failed to read properties file '{}': {}",
211 self.path.display(),
212 e
213 ),
214 ))
215 })?;
216
217 for (key, value) in Self::parse_content(&content) {
218 config.set(&key, value)?;
219 }
220
221 Ok(())
222 }
223}