env_inventory/
lib.rs

1//! `env-inventory`: A Unified Environment Variable Management Crate
2//!
3//! This crate provides utilities for easily registering and managing
4//! environment variables within your Rust applications. This ensures a
5//! centralized approach to handling environment configurations, offering a
6//! consistent method of accessing parameters from the environment.
7//!
8//! Features:
9//! - **Unified Access**: Access environment parameters uniformly from anywhere
10//!   in the code.
11//! - **TOML Integration**: Allows loading of parameters from TOML configuration
12//!   files.
13//! - **Precedence Handling**: Parameters loaded merge with environment
14//!   variables, where the latter takes precedence.
15//! - **Registration System**: Variables of interest are registered via the
16//!   provided macros, ensuring that you only focus on the ones you need.
17//!
18//! Usage involves registering variables using the provided macros, and then
19//! employing the provided utilities to load and validate these variables either
20//! from the environment or TOML files.
21//!
22//! Note: `dotenv` file support isn't available currently.
23//! Note: This crate is still in its early stages and is subject to change.
24//! Note: `shell-expansions` (probably using
25//! [https://docs.rs/shellexpand/latest/shellexpand/fn.tilde.html](shellexpand))
26//! coming soon.
27
28// ce-env/src/lib.rs
29
30#![deny(missing_docs)]
31pub extern crate inventory;
32extern crate thiserror;
33extern crate toml;
34
35use std::collections::HashMap;
36use std::collections::HashSet;
37use std::env;
38use std::fs;
39use std::path::Path;
40use thiserror::Error;
41use toml::Value;
42
43/// Registers one or more environment variables for tracking and validation.
44///
45/// This macro simplifies the process of registering environment variables that
46/// your application depends on. Once registered, you can utilize
47/// `env-inventory`'s utilities to load, validate, and manage these environment
48/// variables.
49///
50/// # Examples
51///
52/// ```rust (ignore)
53/// # #[macro_use] extern crate env_inventory;
54/// # fn main() {
55/// register!("DATABASE_URL", "REDIS_URL", "API_KEY");
56/// register!("LOG_LEVEL" => "debug", "CACHE_SIZE" => 1024);
57/// # }
58/// ```
59///
60/// The first call registers three environment variables: `DATABASE_URL`,
61/// `REDIS_URL`, and `API_KEY`. The second call registers two environment variables
62/// with default values: `LOG_LEVEL` with a default of `"debug"`, and `CACHE_SIZE`
63/// with a default of `1024`.
64///
65/// # Parameters
66///
67/// - `$($var:expr),*`: A comma-separated list of string literals, each
68///   representing an environment variable to register.
69/// - `$($var:expr => $default:expr),*`: A comma-separated list of pairs, where
70///   each pair consists of a string literal representing an environment variable
71///   and its default value.
72///
73/// # Panics
74///
75/// This macro will panic at compile-time if any of the provided arguments are
76/// not string literals or if the pairs don't have the appropriate structure.
77#[macro_export]
78macro_rules! register {
79    ($var:ident) => {
80        const _: () = {
81            use $crate::RequiredVar;
82            $crate::inventory::submit!(RequiredVar::new(stringify!($var)));
83        };
84    };
85    ($var:ident = $default:expr) => {
86        const _: () = {
87            use $crate::RequiredVar;
88            $crate::inventory::submit!(RequiredVar {
89                var_name: stringify!($var),
90                default: Some(ToString::to_string($default)),
91            });
92        };
93    };
94    ($($var:ident),* $(,)?) => {
95        const _: () = {
96            use $crate::RequiredVar;
97            $(
98                $crate::inventory::submit!(RequiredVar::new(stringify!($var)));
99            )*
100        };
101    };
102    ($($var:ident = $default:expr),* $(,)?) => {
103        const _: () = {
104            use $crate::RequiredVar;
105            $(
106                $crate::inventory::submit!(RequiredVar {
107                    var_name: stringify!($var),
108                    default: Some(ToString::to_string($default)),
109                });
110            )*
111        };
112    };
113    ($var:expr) => {
114        const _: () = {
115            use $crate::RequiredVar;
116            $crate::inventory::submit!(RequiredVar::new($var));
117        };
118    };
119    ($var:expr => $default:expr) => {
120        const _: () = {
121            use $crate::RequiredVar;
122            $crate::inventory::submit!(RequiredVar {
123                var_name: $var,
124                default: Some(ToString::to_string($default)),
125            });
126        };
127    };
128    ($($var:expr),* $(,)?) => {
129        const _: () = {
130            use $crate::RequiredVar;
131            $(
132                $crate::inventory::submit!(RequiredVar::new($var));
133            )*
134        };
135    };
136    ($($var:expr => $default:expr),* $(,)?) => {
137        const _: () = {
138            use $crate::RequiredVar;
139            $(
140                $crate::inventory::submit!(RequiredVar {
141                    var_name: $var,
142                    default: Some(ToString::to_string($default)),
143                });
144            )*
145        };
146    };
147
148}
149
150/// Represents the potential errors that can be encountered by the
151/// `env-inventory` module.
152///
153/// This enum provides specific error variants to handle different failure
154/// scenarios when working with environment variable loading and validation in
155/// the `env-inventory` module. It is designed to give users of the module clear
156/// feedback on the nature of the error encountered.
157///
158/// # Variants
159///
160/// * `ReadFileError`: Occurs when there's an issue reading a settings file.
161/// * `ParseFileError`: Occurs when parsing a settings file fails, possibly due
162///   to a malformed structure.
163/// * `MissingEnvVars`: Occurs when one or more registered environment variables
164///   are not present in either the environment or the settings files.
165///
166/// # Examples
167///
168/// ```rust (ignore)
169/// # use std::fs;
170/// # use env_inventory::EnvInventoryError;
171/// fn read_settings(file_path: &str) -> Result<(), EnvInventoryError> {
172///     if fs::read(file_path).is_err() {
173///         return Err(EnvInventoryError::ReadFileError(file_path.to_string()));
174///     }
175///     // ... Additional logic ...
176///     Ok(())
177/// }
178/// ```
179#[derive(Error, Debug)]
180pub enum EnvInventoryError {
181    /// Represents a failure to read a settings file.
182    ///
183    /// Contains a string that provides the path to the file that failed to be
184    /// read.
185    #[error("Failed to read the settings file at {0}")]
186    ReadFileError(String),
187
188    /// Represents a failure to parse a settings file.
189    ///
190    /// Contains a string that provides the path to the file that failed to be
191    /// parsed.
192    #[error("Failed to parse the settings file at {0}")]
193    ParseFileError(String),
194
195    /// Represents the absence of required environment variables.
196    ///
197    /// Contains a vector of strings, each representing a missing environment
198    /// variable.
199    #[error("Missing required environment variables: {0:?}")]
200    MissingEnvVars(Vec<String>),
201}
202
203#[doc(hidden)]
204#[derive(Debug, Clone)]
205pub struct RequiredVar {
206    pub var_name: &'static str,
207    pub default: Option<&'static str>,
208}
209
210inventory::collect!(RequiredVar);
211
212impl RequiredVar {
213    pub const fn new(var_name: &'static str) -> Self {
214        Self {
215            var_name,
216            default: None,
217        }
218    }
219    pub fn is_set(&self) -> bool {
220        env::var(self.var_name).is_ok()
221    }
222}
223
224/// Validates that all registered environment variables are set.
225///
226/// This function checks if the previously registered environment variables (via
227/// the `register!` macro or other means) are present either in the system's
228/// environment or the loaded configuration files.
229///
230/// If any of the registered variables are missing, an
231/// `EnvInventoryError::MissingEnvVars` error is returned, containing a list of
232/// the missing variables.
233///
234/// # Parameters
235///
236/// * `config_paths`: A slice of file paths (as `&str`) pointing to the
237///   configuration files that might contain the environment variables. These
238///   files are expected to be in TOML format with a dedicated `[env]` section.
239/// * `section_name`: The name of the section in the TOML files that contains
240///   the environment variables. By default, this is `"env"`.
241///
242/// # Returns
243///
244/// * `Ok(())`: If all registered environment variables are found.
245/// * `Err(EnvInventoryError)`: If there's an error reading or parsing the
246///   config files or if any registered environment variable is missing.
247///
248/// # Examples
249///
250/// ```rust (ignore)
251/// # use env_inventory::validate_env_vars;
252/// let result = validate_env_vars(&["/path/to/settings.conf"], "env");
253/// if result.is_err() {
254///     eprintln!("Failed to validate environment variables: {:?}", result);
255/// }
256/// ```
257///
258/// # Errors
259///
260/// This function can return the following errors:
261/// * `ReadFileError`: If a provided config file cannot be read.
262/// * `ParseFileError`: If a provided config file cannot be parsed as TOML or
263///   lacks the expected structure.
264/// * `MissingEnvVars`: If one or more registered environment variables are
265///   missing.
266
267pub fn validate_env_vars() -> Result<(), EnvInventoryError> {
268    let missing_vars: Vec<String> = inventory::iter::<RequiredVar>()
269        .filter_map(|var| {
270            if var.is_set() {
271                None
272            } else {
273                Some(var.var_name.to_string())
274            }
275        })
276        .collect();
277
278    if missing_vars.is_empty() {
279        Ok(())
280    } else {
281        type E = EnvInventoryError;
282        Err(E::MissingEnvVars(missing_vars))
283    }
284}
285
286/// List all the registered environment variables.
287/// that are expected from different parts of the application.
288pub fn list_all_vars() -> Vec<String> {
289    let mut v: Vec<String> = inventory::iter::<RequiredVar>()
290        .map(|var| var.var_name.to_string())
291        .collect();
292    v.sort();
293    v
294}
295
296/// Loads the settings from a TOML file and returns them as a `HashMap`.
297pub(crate) fn load_toml_settings<P: AsRef<Path>>(
298    path: P,
299    section: &str,
300) -> Result<HashMap<String, String>, EnvInventoryError> {
301    let content = fs::read_to_string(&path)
302        .map_err(|_| EnvInventoryError::ReadFileError(path.as_ref().display().to_string()))?;
303
304    let value = content
305        .parse::<Value>()
306        .map_err(|_| EnvInventoryError::ParseFileError(path.as_ref().display().to_string()))?;
307
308    let env_section = match value.get(section) {
309        Some(env) => env.as_table(),
310        None => None,
311    };
312
313    let mut settings = HashMap::new();
314
315    if let Some(env_table) = env_section {
316        for (key, val) in env_table.iter() {
317            if let Some(val_str) = val.as_str() {
318                settings.insert(key.clone(), val_str.to_string());
319            }
320        }
321    }
322
323    Ok(settings)
324}
325
326/// Loads environment variables from specified configuration files and validates
327/// their presence.
328///
329/// This function goes through the provided list of configuration file paths,
330/// merges the environment settings from each file, and ensures that all the
331/// registered environment variables are set. If an environment variable
332/// is not already present in the system's environment, it will be set using the
333/// value from the merged settings.
334///
335/// Environment variables present in the system's environment take precedence
336/// over those in the configuration files.
337///
338/// # Parameters
339///
340/// * `config_paths`: A slice containing paths to the configuration files that
341///   should be loaded. The files are expected to be in TOML format and have a
342///   dedicated section for environment variables.
343/// * `section`: The name of the section in the TOML files that contains the
344///   environment variables.
345///
346/// # Returns
347///
348/// * `Ok(())`: If all registered environment variables are present either in
349///   the system's environment or in the merged settings.
350/// * `Err(EnvInventoryError)`: If there's an error reading or parsing the
351///   config files or if any registered environment variable is missing.
352///
353/// # Behavior
354///
355/// The first file in the `config_paths` slice is mandatory and if it can't be
356/// read or parsed, an error is immediately returned. Subsequent files are
357/// optional, and while they will generate a warning if they cannot be read or
358/// parsed, they won't cause the function to return an error.
359///
360/// After merging the settings from all files and overlaying them on the
361/// system's environment variables, the function checks for missing required
362/// environment variables and returns an error if any are found.
363///
364/// # Examples
365///
366/// ```rust (ignore)
367/// # use env_inventory::load_and_validate_env_vars;
368/// # use std::path::Path;
369/// let paths = [Path::new("/path/to/shipped.conf"), Path::new("/path/to/system.conf")];
370/// let result = load_and_validate_env_vars(&paths, "env");
371/// if result.is_err() {
372///     eprintln!("Failed to load and validate environment variables: {:?}", result);
373/// }
374/// ```
375///
376/// # Errors
377///
378/// This function can return the following errors:
379/// * `ReadFileError`: If a provided config file cannot be read.
380/// * `ParseFileError`: If a provided config file cannot be parsed as TOML or
381///   lacks the expected structure.
382/// * `MissingEnvVars`: If one or more registered environment variables are
383///   missing.
384
385pub fn load_and_validate_env_vars<P: AsRef<Path>>(
386    config_paths: &[P],
387    section: &str,
388) -> Result<(), EnvInventoryError> {
389    let mut merged_settings = HashMap::new();
390
391    for (index, path) in config_paths.iter().enumerate() {
392        let settings = load_toml_settings(path.as_ref(), section);
393
394        match settings {
395            Ok(current_settings) => {
396                // Merge settings
397                for (key, value) in current_settings.iter() {
398                    if !merged_settings.contains_key(key) {
399                        merged_settings.insert(key.clone(), value.clone());
400                    }
401                }
402            }
403            Err(e) => {
404                if index == 0 {
405                    // The first file is mandatory
406                    return Err(e);
407                } else {
408                    // Subsequent files are optional, but let's warn for transparency
409                    eprintln!(
410                        "Warning: Could not load settings from {:?}. Reason: {}",
411                        path.as_ref(),
412                        e
413                    );
414                }
415            }
416        }
417    }
418
419    // Override the environment variables with our merged settings if they aren't
420    // already set
421    for (key, value) in merged_settings.iter() {
422        if env::var(key).is_err() {
423            env::set_var(key, value);
424        }
425        let value = env::var(key).unwrap();
426        tracing::info!("{} = {}", key, value);
427    }
428
429    let missing_vars: HashSet<String> = inventory::iter::<RequiredVar>()
430        .filter_map(|var| {
431            if var.is_set() {
432                None
433            } else {
434                Some(var.var_name.to_string())
435            }
436        })
437        .collect();
438    let mut missing_vars = missing_vars.into_iter().collect::<Vec<String>>();
439    missing_vars.sort();
440
441    if missing_vars.is_empty() {
442        Ok(())
443    } else {
444        tracing::warn!("Missing required environment variables: {:?}", missing_vars);
445        Err(EnvInventoryError::MissingEnvVars(missing_vars))
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use std::env;
453    use std::fs;
454    use tempfile::tempdir;
455
456    register!("TEST_ENV_VAR");
457
458    #[test]
459    fn test_load_single_toml() {
460        let dir = tempdir().unwrap();
461        let file_path = dir.path().join("settings.conf");
462
463        fs::write(&file_path, "[env]\nTEST_ENV_VAR = \"test_value\"").unwrap();
464
465        load_and_validate_env_vars(&[file_path], "env").unwrap();
466        assert_eq!(env::var("TEST_ENV_VAR").unwrap(), "test_value");
467    }
468
469    #[test]
470    fn test_merge_priority() {
471        let dir = tempdir().unwrap();
472        let file_path1 = dir.path().join("settings1.conf");
473        let file_path2 = dir.path().join("settings2.conf");
474        fs::write(&file_path1, "[env]\nTEST_ENV_VAR = \"value1\"").unwrap();
475        fs::write(&file_path2, "[env]\nTEST_ENV_VAR = \"value2\"").unwrap();
476
477        load_and_validate_env_vars(&[file_path2, file_path1], "env").unwrap();
478        assert_eq!(env::var("TEST_ENV_VAR").unwrap(), "value2");
479    }
480
481    #[test]
482    fn test_missing_mandatory_config() {
483        let dir = tempdir().unwrap();
484        let file_path1 = dir.path().join("does_not_exist.conf");
485        let file_path2 = dir.path().join("settings.conf");
486        fs::write(&file_path2, "[env]\nTEST_ENV_VAR = \"test_value\"").unwrap();
487
488        assert!(load_and_validate_env_vars(&[file_path1, file_path2], "env").is_err());
489    }
490
491    #[test]
492    fn test_missing_env_vars() {
493        let dir = tempdir().unwrap();
494        let file_path = dir.path().join("settings.conf");
495
496        // Write a file without MISSING_VAR
497        fs::write(&file_path, "[env]\nSOME_OTHER_VAR = \"some_value\"").unwrap();
498
499        // Ensure the environment variable isn't set before the test
500        env::remove_var("MISSING_VAR");
501
502        // Register MISSING_VAR as a required environment variable
503        register!("MISSING_VAR");
504
505        // Since MISSING_VAR isn't in the environment and also isn't in the TOML files,
506        // the function should return an error.
507        assert!(load_and_validate_env_vars(&[file_path], "env").is_err());
508    }
509
510    #[test]
511    fn test_present_env_vars() {
512        let dir = tempdir().unwrap();
513        let file_path = dir.path().join("settings.conf");
514
515        // Write a file with PRESENT_VAR
516        fs::write(
517            &file_path,
518            r#"
519        [env]
520        PRESENT_VAR = "present_value"
521        MISSING_VAR = "missing_value"
522        TEST_ENV_VAR = "test_value"
523        "#,
524        )
525        .unwrap();
526
527        // Ensure the environment variable isn't set before the test
528        env::remove_var("PRESENT_VAR");
529
530        // Register PRESENT_VAR as a required environment variable
531        register!("PRESENT_VAR");
532
533        // Since PRESENT_VAR is in the TOML file, the function should run without errors
534        load_and_validate_env_vars(&[file_path], "env").unwrap();
535    }
536}