Skip to main content

attune_core/
resolve.rs

1use std::{collections::HashMap, env};
2
3use crate::{SettingsError, StoredValue};
4
5/// Function pointer used to parse legacy persisted values.
6pub type DeserializeFallback<T> = fn(&str) -> Result<T, String>;
7
8/// Inputs used to resolve a single read-only config field.
9pub 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
16/// Inputs used to resolve a single persisted config field.
17pub 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
24/// Resolves a read-only config field from environment, TOML, or a default.
25///
26/// Resolution order:
27///
28/// 1. If `options.env` names a set environment variable, parse that value.
29/// 2. If `options.toml` contains `options.key`, deserialize that TOML value.
30/// 3. Otherwise, return `options.default`.
31///
32/// Environment values are first parsed as TOML expressions. If that fails, the
33/// raw environment value is treated as a string. For example, `8080`, `true`,
34/// `dark`, and `"dark"` all parse as expected for compatible field types.
35///
36/// # Errors
37///
38/// Returns [`SettingsError::EnvParse`] when the selected environment variable
39/// cannot be parsed as the requested field type. Returns
40/// [`SettingsError::ConfigValueParse`] when the selected TOML value cannot be
41/// deserialized as the requested field type.
42pub fn resolve_readonly_field<T>(options: FieldResolveOptions<T>) -> Result<T, SettingsError>
43where
44    T: serde::de::DeserializeOwned,
45{
46    // 1. Try to resolve field from environment variable.
47    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    // 2. Try to resolve field from TOML table.
53    } 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    // 3. Fallback to default value.
68    Ok(options.default)
69}
70
71/// Resolves a persisted config field from stored values or read-only fallback sources.
72///
73/// Resolution order:
74///
75/// 1. If `options.stored` contains `options.key`, decode that persisted value.
76/// 2. Otherwise, resolve the field from `options.fallback`.
77///
78/// A present stored value is authoritative. Invalid persisted data returns an
79/// error instead of falling back to env, TOML, or default.
80///
81/// # Errors
82///
83/// Returns [`SettingsError::PersistValueParse`] when a stored value exists but
84/// cannot be decoded as the requested field type. Returns the same errors as
85/// [`resolve_readonly_field`] when falling back to read-only sources.
86pub 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
97/// Decodes a persisted value, with an optional legacy fallback parser.
98///
99/// Normal JSON decoding is always attempted first. If it fails and
100/// `deserialize_fallback` is present, the fallback receives the raw stored
101/// string and may return a value of the requested type.
102///
103/// # Errors
104///
105/// Returns [`SettingsError::PersistValueParse`] when normal decoding fails and
106/// no fallback is present. Returns [`SettingsError::PersistFallbackParse`] when the
107/// fallback is present but cannot parse the raw value.
108pub 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
133/// Parses an environment variable value into the requested field type.
134///
135/// The raw value is first parsed as a TOML expression. If that parse fails, the
136/// raw value is treated as a string. This keeps scalar values like `8080` and
137/// `true` typed, while still allowing ordinary unquoted string environment
138/// values like `dark`.
139///
140/// # Errors
141///
142/// Returns [`SettingsError::EnvParse`] when neither the TOML expression nor the
143/// string fallback can be deserialized as `T`.
144fn 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}