Skip to content

Commit 3488eb1

Browse files
authored
Add symlink resolution and version extraction for Homebrew Poetry (#342)
Fixes #137 Implement symlink resolution to retrieve the actual file path for Homebrew-installed Poetry and extract its version from the resolved path. This enhances compatibility with Homebrew's symlink structure.
1 parent f6a4b18 commit 3488eb1

File tree

2 files changed

+257
-5
lines changed

2 files changed

+257
-5
lines changed

crates/pet-fs/src/path.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,33 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
200200
Some(PathBuf::from(result_str))
201201
}
202202

203+
/// Resolves any symlink to its real file path without filtering.
204+
///
205+
/// Returns `None` if the path is not a symlink or cannot be resolved.
206+
/// If the real file equals the input, returns `None` (the path is not a symlink).
207+
///
208+
/// # Use Cases
209+
/// - Resolving Homebrew symlinks for tools like Poetry: `/opt/homebrew/bin/poetry` → Cellar path
210+
/// - Generic symlink resolution where no filename filtering is needed
211+
///
212+
/// # Related
213+
/// - `resolve_symlink()` - Filtered version for Python/Conda executables only
214+
pub fn resolve_any_symlink<T: AsRef<Path>>(path: &T) -> Option<PathBuf> {
215+
let metadata = std::fs::symlink_metadata(path).ok()?;
216+
if metadata.is_file() || !metadata.file_type().is_symlink() {
217+
return None;
218+
}
219+
if let Ok(readlink) = std::fs::canonicalize(path) {
220+
if readlink == path.as_ref().to_path_buf() {
221+
None
222+
} else {
223+
Some(readlink)
224+
}
225+
} else {
226+
None
227+
}
228+
}
229+
203230
/// Resolves a symlink to its real file path.
204231
///
205232
/// Returns `None` if the path is not a symlink or cannot be resolved.
@@ -217,6 +244,7 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
217244
///
218245
/// # Related
219246
/// - `norm_case()` - Normalizes path case without resolving symlinks
247+
/// - `resolve_any_symlink()` - Unfiltered version for any symlink
220248
pub fn resolve_symlink<T: AsRef<Path>>(exe: &T) -> Option<PathBuf> {
221249
let name = exe.as_ref().file_name()?.to_string_lossy();
222250
// In bin directory of homebrew, we have files like python-build, python-config, python3-config
@@ -590,4 +618,80 @@ mod tests {
590618
result
591619
);
592620
}
621+
622+
// ==================== resolve_any_symlink tests ====================
623+
624+
#[test]
625+
fn test_resolve_any_symlink_nonexistent_path() {
626+
// Non-existent paths should return None
627+
let nonexistent = PathBuf::from("/this/path/does/not/exist/anywhere");
628+
assert_eq!(resolve_any_symlink(&nonexistent), None);
629+
}
630+
631+
#[test]
632+
fn test_resolve_any_symlink_regular_file() {
633+
// Regular files (not symlinks) should return None
634+
use std::io::Write;
635+
let temp_dir = std::env::temp_dir();
636+
let test_file = temp_dir.join("pet_test_regular_file.txt");
637+
638+
// Create a regular file
639+
let mut file = std::fs::File::create(&test_file).expect("Failed to create test file");
640+
file.write_all(b"test").expect("Failed to write test file");
641+
642+
// resolve_any_symlink should return None for regular files
643+
assert_eq!(resolve_any_symlink(&test_file), None);
644+
645+
// Clean up
646+
let _ = std::fs::remove_file(&test_file);
647+
}
648+
649+
#[test]
650+
fn test_resolve_any_symlink_directory() {
651+
// Directories (not symlinks) should return None
652+
let temp_dir = std::env::temp_dir();
653+
let test_dir = temp_dir.join("pet_test_regular_dir");
654+
655+
// Create a regular directory
656+
let _ = std::fs::create_dir(&test_dir);
657+
658+
// resolve_any_symlink should return None for regular directories
659+
assert_eq!(resolve_any_symlink(&test_dir), None);
660+
661+
// Clean up
662+
let _ = std::fs::remove_dir(&test_dir);
663+
}
664+
665+
#[test]
666+
#[cfg(unix)]
667+
fn test_resolve_any_symlink_unix_symlink() {
668+
use std::os::unix::fs::symlink;
669+
670+
let temp_dir = std::env::temp_dir();
671+
let target_file = temp_dir.join("pet_test_symlink_target.txt");
672+
let symlink_path = temp_dir.join("pet_test_symlink.txt");
673+
674+
// Clean up any existing test files
675+
let _ = std::fs::remove_file(&target_file);
676+
let _ = std::fs::remove_file(&symlink_path);
677+
678+
// Create target file
679+
std::fs::write(&target_file, "test").expect("Failed to create target file");
680+
681+
// Create symlink
682+
symlink(&target_file, &symlink_path).expect("Failed to create symlink");
683+
684+
// resolve_any_symlink should return the target path
685+
let result = resolve_any_symlink(&symlink_path);
686+
assert!(result.is_some(), "Should resolve symlink");
687+
688+
let resolved = result.unwrap();
689+
// The resolved path should be canonicalized, so compare canonical forms
690+
let expected = std::fs::canonicalize(&target_file).unwrap();
691+
assert_eq!(resolved, expected);
692+
693+
// Clean up
694+
let _ = std::fs::remove_file(&symlink_path);
695+
let _ = std::fs::remove_file(&target_file);
696+
}
593697
}

