indexbus_platform_config/
kv.rs

1//! Simple key/value configuration loader.
2//!
3//! This module provides a minimal, dependency-light configuration path:
4//! - Load `KEY=VALUE` style files.
5//! - Load environment variables by prefix.
6//! - Merge sources with explicit precedence (later sources override earlier).
7//!
8//! **Non-goals**
9//! - No quoting/unescaping rules (values are treated as opaque strings).
10//! - No interpolation or shell expansion.
11
12use crate::errors::{Error, Result};
13
14use core::fmt;
15use std::collections::BTreeMap;
16use std::path::Path;
17
18/// A minimal, flattened configuration map.
19///
20/// Keys are user-defined. If you want hierarchical keys, a common convention is `section.key`.
21pub type ConfigMap = BTreeMap<String, String>;
22
23/// Error returned by [`parse_kv_lines`] when a line cannot be parsed.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ParseKvError {
26    /// 1-based line number where the parse error occurred.
27    pub line: usize,
28    /// Human-readable error message.
29    pub message: String,
30}
31
32impl fmt::Display for ParseKvError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(f, "line {}: {}", self.line, self.message)
35    }
36}
37
38/// Parse `KEY=VALUE` lines.
39///
40/// Rules:
41/// - Empty lines are ignored.
42/// - Lines starting with `#` are comments.
43/// - Keys are trimmed; values preserve surrounding whitespace after the `=`.
44/// - Later occurrences of the same key override earlier ones.
45///
46/// # Errors
47/// Returns [`ParseKvError`] when an input line is not a comment/blank line and does not contain
48/// exactly one `=` separator, or when the key is empty after trimming.
49pub fn parse_kv_lines(input: &str) -> core::result::Result<ConfigMap, ParseKvError> {
50    let mut out = ConfigMap::new();
51
52    for (idx, raw) in input.lines().enumerate() {
53        let line_no = idx + 1;
54        let line = raw.trim();
55        if line.is_empty() || line.starts_with('#') {
56            continue;
57        }
58
59        let (k, v) = line.split_once('=').ok_or_else(|| ParseKvError {
60            line: line_no,
61            message: "expected KEY=VALUE".to_string(),
62        })?;
63
64        let key = k.trim();
65        if key.is_empty() {
66            return Err(ParseKvError {
67                line: line_no,
68                message: "key must be non-empty".to_string(),
69            });
70        }
71
72        out.insert(key.to_string(), v.to_string());
73    }
74
75    Ok(out)
76}
77
78/// Load a `KEY=VALUE` file from disk.
79///
80/// # Errors
81/// Returns an error if the file cannot be read, or if its contents fail [`parse_kv_lines`].
82pub fn load_kv_file(path: impl AsRef<Path>) -> Result<ConfigMap> {
83    let path = path.as_ref();
84    let raw = std::fs::read_to_string(path).map_err(|e| {
85        Error::msg(format!(
86            "failed to read config file {}: {e}",
87            path.display()
88        ))
89    })?;
90
91    parse_kv_lines(&raw)
92        .map_err(|e| Error::msg(format!("invalid config file {}: {e}", path.display())))
93}
94
95/// Load environment variables starting with `prefix`.
96///
97/// The returned keys have the prefix stripped.
98///
99/// Key normalization:
100/// - `__` becomes `.` (common for nested keys)
101///
102/// # Errors
103/// Returns an error if `prefix` is empty/whitespace.
104pub fn load_env_prefix(prefix: &str) -> Result<ConfigMap> {
105    if prefix.trim().is_empty() {
106        return Err(Error::msg("env prefix must be non-empty"));
107    }
108
109    let mut out = ConfigMap::new();
110    for (k, v) in std::env::vars() {
111        if let Some(rest) = k.strip_prefix(prefix) {
112            let key = rest.replace("__", ".");
113            if !key.is_empty() {
114                out.insert(key, v);
115            }
116        }
117    }
118    Ok(out)
119}
120
121/// Merge `overlay` into `base` (overlay wins on key conflict).
122pub fn merge_into(base: &mut ConfigMap, overlay: ConfigMap) {
123    for (k, v) in overlay {
124        base.insert(k, v);
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn parse_kv_ignores_comments_and_blank_lines() {
134        let m = parse_kv_lines("\n# c\na=1\n\n b = two \n").unwrap();
135        assert_eq!(m.get("a").unwrap(), "1");
136        assert_eq!(m.get("b").unwrap(), " two");
137    }
138
139    #[test]
140    fn parse_kv_rejects_missing_equals() {
141        let e = parse_kv_lines("nope").unwrap_err();
142        assert_eq!(e.line, 1);
143    }
144
145    #[test]
146    fn parse_kv_rejects_empty_key() {
147        let e = parse_kv_lines("=x").unwrap_err();
148        assert_eq!(e.line, 1);
149        assert!(e.message.contains("key must be non-empty"));
150    }
151
152    #[test]
153    fn env_prefix_strips_prefix_and_normalizes_double_underscore() {
154        // Avoid inter-test env leakage by using a unique prefix.
155        let prefix = format!("INDEXBUS_PLATFORM_CONFIG_TEST_{}__", std::process::id());
156        let key_raw = format!("{prefix}a__b");
157        std::env::set_var(&key_raw, "1");
158
159        let m = load_env_prefix(&prefix).unwrap();
160        assert_eq!(m.get("a.b").unwrap(), "1");
161
162        std::env::remove_var(&key_raw);
163    }
164
165    #[test]
166    fn load_kv_file_reports_invalid_line() {
167        let tmp = std::env::temp_dir().join(format!(
168            "indexbus_platform_config_bad_{}_{}.env",
169            std::process::id(),
170            std::time::SystemTime::now()
171                .duration_since(std::time::UNIX_EPOCH)
172                .unwrap()
173                .as_nanos()
174        ));
175
176        std::fs::write(&tmp, "nope\n").unwrap();
177        let err = load_kv_file(&tmp).unwrap_err();
178        assert!(err.to_string().contains("invalid config file"));
179        assert!(err.to_string().contains("line 1"));
180
181        let _ = std::fs::remove_file(&tmp);
182    }
183
184    #[test]
185    fn merge_overlay_wins() {
186        let mut base = ConfigMap::new();
187        base.insert("k".to_string(), "1".to_string());
188
189        let mut overlay = ConfigMap::new();
190        overlay.insert("k".to_string(), "2".to_string());
191        overlay.insert("x".to_string(), "y".to_string());
192
193        merge_into(&mut base, overlay);
194        assert_eq!(base.get("k").unwrap(), "2");
195        assert_eq!(base.get("x").unwrap(), "y");
196    }
197}