diff --git a/crates/pet-mac-xcode/src/lib.rs b/crates/pet-mac-xcode/src/lib.rs index ab86adeb..9ed76de8 100644 --- a/crates/pet-mac-xcode/src/lib.rs +++ b/crates/pet-mac-xcode/src/lib.rs @@ -31,7 +31,7 @@ impl Locator for MacXCode { LocatorKind::MacXCode } fn supported_categories(&self) -> Vec { - vec![PythonEnvironmentKind::MacCommandLineTools] + vec![PythonEnvironmentKind::MacXCode] } fn try_from(&self, env: &PythonEnv) -> Option { @@ -49,8 +49,7 @@ impl Locator for MacXCode { // Support for /Applications/Xcode.app/Contents/Developer/usr/bin/python3 // /Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3 (such paths are on CI, see here https://github.com/microsoft/python-environment-tools/issues/38) - if !exe_str.starts_with("/Applications") && !exe_str.contains("Contents/Developer/usr/bin") - { + if !is_xcode_python_path(&exe_str) { return None; } @@ -231,3 +230,230 @@ impl Locator for MacXCode { // } } } + +fn is_xcode_python_path(executable: &str) -> bool { + let Some(rest) = executable.strip_prefix("/Applications/") else { + return false; + }; + + let Some(app_bundle) = rest.split('/').next() else { + return false; + }; + + if !app_bundle.starts_with("Xcode") || !app_bundle.ends_with(".app") { + return false; + } + + let app_relative_path = &rest[app_bundle.len()..]; + if let Some(usr_bin_entry) = app_relative_path.strip_prefix("/Contents/Developer/usr/bin/") { + return is_macos_python_executable_name(usr_bin_entry) && !usr_bin_entry.contains('/'); + } + + let Some(framework_entry) = app_relative_path + .strip_prefix("/Contents/Developer/Library/Frameworks/Python3.framework/Versions/") + else { + return false; + }; + + let mut framework_parts = framework_entry.split('/'); + framework_parts + .next() + .is_some_and(is_macos_framework_version_dir) + && framework_parts.next() == Some("bin") + && framework_parts + .next() + .is_some_and(is_macos_python_executable_name) + && framework_parts.next().is_none() +} + +fn is_macos_python_executable_name(executable: &str) -> bool { + if executable == "python" || executable == "python3" { + return true; + } + + let Some(minor) = executable.strip_prefix("python3.") else { + return false; + }; + + !minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit()) +} + +fn is_macos_framework_version_dir(version: &str) -> bool { + if version == "Current" { + return true; + } + + let mut parts = version.split('.'); + parts + .next() + .is_some_and(|major| !major.is_empty() && major.chars().all(|ch| ch.is_ascii_digit())) + && parts + .next() + .is_some_and(|minor| !minor.is_empty() && minor.chars().all(|ch| ch.is_ascii_digit())) + && parts.next().is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + use pet_core::Locator; + + #[test] + fn locator_metadata_matches_xcode_kind() { + let locator = MacXCode::new(); + + assert_eq!(locator.get_kind(), LocatorKind::MacXCode); + assert_eq!( + locator.supported_categories(), + vec![PythonEnvironmentKind::MacXCode] + ); + } + + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_python() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn xcode_path_accepts_versioned_xcode_usr_bin_python() { + assert!(is_xcode_python_path( + "/Applications/Xcode_15.0.1.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_python_without_version() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python" + )); + } + + #[test] + fn xcode_path_accepts_default_xcode_usr_bin_versioned_python() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python3.12" + )); + } + + #[test] + fn xcode_path_accepts_framework_python_executable() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9" + )); + } + + #[test] + fn xcode_path_accepts_framework_python_executable_in_current_version_dir() { + assert!(is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Current/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_non_python_framework_path() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/Info.plist" + )); + } + + #[test] + fn xcode_path_rejects_python_config_script() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python-config" + )); + } + + #[test] + fn xcode_path_rejects_versioned_python_config_script() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9-config" + )); + } + + #[test] + fn xcode_path_rejects_framework_python_executable_in_invalid_version_dir() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/Foo/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_framework_python_executable_in_patch_version_dir() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9.0/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_multi_dot_python_executable_name() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/bin/python3.9.0" + )); + } + + #[test] + fn xcode_path_rejects_compact_python_version_name() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/python312" + )); + } + + #[test] + fn xcode_path_rejects_python_prefixed_tool() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/pythonfoo" + )); + } + + #[test] + fn xcode_path_rejects_unrelated_application_python() { + assert!(!is_xcode_python_path( + "/Applications/Other.app/Contents/MacOS/python3" + )); + } + + #[test] + fn xcode_path_rejects_other_application_developer_python() { + assert!(!is_xcode_python_path( + "/Applications/Other.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_developer_path_outside_applications() { + assert!(!is_xcode_python_path( + "/tmp/Xcode.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_nested_developer_layout() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Nested.app/Contents/Developer/usr/bin/python3" + )); + } + + #[test] + fn xcode_path_rejects_nested_usr_bin_entry() { + assert!(!is_xcode_python_path( + "/Applications/Xcode.app/Contents/Developer/usr/bin/nested/python3" + )); + } + + #[cfg(not(target_os = "macos"))] + #[test] + fn try_from_rejects_xcode_path_off_macos() { + let locator = MacXCode::new(); + let env = PythonEnv::new( + PathBuf::from("/Applications/Xcode.app/Contents/Developer/usr/bin/python3"), + Some(PathBuf::from( + "/Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9", + )), + Some("3.9.6".to_string()), + ); + + assert!(locator.try_from(&env).is_none()); + } +}