crates/pet-poetry/src/manager.rs

Lines changed: 153 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,36 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
use lazy_static::lazy_static;
45
use log::trace;
56
use pet_core::manager::{EnvManager, EnvManagerType};
7+
use pet_fs::path::resolve_any_symlink;
8+
use regex::Regex;
69
use std::{env, path::PathBuf};
710

811
use crate::env_variables::EnvVariables;
912

13+
lazy_static! {
14+
/// Matches Homebrew Cellar path for poetry: /Cellar/poetry/X.Y.Z or /Cellar/poetry/X.Y.Z_N
15+
static ref HOMEBREW_POETRY_VERSION: Regex =
16+
Regex::new(r"/Cellar/poetry/(\d+\.\d+\.\d+)").expect("error parsing Homebrew poetry version regex");
17+
}
18+
1019
#[derive(Clone, PartialEq, Eq, Debug)]
1120
pub struct PoetryManager {
1221
pub executable: PathBuf,
22+
pub version: Option<String>,
1323
}
1424

1525
impl PoetryManager {
1626
pub fn find(executable: Option<PathBuf>, env_variables: &EnvVariables) -> Option<Self> {
1727
if let Some(executable) = executable {
1828
if executable.is_file() {
19-
return Some(PoetryManager { executable });
29+
let version = Self::extract_version_from_path(&executable);
30+
return Some(PoetryManager {
31+
executable,
32+
version,
33+
});
2034
}
2135
}
2236

@@ -107,7 +121,11 @@ impl PoetryManager {
107121
}
108122
for executable in search_paths {
109123
if executable.is_file() {
110-
return Some(PoetryManager { executable });
124+
let version = Self::extract_version_from_path(&executable);
125+
return Some(PoetryManager {
126+
executable,
127+
version,
128+
});
111129
}
112130
}
113131

@@ -116,12 +134,20 @@ impl PoetryManager {
116134
for each in env::split_paths(env_path) {
117135
let executable = each.join("poetry");
118136
if executable.is_file() {
119-
return Some(PoetryManager { executable });
137+
let version = Self::extract_version_from_path(&executable);
138+
return Some(PoetryManager {
139+
executable,
140+
version,
141+
});
120142
}
121143
if std::env::consts::OS == "windows" {
122144
let executable = each.join("poetry.exe");
123145
if executable.is_file() {
124-
return Some(PoetryManager { executable });
146+
let version = Self::extract_version_from_path(&executable);
147+
return Some(PoetryManager {
148+
executable,
149+
version,
150+
});
125151
}
126152
}
127153
}
@@ -130,11 +156,133 @@ impl PoetryManager {
130156
trace!("Poetry exe not found");
131157
None
132158
}
159+
160+
/// Extracts poetry version from Homebrew Cellar path.
161+
///
162+
/// Homebrew installs poetry to paths like:
163+
/// - macOS ARM: /opt/homebrew/Cellar/poetry/1.8.3_2/bin/poetry
164+
/// - macOS Intel: /usr/local/Cellar/poetry/1.8.3/bin/poetry
165+
/// - Linux: /home/linuxbrew/.linuxbrew/Cellar/poetry/1.8.3/bin/poetry
166+
///
167+
/// The symlink at /opt/homebrew/bin/poetry points to the Cellar path.
168+
fn extract_version_from_path(executable: &PathBuf) -> Option<String> {
169+
// First try to resolve the symlink to get the actual Cellar path
170+
let resolved = resolve_any_symlink(executable).unwrap_or_else(|| executable.clone());
171+
let path_str = resolved.to_string_lossy();
172+
173+
// Check if this is a Homebrew Cellar path and extract version
174+
if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(&path_str) {
175+
if let Some(version_match) = captures.get(1) {
176+
let version = version_match.as_str().to_string();
177+
trace!(
178+
"Extracted Poetry version {} from Homebrew path: {:?}",
179+
version,
180+
resolved
181+
);
182+
return Some(version);
183+
}
184+
}
185+
None
186+
}
187+
133188
pub fn to_manager(&self) -> EnvManager {
134189
EnvManager {
135190
executable: self.executable.clone(),
136-
version: None,
191+
version: self.version.clone(),
137192
tool: EnvManagerType::Poetry,
138193
}
139194
}
195+
196+
/// Extracts version from a path string using the Homebrew Cellar regex.
197+
/// This is exposed for testing purposes.
198+
#[cfg(test)]
199+
fn extract_version_from_path_str(path_str: &str) -> Option<String> {
200+
if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(path_str) {
201+
captures.get(1).map(|m| m.as_str().to_string())
202+
} else {
203+
None
204+
}
205+
}
206+
}
207+
208+
#[cfg(test)]
209+
mod tests {
210+
use super::*;
211+
212+
#[test]
213+
fn test_extract_version_macos_arm() {
214+
// macOS ARM Homebrew path
215+
let path = "/opt/homebrew/Cellar/poetry/1.8.3/bin/poetry";
216+
assert_eq!(
217+
PoetryManager::extract_version_from_path_str(path),
218+
Some("1.8.3".to_string())
219+
);
220+
}
221+
222+
#[test]
223+
fn test_extract_version_macos_arm_with_revision() {
224+
// macOS ARM Homebrew path with revision suffix
225+
let path = "/opt/homebrew/Cellar/poetry/1.8.3_2/bin/poetry";
226+
assert_eq!(
227+
PoetryManager::extract_version_from_path_str(path),
228+
Some("1.8.3".to_string())
229+
);
230+
}
231+
232+
#[test]
233+
fn test_extract_version_macos_intel() {
234+
// macOS Intel Homebrew path
235+
let path = "/usr/local/Cellar/poetry/2.0.1/bin/poetry";
236+
assert_eq!(
237+
PoetryManager::extract_version_from_path_str(path),
238+
Some("2.0.1".to_string())
239+
);
240+
}
241+
242+
#[test]
243+
fn test_extract_version_linux() {
244+
// Linux Homebrew path
245+
let path = "/home/linuxbrew/.linuxbrew/Cellar/poetry/1.7.0/bin/poetry";
246+
assert_eq!(
247+
PoetryManager::extract_version_from_path_str(path),
248+
Some("1.7.0".to_string())
249+
);
250+
}
251+
252+
#[test]
253+
fn test_extract_version_non_homebrew_path() {
254+
// Non-Homebrew installation paths should return None
255+
let paths = [
256+
"/usr/local/bin/poetry",
257+
"/home/user/.local/bin/poetry",
258+
"/home/user/.poetry/bin/poetry",
259+
"C:\\Users\\user\\AppData\\Roaming\\pypoetry\\venv\\Scripts\\poetry.exe",
260+
];
261+
for path in paths {
262+
assert_eq!(
263+
PoetryManager::extract_version_from_path_str(path),
264+
None,
265+
"Expected None for path: {}",
266+
path
267+
);
268+
}
269+
}
270+
271+
#[test]
272+
fn test_extract_version_invalid_version_format() {
273+
// Invalid version formats should not match
274+
let paths = [
275+
"/opt/homebrew/Cellar/poetry/invalid/bin/poetry",
276+
"/opt/homebrew/Cellar/poetry/1.8/bin/poetry", // Missing patch version
277+
"/opt/homebrew/Cellar/poetry/v1.8.3/bin/poetry", // Has 'v' prefix
278+
];
279+
for path in paths {
280+
assert_eq!(
281+
PoetryManager::extract_version_from_path_str(path),
282+
None,
283+
"Expected None for path: {}",
284+
path
285+
);
286+
}
287+
}
140288
}

0 commit comments

Comments
 (0)