diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e4eb21..9ec7467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ### Changed +- **Internal (djls-project)**: Added `system` module to improve reliability of environment discovery tests. - **Internal**: Moved task queueing functionality to `djls-server` crate, renamed from `Worker` to `Queue`, and simplified API. - **Internal**: Improved Python environment handling, including refactored activation logic. - **Internal**: Centralized Python linking build logic into a shared `djls-dev` crate to reduce duplication. diff --git a/crates/djls-project/src/lib.rs b/crates/djls-project/src/lib.rs index 21d8c29..cad0a3b 100644 --- a/crates/djls-project/src/lib.rs +++ b/crates/djls-project/src/lib.rs @@ -1,12 +1,11 @@ +mod system; mod templatetags; pub use templatetags::TemplateTags; use pyo3::prelude::*; -use std::env; use std::fmt; use std::path::{Path, PathBuf}; -use which::which; #[derive(Debug)] pub struct DjangoProject { @@ -92,19 +91,16 @@ impl PythonEnvironment { fn new(project_path: &Path, venv_path: Option<&str>) -> Option { if let Some(path) = venv_path { let prefix = PathBuf::from(path); - // If an explicit path is provided and it's a valid venv, use it immediately. if let Some(env) = Self::from_venv_prefix(&prefix) { return Some(env); } - // Explicit path was provided but was invalid. Continue searching. + // Invalid explicit path, continue searching... } - if let Ok(virtual_env) = env::var("VIRTUAL_ENV") { - if !virtual_env.is_empty() { - let prefix = PathBuf::from(virtual_env); - if let Some(env) = Self::from_venv_prefix(&prefix) { - return Some(env); - } + if let Ok(virtual_env) = system::env_var("VIRTUAL_ENV") { + let prefix = PathBuf::from(virtual_env); + if let Some(env) = Self::from_venv_prefix(&prefix) { + return Some(env); } } @@ -126,7 +122,6 @@ impl PythonEnvironment { #[cfg(windows)] let python_path = prefix.join("Scripts").join("python.exe"); - // Check if the *prefix* and the *binary* exist. if !prefix.is_dir() || !python_path.exists() { return None; } @@ -137,10 +132,9 @@ impl PythonEnvironment { let bin_dir = prefix.join("Scripts"); let mut sys_path = Vec::new(); - sys_path.push(bin_dir); // Add bin/ or Scripts/ + sys_path.push(bin_dir); if let Some(site_packages) = Self::find_site_packages(prefix) { - // Check existence inside the if let, as find_site_packages might return a path that doesn't exist if site_packages.is_dir() { sys_path.push(site_packages); } @@ -167,12 +161,10 @@ impl PythonEnvironment { } fn from_system_python() -> Option { - let python_path = match which("python") { + let python_path = match system::find_executable("python") { Ok(p) => p, Err(_) => return None, }; - // which() might return a path inside a bin/Scripts dir, or directly the executable - // We need the prefix, which is usually two levels up from the executable in standard layouts let bin_dir = python_path.parent()?; let prefix = bin_dir.parent()?; @@ -194,7 +186,6 @@ impl PythonEnvironment { #[cfg(unix)] fn find_site_packages(prefix: &Path) -> Option { - // Look for lib/pythonX.Y/site-packages let lib_dir = prefix.join("lib"); if !lib_dir.is_dir() { return None; @@ -203,8 +194,8 @@ impl PythonEnvironment { .ok()? .filter_map(Result::ok) .find(|e| { - e.file_type().is_ok_and(|ft| ft.is_dir()) && // Ensure it's a directory - e.file_name().to_string_lossy().starts_with("python") + e.file_type().is_ok_and(|ft| ft.is_dir()) + && e.file_name().to_string_lossy().starts_with("python") }) .map(|e| e.path().join("site-packages")) } @@ -231,10 +222,14 @@ impl fmt::Display for PythonEnvironment { mod tests { use super::*; use std::fs; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; use tempfile::tempdir; mod env_discovery { + use super::system::mock::{self as sys_mock, MockGuard}; use super::*; + use which::Error as WhichError; fn create_mock_venv(dir: &Path, version: Option<&str>) -> PathBuf { let prefix = dir.to_path_buf(); @@ -243,7 +238,7 @@ mod tests { { let bin_dir = prefix.join("bin"); fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python"), "").unwrap(); // Create dummy executable + fs::write(bin_dir.join("python"), "").unwrap(); let lib_dir = prefix.join("lib"); fs::create_dir_all(&lib_dir).unwrap(); let py_version_dir = lib_dir.join(version.unwrap_or("python3.9")); @@ -254,7 +249,7 @@ mod tests { { let bin_dir = prefix.join("Scripts"); fs::create_dir_all(&bin_dir).unwrap(); - fs::write(bin_dir.join("python.exe"), "").unwrap(); // Create dummy executable + fs::write(bin_dir.join("python.exe"), "").unwrap(); let lib_dir = prefix.join("Lib"); fs::create_dir_all(&lib_dir).unwrap(); fs::create_dir_all(lib_dir.join("site-packages")).unwrap(); @@ -263,32 +258,6 @@ mod tests { prefix } - struct VirtualEnvGuard<'a> { - key: &'a str, - original_value: Option, - } - - impl<'a> VirtualEnvGuard<'a> { - fn set(key: &'a str, value: &str) -> Self { - let original_value = env::var(key).ok(); - env::set_var(key, value); - Self { - key, - original_value, - } - } - } - - impl Drop for VirtualEnvGuard<'_> { - fn drop(&mut self) { - if let Some(ref val) = self.original_value { - env::set_var(self.key, val); - } else { - env::remove_var(self.key); - } - } - } - #[test] fn test_explicit_venv_path_found() { let project_dir = tempdir().unwrap(); @@ -324,11 +293,9 @@ mod tests { let project_dir = tempdir().unwrap(); let project_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - // Set VIRTUAL_ENV to something known to be invalid, rather than clearing. - // This prevents the test runner's VIRTUAL_ENV (e.g., from Nox) from interfering. - let invalid_virtual_env_path = project_dir.path().join("non_existent_virtual_env"); - let _guard = - VirtualEnvGuard::set("VIRTUAL_ENV", invalid_virtual_env_path.to_str().unwrap()); + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set (returns VarError::NotPresent) + sys_mock::remove_env_var("VIRTUAL_ENV"); // Provide an invalid explicit path let invalid_path = project_dir.path().join("non_existent_venv"); @@ -346,7 +313,9 @@ mod tests { let venv_dir = tempdir().unwrap(); let venv_prefix = create_mock_venv(venv_dir.path(), None); - let _guard = VirtualEnvGuard::set("VIRTUAL_ENV", venv_prefix.to_str().unwrap()); + let _guard = MockGuard; + // Mock VIRTUAL_ENV to point to the mock venv + sys_mock::set_env_var("VIRTUAL_ENV", venv_prefix.to_str().unwrap().to_string()); let env = PythonEnvironment::new(project_dir.path(), None) .expect("Should find environment via VIRTUAL_ENV"); @@ -363,18 +332,20 @@ mod tests { fn test_explicit_path_overrides_virtual_env() { let project_dir = tempdir().unwrap(); let venv1_dir = tempdir().unwrap(); - let venv1_prefix = create_mock_venv(venv1_dir.path(), None); // Set by VIRTUAL_ENV + let venv1_prefix = create_mock_venv(venv1_dir.path(), None); // Mocked by VIRTUAL_ENV let venv2_dir = tempdir().unwrap(); - let venv2_prefix = create_mock_venv(venv2_dir.path(), None); // Set by explicit path + let venv2_prefix = create_mock_venv(venv2_dir.path(), None); // Provided explicitly - let _guard = VirtualEnvGuard::set("VIRTUAL_ENV", venv1_prefix.to_str().unwrap()); + let _guard = MockGuard; + // Mock VIRTUAL_ENV to point to venv1 + sys_mock::set_env_var("VIRTUAL_ENV", venv1_prefix.to_str().unwrap().to_string()); - let env = PythonEnvironment::new( - project_dir.path(), - Some(venv2_prefix.to_str().unwrap()), // Explicit path - ) - .expect("Should find environment via explicit path"); + // Call with explicit path to venv2 + let env = + PythonEnvironment::new(project_dir.path(), Some(venv2_prefix.to_str().unwrap())) + .expect("Should find environment via explicit path"); + // Explicit path (venv2) should take precedence assert_eq!( env.sys_prefix, venv2_prefix, "Explicit path should take precedence" @@ -386,10 +357,9 @@ mod tests { let project_dir = tempdir().unwrap(); let venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - // Set VIRTUAL_ENV to something known to be invalid to ensure it's ignored. - let invalid_virtual_env_path = project_dir.path().join("non_existent_venv_proj_found"); - let _guard = - VirtualEnvGuard::set("VIRTUAL_ENV", invalid_virtual_env_path.to_str().unwrap()); + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); let env = PythonEnvironment::new(project_dir.path(), None) .expect("Should find environment in project .venv"); @@ -400,48 +370,88 @@ mod tests { #[test] fn test_project_venv_priority() { let project_dir = tempdir().unwrap(); - // Create multiple potential venvs let dot_venv_prefix = create_mock_venv(&project_dir.path().join(".venv"), None); - let _venv_prefix = create_mock_venv(&project_dir.path().join("venv"), None); // Should be ignored if .venv found first + let _venv_prefix = create_mock_venv(&project_dir.path().join("venv"), None); - // Set VIRTUAL_ENV to something known to be invalid to ensure it's ignored. - let invalid_virtual_env_path = project_dir.path().join("non_existent_venv_priority"); - let _guard = - VirtualEnvGuard::set("VIRTUAL_ENV", invalid_virtual_env_path.to_str().unwrap()); + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); let env = PythonEnvironment::new(project_dir.path(), None).expect("Should find environment"); - // Asserts it finds .venv because it's checked first in the loop + // Should find .venv because it's checked first in the loop assert_eq!(env.sys_prefix, dot_venv_prefix); } #[test] - #[ignore = "Relies on system python being available and having standard layout"] fn test_system_python_fallback() { let project_dir = tempdir().unwrap(); - // Set VIRTUAL_ENV to something known to be invalid to ensure it's ignored. - let invalid_virtual_env_path = - project_dir.path().join("non_existent_venv_sys_fallback"); - let _guard = - VirtualEnvGuard::set("VIRTUAL_ENV", invalid_virtual_env_path.to_str().unwrap()); - // We don't create any venvs in project_dir + let _guard = MockGuard; + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); + + let mock_sys_python_dir = tempdir().unwrap(); + let mock_sys_python_prefix = mock_sys_python_dir.path(); + + #[cfg(unix)] + let (bin_subdir, python_exe, site_packages_rel_path) = ( + "bin", + "python", + Path::new("lib").join("python3.9").join("site-packages"), + ); + #[cfg(windows)] + let (bin_subdir, python_exe, site_packages_rel_path) = ( + "Scripts", + "python.exe", + Path::new("Lib").join("site-packages"), + ); + + let bin_dir = mock_sys_python_prefix.join(bin_subdir); + fs::create_dir_all(&bin_dir).unwrap(); + let python_path = bin_dir.join(python_exe); + fs::write(&python_path, "").unwrap(); + + #[cfg(unix)] + { + let mut perms = fs::metadata(&python_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&python_path, perms).unwrap(); + } + + let site_packages_path = mock_sys_python_prefix.join(site_packages_rel_path); + fs::create_dir_all(&site_packages_path).unwrap(); + + sys_mock::set_exec_path("python", python_path.clone()); - // This test assumes `which python` works and points to a standard layout let system_env = PythonEnvironment::new(project_dir.path(), None); + // Assert it found the mock system python via the mocked finder assert!( system_env.is_some(), - "Should fall back to system python if available" + "Should fall back to the mock system python" ); if let Some(env) = system_env { - // Basic checks - exact paths depend heavily on the test environment - assert!(env.python_path.exists()); - assert!(env.sys_prefix.exists()); - assert!(!env.sys_path.is_empty()); - assert!(env.sys_path[0].exists()); // Should contain the bin/Scripts dir + assert_eq!( + env.python_path, python_path, + "Python path should match mock" + ); + assert_eq!( + env.sys_prefix, mock_sys_python_prefix, + "Sys prefix should match mock prefix" + ); + assert!( + env.sys_path.contains(&bin_dir), + "Sys path should contain mock bin dir" + ); + assert!( + env.sys_path.contains(&site_packages_path), + "Sys path should contain mock site-packages" + ); + } else { + panic!("Expected to find environment, but got None"); } } @@ -449,39 +459,19 @@ mod tests { fn test_no_python_found() { let project_dir = tempdir().unwrap(); - // Ensure no explicit path, no project venvs, and set VIRTUAL_ENV to invalid. - let invalid_virtual_env_path = project_dir.path().join("non_existent_venv_no_python"); - let _guard = - VirtualEnvGuard::set("VIRTUAL_ENV", invalid_virtual_env_path.to_str().unwrap()); + let _guard = MockGuard; // Setup guard to clear mocks - // To *ensure* system fallback fails, we'd need to manipulate PATH, - // which is tricky and platform-dependent. Instead, we test the scenario - // where `from_system_python` *would* be called but returns None. - // We can simulate this by ensuring `which("python")` fails. - // For this unit test, let's assume a scenario where all checks fail. - // A more direct test would mock `which`, but that adds complexity. + // Ensure VIRTUAL_ENV is not set + sys_mock::remove_env_var("VIRTUAL_ENV"); - // Let's simulate the *call* path assuming `from_system_python` returns None. - // We can't easily force `which` to fail here without PATH manipulation. - // So, this test mainly verifies that if all preceding steps fail, - // the result of `from_system_python` (which *could* be None) is returned. - // If system python *is* found, this test might incorrectly pass if not ignored. - // A better approach might be needed if strict testing of "None" is required. + // Ensure find_executable returns an error + sys_mock::set_exec_error("python", WhichError::CannotFindBinaryPath); - // For now, let's assume a setup where system python isn't found by `which`. - // This test is inherently flaky if system python *is* on the PATH. - // Consider ignoring it or using mocking for `which` in a real-world scenario. - - // If system python IS found, this test doesn't truly test the "None" case. - // If system python IS NOT found, it tests the final `None` return. let env = PythonEnvironment::new(project_dir.path(), None); - // This assertion depends on whether system python is actually found or not. - // assert!(env.is_none(), "Expected no environment to be found"); - // Given the difficulty, let's skip asserting None directly unless we mock `which`. - println!( - "Test 'test_no_python_found' ran. Result depends on system state: {:?}", - env + assert!( + env.is_none(), + "Expected no environment to be found when all discovery methods fail" ); } @@ -495,7 +485,6 @@ mod tests { fs::write(bin_dir.join("python"), "").unwrap(); let lib_dir = prefix.join("lib"); fs::create_dir_all(&lib_dir).unwrap(); - // Create two python version dirs, ensure it picks one let py_version_dir1 = lib_dir.join("python3.8"); fs::create_dir_all(&py_version_dir1).unwrap(); fs::create_dir_all(py_version_dir1.join("site-packages")).unwrap(); @@ -505,14 +494,11 @@ mod tests { let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); - // It should find *a* site-packages dir. The exact one depends on read_dir order. let found_site_packages = env.sys_path.iter().any(|p| p.ends_with("site-packages")); assert!( found_site_packages, "Should have found a site-packages directory" ); - - // Ensure it contains the bin dir as well assert!(env.sys_path.contains(&prefix.join("bin"))); } @@ -527,7 +513,7 @@ mod tests { let lib_dir = prefix.join("Lib"); fs::create_dir_all(&lib_dir).unwrap(); let site_packages = lib_dir.join("site-packages"); - fs::create_dir_all(&site_packages).unwrap(); // Create the actual dir + fs::create_dir_all(&site_packages).unwrap(); let env = PythonEnvironment::from_venv_prefix(prefix).unwrap(); @@ -541,7 +527,6 @@ mod tests { #[test] fn test_from_venv_prefix_returns_none_if_dir_missing() { let dir = tempdir().unwrap(); - // Don't create the venv structure let result = PythonEnvironment::from_venv_prefix(dir.path()); assert!(result.is_none()); } @@ -550,7 +535,6 @@ mod tests { fn test_from_venv_prefix_returns_none_if_binary_missing() { let dir = tempdir().unwrap(); let prefix = dir.path(); - // Create prefix dir but not the binary fs::create_dir_all(prefix).unwrap(); #[cfg(unix)] @@ -574,7 +558,6 @@ mod tests { fn create_test_env(sys_paths: Vec) -> PythonEnvironment { PythonEnvironment { - // Dummy values for fields not directly used by activate python_path: PathBuf::from("dummy/bin/python"), sys_prefix: PathBuf::from("dummy"), sys_path: sys_paths, @@ -605,8 +588,6 @@ mod tests { initial_len + 2, "Should have added 2 paths" ); - - // Check that the *exact* paths were appended in the correct order assert_eq!( final_sys_path.get(initial_len).unwrap(), path1.to_str().expect("Path 1 should be valid UTF-8") @@ -644,7 +625,6 @@ mod tests { #[test] fn test_activate_with_non_existent_paths() -> PyResult<()> { let temp_dir = tempdir().unwrap(); - // These paths do not actually exist on the filesystem let path1 = temp_dir.path().join("non_existent_dir"); let path2 = temp_dir.path().join("another_missing/path"); @@ -687,11 +667,9 @@ mod tests { let valid_path = temp_dir.path().join("valid_dir"); fs::create_dir(&valid_path).unwrap(); - // Create a PathBuf from invalid UTF-8 bytes let invalid_bytes = b"invalid_\xff_utf8"; let os_str = OsStr::from_bytes(invalid_bytes); let non_utf8_path = PathBuf::from(os_str); - // Sanity check: ensure this path *cannot* be converted to str assert!( non_utf8_path.to_str().is_none(), "Path should not be convertible to UTF-8 str" @@ -708,7 +686,6 @@ mod tests { test_env.activate(py)?; let final_sys_path = get_sys_path(py)?; - // Should have added only the valid path assert_eq!( final_sys_path.len(), initial_len + 1, @@ -719,7 +696,6 @@ mod tests { valid_path.to_str().unwrap() ); - // Check that the invalid path string representation is NOT present let invalid_path_lossy = non_utf8_path.to_string_lossy(); assert!( !final_sys_path @@ -733,17 +709,14 @@ mod tests { } #[test] - #[cfg(windows)] // Test specific behavior for invalid UTF-16/WTF-8 on Windows + #[cfg(windows)] fn test_activate_skips_non_utf8_paths_windows() -> PyResult<()> { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; let temp_dir = tempdir().unwrap(); let valid_path = temp_dir.path().join("valid_dir"); - // No need to create dir, just need the PathBuf - // Create an OsString from invalid UTF-16 (a lone surrogate) - // D800 is a high surrogate, not valid unless paired with a low surrogate. let invalid_wide: Vec = vec![ 'i' as u16, 'n' as u16, 'v' as u16, 'a' as u16, 'l' as u16, 'i' as u16, 'd' as u16, '_' as u16, 0xD800, '_' as u16, 'w' as u16, 'i' as u16, 'd' as u16, 'e' as u16, @@ -751,7 +724,6 @@ mod tests { let os_string = OsString::from_wide(&invalid_wide); let non_utf8_path = PathBuf::from(os_string); - // Sanity check: ensure this path *cannot* be converted to a valid UTF-8 str assert!( non_utf8_path.to_str().is_none(), "Path with lone surrogate should not be convertible to UTF-8 str" @@ -768,7 +740,6 @@ mod tests { test_env.activate(py)?; let final_sys_path = get_sys_path(py)?; - // Should have added only the valid path assert_eq!( final_sys_path.len(), initial_len + 1, @@ -779,7 +750,6 @@ mod tests { valid_path.to_str().unwrap() ); - // Check that the invalid path string representation is NOT present let invalid_path_lossy = non_utf8_path.to_string_lossy(); assert!( !final_sys_path diff --git a/crates/djls-project/src/system.rs b/crates/djls-project/src/system.rs new file mode 100644 index 0000000..3c94f59 --- /dev/null +++ b/crates/djls-project/src/system.rs @@ -0,0 +1,196 @@ +use std::env::VarError; +use std::path::PathBuf; +use which::Error as WhichError; + +pub fn find_executable(name: &str) -> Result { + #[cfg(not(test))] + { + which::which(name) + } + #[cfg(test)] + { + mock::find_executable_mocked(name) + } +} + +pub fn env_var(key: &str) -> Result { + #[cfg(not(test))] + { + std::env::var(key) + } + #[cfg(test)] + { + mock::env_var_mocked(key) + } +} + +#[cfg(test)] +pub mod mock { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + use std::thread_local; + + thread_local! { + static MOCK_EXEC_RESULTS: RefCell>> = RefCell::new(HashMap::new()); + static MOCK_ENV_RESULTS: RefCell>> = RefCell::new(HashMap::new()); + } + + pub(super) fn find_executable_mocked(name: &str) -> Result { + MOCK_EXEC_RESULTS.with(|mocks| { + mocks + .borrow() + .get(name) + .cloned() + .unwrap_or(Err(WhichError::CannotFindBinaryPath)) + }) + } + + pub(super) fn env_var_mocked(key: &str) -> Result { + MOCK_ENV_RESULTS.with(|mocks| { + mocks + .borrow() + .get(key) + .cloned() + .unwrap_or(Err(VarError::NotPresent)) + }) + } + + // RAII guard to clear all mocks automatically after each test. + pub struct MockGuard; + impl Drop for MockGuard { + fn drop(&mut self) { + MOCK_EXEC_RESULTS.with(|mocks| mocks.borrow_mut().clear()); + MOCK_ENV_RESULTS.with(|mocks| mocks.borrow_mut().clear()); + } + } + + pub fn set_exec_path(name: &str, path: PathBuf) { + MOCK_EXEC_RESULTS.with(|mocks| { + mocks.borrow_mut().insert(name.to_string(), Ok(path)); + }); + } + + pub fn set_exec_error(name: &str, error: WhichError) { + MOCK_EXEC_RESULTS.with(|mocks| { + mocks.borrow_mut().insert(name.to_string(), Err(error)); + }); + } + + pub fn set_env_var(key: &str, value: String) { + MOCK_ENV_RESULTS.with(|mocks| { + mocks.borrow_mut().insert(key.to_string(), Ok(value)); + }); + } + + // Simulates VarError::NotPresent + pub fn remove_env_var(key: &str) { + MOCK_ENV_RESULTS.with(|mocks| { + mocks + .borrow_mut() + .insert(key.to_string(), Err(VarError::NotPresent)); + }); + } +} + +#[cfg(test)] +mod tests { + use super::mock::{self as sys_mock, MockGuard}; + use super::*; + use std::env::VarError; + use std::path::PathBuf; + use which::Error as WhichError; + + #[test] + fn test_exec_mock_path_retrieval() { + let _guard = MockGuard; + let expected_path = PathBuf::from("/mock/path/to/python"); + sys_mock::set_exec_path("python", expected_path.clone()); + let result = find_executable("python"); + assert_eq!(result.unwrap(), expected_path); + } + + #[test] + fn test_exec_mock_error_retrieval() { + let _guard = MockGuard; + sys_mock::set_exec_error("cargo", WhichError::CannotFindBinaryPath); + let result = find_executable("cargo"); + assert!(matches!(result, Err(WhichError::CannotFindBinaryPath))); + } + + #[test] + fn test_exec_mock_default_error_if_unmocked() { + let _guard = MockGuard; + let result = find_executable("git"); // Not mocked + assert!(matches!(result, Err(WhichError::CannotFindBinaryPath))); + } + + #[test] + fn test_env_mock_set_var_retrieval() { + let _guard = MockGuard; + sys_mock::set_env_var("MY_VAR", "my_value".to_string()); + let result = env_var("MY_VAR"); + assert_eq!(result.unwrap(), "my_value"); + } + + #[test] + fn test_env_mock_remove_var_retrieval() { + let _guard = MockGuard; + // Set it first, then remove it via mock + sys_mock::set_env_var("TEMP_VAR", "temp_value".to_string()); + sys_mock::remove_env_var("TEMP_VAR"); + let result = env_var("TEMP_VAR"); + assert!(matches!(result, Err(VarError::NotPresent))); + } + + #[test] + fn test_env_mock_default_error_if_unmocked() { + let _guard = MockGuard; + let result = env_var("UNMOCKED_VAR"); // Not mocked + assert!(matches!(result, Err(VarError::NotPresent))); + } + + #[test] + fn test_mock_guard_clears_all_mocks() { + let expected_exec_path = PathBuf::from("/tmp/myprog"); + let expected_env_val = "test_value".to_string(); + + { + let _guard = MockGuard; + sys_mock::set_exec_path("myprog", expected_exec_path.clone()); + sys_mock::set_env_var("MY_TEST_ENV", expected_env_val.clone()); + // Guard drops here, clearing both mocks + } + + // Verify mocks were cleared + let _guard = MockGuard; + let result_exec = find_executable("myprog"); + assert!(matches!(result_exec, Err(WhichError::CannotFindBinaryPath))); + let result_env = env_var("MY_TEST_ENV"); + assert!(matches!(result_env, Err(VarError::NotPresent))); + } + + #[test] + fn test_mocks_are_separate_between_tests() { + let _guard = MockGuard; // Ensures clean state + + // Check state from previous tests (should be cleared) + let result_python = find_executable("python"); + assert!(matches!( + result_python, + Err(WhichError::CannotFindBinaryPath) + )); + let result_myvar = env_var("MY_VAR"); + assert!(matches!(result_myvar, Err(VarError::NotPresent))); + + // Set mocks specific to this test + let expected_path_node = PathBuf::from("/usr/bin/node"); + sys_mock::set_exec_path("node", expected_path_node.clone()); + sys_mock::set_env_var("NODE_ENV", "production".to_string()); + + let result_node = find_executable("node"); + assert_eq!(result_node.unwrap(), expected_path_node); + let result_node_env = env_var("NODE_ENV"); + assert_eq!(result_node_env.unwrap(), "production"); + } +}