From 82590d66483fc8f86529a3bca1a3376686afa0fd Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 5 Feb 2026 16:33:55 -0800 Subject: [PATCH 1/2] feat: add symlink resolution and version extraction for Homebrew Poetry --- crates/pet-fs/src/path.rs | 28 ++++++++++++++++++ crates/pet-poetry/src/manager.rs | 49 ++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index c4ead747..88d29d5d 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -200,6 +200,33 @@ fn normalize_case_windows(path: &Path) -> Option { Some(PathBuf::from(result_str)) } +/// Resolves any symlink to its real file path without filtering. +/// +/// Returns `None` if the path is not a symlink or cannot be resolved. +/// If the real file equals the input, returns `None` (the path is not a symlink). +/// +/// # Use Cases +/// - Resolving Homebrew symlinks for tools like Poetry: `/opt/homebrew/bin/poetry` → Cellar path +/// - Generic symlink resolution where no filename filtering is needed +/// +/// # Related +/// - `resolve_symlink()` - Filtered version for Python/Conda executables only +pub fn resolve_any_symlink>(path: &T) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if metadata.is_file() || !metadata.file_type().is_symlink() { + return None; + } + if let Ok(readlink) = std::fs::canonicalize(path) { + if readlink == path.as_ref().to_path_buf() { + None + } else { + Some(readlink) + } + } else { + None + } +} + /// Resolves a symlink to its real file path. /// /// Returns `None` if the path is not a symlink or cannot be resolved. @@ -217,6 +244,7 @@ fn normalize_case_windows(path: &Path) -> Option { /// /// # Related /// - `norm_case()` - Normalizes path case without resolving symlinks +/// - `resolve_any_symlink()` - Unfiltered version for any symlink pub fn resolve_symlink>(exe: &T) -> Option { let name = exe.as_ref().file_name()?.to_string_lossy(); // In bin directory of homebrew, we have files like python-build, python-config, python3-config diff --git a/crates/pet-poetry/src/manager.rs b/crates/pet-poetry/src/manager.rs index ba0b02bd..70810d32 100644 --- a/crates/pet-poetry/src/manager.rs +++ b/crates/pet-poetry/src/manager.rs @@ -1,22 +1,33 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use lazy_static::lazy_static; use log::trace; use pet_core::manager::{EnvManager, EnvManagerType}; +use pet_fs::path::resolve_any_symlink; +use regex::Regex; use std::{env, path::PathBuf}; use crate::env_variables::EnvVariables; +lazy_static! { + /// Matches Homebrew Cellar path for poetry: /Cellar/poetry/X.Y.Z or /Cellar/poetry/X.Y.Z_N + static ref HOMEBREW_POETRY_VERSION: Regex = + Regex::new(r"/Cellar/poetry/(\d+\.\d+\.\d+)").expect("error parsing Homebrew poetry version regex"); +} + #[derive(Clone, PartialEq, Eq, Debug)] pub struct PoetryManager { pub executable: PathBuf, + pub version: Option, } impl PoetryManager { pub fn find(executable: Option, env_variables: &EnvVariables) -> Option { if let Some(executable) = executable { if executable.is_file() { - return Some(PoetryManager { executable }); + let version = Self::extract_version_from_path(&executable); + return Some(PoetryManager { executable, version }); } } @@ -107,7 +118,8 @@ impl PoetryManager { } for executable in search_paths { if executable.is_file() { - return Some(PoetryManager { executable }); + let version = Self::extract_version_from_path(&executable); + return Some(PoetryManager { executable, version }); } } @@ -116,12 +128,14 @@ impl PoetryManager { for each in env::split_paths(env_path) { let executable = each.join("poetry"); if executable.is_file() { - return Some(PoetryManager { executable }); + let version = Self::extract_version_from_path(&executable); + return Some(PoetryManager { executable, version }); } if std::env::consts::OS == "windows" { let executable = each.join("poetry.exe"); if executable.is_file() { - return Some(PoetryManager { executable }); + let version = Self::extract_version_from_path(&executable); + return Some(PoetryManager { executable, version }); } } } @@ -130,10 +144,35 @@ impl PoetryManager { trace!("Poetry exe not found"); None } + + /// Extracts poetry version from Homebrew Cellar path. + /// + /// Homebrew installs poetry to paths like: + /// - macOS ARM: /opt/homebrew/Cellar/poetry/1.8.3_2/bin/poetry + /// - macOS Intel: /usr/local/Cellar/poetry/1.8.3/bin/poetry + /// - Linux: /home/linuxbrew/.linuxbrew/Cellar/poetry/1.8.3/bin/poetry + /// + /// The symlink at /opt/homebrew/bin/poetry points to the Cellar path. + fn extract_version_from_path(executable: &PathBuf) -> Option { + // First try to resolve the symlink to get the actual Cellar path + let resolved = resolve_any_symlink(executable).unwrap_or_else(|| executable.clone()); + let path_str = resolved.to_string_lossy(); + + // Check if this is a Homebrew Cellar path and extract version + if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(&path_str) { + if let Some(version_match) = captures.get(1) { + let version = version_match.as_str().to_string(); + trace!("Extracted Poetry version {} from Homebrew path: {:?}", version, resolved); + return Some(version); + } + } + None + } + pub fn to_manager(&self) -> EnvManager { EnvManager { executable: self.executable.clone(), - version: None, + version: self.version.clone(), tool: EnvManagerType::Poetry, } } From eaa862528658371f0b17c592355db7ff6b9d48ba Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Thu, 5 Feb 2026 16:56:15 -0800 Subject: [PATCH 2/2] tests: add tests for resolving symlinks and extracting Poetry version from Homebrew paths --- crates/pet-fs/src/path.rs | 76 ++++++++++++++++++++ crates/pet-poetry/src/manager.rs | 119 +++++++++++++++++++++++++++++-- 2 files changed, 190 insertions(+), 5 deletions(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 88d29d5d..8019ed0d 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -618,4 +618,80 @@ mod tests { result ); } + + // ==================== resolve_any_symlink tests ==================== + + #[test] + fn test_resolve_any_symlink_nonexistent_path() { + // Non-existent paths should return None + let nonexistent = PathBuf::from("/this/path/does/not/exist/anywhere"); + assert_eq!(resolve_any_symlink(&nonexistent), None); + } + + #[test] + fn test_resolve_any_symlink_regular_file() { + // Regular files (not symlinks) should return None + use std::io::Write; + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("pet_test_regular_file.txt"); + + // Create a regular file + let mut file = std::fs::File::create(&test_file).expect("Failed to create test file"); + file.write_all(b"test").expect("Failed to write test file"); + + // resolve_any_symlink should return None for regular files + assert_eq!(resolve_any_symlink(&test_file), None); + + // Clean up + let _ = std::fs::remove_file(&test_file); + } + + #[test] + fn test_resolve_any_symlink_directory() { + // Directories (not symlinks) should return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_regular_dir"); + + // Create a regular directory + let _ = std::fs::create_dir(&test_dir); + + // resolve_any_symlink should return None for regular directories + assert_eq!(resolve_any_symlink(&test_dir), None); + + // Clean up + let _ = std::fs::remove_dir(&test_dir); + } + + #[test] + #[cfg(unix)] + fn test_resolve_any_symlink_unix_symlink() { + use std::os::unix::fs::symlink; + + let temp_dir = std::env::temp_dir(); + let target_file = temp_dir.join("pet_test_symlink_target.txt"); + let symlink_path = temp_dir.join("pet_test_symlink.txt"); + + // Clean up any existing test files + let _ = std::fs::remove_file(&target_file); + let _ = std::fs::remove_file(&symlink_path); + + // Create target file + std::fs::write(&target_file, "test").expect("Failed to create target file"); + + // Create symlink + symlink(&target_file, &symlink_path).expect("Failed to create symlink"); + + // resolve_any_symlink should return the target path + let result = resolve_any_symlink(&symlink_path); + assert!(result.is_some(), "Should resolve symlink"); + + let resolved = result.unwrap(); + // The resolved path should be canonicalized, so compare canonical forms + let expected = std::fs::canonicalize(&target_file).unwrap(); + assert_eq!(resolved, expected); + + // Clean up + let _ = std::fs::remove_file(&symlink_path); + let _ = std::fs::remove_file(&target_file); + } } diff --git a/crates/pet-poetry/src/manager.rs b/crates/pet-poetry/src/manager.rs index 70810d32..506418d8 100644 --- a/crates/pet-poetry/src/manager.rs +++ b/crates/pet-poetry/src/manager.rs @@ -27,7 +27,10 @@ impl PoetryManager { if let Some(executable) = executable { if executable.is_file() { let version = Self::extract_version_from_path(&executable); - return Some(PoetryManager { executable, version }); + return Some(PoetryManager { + executable, + version, + }); } } @@ -119,7 +122,10 @@ impl PoetryManager { for executable in search_paths { if executable.is_file() { let version = Self::extract_version_from_path(&executable); - return Some(PoetryManager { executable, version }); + return Some(PoetryManager { + executable, + version, + }); } } @@ -129,13 +135,19 @@ impl PoetryManager { let executable = each.join("poetry"); if executable.is_file() { let version = Self::extract_version_from_path(&executable); - return Some(PoetryManager { executable, version }); + return Some(PoetryManager { + executable, + version, + }); } if std::env::consts::OS == "windows" { let executable = each.join("poetry.exe"); if executable.is_file() { let version = Self::extract_version_from_path(&executable); - return Some(PoetryManager { executable, version }); + return Some(PoetryManager { + executable, + version, + }); } } } @@ -162,7 +174,11 @@ impl PoetryManager { if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(&path_str) { if let Some(version_match) = captures.get(1) { let version = version_match.as_str().to_string(); - trace!("Extracted Poetry version {} from Homebrew path: {:?}", version, resolved); + trace!( + "Extracted Poetry version {} from Homebrew path: {:?}", + version, + resolved + ); return Some(version); } } @@ -176,4 +192,97 @@ impl PoetryManager { tool: EnvManagerType::Poetry, } } + + /// Extracts version from a path string using the Homebrew Cellar regex. + /// This is exposed for testing purposes. + #[cfg(test)] + fn extract_version_from_path_str(path_str: &str) -> Option { + if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(path_str) { + captures.get(1).map(|m| m.as_str().to_string()) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_version_macos_arm() { + // macOS ARM Homebrew path + let path = "/opt/homebrew/Cellar/poetry/1.8.3/bin/poetry"; + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + Some("1.8.3".to_string()) + ); + } + + #[test] + fn test_extract_version_macos_arm_with_revision() { + // macOS ARM Homebrew path with revision suffix + let path = "/opt/homebrew/Cellar/poetry/1.8.3_2/bin/poetry"; + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + Some("1.8.3".to_string()) + ); + } + + #[test] + fn test_extract_version_macos_intel() { + // macOS Intel Homebrew path + let path = "/usr/local/Cellar/poetry/2.0.1/bin/poetry"; + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + Some("2.0.1".to_string()) + ); + } + + #[test] + fn test_extract_version_linux() { + // Linux Homebrew path + let path = "/home/linuxbrew/.linuxbrew/Cellar/poetry/1.7.0/bin/poetry"; + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + Some("1.7.0".to_string()) + ); + } + + #[test] + fn test_extract_version_non_homebrew_path() { + // Non-Homebrew installation paths should return None + let paths = [ + "/usr/local/bin/poetry", + "/home/user/.local/bin/poetry", + "/home/user/.poetry/bin/poetry", + "C:\\Users\\user\\AppData\\Roaming\\pypoetry\\venv\\Scripts\\poetry.exe", + ]; + for path in paths { + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + None, + "Expected None for path: {}", + path + ); + } + } + + #[test] + fn test_extract_version_invalid_version_format() { + // Invalid version formats should not match + let paths = [ + "/opt/homebrew/Cellar/poetry/invalid/bin/poetry", + "/opt/homebrew/Cellar/poetry/1.8/bin/poetry", // Missing patch version + "/opt/homebrew/Cellar/poetry/v1.8.3/bin/poetry", // Has 'v' prefix + ]; + for path in paths { + assert_eq!( + PoetryManager::extract_version_from_path_str(path), + None, + "Expected None for path: {}", + path + ); + } + } }