1use crate::error::FaucetError;
33use serde::de::DeserializeOwned;
34use std::path::Path;
35
36pub 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
55pub 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
69pub 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
91pub 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
109pub 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 #[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 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 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 #[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 #[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}