From cb6bfeae835ee1ea36b645609b385c9ee5ef3017 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 15 Apr 2026 20:20:39 -0700 Subject: [PATCH 1/7] Add resolve_venv utility supporting .venv file (PEP 832) --- crates/pet-fs/src/path.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 8019ed0d..8c2cfc25 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -334,6 +334,42 @@ fn get_user_home() -> Option { } } +/// Resolves the `.venv` entry in a directory to the virtual environment path. `.venv` may be either +/// - A **directory**: The virtual environment itself (traditional convention) +/// - A **file**: A text file containing the path (relative or absolute) to the virtual environment +/// located somewhere else in disk (PEP 832 convention) +/// +/// # Resolution order +/// 1. If `/.venv` is a directory, return that path. +/// 2. If `/.venv` is a file, read its contents, trim whitespaces, and resolve the path: +/// - If the path is absolute, return it. +/// - If the path is relative, resolve it against `` and return the absolute path. +/// - If the resolved path does not exist or is not a directory, return `None`. +/// 3. If `/.venv` does not exist, return `None`. +/// See: +pub fn resolve_dot_venv(dir: &Path) -> Option { + let dot_venv = dir.join(".venv"); + let meta = std::fs::symlink_metadata(&dot_venv).ok()?; + if meta.is_dir() { + Some(dot_venv) + } else if meta.is_file() { + let content = std::fs::read_to_string(&dot_venv).ok()?.trim().to_string(); + let path = PathBuf::from(content); + let resolved_path = if path.is_absolute() { + path + } else { + dir.join(path) + }; + if resolved_path.is_dir() { + Some(resolved_path) + } else { + None + } + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; From 96c0cffda6b756b5d4d99493d77cc7de63fcd3c5 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 15 Apr 2026 20:25:11 -0700 Subject: [PATCH 2/7] Replace .env search with resolve_dot_env --- crates/pet-fs/src/path.rs | 129 ++++++++++++++++++ .../pet-poetry/src/environment_locations.rs | 2 +- crates/pet-uv/src/lib.rs | 6 +- crates/pet/src/find.rs | 2 +- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 8c2cfc25..ae4add16 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -730,4 +730,133 @@ mod tests { let _ = std::fs::remove_file(&symlink_path); let _ = std::fs::remove_file(&target_file); } + + // ==================== resolve_dot_venv tests ==================== + + #[test] + fn test_resolve_dot_venv_no_dot_venv() { + // If .venv does not exist, return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_no_dot_venv"); + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_directory() { + // If .venv is a directory, return its path + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_dir"); + let dot_venv = test_dir.join(".venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&dot_venv).expect("Failed to create .venv dir"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(dot_venv)); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_absolute_path() { + // If .venv is a file containing an absolute path to an existing directory, return it + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_abs"); + let target_venv = temp_dir.join("pet_test_dot_venv_target_abs"); + + let _ = std::fs::remove_dir_all(&test_dir); + let _ = std::fs::remove_dir_all(&target_venv); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, target_venv.to_string_lossy().as_ref()) + .expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + let _ = std::fs::remove_dir_all(&target_venv); + } + + #[test] + fn test_resolve_dot_venv_file_relative_path() { + // If .venv is a file containing a relative path, resolve it against the dir + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_rel"); + let target_venv = test_dir.join("my_venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "my_venv").expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_nonexistent_target() { + // If .venv is a file pointing to a non-existent directory, return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_file_missing"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "/this/path/does/not/exist") + .expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_whitespace_trimmed() { + // Whitespace around the path should be trimmed + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_whitespace"); + let target_venv = test_dir.join("ws_venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, " ws_venv \n").expect("Failed to write .venv file"); + + let result = resolve_dot_venv(&test_dir); + assert_eq!(result, Some(target_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[test] + fn test_resolve_dot_venv_file_points_to_file_not_dir() { + // If .venv file points to a path that exists but is a file (not a dir), return None + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_target_is_file"); + let target_file = test_dir.join("not_a_dir"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + std::fs::write(&target_file, "I am a file").expect("Failed to create target file"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, "not_a_dir").expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } } diff --git a/crates/pet-poetry/src/environment_locations.rs b/crates/pet-poetry/src/environment_locations.rs index 98603354..37a7336c 100644 --- a/crates/pet-poetry/src/environment_locations.rs +++ b/crates/pet-poetry/src/environment_locations.rs @@ -108,7 +108,7 @@ fn list_all_environments_from_project_config( // Order of preference is Project (local config) > EnvVariable > Global if should_use_local_venv_as_poetry_env(global, &local, env) { // If virtualenvs are in the project, then look for .venv - let venv = path.join(".venv"); + let venv = resolve_dot_env(path); if venv.is_dir() { envs.push(venv); } diff --git a/crates/pet-uv/src/lib.rs b/crates/pet-uv/src/lib.rs index c415fd39..199dbef7 100644 --- a/crates/pet-uv/src/lib.rs +++ b/crates/pet-uv/src/lib.rs @@ -422,7 +422,7 @@ fn find_workspace_for_project(project_path: &Path) -> Option /// Builds a `PythonEnvironment` for a uv workspace root if it has a `.venv` with a valid /// uv-managed pyvenv.cfg. fn build_workspace_env(workspace_root: &Path) -> Option { - let prefix = workspace_root.join(".venv"); + let prefix = resolve_dot_env(workspace_root); let pyvenv_cfg = prefix.join("pyvenv.cfg"); if !pyvenv_cfg.exists() { trace!( @@ -465,8 +465,8 @@ fn list_envs_in_directory(path: &Path) -> Vec { let Some(pyproject) = pyproject else { return envs; }; - let pyvenv_cfg = path.join(".venv/pyvenv.cfg"); - let prefix = path.join(".venv"); + let pyvenv_cfg = resolve_dot_env(path).join("pyvenv.cfg"); + let prefix = resolve_dot_env(path); let unix_executable = prefix.join("bin/python"); let windows_executable = prefix.join("Scripts/python.exe"); let executable = if unix_executable.exists() { diff --git a/crates/pet/src/find.rs b/crates/pet/src/find.rs index 30d9a631..d340d5aa 100644 --- a/crates/pet/src/find.rs +++ b/crates/pet/src/find.rs @@ -277,7 +277,7 @@ pub fn find_python_environments_in_workspace_folder_recursive( // Possible this is a virtual env workspace_folder.to_path_buf(), // Optimize for finding these first. - workspace_folder.join(".venv"), + resolve_dot_env(workspace_folder), workspace_folder.join(".conda"), workspace_folder.join(".virtualenv"), workspace_folder.join("venv"), From 7a9e574265aa78c6e8657ae0ba720ab1bef96abd Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 15 Apr 2026 20:33:20 -0700 Subject: [PATCH 3/7] Fix missing imports --- crates/pet-poetry/src/environment_locations.rs | 3 ++- crates/pet-uv/src/lib.rs | 7 ++++--- crates/pet/src/find.rs | 3 ++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/pet-poetry/src/environment_locations.rs b/crates/pet-poetry/src/environment_locations.rs index 37a7336c..d5bec62c 100644 --- a/crates/pet-poetry/src/environment_locations.rs +++ b/crates/pet-poetry/src/environment_locations.rs @@ -6,6 +6,7 @@ use lazy_static::lazy_static; use log::trace; use pet_core::python_environment::PythonEnvironment; use pet_fs::path::norm_case; +use pet_fs::path::resolve_dot_venv; use regex::Regex; use sha2::{Digest, Sha256}; use std::{ @@ -108,7 +109,7 @@ fn list_all_environments_from_project_config( // Order of preference is Project (local config) > EnvVariable > Global if should_use_local_venv_as_poetry_env(global, &local, env) { // If virtualenvs are in the project, then look for .venv - let venv = resolve_dot_env(path); + let venv = resolve_dot_venv(path); if venv.is_dir() { envs.push(venv); } diff --git a/crates/pet-uv/src/lib.rs b/crates/pet-uv/src/lib.rs index 199dbef7..7f2f9938 100644 --- a/crates/pet-uv/src/lib.rs +++ b/crates/pet-uv/src/lib.rs @@ -15,6 +15,7 @@ use pet_core::{ Configuration, Locator, LocatorKind, RefreshStatePersistence, }; use pet_fs::path::norm_case; +use pet_fs::path::resolve_dot_venv; use pet_python_utils::executable::{find_executable, find_executables}; use serde::Deserialize; @@ -422,7 +423,7 @@ fn find_workspace_for_project(project_path: &Path) -> Option /// Builds a `PythonEnvironment` for a uv workspace root if it has a `.venv` with a valid /// uv-managed pyvenv.cfg. fn build_workspace_env(workspace_root: &Path) -> Option { - let prefix = resolve_dot_env(workspace_root); + let prefix = resolve_dot_venv(workspace_root); let pyvenv_cfg = prefix.join("pyvenv.cfg"); if !pyvenv_cfg.exists() { trace!( @@ -465,8 +466,8 @@ fn list_envs_in_directory(path: &Path) -> Vec { let Some(pyproject) = pyproject else { return envs; }; - let pyvenv_cfg = resolve_dot_env(path).join("pyvenv.cfg"); - let prefix = resolve_dot_env(path); + let pyvenv_cfg = resolve_dot_venv(path).join("pyvenv.cfg"); + let prefix = resolve_dot_venv(path); let unix_executable = prefix.join("bin/python"); let windows_executable = prefix.join("Scripts/python.exe"); let executable = if unix_executable.exists() { diff --git a/crates/pet/src/find.rs b/crates/pet/src/find.rs index d340d5aa..462372c6 100644 --- a/crates/pet/src/find.rs +++ b/crates/pet/src/find.rs @@ -9,6 +9,7 @@ use pet_core::python_environment::PythonEnvironmentKind; use pet_core::reporter::Reporter; use pet_core::{Configuration, Locator, LocatorKind}; use pet_env_var_path::get_search_paths_from_env_variables; +use pet_fs::path::resolve_dot_venv; use pet_global_virtualenvs::list_global_virtual_envs_paths; use pet_pixi::is_pixi_env; use pet_python_utils::executable::{ @@ -277,7 +278,7 @@ pub fn find_python_environments_in_workspace_folder_recursive( // Possible this is a virtual env workspace_folder.to_path_buf(), // Optimize for finding these first. - resolve_dot_env(workspace_folder), + resolve_dot_venv(workspace_folder), workspace_folder.join(".conda"), workspace_folder.join(".virtualenv"), workspace_folder.join("venv"), From 9d47e7fbb8ec6a2e73cf8c5f675abdd6dbd4365f Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 16 Apr 2026 10:31:27 -0700 Subject: [PATCH 4/7] Fix issues --- .../pet-poetry/src/environment_locations.rs | 4 +-- crates/pet-uv/src/lib.rs | 31 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/crates/pet-poetry/src/environment_locations.rs b/crates/pet-poetry/src/environment_locations.rs index d5bec62c..18870d3a 100644 --- a/crates/pet-poetry/src/environment_locations.rs +++ b/crates/pet-poetry/src/environment_locations.rs @@ -110,8 +110,8 @@ fn list_all_environments_from_project_config( if should_use_local_venv_as_poetry_env(global, &local, env) { // If virtualenvs are in the project, then look for .venv let venv = resolve_dot_venv(path); - if venv.is_dir() { - envs.push(venv); + if venv.clone()?.is_dir() { + envs.push(venv?); } } Some(envs) diff --git a/crates/pet-uv/src/lib.rs b/crates/pet-uv/src/lib.rs index 7f2f9938..7921140a 100644 --- a/crates/pet-uv/src/lib.rs +++ b/crates/pet-uv/src/lib.rs @@ -423,7 +423,7 @@ fn find_workspace_for_project(project_path: &Path) -> Option /// Builds a `PythonEnvironment` for a uv workspace root if it has a `.venv` with a valid /// uv-managed pyvenv.cfg. fn build_workspace_env(workspace_root: &Path) -> Option { - let prefix = resolve_dot_venv(workspace_root); + let prefix = resolve_dot_venv(workspace_root)?; let pyvenv_cfg = prefix.join("pyvenv.cfg"); if !pyvenv_cfg.exists() { trace!( @@ -466,17 +466,22 @@ fn list_envs_in_directory(path: &Path) -> Vec { let Some(pyproject) = pyproject else { return envs; }; - let pyvenv_cfg = resolve_dot_venv(path).join("pyvenv.cfg"); let prefix = resolve_dot_venv(path); - let unix_executable = prefix.join("bin/python"); - let windows_executable = prefix.join("Scripts/python.exe"); - let executable = if unix_executable.exists() { - Some(unix_executable) - } else if windows_executable.exists() { - Some(windows_executable) - } else { - None - }; + let uv_venv = prefix + .as_ref() + .map(|p| p.join("pyvenv.cfg")) + .and_then(|cfg| UvVenv::maybe_from_file(&cfg)); + let executable = prefix.as_ref().and_then(|p| { + let unix_executable = p.join("bin/python"); + let windows_executable = p.join("Scripts/python.exe"); + if unix_executable.exists() { + Some(unix_executable) + } else if windows_executable.exists() { + Some(windows_executable) + } else { + None + } + }); if pyproject .tool .and_then(|t| t.uv) @@ -484,7 +489,7 @@ fn list_envs_in_directory(path: &Path) -> Vec { .is_some() { trace!("Workspace found in {}", path.display()); - if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + if let (Some(uv_venv), Some(prefix)) = (uv_venv, prefix) { trace!("uv-managed venv found for workspace in {}", path.display()); let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::UvWorkspace)) .name(Some(uv_venv.prompt)) @@ -502,7 +507,7 @@ fn list_envs_in_directory(path: &Path) -> Vec { } // prioritize the workspace over the project if it's the same venv } else if let Some(project) = pyproject.project { - if let Some(uv_venv) = UvVenv::maybe_from_file(&pyvenv_cfg) { + if let (Some(uv_venv), Some(prefix)) = (uv_venv, prefix) { trace!("uv-managed venv found for project in {}", path.display()); let env = PythonEnvironmentBuilder::new(Some(PythonEnvironmentKind::Uv)) .name(Some(uv_venv.prompt)) From 5e66a8c8403e8ffc4add8548d92b95cca96f2f8e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 16 Apr 2026 11:29:39 -0700 Subject: [PATCH 5/7] fix: handle Option from resolve_dot_venv correctly - find.rs: resolve_dot_venv returns Option, use if-let to conditionally insert into search paths vec - environment_locations.rs: fix bug where ? operator on None would short-circuit the function, losing already-collected environments - path.rs: remove trailing whitespace in doc comments (fmt check) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/pet-fs/src/path.rs | 4 ++-- crates/pet-poetry/src/environment_locations.rs | 7 ++++--- crates/pet/src/find.rs | 6 ++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index ae4add16..b8aba4b7 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -336,9 +336,9 @@ fn get_user_home() -> Option { /// Resolves the `.venv` entry in a directory to the virtual environment path. `.venv` may be either /// - A **directory**: The virtual environment itself (traditional convention) -/// - A **file**: A text file containing the path (relative or absolute) to the virtual environment +/// - A **file**: A text file containing the path (relative or absolute) to the virtual environment /// located somewhere else in disk (PEP 832 convention) -/// +/// /// # Resolution order /// 1. If `/.venv` is a directory, return that path. /// 2. If `/.venv` is a file, read its contents, trim whitespaces, and resolve the path: diff --git a/crates/pet-poetry/src/environment_locations.rs b/crates/pet-poetry/src/environment_locations.rs index 18870d3a..5d32962c 100644 --- a/crates/pet-poetry/src/environment_locations.rs +++ b/crates/pet-poetry/src/environment_locations.rs @@ -109,9 +109,10 @@ fn list_all_environments_from_project_config( // Order of preference is Project (local config) > EnvVariable > Global if should_use_local_venv_as_poetry_env(global, &local, env) { // If virtualenvs are in the project, then look for .venv - let venv = resolve_dot_venv(path); - if venv.clone()?.is_dir() { - envs.push(venv?); + if let Some(venv) = resolve_dot_venv(path) { + if venv.is_dir() { + envs.push(venv); + } } } Some(envs) diff --git a/crates/pet/src/find.rs b/crates/pet/src/find.rs index 462372c6..5ca8e669 100644 --- a/crates/pet/src/find.rs +++ b/crates/pet/src/find.rs @@ -277,12 +277,14 @@ pub fn find_python_environments_in_workspace_folder_recursive( let mut paths_to_search_first = vec![ // Possible this is a virtual env workspace_folder.to_path_buf(), - // Optimize for finding these first. - resolve_dot_venv(workspace_folder), workspace_folder.join(".conda"), workspace_folder.join(".virtualenv"), workspace_folder.join("venv"), ]; + // Optimize for finding .venv first (supports PEP 832 file-based .venv). + if let Some(dot_venv) = resolve_dot_venv(workspace_folder) { + paths_to_search_first.insert(1, dot_venv); + } // Add all subdirectories of .pixi/envs/** if let Ok(reader) = fs::read_dir(workspace_folder.join(".pixi").join("envs")) { From 427439ccbf5b1f1529675d66cf88b9c7a398a0de Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 16 Apr 2026 11:31:50 -0700 Subject: [PATCH 6/7] fix: add blank doc line before See link to fix clippy doc_lazy_continuation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/pet-fs/src/path.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index b8aba4b7..4241e47e 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -346,6 +346,7 @@ fn get_user_home() -> Option { /// - If the path is relative, resolve it against `` and return the absolute path. /// - If the resolved path does not exist or is not a directory, return `None`. /// 3. If `/.venv` does not exist, return `None`. +/// /// See: pub fn resolve_dot_venv(dir: &Path) -> Option { let dot_venv = dir.join(".venv"); From 7020c0d2159229087a3ac1ab4ce4b2279e9ad917 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Fri, 24 Apr 2026 17:15:52 -0700 Subject: [PATCH 7/7] Address PR feedback: fix symlink handling, empty .venv guard, doc accuracy - Use fs::metadata instead of symlink_metadata to follow symlinks, so .venv symlinks to directories are correctly treated as directories - Add empty-string guard after trimming .venv file content to prevent whitespace-only files from resolving to the project directory - Fix doc comment to accurately describe that relative paths are not canonicalized - Add tests for whitespace-only .venv file and symlink-to-directory cases Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/pet-fs/src/path.rs | 45 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 4241e47e..6411ebe6 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -343,18 +343,23 @@ fn get_user_home() -> Option { /// 1. If `/.venv` is a directory, return that path. /// 2. If `/.venv` is a file, read its contents, trim whitespaces, and resolve the path: /// - If the path is absolute, return it. -/// - If the path is relative, resolve it against `` and return the absolute path. +/// - If the path is relative, resolve it against `` using `dir.join(path)`. +/// The returned path is not further canonicalized, so it may remain relative if `` +/// is relative and may preserve `.` or `..` components. /// - If the resolved path does not exist or is not a directory, return `None`. /// 3. If `/.venv` does not exist, return `None`. /// /// See: pub fn resolve_dot_venv(dir: &Path) -> Option { let dot_venv = dir.join(".venv"); - let meta = std::fs::symlink_metadata(&dot_venv).ok()?; + let meta = std::fs::metadata(&dot_venv).ok()?; if meta.is_dir() { Some(dot_venv) } else if meta.is_file() { let content = std::fs::read_to_string(&dot_venv).ok()?.trim().to_string(); + if content.is_empty() { + return None; + } let path = PathBuf::from(content); let resolved_path = if path.is_absolute() { path @@ -842,6 +847,42 @@ mod tests { let _ = std::fs::remove_dir_all(&test_dir); } + #[test] + fn test_resolve_dot_venv_file_whitespace_only_returns_none() { + // A whitespace-only .venv file should not resolve to the project directory + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_whitespace_only"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).expect("Failed to create test dir"); + + let dot_venv_file = test_dir.join(".venv"); + std::fs::write(&dot_venv_file, " \n\t ").expect("Failed to write .venv file"); + + assert_eq!(resolve_dot_venv(&test_dir), None); + + let _ = std::fs::remove_dir_all(&test_dir); + } + + #[cfg(unix)] + #[test] + fn test_resolve_dot_venv_symlink_to_directory() { + // A .venv symlink to a directory should be treated like a directory + let temp_dir = std::env::temp_dir(); + let test_dir = temp_dir.join("pet_test_dot_venv_symlink_dir"); + let target_venv = test_dir.join("actual_venv"); + let dot_venv = test_dir.join(".venv"); + + let _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&target_venv).expect("Failed to create target venv dir"); + std::os::unix::fs::symlink(&target_venv, &dot_venv) + .expect("Failed to create .venv symlink"); + + assert_eq!(resolve_dot_venv(&test_dir), Some(dot_venv.clone())); + + let _ = std::fs::remove_dir_all(&test_dir); + } + #[test] fn test_resolve_dot_venv_file_points_to_file_not_dir() { // If .venv file points to a path that exists but is a file (not a dir), return None