Skip to content

Commit 9e0ef7a

Browse files
authored
Improve symlink directory detection in environment finder (#328)
Fixes #196 Enhance the detection of symlinked directories in the environment finder by using `path().is_dir()` instead of `file_type().is_dir()`. This change addresses an issue where symlinked virtual environments were not recognized on Unix systems. Additional tests ensure that both symlinked and regular directories are correctly detected.
1 parent 248575c commit 9e0ef7a

File tree

3 files changed

+228
-3
lines changed

3 files changed

+228
-3
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pet/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ lazy_static = "1.4.0"
5050

5151
[dev-dependencies]
5252
regex = "1.10.4"
53+
tempfile = "3.10"
5354

5455
[features]
5556
ci = []

crates/pet/src/find.rs

Lines changed: 226 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ pub fn find_and_report_envs(
162162
possible_environments.append(
163163
&mut reader
164164
.filter_map(Result::ok)
165-
.filter(|d| d.file_type().is_ok_and(|f| f.is_dir()))
165+
// Use path().is_dir() instead of file_type().is_dir() to follow symlinks
166+
// See: https://github.com/microsoft/python-environment-tools/issues/196
167+
.filter(|d| d.path().is_dir())
166168
.map(|p| p.path())
167169
.collect(),
168170
);
@@ -285,7 +287,8 @@ pub fn find_python_environments_in_workspace_folder_recursive(
285287
if let Ok(reader) = fs::read_dir(workspace_folder.join(".pixi").join("envs")) {
286288
reader
287289
.filter_map(Result::ok)
288-
.filter(|d| d.file_type().is_ok_and(|f| f.is_dir()))
290+
// Use path().is_dir() instead of file_type().is_dir() to follow symlinks
291+
.filter(|d| d.path().is_dir())
289292
.map(|p| p.path())
290293
.for_each(|p| paths_to_search_first.push(p));
291294
}
@@ -310,7 +313,8 @@ pub fn find_python_environments_in_workspace_folder_recursive(
310313
if let Ok(reader) = fs::read_dir(workspace_folder) {
311314
for folder in reader
312315
.filter_map(Result::ok)
313-
.filter(|d| d.file_type().is_ok_and(|f| f.is_dir()))
316+
// Use path().is_dir() instead of file_type().is_dir() to follow symlinks
317+
.filter(|d| d.path().is_dir())
314318
.map(|p| p.path())
315319
.filter(|p| {
316320
// If this directory is a sub directory or is in the environment_directories, then do not search in this directory.
@@ -431,3 +435,222 @@ pub fn identify_python_executables_using_locators(
431435
}
432436
}
433437
}
438+
439+
#[cfg(test)]
440+
mod tests {
441+
use std::fs;
442+
#[cfg(unix)]
443+
use std::path::PathBuf;
444+
use tempfile::TempDir;
445+
446+
/// Test that `path().is_dir()` properly follows symlinks to directories.
447+
/// This is the fix for https://github.com/microsoft/python-environment-tools/issues/196
448+
///
449+
/// The issue was that `DirEntry::file_type().is_dir()` returns false for symlinks
450+
/// to directories on Unix, causing symlinked virtual environments to be missed.
451+
#[test]
452+
#[cfg(unix)]
453+
fn test_symlinked_directory_is_detected() {
454+
use std::os::unix::fs::symlink;
455+
456+
// Create temporary directories
457+
let tmp = TempDir::new().expect("Failed to create temp dir");
458+
let target_dir = tmp.path().join("actual_venv");
459+
let container_dir = tmp.path().join("envs");
460+
let symlink_dir = container_dir.join("linked_venv");
461+
462+
// Create the target directory (simulating a venv)
463+
fs::create_dir_all(&target_dir).expect("Failed to create target dir");
464+
fs::create_dir_all(&container_dir).expect("Failed to create container dir");
465+
466+
// Create a symlink from envs/linked_venv -> actual_venv
467+
symlink(&target_dir, &symlink_dir).expect("Failed to create symlink");
468+
469+
// Verify the symlink was created
470+
assert!(symlink_dir.exists(), "Symlink should exist");
471+
472+
// Test that path().is_dir() follows the symlink
473+
let entries: Vec<_> = fs::read_dir(&container_dir)
474+
.expect("Failed to read dir")
475+
.filter_map(Result::ok)
476+
.collect();
477+
478+
assert_eq!(entries.len(), 1, "Should have one entry");
479+
480+
let entry = &entries[0];
481+
482+
// This is the OLD behavior that caused the bug:
483+
// file_type().is_dir() does NOT follow symlinks
484+
let file_type_is_dir = entry.file_type().is_ok_and(|ft| ft.is_dir());
485+
assert!(
486+
!file_type_is_dir,
487+
"file_type().is_dir() should return false for symlinks (this is the bug)"
488+
);
489+
490+
// This is the NEW behavior that fixes the bug:
491+
// path().is_dir() DOES follow symlinks
492+
let path_is_dir = entry.path().is_dir();
493+
assert!(
494+
path_is_dir,
495+
"path().is_dir() should return true for symlinks to directories"
496+
);
497+
}
498+
499+
/// Test that regular directories still work with the fix
500+
#[test]
501+
fn test_regular_directory_is_detected() {
502+
let tmp = TempDir::new().expect("Failed to create temp dir");
503+
let container_dir = tmp.path().join("envs");
504+
let sub_dir = container_dir.join("my_venv");
505+
506+
fs::create_dir_all(&sub_dir).expect("Failed to create dirs");
507+
508+
let entries: Vec<_> = fs::read_dir(&container_dir)
509+
.expect("Failed to read dir")
510+
.filter_map(Result::ok)
511+
.filter(|d| d.path().is_dir())
512+
.collect();
513+
514+
assert_eq!(entries.len(), 1, "Should detect the regular directory");
515+
assert!(
516+
entries[0].path().ends_with("my_venv"),
517+
"Should be the my_venv directory"
518+
);
519+
}
520+
521+
/// Test that files are not incorrectly detected as directories
522+
#[test]
523+
fn test_file_is_not_detected_as_directory() {
524+
let tmp = TempDir::new().expect("Failed to create temp dir");
525+
let container_dir = tmp.path().join("envs");
526+
let file_path = container_dir.join("some_file.txt");
527+
528+
fs::create_dir_all(&container_dir).expect("Failed to create dirs");
529+
fs::write(&file_path, "test content").expect("Failed to write file");
530+
531+
let dirs: Vec<_> = fs::read_dir(&container_dir)
532+
.expect("Failed to read dir")
533+
.filter_map(Result::ok)
534+
.filter(|d| d.path().is_dir())
535+
.collect();
536+
537+
assert!(dirs.is_empty(), "Should not detect files as directories");
538+
}
539+
540+
/// Test symlinked directory scenario matching the original issue:
541+
/// User has ~/envs with symlinks to venvs in other locations
542+
#[test]
543+
#[cfg(unix)]
544+
fn test_symlinked_venv_in_envs_directory() {
545+
use std::os::unix::fs::symlink;
546+
547+
let tmp = TempDir::new().expect("Failed to create temp dir");
548+
549+
// Simulate user's actual venv location
550+
let project_dir = tmp.path().join("projects").join("myproject");
551+
let actual_venv = project_dir.join(".venv");
552+
553+
// Simulate ~/envs directory with symlink
554+
let envs_dir = tmp.path().join("envs");
555+
let symlinked_venv = envs_dir.join("myproject_venv");
556+
557+
// Create the actual venv structure
558+
fs::create_dir_all(actual_venv.join("bin")).expect("Failed to create venv");
559+
fs::write(actual_venv.join("bin").join("python"), "").expect("Failed to create python");
560+
fs::write(actual_venv.join("pyvenv.cfg"), "home = /usr/bin")
561+
.expect("Failed to create pyvenv.cfg");
562+
563+
// Create envs directory with symlink
564+
fs::create_dir_all(&envs_dir).expect("Failed to create envs dir");
565+
symlink(&actual_venv, &symlinked_venv).expect("Failed to create symlink");
566+
567+
// The fix ensures this symlinked directory is discovered
568+
let discovered: Vec<_> = fs::read_dir(&envs_dir)
569+
.expect("Failed to read envs dir")
570+
.filter_map(Result::ok)
571+
.filter(|d| d.path().is_dir()) // The fix: using path().is_dir()
572+
.map(|d| d.path())
573+
.collect();
574+
575+
assert_eq!(discovered.len(), 1, "Should discover the symlinked venv");
576+
assert_eq!(
577+
discovered[0], symlinked_venv,
578+
"Should be the symlinked venv path"
579+
);
580+
581+
// Verify it's actually a venv by checking for pyvenv.cfg
582+
assert!(
583+
discovered[0].join("pyvenv.cfg").exists(),
584+
"Symlink should point to a valid venv"
585+
);
586+
}
587+
588+
/// CRITICAL TEST: Verify that path().is_dir() does NOT resolve symlinks to their target paths.
589+
/// This ensures we use the symlink path (e.g., ~/envs/myenv) not the deep target path
590+
/// (e.g., /some/deep/path/to/actual/venv).
591+
///
592+
/// This is important because:
593+
/// 1. Users expect to see the symlink path in their environment list
594+
/// 2. We don't want to accidentally traverse into deep filesystem locations
595+
/// 3. The symlink path is the "user-facing" path they configured
596+
#[test]
597+
#[cfg(unix)]
598+
fn test_symlink_path_is_preserved_not_resolved() {
599+
use std::os::unix::fs::symlink;
600+
601+
let tmp = TempDir::new().expect("Failed to create temp dir");
602+
603+
// Create a "deep" target directory structure
604+
let deep_target = tmp
605+
.path()
606+
.join("deep")
607+
.join("nested")
608+
.join("path")
609+
.join("venv");
610+
fs::create_dir_all(&deep_target).expect("Failed to create deep target");
611+
612+
// Create a container with a symlink pointing to the deep target
613+
let container_dir = tmp.path().join("envs");
614+
let symlink_path = container_dir.join("my_venv");
615+
fs::create_dir_all(&container_dir).expect("Failed to create container");
616+
symlink(&deep_target, &symlink_path).expect("Failed to create symlink");
617+
618+
// Get the discovered paths using the same pattern as our fix
619+
let discovered: Vec<PathBuf> = fs::read_dir(&container_dir)
620+
.expect("Failed to read dir")
621+
.filter_map(Result::ok)
622+
.filter(|d| d.path().is_dir()) // This follows symlink to CHECK if it's a dir
623+
.map(|d| d.path()) // But this returns the SYMLINK path, not the target
624+
.collect();
625+
626+
assert_eq!(discovered.len(), 1);
627+
628+
// CRITICAL: The path should be the symlink, NOT the resolved target
629+
assert_eq!(
630+
discovered[0], symlink_path,
631+
"Should return the symlink path, not the deep target"
632+
);
633+
634+
// Verify we did NOT get the deep target path
635+
assert_ne!(
636+
discovered[0], deep_target,
637+
"Should NOT resolve to the deep target path"
638+
);
639+
640+
// The path should NOT contain the deep nested structure
641+
assert!(
642+
!discovered[0].to_string_lossy().contains("deep/nested"),
643+
"Path should not contain the deep nested target structure"
644+
);
645+
646+
// Extra verification: fs::canonicalize WOULD resolve it (showing the difference)
647+
// Note: We canonicalize both paths for comparison because on macOS /var is a
648+
// symlink to /private/var, so canonicalize resolves that too.
649+
let resolved = fs::canonicalize(&discovered[0]).expect("Should resolve");
650+
let canonical_target = fs::canonicalize(&deep_target).expect("Should resolve target");
651+
assert_eq!(
652+
resolved, canonical_target,
653+
"canonicalize() would resolve to target, but path() does not"
654+
);
655+
}
656+
}

0 commit comments

Comments
 (0)