Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions crates/pet-fs/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,33 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
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<T: AsRef<Path>>(path: &T) -> Option<PathBuf> {
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.
Expand All @@ -217,6 +244,7 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
///
/// # Related
/// - `norm_case()` - Normalizes path case without resolving symlinks
/// - `resolve_any_symlink()` - Unfiltered version for any symlink
pub fn resolve_symlink<T: AsRef<Path>>(exe: &T) -> Option<PathBuf> {
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
Expand Down Expand Up @@ -590,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);
}
}
158 changes: 153 additions & 5 deletions crates/pet-poetry/src/manager.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,36 @@
// 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<String>,
}

impl PoetryManager {
pub fn find(executable: Option<PathBuf>, env_variables: &EnvVariables) -> Option<Self> {
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,
});
}
}

Expand Down Expand Up @@ -107,7 +121,11 @@ 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,
});
}
}

Expand All @@ -116,12 +134,20 @@ 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,
});
}
}
}
Expand All @@ -130,11 +156,133 @@ 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<String> {
// 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,
}
}

/// 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<String> {
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
);
}
}
}
Loading