use std::env;
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(target_os = "windows")]
use std::process::Command;
use crate::error::ForgeError;
use crate::fsutil::atomic_write_file;
use crate::model::InstallConfig;
use crate::paths::managed_bin_dir;
use crate::ui::{self, StatusKind};
use crate::util::{home_dir, resolve_command, set_process_var};
mod cargo_config;
mod paths;
pub(crate) mod typed;
use crate::execution::environment::paths::{display_paths, paths_equal, unix_shell_profile};
pub(crate) fn apply_planned_environment(config: &InstallConfig) -> Result<(), ForgeError> {
prepare_detection_environment(config);
refresh_macos_process_environment();
apply_configured_environment(config)
}
pub(crate) fn prepare_detection_environment(config: &InstallConfig) {
prepend_process_path([managed_bin_dir()], true);
typed::apply_process_paths(&config.environment.mutations);
}
pub(crate) fn refresh_install_finish_environment(config: &InstallConfig) -> Result<(), ForgeError> {
ui::stdout_status(StatusKind::Info, "Refreshing the terminal environment.");
apply_configured_environment(config)?;
refresh_rust_process_environment();
refresh_node_process_environment();
if cfg!(unix) {
if let Ok(profile) = unix_shell_profile() {
ui::stdout_status(
StatusKind::Success,
&format!(
"Process environment refreshed ยท profile {}",
profile.path.display()
),
);
} else {
ui::stdout_status(StatusKind::Success, "Process environment refreshed.");
}
ui::stdout_status(
StatusKind::Hint,
"Open a new terminal if installed commands are not yet available.",
);
} else {
ui::stdout_status(StatusKind::Success, "Process environment refreshed.");
ui::stdout_status(
StatusKind::Hint,
"Open a new PowerShell or CMD window if installed commands are not yet available.",
);
}
Ok(())
}
fn refresh_macos_process_environment() {
if !cfg!(target_os = "macos") {
return;
}
let brew = resolve_command("brew");
let Some(bin) = brew.parent().filter(|_| brew.is_file()) else {
return;
};
let added = prepend_process_path([bin.to_path_buf()], false);
if !added.is_empty() {
ui::stdout_status(
StatusKind::Info,
&format!("Homebrew added to process PATH ยท {}", display_paths(&added)),
);
}
}
fn apply_configured_environment(config: &InstallConfig) -> Result<(), ForgeError> {
typed::apply(&config.environment.mutations)
}
#[cfg(target_os = "windows")]
fn persist_windows_user_variable(name: &str, value: &str) -> Result<(), ForgeError> {
let output = Command::new("reg.exe")
.args([
"add",
r"HKCU\Environment",
"/v",
name,
"/t",
"REG_SZ",
"/d",
value,
"/f",
])
.output()
.map_err(|error| {
ForgeError::Command(format!(
"failed to write the Windows user environment variable {name}: {error}"
))
})?;
if output.status.success() {
Ok(())
} else {
Err(ForgeError::Command(format!(
"failed to write the Windows user environment variable {name}: {}",
String::from_utf8_lossy(&output.stderr).trim()
)))
}
}
#[cfg(not(target_os = "windows"))]
fn persist_windows_user_variable(_: &str, _: &str) -> Result<(), ForgeError> {
Ok(())
}
pub(crate) fn refresh_rust_process_environment() {
let cargo_home = env::var_os("CARGO_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".cargo"));
let rustup_home = env::var_os("RUSTUP_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".rustup"));
set_process_var("CARGO_HOME", &cargo_home);
set_process_var("RUSTUP_HOME", &rustup_home);
let added = prepend_process_path([cargo_home.join("bin")], true);
if !added.is_empty() {
ui::stdout_status(
StatusKind::Info,
&format!("Rust environment refreshed ยท {}", display_paths(&added)),
);
}
}
pub(crate) fn refresh_node_process_environment() {
let mut candidates = Vec::new();
if cfg!(windows) {
push_env_path(&mut candidates, "NVM_HOME");
push_env_path(&mut candidates, "NVM_SYMLINK");
if let Some(path) = env::var_os("LOCALAPPDATA") {
candidates.push(PathBuf::from(path).join("nvm"));
}
if let Some(path) = env::var_os("APPDATA") {
candidates.push(PathBuf::from(path).join("nvm"));
}
if let Some(path) = env::var_os("ProgramFiles") {
candidates.push(PathBuf::from(path).join("nodejs"));
}
if let Some(path) = env::var_os("ProgramFiles(x86)") {
candidates.push(PathBuf::from(path).join("nodejs"));
}
} else {
candidates.push(home_dir().join(".local").join("bin"));
candidates.extend(discover_local_node_bins());
let nvm_dir = env::var_os("NVM_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".nvm"));
candidates.push(nvm_dir.join("current").join("bin"));
candidates.extend(discover_nvm_node_bins(&nvm_dir));
}
let added = prepend_process_path(candidates, false);
if !added.is_empty() {
ui::stdout_status(
StatusKind::Info,
&format!("Node.js environment refreshed ยท {}", display_paths(&added)),
);
}
}
pub(crate) fn refresh_python_environment(environment: &Path) {
let environment = typed::expand_path(&environment.display().to_string());
let bin = environment.join(if cfg!(windows) { "Scripts" } else { "bin" });
let added = prepend_process_path([bin], true);
if !added.is_empty() {
ui::stdout_status(
StatusKind::Info,
&format!(
"Python tool environment refreshed ยท {}",
display_paths(&added)
),
);
}
}
fn persist_windows_tool_user_path(bin: Option<PathBuf>) -> Result<(), ForgeError> {
#[cfg(target_os = "windows")]
{
let Some(bin) = bin else {
return Ok(());
};
let system_root = env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
let powershell = system_root
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe");
let bin = bin.display().to_string().replace('\'', "''");
let script = format!(
"$bin = '{bin}'; $current = [Environment]::GetEnvironmentVariable('Path', 'User'); if ([string]::IsNullOrWhiteSpace($current)) {{ $next = $bin }} elseif (((';' + $current + ';').IndexOf(';' + $bin + ';', [System.StringComparison]::OrdinalIgnoreCase)) -lt 0) {{ $next = $current.TrimEnd(';') + ';' + $bin }} else {{ $next = $current }}; [Environment]::SetEnvironmentVariable('Path', $next, 'User')"
);
let output = Command::new(&powershell)
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.output()
.map_err(|error| {
ForgeError::Command(format!("failed to write the Windows user PATH: {error}"))
})?;
if output.status.success() {
ui::stdout_status(
StatusKind::Success,
&format!("Windows user PATH updated ยท {bin}"),
);
Ok(())
} else {
Err(ForgeError::Command(format!(
"failed to write the Windows user PATH: {}",
String::from_utf8_lossy(&output.stderr).trim()
)))
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = bin;
Ok(())
}
}
fn push_env_path(target: &mut Vec<PathBuf>, name: &str) {
if let Some(value) = env::var_os(name).filter(|value| !value.is_empty()) {
target.push(PathBuf::from(value));
}
}
fn discover_local_node_bins() -> Vec<PathBuf> {
let opt_dir = home_dir().join(".local").join("opt");
let Ok(entries) = fs::read_dir(&opt_dir) else {
return Vec::new();
};
let mut bins = entries
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|file_type| file_type.is_dir())?;
let name = entry.file_name();
let name = name.to_string_lossy();
name.starts_with("node-").then(|| entry.path().join("bin"))
})
.collect::<Vec<_>>();
bins.sort();
bins.reverse();
bins
}
fn discover_nvm_node_bins(nvm_dir: &Path) -> Vec<PathBuf> {
let versions_dir = nvm_dir.join("versions").join("node");
let Ok(entries) = fs::read_dir(&versions_dir) else {
return Vec::new();
};
let mut bins = entries
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|file_type| file_type.is_dir())?;
Some(entry.path().join("bin"))
})
.collect::<Vec<_>>();
bins.sort();
bins.reverse();
bins
}
fn prepend_process_path(
paths: impl IntoIterator<Item = PathBuf>,
include_missing: bool,
) -> Vec<PathBuf> {
let mut added: Vec<PathBuf> = Vec::new();
let current_path = env::var_os("PATH").unwrap_or_default();
let mut existing = env::split_paths(¤t_path).collect::<Vec<_>>();
for path in paths {
if path.as_os_str().is_empty() || (!include_missing && !path.exists()) {
continue;
}
if existing.iter().any(|existing| paths_equal(existing, &path))
|| added.iter().any(|existing| paths_equal(existing, &path))
{
continue;
}
added.push(path);
}
if added.is_empty() {
return added;
}
let mut new_paths = added.clone();
new_paths.append(&mut existing);
if let Ok(joined) = env::join_paths(new_paths) {
set_process_var("PATH", joined);
added
} else {
Vec::new()
}
}
fn append_lines_if_missing(label: &str, path: &Path, lines: &[String]) -> Result<(), ForgeError> {
if lines.is_empty() {
return Ok(());
}
if cargo_config::is_cargo_config(path) {
return cargo_config::merge(label, path, lines);
}
let mut existing = fs::read_to_string(path).unwrap_or_default();
let missing = lines
.iter()
.filter(|line| !line.trim().is_empty() && !existing.contains(line.as_str()))
.cloned()
.collect::<Vec<_>>();
if missing.is_empty() {
ui::stdout_status(
StatusKind::Info,
&format!(
"{label} already contains bot-forge configuration ยท {}",
path.display()
),
);
return Ok(());
}
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str("\n# bot-forge environment\n");
existing.push_str(&missing.join("\n"));
existing.push('\n');
write_text(path, &existing)?;
ui::stdout_status(
StatusKind::Success,
&format!("{label} updated ยท {}", path.display()),
);
Ok(())
}
fn write_text(path: &Path, contents: &str) -> Result<(), ForgeError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| ForgeError::Io {
path: parent.to_path_buf(),
source,
})?;
}
atomic_write_file(path, contents.as_bytes())
}
#[cfg(test)]
mod tests {
use crate::execution::TEST_ENV_LOCK;
use crate::execution::environment::typed::{expand_path, mutation_applies};
#[cfg(unix)]
use crate::execution::environment::unix_shell_profile;
use crate::paths::app_home;
use std::{env, ffi::OsString, fs};
use crate::execution::environment::{
append_lines_if_missing, refresh_node_process_environment, refresh_python_environment,
refresh_rust_process_environment,
};
use crate::util::{remove_process_var, set_process_var};
#[test]
fn typed_mutation_platform_and_value_expansion_are_explicit() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
assert!(mutation_applies(&["*".into()]));
assert!(mutation_applies(&[format!("{}-*", std::env::consts::OS)]));
assert!(!mutation_applies(&["unsupported-os-*".into()]));
assert_eq!(expand_path("$BOT_FORGE_HOME/bin"), app_home().join("bin"));
}
#[cfg(unix)]
#[test]
fn selects_profiles_for_supported_macos_shells_and_rejects_unknown_shells() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let shell = env::var_os("SHELL");
let home = env::var_os("HOME");
set_process_var("HOME", "/user-home");
set_process_var("SHELL", "/bin/zsh");
let profile = unix_shell_profile().unwrap();
assert_eq!(profile.path, std::path::Path::new("/user-home/.zprofile"));
set_process_var("SHELL", "/bin/bash");
let profile = unix_shell_profile().unwrap();
assert_eq!(
profile.path,
std::path::Path::new(if cfg!(target_os = "macos") {
"/user-home/.bash_profile"
} else {
"/user-home/.bashrc"
})
);
set_process_var("SHELL", "/opt/homebrew/bin/fish");
let profile = unix_shell_profile().unwrap();
assert_eq!(
profile.path,
std::path::Path::new("/user-home/.config/fish/config.fish")
);
set_process_var("SHELL", "/opt/homebrew/bin/nu");
assert!(unix_shell_profile().is_err());
restore_env("SHELL", shell);
restore_env("HOME", home);
}
fn restore_env(name: &str, value: Option<OsString>) {
match value {
Some(value) => set_process_var(name, value),
None => remove_process_var(name),
}
}
#[test]
fn appends_missing_bashrc_lines() {
let temp =
std::env::temp_dir().join(format!("bot-forge-envfile-bashrc-{}", std::process::id()));
let path = temp.join(".bashrc");
fs::create_dir_all(&temp).unwrap();
fs::write(&path, "export DEMO=1\n").unwrap();
append_lines_if_missing("bashrc", &path, &[". \"$HOME/.cargo/env\"".to_string()]).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("export DEMO=1"));
assert!(contents.contains(". \"$HOME/.cargo/env\""));
append_lines_if_missing("bashrc", &path, &[". \"$HOME/.cargo/env\"".to_string()]).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(contents.matches(". \"$HOME/.cargo/env\"").count(), 1);
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn appends_missing_npmrc_lines() {
let temp =
std::env::temp_dir().join(format!("bot-forge-envfile-npmrc-{}", std::process::id()));
let path = temp.join(".npmrc");
fs::create_dir_all(&temp).unwrap();
fs::write(&path, "strict-ssl=false\n").unwrap();
append_lines_if_missing(
"npmrc",
&path,
&["registry=https://mirror.com/npm/".to_string()],
)
.unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("strict-ssl=false"));
assert!(contents.contains("registry=https://mirror.com/npm/"));
append_lines_if_missing(
"npmrc",
&path,
&["registry=https://mirror.com/npm/".to_string()],
)
.unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert_eq!(
contents.matches("registry=https://mirror.com/npm/").count(),
1
);
fs::remove_dir_all(temp).unwrap();
}
#[test]
fn refreshes_rust_environment_for_current_process() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let temp =
std::env::temp_dir().join(format!("bot-forge-envfile-rust-{}", std::process::id()));
let cargo_home = temp.join(".cargo");
let old_userprofile = env::var_os("USERPROFILE");
let old_home = env::var_os("HOME");
let old_cargo_home = env::var_os("CARGO_HOME");
let old_rustup_home = env::var_os("RUSTUP_HOME");
let old_path = env::var_os("PATH");
set_process_var("USERPROFILE", &temp);
set_process_var("HOME", &temp);
remove_process_var("CARGO_HOME");
remove_process_var("RUSTUP_HOME");
set_process_var("PATH", "");
refresh_rust_process_environment();
assert_eq!(env::var_os("CARGO_HOME").unwrap(), cargo_home.as_os_str());
assert_eq!(
env::var_os("RUSTUP_HOME").unwrap(),
temp.join(".rustup").as_os_str()
);
let paths = env::split_paths(&env::var_os("PATH").unwrap()).collect::<Vec<_>>();
assert!(paths.iter().any(|path| path == &cargo_home.join("bin")));
restore_env("USERPROFILE", old_userprofile);
restore_env("HOME", old_home);
restore_env("CARGO_HOME", old_cargo_home);
restore_env("RUSTUP_HOME", old_rustup_home);
restore_env("PATH", old_path);
let _ = fs::remove_dir_all(temp);
}
#[test]
fn refreshes_python_tool_environment_for_current_process() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let environment = std::env::temp_dir().join(format!(
"bot-forge-python-environment-{}",
std::process::id()
));
let expected = environment.join(if cfg!(windows) { "Scripts" } else { "bin" });
let old_path = env::var_os("PATH");
set_process_var("PATH", "");
refresh_python_environment(&environment);
let paths = env::split_paths(&env::var_os("PATH").unwrap()).collect::<Vec<_>>();
assert_eq!(paths.first(), Some(&expected));
restore_env("PATH", old_path);
}
#[test]
fn refreshes_node_environment_for_current_process() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let temp =
std::env::temp_dir().join(format!("bot-forge-envfile-node-{}", std::process::id()));
let expected_path = if cfg!(windows) {
temp.join("nvm")
} else {
temp.join(".nvm")
.join("versions")
.join("node")
.join("v20.17.0")
.join("bin")
};
fs::create_dir_all(&expected_path).unwrap();
let local_bin = temp.join(".local").join("bin");
let local_node_bin = temp
.join(".local")
.join("opt")
.join("node-v20.17.0-linux-x64")
.join("bin");
if !cfg!(windows) {
fs::create_dir_all(&local_bin).unwrap();
fs::create_dir_all(&local_node_bin).unwrap();
}
let old_userprofile = env::var_os("USERPROFILE");
let old_home = env::var_os("HOME");
let old_nvm_dir = env::var_os("NVM_DIR");
let old_nvm_home = env::var_os("NVM_HOME");
let old_path = env::var_os("PATH");
set_process_var("USERPROFILE", &temp);
set_process_var("HOME", &temp);
if cfg!(windows) {
set_process_var("NVM_HOME", &expected_path);
} else {
remove_process_var("NVM_DIR");
}
set_process_var("PATH", "");
refresh_node_process_environment();
let paths = env::split_paths(&env::var_os("PATH").unwrap()).collect::<Vec<_>>();
assert!(paths.iter().any(|path| path == &expected_path));
if !cfg!(windows) {
assert!(paths.iter().any(|path| path == &local_bin));
assert!(paths.iter().any(|path| path == &local_node_bin));
}
restore_env("USERPROFILE", old_userprofile);
restore_env("HOME", old_home);
restore_env("NVM_DIR", old_nvm_dir);
restore_env("NVM_HOME", old_nvm_home);
restore_env("PATH", old_path);
let _ = fs::remove_dir_all(temp);
}
}