indexbus_platform_config/
config.rs

1//! High-level configuration conventions.
2//!
3//! This module describes a minimal, explicit layering model suitable for many services:
4//! defaults → file → environment.
5//!
6//! **Contract**
7//! - Sources are applied from low → high precedence.
8//! - On key conflict, the later (higher-precedence) source wins.
9//! - Validation is out of scope; callers should validate merged configuration before use.
10
11use crate::errors::{Error, Result};
12
13#[cfg(feature = "std")]
14use crate::kv::{load_env_prefix, load_kv_file, merge_into, ConfigMap};
15
16/// Where configuration should be loaded from.
17#[derive(Debug, Clone, Default)]
18pub enum ConfigSource {
19    /// Use sane defaults only.
20    #[default]
21    Defaults,
22    /// Load from a single file path.
23    FilePath(String),
24    /// Load from environment variables (prefix-based).
25    EnvPrefix(String),
26}
27
28impl ConfigSource {
29    /// Parse a simple textual config source spec.
30    ///
31    /// Supported:
32    /// - `defaults`
33    /// - `file:<path>`
34    /// - `env:<prefix>`
35    ///
36    /// # Errors
37    /// Returns an error when the scheme is unknown or when `file:` / `env:` is provided with an
38    /// empty value.
39    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/// Minimal application configuration placeholder.
69///
70/// This type exists to make the intent of the crate explicit.
71///
72/// In production, applications typically define their own configuration struct (often `serde`)
73/// and use helpers in this crate for loading/merging patterns before validating into the final
74/// schema.
75#[derive(Debug, Clone)]
76pub struct AppConfig {
77    /// Where configuration should be loaded from.
78    pub source: ConfigSource,
79}
80
81impl Default for AppConfig {
82    fn default() -> Self {
83        Self {
84            source: ConfigSource::Defaults,
85        }
86    }
87}
88
89/// A minimal, explicit config layering plan.
90///
91/// Precedence (low → high): Defaults → FilePath → EnvPrefix.
92#[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    /// Create an empty stack (no sources enabled).
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Enable defaults as the lowest-precedence source.
106    pub fn with_defaults(mut self) -> Self {
107        self.defaults = true;
108        self
109    }
110
111    /// Add a file path source.
112    pub fn with_file_path(mut self, path: impl Into<String>) -> Self {
113        self.file_path = Some(path.into());
114        self
115    }
116
117    /// Add an environment variable prefix source.
118    pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
119        self.env_prefix = Some(prefix.into());
120        self
121    }
122
123    /// Return sources in application order (low → high precedence).
124    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    /// Load a merged key/value configuration map.
139    ///
140    /// Precedence (low → high): Defaults → FilePath → EnvPrefix.
141    ///
142    /// Notes:
143    /// - Defaults are provided by the caller (so applications own their schema).
144    /// - File format is simple `KEY=VALUE` lines; comments start with `#`.
145    ///
146    /// # Errors
147    /// Returns an error when reading or parsing the configured file fails, or when environment
148    /// loading fails.
149    #[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}