Skip to content
Open
36 changes: 36 additions & 0 deletions doc/examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Bacon Configuration Examples

This directory contains example configuration files demonstrating various features of Bacon.

## Environment Variable Examples

### `example.env`
A sample environment file showing the format supported by the `env_file` configuration option:
- Comments (lines starting with `#`)
- KEY=VALUE pairs
- Quoted values (both single and double quotes)
- Empty values

### `example-bacon.toml`
An example `bacon.toml` configuration file demonstrating:
- Global `env_file` usage
- Job-specific `env_file` configuration
- How direct `env` variables override `env_file` variables

### Test Files

#### `test.env`
A test environment file used for testing the `env_file` feature.

#### `test-bacon.toml`
A test configuration file for validating the `env_file` functionality.

## Usage

To use these examples in your project:

1. Copy the relevant files to your project root
2. Modify the values to match your project's needs
3. Update the `env_file` paths in your `bacon.toml` if needed

For more information about Bacon configuration, see the [official documentation](https://dystroy.org/bacon/config/).
21 changes: 21 additions & 0 deletions doc/examples/example-bacon.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Example bacon.toml configuration demonstrating the env_file feature

default_job = "check"

# Global environment variables (these have higher priority than env_file)
env.CARGO_TERM_COLOR = "always"

# Load additional environment variables from a file
env_file = "./example.env"

[jobs.check]
command = ["cargo", "check"]
need_stdout = false

[jobs.test-with-env]
command = ["cargo", "test"]
need_stdout = true
# Job-specific env file (if you want different env for different jobs)
env_file = "./test.env"
# Direct env vars have the highest priority
env.TEST_MODE = "true"
20 changes: 20 additions & 0 deletions doc/examples/example.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Example .env file for Bacon
# This file demonstrates the env_file feature

# Cargo configuration
CARGO_TERM_COLOR=always
RUST_BACKTRACE=1

# Build environment
BUILD_ENV=development
DEBUG_MODE=true

# Custom variables with quotes
CUSTOM_MESSAGE="Hello, Bacon!"
ANOTHER_VAR='Single quoted value'

# Empty variable
EMPTY_VAR=

# Variable without quotes
SIMPLE_VAR=simple_value
7 changes: 7 additions & 0 deletions doc/examples/test-bacon.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Test bacon.toml to verify env_file feature
env_file = "./test.env"
env.DIRECT_VAR = "direct"
env.CARGO_TERM_COLOR = "always" # This should override the one from test.env

[jobs.check]
command = ["cargo", "check"]
5 changes: 5 additions & 0 deletions doc/examples/test.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Test environment file for bacon env_file feature
TEST_VAR_1=value1
TEST_VAR_2="quoted value"
TEST_VAR_3='single quotes'
CARGO_TERM_COLOR=never
159 changes: 159 additions & 0 deletions src/jobs/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use {
serde::Deserialize,
std::{
collections::HashMap,
fs,
path::PathBuf,
},
};
Expand Down Expand Up @@ -51,6 +52,10 @@ pub struct Job {
#[serde(default)]
pub env: HashMap<String, String>,

/// Path to a file containing environment variables to load
/// The file should contain KEY=VALUE pairs, one per line
pub env_file: Option<PathBuf>,

/// Whether to expand environment variables in the command
pub expand_env_vars: Option<bool>,

Expand Down Expand Up @@ -117,6 +122,64 @@ pub struct Job {
static DEFAULT_ARGS: &[&str] = &["--color", "always"];

impl Job {
/// Load environment variables from a file
/// Expected format: KEY=VALUE, one per line
/// Lines starting with # are treated as comments and ignored
/// Empty lines are ignored
/// If env_file is relative, it's resolved relative to base_dir (usually package directory)
pub fn load_env_from_file(env_file: &PathBuf, base_dir: Option<&PathBuf>) -> Result<HashMap<String, String>, std::io::Error> {
let resolved_path = Self::filepath_of(env_file, base_dir);

let content = fs::read_to_string(&resolved_path)?;
let mut env_vars = HashMap::new();

for line in content.lines() {
let line = line.trim();

if line.is_empty() || line.starts_with('#') {
continue;
}

Self::parse_key_value_pairs(&mut env_vars, line);
}

Ok(env_vars)
}

fn filepath_of(env_file: &PathBuf, base_dir: Option<&PathBuf>) -> PathBuf {
if env_file.is_absolute() {
env_file.clone()
} else if let Some(base) = base_dir {
base.join(env_file)
} else {
env_file.clone()
}
}

fn parse_key_value_pairs(env_vars: &mut HashMap<String, String>, line: &str) {
if let Some(eq_pos) = line.find('=') {
let key = line[..eq_pos].trim().to_string();
let value = line[eq_pos + 1..].trim().to_string();

let value = if Self::has_surrounding_quotes(&value) {
Self::remove_surrounding_quotes(&value)
} else {
value
};

env_vars.insert(key, value);
}
}

fn remove_surrounding_quotes(value: &str) -> String {
value[1..value.len() - 1].to_string()
}

fn has_surrounding_quotes(value: &str) -> bool {
(value.starts_with('"') && value.ends_with('"')) ||
(value.starts_with('\'') && value.ends_with('\''))
}

/// Build a `Job` for a cargo alias
pub fn from_alias(
alias_name: &str,
Expand Down Expand Up @@ -197,6 +260,17 @@ impl Job {
for (k, v) in &job.env {
self.env.insert(k.clone(), v.clone());
}
if let Some(env_file) = job.env_file.as_ref() {
if let Ok(file_env_vars) = Self::load_env_from_file(env_file, None) {
for (key, value) in file_env_vars {
// env_file variables have lower priority than direct env vars
self.env.entry(key).or_insert(value);
}
}
}
if let Some(env_file) = job.env_file.as_ref() {
self.env_file = Some(env_file.clone());
}
if let Some(b) = job.expand_env_vars {
self.expand_env_vars = Some(b);
}
Expand Down Expand Up @@ -258,6 +332,7 @@ fn test_job_apply() {
env: vec![("RUST_LOG".to_string(), "debug".to_string())]
.into_iter()
.collect(),
env_file: Some(PathBuf::from("/path/to/.env")),
expand_env_vars: Some(false),
extraneous_args: Some(false),
ignore: vec!["special-target".to_string(), "generated".to_string()],
Expand All @@ -281,3 +356,87 @@ fn test_job_apply() {
dbg!(&base_job);
assert_eq!(&base_job, &job_to_apply);
}

#[test]
fn test_load_env_from_file() {
use std::fs;

let temp_dir = std::env::temp_dir();
let env_file_path = temp_dir.join("test_env_file");

let env_content = r#"
# This is a comment
VARIABLE1=value1
VARIABLE2="quoted value"
VARIABLE3='single quoted'

# Another comment
VARIABLE4=unquoted value
EMPTY_VARIABLE=
"#;

fs::write(&env_file_path, env_content).unwrap();

let env_vars = Job::load_env_from_file(&env_file_path, None).unwrap();

assert_eq!(env_vars.get("VARIABLE1"), Some(&"value1".to_string()));
assert_eq!(env_vars.get("VARIABLE2"), Some(&"quoted value".to_string()));
assert_eq!(env_vars.get("VARIABLE3"), Some(&"single quoted".to_string()));
assert_eq!(env_vars.get("VARIABLE4"), Some(&"unquoted value".to_string()));
assert_eq!(env_vars.get("EMPTY_VARIABLE"), Some(&"".to_string()));
assert!(!env_vars.contains_key("NONEXISTENT"));

fs::remove_file(&env_file_path).unwrap();
}

#[test]
fn test_env_file_integration() {
use std::fs;

let temp_dir = std::env::temp_dir();
let test_dir = temp_dir.join("bacon_env_test");
fs::create_dir_all(&test_dir).unwrap();

let env_file_path = test_dir.join(".env");
let env_content = r#"
# Test env file
FILE_VAR_1=from_file
FILE_VAR_2="quoted from file"
CARGO_TERM_COLOR=never
RUST_BACKTRACE=0
"#;
fs::write(&env_file_path, env_content).unwrap();

// Test loading environment variables from file
let loaded_vars = Job::load_env_from_file(&env_file_path, None).unwrap();

assert_eq!(loaded_vars.get("FILE_VAR_1"), Some(&"from_file".to_string()));
assert_eq!(loaded_vars.get("FILE_VAR_2"), Some(&"quoted from file".to_string()));
assert_eq!(loaded_vars.get("CARGO_TERM_COLOR"), Some(&"never".to_string()));
assert_eq!(loaded_vars.get("RUST_BACKTRACE"), Some(&"0".to_string()));

// Test with relative path
let relative_env_file = PathBuf::from(".env");
let loaded_vars_relative = Job::load_env_from_file(&relative_env_file, Some(&test_dir)).unwrap();
assert_eq!(loaded_vars, loaded_vars_relative);

// Test job with env_file
let mut job = Job {
env_file: Some(relative_env_file),
env: vec![
("DIRECT_VAR".to_string(), "direct_value".to_string()),
("CARGO_TERM_COLOR".to_string(), "always".to_string()), // Should override file
].into_iter().collect(),
..Default::default()
};

// Apply the job to itself to trigger env_file processing
let job_copy = job.clone();
job.apply(&job_copy);

// Verify that direct env vars override env_file vars
assert_eq!(job.env.get("DIRECT_VAR"), Some(&"direct_value".to_string()));
assert_eq!(job.env.get("CARGO_TERM_COLOR"), Some(&"always".to_string())); // Should be overridden

fs::remove_dir_all(&test_dir).unwrap();
}
47 changes: 42 additions & 5 deletions src/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,46 @@ pub struct Mission<'s> {
}

impl Mission<'_> {
/// Load environment variables from the job's env_file if specified
/// Returns a HashMap of all environment variables (direct env + env_file)
fn collect_env_vars(&self) -> HashMap<String, String> {
let mut all_env_vars = HashMap::new();

// Start with global env vars from settings
for (k, v) in &self.settings.all_jobs.env {
all_env_vars.insert(k.clone(), v.clone());
}

// Load env vars from global env_file if specified (lowest priority)
if let Some(env_file) = &self.settings.all_jobs.env_file {
if let Ok(file_env_vars) = Job::load_env_from_file(env_file, Some(&self.package_directory)) {
for (k, v) in file_env_vars {
all_env_vars.entry(k).or_insert(v);
}
} else {
warn!("Failed to load global environment file: {env_file:?}");
}
}

// Load env vars from job-specific env_file if specified
if let Some(env_file) = &self.job.env_file {
if let Ok(file_env_vars) = Job::load_env_from_file(env_file, Some(&self.package_directory)) {
for (k, v) in file_env_vars {
all_env_vars.entry(k).or_insert(v);
}
} else {
warn!("Failed to load job environment file: {env_file:?}");
}
}

// Direct env vars from job have the highest priority
for (k, v) in &self.job.env {
all_env_vars.insert(k.clone(), v.clone());
}

all_env_vars
}

/// Return an Ignorer according to the job's settings
pub fn ignorer(&self) -> IgnorerSet {
let mut set = IgnorerSet::default();
Expand Down Expand Up @@ -124,12 +164,9 @@ impl Mission<'_> {
tokens.next().unwrap(), // implies a check in the job
);
command.with_stdout(self.job.need_stdout());
let envs: HashMap<&String, &String> = self
.settings
.all_jobs
.env
let all_env_vars = self.collect_env_vars();
let envs: HashMap<&String, &String> = all_env_vars
.iter()
.chain(self.job.env.iter())
.collect();
if !self.job.extraneous_args() {
command.args(tokens);
Expand Down
Loading