Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mikegarde/dotenv-cli/test.rust.yml)](https://github.com/MikeGarde/dotenv-cli/actions)
[![Codecov](https://img.shields.io/codecov/c/github/mikegarde/dotenv-cli)](https://app.codecov.io/gh/MikeGarde/dotenv-cli)
[![NPM Downloads](https://img.shields.io/npm/dy/%40mikegarde%2Fdotenv-cli?logo=npm&color=blue)](https://www.npmjs.com/package/@mikegarde/dotenv-cli)
[![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=crates&color=blue)](https://crates.io/crates/dotenv-cli)
[![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=Rust&color=blue)](https://crates.io/crates/dotenv-cli)
[![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/mikegarde/dotenv-cli/total?logo=github&color=blue)](https://github.com/MikeGarde/dotenv-cli/releases)

A simple way to retrieve, update, or delete .env variables directly from the command line.
Expand Down Expand Up @@ -76,6 +76,14 @@ Delete a value from a .env file:
dotenv <key> --delete
```

### Validating a File

Check that a .env file can be parsed without errors:

```shell
dotenv --validate --file .env.example
```

## Examples

### RSA Key Pair
Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub struct Cli {
#[arg(short = 'D', long, action = ArgAction::SetTrue, help = "Delete the environment variable from the .env file")]
pub delete: bool,

#[arg(short = 'V', long, action = ArgAction::SetTrue, help = "Validate that the .env file can be parsed without errors")]
pub validate: bool,

#[arg(long, action = ArgAction::SetTrue, help = "Exit 0 (with empty output) instead of 1 when a key is missing")]
pub allow_missing: bool,

Expand Down
10 changes: 9 additions & 1 deletion src/env_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub struct EnvValue {
pub value: String,
pub line_start: i64,
pub line_end: i64,
pub no_expand: bool,
}

impl EnvValue {
Expand All @@ -17,6 +18,7 @@ impl EnvValue {
value,
line_start: -1,
line_end: -1,
no_expand: false,
}
}

Expand All @@ -25,6 +27,7 @@ impl EnvValue {
value,
line_start,
line_end,
no_expand: false,
}
}
}
Expand Down Expand Up @@ -64,7 +67,12 @@ impl EnvObject {
pub fn resolve_nested_variables(&mut self) {
let keys: Vec<String> = self.entries.keys().cloned().collect();
for key in &keys {
let value = self.entries[key.as_str()].value.clone();
let entry = &self.entries[key.as_str()];
// Single-quoted values are literal — never expand them.
if entry.no_expand {
continue;
}
let value = entry.value.clone();
if !value.contains("${") {
continue;
}
Expand Down
49 changes: 43 additions & 6 deletions src/env_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
// Keep raw lines for line-number-based splice operations.
let raw_lines: Vec<&str> = content.split('\n').collect();
let mut env_object = EnvObject::new();
// Non-structural problems (bad spacing, duplicate keys) are collected rather
// than failing fast, so `--validate` can report every offending line at once.
let mut violations: Vec<(usize, String)> = Vec::new();
let mut i = 0;

while i < raw_lines.len() {
Expand All @@ -188,14 +191,31 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
}
};

let key = trimmed[..eq_pos].to_string();
let value_part = trimmed[eq_pos + 1..].trim();
let key_segment = &trimmed[..eq_pos];
let value_segment = &trimmed[eq_pos + 1..];
let key = key_segment.trim().to_string();
let value_part = value_segment.trim();

if key.is_empty() {
i += 1;
continue;
}

// Whitespace hugging the '=' (`KEY = value` or `KEY= value`) is not part
// of the key or value and is almost always a mistake.
if key_segment.ends_with(char::is_whitespace) || value_segment.starts_with(char::is_whitespace)
{
violations.push((
line_start + 1,
format!("whitespace around '=' is not allowed: {}", raw_lines[line_start]),
));
}

// A key defined more than once is ambiguous.
if env_object.get(&key).is_some() {
violations.push((line_start + 1, format!("duplicate key '{}'", key)));
}

if value_part.starts_with('"') || value_part.starts_with('\'') {
let quote = value_part.chars().next().unwrap();
let first_line = raw_lines[line_start];
Expand All @@ -207,10 +227,10 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
let (end, close_pos) = find_quote_close(&raw_lines, line_start, open_pos, quote)?;
let value =
extract_quoted_value(&raw_lines, line_start, end, open_pos, close_pos, quote)?;
env_object.set(
key,
EnvValue::with_lines(value, line_start as i64, end as i64),
);
let mut env_value = EnvValue::with_lines(value, line_start as i64, end as i64);
// Single quotes disable ${VAR} expansion (literal, POSIX-style).
env_value.no_expand = quote == '\'';
env_object.set(key, env_value);
i = end + 1;
} else if value_part.starts_with('[') {
let end = get_end_line(&raw_lines, line_start, "]");
Expand All @@ -237,6 +257,23 @@ pub fn parse_env_file(file_path: &str) -> Result<EnvObject, EnvParseError> {
}
}

if !violations.is_empty() {
let first_line = violations[0].0;
let message = if violations.len() == 1 {
violations[0].1.clone()
} else {
let list: String = violations
.iter()
.map(|(l, m)| format!("\n - line {}: {}", l, m))
.collect();
format!("found {} problems:{}", violations.len(), list)
};
return Err(EnvParseError {
line: first_line,
message,
});
}

env_object.resolve_nested_variables();
Ok(env_object)
}
68 changes: 45 additions & 23 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ fn main() {
if let Some(err) = e.downcast_ref::<RuleViolationError>() {
eprintln!("{}", err);
} else if let Some(err) = e.downcast_ref::<EnvParseError>() {
// Include "EnvParseError" in stderr so tests can detect it
eprintln!("An unexpected error occurred: EnvParseError: {}", err);
eprintln!("{}", err);
} else {
eprintln!("An unexpected error occurred: {}", e);
}
Expand All @@ -35,6 +34,30 @@ fn main() {
std::process::exit(exit_code);
}

fn resolve_env_path(file_arg: Option<String>) -> Result<String, Box<dyn std::error::Error>> {
let env_file = file_arg
.or_else(|| std::env::var("DOTENV_FILE").ok())
.unwrap_or_else(|| ".env".to_string());

let full_env_path: PathBuf = if Path::new(&env_file).is_absolute() {
PathBuf::from(&env_file)
} else {
std::env::current_dir()
.map_err(|e| Box::new(RuleViolationError(e.to_string())) as Box<dyn std::error::Error>)?
.join(&env_file)
};
let full_env_path_str = full_env_path.to_string_lossy().to_string();

if !full_env_path.exists() {
return Err(Box::new(RuleViolationError(format!(
"File not found: {}",
full_env_path_str
))));
}

Ok(full_env_path_str)
}

fn run() -> Result<i32, Box<dyn std::error::Error>> {
let cli = Cli::parse();

Expand All @@ -43,6 +66,25 @@ fn run() -> Result<i32, Box<dyn std::error::Error>> {
return Ok(0);
}

if cli.validate {
if !cli.key.is_empty() || cli.set.is_some() || cli.delete || cli.json || cli.no_json {
return Err(Box::new(RuleViolationError(
"Cannot use --validate with any other options".to_string(),
)));
}

let full_env_path_str = resolve_env_path(cli.file.clone())?;

if cli.debug {
eprintln!("File: {}", full_env_path_str);
}

parse_env_file(&full_env_path_str).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;

println!("{} is valid", full_env_path_str);
return Ok(0);
}

let debug = cli.debug;
let multiline = cli.multiline;
let delete = cli.delete;
Expand All @@ -56,33 +98,13 @@ fn run() -> Result<i32, Box<dyn std::error::Error>> {
};
let is_set = set_value_raw.is_some();

let env_file = cli
.file
.or_else(|| std::env::var("DOTENV_FILE").ok())
.unwrap_or_else(|| ".env".to_string());

// Resolve to absolute path (mirrors Node's path.resolve)
let full_env_path: PathBuf = if Path::new(&env_file).is_absolute() {
PathBuf::from(&env_file)
} else {
std::env::current_dir()
.map_err(|e| Box::new(RuleViolationError(e.to_string())) as Box<dyn std::error::Error>)?
.join(&env_file)
};
let full_env_path_str = full_env_path.to_string_lossy().to_string();
let full_env_path_str = resolve_env_path(cli.file.clone())?;

if debug {
eprintln!("Keys: {:?}", keys);
eprintln!("File: {}", full_env_path_str);
}

if !full_env_path.exists() {
return Err(Box::new(RuleViolationError(format!(
"File not found: {}",
full_env_path_str
))));
}

let mut options = Options {
full_env_path: full_env_path_str,
env_object: None,
Expand Down
128 changes: 127 additions & 1 deletion tests/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ fn bad_list_path() -> String {
.to_string()
}

fn space_path() -> String {
let here = Path::new(env!("CARGO_MANIFEST_DIR"));
here.join("tests/envFiles/space.env")
.to_string_lossy()
.to_string()
}

#[test]
fn missing_env_file() {
bin()
Expand Down Expand Up @@ -138,5 +145,124 @@ fn invalid_list_throws_error() {
.arg(bad_list_path())
.assert()
.failure()
.stderr(predicate::str::contains("EnvParseError"));
.stderr(predicate::str::contains("Error parsing .env file at line 1"));
}

#[test]
fn validate_valid_file_succeeds() {
bin()
.arg("--validate")
.arg("--file")
.arg(env_path())
.assert()
.success()
.stdout(predicate::str::contains("is valid"));
}

#[test]
fn validate_short_flag_succeeds() {
bin()
.arg("-V")
.arg("--file")
.arg(env_path())
.assert()
.success()
.stdout(predicate::str::contains("is valid"));
}

#[test]
fn validate_invalid_file_fails() {
bin()
.arg("--validate")
.arg("--file")
.arg(bad_list_path())
.assert()
.failure()
.stderr(predicate::str::contains("Error parsing .env file at line 1"));
}

#[test]
fn validate_reports_whitespace_and_duplicate_keys() {
bin()
.arg("--validate")
.arg("--file")
.arg(space_path())
.assert()
.failure()
.stderr(predicate::str::contains("found 3 problems"))
.stderr(predicate::str::contains(
"line 1: whitespace around '=' is not allowed",
))
.stderr(predicate::str::contains(
"line 2: whitespace around '=' is not allowed",
))
.stderr(predicate::str::contains("line 2: duplicate key 'SPACE'"));
}

#[test]
fn validate_missing_file_fails() {
bin()
.arg("--validate")
.arg("--file")
.arg("non-existent.env")
.assert()
.failure()
.stderr(predicate::str::contains("File not found"));
}

#[test]
fn validate_with_key_fails() {
bin()
.arg("--validate")
.arg("NAME")
.arg("--file")
.arg(env_path())
.assert()
.failure()
.stderr(predicate::str::contains(
"Cannot use --validate with any other options",
));
}

#[test]
fn validate_with_set_fails() {
bin()
.arg("--validate")
.arg("--set")
.arg("value")
.arg("--file")
.arg(env_path())
.assert()
.failure()
.stderr(predicate::str::contains(
"Cannot use --validate with any other options",
));
}

#[test]
fn validate_with_delete_fails() {
bin()
.arg("--validate")
.arg("--delete")
.arg("--file")
.arg(env_path())
.assert()
.failure()
.stderr(predicate::str::contains(
"Cannot use --validate with any other options",
));
}

#[test]
fn validate_with_json_fails() {
bin()
.arg("--validate")
.arg("--json")
.arg("--file")
.arg(env_path())
.assert()
.failure()
.stderr(predicate::str::contains(
"Cannot use --validate with any other options",
));
}
Loading
Loading