diff --git a/Cargo.toml b/Cargo.toml index 68bfba3..d5ca61b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,4 @@ [workspace] +resolver = "2" -members = ["dotenv", "dotenv_codegen"] +members = ["dotenv", "dotenv_codegen", "test_util"] diff --git a/dotenv/Cargo.toml b/dotenv/Cargo.toml index 51b32a3..84c41eb 100644 --- a/dotenv/Cargo.toml +++ b/dotenv/Cargo.toml @@ -29,8 +29,8 @@ required-features = ["cli"] clap = { version = "4.3.11", optional = true } [dev-dependencies] +dotenvy_test_util = { path = "../test_util", version = "0.1.0" } tempfile = "3.3.0" -once_cell = "1.16.0" [features] cli = ["clap"] diff --git a/dotenv/tests/integration/dotenv.rs b/dotenv/tests/integration/dotenv.rs new file mode 100644 index 0000000..dfe5805 --- /dev/null +++ b/dotenv/tests/integration/dotenv.rs @@ -0,0 +1,23 @@ +use dotenvy_test_util::*; + +const TEST_KEY: &str = "TEST_KEY"; +const TEST_VALUE: &str = "test_val"; +const EXISTING_KEY: &str = "EXISTING_KEY"; +const EXISTING_VALUE: &str = "loaded_from_env"; + +const TEST_ENV_FILE: &str = r" +TEST_KEY=test_val +EXISTING_KEY=loaded_from_file +"; + +#[test] +fn dotenv_ok() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_file(".env", TEST_ENV_FILE)?; + testenv.add_env_var(EXISTING_KEY, EXISTING_VALUE)?; + test_in_env(&testenv, || { + dotenvy::dotenv().ok(); + assert_env_var(TEST_KEY, TEST_VALUE); + assert_env_var(EXISTING_KEY, EXISTING_VALUE); + }) +} diff --git a/dotenv/tests/integration/main.rs b/dotenv/tests/integration/main.rs index 83c8c0a..8c74edd 100644 --- a/dotenv/tests/integration/main.rs +++ b/dotenv/tests/integration/main.rs @@ -1 +1 @@ -mod util; +mod dotenv; diff --git a/dotenv/tests/integration/util/mod.rs b/dotenv/tests/integration/util/mod.rs deleted file mode 100644 index 12430b6..0000000 --- a/dotenv/tests/integration/util/mod.rs +++ /dev/null @@ -1,67 +0,0 @@ -#![allow(dead_code)] - -mod testenv; - -use std::env::{self, VarError}; - -/// Default key used in envfile -pub const TEST_KEY: &str = "TESTKEY"; -/// Default value used in envfile -pub const TEST_VALUE: &str = "test_val"; - -/// Default existing key set before test is run -pub const TEST_EXISTING_KEY: &str = "TEST_EXISTING_KEY"; -/// Default existing value set before test is run -pub const TEST_EXISTING_VALUE: &str = "from_env"; -/// Default overriding value in envfile -pub const TEST_OVERRIDING_VALUE: &str = "from_file"; - -#[inline(always)] -pub fn create_default_envfile() -> String { - format!( - "{}={}\n{}={}", - TEST_KEY, TEST_VALUE, TEST_EXISTING_KEY, TEST_OVERRIDING_VALUE - ) -} - -/// missing equals -#[inline(always)] -pub fn create_invalid_envfile() -> String { - format!( - "{}{}\n{}{}", - TEST_KEY, TEST_VALUE, TEST_EXISTING_KEY, TEST_OVERRIDING_VALUE - ) -} - -/// Assert that an environment variable is set and has the expected value. -pub fn assert_env_var(key: &str, expected: &str) { - match env::var(key) { - Ok(actual) => assert_eq!( - expected, actual, - "\n\nFor Environment Variable `{}`:\n EXPECTED: `{}`\n ACTUAL: `{}`\n", - key, expected, actual - ), - Err(VarError::NotPresent) => panic!("env var `{}` not found", key), - Err(VarError::NotUnicode(val)) => panic!( - "env var `{}` currently has invalid unicode: `{}`", - key, - val.to_string_lossy() - ), - } -} - -/// Assert that an environment variable is not currently set. -pub fn assert_env_var_unset(key: &str) { - match env::var(key) { - Ok(actual) => panic!( - "env var `{}` should not be set, currently it is: `{}`", - key, actual - ), - Err(VarError::NotUnicode(val)) => panic!( - "env var `{}` should not be set, currently has invalid unicode: `{}`", - key, - val.to_string_lossy() - ), - _ => (), - } -} diff --git a/dotenv/tests/integration/util/testenv.rs b/dotenv/tests/integration/util/testenv.rs deleted file mode 100644 index a3d251b..0000000 --- a/dotenv/tests/integration/util/testenv.rs +++ /dev/null @@ -1,337 +0,0 @@ -use super::{create_default_envfile, TEST_EXISTING_KEY, TEST_EXISTING_VALUE}; -use once_cell::sync::OnceCell; -use std::{ - collections::HashMap, - env, fs, - io::{self, Write}, - path::{Path, PathBuf}, - sync::{Arc, Mutex, PoisonError}, -}; -use tempfile::{tempdir, TempDir}; - -/// Env var convenience type. -type EnvMap = HashMap; - -/// Initialized in [`get_env_locker`] -static ENV_LOCKER: OnceCell>> = OnceCell::new(); - -/// A test environment. -/// -/// Will create a new temporary directory. Use its builder methods to configure -/// the directory structure, preset variables, envfile name and contents, and -/// the working directory to run the test from. -/// -/// Creation methods: -/// - [`TestEnv::init`]: blank environment (no envfile) -/// - [`TestEnv::init_with_envfile`]: blank environment with an envfile -/// - [`TestEnv::default`]: default testing environment (1 existing var and 2 -/// set in a `.env` file) -#[derive(Debug)] -pub struct TestEnv { - temp_dir: TempDir, - work_dir: PathBuf, - env_vars: Vec, - envfile_contents: Option, - envfile_path: PathBuf, -} - -/// Simple key value struct for representing environment variables -#[derive(Debug, Clone)] -pub struct KeyVal { - key: String, - value: String, -} - -/// Run a test closure within a test environment. -/// -/// Resets the environment variables, loads the [`TestEnv`], then runs the test -/// closure. Ensures only one thread has access to the process environment. -pub fn test_in_env(test_env: TestEnv, test: F) -where - F: FnOnce(), -{ - let locker = get_env_locker(); - // ignore a poisoned mutex - // we expect some tests may panic to indicate a failure - let original_env = locker.lock().unwrap_or_else(PoisonError::into_inner); - // we reset the environment anyway upon acquiring the lock - reset_env(&original_env); - create_env(&test_env); - test(); - // drop the lock and the `TestEnv` - should delete the tempdir -} - -/// Run a test closure within the default test environment. -/// -/// Resets the environment variables, creates the default [`TestEnv`], then runs -/// the test closure. Ensures only one thread has access to the process -/// environment. -/// -/// The default testing environment sets an existing environment variable -/// `TEST_EXISTING_KEY`, which is set to `from_env`. It also creates a `.env` -/// file with the two lines: -/// -/// ```ini -/// TESTKEY=test_val -/// TEST_EXISTING_KEY=from_file -/// ``` -/// -/// Notice that file has the potential to override `TEST_EXISTING_KEY` depending -/// on the what's being tested. -pub fn test_in_default_env(test: F) -where - F: FnOnce(), -{ - let test_env = TestEnv::default(); - test_in_env(test_env, test); -} - -impl TestEnv { - /// Blank testing environment in a new temporary directory. - /// - /// No envfile_contents or pre-existing variables to set. The envfile_name - /// is set to `.env` but won't be written until its content is set. The - /// working directory is the created temporary directory. - pub fn init() -> Self { - let tempdir = tempdir().expect("create tempdir"); - let work_dir = tempdir.path().to_owned(); - let envfile_path = work_dir.join(".env"); - Self { - temp_dir: tempdir, - work_dir, - env_vars: Default::default(), - envfile_contents: None, - envfile_path, - } - } - - /// Testing environment with custom envfile_contents. - /// - /// No pre-existing env_vars set. The envfile_name is set to `.env`. The - /// working directory is the created temporary directory. - pub fn init_with_envfile(contents: impl ToString) -> Self { - let mut test_env = Self::init(); - test_env.set_envfile_contents(contents); - test_env - } - - /// Change the name of the default `.env` file. - /// - /// It will still be placed in the root temporary directory. If you need to - /// put the envfile in a different directory, use - /// [`set_envfile_path`](TestEnv::set_envfile_path) instead. - pub fn set_envfile_name(&mut self, name: impl AsRef) -> &mut Self { - self.envfile_path = self.temp_path().join(name); - self - } - - /// Change the absolute path to the envfile. - pub fn set_envfile_path(&mut self, path: PathBuf) -> &mut Self { - self.envfile_path = path; - self - } - - /// Specify the contents of the envfile. - /// - /// If this is the only change to the [`TestEnv`] being made, use - /// [`new_with_envfile`](TestEnv::new_with_envfile). - /// - /// Setting it to an empty string will cause an empty envfile to be created - pub fn set_envfile_contents(&mut self, contents: impl ToString) -> &mut Self { - self.envfile_contents = Some(contents.to_string()); - self - } - - /// Set the working directory the test will run from. - /// - /// The default is the created temporary directory. This method is useful if - /// you wish to run a test from a subdirectory or somewhere else. - pub fn set_work_dir(&mut self, path: PathBuf) -> &mut Self { - self.work_dir = path; - self - } - - /// Add an individual environment variable. - /// - /// This adds more pre-existing environment variables to the process before - /// any tests are run. - pub fn add_env_var(&mut self, key: impl ToString, value: impl ToString) -> &mut Self { - self.env_vars.push(KeyVal { - key: key.to_string(), - value: value.to_string(), - }); - self - } - - /// Set the pre-existing environment variables. - /// - /// These variables will get added to the process' environment before the - /// test is run. This overrides any previous env vars added to the - /// [`TestEnv`]. - /// - /// If you wish to just use a slice of tuples, use - /// [`set_env_vars_tuple`](TestEnv::set_env_vars_tuple) instead. - pub fn set_env_vars(&mut self, env_vars: Vec) -> &mut Self { - self.env_vars = env_vars; - self - } - - /// Set the pre-existing environment variables using [`str`] tuples. - /// - /// These variables will get added to the process' environment before the - /// test is run. This overrides any previous env vars added to the - /// [`TestEnv`]. - /// - /// If you wish to add an owned `Vec` instead of `str` tuples, use - /// [`set_env_vars`](TestEnv::set_env_vars) instead. - pub fn set_env_vars_tuple(&mut self, env_vars: &[(&str, &str)]) -> &mut Self { - self.env_vars = env_vars - .iter() - .map(|(key, value)| KeyVal { - key: key.to_string(), - value: value.to_string(), - }) - .collect(); - - self - } - - /// Create a child folder within the temporary directory. - /// - /// This will not change the working directory the test is run in, or where - /// the envfile is created. - /// - /// Will create parent directories if they are missing. - pub fn add_child_dir_all(&self, rel_path: impl AsRef) -> PathBuf { - let rel_path = rel_path.as_ref(); - let child_dir = self.temp_path().join(rel_path); - if let Err(err) = fs::create_dir_all(&child_dir) { - panic!( - "unable to create child directory: `{}` in `{}`: {}", - self.temp_path().display(), - rel_path.display(), - err - ); - } - child_dir - } - - /// Get a reference to the path of the temporary directory. - pub fn temp_path(&self) -> &Path { - self.temp_dir.path() - } - - /// Get a reference to the working directory the test will be run from. - pub fn work_dir(&self) -> &Path { - &self.work_dir - } - - /// Get a reference to environment variables that will be set **before** - /// the test. - pub fn env_vars(&self) -> &[KeyVal] { - &self.env_vars - } - - /// Get a reference to the string that will be placed in the envfile. - /// - /// If `None` is returned, an envfile will not be created - pub fn envfile_contents(&self) -> Option<&str> { - self.envfile_contents.as_deref() - } - - /// Get a reference to the path of the envfile. - pub fn envfile_path(&self) -> &Path { - &self.envfile_path - } -} - -impl Default for TestEnv { - fn default() -> Self { - let temp_dir = tempdir().expect("create tempdir"); - let work_dir = temp_dir.path().to_owned(); - let env_vars = vec![KeyVal { - key: TEST_EXISTING_KEY.into(), - value: TEST_EXISTING_VALUE.into(), - }]; - let envfile_contents = Some(create_default_envfile()); - let envfile_path = work_dir.join(".env"); - Self { - temp_dir, - work_dir, - env_vars, - envfile_contents, - envfile_path, - } - } -} - -impl From<(&str, &str)> for KeyVal { - fn from(kv: (&str, &str)) -> Self { - let (key, value) = kv; - Self { - key: key.to_string(), - value: value.to_string(), - } - } -} - -impl From<(String, String)> for KeyVal { - fn from(kv: (String, String)) -> Self { - let (key, value) = kv; - Self { key, value } - } -} - -/// Get a guarded copy of the original process' env vars. -fn get_env_locker() -> Arc> { - Arc::clone(ENV_LOCKER.get_or_init(|| { - let map: EnvMap = env::vars().collect(); - Arc::new(Mutex::new(map)) - })) -} - -/// Reset the process' env vars back to what was in `original_env`. -fn reset_env(original_env: &EnvMap) { - // remove keys if they weren't in the original environment - env::vars() - .filter(|(key, _)| !original_env.contains_key(key)) - .for_each(|(key, _)| env::remove_var(key)); - // ensure original keys have their original values - original_env - .iter() - .for_each(|(key, value)| env::set_var(key, value)); -} - -/// Create an environment to run tests in. -/// -/// Writes the envfile, sets the working directory, and sets environment vars. -fn create_env(test_env: &TestEnv) { - // only create the envfile if its contents has been set - if let Some(contents) = test_env.envfile_contents() { - create_envfile(&test_env.envfile_path, contents); - } - - env::set_current_dir(&test_env.work_dir).expect("setting working directory"); - - for KeyVal { key, value } in &test_env.env_vars { - env::set_var(key, value) - } -} - -/// Create an envfile for use in tests. -fn create_envfile(path: &Path, contents: &str) { - if path.exists() { - panic!("envfile `{}` already exists", path.display()) - } - // inner function to group together io::Results - fn create_env_file_inner(path: &Path, contents: &str) -> io::Result<()> { - let mut file = fs::File::create(path)?; - file.write_all(contents.as_bytes())?; - file.sync_all() - } - // call inner function - if let Err(err) = create_env_file_inner(path, contents) { - // handle any io::Result::Err - panic!("error creating envfile `{}`: {}", path.display(), err); - } -} diff --git a/test_util/Cargo.toml b/test_util/Cargo.toml new file mode 100644 index 0000000..6513312 --- /dev/null +++ b/test_util/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dotenvy_test_util" +version = "0.1.0" +authors = [ + "Christopher Morton ", + "Allan Zhang ", +] +description = "Test utilities for dotenvy" +homepage = "https://github.com/allan2/dotenvy" +readme = "README.md" +keywords = ["dotenv", "testing", "utility", "util", "harness"] +categories = ["development-tools", "development-tools::testing"] +license = "MIT" +repository = "https://github.com/allan2/dotenvy" +edition = "2021" +rust-version = "1.68.0" + +[dependencies] +tempfile = "3.3.0" +once_cell = "1.16.0" diff --git a/test_util/LICENSE b/test_util/LICENSE new file mode 100644 index 0000000..1fc6dfd --- /dev/null +++ b/test_util/LICENSE @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2024 Christopher Morton and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/test_util/README.md b/test_util/README.md new file mode 100644 index 0000000..f9072bc --- /dev/null +++ b/test_util/README.md @@ -0,0 +1,109 @@ +# dotenvy test util + +This is an internal package used for testing dotenvy. + +## Why + +Eases the annoyance of setting up custom `.env` files, managing existing +environment variables, and running multiple tests at once. + +## How + +By setting up a `TestEnv`, and running a closure via `test_in_env`. + +**Before** executing the closure, the `TestEnv` will: + +- Create a temporary directory +- Lock the environment from other tests +- Store the existing environment variables +- Add any custom env_vars to the environment +- Create any custom env files in the temporary directory + +**In the closure** you can use the assertion functions to test the environment. + +**After** executing the closure, the `TestEnv` will: + +- Remove the temporary directory +- Restore the environment variables to the original state +- Unlock the environment + +See the API docs for more details. For now, they must be built locally with +`cargo doc`. + +### Commented example + +```rust +use dotenvy_test_util::*; +use dotenvy::dotenv_override; + +const EXISTING_KEY: &str = "TEST_KEY"; +const EXISTING_VAL: &str = "test_val"; +const OVERRIDING_VAL: &str = "overriding_val"; + +#[test] +fn dotenv_override_existing_key() -> Result<(), Error> { + // setup testing environment + let mut testenv = TestEnv::init()?; + + // with an existing environment variable + testenv.add_env_var(EXISTING_KEY, EXISTING_VAL)?; + + // with an env file that overrides it + testenv.add_env_file( + ".env", + create_custom_env_file(&[(EXISTING_KEY, OVERRIDING_VAL)]), + )?; + + // execute a closure in the testing environment + test_in_env(&testenv, || { + dotenv_override().expect(".env should be loaded"); + assert_env_var(EXISTING_KEY, OVERRIDING_VAL); + }) + // any changes to environment variables will be reset for other tests +} +``` + +### Customised Envfile + +Use the `EnvFileContents` to manipulate the content of an env file. Useful +for byte-order-mark(BOM) testing, and other edge cases. + +```rust +use dotenvy_test_util::*; +use dotenvy::dotenv; + +#[test] +fn comments_ignored_in_utf8bom_env_file() -> Result<(), Error> { + let mut efc = EnvFileContents::new(); + efc.push_bytes(&[0xEF, 0xBB, 0xBF]); + efc.push_str("# TEST_KEY=TEST_VAL\n"); + + let testenv = TestEnv::init_with_env_file(efc)?; + + test_in_env(&testenv, || { + dotenv().expect(".env should be loaded"); + assert_env_var_unset("TEST_KEY"); + }) +} +``` + +Or use anything that can be converted to a `Vec` if your env_file is +simple. + +```rust +use dotenvy_test_util::*; +use dotenvy::dotenv; + +#[test] +fn comments_ignored() -> Result<(), Error> { + let env_file = "# TEST_KEY=TEST_VAL\n"; + + let testenv = TestEnv::init_with_env_file(env_file)?; + + test_in_env(&testenv, || { + dotenv().expect(".env should be loaded"); + assert_env_var_unset("TEST_KEY"); + }) +} +``` + diff --git a/test_util/src/assertions.rs b/test_util/src/assertions.rs new file mode 100644 index 0000000..dc67153 --- /dev/null +++ b/test_util/src/assertions.rs @@ -0,0 +1,50 @@ +use std::env::{self, VarError}; + +/// Assert multiple environment variables are set and have the expected +/// values. +/// +/// ## Arguments +/// +/// * `vars` - A slice of `(key, expected_value)` tuples +/// +/// ## Example +/// +/// ```no_run +/// # use dotenvy_test_util::assert_env_vars; +/// assert_env_vars(&[ +/// ("TEST_KEY", "test_val"), +/// ("EXISTING_KEY", "loaded_from_env"), +/// ]); +/// ``` +pub fn assert_env_vars(vars: &[(&str, &str)]) { + for (key, expected) in vars { + assert_env_var(key, expected); + } +} + +/// Assert environment variable is set and has the expected value. +pub fn assert_env_var(key: &str, expected: &str) { + match env::var(key) { + Ok(actual) => assert_eq!( + expected, actual, + "\n\nFor Environment Variable `{key}`:\n EXPECTED: `{expected}`\n ACTUAL: `{actual}`\n", + ), + Err(VarError::NotPresent) => panic!("env var `{key}` not found"), + Err(VarError::NotUnicode(val)) => panic!( + "env var `{key}` currently has invalid unicode: `{}`", + val.to_string_lossy() + ), + } +} + +/// Assert environment variable is not currently set. +pub fn assert_env_var_unset(key: &str) { + match env::var(key) { + Ok(actual) => panic!("env var `{key}` should not be set, currently it is: `{actual}`",), + Err(VarError::NotUnicode(val)) => panic!( + "env var `{key}` should not be set, currently has invalid unicode: `{}`", + val.to_string_lossy() + ), + _ => (), + } +} diff --git a/test_util/src/env_file.rs b/test_util/src/env_file.rs new file mode 100644 index 0000000..1d5be38 --- /dev/null +++ b/test_util/src/env_file.rs @@ -0,0 +1,160 @@ +use std::path::PathBuf; + +use crate::Error; + +#[derive(Debug, Clone)] +/// Simple path and byte contents representing a `.env` file +pub struct EnvFile { + pub path: PathBuf, + pub contents: Vec, +} + +/// `.env` file builder. +/// +/// Represented as bytes to allow for advanced manipulation and BOM testing. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct EnvFileContents { + contents: Vec, +} + +impl EnvFileContents { + pub const fn new() -> Self { + Self { + contents: Vec::new(), + } + } + + /// Build a byte vector from the contents of the builder. + pub fn build(&self) -> Vec { + self.contents.clone() + } + + /// Build a string from the contents of the builder. + /// + /// ## Errors + /// + /// If the contents of the builder is not valid UTF-8. + pub fn build_string(&self) -> Result { + Ok(String::from_utf8(self.contents.clone())?) + } + + /// Transform the builder into a byte vector. + pub fn into_owned_bytes(self) -> Vec { + self.contents + } + + /// Transform the builder into a string. + /// + /// ## Errors + /// + /// If the contents of the builder is not valid UTF-8. + pub fn into_owned_string(self) -> Result { + Ok(String::from_utf8(self.contents)?) + } + + /// Get a reference to the contents of the builder. + pub fn as_bytes(&self) -> &[u8] { + &self.contents + } + + /// Returns true when the contents of the builder is empty. + pub fn is_empty(&self) -> bool { + self.contents.is_empty() + } + + /// Append a key-value pair and newline + pub fn add_var(&mut self, key: &str, value: &str) -> &mut Self { + self.push_str(&format!("{key}={value}\n")) + } + + /// Apeend a string + pub fn push_str(&mut self, s: &str) -> &mut Self { + self.push_bytes(s.as_bytes()) + } + + /// Append a byte slice + pub fn push_bytes(&mut self, bytes: &[u8]) -> &mut Self { + self.contents.extend_from_slice(bytes); + self + } + + /// Append a single byte + pub fn push(&mut self, byte: u8) -> &mut Self { + self.contents.push(byte); + self + } +} + +impl From for Vec { + fn from(builder: EnvFileContents) -> Self { + builder.into_owned_bytes() + } +} + +impl From> for EnvFileContents { + fn from(contents: Vec) -> Self { + Self { contents } + } +} + +impl From for EnvFileContents { + fn from(contents: String) -> Self { + Self { + contents: contents.into_bytes(), + } + } +} + +impl AsRef<[u8]> for EnvFileContents { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +impl PartialEq for EnvFileContents { + fn eq(&self, other: &String) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl PartialEq for EnvFileContents { + fn eq(&self, other: &str) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl PartialEq> for EnvFileContents { + fn eq(&self, other: &Vec) -> bool { + self.as_bytes() == other + } +} + +impl PartialEq<[u8]> for EnvFileContents { + fn eq(&self, other: &[u8]) -> bool { + self.as_bytes() == other + } +} + +impl PartialEq for String { + fn eq(&self, other: &EnvFileContents) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl PartialEq for &str { + fn eq(&self, other: &EnvFileContents) -> bool { + self.as_bytes() == other.as_bytes() + } +} + +impl PartialEq for Vec { + fn eq(&self, other: &EnvFileContents) -> bool { + self == other.as_bytes() + } +} + +impl PartialEq for &[u8] { + fn eq(&self, other: &EnvFileContents) -> bool { + *self == other.as_bytes() + } +} diff --git a/test_util/src/error.rs b/test_util/src/error.rs new file mode 100644 index 0000000..6b86767 --- /dev/null +++ b/test_util/src/error.rs @@ -0,0 +1,84 @@ +use std::error::Error as StdError; +use std::path::PathBuf; +use std::string::FromUtf8Error; +use std::{fmt, io}; + +#[derive(Debug)] +pub enum Error { + CanonicalizingPath(PathBuf, io::Error), + CreatingChildDir(PathBuf, io::Error), + CreatingEnvFile(PathBuf, io::Error), + CreatingTempDir(io::Error), + EnvFileConflict(PathBuf), + EnvFilePathSameAsTempDir, + InvalidUtf8(FromUtf8Error), + KeyConflict(String), + KeyEmpty, + PathNotFound(PathBuf), + SettingCurrentDir(PathBuf, io::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CanonicalizingPath(path, err) => { + write!(f, "canonicalizing path `{}`: {err}", path.display()) + } + Self::CreatingChildDir(path, err) => { + write!(f, "creating child directory `{}`: {err}", path.display()) + } + Self::CreatingEnvFile(path, err) => { + write!(f, "creating env file `{}`: {err}", path.display()) + } + Self::CreatingTempDir(err) => { + write!(f, "creating temporary directory: {err}") + } + Self::EnvFileConflict(path) => { + write!( + f, + "env file path `{}` already in test environment", + path.display() + ) + } + Self::EnvFilePathSameAsTempDir => { + write!( + f, + "env file path cannot be the same as the temporary directory" + ) + } + Self::InvalidUtf8(err) => write!(f, "invalid utf8: {err}"), + Self::KeyConflict(key) => { + write!(f, "key `{key}` already in test environment") + } + Self::KeyEmpty => write!(f, "key cannot be empty"), + Self::PathNotFound(path) => write!(f, "path not found: {}", path.display()), + Self::SettingCurrentDir(path, err) => { + write!( + f, + "setting current directory to `{}`: {err}", + path.display() + ) + } + } + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + match self { + Self::InvalidUtf8(err) => Some(err), + Self::CanonicalizingPath(_, err) + | Self::CreatingChildDir(_, err) + | Self::CreatingEnvFile(_, err) + | Self::CreatingTempDir(err) + | Self::SettingCurrentDir(_, err) => Some(err), + _ => None, + } + } +} + +impl From for Error { + fn from(err: FromUtf8Error) -> Self { + Self::InvalidUtf8(err) + } +} diff --git a/test_util/src/lib.rs b/test_util/src/lib.rs new file mode 100644 index 0000000..0008d75 --- /dev/null +++ b/test_util/src/lib.rs @@ -0,0 +1,73 @@ +#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] +#![allow( + clippy::must_use_candidate, + clippy::missing_panics_doc, + clippy::wildcard_imports, + clippy::module_name_repetitions +)] + +//! Test environment setup, assertions and helpers. +//! +//! Setup a [`TestEnv`] and run your tests via [`test_in_env`]. The environment +//! can be tweaked with: +//! +//! - pre-existing environment variables, +//! - different directory layouts, +//! - custom `.env` file contents, +//! - multiple `.env` files, +//! - custom env file name/path. +//! +//! Customize your env files using [`EnvFileContents`]. +//! +//! In your tests, call your environment altering functions such as the +//! [`dotenvy`] API, then make use of the `assert_` helpers, such as +//! [`assert_env_var`] and [`assert_env_var_unset`], to check the state of +//! the environment. +//! +//! ## Example +//! +//! ```rust,ignore +//! use dotenvy_test_util::*; +//! use dotenvy::dotenv_override; +//! +//! const EXISTING_KEY: &str = "TEST_KEY"; +//! const EXISTING_VAL: &str = "test_val"; +//! const OVERRIDING_VAL: &str = "overriding_val"; +//! +//! #[test] +//! fn dotenv_override_existing_key() -> Result<(), Error> { +//! // setup testing environment +//! let mut testenv = TestEnv::new()?; +//! +//! // with an existing environment variable +//! testenv.add_env_var(EXISTING_KEY, EXISTING_VAL)?; +//! +//! // with an env file that overrides it +//! testenv.add_env_file( +//! ".env", +//! format!("{EXISTING_KEY}={OVERRIDING_VAL}"), +//! )?; +//! +//! // execute a closure in the testing environment +//! test_in_env(&testenv, || { +//! dotenv_override().expect(".env should be loaded"); +//! assert_env_var(EXISTING_KEY, OVERRIDING_VAL); +//! }) +//! // any changes to environment variables will be reset for other tests +//! } +//! ``` +//! +//! [`dotenvy`]: https://docs.rs/dotenvy + +mod assertions; +mod env_file; +mod error; +mod testenv; + +#[cfg(test)] +mod tests; + +pub use assertions::*; +pub use env_file::*; +pub use error::*; +pub use testenv::*; diff --git a/test_util/src/testenv.rs b/test_util/src/testenv.rs new file mode 100644 index 0000000..2acc1a4 --- /dev/null +++ b/test_util/src/testenv.rs @@ -0,0 +1,318 @@ +use once_cell::sync::OnceCell; +use std::{ + collections::HashMap, + env, fs, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, PoisonError}, +}; +use tempfile::{tempdir, TempDir}; + +use crate::{EnvFile, Error}; + +/// Env var convenience type. +type EnvMap = HashMap; + +/// Initialized in [`get_env_locker`] +static ENV_LOCKER: OnceCell>> = OnceCell::new(); + +/// A test environment. +/// +/// Will create a new temporary directory. Use its builder methods to configure +/// the directory structure, preset variables, env file name and contents, and +/// the working directory to run the test from. +/// +/// Creation methods: +/// - [`TestEnv::new`]: blank environment (no env file) +/// - [`TestEnv::new_with_env_file`]: blank environment with a custom `.env` +#[derive(Debug)] +pub struct TestEnv { + // Temporary directory that will be deleted on drop + _temp_dir: TempDir, + dir_path: PathBuf, + work_dir: PathBuf, + env_vars: EnvMap, + env_files: Vec, +} + +/// Run a test closure within a test environment. +/// +/// Resets the environment variables, loads the [`TestEnv`], then runs the test +/// closure. Ensures only one thread has access to the process environment. +/// +/// ## Errors +/// +/// - if the test fails +pub fn test_in_env(testenv: &TestEnv, test: F) -> Result<(), Error> +where + F: FnOnce(), +{ + let locker = get_env_locker(); + // ignore a poisoned mutex + // we expect some tests may panic to indicate a failure + // we reset the environment anyway upon acquiring the lock + let original_env = locker.lock().unwrap_or_else(PoisonError::into_inner); + // Safety: we hold the lock so no other thread can access the environment + unsafe { reset_env(&original_env) }; + setup_files(testenv)?; + unsafe { setup_env(testenv.env_vars()) }; + test(); + unsafe { reset_env(&original_env) }; + Ok(()) + // drop the lock +} + +impl TestEnv { + /// Blank testing environment in a new temporary directory. + /// + /// No env file or pre-existing variables set. The working directory is the + /// created temporary directory. + /// + /// ## Errors + /// + /// - if creating the temporary directory fails + /// - if canonicalizing the temporary directory's path fails + pub fn new() -> Result { + let tempdir = tempdir().map_err(Error::CreatingTempDir)?; + let dir_path = canonicalize_path(tempdir.path())?; + let testenv = Self { + _temp_dir: tempdir, + work_dir: dir_path.clone(), + dir_path, + env_vars: HashMap::default(), + env_files: vec![], + }; + Ok(testenv) + } + + /// Testing environment with custom env file contents. + /// + /// No pre-existing env vars set. The env file path is set to `.env`. The + /// working directory is the created temporary directory. + /// + /// ## Errors + /// + /// - if creating the temporary directory fails + /// - if canonicalizing the temporary directory's path fails + pub fn new_with_env_file(contents: impl Into>) -> Result { + let mut testenv = Self::new()?; + testenv.add_env_file(".env", contents)?; + Ok(testenv) + } + + /// Add an individual env file. + /// + /// ## Arguments + /// + /// - `path`: relative from the temporary directory + /// - `contents`: bytes or string + /// + /// ## Errors + /// + /// - if the path is empty or the same as the temporary directory + /// - if the env file already exists + pub fn add_env_file(&mut self, path: P, contents: C) -> Result<&mut Self, Error> + where + P: AsRef, + C: Into>, + { + let path = self.dir_path.join(path); + self.check_env_file_path_is_valid(&path)?; + self.add_env_file_assume_valid(path, contents.into()); + Ok(self) + } + + /// Add an individual environment variable. + /// + /// This adds more pre-existing environment variables to the process before + /// any tests are run. + /// + /// ## Errors + /// + /// - if the env var already exists in the testenv + /// - if the key is empty + pub fn add_env_var(&mut self, key: K, value: V) -> Result<&mut Self, Error> + where + K: Into, + V: Into, + { + let key = key.into(); + self.check_env_var_is_valid(&key)?; + self.env_vars.insert(key, value.into()); + Ok(self) + } + + /// Set all the pre-existing environment variables. + /// + /// These variables will get added to the process' environment before the + /// test is run. This overrides any previous env vars added to the + /// [`TestEnv`]. + /// + /// ## Errors + /// + /// - if an env var is set twice + /// - if a key is empty + pub fn set_env_vars(&mut self, env_vars: &[(&str, &str)]) -> Result<&mut Self, Error> { + for &(key, value) in env_vars { + self.add_env_var(key, value)?; + } + Ok(self) + } + + /// Set the working directory the test will run from. + /// + /// The default is the created temporary directory. This method is useful if + /// you wish to run a test from a subdirectory or somewhere else. + /// + /// ## Arguments + /// + /// - `path`: relative from the temporary directory + /// + /// ## Errors + /// + /// - if the path does not exist + /// - if canonicalizing the path fails + pub fn set_work_dir(&mut self, path: impl AsRef) -> Result<&mut Self, Error> { + let path = self.dir_path.join(path); + if !path.exists() { + return Err(Error::PathNotFound(path)); + } + self.work_dir = canonicalize_path(path)?; + Ok(self) + } + + /// Create a child folder within the temporary directory. + /// + /// This will not change the working directory the test is run in, or where + /// the env file is created. + /// + /// Will create parent directories if they are missing. + /// + /// ## Errors + /// + /// - if creating the directory fails + pub fn add_child_dir(&mut self, path: impl AsRef) -> Result<&mut Self, Error> { + let path = path.as_ref(); + let child_dir = self.temp_path().join(path); + fs::create_dir_all(&child_dir).map_err(|err| Error::CreatingChildDir(child_dir, err))?; + Ok(self) + } + + /// Reference to the path of the temporary directory. + pub fn temp_path(&self) -> &Path { + &self.dir_path + } + + /// Reference to the working directory the test will be run from. + pub fn work_dir(&self) -> &Path { + &self.work_dir + } + + /// Reference to environment variables that will be set **before** the test. + pub const fn env_vars(&self) -> &EnvMap { + &self.env_vars + } + + /// Get a reference to the environment files that will created + pub fn env_files(&self) -> &[EnvFile] { + &self.env_files + } + + fn add_env_file_assume_valid(&mut self, path: PathBuf, contents: Vec) -> &mut Self { + let env_file = EnvFile { path, contents }; + self.env_files.push(env_file); + self + } + + fn check_env_file_path_is_valid(&self, path: &Path) -> Result<(), Error> { + if path == self.temp_path() { + return Err(Error::EnvFilePathSameAsTempDir); + } + if self.env_files.iter().any(|f| f.path == path) { + return Err(Error::EnvFileConflict(path.to_owned())); + } + Ok(()) + } + + fn check_env_var_is_valid(&self, key: &str) -> Result<(), Error> { + if key.is_empty() { + return Err(Error::KeyEmpty); + } + if self.env_vars.contains_key(key) { + return Err(Error::KeyConflict(key.to_owned())); + } + Ok(()) + } +} + +/// Get a guarded copy of the original process' env vars. +fn get_env_locker() -> Arc> { + Arc::clone(ENV_LOCKER.get_or_init(|| { + let map: EnvMap = env::vars().collect(); + Arc::new(Mutex::new(map)) + })) +} + +/// Reset the process' env vars back to what was in `original_env`. +/// +/// ## Safety +/// +/// This function should only be called in a single-threaded context. +unsafe fn reset_env(original_env: &EnvMap) { + // remove keys if they weren't in the original environment + env::vars() + .filter(|(key, _)| !original_env.contains_key(key)) + .for_each(|(key, _)| env::remove_var(key)); + // ensure original keys have their original values + original_env + .iter() + .for_each(|(key, value)| env::set_var(key, value)); +} + +/// ## Safety +/// +/// This function should only be called in a single-threaded context. +unsafe fn setup_env(env_vars: &EnvMap) { + for (key, value) in env_vars { + env::set_var(key, value); + } +} + +/// Create an environment to run tests in. +/// +/// Writes the env files, sets the working directory, and sets environment vars. +fn setup_files(testenv: &TestEnv) -> Result<(), Error> { + env::set_current_dir(&testenv.work_dir) + .map_err(|err| Error::SettingCurrentDir(testenv.work_dir.clone(), err))?; + + for EnvFile { path, contents } in &testenv.env_files { + create_env_file(path, contents)?; + } + + Ok(()) +} + +/// Create an env file for use in tests. +fn create_env_file(path: &Path, contents: &[u8]) -> Result<(), Error> { + if path.exists() { + return Err(Error::EnvFileConflict(path.to_owned())); + } + + create_env_file_inner(path, contents) + .map_err(|err| Error::CreatingEnvFile(path.to_owned(), err))?; + + Ok(()) +} + +// inner function to group together io::Results +fn create_env_file_inner(path: &Path, contents: &[u8]) -> io::Result<()> { + let mut file = fs::File::create(path)?; + file.write_all(contents)?; + file.sync_all() +} + +fn canonicalize_path(path: impl AsRef) -> Result { + let path = path.as_ref(); + path.canonicalize() + .map_err(|err| Error::CanonicalizingPath(path.to_owned(), err)) +} diff --git a/test_util/src/tests/env_file.rs b/test_util/src/tests/env_file.rs new file mode 100644 index 0000000..e54c796 --- /dev/null +++ b/test_util/src/tests/env_file.rs @@ -0,0 +1,89 @@ +use super::*; + +#[test] +fn new_builds_empty() { + let efc = EnvFileContents::new(); + assert!(efc.is_empty()); +} + +#[test] +fn default_builds_empty() { + let efc = EnvFileContents::default(); + assert!(efc.is_empty()); +} + +#[test] +fn add_key_empty_value() { + let mut efc = EnvFileContents::new(); + efc.add_var(TEST_KEY, ""); + let expected = format!("{TEST_KEY}=\n"); + assert_eq!(expected, efc); +} + +#[test] +fn add_key_value() { + let mut efc = EnvFileContents::new(); + efc.add_var(TEST_KEY, TEST_VALUE); + let expected = format!("{TEST_KEY}={TEST_VALUE}\n"); + assert_eq!(expected, efc); +} + +#[test] +fn add_multiple_key_values() { + let mut efc = EnvFileContents::new(); + efc.add_var(TEST_KEY, TEST_VALUE); + efc.add_var(EXISTING_KEY, OVERRIDING_VALUE); + let expected = expected_env_file(&[(TEST_KEY, TEST_VALUE), (EXISTING_KEY, OVERRIDING_VALUE)]); + assert_eq!(expected, efc); +} + +#[test] +fn add_str() { + let mut efc = EnvFileContents::new(); + efc.push_str("test"); + assert_eq!("test", efc); +} + +#[test] +fn add_bytes() { + let mut efc = EnvFileContents::new(); + efc.push_bytes(b"test"); + assert_eq!("test", efc); +} + +#[test] +fn add_byte() { + let mut efc = EnvFileContents::new(); + efc.push(b't'); + assert_eq!("t", efc); +} + +#[test] +fn insert_utf8_bom() { + let mut efc = EnvFileContents::new(); + efc.push_bytes(&[0xEF, 0xBB, 0xBF]); + efc.push_str("test"); + assert_eq!("\u{FEFF}test", efc); +} + +#[test] +fn from_vec_u8() { + let vec: Vec = Vec::from(create_test_env_file()); + let efc = EnvFileContents::from(vec); + assert_eq!(create_test_env_file(), efc); +} + +#[test] +fn to_vec_u8() { + let mut efc = EnvFileContents::new(); + efc.push_str(create_test_env_file().as_str()); + let vec = Vec::from(efc); + let expected = create_test_env_file().into_bytes(); + assert_eq!(expected, vec); +} + +#[test] +fn from_string() { + let efc = EnvFileContents::from(create_test_env_file()); + assert_eq!(create_test_env_file(), efc); +} diff --git a/test_util/src/tests/mod.rs b/test_util/src/tests/mod.rs new file mode 100644 index 0000000..69abda1 --- /dev/null +++ b/test_util/src/tests/mod.rs @@ -0,0 +1,85 @@ +use std::path::Path; + +use super::*; + +mod env_file; +mod testenv; + +const TEST_KEY: &str = "TEST_KEY"; +const TEST_VALUE: &str = "test_val"; + +const EXISTING_KEY: &str = "EXISTING_KEY"; +const OVERRIDING_VALUE: &str = "loaded_from_file"; + +const CUSTOM_VARS: &[(&str, &str)] = &[ + ("CUSTOM_KEY_1", "CUSTOM_VALUE_1"), + ("CUSTOM_KEY_2", "CUSTOM_VALUE_2"), +]; + +fn test_env_files(testenv: &TestEnv) -> Result<(), Error> { + let files = testenv.env_files(); + + test_in_env(testenv, || { + for EnvFile { path, contents } in files { + assert_env_file(path, contents); + } + }) +} + +fn test_keys_not_set(testenv: &TestEnv) -> Result<(), Error> { + test_in_env(testenv, assert_test_keys_unset) +} + +fn test_env_vars(testenv: &TestEnv, vars: &[(&str, &str)]) -> Result<(), Error> { + test_in_env(testenv, || assert_env_vars(vars)) +} + +fn assert_path_exists(testenv: &TestEnv, path: impl AsRef) { + let path = testenv.temp_path().join(path.as_ref()); + assert!(path.exists(), "{} should exist in testenv", path.display()); +} + +fn assert_test_keys_unset() { + assert_env_var_unset(TEST_KEY); + assert_env_var_unset(EXISTING_KEY); +} + +fn assert_env_file(path: &Path, expected: &[u8]) { + assert!(path.exists(), "{} should exist in testenv", path.display()); + + let actual = std::fs::read(path) + .unwrap_or_else(|e| panic!("failed to read {} in testenv: {}", path.display(), e)); + + assert_eq!( + expected, + &actual, + "{} has incorrect contents", + path.display() + ); +} + +fn expected_env_file(env_vars: &[(&str, &str)]) -> String { + let mut env_file = String::new(); + for (key, value) in env_vars { + env_file.push_str(key); + env_file.push('='); + env_file.push_str(value); + env_file.push('\n'); + } + env_file +} + +fn create_test_env_file() -> String { + format!("{TEST_KEY}={TEST_VALUE}\n{EXISTING_KEY}={OVERRIDING_VALUE}") +} + +fn create_custom_env_file() -> String { + let mut env_file = String::new(); + for (key, value) in CUSTOM_VARS { + env_file.push_str(key); + env_file.push('='); + env_file.push_str(value); + env_file.push('\n'); + } + env_file +} diff --git a/test_util/src/tests/testenv.rs b/test_util/src/tests/testenv.rs new file mode 100644 index 0000000..2d6a096 --- /dev/null +++ b/test_util/src/tests/testenv.rs @@ -0,0 +1,397 @@ +use super::*; + +mod new { + use std::collections::HashMap; + + use super::*; + + #[test] + fn vars_state() -> Result<(), Error> { + let testenv = TestEnv::new()?; + let mut vars: HashMap = HashMap::new(); + test_in_env(&testenv, || { + for (k, v) in std::env::vars() { + vars.insert(k, v); + } + })?; + for (k, v) in &vars { + assert_env_var(k.as_str(), v.as_str()); + } + Ok(()) + } + + #[test] + fn no_env_file() -> Result<(), Error> { + let testenv = TestEnv::new()?; + let env_file_path = testenv.temp_path().join(".env"); + + test_in_env(&testenv, || { + assert!(!env_file_path.exists()); + }) + } + + #[test] + fn work_dir_is_temp() -> Result<(), Error> { + let testenv = TestEnv::new()?; + assert_eq!(testenv.work_dir(), testenv.temp_path()); + Ok(()) + } + + #[test] + fn env_vars_are_empty() -> Result<(), Error> { + let testenv = TestEnv::new()?; + assert!(testenv.env_vars().is_empty()); + Ok(()) + } + + #[test] + fn env_files_are_empty() -> Result<(), Error> { + let testenv = TestEnv::new()?; + assert!(testenv.env_files().is_empty()); + Ok(()) + } +} + +mod new_with_env_file { + use super::*; + + #[test] + fn env_file_vars_state() -> Result<(), Error> { + let testenv = testenv_with_test_env_file()?; + test_keys_not_set(&testenv) + } + + #[test] + fn env_file_exists() -> Result<(), Error> { + let testenv = testenv_with_test_env_file()?; + test_env_files(&testenv) + } + + #[test] + fn custom_env_file_vars_state() -> Result<(), Error> { + let testenv = testenv_with_custom_env_file()?; + test_in_env(&testenv, || { + assert_test_keys_unset(); + for (key, _) in CUSTOM_VARS { + assert_env_var_unset(key); + } + }) + } + + #[test] + fn custom_env_file_exists() -> Result<(), Error> { + let testenv = testenv_with_custom_env_file()?; + test_env_files(&testenv) + } + + #[test] + fn empty_env_file_exists() -> Result<(), Error> { + let testenv = testenv_with_empty_env_file()?; + test_env_files(&testenv) + } + + #[test] + fn custom_bom_env_file_exists() -> Result<(), Error> { + let testenv = testenv_with_custom_bom_env_file()?; + test_env_files(&testenv) + } + + fn testenv_with_test_env_file() -> Result { + let env_file = create_test_env_file(); + TestEnv::new_with_env_file(env_file) + } + + fn testenv_with_custom_env_file() -> Result { + let env_file = create_custom_env_file(); + TestEnv::new_with_env_file(env_file) + } + + fn testenv_with_empty_env_file() -> Result { + TestEnv::new_with_env_file([]) + } + + fn testenv_with_custom_bom_env_file() -> Result { + let mut efc = EnvFileContents::new(); + let bom = b"\xEF\xBB\xBF"; + efc.push_bytes(bom); + efc.add_var(TEST_KEY, TEST_VALUE); + let env_file = efc.into_owned_string()?; + TestEnv::new_with_env_file(env_file) + } +} + +mod add_env_file { + use super::*; + + #[test] + fn errors_add_twice() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_file(".env", create_test_env_file())?; + test_add_env_file_conflict(&mut testenv, ".env"); + Ok(()) + } + + #[test] + fn errors_same_path_as_new_with_env_file() -> Result<(), Error> { + let mut testenv = TestEnv::new_with_env_file(create_test_env_file())?; + test_add_env_file_conflict(&mut testenv, ".env"); + Ok(()) + } + + #[test] + fn errors_empty_path() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let err = testenv + .add_env_file("", create_test_env_file()) + .unwrap_err(); + assert!(matches!(err, Error::EnvFilePathSameAsTempDir)); + Ok(()) + } + + #[test] + fn allow_empty_contents() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_file(".env", [])?; + test_env_files(&testenv) + } + + #[test] + fn allow_absolute_path() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let path = testenv.temp_path().join(".env"); + assert!(path.is_absolute()); + testenv.add_env_file(&path, create_test_env_file())?; + test_env_files(&testenv) + } + + #[test] + fn two_files() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_file(".env", create_test_env_file())?; + testenv.add_env_file(".env.local", create_custom_env_file())?; + test_env_files(&testenv) + } + + fn test_add_env_file_conflict(testenv: &mut TestEnv, path: impl AsRef) { + let path = path.as_ref(); + let expected_path = testenv.temp_path().join(path); + let res = testenv.add_env_file(path, create_custom_env_file()); + assert_env_file_conflict(res, expected_path); + } + + fn assert_env_file_conflict( + result: Result<&mut TestEnv, Error>, + expected_path: impl AsRef, + ) { + if let Err(err) = result { + match err { + Error::EnvFileConflict(path) => assert_eq!(expected_path.as_ref(), path), + _ => panic!("unexpected error: {err}"), + } + } else { + panic!("expected error"); + } + } +} + +mod add_env_var { + use super::*; + + #[test] + fn errors_add_twice() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_var("TEST_KEY", "one_value")?; + + let res = testenv.add_env_var("TEST_KEY", "two_value"); + + assert_key_conflict(res, "TEST_KEY"); + Ok(()) + } + + #[test] + fn errors_empty_key() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let err = testenv.add_env_var("", "value").unwrap_err(); + assert!(matches!(err, Error::KeyEmpty)); + Ok(()) + } + + #[test] + fn allow_empty_value() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_var("TEST_KEY", "")?; + test_env_vars(&testenv, &[("TEST_KEY", "")]) + } + + #[test] + fn two_vars() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let vars = [("TEST_KEY", "one_value"), ("TEST_KEY_2", "two_value")]; + testenv.add_env_var(vars[0].0, vars[0].1)?; + testenv.add_env_var(vars[1].0, vars[1].1)?; + test_env_vars(&testenv, &vars) + } + + #[test] + fn owned_strings() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_env_var("TEST_KEY".to_string(), "test_val".to_string())?; + test_env_vars(&testenv, &[("TEST_KEY", "test_val")]) + } +} + +mod set_env_vars { + use super::*; + + #[test] + fn errors_double_key() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let mut vars = VARS.to_vec(); + vars.push(VARS[0]); + + let res = testenv.set_env_vars(&vars); + + assert_key_conflict(res, VARS[0].0); + Ok(()) + } + + #[test] + fn errors_empty_key() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let err = testenv.set_env_vars(&[("", "value")]).unwrap_err(); + assert!(matches!(err, Error::KeyEmpty)); + Ok(()) + } + + #[test] + fn from_tuples_slice() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.set_env_vars(VARS.as_slice())?; + test_vars(&testenv) + } + + #[test] + fn from_tuples_ref() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.set_env_vars(&VARS)?; + test_vars(&testenv) + } + + #[test] + fn from_vec_slice() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let vec = VARS.to_vec(); + testenv.set_env_vars(vec.as_slice())?; + test_vars(&testenv) + } + + const VARS: [(&str, &str); 2] = [("TEST_KEY", "one_value"), ("TEST_KEY_2", "two_value")]; + + fn test_vars(testenv: &TestEnv) -> Result<(), Error> { + test_env_vars(testenv, &VARS) + } +} + +mod set_work_dir { + use super::*; + + #[test] + fn errors_non_existing() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let expected_path = testenv.temp_path().join("subdir"); + + let res = testenv.set_work_dir("subdir"); + + assert_path_not_found(res, expected_path); + Ok(()) + } + + #[test] + fn allow_absolute_path() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let path = testenv.temp_path().join("subdir"); + assert!(path.is_absolute()); + + testenv.add_child_dir(&path)?; + testenv.set_work_dir(&path)?; + + assert_path_exists(&testenv, "subdir"); + Ok(()) + } + + #[test] + fn relative_path() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + + testenv.add_child_dir("subdir")?; + testenv.set_work_dir("subdir")?; + + assert_path_exists(&testenv, "subdir"); + Ok(()) + } + + #[test] + fn in_testenv() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_child_dir("subdir")?; + testenv.set_work_dir("subdir")?; + test_in_env(&testenv, || { + let current_dir = std::env::current_dir().expect("failed to get current dir"); + assert_eq!(current_dir, testenv.work_dir()); + }) + } + + fn assert_path_not_found(result: Result<&mut TestEnv, Error>, expected_path: impl AsRef) { + if let Err(err) = result { + match err { + Error::PathNotFound(path) => assert_eq!(expected_path.as_ref(), path), + _ => panic!("unexpected error: {err}"), + } + } else { + panic!("expected error"); + } + } +} + +mod add_child_dir { + use super::*; + + #[test] + fn subdir() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_child_dir("subdir")?; + assert_path_exists(&testenv, "subdir"); + Ok(()) + } + + #[test] + fn allow_absolute_path() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + let path = testenv.temp_path().join("subdir"); + assert!(path.is_absolute()); + + testenv.add_child_dir(&path)?; + + assert_path_exists(&testenv, "subdir"); + Ok(()) + } + + #[test] + fn create_parents() -> Result<(), Error> { + let mut testenv = TestEnv::new()?; + testenv.add_child_dir("subdir/subsubdir")?; + assert_path_exists(&testenv, "subdir/subsubdir"); + Ok(()) + } +} + +fn assert_key_conflict(result: Result<&mut TestEnv, Error>, key: &str) { + if let Err(err) = result { + match err { + Error::KeyConflict(k) => assert_eq!(key, k), + _ => panic!("unexpected error: {err}"), + } + } else { + panic!("expected error"); + } +}