diff --git a/doc/examples/README.md b/doc/examples/README.md new file mode 100644 index 00000000..6f356a46 --- /dev/null +++ b/doc/examples/README.md @@ -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/). diff --git a/doc/examples/example-bacon.toml b/doc/examples/example-bacon.toml new file mode 100644 index 00000000..fa872a43 --- /dev/null +++ b/doc/examples/example-bacon.toml @@ -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" diff --git a/doc/examples/example.env b/doc/examples/example.env new file mode 100644 index 00000000..22973b6f --- /dev/null +++ b/doc/examples/example.env @@ -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 diff --git a/doc/examples/test-bacon.toml b/doc/examples/test-bacon.toml new file mode 100644 index 00000000..08e875d7 --- /dev/null +++ b/doc/examples/test-bacon.toml @@ -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"] diff --git a/doc/examples/test.env b/doc/examples/test.env new file mode 100644 index 00000000..e3cb0fba --- /dev/null +++ b/doc/examples/test.env @@ -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 diff --git a/src/jobs/job.rs b/src/jobs/job.rs index 4eaa9bf8..ccc65e5b 100644 --- a/src/jobs/job.rs +++ b/src/jobs/job.rs @@ -3,6 +3,7 @@ use { serde::Deserialize, std::{ collections::HashMap, + fs, path::PathBuf, }, }; @@ -51,6 +52,10 @@ pub struct Job { #[serde(default)] pub env: HashMap, + /// Path to a file containing environment variables to load + /// The file should contain KEY=VALUE pairs, one per line + pub env_file: Option, + /// Whether to expand environment variables in the command pub expand_env_vars: Option, @@ -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, 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, 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, @@ -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); } @@ -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()], @@ -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(); +} diff --git a/src/mission.rs b/src/mission.rs index c4985fa3..2e90f617 100644 --- a/src/mission.rs +++ b/src/mission.rs @@ -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 { + 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(); @@ -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); diff --git a/website/docs/config.md b/website/docs/config.md index 8cbde0ad..9b24e6df 100644 --- a/website/docs/config.md +++ b/website/docs/config.md @@ -1,4 +1,3 @@ - # Configuration Files All configuration files are optional but you'll probably need specific jobs for your targets, examples, etc. @@ -84,6 +83,7 @@ background | compute in background and display only on end | `true` command | the tokens making the command to execute (first one is the executable) | default_watch | whether to watch default files (`src`, `tests`, `examples`, `build.rs`, and `benches`). When it's set to `false`, only the files in your `watch` parameter are watched | `true` env | a map of environment vars, for example `env.LOG_LEVEL="die"` | +env_file | path to a file containing environment variables in KEY=VALUE format. Relative paths are resolved from the package directory. Variables from env_file have lower priority than direct env vars | kill | a command replacing the default job interruption (platform dependant, `SIGKILL` on unix). For example `kill = ["kill", "-s", "INT"]` | ignore | list of glob patterns for files to ignore | ignored_lines | regular expressions for lines to ignore | @@ -110,6 +110,73 @@ need_stdout = true Note: Some tools detect that their output is piped and don't add style information unless you add a parameter which usually looks like `--color always`. This isn't normally necessary for cargo because bacon, by default, sets the `CARGO_TERM_COLOR` environment variable. +## Environment Variables + +Bacon supports setting environment variables for job execution in two ways: + +### Direct Environment Variables + +You can set environment variables directly in your `bacon.toml` file using the `env` property: + +```TOML +# Global environment variables for all jobs +env.CARGO_TERM_COLOR = "always" +env.RUST_BACKTRACE = "1" + +[jobs.test] +command = ["cargo", "test"] +# Job-specific environment variables +env.TEST_MODE = "true" +env.LOG_LEVEL = "debug" +``` + +### Environment Files + +You can load environment variables from a file using the `env_file` property: + +```TOML +# Load environment variables from a file +env_file = "./.env" + +[jobs.test] +command = ["cargo", "test"] +# Job-specific environment file +env_file = "./test.env" +``` + +The environment file should contain KEY=VALUE pairs, one per line: + +```bash +# .env file example +CARGO_TERM_COLOR=always +RUST_BACKTRACE=1 +BUILD_ENV=development +CUSTOM_MESSAGE="Hello, Bacon!" +ANOTHER_VAR='Single quoted value' + +# Comments and empty lines are ignored +SIMPLE_VAR=simple_value +``` + +#### Priority Order + +Environment variables are applied in the following priority order (highest to lowest): + +1. Direct `env` variables in job configuration +2. Direct `env` variables in global configuration +3. Variables from `env_file` in job configuration +4. Variables from `env_file` in global configuration + +This means that direct `env` variables will always override variables loaded from `env_file`. + +#### Path Resolution + +Environment file paths are resolved relative to the package directory (where your `Cargo.toml` is located). Absolute paths are used as-is. + +#### Examples + +For complete examples of `env_file` usage, see the [example configurations](https://github.com/Canop/bacon/tree/main/doc/examples) in the repository. + ## Analyzers The output of the standard cargo tools is understood by bacon's standard analyzer.