259 lines
7.8 KiB
Rust
259 lines
7.8 KiB
Rust
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<String, String>) -> 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<Vec<(String, String)>> {
|
|
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::<Result<Vec<_>>>()
|
|
.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);
|
|
}
|
|
}
|