indexbus_platform_config/
kv.rs1use crate::errors::{Error, Result};
13
14use core::fmt;
15use std::collections::BTreeMap;
16use std::path::Path;
17
18pub type ConfigMap = BTreeMap<String, String>;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ParseKvError {
26 pub line: usize,
28 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
38pub 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
78pub 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
95pub 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
121pub 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 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}