Skip to main content

bare_config/
properties.rs

1//! Properties format support for configuration content.
2//!
3//! This module provides the `PropertiesContent` type for working
4//! with Java Properties configuration files.
5//!
6//! # Features
7//!
8//! - Full CRUD operations (select, insert, update, delete, upsert)
9//! - Sorted output for consistent round-trip serialization
10//! - Support for both `=` and `:` as key-value separators
11//!
12//! # Example
13//!
14//! ```
15//! use bare_config::properties::PropertiesContent;
16//! use bare_config::{ConfigContent, key::Key};
17//! use bare_config::value::Value;
18//! use std::str::FromStr;
19//!
20//! // Parse Properties configuration
21//! let config = PropertiesContent::from_str("database.url=localhost").unwrap();
22//!
23//! // Select a value
24//! let value = config.select(&Key::from_str(".database.url").unwrap()).unwrap();
25//! assert_eq!(value.as_str(), Some("localhost"));
26//!
27//! // Insert a new value
28//! let mut config = PropertiesContent::from_str("").unwrap();
29//! config.insert(&Key::from_str(".server.port").unwrap(), &Value::string("8080")).unwrap();
30//!
31//! // Format as string (sorted output)
32//! let output = config.to_string();
33//! println!("{}", output);
34//! ```
35
36use core::fmt;
37use core::str::FromStr;
38
39use crate::ConfigContent;
40use crate::error::{ConfigError, ConfigResult};
41use crate::key::{Key, KeySegment};
42use crate::value::Value;
43use std::collections::HashMap;
44
45/// Properties configuration content.
46///
47/// This type provides CRUD operations for Properties configuration data.
48#[derive(Debug, Clone)]
49pub struct PropertiesContent {
50    data: HashMap<String, String>,
51}
52
53impl PartialEq for PropertiesContent {
54    fn eq(&self, other: &Self) -> bool {
55        self.data == other.data
56    }
57}
58
59#[allow(dead_code)]
60impl PropertiesContent {
61    /// Creates a new PropertiesContent from a HashMap.
62    pub fn from_map(map: HashMap<String, String>) -> Self {
63        Self { data: map }
64    }
65
66    /// Returns a reference to the underlying HashMap.
67    pub fn as_map(&self) -> &HashMap<String, String> {
68        &self.data
69    }
70
71    /// Converts to the underlying HashMap.
72    pub fn into_map(self) -> HashMap<String, String> {
73        self.data
74    }
75
76    fn parse_key(key: &Key) -> ConfigResult<String> {
77        let segments = key.segments();
78
79        if segments.is_empty() {
80            return Err(ConfigError::InvalidKey("Empty key".to_string()));
81        }
82
83        let parts: Vec<String> = segments
84            .iter()
85            .filter_map(|s| match s {
86                KeySegment::Key(s) => Some(s.clone()),
87                KeySegment::Index(_) => None,
88                KeySegment::Attribute(_) => None,
89            })
90            .collect();
91
92        if parts.is_empty() {
93            return Err(ConfigError::InvalidKey("Invalid key format".to_string()));
94        }
95
96        Ok(parts.join("."))
97    }
98
99    fn get_value_at_key(&self, key: &Key) -> ConfigResult<String> {
100        let key_str = Self::parse_key(key)?;
101
102        self.data
103            .get(&key_str)
104            .cloned()
105            .ok_or_else(|| ConfigError::KeyNotFound(key.to_key_string()))
106    }
107
108    fn contains_key_at(&self, key: &Key) -> bool {
109        if let Ok(key_str) = Self::parse_key(key) {
110            self.data.contains_key(&key_str)
111        } else {
112            false
113        }
114    }
115
116    fn keys_at(&self) -> Vec<Key> {
117        self.data
118            .keys()
119            .filter_map(|k| Key::from_str(&format!(".{}", k)).ok())
120            .collect()
121    }
122
123    fn len_at(&self) -> usize {
124        self.data.len()
125    }
126
127    fn delete_at(&mut self, key: &Key) -> ConfigResult<()> {
128        let key_str = Self::parse_key(key)?;
129
130        if self.data.remove(&key_str).is_some() {
131            Ok(())
132        } else {
133            Err(ConfigError::KeyNotFound(key.to_key_string()))
134        }
135    }
136
137    fn upsert_at(&mut self, key: &Key, value: &Value) -> ConfigResult<()> {
138        let key_str = Self::parse_key(key)?;
139        let value_str = value.to_string_repr();
140
141        drop(self.data.insert(key_str, value_str));
142        Ok(())
143    }
144}
145
146impl ConfigContent for PropertiesContent {
147    fn select(&self, key: &Key) -> ConfigResult<Value> {
148        let value = self.get_value_at_key(key)?;
149        Ok(Value::String(value))
150    }
151
152    fn insert(&mut self, key: &Key, value: &Value) -> ConfigResult<()> {
153        if self.contains_key_at(key) {
154            return Err(ConfigError::KeyAlreadyExists(key.to_key_string()));
155        }
156        self.upsert_at(key, value)
157    }
158
159    fn update(&mut self, key: &Key, value: &Value) -> ConfigResult<()> {
160        if !self.contains_key_at(key) {
161            return Err(ConfigError::KeyDoesNotExist(key.to_key_string()));
162        }
163        self.upsert_at(key, value)
164    }
165
166    fn delete(&mut self, key: &Key) -> ConfigResult<()> {
167        self.delete_at(key)
168    }
169
170    fn upsert(&mut self, key: &Key, value: &Value) -> ConfigResult<()> {
171        self.upsert_at(key, value)
172    }
173
174    fn keys(&self) -> Vec<Key> {
175        self.keys_at()
176    }
177}
178
179impl FromStr for PropertiesContent {
180    type Err = ConfigError;
181
182    fn from_str(s: &str) -> Result<Self, Self::Err> {
183        // Pre-allocate: estimate based on non-empty, non-comment lines
184        let estimated = s
185            .lines()
186            .map(|l| l.trim())
187            .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('!'))
188            .count();
189        let mut map = HashMap::with_capacity(estimated);
190
191        // Process lines with line continuation support
192        let mut current_key: Option<String> = None;
193        let mut current_value = String::new();
194
195        // Pre-process: join lines with backslash-newline continuation
196        let processed: String = {
197            let mut result = String::new();
198            let mut previous_backslash = false;
199
200            for line in s.lines() {
201                if previous_backslash {
202                    // Continuation - append line (without the leading whitespace)
203                    result.push_str(line);
204                    previous_backslash = false;
205                } else {
206                    // New line
207                    result.push('\n');
208                    result.push_str(line);
209                }
210
211                // Check if this line ends with backslash
212                let trimmed = line.trim_end();
213                if trimmed.ends_with('\\') {
214                    previous_backslash = true;
215                    // Remove the trailing backslash
216                    let _ = result.pop();
217                }
218            }
219            result
220        };
221
222        for line in processed.lines() {
223            let line = line.trim();
224
225            if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
226                continue;
227            }
228
229            // Flush previous key-value if any
230            if let Some(key) = current_key.take() {
231                if !current_value.is_empty() || !map.contains_key(&key) {
232                    drop(map.insert(key, current_value.clone()));
233                }
234                current_value.clear();
235            }
236
237            if let Some(pos) = line.find('=') {
238                let key = line[..pos].trim().to_string();
239                let value = line[pos + 1..].trim().to_string();
240                drop(map.insert(key, value));
241            } else if let Some(pos) = line.find(':') {
242                let key = line[..pos].trim().to_string();
243                let value = line[pos + 1..].trim().to_string();
244                drop(map.insert(key, value));
245            }
246        }
247
248        // Handle last key-value pair if exists
249        if let Some(key) = current_key {
250            if !current_value.is_empty() || !map.contains_key(&key) {
251                drop(map.insert(key, current_value));
252            }
253        }
254
255        Ok(Self { data: map })
256    }
257}
258
259impl fmt::Display for PropertiesContent {
260    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261        let mut keys: Vec<_> = self.data.keys().collect();
262        keys.sort();
263
264        for key in keys {
265            if let Some(value) = self.data.get(key) {
266                writeln!(f, "{}={}", key, value)?;
267            }
268        }
269
270        Ok(())
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_properties_from_str() {
280        let content = PropertiesContent::from_str("key=value").expect("test should succeed");
281        assert!(
282            content
283                .select(&Key::from_str(".key").expect("test should succeed"))
284                .is_ok()
285        );
286    }
287
288    #[test]
289    fn test_properties_select() {
290        let content = PropertiesContent::from_str("name=test").expect("test should succeed");
291        let value = content
292            .select(&Key::from_str(".name").expect("test should succeed"))
293            .expect("test should succeed");
294        assert_eq!(value.as_str(), Some("test"));
295    }
296
297    #[test]
298    fn test_properties_nested() {
299        let content =
300            PropertiesContent::from_str("database.url=localhost").expect("test should succeed");
301        let value = content
302            .select(&Key::from_str(".database.url").expect("test should succeed"))
303            .expect("test should succeed");
304        assert_eq!(value.as_str(), Some("localhost"));
305    }
306
307    #[test]
308    fn test_properties_insert() {
309        let mut content = PropertiesContent::from_str("").expect("test should succeed");
310        content
311            .insert(
312                &Key::from_str(".name").expect("test should succeed"),
313                &Value::string("test"),
314            )
315            .expect("test should succeed");
316        let value = content
317            .select(&Key::from_str(".name").expect("test should succeed"))
318            .expect("test should succeed");
319        assert_eq!(value.as_str(), Some("test"));
320    }
321
322    #[test]
323    fn test_properties_update() {
324        let mut content = PropertiesContent::from_str("name=old").expect("test should succeed");
325        content
326            .update(
327                &Key::from_str(".name").expect("test should succeed"),
328                &Value::string("new"),
329            )
330            .expect("test should succeed");
331        let value = content
332            .select(&Key::from_str(".name").expect("test should succeed"))
333            .expect("test should succeed");
334        assert_eq!(value.as_str(), Some("new"));
335    }
336
337    #[test]
338    fn test_properties_delete() {
339        let mut content = PropertiesContent::from_str("name=test").expect("test should succeed");
340        content
341            .delete(&Key::from_str(".name").expect("test should succeed"))
342            .expect("test should succeed");
343        assert!(
344            content
345                .select(&Key::from_str(".name").expect("test should succeed"))
346                .is_err()
347        );
348    }
349
350    #[test]
351    fn test_properties_upsert() {
352        let mut content = PropertiesContent::from_str("").expect("test should succeed");
353        content
354            .upsert(
355                &Key::from_str(".name").expect("test should succeed"),
356                &Value::string("test"),
357            )
358            .expect("test should succeed");
359        let value = content
360            .select(&Key::from_str(".name").expect("test should succeed"))
361            .expect("test should succeed");
362        assert_eq!(value.as_str(), Some("test"));
363    }
364
365    #[test]
366    fn test_properties_display_is_stable_and_sorted() {
367        let content = PropertiesContent::from_str("z=1\na=2").expect("test should succeed");
368        let first = content.to_string();
369        let second = content.to_string();
370        assert_eq!(first, second);
371        assert_eq!(first.lines().next(), Some("a=2"));
372    }
373
374    #[test]
375    fn test_properties_comment() {
376        let content = PropertiesContent::from_str("# This is a comment\nkey=value")
377            .expect("test should succeed");
378        assert!(
379            content
380                .select(&Key::from_str(".key").expect("test should succeed"))
381                .is_ok()
382        );
383    }
384
385    #[test]
386    fn test_properties_whitespace() {
387        let content = PropertiesContent::from_str("key = value").expect("test should succeed");
388        let value = content
389            .select(&Key::from_str(".key").expect("test should succeed"))
390            .expect("test should succeed");
391        assert_eq!(value.as_str(), Some("value"));
392    }
393
394    #[test]
395    fn test_properties_continuation() {
396        let content =
397            PropertiesContent::from_str("key=value1\\\nvalue2").expect("test should succeed");
398        let value = content
399            .select(&Key::from_str(".key").expect("test should succeed"))
400            .expect("test should succeed");
401        assert!(
402            value
403                .as_str()
404                .expect("test should succeed")
405                .contains("value1")
406        );
407    }
408
409    #[test]
410    fn test_properties_colon_separator() {
411        let content = PropertiesContent::from_str("key:value").expect("test should succeed");
412        let value = content
413            .select(&Key::from_str(".key").expect("test should succeed"))
414            .expect("test should succeed");
415        assert_eq!(value.as_str(), Some("value"));
416    }
417
418    #[test]
419    fn test_properties_update_existing() {
420        let mut content = PropertiesContent::from_str("key=old").expect("test should succeed");
421        content
422            .update(
423                &Key::from_str(".key").expect("test should succeed"),
424                &Value::string("new"),
425            )
426            .expect("test should succeed");
427        let value = content
428            .select(&Key::from_str(".key").expect("test should succeed"))
429            .expect("test should succeed");
430        assert_eq!(value.as_str(), Some("new"));
431    }
432
433    #[test]
434    fn test_properties_delete_existing() {
435        let mut content = PropertiesContent::from_str("key=value").expect("test should succeed");
436        content
437            .delete(&Key::from_str(".key").expect("test should succeed"))
438            .expect("test should succeed");
439        assert!(
440            content
441                .select(&Key::from_str(".key").expect("test should succeed"))
442                .is_err()
443        );
444    }
445
446    #[test]
447    fn test_properties_upsert_new() {
448        let mut content = PropertiesContent::from_str("").expect("test should succeed");
449        content
450            .upsert(
451                &Key::from_str(".key").expect("test should succeed"),
452                &Value::string("value"),
453            )
454            .expect("test should succeed");
455        let value = content
456            .select(&Key::from_str(".key").expect("test should succeed"))
457            .expect("test should succeed");
458        assert_eq!(value.as_str(), Some("value"));
459    }
460
461    #[test]
462    fn test_properties_special_characters() {
463        let content =
464            PropertiesContent::from_str(r#"path=C\:\\Windows"#).expect("test should succeed");
465        let value = content
466            .select(&Key::from_str(".path").expect("test should succeed"))
467            .expect("test should succeed");
468        assert!(value.as_str().is_some());
469    }
470}