Skip to main content

faucet_core/
config.rs

1//! Configuration loading utilities.
2//!
3//! Helpers for loading any `Deserialize`-able config struct from JSON files
4//! or environment variables.
5//!
6//! # JSON file
7//!
8//! ```rust,no_run
9//! use faucet_core::config::load_json;
10//! # #[derive(serde::Deserialize)] struct MyConfig { url: String }
11//! let config: MyConfig = load_json("config.json").unwrap();
12//! ```
13//!
14//! # Environment variables
15//!
16//! ```rust,no_run
17//! use faucet_core::config::load_env;
18//! # #[derive(serde::Deserialize)] struct MyConfig { url: String }
19//! // Reads MYAPP_URL, MYAPP_BATCH_SIZE, etc.
20//! let config: MyConfig = load_env("MYAPP").unwrap();
21//! ```
22//!
23//! # `.env` file + environment variables
24//!
25//! ```rust,no_run
26//! use faucet_core::config::load_env_file;
27//! # #[derive(serde::Deserialize)] struct MyConfig { url: String }
28//! // Loads .env file, then reads MYAPP_URL, MYAPP_BATCH_SIZE, etc.
29//! let config: MyConfig = load_env_file(".env", "MYAPP").unwrap();
30//! ```
31
32use crate::error::FaucetError;
33use serde::de::DeserializeOwned;
34use std::path::Path;
35
36/// Load a config struct from a JSON file.
37///
38/// The file contents are read and deserialized into `T`.
39pub fn load_json<T: DeserializeOwned>(path: impl AsRef<Path>) -> Result<T, FaucetError> {
40    let path = path.as_ref();
41    let contents = std::fs::read_to_string(path).map_err(|e| {
42        FaucetError::Config(format!(
43            "failed to read config file '{}': {e}",
44            path.display()
45        ))
46    })?;
47    serde_json::from_str(&contents).map_err(|e| {
48        FaucetError::Config(format!(
49            "failed to parse JSON config from '{}': {e}",
50            path.display()
51        ))
52    })
53}
54
55/// Load a config struct from environment variables with a prefix.
56///
57/// Environment variable names are formed by uppercasing the field name
58/// and prepending the prefix with an underscore separator.
59/// For example, with prefix `"BQ"` and a field `project_id`, the env
60/// var `BQ_PROJECT_ID` is read.
61///
62/// Nested structs and enums are supported via `envy`'s deserialization.
63pub fn load_env<T: DeserializeOwned>(prefix: &str) -> Result<T, FaucetError> {
64    envy::prefixed(format!("{prefix}_"))
65        .from_env()
66        .map_err(|e| FaucetError::Config(format!("failed to load config from env vars: {e}")))
67}
68
69/// Load a `.env` file into the process environment, then deserialize
70/// a config struct from environment variables with a prefix.
71///
72/// This combines `dotenvy` (for `.env` file loading) with `envy`
73/// (for struct deserialization from env vars).
74///
75/// The `.env` file is loaded first, setting any variables that aren't
76/// already set in the environment. Then [`load_env`] is called.
77pub fn load_env_file<T: DeserializeOwned>(
78    env_path: impl AsRef<Path>,
79    prefix: &str,
80) -> Result<T, FaucetError> {
81    let env_path = env_path.as_ref();
82    dotenvy::from_path(env_path).map_err(|e| {
83        FaucetError::Config(format!(
84            "failed to load .env file '{}': {e}",
85            env_path.display()
86        ))
87    })?;
88    load_env(prefix)
89}
90
91/// Serde helper module for serializing `Duration` as seconds (u64).
92///
93/// Use with `#[serde(with = "faucet_core::config::duration_secs")]` on
94/// `std::time::Duration` fields.
95pub mod duration_secs {
96    use serde::{Deserialize, Deserializer, Serializer};
97    use std::time::Duration;
98
99    pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
100        serializer.serialize_u64(duration.as_secs())
101    }
102
103    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
104        let secs = u64::deserialize(deserializer)?;
105        Ok(Duration::from_secs(secs))
106    }
107}
108
109/// Serde helper for optional Duration fields.
110pub mod duration_secs_option {
111    use serde::{Deserialize, Deserializer, Serializer};
112    use std::time::Duration;
113
114    pub fn serialize<S: Serializer>(
115        duration: &Option<Duration>,
116        serializer: S,
117    ) -> Result<S::Ok, S::Error> {
118        match duration {
119            Some(d) => serializer.serialize_some(&d.as_secs()),
120            None => serializer.serialize_none(),
121        }
122    }
123
124    pub fn deserialize<'de, D: Deserializer<'de>>(
125        deserializer: D,
126    ) -> Result<Option<Duration>, D::Error> {
127        let opt = Option::<u64>::deserialize(deserializer)?;
128        Ok(opt.map(Duration::from_secs))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use serde::Deserialize;
136    use std::io::Write;
137
138    #[derive(Debug, Deserialize, PartialEq)]
139    struct TestConfig {
140        url: String,
141        #[serde(default)]
142        batch_size: Option<usize>,
143    }
144
145    #[test]
146    fn load_json_works() {
147        let dir = std::env::temp_dir();
148        let path = dir.join("faucet_test_config.json");
149        let mut f = std::fs::File::create(&path).unwrap();
150        write!(f, r#"{{"url": "https://example.com", "batch_size": 100}}"#).unwrap();
151
152        let config: TestConfig = load_json(&path).unwrap();
153        assert_eq!(config.url, "https://example.com");
154        assert_eq!(config.batch_size, Some(100));
155
156        std::fs::remove_file(&path).ok();
157    }
158
159    #[test]
160    fn load_json_missing_file() {
161        let result = load_json::<TestConfig>("/nonexistent/path.json");
162        assert!(result.is_err());
163        assert!(result.unwrap_err().to_string().contains("failed to read"));
164    }
165
166    #[test]
167    fn load_json_invalid_json() {
168        let dir = std::env::temp_dir();
169        let path = dir.join("faucet_test_bad.json");
170        std::fs::write(&path, "not json").unwrap();
171
172        let result = load_json::<TestConfig>(&path);
173        assert!(result.is_err());
174        assert!(result.unwrap_err().to_string().contains("failed to parse"));
175
176        std::fs::remove_file(&path).ok();
177    }
178
179    #[test]
180    fn load_json_with_defaults() {
181        let dir = std::env::temp_dir();
182        let path = dir.join("faucet_test_defaults.json");
183        std::fs::write(&path, r#"{"url": "https://example.com"}"#).unwrap();
184
185        let config: TestConfig = load_json(&path).unwrap();
186        assert_eq!(config.url, "https://example.com");
187        assert_eq!(config.batch_size, None);
188
189        std::fs::remove_file(&path).ok();
190    }
191
192    #[test]
193    fn duration_secs_roundtrip() {
194        use std::time::Duration;
195
196        #[derive(Debug, serde::Serialize, Deserialize, PartialEq)]
197        struct D {
198            #[serde(with = "super::duration_secs")]
199            timeout: Duration,
200        }
201
202        let d = D {
203            timeout: Duration::from_secs(30),
204        };
205        let json = serde_json::to_string(&d).unwrap();
206        assert_eq!(json, r#"{"timeout":30}"#);
207
208        let parsed: D = serde_json::from_str(&json).unwrap();
209        assert_eq!(parsed, d);
210    }
211}