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