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
212    // ── load_env ─────────────────────────────────────────────────────────────
213
214    #[derive(Debug, Deserialize, PartialEq)]
215    struct EnvConfig {
216        url: String,
217        #[serde(default)]
218        batch_size: Option<usize>,
219    }
220
221    #[test]
222    fn load_env_reads_prefixed_vars() {
223        // SAFETY: the test sets and reads its own uniquely-prefixed vars; no
224        // other test uses the FAUCETCFGA_ prefix.
225        unsafe {
226            std::env::set_var("FAUCETCFGA_URL", "https://env.example.com");
227            std::env::set_var("FAUCETCFGA_BATCH_SIZE", "55");
228        }
229        let config: EnvConfig = load_env("FAUCETCFGA").unwrap();
230        assert_eq!(config.url, "https://env.example.com");
231        assert_eq!(config.batch_size, Some(55));
232        unsafe {
233            std::env::remove_var("FAUCETCFGA_URL");
234            std::env::remove_var("FAUCETCFGA_BATCH_SIZE");
235        }
236    }
237
238    #[test]
239    fn load_env_missing_required_field_errors() {
240        // No FAUCETCFGB_* vars set → the required `url` field cannot be read.
241        let result = load_env::<EnvConfig>("FAUCETCFGB");
242        assert!(result.is_err());
243        match result.unwrap_err() {
244            FaucetError::Config(msg) => {
245                assert!(msg.contains("failed to load config from env vars"), "{msg}")
246            }
247            other => panic!("expected Config error, got {other:?}"),
248        }
249    }
250
251    // ── load_env_file ────────────────────────────────────────────────────────
252
253    #[test]
254    fn load_env_file_loads_dotenv_then_reads_vars() {
255        let dir = std::env::temp_dir();
256        let path = dir.join("faucet_test_loadenvfile.env");
257        std::fs::write(
258            &path,
259            "FAUCETCFGC_URL=https://dotenv.example.com\nFAUCETCFGC_BATCH_SIZE=12\n",
260        )
261        .unwrap();
262
263        let config: EnvConfig = load_env_file(&path, "FAUCETCFGC").unwrap();
264        assert_eq!(config.url, "https://dotenv.example.com");
265        assert_eq!(config.batch_size, Some(12));
266
267        std::fs::remove_file(&path).ok();
268        unsafe {
269            std::env::remove_var("FAUCETCFGC_URL");
270            std::env::remove_var("FAUCETCFGC_BATCH_SIZE");
271        }
272    }
273
274    #[test]
275    fn load_env_file_missing_file_errors() {
276        let result = load_env_file::<EnvConfig>("/nonexistent/path.env", "FAUCETCFGD");
277        assert!(result.is_err());
278        match result.unwrap_err() {
279            FaucetError::Config(msg) => assert!(msg.contains("failed to load .env file"), "{msg}"),
280            other => panic!("expected Config error, got {other:?}"),
281        }
282    }
283
284    // ── duration_secs_option ─────────────────────────────────────────────────
285
286    #[test]
287    fn duration_secs_option_roundtrip_some_and_none() {
288        use std::time::Duration;
289
290        #[derive(Debug, serde::Serialize, Deserialize, PartialEq)]
291        struct D {
292            #[serde(with = "super::duration_secs_option")]
293            timeout: Option<Duration>,
294        }
295
296        let some = D {
297            timeout: Some(Duration::from_secs(45)),
298        };
299        let json = serde_json::to_string(&some).unwrap();
300        assert_eq!(json, r#"{"timeout":45}"#);
301        let parsed: D = serde_json::from_str(&json).unwrap();
302        assert_eq!(parsed, some);
303
304        let none = D { timeout: None };
305        let json = serde_json::to_string(&none).unwrap();
306        assert_eq!(json, r#"{"timeout":null}"#);
307        let parsed: D = serde_json::from_str(&json).unwrap();
308        assert_eq!(parsed, none);
309    }
310}