@@ -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