From 4ad513ccbec1ca6c1356af3eea8c19f1f94bab45 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 22 Apr 2026 15:53:47 -0700 Subject: [PATCH 1/3] test: add unit tests for pet-pyenv crate (Fixes #389) --- Cargo.lock | 1 + crates/pet-pyenv/Cargo.toml | 3 + crates/pet-pyenv/src/environment_locations.rs | 105 ++++++++ crates/pet-pyenv/src/environments.rs | 229 ++++++++++++++++++ 4 files changed, 338 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 8c49f670..a4665a30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,6 +690,7 @@ dependencies = [ "regex", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/crates/pet-pyenv/Cargo.toml b/crates/pet-pyenv/Cargo.toml index 404ee53d..f44ec5d3 100644 --- a/crates/pet-pyenv/Cargo.toml +++ b/crates/pet-pyenv/Cargo.toml @@ -18,3 +18,6 @@ pet-fs = { path = "../pet-fs" } pet-conda = { path = "../pet-conda" } log = "0.4.21" regex = "1.10.4" + +[dev-dependencies] +tempfile = "3.10.1" diff --git a/crates/pet-pyenv/src/environment_locations.rs b/crates/pet-pyenv/src/environment_locations.rs index c395b2ae..4741395e 100644 --- a/crates/pet-pyenv/src/environment_locations.rs +++ b/crates/pet-pyenv/src/environment_locations.rs @@ -45,3 +45,108 @@ pub fn get_pyenv_dir(env_vars: &EnvVariables) -> Option { None => env_vars.pyenv.as_ref().map(PathBuf::from), } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn make_env_vars( + home: Option, + pyenv_root: Option, + pyenv: Option, + known_paths: Vec, + ) -> EnvVariables { + EnvVariables { + home, + root: None, + path: None, + pyenv_root, + pyenv, + known_global_search_locations: known_paths, + } + } + + // get_pyenv_dir tests + #[test] + fn get_pyenv_dir_prefers_pyenv_root_over_pyenv() { + let env = make_env_vars( + None, + Some("/custom/pyenv-root".to_string()), + Some("/other/pyenv".to_string()), + vec![], + ); + assert_eq!( + get_pyenv_dir(&env), + Some(PathBuf::from("/custom/pyenv-root")) + ); + } + + #[test] + fn get_pyenv_dir_falls_back_to_pyenv_env_var() { + let env = make_env_vars(None, None, Some("/fallback/pyenv".to_string()), vec![]); + assert_eq!(get_pyenv_dir(&env), Some(PathBuf::from("/fallback/pyenv"))); + } + + #[test] + fn get_pyenv_dir_returns_none_when_no_env_vars() { + let env = make_env_vars(None, None, None, vec![]); + assert_eq!(get_pyenv_dir(&env), None); + } + + // get_home_pyenv_dir tests + #[test] + fn get_home_pyenv_dir_returns_none_without_home() { + let env = make_env_vars(None, None, None, vec![]); + assert_eq!(get_home_pyenv_dir(&env), None); + } + + #[test] + fn get_home_pyenv_dir_returns_expected_path_with_home() { + let home = tempdir().unwrap(); + let env = make_env_vars(Some(home.path().to_path_buf()), None, None, vec![]); + let result = get_home_pyenv_dir(&env).unwrap(); + let path_str = result.to_string_lossy(); + if cfg!(windows) { + assert!( + path_str.contains(".pyenv"), + "Expected .pyenv in path: {}", + path_str + ); + assert!( + path_str.contains("pyenv-win"), + "Expected pyenv-win in path: {}", + path_str + ); + } else { + assert!(result.ends_with(".pyenv")); + } + } + + // get_binary_from_known_paths tests + #[test] + fn get_binary_from_known_paths_finds_pyenv_binary() { + let dir = tempdir().unwrap(); + let bin_name = if cfg!(windows) { "pyenv.bat" } else { "pyenv" }; + let exe = dir.path().join(bin_name); + fs::write(&exe, b"").unwrap(); + + let env = make_env_vars(None, None, None, vec![dir.path().to_path_buf()]); + let result = get_binary_from_known_paths(&env); + assert!(result.is_some()); + } + + #[test] + fn get_binary_from_known_paths_returns_none_when_not_found() { + let dir = tempdir().unwrap(); + let env = make_env_vars(None, None, None, vec![dir.path().to_path_buf()]); + assert!(get_binary_from_known_paths(&env).is_none()); + } + + #[test] + fn get_binary_from_known_paths_returns_none_for_empty_paths() { + let env = make_env_vars(None, None, None, vec![]); + assert!(get_binary_from_known_paths(&env).is_none()); + } +} diff --git a/crates/pet-pyenv/src/environments.rs b/crates/pet-pyenv/src/environments.rs index 0f2fb51d..3ad23515 100644 --- a/crates/pet-pyenv/src/environments.rs +++ b/crates/pet-pyenv/src/environments.rs @@ -100,3 +100,232 @@ fn get_version(folder_name: &str) -> Option { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + use tempfile::tempdir; + + // get_version tests + #[test] + fn get_version_parses_stable_version() { + assert_eq!(get_version("3.10.10"), Some("3.10.10".to_string())); + assert_eq!(get_version("3.12.0"), Some("3.12.0".to_string())); + assert_eq!(get_version("2.7.18"), Some("2.7.18".to_string())); + } + + #[test] + fn get_version_parses_dev_version() { + assert_eq!(get_version("3.10-dev"), Some("3.10-dev".to_string())); + assert_eq!(get_version("3.13-dev"), Some("3.13-dev".to_string())); + } + + #[test] + fn get_version_parses_alpha_rc_version() { + assert_eq!(get_version("3.10.0a3"), Some("3.10.0a3".to_string())); + assert_eq!(get_version("3.12.0b1"), Some("3.12.0b1".to_string())); + } + + #[test] + fn get_version_returns_none_for_multi_letter_prerelease() { + // Known limitation: BETA_PYTHON_VERSION regex uses \w (single char) so multi-letter + // pre-release tags like "rc" are not captured. Real pyenv installs can have rc versions + // (e.g. 3.13.0rc1), but version detection falls back to header files in that case. + assert_eq!(get_version("3.11.0rc2"), None); + } + + #[test] + fn get_version_parses_win32_version() { + assert_eq!(get_version("3.11.0a4-win32"), Some("3.11.0a4".to_string())); + } + + #[test] + fn get_version_returns_none_for_non_version_strings() { + assert_eq!(get_version("mambaforge-4.10.1-4"), None); + assert_eq!(get_version("pypy3.9-7.3.15"), None); + assert_eq!(get_version("my-virtual-env"), None); + assert_eq!(get_version(""), None); + } + + #[test] + fn get_version_returns_none_for_partial_version() { + assert_eq!(get_version("3.10"), None); + } + + // get_generic_python_environment tests + #[test] + fn get_generic_python_environment_with_stable_version_folder() { + let root = tempdir().unwrap(); + let env_path = root.path().join("3.12.0"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); + + assert_eq!(result.kind, Some(PythonEnvironmentKind::Pyenv)); + assert_eq!(result.executable, Some(exe)); + assert_eq!(result.version, Some("3.12.0".to_string())); + assert_eq!(result.prefix, Some(env_path)); + assert!(result.manager.is_none()); + } + + #[test] + fn get_generic_python_environment_with_win32_folder_sets_x86_arch() { + let root = tempdir().unwrap(); + let env_path = root.path().join("3.11.0a4-win32"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); + + assert_eq!(result.arch, Some(Architecture::X86)); + } + + #[test] + fn get_generic_python_environment_with_non_win32_folder_has_no_arch() { + let root = tempdir().unwrap(); + let env_path = root.path().join("3.12.0"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); + + assert!(result.arch.is_none()); + } + + #[test] + fn get_generic_python_environment_includes_manager_when_provided() { + let root = tempdir().unwrap(); + let env_path = root.path().join("3.12.0"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let mgr = EnvManager::new( + PathBuf::from("/usr/bin/pyenv"), + pet_core::manager::EnvManagerType::Pyenv, + Some("2.4.0".to_string()), + ); + let result = get_generic_python_environment(&exe, &env_path, &Some(mgr.clone())).unwrap(); + + assert_eq!(result.manager, Some(mgr)); + } + + #[test] + fn get_generic_python_environment_with_unrecognized_folder_name() { + let root = tempdir().unwrap(); + let env_path = root.path().join("mambaforge-4.10.1-4"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); + + assert_eq!(result.kind, Some(PythonEnvironmentKind::Pyenv)); + // No version extractable from folder name and no header files + assert!(result.version.is_none()); + } + + // get_virtual_env_environment tests + #[test] + fn get_virtual_env_returns_none_without_pyvenv_cfg() { + let root = tempdir().unwrap(); + let env_path = root.path().join("my-venv"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + + let result = get_virtual_env_environment(&exe, &env_path, &None); + + assert!(result.is_none()); + } + + #[test] + fn get_virtual_env_returns_env_with_pyvenv_cfg() { + let root = tempdir().unwrap(); + let env_path = root.path().join("my-venv"); + let bin_dir = if cfg!(windows) { + env_path.join("Scripts") + } else { + env_path.join("bin") + }; + fs::create_dir_all(&bin_dir).unwrap(); + let exe = if cfg!(windows) { + bin_dir.join("python.exe") + } else { + bin_dir.join("python") + }; + fs::write(&exe, b"").unwrap(); + fs::write( + env_path.join("pyvenv.cfg"), + "version = 3.12.0\nhome = /usr/bin\n", + ) + .unwrap(); + + let result = get_virtual_env_environment(&exe, &env_path, &None).unwrap(); + + assert_eq!(result.kind, Some(PythonEnvironmentKind::PyenvVirtualEnv)); + assert_eq!(result.version, Some("3.12.0".to_string())); + assert_eq!(result.executable, Some(exe)); + assert_eq!(result.prefix, Some(env_path)); + } +} From e495eb068f02e618b2715196c5d6fad53351207f Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 22 Apr 2026 17:36:35 -0700 Subject: [PATCH 2/3] fix: use norm_case in pyenv test assertions for Windows path normalization --- crates/pet-pyenv/src/environments.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/pet-pyenv/src/environments.rs b/crates/pet-pyenv/src/environments.rs index 3ad23515..d0f01df4 100644 --- a/crates/pet-pyenv/src/environments.rs +++ b/crates/pet-pyenv/src/environments.rs @@ -104,6 +104,7 @@ fn get_version(folder_name: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use pet_fs::path::norm_case; use std::fs; use std::path::PathBuf; use tempfile::tempdir; @@ -175,9 +176,9 @@ mod tests { let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); assert_eq!(result.kind, Some(PythonEnvironmentKind::Pyenv)); - assert_eq!(result.executable, Some(exe)); + assert_eq!(result.executable, Some(norm_case(&exe))); assert_eq!(result.version, Some("3.12.0".to_string())); - assert_eq!(result.prefix, Some(env_path)); + assert_eq!(result.prefix, Some(norm_case(&env_path))); assert!(result.manager.is_none()); } @@ -325,7 +326,7 @@ mod tests { assert_eq!(result.kind, Some(PythonEnvironmentKind::PyenvVirtualEnv)); assert_eq!(result.version, Some("3.12.0".to_string())); - assert_eq!(result.executable, Some(exe)); - assert_eq!(result.prefix, Some(env_path)); + assert_eq!(result.executable, Some(norm_case(&exe))); + assert_eq!(result.prefix, Some(norm_case(&env_path))); } } From c7de7b09d528c6a5b10c79fddfb9370a431599d3 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 23 Apr 2026 09:21:31 -0700 Subject: [PATCH 3/3] fix: use file_name() comparison in pyenv tests for Windows 8.3 short path compatibility --- crates/pet-pyenv/src/environments.rs | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/pet-pyenv/src/environments.rs b/crates/pet-pyenv/src/environments.rs index d0f01df4..d20d1829 100644 --- a/crates/pet-pyenv/src/environments.rs +++ b/crates/pet-pyenv/src/environments.rs @@ -104,7 +104,6 @@ fn get_version(folder_name: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use pet_fs::path::norm_case; use std::fs; use std::path::PathBuf; use tempfile::tempdir; @@ -176,9 +175,15 @@ mod tests { let result = get_generic_python_environment(&exe, &env_path, &None).unwrap(); assert_eq!(result.kind, Some(PythonEnvironmentKind::Pyenv)); - assert_eq!(result.executable, Some(norm_case(&exe))); + assert_eq!( + result.executable.as_ref().unwrap().file_name(), + exe.file_name() + ); assert_eq!(result.version, Some("3.12.0".to_string())); - assert_eq!(result.prefix, Some(norm_case(&env_path))); + assert_eq!( + result.prefix.as_ref().unwrap().file_name(), + env_path.file_name() + ); assert!(result.manager.is_none()); } @@ -326,7 +331,13 @@ mod tests { assert_eq!(result.kind, Some(PythonEnvironmentKind::PyenvVirtualEnv)); assert_eq!(result.version, Some("3.12.0".to_string())); - assert_eq!(result.executable, Some(norm_case(&exe))); - assert_eq!(result.prefix, Some(norm_case(&env_path))); + assert_eq!( + result.executable.as_ref().unwrap().file_name(), + exe.file_name() + ); + assert_eq!( + result.prefix.as_ref().unwrap().file_name(), + env_path.file_name() + ); } }