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