diff --git a/README.md b/README.md index 34ac0483aa..01100087ad 100644 --- a/README.md +++ b/README.md @@ -558,6 +558,7 @@ System-level environment variables (usually set automatically): ```bash # .env +FORGE_CONFIG=/custom/config/dir # Base directory for all Forge config files (default: ~/forge) FORGE_MAX_SEARCH_RESULT_BYTES=10240 # Maximum bytes for search results (default: 10240 - 10 KB) FORGE_HISTORY_FILE=/path/to/history # Custom path for Forge history file (default: uses system default location) FORGE_BANNER="Your custom banner text" # Custom banner text to display on startup (default: Forge ASCII art) diff --git a/crates/forge_app/src/orch_spec/orch_setup.rs b/crates/forge_app/src/orch_spec/orch_setup.rs index 619feebe99..ce411dd1f4 100644 --- a/crates/forge_app/src/orch_spec/orch_setup.rs +++ b/crates/forge_app/src/orch_spec/orch_setup.rs @@ -56,7 +56,6 @@ impl Default for TestContext { attachments: Default::default(), env: Environment { os: "MacOS".to_string(), - pid: 1234, cwd: PathBuf::from("/Users/tushar"), home: Some(PathBuf::from("/Users/tushar")), shell: "bash".to_string(), diff --git a/crates/forge_config/src/reader.rs b/crates/forge_config/src/reader.rs index ea74746de6..54a09375c7 100644 --- a/crates/forge_config/src/reader.rs +++ b/crates/forge_config/src/reader.rs @@ -49,8 +49,14 @@ impl ConfigReader { Self::base_path().join(".forge.toml") } - /// Returns the base directory for all Forge config files (`~/forge`). + /// Returns the base directory for all Forge config files. + /// + /// If the `FORGE_CONFIG` environment variable is set, its value is used + /// directly as the base path. Otherwise defaults to `~/forge`. pub fn base_path() -> PathBuf { + if let Ok(path) = std::env::var("FORGE_CONFIG") { + return PathBuf::from(path); + } dirs::home_dir().unwrap_or(PathBuf::from(".")).join("forge") } @@ -164,6 +170,21 @@ mod tests { } } + #[test] + fn test_base_path_uses_forge_config_env_var() { + let _guard = EnvGuard::set(&[("FORGE_CONFIG", "/custom/forge/dir")]); + let actual = ConfigReader::base_path(); + let expected = PathBuf::from("/custom/forge/dir"); + assert_eq!(actual, expected); + } + + #[test] + fn test_base_path_falls_back_to_home_dir_when_env_var_absent() { + let actual = ConfigReader::base_path(); + // Without FORGE_CONFIG set the path must end with "forge" + assert_eq!(actual.file_name().unwrap(), "forge"); + } + #[test] fn test_read_parses_without_error() { let actual = ConfigReader::default().read_defaults().build(); diff --git a/crates/forge_domain/src/env.rs b/crates/forge_domain/src/env.rs index fac789da27..3119ebb6bb 100644 --- a/crates/forge_domain/src/env.rs +++ b/crates/forge_domain/src/env.rs @@ -47,8 +47,8 @@ const VERSION: &str = match option_env!("APP_VERSION") { /// Represents the minimal runtime environment in which the application is /// running. /// -/// Contains only the six fields that cannot be sourced from [`ForgeConfig`]: -/// `os`, `pid`, `cwd`, `home`, `shell`, and `base_path`. All configuration +/// Contains only the five fields that cannot be sourced from [`ForgeConfig`]: +/// `os`, `cwd`, `home`, `shell`, and `base_path`. All configuration /// values previously carried here are now accessed through /// `EnvironmentInfra::get_config()`. #[derive(Debug, Setters, Clone, PartialEq, Serialize, Deserialize, fake::Dummy)] @@ -57,8 +57,6 @@ const VERSION: &str = match option_env!("APP_VERSION") { pub struct Environment { /// The operating system of the environment. pub os: String, - /// The process ID of the current process. - pub pid: u32, /// The current working directory. pub cwd: PathBuf, /// The home directory. diff --git a/crates/forge_infra/src/env.rs b/crates/forge_infra/src/env.rs index 18b578093c..cffa3f5081 100644 --- a/crates/forge_infra/src/env.rs +++ b/crates/forge_infra/src/env.rs @@ -9,14 +9,12 @@ use tracing::debug; /// Builds a [`forge_domain::Environment`] from runtime context only. /// -/// Only the six fields that cannot be sourced from [`ForgeConfig`] are set -/// here: `os`, `pid`, `cwd`, `home`, `shell`, and `base_path`. All -/// configuration values are now accessed through -/// `EnvironmentInfra::get_config()`. +/// Only the five fields that cannot be sourced from [`ForgeConfig`] are set +/// here: `os`, `cwd`, `home`, `shell`, and `base_path`. All configuration +/// values are now accessed through `EnvironmentInfra::get_config()`. pub fn to_environment(cwd: PathBuf) -> Environment { Environment { os: std::env::consts::OS.to_string(), - pid: std::process::id(), cwd, home: dirs::home_dir(), shell: if cfg!(target_os = "windows") { @@ -24,9 +22,7 @@ pub fn to_environment(cwd: PathBuf) -> Environment { } else { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) }, - base_path: dirs::home_dir() - .map(|h| h.join("forge")) - .unwrap_or_else(|| PathBuf::from(".").join("forge")), + base_path: ConfigReader::base_path(), } } @@ -172,12 +168,43 @@ impl EnvironmentInfra for ForgeEnvironmentInfra { #[cfg(test)] mod tests { use std::path::PathBuf; + use std::sync::{Mutex, MutexGuard}; use forge_config::ForgeConfig; use pretty_assertions::assert_eq; use super::*; + /// Serializes tests that mutate environment variables to prevent races. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + /// Holds env vars set for a test's duration and removes them on drop, + /// while holding [`ENV_MUTEX`]. + struct EnvGuard { + keys: Vec<&'static str>, + _lock: MutexGuard<'static, ()>, + } + + impl EnvGuard { + #[must_use] + fn set(pairs: &[(&'static str, &str)]) -> Self { + let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let keys = pairs.iter().map(|(k, _)| *k).collect(); + for (key, value) in pairs { + unsafe { std::env::set_var(key, value) }; + } + Self { keys, _lock: lock } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.keys { + unsafe { std::env::remove_var(key) }; + } + } + } + #[test] fn test_to_environment_sets_cwd() { let fixture_cwd = PathBuf::from("/test/cwd"); @@ -185,6 +212,21 @@ mod tests { assert_eq!(actual.cwd, fixture_cwd); } + #[test] + fn test_to_environment_uses_forge_config_env_var() { + let _guard = EnvGuard::set(&[("FORGE_CONFIG", "/custom/config/dir")]); + let actual = to_environment(PathBuf::from("/any/cwd")); + let expected = PathBuf::from("/custom/config/dir"); + assert_eq!(actual.base_path, expected); + } + + #[test] + fn test_to_environment_falls_back_to_home_dir_when_env_var_absent() { + let actual = to_environment(PathBuf::from("/any/cwd")); + // Without FORGE_CONFIG the base_path must end with "forge" + assert_eq!(actual.base_path.file_name().unwrap(), "forge"); + } + #[test] fn test_apply_config_op_set_provider() { use forge_domain::ProviderId; diff --git a/crates/forge_services/src/app_config.rs b/crates/forge_services/src/app_config.rs index 8dbbe6ca85..eaefedd83f 100644 --- a/crates/forge_services/src/app_config.rs +++ b/crates/forge_services/src/app_config.rs @@ -267,7 +267,6 @@ mod tests { fn get_environment(&self) -> Environment { Environment { os: "test".to_string(), - pid: 0, cwd: PathBuf::new(), home: None, shell: "bash".to_string(),