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}