diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..83c5ccc6b49cbee9d31080b7faeba93a22191755 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..09cf6e97d88f9dab05e6fcbeb305f506d7c73ccb --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +image: + name: cr.weirdboi.dev/base/rust:1-82 + pull_policy: always +stages: + - test +cache: &global_cache + key: ${CI_COMMIT_REF_SLUG} + paths: + - .cargo/bin + - .cargo/registry/index + - .cargo/registry/cache + - target/debug/deps + - target/debug/build + policy: pull-push + +variables: + CARGO_HOME: ${CI_PROJECT_DIR}/.cargo + +test: + stage: test + script: + - cargo +nightly test --workspace -- --format=json -Z unstable-options --report-time | junitify --out $CI_PROJECT_DIR/tests/ + artifacts: + when: always + reports: + junit: $CI_PROJECT_DIR/tests/*.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..13566b81b018ad684f3a35fee301741b2734c8f4 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/envish.iml b/.idea/envish.iml new file mode 100644 index 0000000000000000000000000000000000000000..bbe0a70f71c5b253c29e73ecf956e6f6ee2bfb13 --- /dev/null +++ b/.idea/envish.iml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<module type="EMPTY_MODULE" version="4"> + <component name="NewModuleRootManager"> + <content url="file://$MODULE_DIR$"> + <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> + <sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" /> + <excludeFolder url="file://$MODULE_DIR$/target" /> + </content> + <orderEntry type="inheritedJdk" /> + <orderEntry type="sourceFolder" forTests="false" /> + </component> +</module> \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000000000000000000000000000000000..e7b282bfbac670d848dad1aa9b5d542ecb54a38e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="ProjectModuleManager"> + <modules> + <module fileurl="file://$PROJECT_DIR$/.idea/envish.iml" filepath="$PROJECT_DIR$/.idea/envish.iml" /> + </modules> + </component> +</project> \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000000000000000000000000000000000..94a25f7f4cb416c083d265558da75d457237d671 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<project version="4"> + <component name="VcsDirectoryMappings"> + <mapping directory="$PROJECT_DIR$" vcs="Git" /> + </component> +</project> \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..5a3411970fd4e04ee06ebc49546f89902f014c33 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "envish" +description = "A library for sourcing, reading, and interacting with .env files, including interpolation support" +version = "0.1.0" +edition = "2021" + +[features] +default = ["fs"] +fs = [] + +[dependencies] +nom = "7.1.3" +nom_locate = "4.2.0" +thiserror = "2.0.3" diff --git a/src/env_file.rs b/src/env_file.rs new file mode 100644 index 0000000000000000000000000000000000000000..c013c255468d54cc7f7167cd81ef75f25c1ecf9b --- /dev/null +++ b/src/env_file.rs @@ -0,0 +1,287 @@ +use std::env; +use std::env::VarError; +use crate::parser::{file, FileLine}; +use nom_locate::LocatedSpan; +use std::str::FromStr; +use std::string::ParseError; + +#[derive(Clone, Debug, Default)] +pub struct EnvironmentFile { + lines: Vec<FileLine>, +} + +#[derive(Debug, thiserror::Error)] +pub enum EnvironmentFileError { + #[error("Unable to determine eof marker")] + InvalidEof, + #[error("Parse error at line {line}, column {column}: {kind:?}")] + ParseError { + line: usize, + column: usize, + kind: nom::error::ErrorKind, + }, + #[error(transparent)] + StdParseError(#[from] ParseError), + #[error("Value could not be determined to be valid UTF-8")] + InvalidValue, +} + +impl FromStr for EnvironmentFile { + type Err = EnvironmentFileError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let (_, lines) = file(LocatedSpan::new(s)).map_err(|err| match err { + nom::Err::Incomplete(_) => EnvironmentFileError::InvalidEof, + nom::Err::Error(err) | nom::Err::Failure(err) => EnvironmentFileError::ParseError { + column: err.input.get_column(), + line: err.input.location_line() as usize, + kind: err.code, + }, + })?; + + Ok(Self::new(lines)) + } +} + +fn is_kv_line(line: &FileLine) -> bool { + matches!(line, FileLine::KeyValue { .. }) +} + +fn is_comment_line(line: &FileLine) -> bool { + matches!(line, FileLine::Comment(_)) +} + +impl EnvironmentFile { + pub fn new(lines: Vec<FileLine>) -> Self { + Self { lines } + } + + pub fn parse(value: impl AsRef<str>) -> Result<Self, EnvironmentFileError> { + value.as_ref().parse() + } + + pub fn iter(&self) -> EnvFileIterator { + EnvFileIterator { + lines: &self.lines, + current: 0, + } + } + + pub fn lines_kv(&self) -> SubTypeFileIterator { + SubTypeFileIterator { + lines: &self.lines, + current: 0, + predicate: is_kv_line, + } + } + + pub fn lines_comment(&self) -> SubTypeFileIterator { + SubTypeFileIterator { + lines: &self.lines, + current: 0, + predicate: is_comment_line, + } + } + + pub fn get_raw(&self, key: &str) -> Option<String> { + for line in &self.lines { + if let FileLine::KeyValue { key: k, value } = line { + if k == key { + return Some(value.iter().map(|part| part.to_string()).collect::<String>()); + } + } + } + + None + } + + pub fn len(&self) -> usize { + self.lines.len() + } + + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + pub fn apply(&self) -> Result<(), EnvironmentFileError> { + set_from_file(self) + } +} + +pub struct EnvFileIterator<'a> { + lines: &'a [FileLine], + current: usize, +} + +impl<'a> Iterator for EnvFileIterator<'a> { + type Item = &'a FileLine; + + fn next(&mut self) -> Option<Self::Item> { + if self.current >= self.lines.len() { + None + } else { + let line = &self.lines[self.current]; + self.current += 1; + Some(line) + } + } +} + +impl ExactSizeIterator for EnvFileIterator<'_> { + fn len(&self) -> usize { + self.lines.len() - self.current + } +} + +pub struct SubTypeFileIterator<'a> { + lines: &'a [FileLine], + current: usize, + predicate: fn(&FileLine) -> bool, +} + +impl<'a> Iterator for SubTypeFileIterator<'a> { + type Item = &'a FileLine; + + fn next(&mut self) -> Option<Self::Item> { + let mut found = None; + + while self.current < self.lines.len() { + let line = &self.lines[self.current]; + self.current += 1; + if (self.predicate)(line) { + found = Some(line); + break; + } + } + + found + } +} + +fn is_missing_key(key: &str) -> Result<bool, EnvironmentFileError> { + match env::var(key) { + Ok(_) => Ok(false), + Err(VarError::NotPresent) => Ok(true), + Err(_) => Err(EnvironmentFileError::InvalidValue), + } +} + +fn set_from_file(file: &EnvironmentFile) -> Result<(), EnvironmentFileError> { + let mut defferred = Vec::with_capacity(file.len()); + + for line in file.lines_kv() { + if let FileLine::KeyValue { key, .. } = &line { + if is_missing_key(key)? { + if line.is_complete() { + env::set_var(key, line.assemble_value()); + } else { + defferred.push(line); + } + } + } + } + + for line in defferred { + if let FileLine::KeyValue { key, .. } = line { + env::set_var(key, line.assemble_value()); + } + } + + Ok(()) +} + + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::ValuePart; + + #[test] + fn test_lines_kv_iterator() { + let env_file = EnvironmentFile::new(vec![ + FileLine::empty(), + FileLine::comment("# This is a comment"), + FileLine::key_value("KEY1".to_string(), vec![ValuePart::Static("VALUE1".to_string())]), + FileLine::empty(), + FileLine::key_value("KEY2".to_string(), vec![ValuePart::Static("VALUE2".to_string())]), + ]); + + let kv_lines: Vec<&FileLine> = env_file.lines_kv().collect(); + + assert_eq!(kv_lines.len(), 2); + assert!( + matches!(kv_lines[0], FileLine::KeyValue { key, value } if key == "KEY1" && value == &vec![ValuePart::Static("VALUE1".to_string())]) + ); + assert!( + matches!(kv_lines[1], FileLine::KeyValue { key, value } if key == "KEY2" && value == &vec![ValuePart::Static("VALUE2".to_string())]) + ); + } + + #[test] + fn test_lines_comment_iterator() { + let env_file = EnvironmentFile::new(vec![ + FileLine::empty(), + FileLine::comment(" Comment 1"), + FileLine::key_value("KEY1".to_string(), vec![ValuePart::Static("VALUE1".to_string())]), + FileLine::comment(" Comment 2"), + FileLine::key_value("KEY2".to_string(), vec![ValuePart::Static("VALUE2".to_string())]), + FileLine::comment(" Comment 3"), + ]); + + let comment_lines: Vec<&FileLine> = env_file.lines_comment().collect(); + + assert_eq!(comment_lines.len(), 3); + assert!(matches!(comment_lines[0], FileLine::Comment(comment) if comment == " Comment 1")); + assert!(matches!(comment_lines[1], FileLine::Comment(comment) if comment == " Comment 2")); + assert!(matches!(comment_lines[2], FileLine::Comment(comment) if comment == " Comment 3")); + } + + #[test] + fn test_iter_returns_all_lines() { + let env_file = EnvironmentFile::new(vec![ + FileLine::empty(), + FileLine::comment("This is a comment"), + FileLine::key_value("KEY1".to_string(), vec![ValuePart::Static("VALUE1".to_string())]), + FileLine::empty(), + FileLine::key_value("KEY2".to_string(), vec![ValuePart::Static("VALUE2".to_string())]), + ]); + + let all_lines: Vec<&FileLine> = env_file.iter().collect(); + + assert_eq!(all_lines.len(), 5); + assert!(matches!(all_lines[0], FileLine::Empty)); + assert!(matches!(all_lines[1], FileLine::Comment(comment) if comment == "This is a comment")); + assert!( + matches!(all_lines[2], FileLine::KeyValue { key, value } if key == "KEY1" && value == &vec![ValuePart::Static("VALUE1".to_string())]) + ); + assert!(matches!(all_lines[3], FileLine::Empty)); + assert!( + matches!(all_lines[4], FileLine::KeyValue { key, value } if key == "KEY2" && value == &vec![ValuePart::Static("VALUE2".to_string())]) + ); + } + + #[test] + fn test_lines_kv_maintains_order() { + let env_file = EnvironmentFile::new(vec![ + FileLine::empty(), + FileLine::key_value("KEY1".to_string(), vec![ValuePart::Static("VALUE1".to_string())]), + FileLine::comment("Comment"), + FileLine::key_value("KEY2".to_string(), vec![ValuePart::Static("VALUE2".to_string())]), + FileLine::key_value("KEY3".to_string(), vec![ValuePart::Static("VALUE3".to_string())]), + FileLine::empty(), + ]); + + let kv_lines: Vec<&FileLine> = env_file.lines_kv().collect(); + + assert_eq!(kv_lines.len(), 3); + assert!( + matches!(kv_lines[0], FileLine::KeyValue { key, value } if key == "KEY1" && value == &vec![ValuePart::Static("VALUE1".to_string())]) + ); + assert!( + matches!(kv_lines[1], FileLine::KeyValue { key, value } if key == "KEY2" && value == &vec![ValuePart::Static("VALUE2".to_string())]) + ); + assert!( + matches!(kv_lines[2], FileLine::KeyValue { key, value } if key == "KEY3" && value == &vec![ValuePart::Static("VALUE3".to_string())]) + ); + } +} diff --git a/src/filesystem.rs b/src/filesystem.rs new file mode 100644 index 0000000000000000000000000000000000000000..af50b9f5c720952614b73dad5fc3ca48b245c7e1 --- /dev/null +++ b/src/filesystem.rs @@ -0,0 +1,47 @@ +use crate::{EnvironmentFile}; +use std::fmt::Display; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +#[allow(clippy::enum_variant_names)] +pub enum EnvFsError { + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + ParseError(#[from] crate::EnvironmentFileError), + #[error("The target environment variable exists, but is not Unicode")] + EnvironmentError, +} + +pub fn env_file() -> Result<EnvironmentFile, EnvFsError> { + env_file_from_path(".env") +} + +pub fn env_file_suffix(environment: impl Display) -> Result<EnvironmentFile, EnvFsError> { + env_file_from_path(format!(".env.{}", environment)) +} + +pub fn env_file_from_path(path: impl AsRef<Path>) -> Result<EnvironmentFile, EnvFsError> { + let mut file = File::open(path)?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer)?; + Ok(buffer.parse()?) +} + +pub fn dotenv() -> Result<(), EnvFsError> { + let file = env_file()?; + file.apply().map_err(|_| EnvFsError::EnvironmentError) +} + +pub fn dotenv_suffix(environment: impl Display) -> Result<(), EnvFsError> { + let file = env_file_suffix(environment)?; + file.apply().map_err(|_| EnvFsError::EnvironmentError) +} + +pub fn dotenv_from(path: impl AsRef<Path>) -> Result<(), EnvFsError> { + let file = env_file_from_path(path)?; + file.apply().map_err(|_| EnvFsError::EnvironmentError) +} + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..82f59c5ca4aca804c4c0244938c53264d586be73 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +#![allow(unused_labels)] + +mod env_file; +#[cfg(feature = "fs")] +mod filesystem; +mod parser; + +pub use env_file::{EnvironmentFile, EnvironmentFileError}; +pub use parser::{FileLine, ValuePart}; + +#[cfg(feature = "fs")] +pub use filesystem::{dotenv, dotenv_from, dotenv_suffix}; diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..efb5b59c57b7572331c03b1095d9a309e8995550 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,655 @@ +use nom::bytes::complete::take_while1; +use nom::character::complete::space0; +use nom::error::{context, ErrorKind}; +use nom::multi::separated_list0; +use nom::sequence::{delimited, separated_pair}; +use nom::{ + branch::alt, + bytes::complete::{is_not, tag}, + combinator::map, + multi::many0, + sequence::preceded, + IResult, InputTake, +}; +use std::fmt::Display; + +#[derive(Clone, Debug, PartialEq)] +pub enum ValuePart { + Static(String), + Variable(String), + Comment(String), +} + +impl Display for ValuePart { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static(static_value) => write!(f, "{}", static_value), + Self::Variable(variable_name) => write!(f, "${{{}}}", variable_name), + Self::Comment(comment_value) => write!(f, "#{}", comment_value), + } + } +} + +impl ValuePart { + pub fn new(value: impl ToString) -> Self { + Self::Static(value.to_string()) + } + pub fn variable(value: impl ToString) -> Self { + Self::Variable(value.to_string()) + } + pub fn comment(value: impl ToString) -> Self { + Self::Comment(value.to_string()) + } + + fn var_from_span(span: Span) -> Self { + Self::Variable(span.fragment().to_string()) + } + + fn static_from_span(span: Span) -> Self { + Self::Static(span.fragment().to_string()) + } + + #[allow(dead_code)] + fn static_from_string(span: String) -> Self { + Self::Static(span) + } + + fn comment_from_span(span: Span) -> Self { + Self::Comment(span.fragment().to_string()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum FileLine { + Empty, + Comment(String), + KeyValue { key: String, value: Vec<ValuePart> }, +} + +impl Display for FileLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => write!(f, ""), + Self::Comment(comment) => write!(f, "#{}", comment), + Self::KeyValue { key, value } => { + write!(f, "{}={}", key, value.iter().map(|part| part.to_string()).collect::<String>()) + } + } + } +} + +impl FileLine { + pub fn empty() -> Self { + Self::Empty + } + + pub fn comment(comment: impl ToString) -> Self { + Self::Comment(comment.to_string()) + } + + pub fn key_value(key: impl ToString, value: impl ToOwned<Owned = Vec<ValuePart>>) -> Self { + Self::KeyValue { + key: key.to_string(), + value: value.to_owned(), + } + } + + fn empty_from_span(_span: Span) -> Self { + Self::Empty + } + fn key_value_form_span_pair((key_span, value): (Span, Vec<ValuePart>)) -> Self { + Self::KeyValue { + key: key_span.fragment().to_string(), + value, + } + } + fn comment_from_span(span: Span) -> Self { + Self::Comment(span.fragment().to_string()) + } + + /// Whether this line is entirely self contained. If the line is a kv pair that requires + /// interpolation, it is not considered complete. + pub fn is_complete(&self) -> bool { + match self { + Self::KeyValue { value, .. } => value.iter().all(|part| !matches!(part, &ValuePart::Variable(..))), + _ => true, + } + } + + pub fn strip_comments(&self) -> Self { + match self { + Self::Empty => Self::Empty, + Self::Comment(_) => Self::Empty, + Self::KeyValue { key, value } => Self::KeyValue { + key: key.clone(), + value: value + .into_iter() + .filter(|part| !matches!(part, ValuePart::Comment(..))) + .cloned() + .collect(), + }, + } + } + + /// Convert the line into a complete value string + pub fn assemble_value(&self) -> String { + match self { + Self::Empty => String::new(), + Self::Comment(_) => String::new(), + Self::KeyValue { value, .. } => value + .iter() + .map(|part| match part { + ValuePart::Static(val) => val.clone(), + ValuePart::Variable(var) => std::env::var(var).unwrap_or_default(), + ValuePart::Comment(_) => String::new(), + }) + .collect::<String>(), + } + } +} + +type Span<'a> = nom_locate::LocatedSpan<&'a str>; + +/// Takes everything until the end of line. +fn take_until_eol(input: Span) -> IResult<Span, Span> { + if input.fragment().starts_with('\n') { + return Ok((input, unsafe { + Span::new_from_raw_offset(input.location_offset(), input.location_line(), "", ()) + })); + } + is_not("\n")(input) +} + +fn is_ident_first_char(c: char) -> bool { + c.is_alphabetic() || c == '_' +} +fn is_ident_any_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn identifier(input: Span) -> IResult<Span, Span> { + let mut last_index = 0; + let mut input_chars = input.chars().peekable(); + let mut valid = match input_chars.next() { + Some(c) => is_ident_first_char(c), + None => false, + }; + + 'checkloop: while valid { + last_index += 1; + let next_char = input_chars.next(); + if next_char.is_none() { + break 'checkloop; + } + valid = next_char.map(is_ident_any_char).unwrap_or(false); + } + + if last_index == 0 { + Err(nom::Err::Error(nom::error::Error::new(input, ErrorKind::Fail))) + } else { + Ok(input.take_split(last_index)) + } +} + +fn value_separator(input: Span) -> IResult<Span, Span> { + context("Value Separator", delimited(space0, tag("="), space0))(input) +} + +fn values(input: Span) -> IResult<Span, Vec<ValuePart>> { + context( + "Environment Variable Values", + many0(alt(( + preceded(tag("#"), map(take_until_eol, ValuePart::comment_from_span)), + preceded(tag("$"), map(identifier, ValuePart::var_from_span)), + delimited(tag("${"), map(identifier, ValuePart::var_from_span), tag("}")), + map( + take_while1(|c: char| c != '$' && c != '\n' && c != '#'), + ValuePart::static_from_span, + ), + ))), + )(input) +} + +fn line(input: Span) -> IResult<Span, FileLine> { + context( + "Environment Variable Line", + delimited( + space0, + alt(( + map(preceded(tag("#"), take_until_eol), FileLine::comment_from_span), + map( + separated_pair(identifier, value_separator, values), + FileLine::key_value_form_span_pair, + ), + map(take_until_eol, FileLine::empty_from_span), // Empty line + )), + space0, + ), + )(input) +} + +pub(crate) fn file(input: Span) -> IResult<Span, Vec<FileLine>> { + context("Environment File", separated_list0(tag("\n"), line))(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_take_until_eol_empty_line() { + let input = Span::new(" \n"); + let result = take_until_eol(input); + let (remaining, parsed) = result.expect("Failed to parse"); + + assert_eq!(parsed.fragment(), &" "); + assert_eq!(remaining.fragment(), &"\n"); + } + + #[test] + fn test_take_until_eol_whitespace_only() { + let input = Span::new(" \t "); + let result = take_until_eol(input); + let (remaining, parsed) = result.expect("Failed to parse"); + + assert_eq!(parsed.fragment(), &" \t "); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_identifier_alphanumeric() { + let input = Span::new("abc123_XYZ"); + let result = identifier(input); + let (remaining, parsed) = result.expect("Failed to parse identifier"); + + assert_eq!(parsed.fragment(), &"abc123_XYZ"); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_identifier_starting_with_number() { + let input = Span::new("123abc"); + let result = identifier(input); + assert!(result.is_err(), "Expected an error for identifier starting with a number"); + } + + #[test] + fn test_identifier_mixed_case() { + let input = Span::new("abcDEF123"); + let result = identifier(input); + let (remaining, parsed) = result.expect("Failed to parse identifier"); + + assert_eq!(parsed.fragment(), &"abcDEF123"); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_identifier_at_end_of_input() { + let input = Span::new("ABC_123\n"); + let result = identifier(input); + let (remaining, parsed) = result.expect("Failed to parse identifier"); + + assert_eq!(parsed.fragment(), &"ABC_123"); + assert_eq!(remaining.fragment(), &"\n"); + } + + #[test] + fn test_identifier_longest_valid() { + let input = Span::new("abc123_XYZ@invalid"); + let result = identifier(input); + let (remaining, parsed) = result.expect("Failed to parse identifier"); + + assert_eq!(parsed.fragment(), &"abc123_XYZ"); + assert_eq!(remaining.fragment(), &"@invalid"); + } + + #[test] + fn test_value_separator_multiple_spaces_before() { + let input = Span::new(" = value"); + let result = value_separator(input); + let (remaining, parsed) = result.expect("Failed to parse value separator"); + + assert_eq!(parsed.fragment(), &"="); + assert_eq!(remaining.fragment(), &"value"); + } + + #[test] + fn test_value_separator_multiple_spaces_after() { + let input = Span::new("= \t "); + let result = value_separator(input); + let (remaining, parsed) = result.expect("Failed to parse value separator"); + + assert_eq!(parsed.fragment(), &"="); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_value_separator_missing_equals() { + let input = Span::new("KEY value"); + let result = value_separator(input); + assert!(result.is_err(), "Expected an error for input without '=' separator"); + } + + #[test] + fn test_value_separator_at_end_of_file() { + let input = Span::new("="); + let result = value_separator(input); + let (remaining, parsed) = result.expect("Failed to parse value separator"); + + assert_eq!(parsed.fragment(), &"="); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_comment() { + let input = Span::new("# This is a comment"); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!(parsed.len(), 1); + match &parsed[0] { + ValuePart::Comment(comment) => assert_eq!(comment, " This is a comment"), + _ => panic!("Expected a Comment ValuePart"), + } + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_variable_with_dollar_prefix() { + let input = Span::new("$VAR_NAME"); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!(parsed.len(), 1); + match &parsed[0] { + ValuePart::Variable(var_name) => assert_eq!(var_name, "VAR_NAME"), + _ => panic!("Expected a Variable ValuePart"), + } + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_variable_with_curly_braces() { + let input = Span::new("${VAR_NAME}"); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!(parsed.len(), 1); + match &parsed[0] { + ValuePart::Variable(var_name) => assert_eq!(var_name, "VAR_NAME"), + _ => panic!("Expected a Variable ValuePart"), + } + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_static_part() { + let example_input = "THis is aosidnon409ansd0 9j0an oians**d6a7d98odin on"; + let input = Span::new(example_input); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!(parsed.len(), 1); + match &parsed[0] { + ValuePart::Static(value) => assert_eq!(value, example_input), + _ => panic!("Expected a Static ValuePart"), + } + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_multiple_parts() { + let input = Span::new("static1 $VAR1 ${VAR2} static2 # Comment"); + let result = values(input); + let (_, parsed) = result.expect("Failed to parse values"); + + assert_eq!( + &parsed, + &[ + ValuePart::Static(String::from("static1 ")), + ValuePart::Variable(String::from("VAR1")), + ValuePart::Static(String::from(" ")), + ValuePart::Variable(String::from("VAR2")), + ValuePart::Static(String::from(" static2 ")), + ValuePart::Comment(String::from(" Comment")), + ] + ); + } + + #[test] + fn test_values_only_whitespace() { + let input = Span::new(" \t "); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!(parsed.len(), 1); + match &parsed[0] { + ValuePart::Static(value) => assert_eq!(value, " \t "), + _ => panic!("Expected a Static ValuePart"), + } + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_values_consecutive_variables() { + let input = Span::new("$VAR1${VAR2}$VAR3"); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!( + parsed, + vec![ + ValuePart::Variable("VAR1".to_string()), + ValuePart::Variable("VAR2".to_string()), + ValuePart::Variable("VAR3".to_string()), + ] + ); + assert_eq!(remaining.fragment(), &""); + } + #[test] + fn test_values_consecutive_variables_same_type() { + let input = Span::new("$VAR1$VAR2$VAR3"); + let result = values(input); + let (remaining, parsed) = result.expect("Failed to parse values"); + + assert_eq!( + parsed, + vec![ + ValuePart::Variable("VAR1".to_string()), + ValuePart::Variable("VAR2".to_string()), + ValuePart::Variable("VAR3".to_string()), + ] + ); + assert_eq!(remaining.fragment(), &""); + } + + #[test] + fn test_line_comment_with_whitespace() { + let input = Span::new(" # This is a comment \n"); + let result = line(input); + let (remaining, parsed) = result.expect("Failed to parse line"); + + match parsed { + FileLine::Comment(comment) => assert_eq!(comment, " This is a comment "), + _ => panic!("Expected a Comment FileLine"), + } + assert_eq!(remaining.fragment(), &"\n"); + } + + #[test] + fn test_line_key_value_pair_empty_value() { + let input = Span::new("KEY="); + let result = line(input); + let (remaining, parsed) = result.expect("Failed to parse line"); + + assert_eq!(remaining.fragment(), &""); + match parsed { + FileLine::KeyValue { key, value } => { + assert_eq!(key, "KEY"); + assert!(value.is_empty()); + } + _ => panic!("Expected KeyValue FileLine"), + } + } + #[test] + fn test_line_key_value_with_multiple_variables() { + let input = Span::new("KEY = value_${VAR1}_${VAR2}_$VAR3"); + let result = line(input); + let (remaining, parsed) = result.expect("Failed to parse line"); + + assert_eq!(remaining.fragment(), &""); + match parsed { + FileLine::KeyValue { key, value } => { + assert_eq!(key, "KEY"); + assert_eq!(value.len(), 6); + assert_eq!(value[0], ValuePart::Static("value_".to_string())); + assert_eq!(value[1], ValuePart::Variable("VAR1".to_string())); + assert_eq!(value[2], ValuePart::Static("_".to_string())); + assert_eq!(value[3], ValuePart::Variable("VAR2".to_string())); + assert_eq!(value[4], ValuePart::Static("_".to_string())); + assert_eq!(value[5], ValuePart::Variable("VAR3".to_string())); + } + other => panic!("Expected KeyValue FileLine, got {other:?}"), + } + } + + #[test] + fn test_file_empty_input() { + let input = Span::new(""); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse empty file"); + + assert!(parsed.is_empty(), "Expected empty Vec for an empty input"); + assert_eq!(remaining.fragment(), &"", "Expected remaining input to be empty"); + } + + #[test] + fn test_file_multiple_lines() { + let input = Span::new("KEY1=value1\n# Comment\nKEY2=value2\n"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file"); + + assert_eq!(remaining.fragment(), &"\n"); + assert_eq!(parsed.len(), 3); + + assert_eq!( + &parsed[0], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value1".to_string())]) + ); + assert_eq!(&parsed[1], &FileLine::comment(" Comment")); + assert_eq!( + &parsed[2], + &FileLine::key_value("KEY2", vec![ValuePart::Static("value2".to_string())]) + ); + } + + #[test] + fn test_file_only_empty_lines() { + let input = Span::new("\n\n\n"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file with only empty lines"); + + assert_eq!(remaining.fragment(), &"\n"); + assert_eq!(parsed.len(), 3); + + assert_eq!(parsed, vec![FileLine::empty(), FileLine::empty(), FileLine::empty()]); + } + + #[test] + fn test_file_with_empty_line_in_middle() { + let input = Span::new("KEY1=value1\n\nKEY2=value2"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file with empty line"); + + assert_eq!(remaining.fragment(), &""); + assert_eq!(parsed.len(), 3); + + assert_eq!( + &parsed[0], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value1".to_string())]) + ); + assert_eq!(&parsed[1], &FileLine::empty()); + assert_eq!( + &parsed[2], + &FileLine::key_value("KEY2", vec![ValuePart::Static("value2".to_string())]) + ); + } + + #[test] + fn test_file_only_comments() { + let input = Span::new("# Comment 1\n# Comment 2\n# Comment 3"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file with only comments"); + + assert_eq!(remaining.fragment(), &""); + assert_eq!(parsed.len(), 3); + + assert_eq!(&parsed[0], &FileLine::comment(" Comment 1")); + assert_eq!(&parsed[1], &FileLine::comment(" Comment 2")); + assert_eq!(&parsed[2], &FileLine::comment(" Comment 3")); + } + + #[test] + fn test_file_with_duplicate_keys() { + let input = Span::new("KEY1=value1\nKEY1=value2\nKEY2=value3\nKEY1=value4"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file with duplicate keys"); + + assert_eq!(remaining.fragment(), &""); + assert_eq!(parsed.len(), 4); + + assert_eq!( + &parsed[0], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value1".to_string())]) + ); + assert_eq!( + &parsed[1], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value2".to_string())]) + ); + assert_eq!( + &parsed[2], + &FileLine::key_value("KEY2", vec![ValuePart::Static("value3".to_string())]) + ); + assert_eq!( + &parsed[3], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value4".to_string())]) + ); + } + + #[test] + fn test_file_key_without_value() { + let input = Span::new("KEY_ONLY=\n"); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file"); + + assert_eq!(remaining.fragment(), &"\n"); + assert_eq!(parsed.len(), 1); + match &parsed[0] { + FileLine::KeyValue { key, value } => { + assert_eq!(key, "KEY_ONLY"); + assert!(value.is_empty(), "Expected empty value for key without equals sign"); + } + _ => panic!("Expected KeyValue FileLine"), + } + } + #[test] + fn test_file_mixed_lines_with_empty_values() { + let input = Span::new("KEY1=value1\nKEY2=\nKEY3=value3\nKEY4="); + let result = file(input); + let (remaining, parsed) = result.expect("Failed to parse file"); + + assert_eq!(remaining.fragment(), &""); + assert_eq!(parsed.len(), 4); + + assert_eq!( + &parsed[0], + &FileLine::key_value("KEY1", vec![ValuePart::Static("value1".to_string())]) + ); + assert_eq!(&parsed[1], &FileLine::key_value("KEY2", Vec::new())); + assert_eq!( + &parsed[2], + &FileLine::key_value("KEY3", vec![ValuePart::Static("value3".to_string())]) + ); + assert_eq!(&parsed[3], &FileLine::key_value("KEY4", Vec::new())); + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000000000000000000000000000000000000..bbdd0a4bf3b743003d30b62f264249de7b4cec5e --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,44 @@ +use envish::EnvironmentFile; + +#[test] +fn it_parses_basic_dotenv_file() { + let file_contents = r#" + # This value won't be set in the test, and this comment will be ignored + MY_BEST_VARIABLE=some_value + # This variable is also not defined, and it'll still be a string, + # because all environment variables are strings without being converted to other data types + SOME_OTHER_VARIABLE=1234 + "#; + + std::env::var("MY_BEST_VARIABLE").expect_err("MY_BEST_VARIABLE should not be set"); + std::env::var("SOME_OTHER_VARIABLE").expect_err("SOME_OTHER_VARIABLE should not be set"); + + let file = EnvironmentFile::parse(file_contents).expect("Failed to parse environment file"); + file.apply().expect("Failed to apply environment file"); + + assert_eq!(std::env::var("MY_BEST_VARIABLE").unwrap(), "some_value"); + assert_eq!(std::env::var("SOME_OTHER_VARIABLE").unwrap(), "1234"); +} + +fn it_parses_dotenv_file_with_interpolation() { + let file_contents = r#" + # This value won't be set in the test, and this comment will be ignored + MY_BEST_VARIABLE=some_value + # This variable is also not defined, and it'll still be a string, + # because all environment variables are strings without being converted to other data types + SOME_OTHER_VARIABLE=1234 + # This variable contains an interpolated value + INTERPOLATED_VARIABLE=${SOME_OTHER_VARIABLE}567 + "#; + + std::env::var("MY_BEST_VARIABLE").expect_err("MY_BEST_VARIABLE should not be set"); + std::env::var("SOME_OTHER_VARIABLE").expect_err("SOME_OTHER_VARIABLE should not be set"); + std::env::var("INTERPOLATED_VARIABLE").expect_err("INTERPOLATED_VARIABLE should not be set"); + + let file = EnvironmentFile::parse(file_contents).expect("Failed to parse environment file"); + file.apply().expect("Failed to apply environment file"); + + assert_eq!(std::env::var("MY_BEST_VARIABLE").unwrap(), "some_value"); + assert_eq!(std::env::var("SOME_OTHER_VARIABLE").unwrap(), "1234"); + assert_eq!(std::env::var("INTERPOLATED_VARIABLE").unwrap(), "1234567"); +} \ No newline at end of file