Skip to main content

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}