From a2be9078c05fb04a71d9e3da42205c294a8d16e4 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 22 Apr 2026 15:40:02 -0700 Subject: [PATCH] test: add unit tests for pet-homebrew crate (Fixes #389) --- .../pet-homebrew/src/environment_locations.rs | 39 +++++++ crates/pet-homebrew/src/environments.rs | 101 ++++++++++++++++++ crates/pet-homebrew/src/lib.rs | 96 +++++++++++++++++ crates/pet-homebrew/src/sym_links.rs | 70 +++++++++++- 4 files changed, 305 insertions(+), 1 deletion(-) diff --git a/crates/pet-homebrew/src/environment_locations.rs b/crates/pet-homebrew/src/environment_locations.rs index 16de9448..710012b2 100644 --- a/crates/pet-homebrew/src/environment_locations.rs +++ b/crates/pet-homebrew/src/environment_locations.rs @@ -91,4 +91,43 @@ mod tests { .iter() .any(|path| path == &missing_homebrew_prefix.join("bin"))); } + + #[test] + fn homebrew_prefix_bin_returns_results_without_env_var() { + let env_vars = EnvVariables { + home: None, + root: None, + path: None, + homebrew_prefix: None, + known_global_search_locations: vec![], + }; + + // Should not panic and should return whatever standard paths exist + let prefix_bins = get_homebrew_prefix_bin(&env_vars); + // All returned paths should actually exist + for path in &prefix_bins { + assert!(path.exists(), "{:?} should exist", path); + } + } + + #[test] + fn homebrew_prefix_bin_does_not_duplicate_when_env_var_matches_existing_dir() { + // Create a temp dir to act as a custom homebrew prefix. + // Call get_homebrew_prefix_bin twice with the same prefix to ensure + // the env var path only appears once in the result. + let custom_prefix = tempdir().unwrap(); + let custom_bin = custom_prefix.path().join("bin"); + fs::create_dir_all(&custom_bin).unwrap(); + let env_vars = EnvVariables { + home: None, + root: None, + path: None, + homebrew_prefix: Some(custom_prefix.path().to_string_lossy().to_string()), + known_global_search_locations: vec![], + }; + + let prefix_bins = get_homebrew_prefix_bin(&env_vars); + let count = prefix_bins.iter().filter(|p| **p == custom_bin).count(); + assert_eq!(count, 1, "Custom bin path should appear exactly once"); + } } diff --git a/crates/pet-homebrew/src/environments.rs b/crates/pet-homebrew/src/environments.rs index 9e6326ed..8428484f 100644 --- a/crates/pet-homebrew/src/environments.rs +++ b/crates/pet-homebrew/src/environments.rs @@ -167,4 +167,105 @@ mod tests { Some("3.11.9".to_string()) ); } + + #[test] + fn extract_version_from_opt_homebrew_path() { + assert_eq!( + get_version(&PathBuf::from( + "/opt/homebrew/Cellar/python@3.12/3.12.3/Frameworks/Python.framework/Versions/3.12/bin/python3.12" + )), + Some("3.12.3".to_string()) + ); + } + + #[test] + fn extract_version_from_usr_local_cellar_path() { + assert_eq!( + get_version(&PathBuf::from( + "/usr/local/Cellar/python@3.8/3.8.20/Frameworks/Python.framework/Versions/3.8/bin/python3.8" + )), + Some("3.8.20".to_string()) + ); + } + + #[test] + fn extract_version_returns_none_for_path_without_version() { + assert_eq!(get_version(&PathBuf::from("/usr/bin/python3")), None); + } + + #[test] + fn get_prefix_always_returns_none() { + assert!(get_prefix(&PathBuf::from( + "/opt/homebrew/Cellar/python@3.12/3.12.3/Frameworks/Python.framework/Versions/3.12/bin/python3.12" + )) + .is_none()); + assert!(get_prefix(&PathBuf::from( + "/home/linuxbrew/.linuxbrew/Cellar/python@3.12/3.12.4/bin/python3.12" + )) + .is_none()); + assert!(get_prefix(&PathBuf::from( + "/usr/local/Cellar/python@3.8/3.8.20/bin/python3.8" + )) + .is_none()); + } + + #[test] + fn get_python_info_returns_correct_kind_and_executable() { + let bin_exe = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/python3.12"); + let resolved_exe = + PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/python@3.12/3.12.4/bin/python3.12"); + + let env = get_python_info(&bin_exe, &resolved_exe).unwrap(); + + assert_eq!(env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!(env.executable, Some(bin_exe.clone())); + assert_eq!(env.version, Some("3.12.4".to_string())); + assert_eq!(env.prefix, None); + // Both bin exe and resolved exe should be in symlinks + let symlinks = env.symlinks.unwrap(); + assert!(symlinks.contains(&bin_exe)); + assert!(symlinks.contains(&resolved_exe)); + } + + #[test] + fn get_python_info_returns_none_version_for_unversioned_path() { + let bin_exe = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/python3"); + let resolved_exe = PathBuf::from("/home/linuxbrew/.linuxbrew/bin/python3"); + + let env = get_python_info(&bin_exe, &resolved_exe).unwrap(); + + assert_eq!(env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!(env.version, None); + } + + #[test] + fn get_python_info_for_opt_homebrew_path() { + let bin_exe = PathBuf::from("/opt/homebrew/bin/python3.12"); + let resolved_exe = PathBuf::from( + "/opt/homebrew/Cellar/python@3.12/3.12.3/Frameworks/Python.framework/Versions/3.12/bin/python3.12", + ); + + let env = get_python_info(&bin_exe, &resolved_exe).unwrap(); + + assert_eq!(env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!(env.executable, Some(bin_exe.clone())); + assert_eq!(env.version, Some("3.12.3".to_string())); + let symlinks = env.symlinks.unwrap(); + assert!(symlinks.contains(&bin_exe)); + assert!(symlinks.contains(&resolved_exe)); + } + + #[test] + fn get_python_info_for_usr_local_cellar_path() { + let bin_exe = PathBuf::from("/usr/local/bin/python3.8"); + let resolved_exe = PathBuf::from( + "/usr/local/Cellar/python@3.8/3.8.20/Frameworks/Python.framework/Versions/3.8/bin/python3.8", + ); + + let env = get_python_info(&bin_exe, &resolved_exe).unwrap(); + + assert_eq!(env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!(env.executable, Some(bin_exe)); + assert_eq!(env.version, Some("3.8.20".to_string())); + } } diff --git a/crates/pet-homebrew/src/lib.rs b/crates/pet-homebrew/src/lib.rs index 15d8d68d..ca87b483 100644 --- a/crates/pet-homebrew/src/lib.rs +++ b/crates/pet-homebrew/src/lib.rs @@ -281,4 +281,100 @@ mod tests { ); assert!(locator.try_from(&conda).is_none()); } + + #[test] + fn try_from_identifies_opt_homebrew_python() { + let locator = Homebrew::from(&TestEnvironment { + homebrew_prefix: None, + }); + let env = PythonEnv::new( + PathBuf::from( + "/opt/homebrew/Cellar/python@3.12/3.12.3/Frameworks/Python.framework/Versions/3.12/bin/python3.12", + ), + None, + None, + ); + + let homebrew_env = locator.try_from(&env).unwrap(); + + assert_eq!(homebrew_env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!( + homebrew_env.executable, + Some(PathBuf::from("/opt/homebrew/bin/python3.12")) + ); + assert_eq!(homebrew_env.version, Some("3.12.3".to_string())); + } + + #[test] + fn try_from_identifies_usr_local_cellar_python() { + let locator = Homebrew::from(&TestEnvironment { + homebrew_prefix: None, + }); + let env = PythonEnv::new( + PathBuf::from( + "/usr/local/Cellar/python@3.8/3.8.20/Frameworks/Python.framework/Versions/3.8/bin/python3.8", + ), + None, + None, + ); + + let homebrew_env = locator.try_from(&env).unwrap(); + + assert_eq!(homebrew_env.kind, Some(PythonEnvironmentKind::Homebrew)); + assert_eq!( + homebrew_env.executable, + Some(PathBuf::from("/usr/local/bin/python3.8")) + ); + assert_eq!(homebrew_env.version, Some("3.8.20".to_string())); + } + + #[test] + fn try_from_rejects_conda_env_when_parent_is_conda() { + let locator = Homebrew::from(&TestEnvironment { + homebrew_prefix: None, + }); + // Create a directory that looks like a conda env (has conda-meta) + let conda_root = tempdir().unwrap(); + fs::create_dir_all(conda_root.path().join("conda-meta")).unwrap(); + // Place executable directly in the conda-meta parent directory + let exe = conda_root.path().join("python3.12"); + fs::write(&exe, b"").unwrap(); + + let env = PythonEnv::new(exe, None, None); + assert!(locator.try_from(&env).is_none()); + } + + #[test] + fn try_from_rejects_conda_env_when_grandparent_is_conda() { + let locator = Homebrew::from(&TestEnvironment { + homebrew_prefix: None, + }); + // Create a directory that looks like a conda env (has conda-meta) + let conda_root = tempdir().unwrap(); + fs::create_dir_all(conda_root.path().join("conda-meta")).unwrap(); + let bin_dir = conda_root.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let exe = bin_dir.join("python3.12"); + fs::write(&exe, b"").unwrap(); + + let env = PythonEnv::new(exe, None, None); + assert!(locator.try_from(&env).is_none()); + } + + #[test] + fn try_from_rejects_conda_env_via_prefix() { + let locator = Homebrew::from(&TestEnvironment { + homebrew_prefix: None, + }); + // Conda env detected via prefix having conda-meta + let conda_root = tempdir().unwrap(); + fs::create_dir_all(conda_root.path().join("conda-meta")).unwrap(); + let bin_dir = conda_root.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let exe = bin_dir.join("python3.12"); + fs::write(&exe, b"").unwrap(); + + let env = PythonEnv::new(exe, Some(conda_root.path().to_path_buf()), None); + assert!(locator.try_from(&env).is_none()); + } } diff --git a/crates/pet-homebrew/src/sym_links.rs b/crates/pet-homebrew/src/sym_links.rs index 75b88e34..06e82b51 100644 --- a/crates/pet-homebrew/src/sym_links.rs +++ b/crates/pet-homebrew/src/sym_links.rs @@ -244,7 +244,7 @@ pub fn get_known_symlinks_impl( } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; @@ -262,6 +262,29 @@ mod tests { assert!(!is_homebrew_python(Path::new("/usr/bin/python3.12"))); } + #[test] + fn is_homebrew_python_recognizes_opt_homebrew_bin_paths() { + assert!(is_homebrew_python(Path::new( + "/opt/homebrew/bin/python3.12" + ))); + assert!(is_homebrew_python(Path::new( + "/opt/homebrew/opt/python@3.12/bin/python3.12" + ))); + assert!(is_homebrew_python(Path::new( + "/opt/homebrew/Frameworks/Python.framework/Versions/3.12/bin/python3.12" + ))); + } + + #[test] + fn is_homebrew_python_rejects_non_homebrew_paths() { + assert!(!is_homebrew_python(Path::new("/usr/local/bin/python3.12"))); + assert!(!is_homebrew_python(Path::new("/usr/bin/python3"))); + assert!(!is_homebrew_python(Path::new( + "/home/user/.pyenv/versions/3.12.0/bin/python3.12" + ))); + assert!(!is_homebrew_python(Path::new(""))); + } + #[test] fn known_symlink_templates_include_resolved_executable_for_linuxbrew() { let resolved_exe = @@ -278,4 +301,49 @@ mod tests { .is_empty() ); } + + #[test] + fn known_symlink_templates_include_self_for_opt_homebrew() { + let resolved_exe = PathBuf::from( + "/opt/homebrew/Cellar/python@3.12/3.12.3/Frameworks/Python.framework/Versions/3.12/bin/python3.12", + ); + let symlinks = get_known_symlinks_impl(&resolved_exe, &"3.12.3".to_string()); + + assert!(symlinks.contains(&resolved_exe)); + assert!(symlinks.len() >= 1); + } + + #[test] + fn known_symlink_templates_include_self_for_usr_local_cellar() { + let resolved_exe = PathBuf::from( + "/usr/local/Cellar/python@3.8/3.8.20/Frameworks/Python.framework/Versions/3.8/bin/python3.8", + ); + let symlinks = get_known_symlinks_impl(&resolved_exe, &"3.8.20".to_string()); + + assert!(symlinks.contains(&resolved_exe)); + assert!(symlinks.len() >= 1); + } + + #[test] + fn known_symlink_templates_return_empty_when_version_regex_does_not_match() { + // Path under /opt/homebrew but without a python@version segment + let resolved_exe = PathBuf::from("/opt/homebrew/bin/python3.12"); + let symlinks = get_known_symlinks_impl(&resolved_exe, &"3.12.0".to_string()); + + // No python@version/ in path, so regex won't capture → returns empty + assert!(symlinks.is_empty()); + } + + #[test] + fn known_symlink_templates_for_linuxbrew_contain_expected_paths() { + let resolved_exe = + PathBuf::from("/home/linuxbrew/.linuxbrew/Cellar/python@3.12/3.12.4/bin/python3.12"); + let symlinks = get_known_symlinks_impl(&resolved_exe, &"3.12.4".to_string()); + + // The resolved exe itself is always included + assert!(symlinks.contains(&resolved_exe)); + // On a test system without real symlinks, only the resolved exe will pass validation. + // But verify the function doesn't panic and returns at least the resolved exe. + assert!(!symlinks.is_empty()); + } }