1use std::{collections::HashMap, env};
2
3use crate::{SettingsError, StoredValue};
4
5pub type DeserializeFallback<T> = fn(&str) -> Result<T, String>;
7
8pub struct FieldResolveOptions<'a, T> {
10 pub env: Option<&'a str>,
11 pub toml: Option<&'a toml::Table>,
12 pub key: &'a str,
13 pub default: T,
14}
15
16pub struct PersistResolveOptions<'a, T> {
18 pub stored: &'a HashMap<String, StoredValue>,
19 pub key: &'a str,
20 pub deserialize_fallback: Option<DeserializeFallback<T>>,
21 pub fallback: FieldResolveOptions<'a, T>,
22}
23
24pub fn resolve_readonly_field<T>(options: FieldResolveOptions<T>) -> Result<T, SettingsError>
43where
44 T: serde::de::DeserializeOwned,
45{
46 if let Some(env_name) = options.env
48 && let Ok(env_var) = env::var(env_name)
49 {
50 return parse_env_value(env_name, &env_var);
51
52 } else if let Some(table) = options.toml
54 && let Some(table_value) = table.get(options.key)
55 {
56 let field_value =
57 table_value
58 .clone()
59 .try_into::<T>()
60 .map_err(|e| SettingsError::ConfigValueParse {
61 key: options.key.to_string(),
62 source: e,
63 })?;
64 return Ok(field_value);
65 };
66
67 Ok(options.default)
69}
70
71pub fn resolve_persist_field<T>(options: PersistResolveOptions<T>) -> Result<T, SettingsError>
87where
88 T: serde::de::DeserializeOwned,
89{
90 if let Some(stored_value) = options.stored.get(options.key) {
91 return decode_persist_value(options.key, stored_value, options.deserialize_fallback);
92 }
93
94 resolve_readonly_field(options.fallback)
95}
96
97pub fn decode_persist_value<T>(
109 key: &str,
110 stored_value: &StoredValue,
111 deserialize_fallback: Option<DeserializeFallback<T>>,
112) -> Result<T, SettingsError>
113where
114 T: serde::de::DeserializeOwned,
115{
116 match stored_value.decode::<T>() {
117 Ok(value) => Ok(value),
118 Err(source) => match deserialize_fallback {
119 Some(fallback) => fallback(stored_value.as_str()).map_err(|error| {
120 SettingsError::PersistFallbackParse {
121 key: key.to_string(),
122 error,
123 }
124 }),
125 None => Err(SettingsError::PersistValueParse {
126 key: key.to_string(),
127 source,
128 }),
129 },
130 }
131}
132
133fn parse_env_value<T>(name: &str, raw: &str) -> Result<T, SettingsError>
145where
146 T: serde::de::DeserializeOwned,
147{
148 let parsed = raw
149 .parse::<toml::Value>()
150 .unwrap_or_else(|_| toml::Value::String(raw.to_string()));
151
152 parsed
153 .clone()
154 .try_into::<T>()
155 .or_else(|_| toml::Value::String(raw.to_string()).try_into::<T>())
156 .map_err(|source| SettingsError::EnvParse {
157 name: name.to_string(),
158 source,
159 })
160}
161
162#[cfg(test)]
163mod test {
164 use super::*;
165 use std::assert_matches;
166
167 #[test]
168 fn test_resolve_readonly_field_env_wins_over_toml_and_default() {
169 let env_var_key = "TEST_APP_PORT";
170 let table = "port = 3000".parse::<toml::Table>().unwrap();
171 let options = FieldResolveOptions::<u16> {
172 env: Some(env_var_key),
173 toml: Some(&table),
174 key: "port",
175 default: 1000,
176 };
177
178 temp_env::with_var(env_var_key, Some("8080"), || {
179 let field_value = resolve_readonly_field(options).unwrap();
180 assert_eq!(field_value, 8080);
181 });
182 }
183
184 #[test]
185 fn test_resolve_readonly_field_toml_wins_over_default_when_env_is_absent() {
186 let table = "port = 3000".parse::<toml::Table>().unwrap();
187 let options = FieldResolveOptions::<u16> {
188 env: Some("TEST_APP_MISSING_PORT"),
189 toml: Some(&table),
190 key: "port",
191 default: 1000,
192 };
193
194 temp_env::with_var("TEST_APP_MISSING_PORT", None::<&str>, || {
195 let field_value = resolve_readonly_field(options).unwrap();
196 assert_eq!(field_value, 3000);
197 });
198 }
199
200 #[test]
201 fn test_resolve_readonly_field_uses_default_when_env_and_toml_are_absent() {
202 let table = "other = 3000".parse::<toml::Table>().unwrap();
203 let options = FieldResolveOptions::<u16> {
204 env: Some("TEST_APP_DEFAULT_PORT"),
205 toml: Some(&table),
206 key: "port",
207 default: 1000,
208 };
209
210 temp_env::with_var("TEST_APP_DEFAULT_PORT", None::<&str>, || {
211 let field_value = resolve_readonly_field(options).unwrap();
212 assert_eq!(field_value, 1000);
213 });
214 }
215
216 #[test]
217 fn test_resolve_readonly_field_returns_env_parse_error_for_invalid_env_value() {
218 let env_var_key = "TEST_APP_BAD_PORT";
219 let table = "port = 3000".parse::<toml::Table>().unwrap();
220 let options = FieldResolveOptions::<u16> {
221 env: Some(env_var_key),
222 toml: Some(&table),
223 key: "port",
224 default: 1000,
225 };
226
227 temp_env::with_var(env_var_key, Some("\"not-a-number\""), || {
228 let error = resolve_readonly_field(options).unwrap_err();
229 assert_matches!(error, SettingsError::EnvParse { name, .. } if name == env_var_key);
230 });
231 }
232
233 #[test]
234 fn test_resolve_readonly_field_returns_config_value_parse_error_for_bad_toml_type() {
235 let table = "port = 'not-a-number'".parse::<toml::Table>().unwrap();
236 let options = FieldResolveOptions::<u16> {
237 env: None,
238 toml: Some(&table),
239 key: "port",
240 default: 1000,
241 };
242
243 let error = resolve_readonly_field(options).unwrap_err();
244 assert_matches!(error, SettingsError::ConfigValueParse { key, .. } if key == "port");
245 }
246
247 #[test]
248 fn test_resolve_readonly_field_parses_unquoted_env_string() {
249 let env_var_key = "TEST_APP_THEME";
250 let options = FieldResolveOptions::<String> {
251 env: Some(env_var_key),
252 toml: None,
253 key: "theme",
254 default: "system".to_string(),
255 };
256
257 temp_env::with_var(env_var_key, Some("dark"), || {
258 let field_value = resolve_readonly_field(options).unwrap();
259 assert_eq!(field_value, "dark");
260 });
261 }
262
263 #[test]
264 fn test_resolve_readonly_field_parses_toml_boolean_env_value() {
265 let env_var_key = "TEST_APP_DEBUG";
266 let options = FieldResolveOptions::<bool> {
267 env: Some(env_var_key),
268 toml: None,
269 key: "debug",
270 default: false,
271 };
272
273 temp_env::with_var(env_var_key, Some("true"), || {
274 let field_value = resolve_readonly_field(options).unwrap();
275 assert!(field_value);
276 });
277 }
278
279 #[test]
280 fn test_resolve_persist_field_uses_stored_value_when_present() {
281 let mut stored = HashMap::new();
282 stored.insert(
283 "theme".to_string(),
284 StoredValue::encode(&"dark".to_string()).unwrap(),
285 );
286 let table = "theme = 'light'".parse::<toml::Table>().unwrap();
287 let options = PersistResolveOptions {
288 stored: &stored,
289 key: "theme",
290 deserialize_fallback: None,
291 fallback: FieldResolveOptions::<String> {
292 env: Some("TEST_APP_PERSIST_THEME"),
293 toml: Some(&table),
294 key: "theme",
295 default: "system".to_string(),
296 },
297 };
298
299 temp_env::with_var("TEST_APP_PERSIST_THEME", Some("env-dark"), || {
300 let field_value = resolve_persist_field(options).unwrap();
301 assert_eq!(field_value, "dark");
302 });
303 }
304
305 #[test]
306 fn test_resolve_persist_field_falls_back_to_env_when_stored_value_is_missing() {
307 let stored = HashMap::new();
308 let table = "theme = 'light'".parse::<toml::Table>().unwrap();
309 let options = PersistResolveOptions {
310 stored: &stored,
311 key: "theme",
312 deserialize_fallback: None,
313 fallback: FieldResolveOptions::<String> {
314 env: Some("TEST_APP_PERSIST_ENV_THEME"),
315 toml: Some(&table),
316 key: "theme",
317 default: "system".to_string(),
318 },
319 };
320
321 temp_env::with_var("TEST_APP_PERSIST_ENV_THEME", Some("env-dark"), || {
322 let field_value = resolve_persist_field(options).unwrap();
323 assert_eq!(field_value, "env-dark");
324 });
325 }
326
327 #[test]
328 fn test_resolve_persist_field_falls_back_to_toml_when_env_and_stored_value_are_missing() {
329 let stored = HashMap::new();
330 let table = "theme = 'light'".parse::<toml::Table>().unwrap();
331 let options = PersistResolveOptions {
332 stored: &stored,
333 key: "theme",
334 deserialize_fallback: None,
335 fallback: FieldResolveOptions::<String> {
336 env: Some("TEST_APP_PERSIST_MISSING_THEME"),
337 toml: Some(&table),
338 key: "theme",
339 default: "system".to_string(),
340 },
341 };
342
343 temp_env::with_var("TEST_APP_PERSIST_MISSING_THEME", None::<&str>, || {
344 let field_value = resolve_persist_field(options).unwrap();
345 assert_eq!(field_value, "light");
346 });
347 }
348
349 #[test]
350 fn test_resolve_persist_field_falls_back_to_default_when_other_sources_are_missing() {
351 let stored = HashMap::new();
352 let table = "other = 'light'".parse::<toml::Table>().unwrap();
353 let options = PersistResolveOptions {
354 stored: &stored,
355 key: "theme",
356 deserialize_fallback: None,
357 fallback: FieldResolveOptions::<String> {
358 env: Some("TEST_APP_PERSIST_DEFAULT_THEME"),
359 toml: Some(&table),
360 key: "theme",
361 default: "system".to_string(),
362 },
363 };
364
365 temp_env::with_var("TEST_APP_PERSIST_DEFAULT_THEME", None::<&str>, || {
366 let field_value = resolve_persist_field(options).unwrap();
367 assert_eq!(field_value, "system");
368 });
369 }
370
371 #[test]
372 fn test_resolve_persist_field_returns_error_for_bad_stored_value() {
373 let mut stored = HashMap::new();
374 stored.insert(
375 "port".to_string(),
376 StoredValue::from_raw("\"not-a-number\"".to_string()),
377 );
378 let options = PersistResolveOptions {
379 stored: &stored,
380 key: "port",
381 deserialize_fallback: None,
382 fallback: FieldResolveOptions::<u16> {
383 env: None,
384 toml: None,
385 key: "port",
386 default: 8080,
387 },
388 };
389
390 let error = resolve_persist_field(options).unwrap_err();
391 assert_matches!(error, SettingsError::PersistValueParse { key, .. } if key == "port");
392 }
393
394 #[test]
395 fn test_resolve_persist_field_uses_deserialize_fallback_for_bad_stored_value() {
396 fn legacy_port(raw: &str) -> Result<u16, String> {
397 match raw {
398 "\"legacy-port\"" => Ok(9000),
399 other => Err(format!("unknown legacy port: {other}")),
400 }
401 }
402
403 let mut stored = HashMap::new();
404 stored.insert(
405 "port".to_string(),
406 StoredValue::encode(&"legacy-port".to_string()).unwrap(),
407 );
408 let options = PersistResolveOptions {
409 stored: &stored,
410 key: "port",
411 deserialize_fallback: Some(legacy_port),
412 fallback: FieldResolveOptions::<u16> {
413 env: None,
414 toml: None,
415 key: "port",
416 default: 8080,
417 },
418 };
419
420 let field_value = resolve_persist_field(options).unwrap();
421 assert_eq!(field_value, 9000);
422 }
423
424 #[test]
425 fn test_resolve_persist_field_returns_fallback_error_when_fallback_fails() {
426 fn legacy_port(raw: &str) -> Result<u16, String> {
427 Err(format!("unknown legacy port: {raw}"))
428 }
429
430 let mut stored = HashMap::new();
431 stored.insert(
432 "port".to_string(),
433 StoredValue::encode(&"unknown".to_string()).unwrap(),
434 );
435 let options = PersistResolveOptions {
436 stored: &stored,
437 key: "port",
438 deserialize_fallback: Some(legacy_port),
439 fallback: FieldResolveOptions::<u16> {
440 env: None,
441 toml: None,
442 key: "port",
443 default: 8080,
444 },
445 };
446
447 let error = resolve_persist_field(options).unwrap_err();
448 assert_matches!(error, SettingsError::PersistFallbackParse { key, error } if key == "port" && error.contains("unknown legacy port"));
449 }
450}