Skip to main content

adotenv_figment/
lib.rs

1//! Figment provider for literal adotenv documents and loaded environments.
2//!
3//! Values are exposed as a flat `Profile::Default` dictionary with their
4//! original key spelling. Figment does not perform scalar coercion from
5//! strings; deserialize string fields or convert values explicitly. Duplicate
6//! policy and lookup participation follow the wrapped source. With the `load`
7//! feature the provider also accepts a `LoadedEnvironment`.
8
9use std::collections::BTreeMap;
10
11#[cfg(feature = "load")]
12use adotenv::load::LoadedEnvironment;
13use adotenv::Document;
14use figment::{
15    value::{Dict, Map, Value},
16    Metadata, Profile, Provider,
17};
18
19/// A Figment provider containing the adapter's effective string values.
20#[derive(Debug, Clone)]
21pub struct AdotenvProvider {
22    values: BTreeMap<String, String>,
23    source: &'static str,
24}
25
26impl Default for AdotenvProvider {
27    fn default() -> Self {
28        Self {
29            values: BTreeMap::new(),
30            source: "empty",
31        }
32    }
33}
34
35impl AdotenvProvider {
36    /// Build a provider from an explicit key/value map.
37    #[must_use]
38    pub fn from_values(values: BTreeMap<String, String>) -> Self {
39        Self {
40            values,
41            source: "explicit values",
42        }
43    }
44
45    /// Build a provider from final values in a loaded environment.
46    ///
47    /// Available only with the `load` feature.
48    #[cfg(feature = "load")]
49    #[must_use]
50    pub fn from_environment(environment: &LoadedEnvironment) -> Self {
51        Self {
52            values: environment.values().clone(),
53            source: "loaded environment",
54        }
55    }
56
57    /// Build a provider from a document's effective literal declarations.
58    ///
59    /// Duplicate keys follow the document's dialect. Nonparticipating and empty
60    /// keys are omitted; bare declarations contribute `""` only under
61    /// `BareKeys::Empty`.
62    #[must_use]
63    pub fn from_document(document: &Document<'_>) -> Self {
64        let values = document
65            .effective_entries()
66            .map(|entry| (entry.key().to_string(), entry.value().to_string()))
67            .collect();
68        Self {
69            values,
70            source: "literal document",
71        }
72    }
73
74    /// The effective values exposed by this provider.
75    #[must_use]
76    pub fn values(&self) -> &BTreeMap<String, String> {
77        &self.values
78    }
79}
80
81impl Provider for AdotenvProvider {
82    fn metadata(&self) -> Metadata {
83        Metadata::from("adotenv", self.source)
84    }
85
86    fn data(&self) -> Result<Map<Profile, Dict>, figment::Error> {
87        let mut dict = Dict::new();
88        for (key, value) in &self.values {
89            dict.insert(key.clone(), Value::from(value.as_str()));
90        }
91        let mut profiles = Map::new();
92        profiles.insert(Profile::Default, dict);
93        Ok(profiles)
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use adotenv::{parse_with, BareKeys, Dialect, Duplicates};
101    use figment::Figment;
102    use serde::Deserialize;
103    use std::collections::HashMap;
104
105    #[derive(Debug, Deserialize, PartialEq)]
106    struct Config {
107        host: String,
108        port: String,
109    }
110
111    #[test]
112    fn figment_provider_exposes_flat_values() {
113        let figment = Figment::new().merge(AdotenvProvider::from_values(BTreeMap::from([
114            ("host".to_string(), "localhost".to_string()),
115            ("port".to_string(), "8080".to_string()),
116        ])));
117        let config: Config = figment.extract().unwrap();
118        assert_eq!(
119            config,
120            Config {
121                host: "localhost".to_string(),
122                port: "8080".to_string(),
123            }
124        );
125    }
126
127    #[test]
128    fn figment_preserves_literal_key_spelling() {
129        let values: HashMap<String, String> = Figment::new()
130            .merge(AdotenvProvider::from_values(BTreeMap::from([
131                ("A.B".to_string(), "dotted".to_string()),
132                ("UPPER".to_string(), "upper".to_string()),
133            ])))
134            .extract()
135            .unwrap();
136        assert_eq!(values.get("A.B").map(String::as_str), Some("dotted"));
137        assert_eq!(values.get("UPPER").map(String::as_str), Some("upper"));
138    }
139
140    #[test]
141    fn document_provider_honors_duplicate_and_quote_rules() {
142        let (document, _) = parse_with(
143            "A='first'\nA=second\nB=\"two words\"\nC='$HOME'\nD=\"line\\nbreak\"\n",
144            Dialect::PORTABLE,
145        );
146        let provider = AdotenvProvider::from_document(&document);
147        assert_eq!(
148            provider.values().get("A").map(String::as_str),
149            Some("second")
150        );
151        assert_eq!(
152            provider.values().get("B").map(String::as_str),
153            Some("two words")
154        );
155        assert_eq!(
156            provider.values().get("C").map(String::as_str),
157            Some("$HOME")
158        );
159        assert_eq!(
160            provider.values().get("D").map(String::as_str),
161            Some("line\nbreak")
162        );
163
164        let first = Dialect::PORTABLE.with_duplicates(Duplicates::First);
165        let (document, _) = parse_with("A='first'\nA=second\n", first);
166        let provider = AdotenvProvider::from_document(&document);
167        assert_eq!(
168            provider.values().get("A").map(String::as_str),
169            Some("first")
170        );
171    }
172
173    #[test]
174    fn document_provider_honors_bare_and_nonparticipating_rules() {
175        let empty = Dialect::PORTABLE.with_bare_keys(BareKeys::Empty);
176        let (document, _) = parse_with("A=first\nA\nB\n", empty);
177        let provider = AdotenvProvider::from_document(&document);
178        assert_eq!(provider.values().get("A").map(String::as_str), Some(""));
179        assert_eq!(provider.values().get("B").map(String::as_str), Some(""));
180
181        let ignore = Dialect::PORTABLE.with_bare_keys(BareKeys::Ignore);
182        let (document, _) = parse_with("A=first\nA\nB\n", ignore);
183        let provider = AdotenvProvider::from_document(&document);
184        assert_eq!(
185            provider.values().get("A").map(String::as_str),
186            Some("first")
187        );
188        assert!(!provider.values().contains_key("B"));
189    }
190
191    #[cfg(feature = "load")]
192    #[test]
193    fn environment_provider_copies_final_values() {
194        let path = std::env::temp_dir().join(format!(
195            "adotenv-figment-environment-{}.env",
196            std::process::id()
197        ));
198        std::fs::write(&path, "A=from-file\nA=final\nB=two\n").unwrap();
199        let environment = adotenv::load::load_files([&path], Dialect::PORTABLE).unwrap();
200        let provider = AdotenvProvider::from_environment(&environment);
201        assert_eq!(
202            provider.values().get("A").map(String::as_str),
203            Some("final")
204        );
205        assert_eq!(provider.values().get("B").map(String::as_str), Some("two"));
206        let _ = std::fs::remove_file(path);
207    }
208}
209
210#[cfg(test)]
211mod typed_tests {
212    use super::*;
213    use figment::Figment;
214    use serde::Deserialize;
215
216    #[derive(Debug, Deserialize)]
217    #[allow(dead_code)]
218    struct NumericConfig {
219        port: u16,
220    }
221
222    #[derive(Debug, Deserialize, PartialEq)]
223    struct StringConfig {
224        port: String,
225    }
226
227    #[test]
228    fn numeric_values_require_explicit_conversion() {
229        let figment = Figment::new().merge(AdotenvProvider::from_values(BTreeMap::from([(
230            "port".to_string(),
231            "8080".to_string(),
232        )])));
233
234        let direct: Result<NumericConfig, _> = figment.extract();
235        assert!(direct.is_err());
236
237        let raw: StringConfig = figment.extract().unwrap();
238        assert_eq!(raw.port.parse::<u16>().unwrap(), 8080);
239    }
240}