indexbus_platform_config/
config.rs1use crate::errors::{Error, Result};
12
13#[cfg(feature = "std")]
14use crate::kv::{load_env_prefix, load_kv_file, merge_into, ConfigMap};
15
16#[derive(Debug, Clone, Default)]
18pub enum ConfigSource {
19 #[default]
21 Defaults,
22 FilePath(String),
24 EnvPrefix(String),
26}
27
28impl ConfigSource {
29 pub fn parse(input: &str) -> Result<Self> {
40 let input = input.trim();
41
42 if input.eq_ignore_ascii_case("defaults") {
43 return Ok(Self::Defaults);
44 }
45
46 if let Some(rest) = input.strip_prefix("file:") {
47 let path = rest.trim();
48 if path.is_empty() {
49 return Err(Error::msg("file:<path> requires a non-empty path"));
50 }
51 return Ok(Self::FilePath(path.to_string()));
52 }
53
54 if let Some(rest) = input.strip_prefix("env:") {
55 let prefix = rest.trim();
56 if prefix.is_empty() {
57 return Err(Error::msg("env:<prefix> requires a non-empty prefix"));
58 }
59 return Ok(Self::EnvPrefix(prefix.to_string()));
60 }
61
62 Err(Error::msg(format!(
63 "unsupported config source: {input} (expected defaults|file:<path>|env:<prefix>)"
64 )))
65 }
66}
67
68#[derive(Debug, Clone)]
76pub struct AppConfig {
77 pub source: ConfigSource,
79}
80
81impl Default for AppConfig {
82 fn default() -> Self {
83 Self {
84 source: ConfigSource::Defaults,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Default)]
93pub struct ConfigStack {
94 defaults: bool,
95 file_path: Option<String>,
96 env_prefix: Option<String>,
97}
98
99impl ConfigStack {
100 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn with_defaults(mut self) -> Self {
107 self.defaults = true;
108 self
109 }
110
111 pub fn with_file_path(mut self, path: impl Into<String>) -> Self {
113 self.file_path = Some(path.into());
114 self
115 }
116
117 pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
119 self.env_prefix = Some(prefix.into());
120 self
121 }
122
123 pub fn sources(&self) -> Vec<ConfigSource> {
125 let mut sources = Vec::new();
126 if self.defaults {
127 sources.push(ConfigSource::Defaults);
128 }
129 if let Some(path) = &self.file_path {
130 sources.push(ConfigSource::FilePath(path.clone()));
131 }
132 if let Some(prefix) = &self.env_prefix {
133 sources.push(ConfigSource::EnvPrefix(prefix.clone()));
134 }
135 sources
136 }
137
138 #[cfg(feature = "std")]
150 pub fn load_kv_map(&self, defaults: ConfigMap) -> Result<ConfigMap> {
151 let mut out = if self.defaults {
152 defaults
153 } else {
154 ConfigMap::new()
155 };
156
157 if let Some(path) = &self.file_path {
158 let m = load_kv_file(path);
159 let m = m.map_err(|e| Error::msg(format!("file config load failed: {e}")))?;
160 merge_into(&mut out, m);
161 }
162
163 if let Some(prefix) = &self.env_prefix {
164 let m = load_env_prefix(prefix)?;
165 merge_into(&mut out, m);
166 }
167
168 Ok(out)
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[cfg(feature = "std")]
177 use crate::kv::ConfigMap;
178
179 #[cfg(feature = "std")]
180 use std::sync::{Mutex, OnceLock};
181
182 #[test]
183 fn parse_defaults() {
184 assert!(matches!(
185 ConfigSource::parse("defaults").unwrap(),
186 ConfigSource::Defaults
187 ));
188 }
189
190 #[test]
191 fn parse_file_path() {
192 assert!(matches!(
193 ConfigSource::parse("file:./config.toml").unwrap(),
194 ConfigSource::FilePath(p) if p == "./config.toml"
195 ));
196 }
197
198 #[test]
199 fn parse_env_prefix() {
200 assert!(matches!(
201 ConfigSource::parse("env:APP_").unwrap(),
202 ConfigSource::EnvPrefix(p) if p == "APP_"
203 ));
204 }
205
206 #[test]
207 fn parse_rejects_unknown_scheme() {
208 let e = ConfigSource::parse("nope").unwrap_err();
209 assert!(e.to_string().contains("unsupported config source"));
210 }
211
212 #[test]
213 fn parse_rejects_empty_file_path() {
214 let e = ConfigSource::parse("file:").unwrap_err();
215 assert!(e.to_string().contains("requires a non-empty path"));
216 }
217
218 #[test]
219 fn parse_rejects_empty_env_prefix() {
220 let e = ConfigSource::parse("env:").unwrap_err();
221 assert!(e.to_string().contains("requires a non-empty prefix"));
222 }
223
224 #[test]
225 fn stack_precedence_order() {
226 let stack = ConfigStack::new()
227 .with_defaults()
228 .with_file_path("./config.toml")
229 .with_env_prefix("APP_");
230
231 let sources = stack.sources();
232
233 assert!(matches!(sources[0], ConfigSource::Defaults));
234 assert!(matches!(sources[1], ConfigSource::FilePath(_)));
235 assert!(matches!(sources[2], ConfigSource::EnvPrefix(_)));
236 }
237
238 #[cfg(feature = "std")]
239 fn env_lock() -> &'static Mutex<()> {
240 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
241 LOCK.get_or_init(|| Mutex::new(()))
242 }
243
244 #[cfg(feature = "std")]
245 #[test]
246 fn load_kv_map_merges_with_precedence() {
247 let _guard = env_lock().lock().unwrap();
248
249 let defaults: ConfigMap = [("k".to_string(), "d".to_string())].into_iter().collect();
250
251 let tmp = std::env::temp_dir().join(format!(
252 "indexbus_platform_config_{}_{}.env",
253 std::process::id(),
254 std::time::SystemTime::now()
255 .duration_since(std::time::UNIX_EPOCH)
256 .unwrap()
257 .as_nanos()
258 ));
259 std::fs::write(&tmp, "k=f\n").unwrap();
260
261 let prefix = format!("INDEXBUS_TEST_{}_", std::process::id());
262 let env_key = format!("{prefix}k");
263 std::env::set_var(&env_key, "e");
264
265 let stack = ConfigStack::new()
266 .with_defaults()
267 .with_file_path(tmp.to_string_lossy().to_string())
268 .with_env_prefix(&prefix);
269
270 let merged = stack.load_kv_map(defaults).unwrap();
271 assert_eq!(merged.get("k").unwrap(), "e");
272
273 std::env::remove_var(&env_key);
274 let _ = std::fs::remove_file(&tmp);
275 }
276
277 #[cfg(feature = "std")]
278 #[test]
279 fn load_kv_map_errors_when_file_missing() {
280 let defaults: ConfigMap = [("k".to_string(), "d".to_string())].into_iter().collect();
281
282 let missing = std::env::temp_dir().join(format!(
283 "indexbus_platform_config_missing_{}_{}.env",
284 std::process::id(),
285 std::time::SystemTime::now()
286 .duration_since(std::time::UNIX_EPOCH)
287 .unwrap()
288 .as_nanos()
289 ));
290
291 let stack = ConfigStack::new()
292 .with_defaults()
293 .with_file_path(missing.to_string_lossy().to_string());
294
295 let err = stack.load_kv_map(defaults).unwrap_err();
296 assert!(err.to_string().contains("file config load failed"));
297 assert!(err.to_string().contains("failed to read config file"));
298 }
299
300 #[cfg(feature = "std")]
301 #[test]
302 fn load_kv_map_can_skip_defaults() {
303 let defaults: ConfigMap = [("k".to_string(), "d".to_string())].into_iter().collect();
304 let stack = ConfigStack::new();
305 let merged = stack.load_kv_map(defaults).unwrap();
306 assert!(merged.is_empty());
307 }
308}