yaml_config/
lib.rs

1pub mod error;
2
3pub use crate::error::ParseError;
4
5use enum_as_inner::EnumAsInner;
6use fxhash::FxBuildHasher;
7use indexmap::IndexMap;
8use linked_hash_map::LinkedHashMap;
9use std::env;
10use std::fs::read_to_string;
11use yaml_rust::{Yaml, YamlLoader};
12
13/// Defines the preference for loading of a configuration when a variable exists in the
14/// YAML and also along the same path in the environment.
15#[derive(Debug, PartialEq, Eq)]
16pub enum Preference {
17    PreferYaml,
18    PreferEnv,
19}
20
21/// A wrapped type enum useful for allowing polymorphic returns from
22/// the map creation function.
23///
24/// # Examples
25///
26/// fn main() {
27///
28/// ```rust
29/// use yaml_config::Value;
30/// let x = Value::I32(10);
31/// let val = *x.as_i32().unwrap();
32/// ```
33/// }
34#[derive(Debug, EnumAsInner)]
35pub enum Value {
36    I32(i32),
37    I64(i64),
38    F32(f32),
39    F64(f64),
40    String(String),
41    Bool(bool),
42}
43
44/// Provides a simple way to allow question mark syntax in order to
45/// convert environment errors into ParseErrors.
46fn env_or_error(key: &str) -> Result<String, ParseError> {
47    match env::var_os(key) {
48        Some(v) => Ok(v
49            .into_string()
50            .expect("Could not convert OsString into string.")),
51        None => {
52            let msg = format!("Error parsing OS environment variable for {}", key);
53            Err(ParseError {
54                module: "std::env".to_string(),
55                message: msg,
56            })
57        }
58    }
59}
60
61/// Takes a key and a Yaml reference, parses it, and sets the key.
62///
63/// In addition to doing the initial parsing it will also do environment finding. If a given
64/// key is null, or `prefer_env` is true, then it will search the environment for the given
65/// key string and attempt to use that key string's value.
66///
67fn maybe_yaml_to_value(
68    key: &str,
69    maybe_val: &Yaml,
70    prefer_env: bool,
71    map: &mut IndexMap<String, Value, FxBuildHasher>,
72) -> Result<(), ParseError> {
73    if maybe_val.is_null() {
74        // Because the value is null we have to attempt a full parse of whatever is coming back
75        // from the user's environment since we don't have an indicator from the YAML itself.
76        let val_str = env_or_error(key)?;
77
78        let val = match val_str.parse::<i64>() {
79            Ok(v) => Value::I64(v),
80            Err(_) => match val_str.parse::<f64>() {
81                Ok(v) => Value::F64(v),
82                Err(_) => match val_str.parse::<bool>() {
83                    Ok(v) => Value::Bool(v),
84                    Err(_) => Value::String(val_str),
85                },
86            },
87        };
88
89        map.insert(key.to_string(), val);
90        return Ok(());
91    }
92
93    if maybe_val.as_str().is_some() {
94        if prefer_env {
95            match env_or_error(key) {
96                Ok(v) => {
97                    map.insert(key.to_string(), Value::String(v));
98                }
99                Err(_) => {
100                    map.insert(
101                        key.to_string(),
102                        Value::String(maybe_val.as_str().unwrap().to_string()),
103                    );
104                }
105            };
106        } else {
107            map.insert(
108                key.to_string(),
109                Value::String(maybe_val.as_str().unwrap().to_string()),
110            );
111        }
112
113        return Ok(());
114    }
115
116    if maybe_val.as_i64().is_some() {
117        if prefer_env {
118            match env_or_error(key) {
119                Ok(v) => {
120                    let e_val = v.parse::<i64>().unwrap();
121                    map.insert(key.to_string(), Value::I64(e_val));
122                }
123                Err(_) => {
124                    map.insert(key.to_string(), Value::I64(maybe_val.as_i64().unwrap()));
125                }
126            };
127        } else {
128            map.insert(key.to_string(), Value::I64(maybe_val.as_i64().unwrap()));
129        }
130
131        return Ok(());
132    }
133
134    if maybe_val.as_bool().is_some() {
135        if prefer_env {
136            match env_or_error(key) {
137                Ok(v) => {
138                    let e_val = v.parse::<bool>().unwrap();
139                    map.insert(key.to_string(), Value::Bool(e_val));
140                }
141                Err(_) => {
142                    map.insert(key.to_string(), Value::Bool(maybe_val.as_bool().unwrap()));
143                }
144            };
145        } else {
146            map.insert(key.to_string(), Value::Bool(maybe_val.as_bool().unwrap()));
147        }
148
149        return Ok(());
150    }
151
152    if maybe_val.as_f64().is_some() {
153        if prefer_env {
154            match env_or_error(key) {
155                Ok(v) => {
156                    let e_val = v.parse::<f64>().unwrap();
157                    map.insert(key.to_string(), Value::F64(e_val));
158                }
159                Err(_) => {
160                    map.insert(key.to_string(), Value::F64(maybe_val.as_f64().unwrap()));
161                }
162            };
163        } else {
164            map.insert(key.to_string(), Value::F64(maybe_val.as_f64().unwrap()));
165        }
166
167        Ok(())
168    } else {
169        let msg = format!("Failed to convert type for {}", key);
170        Err(ParseError {
171            module: "config".to_string(),
172            message: msg,
173        })
174    }
175}
176
177/// Converts a YAML key into a string for processing.
178fn key_string(key: &Yaml) -> Result<&str, ParseError> {
179    match key.as_str() {
180        Some(s) => Ok(s),
181        None => Err(ParseError {
182            module: "config".to_string(),
183            message: format!("Could not convert key {:?} into String.", key),
184        }),
185    }
186}
187
188/// Recursive map builder.
189///
190/// Given a "root" of the yaml file it will generate a configuration recursively. Due
191/// to it's use of recursion the actual depth of the YAML file is limited to the depth of
192/// the stack. But given most (arguably 99.9%) of YAML files are not even 5 levels deep
193/// this seemed like an acceptable trade off for an easier to write algorithm.
194///
195/// Effectively, this performs a depth first search of the YAML file treating each top level
196/// feature as a tree with 1-to-N values. When a concrete (non-hash) value is arrived at
197/// the builder constructs a depth-based string definining it.
198///
199/// The arguments enforce an `FxBuildHasher` based `IndexMap` to insure extremely fast
200/// searching of the map. *this map is modified in place*.
201///
202/// # Arguments
203///
204/// * `root` - The start of the YAML document as given by `yaml-rust`.
205/// * `config` - An IndexMap of String -> Value. It must use an FxBuilderHasher.
206/// * `prefer_env` - When `true` will return an environment variable matching the path string
207///                  regardless of whether the YAML contains a value for this key. It will prefer
208///                  the given value otherwise unless that value is `null`.
209/// * `current_key_str` - An optional argument that stores the current string of the path.
210///
211fn build_map(
212    root: &LinkedHashMap<Yaml, Yaml>,
213    config: &mut IndexMap<String, Value, FxBuildHasher>,
214    prefer_env: bool,
215    current_key_str: Option<&str>,
216) -> Result<(), ParseError> {
217    // Recursively parse each root key to resolve.
218    for key in root.keys() {
219        let maybe_val = &root[key];
220
221        let key_str = match current_key_str {
222            Some(k) => {
223                // In this case we have a previous value.
224                // We need to construct the current depth-related key.
225                let mut next_key = k.to_uppercase().to_string();
226                next_key.push('_');
227                next_key.push_str(&key_string(key)?.to_uppercase());
228                next_key
229            }
230            None => key_string(key)?.to_uppercase().to_string(),
231        };
232
233        if maybe_val.is_array() {
234            return Err(ParseError {
235                module: "config::build_map".to_string(),
236                message: "Arrays are currently unsupported for configuration.".to_string(),
237            });
238        }
239
240        if maybe_val.as_hash().is_none() {
241            // Base condition
242            maybe_yaml_to_value(&key_str.to_uppercase(), maybe_val, prefer_env, config)?;
243        } else {
244            // Now we need to construct the key for one layer deeper.
245            build_map(
246                maybe_val.as_hash().unwrap(),
247                config,
248                prefer_env,
249                Some(&key_str),
250            )?;
251        }
252    }
253
254    Ok(())
255}
256
257/// Loads a configuration file.
258///
259/// The parser will first load the YAML file. It then re-organizes the YAML
260/// file into a common naming convention. Given:
261///
262/// ```yaml
263/// X:
264///   y: "value"
265/// ```
266///
267/// The key will be `X_Y` and the value will be the string `"value"`.
268///
269/// After loading, it investigates each value looking for nulls. In the
270/// case of a null, it will search the environment for the
271/// key (in the above example `X_Y`). If found, it replaces the value.
272/// If not found, it will error.
273///
274/// In the event that a key in the environment matches a key that is
275/// provided in the YAML it will prefer the key in the YAML file. To
276/// override this, pass a `Some(Preference::PreferEnv)` to the
277/// `preference` argument.
278///
279/// The resulting `IndexMap` will have string keys representing the path
280/// configuration described above, and values that are contained in the `Value`
281/// enum. See the documentation for `config::Value` for more information on
282/// usage.
283///
284/// # Arguments
285///
286/// * `file_path` - A string representing the path to the YAML file.
287/// * `preference` - The preference for handling values when a key has a value in the
288///
289/// # Examples
290///
291/// ```rust
292/// use yaml_config::load;
293/// let configuration = load("path/to/yaml/file.yaml", None);
294///
295/// ```
296///
297/// Use with preference:
298///
299/// ```rust
300/// use yaml_config::Preference;
301/// use yaml_config::load;
302/// let configuration = load("path/to/yaml/file.yaml",
303///                          Some(Preference::PreferEnv));
304/// ```
305pub fn load(
306    file_path: &str,
307    preference: Option<Preference>,
308) -> Result<IndexMap<String, Value, FxBuildHasher>, ParseError> {
309    let prefer_env = match preference {
310        Some(p) => p == Preference::PreferEnv,
311        None => false,
312    };
313    let doc_str = read_to_string(file_path)?;
314    let yaml_docs = YamlLoader::load_from_str(&doc_str)?;
315    let base_config = &yaml_docs[0];
316    let user_config = match base_config.as_hash() {
317        Some(hash) => hash,
318        None => {
319            return Err(ParseError {
320                module: "config".to_string(),
321                message: "Failed to parse YAML as hashmap.".to_string(),
322            })
323        }
324    };
325
326    let mut config = IndexMap::with_hasher(FxBuildHasher::default());
327
328    build_map(user_config, &mut config, prefer_env, None)?;
329
330    Ok(config)
331}
332
333#[cfg(test)]
334mod test;