use std::collections::BTreeMap; use std::path::Path; use std::process::Command; use anyhow::{Context, Result, bail}; use std::fs; pub fn execute(cmd: &str, workdir: &Path, extra_env: &BTreeMap) -> Result<()> { let mut command = shell_command(cmd); command.current_dir(workdir); for (key, value) in load_dotenv(workdir)? { if std::env::var_os(&key).is_none() { command.env(key, value); } } for (key, value) in extra_env { command.env(key, value); } let status = command .status() .with_context(|| format!("failed to spawn '{cmd}' in {}", workdir.display()))?; if !status.success() { let code = match status.code() { Some(code) => format!("exit code {code}"), None => "terminated by signal".to_string(), }; bail!("{cmd} FAILED ({code})"); } Ok(()) } fn load_dotenv(workdir: &Path) -> Result> { let path = workdir.join(".env"); if !path.is_file() { return Ok(Vec::new()); } let raw = fs::read_to_string(&path).with_context(|| format!("cannot read {}", path.display()))?; let mut vars = Vec::new(); for (num, line) in raw.lines().enumerate() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); let Some((key, value)) = line.split_once('=') else { bail!(".env line {}: expected KEY=VALUE", num + 1); }; validate_var_name(key, num)?; vars.push((key.to_string(), unquote(value.trim()))); } Ok(vars) } fn validate_var_name(key: &str, line: usize) -> Result<()> { let valid = !key.is_empty() && !key.chars().next().unwrap().is_ascii_digit() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); if !valid { bail!(".env line {line}: invalid variable name '{key}'"); } Ok(()) } fn unquote(mut value: &str) -> String { for quote in ['"', '\''] { if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) { value = &value[1..value.len() - 1]; break; } } value.to_string() } fn shell_command(cmd: &str) -> Command { #[cfg(target_os = "windows")] { let mut command = Command::new("cmd"); command.arg("/C").arg(cmd); command } #[cfg(not(target_os = "windows"))] { let mut command = Command::new("sh"); command.arg("-c").arg(cmd); command } } #[cfg(test)] mod tests { use super::*; use std::fs; use std::path::PathBuf; fn tmpdir(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("xboct-deploy-local-{}-{tag}", std::process::id())); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir } fn ok_cmd() -> String { if cfg!(windows) { "exit /b 0".into() } else { "true".into() } } fn fail_cmd(code: u8) -> String { if cfg!(windows) { format!("exit /b {code}") } else { format!("exit {code}") } } fn marker_cmd(name: &str) -> String { if cfg!(windows) { format!("type nul > {name}") } else { format!("touch {name}") } } #[test] fn successful_commands_pass_silently() { execute(&ok_cmd(), Path::new("."), &BTreeMap::new()).unwrap(); execute(&ok_cmd(), Path::new("."), &BTreeMap::new()).unwrap(); } #[test] fn fail_fast_stops_on_first_error_with_exit_code() { let workdir = tmpdir("failfast"); let marker = workdir.join("first-ran.txt"); let cmds = [ marker_cmd("first-ran.txt"), fail_cmd(7), marker_cmd("never.txt"), ]; let err = cmds .iter() .map(|cmd| execute(cmd, &workdir, &BTreeMap::new())) .collect::>>() .unwrap_err(); assert!(err.to_string().contains("exit code 7"), "{err:#}"); assert!(marker.exists(), "first command did not run"); assert!( !workdir.join("never.txt").exists(), "fail-fast violated: commands ran after the failure" ); let _ = fs::remove_dir_all(workdir); } #[test] fn commands_run_in_workdir() { let workdir = tmpdir("workdir"); execute(&marker_cmd("in-workdir.txt"), &workdir, &BTreeMap::new()).unwrap(); assert!(workdir.join("in-workdir.txt").exists()); let _ = fs::remove_dir_all(workdir); } #[test] fn missing_workdir_is_contextual_error() { let missing = tmpdir("gone"); fs::remove_dir(&missing).unwrap(); let err = execute(&ok_cmd(), &missing, &BTreeMap::new()).unwrap_err(); assert!(err.to_string().contains("failed to spawn"), "{err:#}"); } #[test] fn dotenv_parses_comments_quotes_and_export() { let workdir = tmpdir("dotenv"); fs::write( workdir.join(".env"), "# comment\nDB_HOST=localhost\nexport API_KEY=\"abc=123\"\nSINGLE='v v'\n\n", ) .unwrap(); let vars = load_dotenv(&workdir).unwrap(); assert_eq!( vars, vec![ ("DB_HOST".to_string(), "localhost".to_string()), ("API_KEY".to_string(), "abc=123".to_string()), ("SINGLE".to_string(), "v v".to_string()), ] ); let _ = fs::remove_dir_all(workdir); } #[test] fn dotenv_invalid_line_is_clear_error() { let workdir = tmpdir("dotenv-bad"); fs::write(workdir.join(".env"), "GOOD=1\nNO_EQUALS_HERE\n").unwrap(); let err = load_dotenv(&workdir).unwrap_err(); assert!(err.to_string().contains("line 2"), "{err:#}"); let _ = fs::remove_dir_all(workdir); } #[test] fn dotenv_missing_file_is_empty() { let workdir = tmpdir("dotenv-none"); assert!(load_dotenv(&workdir).unwrap().is_empty()); let _ = fs::remove_dir_all(workdir); } #[test] fn env_vars_visible_inside_local_commands() { let workdir = tmpdir("dotenv-exec"); let echo_var = if cfg!(windows) { "echo %XBP_TEST_VAR%" } else { "echo $XBP_TEST_VAR" }; fs::write(workdir.join(".env"), "XBP_TEST_VAR=hello-env\n").unwrap(); let mut command = shell_command(echo_var); command.current_dir(&workdir); for (k, v) in load_dotenv(&workdir).unwrap() { if std::env::var_os(&k).is_none() { command.env(k, v); } } let out = command.output().unwrap(); assert!(String::from_utf8_lossy(&out.stdout).contains("hello-env")); let _ = fs::remove_dir_all(workdir); } #[test] fn config_env_overrides_dotenv() { let workdir = tmpdir("dotenv-precedence"); let echo_var = if cfg!(windows) { "echo %XBP_P_VAR%" } else { "echo $XBP_P_VAR" }; fs::write(workdir.join(".env"), "XBP_P_VAR=from-dotenv\n").unwrap(); let mut extra = BTreeMap::new(); extra.insert("XBP_P_VAR".to_string(), "from-config".to_string()); let mut command = shell_command(echo_var); command.current_dir(&workdir); for (k, v) in load_dotenv(&workdir).unwrap() { if std::env::var_os(&k).is_none() { command.env(k, v); } } for (k, v) in &extra { command.env(k, v); } let out = command.output().unwrap(); assert!(String::from_utf8_lossy(&out.stdout).contains("from-config")); assert!(!String::from_utf8_lossy(&out.stdout).contains("from-dotenv")); let _ = fs::remove_dir_all(workdir); } }