Skip to content

Commit 82590d6

Browse files
committed
feat: add symlink resolution and version extraction for Homebrew Poetry
1 parent f6a4b18 commit 82590d6

File tree

2 files changed

+72
-5
lines changed

2 files changed

+72
-5
lines changed

crates/pet-fs/src/path.rs

Lines changed: 28 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

crates/pet-poetry/src/manager.rs

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
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 { executable, version });
2031
}
2132
}
2233

@@ -107,7 +118,8 @@ impl PoetryManager {
107118
}
108119
for executable in search_paths {
109120
if executable.is_file() {
110-
return Some(PoetryManager { executable });
121+
let version = Self::extract_version_from_path(&executable);
122+
return Some(PoetryManager { executable, version });
111123
}
112124
}
113125

@@ -116,12 +128,14 @@ impl PoetryManager {
116128
for each in env::split_paths(env_path) {
117129
let executable = each.join("poetry");
118130
if executable.is_file() {
119-
return Some(PoetryManager { executable });
131+
let version = Self::extract_version_from_path(&executable);
132+
return Some(PoetryManager { executable, version });
120133
}
121134
if std::env::consts::OS == "windows" {
122135
let executable = each.join("poetry.exe");
123136
if executable.is_file() {
124-
return Some(PoetryManager { executable });
137+
let version = Self::extract_version_from_path(&executable);
138+
return Some(PoetryManager { executable, version });
125139
}
126140
}
127141
}
@@ -130,10 +144,35 @@ impl PoetryManager {
130144
trace!("Poetry exe not found");
131145
None
132146
}
147+
148+
/// Extracts poetry version from Homebrew Cellar path.
149+
///
150+
/// Homebrew installs poetry to paths like:
151+
/// - macOS ARM: /opt/homebrew/Cellar/poetry/1.8.3_2/bin/poetry
152+
/// - macOS Intel: /usr/local/Cellar/poetry/1.8.3/bin/poetry
153+
/// - Linux: /home/linuxbrew/.linuxbrew/Cellar/poetry/1.8.3/bin/poetry
154+
///
155+
/// The symlink at /opt/homebrew/bin/poetry points to the Cellar path.
156+
fn extract_version_from_path(executable: &PathBuf) -> Option<String> {
157+
// First try to resolve the symlink to get the actual Cellar path
158+
let resolved = resolve_any_symlink(executable).unwrap_or_else(|| executable.clone());
159+
let path_str = resolved.to_string_lossy();
160+
161+
// Check if this is a Homebrew Cellar path and extract version
162+
if let Some(captures) = HOMEBREW_POETRY_VERSION.captures(&path_str) {
163+
if let Some(version_match) = captures.get(1) {
164+
let version = version_match.as_str().to_string();
165+
trace!("Extracted Poetry version {} from Homebrew path: {:?}", version, resolved);
166+
return Some(version);
167+
}
168+
}
169+
None
170+
}
171+
133172
pub fn to_manager(&self) -> EnvManager {
134173
EnvManager {
135174
executable: self.executable.clone(),
136-
version: None,
175+
version: self.version.clone(),
137176
tool: EnvManagerType::Poetry,
138177
}
139178
}

0 commit comments

Comments
 (0)