Skip to main content

commons/
config.rs

1//! Configuration management utilities.
2//!
3//! This module provides a flexible configuration system that supports
4//! loading from files, environment variables, and defaults.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! use commons::config::Config;
10//! use serde::Deserialize;
11//!
12//! #[derive(Debug, Deserialize)]
13//! struct AppConfig {
14//!     name: String,
15//!     port: u16,
16//! }
17//!
18//! let config = Config::from_file("config.toml").unwrap();
19//! let app_config: AppConfig = config.parse().unwrap();
20//! ```
21
22use serde::de::DeserializeOwned;
23use std::path::Path;
24
25/// Configuration loading and management.
26///
27/// The TOML content is parsed once on creation and cached internally,
28/// so repeated calls to [`get`](Config::get) and [`has_key`](Config::has_key)
29/// are cheap lookups rather than full re-parses.
30#[derive(Debug, Clone)]
31pub struct Config {
32    /// Raw TOML content.
33    content: String,
34    /// Pre-parsed TOML value tree for efficient lookups.
35    parsed: toml::Value,
36}
37
38impl Config {
39    /// Create a new configuration from TOML string content.
40    ///
41    /// # Arguments
42    ///
43    /// * `content` - TOML formatted configuration string
44    ///
45    /// # Example
46    ///
47    /// ```rust
48    /// use commons::config::Config;
49    ///
50    /// let config = Config::new(r#"
51    ///     name = "app"
52    ///     port = 8080
53    /// "#);
54    /// ```
55    #[must_use]
56    pub fn new(content: &str) -> Self {
57        let parsed =
58            toml::from_str(content).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()));
59        Self {
60            content: content.to_string(),
61            parsed,
62        }
63    }
64
65    /// Load configuration from a TOML file.
66    ///
67    /// # Arguments
68    ///
69    /// * `path` - Path to the TOML configuration file
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the file cannot be read.
74    ///
75    /// # Example
76    ///
77    /// ```rust,no_run
78    /// use commons::config::Config;
79    ///
80    /// let config = Config::from_file("config.toml").unwrap();
81    /// ```
82    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
83        let content = std::fs::read_to_string(path.as_ref())
84            .map_err(|e| ConfigError::FileRead(format!("{}: {}", path.as_ref().display(), e)))?;
85        let parsed =
86            toml::from_str(&content).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new()));
87        Ok(Self { content, parsed })
88    }
89
90    /// Parse the configuration into a typed struct.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the configuration cannot be parsed into the target type.
95    ///
96    /// # Example
97    ///
98    /// ```rust
99    /// use commons::config::Config;
100    /// use serde::Deserialize;
101    ///
102    /// #[derive(Debug, Deserialize)]
103    /// struct MyConfig {
104    ///     name: String,
105    /// }
106    ///
107    /// let config = Config::new("name = \"test\"");
108    /// let parsed: MyConfig = config.parse().unwrap();
109    /// assert_eq!(parsed.name, "test");
110    /// ```
111    pub fn parse<T: DeserializeOwned>(&self) -> Result<T, ConfigError> {
112        toml::from_str(&self.content).map_err(|e| ConfigError::Parse(e.to_string()))
113    }
114
115    /// Get a value from the configuration by key path.
116    ///
117    /// Supports nested keys using dot notation: "section.key"
118    ///
119    /// # Example
120    ///
121    /// ```rust
122    /// use commons::config::Config;
123    ///
124    /// let config = Config::new(r#"
125    ///     [server]
126    ///     port = 8080
127    /// "#);
128    /// let port: Option<i64> = config.get("server.port");
129    /// assert_eq!(port, Some(8080));
130    /// ```
131    #[must_use]
132    pub fn get<T: FromTomlValue>(&self, key: &str) -> Option<T> {
133        let mut current = &self.parsed;
134
135        for part in key.split('.') {
136            current = current.get(part)?;
137        }
138
139        T::from_toml_value(current)
140    }
141
142    /// Check if a key exists in the configuration.
143    #[must_use]
144    pub fn has_key(&self, key: &str) -> bool {
145        self.get::<toml::Value>(key).is_some()
146    }
147
148    /// Get the raw TOML content.
149    #[must_use]
150    pub fn raw(&self) -> &str {
151        &self.content
152    }
153}
154
155/// Error type for configuration operations.
156#[derive(Debug, thiserror::Error)]
157pub enum ConfigError {
158    /// Failed to read configuration file.
159    #[error("Failed to read config file: {0}")]
160    FileRead(String),
161
162    /// Failed to parse configuration.
163    #[error("Failed to parse config: {0}")]
164    Parse(String),
165
166    /// Missing required configuration key.
167    #[error("Missing required config key: {0}")]
168    MissingKey(String),
169}
170
171/// Trait for converting TOML values to Rust types.
172pub trait FromTomlValue: Sized {
173    /// Convert from a TOML value.
174    fn from_toml_value(value: &toml::Value) -> Option<Self>;
175}
176
177impl FromTomlValue for String {
178    fn from_toml_value(value: &toml::Value) -> Option<Self> {
179        value.as_str().map(Self::from)
180    }
181}
182
183impl FromTomlValue for i64 {
184    fn from_toml_value(value: &toml::Value) -> Option<Self> {
185        value.as_integer()
186    }
187}
188
189impl FromTomlValue for f64 {
190    fn from_toml_value(value: &toml::Value) -> Option<Self> {
191        value.as_float()
192    }
193}
194
195impl FromTomlValue for bool {
196    fn from_toml_value(value: &toml::Value) -> Option<Self> {
197        value.as_bool()
198    }
199}
200
201impl FromTomlValue for toml::Value {
202    fn from_toml_value(value: &toml::Value) -> Option<Self> {
203        Some(value.clone())
204    }
205}
206
207impl<T: FromTomlValue> FromTomlValue for Vec<T> {
208    fn from_toml_value(value: &toml::Value) -> Option<Self> {
209        value
210            .as_array()
211            .map(|arr| arr.iter().filter_map(T::from_toml_value).collect())
212    }
213}
214
215/// Builder for creating configurations programmatically.
216#[derive(Debug, Default)]
217pub struct ConfigBuilder {
218    values: toml::map::Map<String, toml::Value>,
219}
220
221impl ConfigBuilder {
222    /// Create a new configuration builder.
223    #[must_use]
224    pub fn new() -> Self {
225        Self::default()
226    }
227
228    /// Set a string value.
229    #[must_use]
230    pub fn set_string(mut self, key: &str, value: &str) -> Self {
231        self.values
232            .insert(key.to_string(), toml::Value::String(value.to_string()));
233        self
234    }
235
236    /// Set an integer value.
237    #[must_use]
238    pub fn set_int(mut self, key: &str, value: i64) -> Self {
239        self.values
240            .insert(key.to_string(), toml::Value::Integer(value));
241        self
242    }
243
244    /// Set a boolean value.
245    #[must_use]
246    pub fn set_bool(mut self, key: &str, value: bool) -> Self {
247        self.values
248            .insert(key.to_string(), toml::Value::Boolean(value));
249        self
250    }
251
252    /// Build the configuration.
253    #[must_use]
254    pub fn build(self) -> Config {
255        let parsed = toml::Value::Table(self.values);
256        let content = toml::to_string_pretty(&parsed).unwrap_or_default();
257        Config { content, parsed }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde::Deserialize;
265
266    #[derive(Debug, Deserialize, PartialEq)]
267    struct TestConfig {
268        name: String,
269        port: u16,
270    }
271
272    #[test]
273    fn test_parse_config() {
274        let config = Config::new(
275            r#"
276            name = "test"
277            port = 8080
278        "#,
279        );
280        let parsed: TestConfig = config.parse().unwrap();
281        assert_eq!(parsed.name, "test");
282        assert_eq!(parsed.port, 8080);
283    }
284
285    #[test]
286    fn test_get_nested_key() {
287        let config = Config::new(
288            r#"
289            [server]
290            host = "localhost"
291            port = 3000
292        "#,
293        );
294        assert_eq!(
295            config.get::<String>("server.host"),
296            Some("localhost".into())
297        );
298        assert_eq!(config.get::<i64>("server.port"), Some(3000));
299    }
300
301    #[test]
302    fn test_get_array() {
303        let config = Config::new(
304            r#"
305            allowed_hosts = ["localhost", "127.0.0.1"]
306            ports = [8080, 8443]
307        "#,
308        );
309        assert_eq!(
310            config.get::<Vec<String>>("allowed_hosts"),
311            Some(vec!["localhost".to_string(), "127.0.0.1".to_string()])
312        );
313        assert_eq!(config.get::<Vec<i64>>("ports"), Some(vec![8080, 8443]));
314        assert_eq!(config.get::<Vec<String>>("missing"), None);
315    }
316
317    #[test]
318    fn test_config_builder() {
319        let config = ConfigBuilder::new()
320            .set_string("name", "app")
321            .set_int("port", 8080)
322            .set_bool("debug", true)
323            .build();
324
325        assert_eq!(config.get::<String>("name"), Some("app".into()));
326        assert_eq!(config.get::<i64>("port"), Some(8080));
327        assert_eq!(config.get::<bool>("debug"), Some(true));
328    }
329}