diff --git a/.gitignore b/.gitignore index 2c96eb1b..1ec7ed7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ Cargo.lock +.vscode/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 68838494..29cf77b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Changed - update to 2021 edition -- update MSRV to 1.72.0 -- **breaking**: dotenvy CLI uses `from_path` instead of `from_filename` +- update MSRV to 1.74.0 + +- **breaking**: `dotenvy::Error` now includes IO file path info and variable name info +- **breaking**: `dotenvy::var` no longer calls `load` internally +- **breaking**: `dotenvy::Result` is now private +- **breaking**: deprecate `dotenvy::var`, `dotenvy::from_filename*` +- `Error` is now `From ({ - eprintln!($fmt); - process::exit(1); - }); - ($fmt:expr, $($arg:tt)*) => ({ - eprintln!($fmt, $($arg)*); - process::exit(1); - }); -} - -fn mk_cmd(program: &str, args: &[String]) -> process::Command { - let mut cmd = process::Command::new(program); - for arg in args { - cmd.arg(arg); - } - cmd -} - -#[derive(Parser)] -#[command( - name = "dotenvy", - about = "Run a command using an environment loaded from a .env file", - arg_required_else_help = true, - allow_external_subcommands = true -)] -struct Cli { - #[arg(short, long, default_value = "./.env")] - file: PathBuf, - #[clap(subcommand)] - subcmd: Subcmd, -} - -#[derive(Subcommand)] -enum Subcmd { - #[clap(external_subcommand)] - External(Vec), -} - -fn main() -> Result<(), Box> { - let cli = Cli::parse(); - - // load the file - if let Err(e) = dotenvy::from_path(&cli.file) { - die!("Failed to load {path}: {e}", path = cli.file.display()); - } - - // prepare the command - let Subcmd::External(args) = cli.subcmd; - let (program, args) = args.split_first().unwrap(); - let mut cmd = mk_cmd(program, args); - - // run the command - if cfg!(target_os = "windows") { - match cmd.spawn().and_then(|mut child| child.wait()) { - Ok(status) => process::exit(status.code().unwrap_or(1)), - Err(e) => die!("fatal: {e}"), - }; - } else { - die!("fatal: {}", cmd.exec()); - }; -} diff --git a/dotenv/src/errors.rs b/dotenv/src/errors.rs deleted file mode 100644 index cb7a28a3..00000000 --- a/dotenv/src/errors.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::{env, error, fmt, io}; - -pub type Result = std::result::Result; - -#[derive(Debug)] -#[non_exhaustive] -pub enum Error { - LineParse(String, usize), - Io(io::Error), - EnvVar(env::VarError), -} - -impl Error { - #[must_use] - pub fn not_found(&self) -> bool { - if let Self::Io(ref io_error) = *self { - return io_error.kind() == io::ErrorKind::NotFound; - } - false - } -} - -impl error::Error for Error { - fn source(&self) -> Option<&(dyn error::Error + 'static)> { - match self { - Self::Io(err) => Some(err), - Self::EnvVar(err) => Some(err), - _ => None, - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { - match self { - Self::Io(e) => write!(fmt, "{e}"), - Self::EnvVar(e) => write!(fmt, "{e}"), - Self::LineParse(line, index) => write!( - fmt, - "Error parsing line: '{line}', error at line index: {index}", - ), - } - } -} - -#[cfg(test)] -mod test { - use std::error::Error as StdError; - - use super::*; - - #[test] - fn test_io_error_source() { - let err = Error::Io(io::ErrorKind::PermissionDenied.into()); - let io_err = err.source().unwrap().downcast_ref::().unwrap(); - assert_eq!(io::ErrorKind::PermissionDenied, io_err.kind()); - } - - #[test] - fn test_envvar_error_source() { - let err = Error::EnvVar(env::VarError::NotPresent); - let var_err = err - .source() - .unwrap() - .downcast_ref::() - .unwrap(); - assert_eq!(&env::VarError::NotPresent, var_err); - } - - #[test] - fn test_lineparse_error_source() { - let err = Error::LineParse("test line".to_string(), 2); - assert!(err.source().is_none()); - } - - #[test] - fn test_error_not_found_true() { - let err = Error::Io(io::ErrorKind::NotFound.into()); - assert!(err.not_found()); - } - - #[test] - fn test_error_not_found_false() { - let err = Error::Io(io::ErrorKind::PermissionDenied.into()); - assert!(!err.not_found()); - } - - #[test] - fn test_io_error_display() { - let err = Error::Io(io::ErrorKind::PermissionDenied.into()); - let io_err: io::Error = io::ErrorKind::PermissionDenied.into(); - - let err_desc = format!("{err}"); - let io_err_desc = format!("{io_err}"); - assert_eq!(io_err_desc, err_desc); - } - - #[test] - fn test_envvar_error_display() { - let err = Error::EnvVar(env::VarError::NotPresent); - let var_err = env::VarError::NotPresent; - - let err_desc = format!("{err}"); - let var_err_desc = format!("{var_err}"); - assert_eq!(var_err_desc, err_desc); - } - - #[test] - fn test_lineparse_error_display() { - let err = Error::LineParse("test line".to_string(), 2); - let err_desc = format!("{err}"); - assert_eq!( - "Error parsing line: 'test line', error at line index: 2", - err_desc - ); - } -} diff --git a/dotenv/src/find.rs b/dotenv/src/find.rs deleted file mode 100644 index 8db3f6e0..00000000 --- a/dotenv/src/find.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::{ - errors::{Error, Result}, - iter::Iter, -}; -use std::{ - env, - fs::{self, File}, - io, - path::{Path, PathBuf}, -}; - -pub struct Finder<'a> { - filename: &'a Path, -} - -impl<'a> Finder<'a> { - pub fn new() -> Self { - Finder { - filename: Path::new(".env"), - } - } - - pub const fn filename(mut self, filename: &'a Path) -> Self { - self.filename = filename; - self - } - - pub fn find(self) -> Result<(PathBuf, Iter)> { - let path = find(&env::current_dir().map_err(Error::Io)?, self.filename)?; - let file = File::open(&path).map_err(Error::Io)?; - let iter = Iter::new(file); - Ok((path, iter)) - } -} - -/// Searches for `filename` in `directory` and parent directories until found or root is reached. -pub fn find(mut directory: &Path, filename: &Path) -> Result { - loop { - let candidate = directory.join(filename); - - match fs::metadata(&candidate) { - Ok(metadata) if metadata.is_file() => return Ok(candidate), - Ok(_) => {} - Err(error) if matches!(error.kind(), io::ErrorKind::NotFound) => {} - Err(error) => return Err(Error::Io(error)), - } - - if let Some(parent) = directory.parent() { - directory = parent; - } else { - return Err(Error::Io(io::Error::new( - io::ErrorKind::NotFound, - "path not found", - ))); - } - } -} diff --git a/dotenv/src/iter.rs b/dotenv/src/iter.rs deleted file mode 100644 index 05f4ddd5..00000000 --- a/dotenv/src/iter.rs +++ /dev/null @@ -1,201 +0,0 @@ -use crate::{ - errors::{Error, Result}, - parse, -}; -use std::{ - collections::HashMap, - env, - io::{BufRead, BufReader, Read}, -}; - -pub struct Iter { - lines: QuotedLines>, - substitution_data: HashMap>, -} - -impl Iter { - pub fn new(reader: R) -> Self { - Self { - lines: QuotedLines { - buf: BufReader::new(reader), - }, - substitution_data: HashMap::new(), - } - } - - /// Loads all variables found in the `reader` into the environment, - /// preserving any existing environment variables of the same name. - /// - /// If a variable is specified multiple times within the reader's data, - /// then the first occurrence is applied. - pub fn load(mut self) -> Result<()> { - self.remove_bom()?; - - for item in self { - let (key, value) = item?; - if env::var(&key).is_err() { - env::set_var(&key, value); - } - } - - Ok(()) - } - - /// Loads all variables found in the `reader` into the environment, - /// overriding any existing environment variables of the same name. - /// - /// If a variable is specified multiple times within the reader's data, - /// then the last occurrence is applied. - pub fn load_override(mut self) -> Result<()> { - self.remove_bom()?; - - for item in self { - let (key, value) = item?; - env::set_var(key, value); - } - - Ok(()) - } - - fn remove_bom(&mut self) -> Result<()> { - let buffer = self.lines.buf.fill_buf().map_err(Error::Io)?; - // https://www.compart.com/en/unicode/U+FEFF - if buffer.starts_with(&[0xEF, 0xBB, 0xBF]) { - // remove the BOM from the bufreader - self.lines.buf.consume(3); - } - Ok(()) - } -} - -struct QuotedLines { - buf: B, -} - -enum ParseState { - Complete, - Escape, - StrongOpen, - StrongOpenEscape, - WeakOpen, - WeakOpenEscape, - Comment, - WhiteSpace, -} - -fn eval_end_state(prev_state: ParseState, buf: &str) -> (usize, ParseState) { - let mut cur_state = prev_state; - let mut cur_pos: usize = 0; - - for (pos, c) in buf.char_indices() { - cur_pos = pos; - cur_state = match cur_state { - ParseState::WhiteSpace => match c { - '#' => return (cur_pos, ParseState::Comment), - '\\' => ParseState::Escape, - '"' => ParseState::WeakOpen, - '\'' => ParseState::StrongOpen, - _ => ParseState::Complete, - }, - ParseState::Escape => ParseState::Complete, - ParseState::Complete => match c { - c if c.is_whitespace() && c != '\n' && c != '\r' => ParseState::WhiteSpace, - '\\' => ParseState::Escape, - '"' => ParseState::WeakOpen, - '\'' => ParseState::StrongOpen, - _ => ParseState::Complete, - }, - ParseState::WeakOpen => match c { - '\\' => ParseState::WeakOpenEscape, - '"' => ParseState::Complete, - _ => ParseState::WeakOpen, - }, - ParseState::WeakOpenEscape => ParseState::WeakOpen, - ParseState::StrongOpen => match c { - '\\' => ParseState::StrongOpenEscape, - '\'' => ParseState::Complete, - _ => ParseState::StrongOpen, - }, - ParseState::StrongOpenEscape => ParseState::StrongOpen, - // Comments last the entire line. - ParseState::Comment => panic!("should have returned early"), - }; - } - (cur_pos, cur_state) -} - -impl Iterator for QuotedLines { - type Item = Result; - - fn next(&mut self) -> Option> { - let mut buf = String::new(); - let mut cur_state = ParseState::Complete; - let mut buf_pos; - let mut cur_pos; - loop { - buf_pos = buf.len(); - match self.buf.read_line(&mut buf) { - Ok(0) => { - if matches!(cur_state, ParseState::Complete) { - return None; - } - let len = buf.len(); - return Some(Err(Error::LineParse(buf, len))); - } - Ok(_n) => { - // Skip lines which start with a # before iteration - // This optimizes parsing a bit. - if buf.trim_start().starts_with('#') { - return Some(Ok(String::with_capacity(0))); - } - let result = eval_end_state(cur_state, &buf[buf_pos..]); - cur_pos = result.0; - cur_state = result.1; - - match cur_state { - ParseState::Complete => { - if buf.ends_with('\n') { - buf.pop(); - if buf.ends_with('\r') { - buf.pop(); - } - } - return Some(Ok(buf)); - } - ParseState::Escape - | ParseState::StrongOpen - | ParseState::StrongOpenEscape - | ParseState::WeakOpen - | ParseState::WeakOpenEscape - | ParseState::WhiteSpace => {} - ParseState::Comment => { - buf.truncate(buf_pos + cur_pos); - return Some(Ok(buf)); - } - } - } - Err(e) => return Some(Err(Error::Io(e))), - } - } - } -} - -impl Iterator for Iter { - type Item = Result<(String, String)>; - - fn next(&mut self) -> Option { - loop { - let line = match self.lines.next() { - Some(Ok(line)) => line, - Some(Err(err)) => return Some(Err(err)), - None => return None, - }; - - match parse::parse_line(&line, &mut self.substitution_data) { - Ok(Some(result)) => return Some(Ok(result)), - Ok(None) => {} - Err(err) => return Some(Err(err)), - } - } - } -} diff --git a/dotenv/src/lib.rs b/dotenv/src/lib.rs deleted file mode 100644 index c9425ba5..00000000 --- a/dotenv/src/lib.rs +++ /dev/null @@ -1,381 +0,0 @@ -#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] -#![allow(clippy::missing_errors_doc, clippy::too_many_lines)] - -//! [`dotenv`]: https://crates.io/crates/dotenv -//! A well-maintained fork of the [`dotenv`] crate -//! -//! This library loads environment variables from a *.env* file. This is convenient for dev environments. - -mod errors; -mod find; -mod iter; -mod parse; - -use std::env::{self, Vars}; -use std::ffi::OsStr; -use std::fs::File; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::Once; - -pub use crate::errors::*; -use crate::find::Finder; -pub use crate::iter::Iter; - -static START: Once = Once::new(); - -/// Gets the value for an environment variable. -/// -/// The value is `Ok(s)` if the environment variable is present and valid unicode. -/// -/// Note: this function gets values from any visible environment variable key, -/// regardless of whether a *.env* file was loaded. -/// -/// # Examples: -/// -/// ```no_run -/// # fn main() -> Result<(), Box> { -/// let value = dotenvy::var("HOME")?; -/// println!("{}", value); // prints `/home/foo` -/// # Ok(()) -/// # } -/// ``` -pub fn var>(key: K) -> Result { - START.call_once(|| { - dotenv().ok(); - }); - env::var(key).map_err(Error::EnvVar) -} - -/// Returns an iterator of `(key, value)` pairs for all environment variables of the current process. -/// The returned iterator contains a snapshot of the process's environment variables at the time of invocation. Modifications to environment variables afterwards will not be reflected. -/// -/// # Examples: -/// -/// ```no_run -/// use std::io; -/// -/// let result: Vec<(String, String)> = dotenvy::vars().collect(); -/// ``` -pub fn vars() -> Vars { - START.call_once(|| { - dotenv().ok(); - }); - env::vars() -} - -/// Loads environment variables from the specified path. -/// -/// If variables with the same names already exist in the environment, then their values will be -/// preserved. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* -/// file, the *first one* is applied. -/// -/// If you wish to ensure all variables are loaded from your *.env* file, ignoring variables -/// already existing in the environment, then use [`from_path_override`] instead. -/// -/// # Examples -/// -/// ```no_run -/// use std::path::Path; -/// -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_path(Path::new("path/to/.env"))?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_path>(path: P) -> Result<()> { - let iter = Iter::new(File::open(path).map_err(Error::Io)?); - iter.load() -} - -/// Loads environment variables from the specified path, -/// overriding existing environment variables. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* file, the -/// *last one* is applied. -/// -/// If you want the existing environment to take precedence, -/// or if you want to be able to override environment variables on the command line, -/// then use [`from_path`] instead. -/// -/// # Examples -/// -/// ```no_run -/// use std::path::Path; -/// -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_path_override(Path::new("path/to/.env"))?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_path_override>(path: P) -> Result<()> { - let iter = Iter::new(File::open(path).map_err(Error::Io)?); - iter.load_override() -} - -/// Returns an iterator over environment variables from the specified path. -/// -/// # Examples -/// -/// ```no_run -/// use std::path::Path; -/// -/// # fn main() -> Result<(), Box> { -/// for item in dotenvy::from_path_iter(Path::new("path/to/.env"))? { -/// let (key, val) = item?; -/// println!("{}={}", key, val); -/// } -/// # Ok(()) -/// # } -/// ``` -pub fn from_path_iter>(path: P) -> Result> { - Ok(Iter::new(File::open(path).map_err(Error::Io)?)) -} - -/// Loads environment variables from the specified file. -/// -/// If variables with the same names already exist in the environment, then their values will be -/// preserved. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* -/// file, the *first one* is applied. -/// -/// If you wish to ensure all variables are loaded from your *.env* file, ignoring variables -/// already existing in the environment, then use [`from_filename_override`] instead. -/// -/// # Examples -/// ```no_run -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_filename("custom.env")?; -/// # Ok(()) -/// # } -/// ``` -/// -/// It is also possible to load from a typical *.env* file like so. However, using [`dotenv`] is preferred. -/// -/// ``` -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_filename(".env")?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_filename>(filename: P) -> Result { - let (path, iter) = Finder::new().filename(filename.as_ref()).find()?; - iter.load()?; - Ok(path) -} - -/// Loads environment variables from the specified file, -/// overriding existing environment variables. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* file, the -/// *last one* is applied. -/// -/// If you want the existing environment to take precedence, -/// or if you want to be able to override environment variables on the command line, -/// then use [`from_filename`] instead. -/// -/// # Examples -/// ```no_run -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_filename_override("custom.env")?; -/// # Ok(()) -/// # } -/// ``` -/// -/// It is also possible to load from a typical *.env* file like so. However, using [`dotenv_override`] is preferred. -/// -/// ``` -/// # fn main() -> Result<(), Box> { -/// dotenvy::from_filename_override(".env")?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_filename_override>(filename: P) -> Result { - let (path, iter) = Finder::new().filename(filename.as_ref()).find()?; - iter.load_override()?; - Ok(path) -} - -/// Returns an iterator over environment variables from the specified file. -/// -/// # Examples -/// -/// ```no_run -/// # fn main() -> Result<(), Box> { -/// for item in dotenvy::from_filename_iter("custom.env")? { -/// let (key, val) = item?; -/// println!("{}={}", key, val); -/// } -/// # Ok(()) -/// # } -/// ``` -pub fn from_filename_iter>(filename: P) -> Result> { - let (_, iter) = Finder::new().filename(filename.as_ref()).find()?; - Ok(iter) -} - -/// Loads environment variables from [`io::Read`]. -/// -/// This is useful for loading environment variables from IPC or the network. -/// -/// If variables with the same names already exist in the environment, then their values will be -/// preserved. -/// -/// Where multiple declarations for the same environment variable exist in your `reader`, -/// the *first one* is applied. -/// -/// If you wish to ensure all variables are loaded from your `reader`, ignoring variables -/// already existing in the environment, then use [`from_read_override`] instead. -/// -/// For regular files, use [`from_path`] or [`from_filename`]. -/// -/// # Examples -/// -/// ```no_run -/// use std::io::Read; -/// # #[cfg(unix)] -/// use std::os::unix::net::UnixStream; -/// -/// # fn main() -> Result<(), Box> { -/// # #[cfg(unix)] -/// let mut stream = UnixStream::connect("/some/socket")?; -/// # #[cfg(unix)] -/// dotenvy::from_read(stream)?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_read(reader: R) -> Result<()> { - let iter = Iter::new(reader); - iter.load()?; - Ok(()) -} - -/// Loads environment variables from [`io::Read`], -/// overriding existing environment variables. -/// -/// This is useful for loading environment variables from IPC or the network. -/// -/// Where multiple declarations for the same environment variable exist in your `reader`, the -/// *last one* is applied. -/// -/// If you want the existing environment to take precedence, -/// or if you want to be able to override environment variables on the command line, -/// then use [`from_read`] instead. -/// -/// For regular files, use [`from_path_override`] or [`from_filename_override`]. -/// -/// # Examples -/// ```no_run -/// use std::io::Read; -/// # #[cfg(unix)] -/// use std::os::unix::net::UnixStream; -/// -/// # fn main() -> Result<(), Box> { -/// # #[cfg(unix)] -/// let mut stream = UnixStream::connect("/some/socket")?; -/// # #[cfg(unix)] -/// dotenvy::from_read_override(stream)?; -/// # Ok(()) -/// # } -/// ``` -pub fn from_read_override(reader: R) -> Result<()> { - let iter = Iter::new(reader); - iter.load_override()?; - Ok(()) -} - -/// Returns an iterator over environment variables from [`io::Read`]. -/// -/// # Examples -/// -/// ```no_run -/// use std::io::Read; -/// # #[cfg(unix)] -/// use std::os::unix::net::UnixStream; -/// -/// # fn main() -> Result<(), Box> { -/// # #[cfg(unix)] -/// let mut stream = UnixStream::connect("/some/socket")?; -/// -/// # #[cfg(unix)] -/// for item in dotenvy::from_read_iter(stream) { -/// let (key, val) = item?; -/// println!("{}={}", key, val); -/// } -/// # Ok(()) -/// # } -/// ``` -pub fn from_read_iter(reader: R) -> Iter { - Iter::new(reader) -} - -/// Loads the *.env* file from the current directory or parents. This is typically what you want. -/// -/// If variables with the same names already exist in the environment, then their values will be -/// preserved. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* -/// file, the *first one* is applied. -/// -/// If you wish to ensure all variables are loaded from your *.env* file, ignoring variables -/// already existing in the environment, then use [`dotenv_override`] instead. -/// -/// An error will be returned if the file is not found. -/// -/// # Examples -/// -/// ``` -/// # fn main() -> Result<(), Box> { -/// dotenvy::dotenv()?; -/// # Ok(()) -/// # } -/// ``` -pub fn dotenv() -> Result { - let (path, iter) = Finder::new().find()?; - iter.load()?; - Ok(path) -} - -/// Loads all variables found in the `reader` into the environment, -/// overriding any existing environment variables of the same name. -/// -/// Where multiple declarations for the same environment variable exist in your *.env* file, the -/// *last one* is applied. -/// -/// If you want the existing environment to take precedence, -/// or if you want to be able to override environment variables on the command line, -/// then use [`dotenv`] instead. -/// -/// # Examples -/// ``` -/// # fn main() -> Result<(), Box> { -/// dotenvy::dotenv_override()?; -/// # Ok(()) -/// # } -/// ``` -pub fn dotenv_override() -> Result { - let (path, iter) = Finder::new().find()?; - iter.load_override()?; - Ok(path) -} - -/// Returns an iterator over environment variables. -/// -/// # Examples -/// -/// ``` -/// # fn main() -> Result<(), Box> { -/// for item in dotenvy::dotenv_iter()? { -/// let (key, val) = item?; -/// println!("{}={}", key, val); -/// } -/// # Ok(()) -/// # } -/// ``` -pub fn dotenv_iter() -> Result> { - let (_, iter) = Finder::new().find()?; - Ok(iter) -} diff --git a/dotenv/tests/common/mod.rs b/dotenv/tests/common/mod.rs deleted file mode 100644 index d4df7b3b..00000000 --- a/dotenv/tests/common/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -use std::{ - env, - fs::File, - io::{self, Write}, -}; -use tempfile::{tempdir, TempDir}; - -pub fn tempdir_with_dotenv(dotenv_text: &str) -> io::Result { - env::set_var("EXISTING", "from_env"); - let dir = tempdir()?; - env::set_current_dir(dir.path())?; - let dotenv_path = dir.path().join(".env"); - let mut dotenv_file = File::create(dotenv_path)?; - dotenv_file.write_all(dotenv_text.as_bytes())?; - dotenv_file.sync_all()?; - Ok(dir) -} - -#[allow(dead_code)] -pub fn make_test_dotenv() -> io::Result { - tempdir_with_dotenv("TESTKEY=test_val\nTESTKEY=test_val_overridden\nEXISTING=from_file") -} diff --git a/dotenv/tests/integration/main.rs b/dotenv/tests/integration/main.rs deleted file mode 100644 index 83c8c0aa..00000000 --- a/dotenv/tests/integration/main.rs +++ /dev/null @@ -1 +0,0 @@ -mod util; diff --git a/dotenv/tests/integration/util/mod.rs b/dotenv/tests/integration/util/mod.rs deleted file mode 100644 index 12430b63..00000000 --- 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 23b0ff6f..00000000 --- a/dotenv/tests/integration/util/testenv.rs +++ /dev/null @@ -1,336 +0,0 @@ -use super::{create_default_envfile, TEST_EXISTING_KEY, TEST_EXISTING_VALUE}; -use std::{ - collections::HashMap, - env, fs, - io::{self, Write}, - path::{Path, PathBuf}, - sync::{Arc, Mutex, OnceLock, PoisonError}, -}; -use tempfile::{tempdir, TempDir}; - -/// Env var convenience type. -type EnvMap = HashMap; - -/// Initialized in [`get_env_locker`] -static ENV_LOCKER: OnceLock>> = OnceLock::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/dotenv/tests/test-child-dir.rs b/dotenv/tests/test-child-dir.rs deleted file mode 100644 index e2ffef77..00000000 --- a/dotenv/tests/test-child-dir.rs +++ /dev/null @@ -1,20 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error, fs}; - -#[test] -fn test_child_dir() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - fs::create_dir("child")?; - - env::set_current_dir("child")?; - - dotenvy::dotenv()?; - assert_eq!(env::var("TESTKEY")?, "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-default-location-override.rs b/dotenv/tests/test-default-location-override.rs deleted file mode 100644 index 464ee527..00000000 --- a/dotenv/tests/test-default-location-override.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_default_location_override() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - dotenvy::dotenv_override()?; - - assert_eq!(env::var("TESTKEY")?, "test_val_overridden"); - assert_eq!(env::var("EXISTING")?, "from_file"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-default-location.rs b/dotenv/tests/test-default-location.rs deleted file mode 100644 index 43616f0f..00000000 --- a/dotenv/tests/test-default-location.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_default_location() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - dotenvy::dotenv()?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - assert_eq!(env::var("EXISTING")?, "from_env"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-dotenv-iter.rs b/dotenv/tests/test-dotenv-iter.rs deleted file mode 100644 index 17fb1ac1..00000000 --- a/dotenv/tests/test-dotenv-iter.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_dotenv_iter() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let iter = dotenvy::dotenv_iter()?; - assert!(env::var("TESTKEY").is_err()); - - iter.load()?; - assert_eq!(env::var("TESTKEY")?, "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-filename-iter.rs b/dotenv/tests/test-from-filename-iter.rs deleted file mode 100644 index bcc33cd4..00000000 --- a/dotenv/tests/test-from-filename-iter.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_from_filename_iter() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let iter = dotenvy::from_filename_iter(".env")?; - - assert!(env::var("TESTKEY").is_err()); - - iter.load()?; - - assert_eq!(env::var("TESTKEY").unwrap(), "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-filename-override.rs b/dotenv/tests/test-from-filename-override.rs deleted file mode 100644 index 560f101d..00000000 --- a/dotenv/tests/test-from-filename-override.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_from_filename_override() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - dotenvy::from_filename_override(".env")?; - - assert_eq!(env::var("TESTKEY")?, "test_val_overridden"); - assert_eq!(env::var("EXISTING")?, "from_file"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-filename.rs b/dotenv/tests/test-from-filename.rs deleted file mode 100644 index 83e25607..00000000 --- a/dotenv/tests/test-from-filename.rs +++ /dev/null @@ -1,19 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use dotenvy::from_filename; -use std::{env, error}; - -#[test] -fn test_from_filename() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - from_filename(".env")?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - assert_eq!(env::var("EXISTING")?, "from_env"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-path-iter.rs b/dotenv/tests/test-from-path-iter.rs deleted file mode 100644 index d19f953f..00000000 --- a/dotenv/tests/test-from-path-iter.rs +++ /dev/null @@ -1,25 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use dotenvy::from_path_iter; -use std::{env, error}; - -#[test] -fn test_from_path_iter() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let mut path = env::current_dir()?; - path.push(".env"); - - let iter = from_path_iter(&path)?; - - assert!(env::var("TESTKEY").is_err()); - - iter.load()?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-path-override.rs b/dotenv/tests/test-from-path-override.rs deleted file mode 100644 index 82080013..00000000 --- a/dotenv/tests/test-from-path-override.rs +++ /dev/null @@ -1,24 +0,0 @@ -mod common; - -use std::{env, error}; - -use dotenvy::from_path_override; - -use crate::common::make_test_dotenv; - -#[test] -fn test_from_path_override() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let mut path = env::current_dir()?; - path.push(".env"); - - from_path_override(&path)?; - - assert_eq!(env::var("TESTKEY")?, "test_val_overridden"); - assert_eq!(env::var("EXISTING")?, "from_file"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-path.rs b/dotenv/tests/test-from-path.rs deleted file mode 100644 index 6c6d3fc3..00000000 --- a/dotenv/tests/test-from-path.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_from_path() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let mut path = env::current_dir()?; - path.push(".env"); - - dotenvy::from_path(&path)?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - assert_eq!(env::var("EXISTING")?, "from_env"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-read-override.rs b/dotenv/tests/test-from-read-override.rs deleted file mode 100644 index 9f8429f2..00000000 --- a/dotenv/tests/test-from-read-override.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error, fs::File}; - -#[test] -fn test_from_read_override() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - dotenvy::from_read_override(File::open(".env")?)?; - - assert_eq!(env::var("TESTKEY")?, "test_val_overridden"); - assert_eq!(env::var("EXISTING")?, "from_file"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-from-read.rs b/dotenv/tests/test-from-read.rs deleted file mode 100644 index d7efed33..00000000 --- a/dotenv/tests/test-from-read.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error, fs::File}; - -#[test] -fn test_from_read() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - dotenvy::from_read(File::open(".env")?)?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - assert_eq!(env::var("EXISTING")?, "from_env"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-ignore-bom.rs b/dotenv/tests/test-ignore-bom.rs deleted file mode 100644 index 35f1819f..00000000 --- a/dotenv/tests/test-ignore-bom.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod common; - -use crate::common::tempdir_with_dotenv; -use std::{env, error}; - -#[test] -fn test_ignore_bom() -> Result<(), Box> { - let bom = "\u{feff}"; - let dir = tempdir_with_dotenv(&format!("{}TESTKEY=test_val", bom))?; - - let mut path = env::current_dir()?; - path.push(".env"); - - dotenvy::from_path(&path)?; - - assert_eq!(env::var("TESTKEY")?, "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-multiline-comment.rs b/dotenv/tests/test-multiline-comment.rs deleted file mode 100644 index 8f67dc21..00000000 --- a/dotenv/tests/test-multiline-comment.rs +++ /dev/null @@ -1,55 +0,0 @@ -mod common; -use std::env; - -use common::tempdir_with_dotenv; - -#[test] -fn test_issue_12() { - let _f = tempdir_with_dotenv( - r#" -# Start of .env file -# Comment line with single ' quote -# Comment line with double " quote - # Comment line with double " quote and starts with a space -TESTKEY1=test_val # 1 '" comment -TESTKEY2=test_val_with_#_hash # 2 '" comment -TESTKEY3="test_val quoted with # hash" # 3 '" comment -TESTKEY4="Line 1 -# Line 2 -Line 3" # 4 Multiline "' comment -TESTKEY5="Line 4 -# Line 5 -Line 6 -" # 5 Multiline "' comment -# End of .env file -"#, - ) - .expect("should write test env"); - - dotenvy::dotenv().expect("should succeed"); - assert_eq!( - env::var("TESTKEY1").expect("testkey1 env key not set"), - "test_val" - ); - assert_eq!( - env::var("TESTKEY2").expect("testkey2 env key not set"), - "test_val_with_#_hash" - ); - assert_eq!( - env::var("TESTKEY3").expect("testkey3 env key not set"), - "test_val quoted with # hash" - ); - assert_eq!( - env::var("TESTKEY4").expect("testkey4 env key not set"), - r#"Line 1 -# Line 2 -Line 3"# - ); - assert_eq!( - env::var("TESTKEY5").expect("testkey5 env key not set"), - r#"Line 4 -# Line 5 -Line 6 -"# - ); -} diff --git a/dotenv/tests/test-multiline.rs b/dotenv/tests/test-multiline.rs deleted file mode 100644 index e730e522..00000000 --- a/dotenv/tests/test-multiline.rs +++ /dev/null @@ -1,45 +0,0 @@ -mod common; - -use crate::common::tempdir_with_dotenv; -use std::{env, error}; - -#[test] -fn test_multiline() -> Result<(), Box> { - let value = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\\n\\\"QUOTED\\\""; - let weak = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n\"QUOTED\""; - let dir = tempdir_with_dotenv(&format!( - r#" -KEY=my\ cool\ value -KEY3="awesome \"stuff\" -more -on other -lines" -KEY4='hello '\''world'" -good ' \'morning" -WEAK="{}" -STRONG='{}' -"#, - value, value - ))?; - - dotenvy::dotenv()?; - assert_eq!(env::var("KEY")?, r#"my cool value"#); - assert_eq!( - env::var("KEY3")?, - r#"awesome "stuff" -more -on other -lines"# - ); - assert_eq!( - env::var("KEY4")?, - r#"hello 'world -good ' 'morning"# - ); - assert_eq!(env::var("WEAK")?, weak); - assert_eq!(env::var("STRONG")?, value); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-var.rs b/dotenv/tests/test-var.rs deleted file mode 100644 index 43b68ddc..00000000 --- a/dotenv/tests/test-var.rs +++ /dev/null @@ -1,15 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{env, error}; - -#[test] -fn test_var() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - assert_eq!(dotenvy::var("TESTKEY")?, "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-variable-substitution.rs b/dotenv/tests/test-variable-substitution.rs deleted file mode 100644 index 6bde0f5c..00000000 --- a/dotenv/tests/test-variable-substitution.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod common; - -use crate::common::tempdir_with_dotenv; -use std::{env, error}; - -#[test] -fn test_variable_substitutions() -> Result<(), Box> { - std::env::set_var("KEY", "value"); - std::env::set_var("KEY1", "value1"); - - let substitutions_to_test = [ - "$ZZZ", "$KEY", "$KEY1", "${KEY}1", "$KEY_U", "${KEY_U}", "\\$KEY", - ]; - - let common_string = substitutions_to_test.join(">>"); - let dir = tempdir_with_dotenv(&format!( - r#" -KEY1=new_value1 -KEY_U=$KEY+valueU - -SUBSTITUTION_FOR_STRONG_QUOTES='{}' -SUBSTITUTION_FOR_WEAK_QUOTES="{}" -SUBSTITUTION_WITHOUT_QUOTES={} -"#, - common_string, common_string, common_string - ))?; - - dotenvy::dotenv()?; - - assert_eq!(env::var("KEY")?, "value"); - assert_eq!(env::var("KEY1")?, "value1"); - assert_eq!(env::var("KEY_U")?, "value+valueU"); - assert_eq!(env::var("SUBSTITUTION_FOR_STRONG_QUOTES")?, common_string); - assert_eq!( - env::var("SUBSTITUTION_FOR_WEAK_QUOTES")?, - [ - "", - "value", - "value1", - "value1", - "value_U", - "value+valueU", - "$KEY" - ] - .join(">>") - ); - assert_eq!( - env::var("SUBSTITUTION_WITHOUT_QUOTES")?, - [ - "", - "value", - "value1", - "value1", - "value_U", - "value+valueU", - "$KEY" - ] - .join(">>") - ); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv/tests/test-vars.rs b/dotenv/tests/test-vars.rs deleted file mode 100644 index 4f513344..00000000 --- a/dotenv/tests/test-vars.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod common; - -use crate::common::make_test_dotenv; -use std::{collections::HashMap, env, error}; - -#[test] -fn test_vars() -> Result<(), Box> { - let dir = make_test_dotenv()?; - - let vars: HashMap = dotenvy::vars().collect(); - - assert_eq!(vars["TESTKEY"], "test_val"); - - env::set_current_dir(dir.path().parent().unwrap())?; - dir.close()?; - Ok(()) -} diff --git a/dotenv_codegen/Cargo.toml b/dotenv_codegen/Cargo.toml index c8c57ce9..283b22ec 100644 --- a/dotenv_codegen/Cargo.toml +++ b/dotenv_codegen/Cargo.toml @@ -1,6 +1,3 @@ -[lib] -proc-macro = true - [package] name = "dotenvy_macro" version = "0.15.7" @@ -20,10 +17,13 @@ homepage = "https://github.com/allan2/dotenvy" repository = "https://github.com/allan2/dotenvy" description = "A macro for compile time dotenv inspection" edition = "2021" -rust-version = "1.72.0" +rust-version = "1.74.0" + +[lib] +proc-macro = true [dependencies] proc-macro2 = "1" quote = "1" -syn = "1" -dotenvy = { version = "0.15", path = "../dotenv" } +syn = "2" +dotenvy = { path = "../dotenvy" } diff --git a/dotenv_codegen/LICENSE b/dotenv_codegen/LICENSE deleted file mode 120000 index ea5b6064..00000000 --- a/dotenv_codegen/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../LICENSE \ No newline at end of file diff --git a/dotenv_codegen/LICENSE b/dotenv_codegen/LICENSE new file mode 100644 index 00000000..11e157d4 --- /dev/null +++ b/dotenv_codegen/LICENSE @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2014 Santiago Lapresta 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. \ No newline at end of file diff --git a/dotenv_codegen/README.md b/dotenv_codegen/README.md index e054288f..efa8f41d 100644 --- a/dotenv_codegen/README.md +++ b/dotenv_codegen/README.md @@ -6,3 +6,4 @@ A macro for compile time dotenv inspection. This is a well-maintained fork of `dotenv_codegen`. + \ No newline at end of file diff --git a/dotenv_codegen/src/lib.rs b/dotenv_codegen/src/lib.rs index 418c2dfe..18798920 100644 --- a/dotenv_codegen/src/lib.rs +++ b/dotenv_codegen/src/lib.rs @@ -1,17 +1,21 @@ -#![forbid(unsafe_code)] - +use dotenvy::EnvLoader; +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; use quote::quote; use std::env::{self, VarError}; use syn::{parse::Parser, punctuated::Punctuated, spanned::Spanned, Token}; #[proc_macro] -pub fn dotenv(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - dotenv_inner(input.into()).into() +/// TODO: add safety warning +pub fn dotenv(input: TokenStream) -> TokenStream { + let input = input.into(); + unsafe { dotenv_inner(input) }.into() } -fn dotenv_inner(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { - if let Err(err) = dotenvy::dotenv() { - let msg = format!("Error loading .env file: {}", err); +unsafe fn dotenv_inner(input: TokenStream2) -> TokenStream2 { + let loader = EnvLoader::new(); + if let Err(e) = unsafe { loader.load_and_modify() } { + let msg = e.to_string(); return quote! { compile_error!(#msg); }; @@ -23,7 +27,7 @@ fn dotenv_inner(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream { } } -fn expand_env(input_raw: proc_macro2::TokenStream) -> syn::Result { +fn expand_env(input_raw: TokenStream2) -> syn::Result { let args = >::parse_terminated .parse(input_raw.into()) .expect("expected macro to be called with a comma-separated list of string literals"); @@ -50,13 +54,12 @@ fn expand_env(input_raw: proc_macro2::TokenStream) -> syn::Result { - format!("environment variable `{}` not defined", var_name) + format!("environment variable `{var_name}` not defined") } - VarError::NotUnicode(s) => format!( - "environment variable `{}` was not valid unicode: {:?}", - var_name, s - ), + VarError::NotUnicode(s) => { + format!("environment variable `{var_name}` was not valid Unicode: {s:?}",) + } }, |lit| lit.value(), ), diff --git a/dotenvy-macros/Cargo.toml b/dotenvy-macros/Cargo.toml new file mode 100644 index 00000000..2129a166 --- /dev/null +++ b/dotenvy-macros/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "dotenvy-macros" +version = "0.1.0" +authors = ["Allan"] +readme = "README.md" +keywords = ["dotenv", "env", "environment", "settings", "config"] +categories = ["configuration"] +license = "MIT" +homepage = "https://github.com/allan2/dotenvy" +repository = "https://github.com/allan2/dotenvy" +description = "Runtime macros for dotenvy" +edition = "2021" +rust-version = "1.74.0" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["parsing"] } + +[dev-dependencies] +dotenvy = { path = "../dotenvy" } \ No newline at end of file diff --git a/dotenvy-macros/src/lib.rs b/dotenvy-macros/src/lib.rs new file mode 100644 index 00000000..cae16ff4 --- /dev/null +++ b/dotenvy-macros/src/lib.rs @@ -0,0 +1,122 @@ +#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] +#![deny(clippy::uninlined_format_args, clippy::wildcard_imports)] + +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, ItemFn, LitBool, LitStr, +}; + +/// Loads environment variables from a file and modifies the environment. +/// +/// Three optional arguments are supported: `path`, `required`, and `override_`. +/// Usage is like `#[dotenvy::load(path = ".env", required = true, override_ = true)]`. +/// +/// The default path is "./.env". The default sequence is `EnvSequence::InputThenEnv`. +#[proc_macro_attribute] +pub fn load(attr: TokenStream, item: TokenStream) -> TokenStream { + let attrs = parse_macro_input!(attr as LoadInput); + let item = parse_macro_input!(item as ItemFn); + + let path = attrs.path; + let required = attrs.required; + let override_ = attrs.override_; + + let load_env = quote! { + use dotenvy::{EnvLoader, EnvSequence}; + use std::{error::Error, io::{self, ErrorKind}, process}; + + let seq = if #override_ { + EnvSequence::InputOnly + } else { + EnvSequence::InputThenEnv + }; + let mut loader = EnvLoader::with_path(#path).sequence(seq); + if let Err(e) = unsafe { loader.load_and_modify() } { + if let Some(io_err) = e.source().and_then(|src| src.downcast_ref::()) { + if io_err.kind() == io::ErrorKind::NotFound && !#required { + // `required` is false and file not found, so continue + } + } + eprintln!("Failed to load env file from path '{}': {e}", #path); + process::exit(1); + } + }; + + let attrs = &item.attrs; + let block = &item.block; + let sig = &item.sig; + let vis = &item.vis; + let fn_name = &item.sig.ident; + let output = &item.sig.output; + let new_fn_name = format_ident!("{fn_name}_inner"); + + let expanded = if sig.asyncness.is_some() { + // this works with `tokio::main`` but not `async_std::main`` + quote! { + // non-async wrapper function + #vis fn #fn_name() #output { + #load_env + #new_fn_name() + } + + // orig async function, but renamed + #(#attrs)* + #vis async fn #new_fn_name() #output { + #block + } + } + } else { + // not using async, just inject `load_env` at the start + quote! { + #(#attrs)* + #vis #sig { + #load_env + #block + } + } + }; + + TokenStream::from(expanded) +} + +struct LoadInput { + path: String, + required: bool, + override_: bool, +} + +impl Parse for LoadInput { + fn parse(input: ParseStream) -> syn::Result { + let mut path = "./.env".to_owned(); + let mut required = true; + let mut override_ = false; + + while !input.is_empty() { + let ident: syn::Ident = input.parse()?; + input.parse::()?; + match ident.to_string().as_str() { + "path" => { + path = input.parse::()?.value(); + } + "required" => { + required = input.parse::()?.value(); + } + "override_" => { + override_ = input.parse::()?.value(); + } + _ => return Err(syn::Error::new(ident.span(), "unknown attribute")), + } + if !input.is_empty() { + input.parse::()?; + } + } + + Ok(Self { + path, + required, + override_, + }) + } +} diff --git a/dotenv/Cargo.toml b/dotenvy/Cargo.toml similarity index 80% rename from dotenv/Cargo.toml rename to dotenvy/Cargo.toml index f6dc182e..3e65d843 100644 --- a/dotenv/Cargo.toml +++ b/dotenvy/Cargo.toml @@ -13,13 +13,13 @@ authors = [ ] description = "A well-maintained fork of the dotenv crate" homepage = "https://github.com/allan2/dotenvy" -readme = "README.md" +readme = "../README.md" keywords = ["dotenv", "env", "environment", "settings", "config"] categories = ["configuration"] license = "MIT" repository = "https://github.com/allan2/dotenvy" edition = "2021" -rust-version = "1.72.0" +rust-version = "1.74.0" [[bin]] name = "dotenvy" @@ -27,9 +27,12 @@ required-features = ["cli"] [dependencies] clap = { version = "4.5.16", features = ["derive"], optional = true } +dotenvy-macros = { path = "../dotenvy-macros", optional = true } [dev-dependencies] -tempfile = "3.12.0" +temp-env = "0.3.6" [features] -cli = ["clap"] +default = ["cli"] +cli = ["dep:clap"] +macros = ["dep:dotenvy-macros"] diff --git a/dotenvy/LICENSE b/dotenvy/LICENSE new file mode 100644 index 00000000..11e157d4 --- /dev/null +++ b/dotenvy/LICENSE @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2014 Santiago Lapresta 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. \ No newline at end of file diff --git a/dotenvy/src/bin/dotenvy.rs b/dotenvy/src/bin/dotenvy.rs new file mode 100644 index 00000000..909e4af1 --- /dev/null +++ b/dotenvy/src/bin/dotenvy.rs @@ -0,0 +1,94 @@ +//! A CLI tool that loads an env file before running a command. +//! +//! # Example +//! +//! Given a file *env.txt* with body `FOO=bar`, running +//! +//! ```sh +//! dotenvy -f env.txt printenv FOO +//! ``` +//! +//! will output `bar`. +use clap::{Parser, Subcommand}; +use dotenvy::{EnvLoader, EnvSequence}; +use std::{error, fs::File, io::ErrorKind, path::PathBuf, process}; + +fn mk_cmd(program: &str, args: &[String]) -> process::Command { + let mut cmd = process::Command::new(program); + for arg in args { + cmd.arg(arg); + } + cmd +} + +#[derive(Parser)] +#[command( + name = "dotenvy", + version, + about = "Run a command using an environment loaded from an env file", + arg_required_else_help = true, + allow_external_subcommands = true +)] +struct Cli { + #[arg(short, long, default_value = "./.env")] + /// Path to the env file + file: PathBuf, + #[clap(subcommand)] + subcmd: Subcmd, + #[arg(long, default_value_t = true)] + required: bool, + #[arg(long, default_value_t = false)] + r#override: bool, +} + +#[derive(Subcommand)] +enum Subcmd { + #[clap(external_subcommand)] + External(Vec), +} + +fn main() -> Result<(), Box> { + let cli = Cli::parse(); + + match File::open(&cli.file) { + Ok(file) => { + let seq = if cli.r#override { + EnvSequence::EnvThenInput + } else { + EnvSequence::InputThenEnv + }; + + // load the file + let loader = EnvLoader::with_reader(file).path(&cli.file).sequence(seq); + unsafe { loader.load_and_modify() }?; + } + Err(e) => { + if cli.required && e.kind() == ErrorKind::NotFound { + eprintln!("Failed to load {path}: {e}", path = cli.file.display()); + } + process::exit(1); + } + }; + + // prepare the command + let Subcmd::External(args) = cli.subcmd; + let (program, args) = args.split_first().unwrap(); + let mut cmd = mk_cmd(program, args); + + // run the command + #[cfg(windows)] + match cmd.spawn().and_then(|mut child| child.wait()) { + Ok(status) => process::exit(status.code().unwrap_or(1)), + Err(e) => { + eprintln!("fatal: {e}"); + process::exit(1); + } + }; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + eprintln!("fatal: {}", cmd.exec()); + process::exit(1); + } +} diff --git a/dotenvy/src/err.rs b/dotenvy/src/err.rs new file mode 100644 index 00000000..54a8fd41 --- /dev/null +++ b/dotenvy/src/err.rs @@ -0,0 +1,84 @@ +use std::{error, ffi::OsString, fmt, io, path::PathBuf}; + +use crate::iter::ParseBufError; + +#[derive(Debug)] +pub enum Error { + LineParse(String, usize), + /// An IO error may be encountered when reading from a file or reader. + Io(io::Error, Option), + /// The variable was not found in the environment. The `String` is the name of the variable. + NotPresent(String), + /// The variable was not valid unicode. The `String` is the name of the variable. + NotUnicode(OsString, String), + /// When `load_and_modify` is called with `EnvSequence::EnvOnly` + /// + /// There is nothing to modify, so we consider this an invalid operation because of the unnecessary unsafe call. + InvalidOp, + /// When a load function is called with no path or reader. + /// + /// Only `EnvLoader::default` would have no path or reader. + NoInput, +} + +impl Error { + #[must_use] + pub fn not_found(&self) -> bool { + if let Self::Io(e, _) = self { + e.kind() == io::ErrorKind::NotFound + } else { + false + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Io(e, _) => Some(e), + Self::LineParse(_, _) + | Self::NotPresent(_) + | Self::NotUnicode(_, _) + | Self::InvalidOp + | Self::NoInput => None, + } + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Io(e, path) => { + if let Some(path) = path { + write!(f, "error reading '{}':, {e}", path.to_string_lossy()) + } else { + e.fmt(f) + } + } + Self::LineParse(line, index) => write!( + f, + "error parsing line: '{line}', error at line index: {index}", + ), + Self::NotPresent(s) => write!(f, "{s} is not set"), + Self::NotUnicode(os_str, s) => { + write!(f, "{s} is not valid Unicode: {os_str:?}",) + } + Self::InvalidOp => write!(f, "modify is not permitted with `EnvSequence::EnvOnly`"), + Self::NoInput => write!(f, "no input provided"), + } + } +} + +impl From<(io::Error, PathBuf)> for Error { + fn from((e, path): (io::Error, PathBuf)) -> Self { + Self::Io(e, Some(path)) + } +} +impl From<(ParseBufError, Option)> for Error { + fn from((e, path): (ParseBufError, Option)) -> Self { + match e { + ParseBufError::LineParse(line, index) => Self::LineParse(line, index), + ParseBufError::Io(e) => Self::Io(e, path), + } + } +} diff --git a/dotenvy/src/iter.rs b/dotenvy/src/iter.rs new file mode 100644 index 00000000..189a5315 --- /dev/null +++ b/dotenvy/src/iter.rs @@ -0,0 +1,243 @@ +use crate::{parse, EnvMap}; +use std::{ + collections::HashMap, + env::{self}, + io::{self, BufRead}, +}; + +pub struct Iter { + lines: Lines, + substitution_data: HashMap>, +} + +impl Iter { + pub fn new(buf: B) -> Self { + Self { + lines: Lines(buf), + substitution_data: HashMap::new(), + } + } + + fn internal_load(mut self, mut load_fn: F) -> Result + where + F: FnMut(String, String, &mut EnvMap), + { + self.remove_bom()?; + let mut map = EnvMap::new(); + for item in self { + let (k, v) = item?; + load_fn(k, v, &mut map); + } + Ok(map) + } + + pub fn load(self) -> Result { + self.internal_load(|k, v: String, map| { + map.insert(k, v); + }) + } + + pub unsafe fn load_and_modify(self) -> Result { + self.internal_load(|k, v, map| { + if env::var(&k).is_err() { + unsafe { env::set_var(&k, &v) }; + } + map.insert(k, v); + }) + } + + pub unsafe fn load_and_modify_override(self) -> Result { + self.internal_load(|k, v, map| { + unsafe { env::set_var(&k, &v) }; + map.insert(k, v); + }) + } + + /// Removes the BOM if it exists. + /// + /// For more info, see the [Unicode BOM character](https://www.compart.com/en/unicode/U+FEFF). + fn remove_bom(&mut self) -> io::Result<()> { + let buf = self.lines.0.fill_buf()?; + + if buf.starts_with(&[0xEF, 0xBB, 0xBF]) { + self.lines.0.consume(3); + } + Ok(()) + } +} + +struct Lines(B); + +enum ParseState { + Complete, + Escape, + StrongOpen, + StrongOpenEscape, + WeakOpen, + WeakOpenEscape, + Comment, + WhiteSpace, +} + +impl ParseState { + fn eval_end(self, buf: &str) -> (usize, Self) { + let mut cur_state = self; + let mut cur_pos = 0; + + for (pos, c) in buf.char_indices() { + cur_pos = pos; + cur_state = match cur_state { + Self::WhiteSpace => match c { + '#' => return (cur_pos, Self::Comment), + '\\' => Self::Escape, + '"' => Self::WeakOpen, + '\'' => Self::StrongOpen, + _ => Self::Complete, + }, + Self::Escape => Self::Complete, + Self::Complete => match c { + c if c.is_whitespace() && c != '\n' && c != '\r' => Self::WhiteSpace, + '\\' => Self::Escape, + '"' => Self::WeakOpen, + '\'' => Self::StrongOpen, + _ => Self::Complete, + }, + Self::WeakOpen => match c { + '\\' => Self::WeakOpenEscape, + '"' => Self::Complete, + _ => Self::WeakOpen, + }, + Self::WeakOpenEscape => Self::WeakOpen, + Self::StrongOpen => match c { + '\\' => Self::StrongOpenEscape, + '\'' => Self::Complete, + _ => Self::StrongOpen, + }, + Self::StrongOpenEscape => Self::StrongOpen, + // Comments last the entire line. + Self::Comment => unreachable!("should have returned already"), + }; + } + (cur_pos, cur_state) + } +} + +impl Iterator for Lines { + type Item = Result; + + fn next(&mut self) -> Option { + let mut buf = String::new(); + let mut cur_state = ParseState::Complete; + let mut buf_pos; + let mut cur_pos; + loop { + buf_pos = buf.len(); + match self.0.read_line(&mut buf) { + Ok(0) => { + if matches!(cur_state, ParseState::Complete) { + return None; + } + let len = buf.len(); + return Some(Err(ParseBufError::LineParse(buf, len))); + } + Ok(_n) => { + // Skip lines which start with a `#` before iteration + // This optimizes parsing a bit. + if buf.trim_start().starts_with('#') { + return Some(Ok(String::with_capacity(0))); + } + let result = cur_state.eval_end(&buf[buf_pos..]); + cur_pos = result.0; + cur_state = result.1; + + match cur_state { + ParseState::Complete => { + if buf.ends_with('\n') { + buf.pop(); + if buf.ends_with('\r') { + buf.pop(); + } + } + return Some(Ok(buf)); + } + ParseState::Escape + | ParseState::StrongOpen + | ParseState::StrongOpenEscape + | ParseState::WeakOpen + | ParseState::WeakOpenEscape + | ParseState::WhiteSpace => {} + ParseState::Comment => { + buf.truncate(buf_pos + cur_pos); + return Some(Ok(buf)); + } + } + } + Err(e) => return Some(Err(ParseBufError::Io(e))), + } + } + } +} + +impl Iterator for Iter { + type Item = Result<(String, String), ParseBufError>; + + fn next(&mut self) -> Option { + loop { + let line = match self.lines.next() { + Some(Ok(line)) => line, + Some(Err(e)) => return Some(Err(e)), + None => return None, + }; + + match parse::parse_line(&line, &mut self.substitution_data) { + Ok(Some(res)) => return Some(Ok(res)), + Ok(None) => {} + Err(e) => return Some(Err(e)), + } + } + } +} + +/// An internal error type +/// +/// This is necessary so we can handle IO errors without knowing the path. +#[derive(Debug)] +pub enum ParseBufError { + LineParse(String, usize), + Io(io::Error), +} + +impl From for ParseBufError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + use std::io::Cursor; + + #[test] + fn test_remove_bom() { + // BOM present + let b = b"\xEF\xBB\xBFkey=value\n"; + let rdr = BufReader::new(Cursor::new(b)); + let mut iter = Iter::new(rdr); + iter.remove_bom().unwrap(); + let first_line = iter.lines.next().unwrap().unwrap(); + assert_eq!(first_line, "key=value"); + } + + #[test] + fn test_remove_bom_no_bom() { + // no BOM + let b = b"key=value\n"; + let reader = BufReader::new(Cursor::new(b)); + let mut iter = Iter::new(reader); + iter.remove_bom().unwrap(); + let first_line = iter.lines.next().unwrap().unwrap(); + assert_eq!(first_line, "key=value"); + } +} diff --git a/dotenvy/src/lib.rs b/dotenvy/src/lib.rs new file mode 100644 index 00000000..a41351d8 --- /dev/null +++ b/dotenvy/src/lib.rs @@ -0,0 +1,452 @@ +#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] +#![allow( + clippy::missing_errors_doc, + clippy::too_many_lines, + clippy::missing_safety_doc, + unused_unsafe, // until Rust 2024 +)] +#![deny(clippy::uninlined_format_args, clippy::wildcard_imports)] + +//! [`dotenv`]: https://crates.io/crates/dotenv +//! A well-maintained fork of the [`dotenv`] crate. +//! +//! This library allows for loading environment variables from an env file or a reader. +use crate::iter::Iter; +use std::{ + collections::HashMap, + env::{self, VarError}, + fs::File, + io::{BufReader, Read}, + ops::{Deref, DerefMut}, + path::{Path, PathBuf}, +}; + +mod err; +mod iter; +mod parse; + +/// A map of environment variables. +/// +/// This is a newtype around `HashMap` with one additional function, `var`. +#[derive(Default, Clone, Debug, PartialEq, Eq)] +pub struct EnvMap(HashMap); + +impl Deref for EnvMap { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for EnvMap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl FromIterator<(String, String)> for EnvMap { + fn from_iter>(iter: I) -> Self { + Self(HashMap::from_iter(iter)) + } +} + +impl IntoIterator for EnvMap { + type Item = (String, String); + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl EnvMap { + #[must_use] + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn var(&self, key: &str) -> Result { + self.get(key) + .cloned() + .ok_or_else(|| Error::NotPresent(key.to_owned())) + } +} + +pub use crate::err::Error; + +#[cfg(feature = "macros")] +pub use dotenvy_macros::*; + +/// Fetches the environment variable `key` from the current process. +/// +/// This is `std_env_var` but with an error type of `dotenvy::Error`. +/// `dotenvy::Error` uses `NotPresent(String)` instead of `NotPresent`, reporting the name of the missing key. +/// +/// # Errors +/// +/// This function will return an error if the environment variable isn't set. +/// +/// This function may return an error if the environment variable's name contains +/// the equal sign character (`=`) or the NUL character. +/// +/// This function will return an error if the environment variable's value is +/// not valid Unicode. +/// +/// # Examples +/// +/// ``` +/// let key = "HOME"; +/// match dotenvy::var(key) { +/// Ok(val) => println!("{key}: {val:?}"), +/// Err(e) => println!("couldn't interpret {key}: {e}"), +/// } +/// ``` +pub fn var(key: &str) -> Result { + env::var(key).map_err(|e| match e { + VarError::NotPresent => Error::NotPresent(key.to_owned()), + VarError::NotUnicode(os_str) => Error::NotUnicode(os_str, key.to_owned()), + }) +} + +/// The sequence in which to load environment variables. +/// +/// Values in the latter override values in the former. +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub enum EnvSequence { + /// Inherit the existing environment without loading from input. + EnvOnly, + /// Inherit the existing environment, and then load from input, overriding existing values. + EnvThenInput, + /// Load from input only. + InputOnly, + /// Load from input and then inherit the existing environment. Values in the existing environment are not overwritten. + #[default] + InputThenEnv, +} + +#[derive(Default)] +pub struct EnvLoader<'a> { + path: Option, + reader: Option>, + sequence: EnvSequence, +} + +impl<'a> EnvLoader<'a> { + #[must_use] + /// Creates a new `EnvLoader` with the path set to `./.env` in the current directory. + pub fn new() -> Self { + Self::with_path("./.env") + } + + /// Creates a new `EnvLoader` with the path as input. + /// + /// This operation is infallible. IO is deferred until `load` or `load_and_modify` is called. + pub fn with_path>(path: P) -> Self { + Self { + path: Some(path.as_ref().to_owned()), + ..Default::default() + } + } + + /// Creates a new `EnvLoader` with the reader as input. + /// + /// This operation is infallible. IO is deferred until `load` or `load_and_modify` is called. + pub fn with_reader(rdr: R) -> Self { + Self { + reader: Some(Box::new(rdr)), + ..Default::default() + } + } + + /// Sets the path to the specified path. + /// + /// This is useful when constructing with a reader, but still desiring a path to be used in the error message context. + /// + /// If a reader exists and a path is specified, loading will be done using the reader. + #[must_use] + pub fn path>(mut self, path: P) -> Self { + self.path = Some(path.as_ref().to_owned()); + self + } + + /// Sets the sequence in which to load environment variables. + #[must_use] + pub const fn sequence(mut self, sequence: EnvSequence) -> Self { + self.sequence = sequence; + self + } + + fn buf(self) -> Result>, crate::Error> { + let rdr = if let Some(rdr) = self.reader { + rdr + } else if let Some(path) = self.path { + let file = File::open(&path).map_err(|io_err| crate::Error::from((io_err, path)))?; + Box::new(file) + } else { + // only `EnvLoader::default` would have no reader or path + return Err(Error::NoInput); + }; + Ok(BufReader::new(rdr)) + } + + fn load_input(self) -> Result { + let path = self.path.clone(); + let iter = Iter::new(self.buf()?); + iter.load().map_err(|e| ((e, path).into())) + } + + unsafe fn load_input_and_modify(self) -> Result { + let path = self.path.clone(); + let iter = Iter::new(self.buf()?); + unsafe { iter.load_and_modify() }.map_err(|e| ((e, path).into())) + } + + unsafe fn load_input_and_modify_override(self) -> Result { + let path = self.path.clone(); + let iter = Iter::new(self.buf()?); + unsafe { iter.load_and_modify_override() }.map_err(|e| ((e, path).into())) + } + + /// Loads environment variables into a hash map. + /// + /// This is the primary method for loading environment variables. + pub fn load(self) -> Result { + match self.sequence { + EnvSequence::EnvOnly => Ok(env::vars().collect()), + EnvSequence::EnvThenInput => { + let mut existing: EnvMap = env::vars().collect(); + let input = self.load_input()?; + existing.extend(input); + Ok(existing) + } + EnvSequence::InputOnly => self.load_input(), + EnvSequence::InputThenEnv => { + let mut input = self.load_input()?; + input.extend(env::vars()); + Ok(input) + } + } + } + + /// Loads environment variables into a hash map, modifying the existing environment. + /// + /// This calls `std::env::set_var` internally and is not thread-safe. + pub unsafe fn load_and_modify(self) -> Result { + match self.sequence { + // nothing to modify + EnvSequence::EnvOnly => Err(Error::InvalidOp), + // override existing env with input, returning entire env + EnvSequence::EnvThenInput => { + let mut existing: EnvMap = env::vars().collect(); + let input = unsafe { self.load_input_and_modify_override() }?; + existing.extend(input); + Ok(existing) + } + // override existing env with input, returning input only + EnvSequence::InputOnly => unsafe { self.load_input_and_modify_override() }, + // load input into env, but don't override existing + EnvSequence::InputThenEnv => { + let existing: EnvMap = env::vars().collect(); + + let mut input = unsafe { self.load_input_and_modify() }?; + + for k in input.keys() { + if !existing.contains_key(k) { + unsafe { env::set_var(k, &input[k]) }; + } + } + input.extend(existing); + Ok(input) + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::{EnvLoader, EnvSequence}; + use std::{env, error, io::Cursor}; + + #[test] + fn test_substitution() -> Result<(), crate::Error> { + unsafe { + env::set_var("KEY", "value"); + env::set_var("KEY1", "value1"); + } + + let subs = [ + "$ZZZ", "$KEY", "$KEY1", "${KEY}1", "$KEY_U", "${KEY_U}", "\\$KEY", + ]; + + let common_string = subs.join(">>"); + let s = format!( + r#" + KEY1=new_value1 + KEY_U=$KEY+valueU + + STRONG_QUOTES='{common_string}' + WEAK_QUOTES="{common_string}" + NO_QUOTES={common_string} + "#, + ); + let env_map = EnvLoader::with_reader(Cursor::new(s)) + .sequence(EnvSequence::InputThenEnv) + .load()?; + + assert_eq!(env_map.var("KEY")?, "value"); + assert_eq!(env_map.var("KEY1")?, "value1"); + assert_eq!(env_map.var("KEY_U")?, "value+valueU"); + assert_eq!(env_map.var("STRONG_QUOTES")?, common_string); + assert_eq!( + env_map.var("WEAK_QUOTES")?, + [ + "", + "value", + "value1", + "value1", + "value_U", + "value+valueU", + "$KEY" + ] + .join(">>") + ); + assert_eq!( + env_map.var("NO_QUOTES")?, + [ + "", + "value", + "value1", + "value1", + "value_U", + "value+valueU", + "$KEY" + ] + .join(">>") + ); + Ok(()) + } + + #[test] + fn test_multiline() -> Result<(), crate::Error> { + let value = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\\n\\\"QUOTED\\\""; + let weak = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----\n\"QUOTED\""; + + let s = format!( + r#" + KEY=my\ cool\ value + KEY3="awesome \"stuff\" + more + on other + lines" + KEY4='hello '\''world'" + good ' \'morning" + WEAK="{value}" + STRONG='{value}' + "# + ); + + let env_map = EnvLoader::with_reader(Cursor::new(s)) + .sequence(EnvSequence::InputOnly) + .load()?; + assert_eq!(env_map.var("KEY")?, "my cool value"); + assert_eq!( + env_map.var("KEY3")?, + r#"awesome "stuff" + more + on other + lines"# + ); + assert_eq!( + env_map.var("KEY4")?, + "hello 'world + good ' 'morning" + ); + assert_eq!(env_map.var("WEAK")?, weak); + assert_eq!(env_map.var("STRONG")?, value); + Ok(()) + } + + #[test] + fn test_multiline_comment() -> Result<(), crate::Error> { + let s = r#" +# Start of env file +# Comment line with single ' quote +# Comment line with double " quote + # Comment line with double " quote and starts with a space +TESTKEY1=test_val # 1 '" comment +TESTKEY2=test_val_with_#_hash # 2 '" comment +TESTKEY3="test_val quoted with # hash" # 3 '" comment +TESTKEY4="Line 1 +# Line 2 +Line 3" # 4 Multiline "' comment +TESTKEY5="Line 4 +# Line 5 +Line 6 +" # 5 Multiline "' comment +# End of env file +"#; + + let env_map = EnvLoader::with_reader(Cursor::new(s)) + .sequence(EnvSequence::InputOnly) + .load()?; + assert_eq!(env_map.var("TESTKEY1")?, "test_val"); + assert_eq!(env_map.var("TESTKEY2")?, "test_val_with_#_hash"); + assert_eq!(env_map.var("TESTKEY3")?, "test_val quoted with # hash"); + assert_eq!( + env_map.var("TESTKEY4")?, + "Line 1 +# Line 2 +Line 3" + ); + assert_eq!( + env_map.var("TESTKEY5")?, + "Line 4 +# Line 5 +Line 6 +" + ); + Ok(()) + } + + #[test] + fn test_non_modify() -> Result<(), crate::Error> { + temp_env::with_var("SRC", Some("env"), || { + let s = "SRC=envfile\nFOO=bar"; + let env_map = EnvLoader::with_reader(Cursor::new(s)) + .sequence(EnvSequence::EnvThenInput) + .load()?; + assert_eq!("envfile", env_map.var("SRC")?); + assert_eq!("bar", env_map.var("FOO")?); + + let env_map = EnvLoader::with_reader(Cursor::new(s)) + .sequence(EnvSequence::InputThenEnv) + .load()?; + assert_eq!("env", env_map.var("SRC")?); + Ok(()) + }) + } + + #[test] + fn test_modify() -> Result<(), Box> { + let s = "SRC=envfile\nFOO=bar"; + let cursor = Cursor::new(s); + + temp_env::with_var("SRC", Some("env"), || { + let loader = EnvLoader::with_reader(cursor.clone()).sequence(EnvSequence::InputThenEnv); + unsafe { loader.load_and_modify() }?; + assert_eq!("env", env::var("SRC")?); + assert_eq!("bar", env::var("FOO")?); + Ok::<_, Box>(()) + })?; + + // override + temp_env::with_var("SRC", Some("env"), || { + let loader = EnvLoader::with_reader(cursor).sequence(EnvSequence::EnvThenInput); + unsafe { loader.load_and_modify() }?; + assert_eq!("envfile", env::var("SRC")?); + assert_eq!("bar", env::var("FOO")?); + Ok(()) + }) + } +} diff --git a/dotenv/src/parse.rs b/dotenvy/src/parse.rs similarity index 70% rename from dotenv/src/parse.rs rename to dotenvy/src/parse.rs index 397c73d5..f8d59417 100644 --- a/dotenv/src/parse.rs +++ b/dotenvy/src/parse.rs @@ -1,15 +1,13 @@ #![allow(clippy::module_name_repetitions)] -use crate::errors::{Error, Result}; use std::{collections::HashMap, env}; -// for readability's sake -pub type ParsedLine = Result>; +use crate::iter::ParseBufError; pub fn parse_line( line: &str, substitution_data: &mut HashMap>, -) -> ParsedLine { +) -> Result, ParseBufError> { let mut parser = LineParser::new(line, substitution_data); parser.parse_line() } @@ -31,11 +29,11 @@ impl<'a> LineParser<'a> { } } - fn err(&self) -> Error { - Error::LineParse(self.original_line.into(), self.pos) + fn err(&self) -> ParseBufError { + ParseBufError::LineParse(self.original_line.into(), self.pos) } - fn parse_line(&mut self) -> ParsedLine { + fn parse_line(&mut self) -> Result, ParseBufError> { self.skip_whitespace(); // if its an empty line or a comment, skip it if self.line.is_empty() || self.line.starts_with('#') { @@ -70,7 +68,7 @@ impl<'a> LineParser<'a> { Ok(Some((key, parsed_value))) } - fn parse_key(&mut self) -> Result { + fn parse_key(&mut self) -> Result { if !self .line .starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') @@ -90,7 +88,7 @@ impl<'a> LineParser<'a> { Ok(key) } - fn expect_equal(&mut self) -> Result<()> { + fn expect_equal(&mut self) -> Result<(), ParseBufError> { if !self.line.starts_with('=') { return Err(self.err()); } @@ -117,7 +115,10 @@ enum SubstitutionMode { EscapedBlock, } -fn parse_value(input: &str, substitution_data: &HashMap>) -> Result { +fn parse_value( + input: &str, + substitution_data: &HashMap>, +) -> Result { let mut strong_quote = false; // ' let mut weak_quote = false; // " let mut escaped = false; @@ -140,7 +141,7 @@ fn parse_value(input: &str, substitution_data: &HashMap>) } else if c == '#' { break; } - return Err(Error::LineParse(input.to_owned(), index)); + return Err(ParseBufError::LineParse(input.to_owned(), index)); } else if escaped { //TODO I tried handling literal \r but various issues //imo not worth worrying about until there's a use case @@ -150,7 +151,7 @@ fn parse_value(input: &str, substitution_data: &HashMap>) '\\' | '\'' | '"' | '$' | ' ' => output.push(c), 'n' => output.push('\n'), // handle \n case _ => { - return Err(Error::LineParse(input.to_owned(), index)); + return Err(ParseBufError::LineParse(input.to_owned(), index)); } } @@ -232,7 +233,7 @@ fn parse_value(input: &str, substitution_data: &HashMap>) //XXX also fail if escaped? or... if substitution_mode == SubstitutionMode::EscapedBlock || strong_quote || weak_quote { let value_length = input.len(); - Err(Error::LineParse( + Err(ParseBufError::LineParse( input.to_owned(), if value_length == 0 { 0 @@ -268,12 +269,10 @@ fn apply_substitution( #[cfg(test)] mod test { - use crate::iter::Iter; - - use super::*; + use crate::iter::{Iter, ParseBufError}; #[test] - fn test_parse_line_env() { + fn test_parse_line_env() -> Result<(), ParseBufError> { // Note 5 spaces after 'KEY8=' below let actual_iter = Iter::new( r#" @@ -310,16 +309,15 @@ export SHELL_LOVER=1 ("SHELL_LOVER", "1"), ] .into_iter() - .map(|(key, value)| (key.to_string(), value.to_string())); + .map(|(key, value)| (key.to_owned(), value.to_owned())); let mut count = 0; for (expected, actual) in expected_iter.zip(actual_iter) { - assert!(actual.is_ok()); - assert_eq!(expected, actual.unwrap()); + assert_eq!(expected, actual?); count += 1; } - assert_eq!(count, 13); + Ok(()) } #[test] @@ -327,7 +325,7 @@ export SHELL_LOVER=1 let input = br" # foo=bar # "; - let result: Result> = Iter::new(&input[..]).collect(); + let result: Result, ParseBufError> = Iter::new(&input[..]).collect(); assert!(result.unwrap().is_empty()); } @@ -351,7 +349,7 @@ very bacon = yes indeed } #[test] - fn test_parse_value_escapes() { + fn test_parse_value_escapes() -> Result<(), ParseBufError> { let actual_iter = Iter::new( r#" KEY=my\ cool\ value @@ -379,9 +377,9 @@ KEY7="line 1\nline 2" .map(|(key, value)| (key.to_string(), value.to_string())); for (expected, actual) in expected_iter.zip(actual_iter) { - assert!(actual.is_ok()); - assert_eq!(expected, actual.unwrap()); + assert_eq!(expected, actual?); } + Ok(()) } #[test] @@ -403,100 +401,99 @@ KEY4=h\8u } #[cfg(test)] -mod variable_substitution_tests { - use crate::iter::Iter; - use std::env; +mod substitution_tests { + use crate::iter::{Iter, ParseBufError}; - fn assert_parsed_string(input_string: &str, expected_parse_result: Vec<(&str, &str)>) { - let actual_iter = Iter::new(input_string.as_bytes()); - let expected_count = &expected_parse_result.len(); + /// Asserts the parsed string is equal to the expected string. + fn assert_string(input: &str, expected: Vec<(&str, &str)>) -> Result<(), ParseBufError> { + let actual_iter = Iter::new(input.as_bytes()); + let expected_count = expected.len(); - let expected_iter = expected_parse_result + let expected_iter = expected .into_iter() - .map(|(key, value)| (key.to_string(), value.to_string())); + .map(|(k, v)| (k.to_owned(), v.to_owned())); let mut count = 0; for (expected, actual) in expected_iter.zip(actual_iter) { - assert!(actual.is_ok()); - assert_eq!(expected, actual.unwrap()); + assert_eq!(expected, actual?); count += 1; } - - assert_eq!(count, *expected_count); + assert_eq!(count, expected_count); + Ok(()) } #[test] - fn variable_in_parenthesis_surrounded_by_quotes() { - assert_parsed_string( + fn variable_in_parenthesis_surrounded_by_quotes() -> Result<(), ParseBufError> { + assert_string( r#" KEY=test KEY1="${KEY}" "#, vec![("KEY", "test"), ("KEY1", "test")], - ); + ) } #[test] - fn substitute_undefined_variables_to_empty_string() { - assert_parsed_string(r#"KEY=">$KEY1<>${KEY2}<""#, vec![("KEY", "><><")]); + fn sub_undefined_variables_to_empty_string() -> Result<(), ParseBufError> { + assert_string(r#"KEY=">$KEY1<>${KEY2}<""#, vec![("KEY", "><><")]) } #[test] - fn do_not_substitute_variables_with_dollar_escaped() { - assert_parsed_string( + fn do_not_sub_with_dollar_escaped() -> Result<(), ParseBufError> { + assert_string( "KEY=>\\$KEY1<>\\${KEY2}<", vec![("KEY", ">$KEY1<>${KEY2}<")], - ); + ) } #[test] - fn do_not_substitute_variables_in_weak_quotes_with_dollar_escaped() { - assert_parsed_string( + fn do_not_sub_in_weak_quotes_with_dollar_escaped() -> Result<(), ParseBufError> { + assert_string( r#"KEY=">\$KEY1<>\${KEY2}<""#, vec![("KEY", ">$KEY1<>${KEY2}<")], - ); + ) } #[test] - fn do_not_substitute_variables_in_strong_quotes() { - assert_parsed_string("KEY='>${KEY1}<>$KEY2<'", vec![("KEY", ">${KEY1}<>$KEY2<")]); + fn do_not_sub_in_strong_quotes() -> Result<(), ParseBufError> { + assert_string("KEY='>${KEY1}<>$KEY2<'", vec![("KEY", ">${KEY1}<>$KEY2<")]) } #[test] - fn same_variable_reused() { - assert_parsed_string( + fn same_variable_reused() -> Result<(), ParseBufError> { + assert_string( r" KEY=VALUE KEY1=$KEY$KEY ", vec![("KEY", "VALUE"), ("KEY1", "VALUEVALUE")], - ); + ) } #[test] - fn with_dot() { - assert_parsed_string( + fn with_dot() -> Result<(), ParseBufError> { + assert_string( r" KEY.Value=VALUE ", vec![("KEY.Value", "VALUE")], - ); + ) } #[test] - fn recursive_substitution() { - assert_parsed_string( + fn recursive_substitution() -> Result<(), ParseBufError> { + assert_string( r" KEY=${KEY1}+KEY_VALUE KEY1=${KEY}+KEY1_VALUE ", vec![("KEY", "+KEY_VALUE"), ("KEY1", "+KEY_VALUE+KEY1_VALUE")], - ); + ) } #[test] - fn variable_without_parenthesis_is_substituted_before_separators() { - assert_parsed_string( + fn var_without_paranthesis_subbed_before_separators() -> Result<(), ParseBufError> { + assert_string( r#" KEY1=test_user KEY1_1=test_user_with_separator @@ -507,32 +504,33 @@ mod variable_substitution_tests { ("KEY1_1", "test_user_with_separator"), ("KEY", ">test_user_1<>test_user}<>test_user{<"), ], - ); + ) } #[test] - fn substitute_variable_from_env_variable() { - env::set_var("KEY11", "test_user_env"); - - assert_parsed_string(r#"KEY=">${KEY11}<""#, vec![("KEY", ">test_user_env<")]); + fn sub_var_from_env_var() -> Result<(), ParseBufError> { + temp_env::with_var("KEY11", Some("test_user_env"), || { + assert_string(r#"KEY=">${KEY11}<""#, vec![("KEY", ">test_user_env<")]) + }) } #[test] - fn substitute_variable_env_variable_overrides_dotenv_in_substitution() { - env::set_var("KEY11", "test_user_env"); - - assert_parsed_string( - r#" + fn substitute_variable_env_variable_overrides_dotenv_in_substitution( + ) -> Result<(), ParseBufError> { + temp_env::with_var("KEY11", Some("test_user_env"), || { + assert_string( + r#" KEY11=test_user KEY=">${KEY11}<" "#, - vec![("KEY11", "test_user"), ("KEY", ">test_user_env<")], - ); + vec![("KEY11", "test_user"), ("KEY", ">test_user_env<")], + ) + }) } #[test] - fn consequent_substitutions() { - assert_parsed_string( + fn consequent_substitutions() -> Result<(), ParseBufError> { + assert_string( r" KEY1=test_user KEY2=$KEY1_2 @@ -543,100 +541,83 @@ mod variable_substitution_tests { ("KEY2", "test_user_2"), ("KEY", ">test_user<>test_user_2<"), ], - ); + ) } #[test] - fn consequent_substitutions_with_one_missing() { - assert_parsed_string( + fn consequent_substitutions_with_one_missing() -> Result<(), ParseBufError> { + assert_string( r" KEY2=$KEY1_2 KEY=>${KEY1}<>${KEY2}< ", vec![("KEY2", "_2"), ("KEY", "><>_2<")], - ); + ) } } #[cfg(test)] mod error_tests { - use crate::errors::Error::LineParse; - use crate::iter::Iter; + use crate::iter::{Iter, ParseBufError}; #[test] - fn should_not_parse_unfinished_substitutions() { - let wrong_value = ">${KEY{<"; + fn should_not_parse_unfinished_subs() { + let invalid_value = ">${baz{<"; - let parsed_values: Vec<_> = Iter::new( + let iter = Iter::new( format!( r#" - KEY=VALUE - KEY1={wrong_value} + FOO=bar + BAR={invalid_value} "# ) .as_bytes(), ) - .collect(); + .collect::>(); - assert_eq!(parsed_values.len(), 2); - - if let Ok(first_line) = &parsed_values[0] { - assert_eq!(first_line, &(String::from("KEY"), String::from("VALUE"))); - } else { - panic!("Expected the first value to be parsed"); - } - - if let Err(LineParse(second_value, index)) = &parsed_values[1] { - assert_eq!(second_value, wrong_value); - assert_eq!(*index, wrong_value.len() - 1); - } else { - panic!("Expected the second value not to be parsed") - } + // first line works + assert_eq!( + iter[0].as_ref().unwrap(), + &("FOO".to_owned(), "bar".to_owned()) + ); + // second line error + assert!(matches!( + iter[1], + Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_value && idx == invalid_value.len() - 1 + )); } #[test] - fn should_not_allow_dot_as_first_character_of_key() { - let wrong_key_value = ".Key=VALUE"; - - let parsed_values: Vec<_> = Iter::new(wrong_key_value.as_bytes()).collect(); + fn should_not_allow_dot_as_first_char_of_key() { + let invalid_key = ".KEY=value"; - assert_eq!(parsed_values.len(), 1); + let iter = Iter::new(invalid_key.as_bytes()).collect::>(); - if let Err(LineParse(second_value, index)) = &parsed_values[0] { - assert_eq!(second_value, wrong_key_value); - assert_eq!(*index, 0); - } else { - panic!("Expected the second value not to be parsed") - } + assert!(matches!( + iter[0], + Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_key && idx == 0 + )); } #[test] - fn should_not_parse_illegal_format() { - let wrong_format = r"<><><>"; - let parsed_values: Vec<_> = Iter::new(wrong_format.as_bytes()).collect(); - - assert_eq!(parsed_values.len(), 1); - - if let Err(LineParse(wrong_value, index)) = &parsed_values[0] { - assert_eq!(wrong_value, wrong_format); - assert_eq!(*index, 0); - } else { - panic!("Expected the second value not to be parsed") - } + fn should_not_parse_invalid_format() { + let invalid_fmt = r"<><><>"; + let iter = Iter::new(invalid_fmt.as_bytes()).collect::>(); + + assert!(matches!( + iter[0], + Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_fmt && idx == 0 + )); } #[test] - fn should_not_parse_illegal_escape() { - let wrong_escape = r">\f<"; - let parsed_values: Vec<_> = Iter::new(format!("VALUE={wrong_escape}").as_bytes()).collect(); - - assert_eq!(parsed_values.len(), 1); - - if let Err(LineParse(wrong_value, index)) = &parsed_values[0] { - assert_eq!(wrong_value, wrong_escape); - assert_eq!(*index, wrong_escape.find('\\').unwrap() + 1); - } else { - panic!("Expected the second value not to be parsed") - } + fn should_not_parse_invalid_escape() { + let invalid_esc = r">\f<"; + let iter = Iter::new(format!("VALUE={invalid_esc}").as_bytes()).collect::>(); + + assert!(matches!( + iter[0], + Err(ParseBufError::LineParse(ref v, idx)) if v == invalid_esc && idx == invalid_esc.find('\\').unwrap() + 1 + )); } } diff --git a/examples/.env b/examples/.env deleted file mode 100644 index 95f961dc..00000000 --- a/examples/.env +++ /dev/null @@ -1 +0,0 @@ -HOST=dev.com \ No newline at end of file diff --git a/examples/Cargo.toml b/examples/Cargo.toml deleted file mode 100644 index 66964292..00000000 --- a/examples/Cargo.toml +++ /dev/null @@ -1,4 +0,0 @@ -[workspace] -members = ["*"] -exclude = ["target"] -resolver = "2" diff --git a/examples/dev-prod/Cargo.toml b/examples/dev-prod/Cargo.toml index 6bf98bc5..3aea642c 100644 --- a/examples/dev-prod/Cargo.toml +++ b/examples/dev-prod/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "example-dev-prod" +name = "dev-prod-example" version = "0.1.0" edition = "2021" publish = false [dependencies] -dotenvy = { path = "../../dotenv" } +dotenvy = { path = "../../dotenvy" } diff --git a/examples/dev-prod/src/main.rs b/examples/dev-prod/src/main.rs index 75aecbbc..210c240c 100644 --- a/examples/dev-prod/src/main.rs +++ b/examples/dev-prod/src/main.rs @@ -1,43 +1,52 @@ -use std::{env, fmt}; +//! This example loads from an env file in development but from the environment only in production. +//! +/// Commands to try: +/// 1) `cargo run` +/// 2) `APP_ENV=prod cargo run` +/// 3) `APP_ENV=prod HOST=prod.com cargo run` +use dotenvy::{EnvLoader, EnvSequence}; +use std::{env, error, str::FromStr}; + +fn main() -> Result<(), Box> { + let app_env = env::var("APP_ENV") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(AppEnv::Dev); + + let env_map = EnvLoader::with_path("../env-example") + .sequence(app_env.into()) + .load()?; + + if let Some(v) = env_map.get("HOST") { + println!("Host: {v}"); + } else { + println!("HOST not set"); + } + Ok(()) +} -#[derive(PartialEq)] enum AppEnv { Dev, Prod, } -/// A common setup that: -/// - loads from a .env file in dev mode -/// - loads from the environment in prod mode -/// -/// A few commands to try: -/// 1) `cargo run` -/// 2) `APP_ENV=prod cargo run` -/// 3) `APP_ENV=prod HOST=prod.com cargo run` -fn main() { - let app_env = match env::var("APP_ENV") { - Ok(v) if v == "prod" => AppEnv::Prod, - _ => AppEnv::Dev, - }; +impl FromStr for AppEnv { + type Err = String; - println!("Running in {app_env} mode"); - - if app_env == AppEnv::Dev { - match dotenvy::dotenv() { - Ok(path) => println!(".env read successfully from {}", path.display()), - Err(e) => println!("Could not load .env file: {e}"), - }; + fn from_str(s: &str) -> Result { + match s { + "dev" => Ok(Self::Dev), + "prod" => Ok(Self::Prod), + s => Err(format!("Invalid AppEnv: {s}")), + } } - - let host = env::var("HOST").expect("HOST not set"); - println!("Host: {host}"); } -impl fmt::Display for AppEnv { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - AppEnv::Dev => write!(f, "dev"), - AppEnv::Prod => write!(f, "prod"), +impl From for EnvSequence { + fn from(v: AppEnv) -> Self { + match v { + AppEnv::Dev => Self::InputThenEnv, + AppEnv::Prod => Self::EnvOnly, } } } diff --git a/examples/env-example b/examples/env-example new file mode 100644 index 00000000..d8dd4252 --- /dev/null +++ b/examples/env-example @@ -0,0 +1 @@ +HOST=dev.com diff --git a/examples/env-example-2 b/examples/env-example-2 new file mode 100644 index 00000000..83502d36 --- /dev/null +++ b/examples/env-example-2 @@ -0,0 +1 @@ +HOST=dev2.com diff --git a/examples/find/Cargo.toml b/examples/find/Cargo.toml new file mode 100644 index 00000000..1eac6f2c --- /dev/null +++ b/examples/find/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "find-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy" } diff --git a/examples/find/src/main.rs b/examples/find/src/main.rs new file mode 100644 index 00000000..90b0e1ea --- /dev/null +++ b/examples/find/src/main.rs @@ -0,0 +1,46 @@ +//! This example shows finding an env file by filename. +use dotenvy::EnvLoader; +use std::{ + env, error, fs, io, + path::{Path, PathBuf}, +}; + +fn main() -> Result<(), Box> { + let filename = "env-example"; + + println!("Looking for env file with filename: `{filename}`"); + let path = find(env::current_dir()?.as_path(), filename)?; + println!("Env file found at `{}`", path.display()); + + let env_map = EnvLoader::with_path(path).load()?; + if let Some(v) = env_map.get("HOST") { + println!("HOST={v}"); + } + Ok(()) +} + +/// Searches for the filename in the directory and parent directories until the file is found or the filesystem root is reached. +pub fn find(mut dir: &Path, filename: &str) -> io::Result { + loop { + let candidate = dir.join(filename); + + match fs::metadata(&candidate) { + Ok(metadata) => { + if metadata.is_file() { + return Ok(candidate); + } + } + Err(e) => { + if e.kind() != io::ErrorKind::NotFound { + return Err(e); + } + } + } + + if let Some(parent) = dir.parent() { + dir = parent; + } else { + return Err(io::ErrorKind::NotFound.into()); + } + } +} diff --git a/examples/modify-macro/Cargo.toml b/examples/modify-macro/Cargo.toml new file mode 100644 index 00000000..491bcbf2 --- /dev/null +++ b/examples/modify-macro/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "modify-macro-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy", features = ["macros"] } \ No newline at end of file diff --git a/examples/modify-macro/src/main.rs b/examples/modify-macro/src/main.rs new file mode 100644 index 00000000..ef1479ca --- /dev/null +++ b/examples/modify-macro/src/main.rs @@ -0,0 +1,7 @@ +use std::{env, error}; + +#[dotenvy::load(path = "../env-example", required = true, override_ = true)] +fn main() -> Result<(), Box> { + println!("HOST={}", env::var("HOST")?); + Ok(()) +} diff --git a/examples/modify-tokio-macro/Cargo.toml b/examples/modify-tokio-macro/Cargo.toml new file mode 100644 index 00000000..8715a6d2 --- /dev/null +++ b/examples/modify-tokio-macro/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "modify-tokio-macro-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy", features = ["macros"] } +tokio = { version = "1", features = [ + "process", + "rt-multi-thread", + "rt", + "macros", +] } diff --git a/examples/modify-tokio-macro/src/main.rs b/examples/modify-tokio-macro/src/main.rs new file mode 100644 index 00000000..6ab8c181 --- /dev/null +++ b/examples/modify-tokio-macro/src/main.rs @@ -0,0 +1,10 @@ +//! `#[dotenvy::load]` must go before `#[tokio::main]`. + +use std::{env, error}; + +#[dotenvy::load(path = "../env-example")] +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("HOST={}", env::var("HOST")?); + Ok(()) +} diff --git a/examples/modify-tokio/Cargo.toml b/examples/modify-tokio/Cargo.toml new file mode 100644 index 00000000..4d2b4ad6 --- /dev/null +++ b/examples/modify-tokio/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "modify-tokio-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy" } +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"]} \ No newline at end of file diff --git a/examples/modify-tokio/src/main.rs b/examples/modify-tokio/src/main.rs new file mode 100644 index 00000000..d496ecb4 --- /dev/null +++ b/examples/modify-tokio/src/main.rs @@ -0,0 +1,24 @@ +use dotenvy::EnvLoader; +use std::{ + env::{self, VarError}, + error, +}; + +// `load_and_modify` uses `std::env::set_var` internally, which is not thread-safe. +// As such, loading must be done before the async runtime is spawned. +// This is why we don't use `#[tokio::main]` here. +fn main() -> Result<(), Box> { + let loader = EnvLoader::with_path("../env-example"); + unsafe { loader.load_and_modify() }?; + + // this is the expansion of `#[tokio::main]` + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async { + println!("HOST={}", env::var("HOST")?); + Ok::<_, VarError>(()) + })?; + + Ok(()) +} diff --git a/examples/modify/Cargo.toml b/examples/modify/Cargo.toml new file mode 100644 index 00000000..ee61f5f7 --- /dev/null +++ b/examples/modify/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "modify-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy" } diff --git a/examples/modify/print_host.py b/examples/modify/print_host.py new file mode 100644 index 00000000..6eb71b9f --- /dev/null +++ b/examples/modify/print_host.py @@ -0,0 +1,4 @@ +import os + +print('Hello, Python world') +print(f'HOST={os.environ.get("HOST")}') \ No newline at end of file diff --git a/examples/modify/src/main.rs b/examples/modify/src/main.rs new file mode 100644 index 00000000..78ff39f9 --- /dev/null +++ b/examples/modify/src/main.rs @@ -0,0 +1,22 @@ +//! This example modifies the existing environment. +//! +//! This makes environment varaibles from available to subprocesses, e.g., a Python script. +use dotenvy::{EnvLoader, EnvSequence}; +use std::{env, error, fs, io, process::Command}; + +fn main() -> Result<(), Box> { + // to override, set sequence to `EnvThenInput` or `InputOnly` + let loader = EnvLoader::with_path("../env-example").sequence(EnvSequence::InputThenEnv); + unsafe { loader.load_and_modify() }?; + + println!("HOST={}", env::var("HOST")?); + print_host_py()?; + Ok(()) +} + +fn print_host_py() -> io::Result<()> { + let script = fs::read_to_string("print_host.py")?; + let output = Command::new("python3").arg("-c").arg(script).output()?; + print!("{}", String::from_utf8_lossy(&output.stdout)); + Ok(()) +} diff --git a/examples/multiple-files/Cargo.toml b/examples/multiple-files/Cargo.toml new file mode 100644 index 00000000..60193ed3 --- /dev/null +++ b/examples/multiple-files/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "multiple-files-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy" } diff --git a/examples/multiple-files/src/main.rs b/examples/multiple-files/src/main.rs new file mode 100644 index 00000000..b993cbb0 --- /dev/null +++ b/examples/multiple-files/src/main.rs @@ -0,0 +1,19 @@ +use dotenvy::{EnvLoader, EnvSequence}; +use std::error; + +fn main() -> Result<(), Box> { + let map_a = EnvLoader::with_path("../env-example") + .sequence(EnvSequence::EnvThenInput) + .load()?; + let map_b = EnvLoader::with_path("../env-example-2") + .sequence(EnvSequence::InputOnly) // we already loaded from the environment in map_a + .load()?; + + let mut env_map = map_a.clone(); + env_map.extend(map_b); + + if let Some(v) = env_map.get("HOST") { + println!("HOST={v}"); + } + Ok(()) +} diff --git a/examples/optional/Cargo.toml b/examples/optional/Cargo.toml new file mode 100644 index 00000000..f4063dc4 --- /dev/null +++ b/examples/optional/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "optional-example" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +dotenvy = { path = "../../dotenvy" } diff --git a/examples/optional/src/main.rs b/examples/optional/src/main.rs new file mode 100644 index 00000000..545d2877 --- /dev/null +++ b/examples/optional/src/main.rs @@ -0,0 +1,31 @@ +//! This example loads an env file only if the file exists. +//! +//! `HOST=abc cargo run` +use dotenvy::{EnvLoader, EnvSequence}; +use std::{error, fs::File, io, path::Path}; + +fn main() -> Result<(), Box> { + let path = Path::new("non-existent-env"); + + // Rather than checking with `Path::exists` and then opening the file handle, we call `File::open` directly to avoid a race condition where the file is inaccessible between the exist check and open + // Even though we pass the file handle as input, we specify the path so it can be used as context in the error message + let loader = match File::open(path) { + Ok(file) => EnvLoader::with_reader(file) + .path(path) + .sequence(EnvSequence::InputThenEnv), + Err(e) => { + if e.kind() == io::ErrorKind::NotFound { + EnvLoader::default().sequence(EnvSequence::EnvOnly) + } else { + return Err(e.into()); + } + } + }; + + let env_map = loader.load()?; + + if let Some(v) = env_map.get("HOST") { + println!("Host: {v}"); + } + Ok(()) +} diff --git a/scripts/cicheck.sh b/scripts/cicheck.sh index 19e279df..4fed8853 100755 --- a/scripts/cicheck.sh +++ b/scripts/cicheck.sh @@ -1,7 +1,7 @@ #!/usr/bin/env sh set -e -MSRV="1.72.0" +MSRV="1.74.0" echo "fmt check" cargo fmt --all --check