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
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}