From e53972f8df7c37b9e89f34e3a18e541350b0d911 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 22 Apr 2026 15:03:43 -0700 Subject: [PATCH] test: add Mac Command Line Tools locator coverage (Fixes #389) --- crates/pet-mac-commandlinetools/src/lib.rs | 298 ++++++++++++++++++++- 1 file changed, 290 insertions(+), 8 deletions(-) diff --git a/crates/pet-mac-commandlinetools/src/lib.rs b/crates/pet-mac-commandlinetools/src/lib.rs index ebcdf9e8..12edd5de 100644 --- a/crates/pet-mac-commandlinetools/src/lib.rs +++ b/crates/pet-mac-commandlinetools/src/lib.rs @@ -12,7 +12,76 @@ use pet_fs::path::resolve_symlink; use pet_python_utils::version; use pet_python_utils::{env::ResolvedPythonEnv, executable::find_executables}; use pet_virtualenv::is_virtualenv; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; + +/// Returns `true` when `name` is `python`, `python3`, or `python3.` +/// (where minor is one or more ASCII digits). +fn is_macos_python_executable_name(name: &str) -> bool { + if name == "python" || name == "python3" { + return true; + } + if let Some(rest) = name.strip_prefix("python3.") { + return !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()); + } + false +} + +/// Returns `true` when `dir` is `"Current"` or a `.` pair of +/// ASCII digits (e.g. `"3.9"`). +fn is_valid_framework_version_dir(dir: &str) -> bool { + if dir == "Current" { + return true; + } + match dir.split_once('.') { + Some((major, minor)) => { + !major.is_empty() + && major.chars().all(|c| c.is_ascii_digit()) + && !minor.is_empty() + && minor.chars().all(|c| c.is_ascii_digit()) + } + None => false, + } +} + +/// Checks whether the given path (as a pre-computed lossy string) is a valid +/// Command Line Tools Python executable path. +/// +/// Accepted shapes: +/// /Library/Developer/CommandLineTools/usr/bin/ +/// /Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions//bin/ +fn is_cmdlinetools_python_path(path_str: &str) -> bool { + let path = Path::new(path_str); + + let exe_name = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name, + None => return false, + }; + + if !is_macos_python_executable_name(exe_name) { + return false; + } + + // Shape 1: /Library/Developer/CommandLineTools/usr/bin/ + if let Ok(rest) = path.strip_prefix("/Library/Developer/CommandLineTools/usr/bin") { + return rest.components().count() == 1; + } + + // Shape 2: .../Python3.framework/Versions//bin/ + if let Ok(rest) = path.strip_prefix( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions", + ) { + let components: Vec<_> = rest.components().collect(); + // Expect exactly 3 components: , "bin", + if components.len() != 3 { + return false; + } + let version_dir = components[0].as_os_str().to_string_lossy(); + let bin_dir = components[1].as_os_str().to_string_lossy(); + return is_valid_framework_version_dir(&version_dir) && bin_dir == "bin"; + } + + false +} pub struct MacCmdLineTools {} @@ -45,13 +114,8 @@ impl Locator for MacCmdLineTools { return None; } - if !env - .executable - .starts_with("/Library/Developer/CommandLineTools/usr/bin") - && !env.executable.starts_with( - "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions", - ) - { + let exe_str = env.executable.to_string_lossy(); + if !is_cmdlinetools_python_path(&exe_str) { return None; } @@ -246,3 +310,221 @@ impl Locator for MacCmdLineTools { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pet_core::{python_environment::PythonEnvironmentKind, Locator, LocatorKind}; + + // ── locator metadata ────────────────────────────────────────── + + #[test] + fn locator_metadata_matches_cmdlinetools_kind() { + let loc = MacCmdLineTools::new(); + assert_eq!(loc.get_kind(), LocatorKind::MacCommandLineTools); + assert_eq!( + loc.supported_categories(), + vec![PythonEnvironmentKind::MacCommandLineTools] + ); + } + + // ── is_macos_python_executable_name ─────────────────────────── + + #[test] + fn exe_name_accepts_python() { + assert!(is_macos_python_executable_name("python")); + } + + #[test] + fn exe_name_accepts_python3() { + assert!(is_macos_python_executable_name("python3")); + } + + #[test] + fn exe_name_accepts_python3_minor() { + assert!(is_macos_python_executable_name("python3.9")); + assert!(is_macos_python_executable_name("python3.12")); + } + + #[test] + fn exe_name_rejects_config_script() { + assert!(!is_macos_python_executable_name("python3-config")); + assert!(!is_macos_python_executable_name("python3.9-config")); + } + + #[test] + fn exe_name_rejects_non_python_tools() { + assert!(!is_macos_python_executable_name("idle3")); + assert!(!is_macos_python_executable_name("pydoc3")); + assert!(!is_macos_python_executable_name("pip3")); + } + + #[test] + fn exe_name_rejects_compact_version() { + assert!(!is_macos_python_executable_name("python39")); + } + + #[test] + fn exe_name_rejects_multi_dot_version() { + assert!(!is_macos_python_executable_name("python3.9.1")); + } + + #[test] + fn exe_name_rejects_trailing_dot() { + assert!(!is_macos_python_executable_name("python3.")); + } + + // ── is_valid_framework_version_dir ──────────────────────────── + + #[test] + fn version_dir_accepts_current() { + assert!(is_valid_framework_version_dir("Current")); + } + + #[test] + fn version_dir_accepts_major_minor() { + assert!(is_valid_framework_version_dir("3.9")); + assert!(is_valid_framework_version_dir("3.12")); + } + + #[test] + fn version_dir_rejects_patch_version() { + assert!(!is_valid_framework_version_dir("3.9.1")); + } + + #[test] + fn version_dir_rejects_bare_major() { + assert!(!is_valid_framework_version_dir("3")); + } + + #[test] + fn version_dir_rejects_empty() { + assert!(!is_valid_framework_version_dir("")); + } + + #[test] + fn version_dir_rejects_dot_only() { + assert!(!is_valid_framework_version_dir(".")); + assert!(!is_valid_framework_version_dir("3.")); + assert!(!is_valid_framework_version_dir(".9")); + } + + // ── is_cmdlinetools_python_path ─────────────────────────────── + + #[test] + fn cmdlinetools_path_accepts_usr_bin_python3() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/python3" + )); + } + + #[test] + fn cmdlinetools_path_accepts_usr_bin_python3_versioned() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/python3.9" + )); + } + + #[test] + fn cmdlinetools_path_accepts_usr_bin_python() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/python" + )); + } + + #[test] + fn cmdlinetools_path_accepts_framework_versioned_python() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9" + )); + } + + #[test] + fn cmdlinetools_path_accepts_framework_current_version() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/Current/bin/python3" + )); + } + + #[test] + fn cmdlinetools_path_accepts_framework_bare_python() { + assert!(is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/python" + )); + } + + #[test] + fn cmdlinetools_path_rejects_empty_string() { + assert!(!is_cmdlinetools_python_path("")); + } + + #[test] + fn cmdlinetools_path_rejects_config_script() { + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/python3-config" + )); + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/python3.9-config" + )); + } + + #[test] + fn cmdlinetools_path_rejects_non_python_tools() { + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/idle3" + )); + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/pydoc3" + )); + } + + #[test] + fn cmdlinetools_path_rejects_framework_invalid_version() { + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9.1/bin/python3.9" + )); + } + + #[test] + fn cmdlinetools_path_rejects_framework_nested_path() { + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.9/bin/subdir/python3.9" + )); + } + + #[test] + fn cmdlinetools_path_rejects_unrelated_path() { + assert!(!is_cmdlinetools_python_path("/usr/bin/python3")); + assert!(!is_cmdlinetools_python_path("/usr/local/bin/python3")); + assert!(!is_cmdlinetools_python_path("/opt/homebrew/bin/python3.11")); + } + + #[test] + fn cmdlinetools_path_rejects_xcode_path() { + assert!(!is_cmdlinetools_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn cmdlinetools_path_rejects_usr_bin_nested_deeper() { + assert!(!is_cmdlinetools_python_path( + "/Library/Developer/CommandLineTools/usr/bin/subdir/python3" + )); + } + + #[test] + fn try_from_rejects_cmdlinetools_path_off_macos() { + // On non-macOS the locator always returns None regardless of path. + if std::env::consts::OS == "macos" { + return; // skip on macOS — this test is for other platforms + } + let loc = MacCmdLineTools::new(); + let env = PythonEnv::new( + PathBuf::from("/Library/Developer/CommandLineTools/usr/bin/python3.9"), + None, + None, + ); + assert!(loc.try_from(&env).is_none()); + } +}