@@ -17,6 +17,31 @@ lazy_static! {
1717 Regex :: new( r"python(\d+\.?)*$" ) . expect( "error parsing Unix executable regex" ) ;
1818}
1919
20+ /// Checks if a path is a broken symlink (symlink that points to a non-existent target).
21+ /// Returns true if the path is a symlink and its target does not exist.
22+ pub fn is_broken_symlink ( path : & Path ) -> bool {
23+ // First check if it's a symlink using symlink_metadata (doesn't follow symlinks)
24+ if let Ok ( metadata) = fs:: symlink_metadata ( path) {
25+ if metadata. file_type ( ) . is_symlink ( ) {
26+ // Now check if the target exists using regular metadata (follows symlinks)
27+ // If this fails or returns false for exists(), then it's broken
28+ return !path. exists ( ) ;
29+ }
30+ }
31+ false
32+ }
33+
34+ /// Result of looking for an executable in an environment path.
35+ #[ derive( Debug , Clone ) ]
36+ pub enum ExecutableResult {
37+ /// A valid executable was found
38+ Found ( PathBuf ) ,
39+ /// An executable path exists but is broken (e.g., broken symlink)
40+ Broken ( PathBuf ) ,
41+ /// No executable was found
42+ NotFound ,
43+ }
44+
2045#[ cfg( windows) ]
2146pub fn find_executable ( env_path : & Path ) -> Option < PathBuf > {
2247 [
@@ -43,6 +68,56 @@ pub fn find_executable(env_path: &Path) -> Option<PathBuf> {
4368 . find ( |path| path. is_file ( ) )
4469}
4570
71+ /// Finds an executable in the environment path, including broken symlinks.
72+ /// This is useful for detecting virtual environments that have broken Python executables.
73+ #[ cfg( windows) ]
74+ pub fn find_executable_or_broken ( env_path : & Path ) -> ExecutableResult {
75+ let candidates = [
76+ env_path. join ( "Scripts" ) . join ( "python.exe" ) ,
77+ env_path. join ( "Scripts" ) . join ( "python3.exe" ) ,
78+ env_path. join ( "bin" ) . join ( "python.exe" ) ,
79+ env_path. join ( "bin" ) . join ( "python3.exe" ) ,
80+ env_path. join ( "python.exe" ) ,
81+ env_path. join ( "python3.exe" ) ,
82+ ] ;
83+
84+ // First try to find a valid executable
85+ if let Some ( path) = candidates. iter ( ) . find ( |path| path. is_file ( ) ) {
86+ return ExecutableResult :: Found ( path. clone ( ) ) ;
87+ }
88+
89+ // Then check for broken symlinks
90+ if let Some ( path) = candidates. iter ( ) . find ( |path| is_broken_symlink ( path) ) {
91+ return ExecutableResult :: Broken ( path. clone ( ) ) ;
92+ }
93+
94+ ExecutableResult :: NotFound
95+ }
96+
97+ /// Finds an executable in the environment path, including broken symlinks.
98+ /// This is useful for detecting virtual environments that have broken Python executables.
99+ #[ cfg( unix) ]
100+ pub fn find_executable_or_broken ( env_path : & Path ) -> ExecutableResult {
101+ let candidates = [
102+ env_path. join ( "bin" ) . join ( "python" ) ,
103+ env_path. join ( "bin" ) . join ( "python3" ) ,
104+ env_path. join ( "python" ) ,
105+ env_path. join ( "python3" ) ,
106+ ] ;
107+
108+ // First try to find a valid executable
109+ if let Some ( path) = candidates. iter ( ) . find ( |path| path. is_file ( ) ) {
110+ return ExecutableResult :: Found ( path. clone ( ) ) ;
111+ }
112+
113+ // Then check for broken symlinks
114+ if let Some ( path) = candidates. iter ( ) . find ( |path| is_broken_symlink ( path) ) {
115+ return ExecutableResult :: Broken ( path. clone ( ) ) ;
116+ }
117+
118+ ExecutableResult :: NotFound
119+ }
120+
46121pub fn find_executables < T : AsRef < Path > > ( env_path : T ) -> Vec < PathBuf > {
47122 let mut env_path = env_path. as_ref ( ) . to_path_buf ( ) ;
48123 // Never find exes in pyenv shims folder, they are not valid exes.
@@ -306,4 +381,137 @@ mod tests {
306381 PathBuf :: from( "/home/user/project/shims" ) . as_path( )
307382 ) ) ;
308383 }
384+
385+ #[ test]
386+ fn test_is_broken_symlink_regular_file ( ) {
387+ // A regular file should not be detected as a broken symlink
388+ let temp_dir = std:: env:: temp_dir ( ) ;
389+ let test_file = temp_dir. join ( "pet_test_regular_file.txt" ) ;
390+ fs:: write ( & test_file, "test" ) . unwrap ( ) ;
391+
392+ assert ! ( !is_broken_symlink( & test_file) ) ;
393+
394+ let _ = fs:: remove_file ( & test_file) ;
395+ }
396+
397+ #[ test]
398+ fn test_is_broken_symlink_nonexistent ( ) {
399+ // A non-existent path should not be detected as a broken symlink
400+ let nonexistent = PathBuf :: from ( "/this/path/does/not/exist/python" ) ;
401+ assert ! ( !is_broken_symlink( & nonexistent) ) ;
402+ }
403+
404+ #[ test]
405+ #[ cfg( unix) ]
406+ fn test_is_broken_symlink_unix ( ) {
407+ use std:: os:: unix:: fs:: symlink;
408+
409+ let temp_dir = std:: env:: temp_dir ( ) ;
410+ let target = temp_dir. join ( "pet_test_symlink_target_nonexistent" ) ;
411+ let link = temp_dir. join ( "pet_test_broken_symlink" ) ;
412+
413+ // Clean up any previous test artifacts
414+ let _ = fs:: remove_file ( & link) ;
415+ let _ = fs:: remove_file ( & target) ;
416+
417+ // Create a symlink to a non-existent target
418+ symlink ( & target, & link) . unwrap ( ) ;
419+
420+ // The symlink should be detected as broken
421+ assert ! ( is_broken_symlink( & link) ) ;
422+
423+ // Clean up
424+ let _ = fs:: remove_file ( & link) ;
425+ }
426+
427+ #[ test]
428+ #[ cfg( unix) ]
429+ fn test_is_broken_symlink_valid_symlink ( ) {
430+ use std:: os:: unix:: fs:: symlink;
431+
432+ let temp_dir = std:: env:: temp_dir ( ) ;
433+ let target = temp_dir. join ( "pet_test_symlink_target_exists" ) ;
434+ let link = temp_dir. join ( "pet_test_valid_symlink" ) ;
435+
436+ // Clean up any previous test artifacts
437+ let _ = fs:: remove_file ( & link) ;
438+ let _ = fs:: remove_file ( & target) ;
439+
440+ // Create the target file
441+ fs:: write ( & target, "test" ) . unwrap ( ) ;
442+
443+ // Create a symlink to the existing target
444+ symlink ( & target, & link) . unwrap ( ) ;
445+
446+ // The symlink should NOT be detected as broken
447+ assert ! ( !is_broken_symlink( & link) ) ;
448+
449+ // Clean up
450+ let _ = fs:: remove_file ( & link) ;
451+ let _ = fs:: remove_file ( & target) ;
452+ }
453+
454+ #[ test]
455+ fn test_find_executable_or_broken_not_found ( ) {
456+ let temp_dir = std:: env:: temp_dir ( ) . join ( "pet_test_empty_env" ) ;
457+ let _ = fs:: create_dir_all ( & temp_dir) ;
458+
459+ match find_executable_or_broken ( & temp_dir) {
460+ ExecutableResult :: NotFound => ( ) ,
461+ other => panic ! ( "Expected NotFound, got {:?}" , other) ,
462+ }
463+
464+ let _ = fs:: remove_dir_all ( & temp_dir) ;
465+ }
466+
467+ #[ test]
468+ fn test_find_executable_or_broken_found ( ) {
469+ let temp_dir = std:: env:: temp_dir ( ) . join ( "pet_test_valid_env" ) ;
470+ #[ cfg( windows) ]
471+ let bin_dir = temp_dir. join ( "Scripts" ) ;
472+ #[ cfg( unix) ]
473+ let bin_dir = temp_dir. join ( "bin" ) ;
474+
475+ let _ = fs:: remove_dir_all ( & temp_dir) ;
476+ fs:: create_dir_all ( & bin_dir) . unwrap ( ) ;
477+
478+ #[ cfg( windows) ]
479+ let python_exe = bin_dir. join ( "python.exe" ) ;
480+ #[ cfg( unix) ]
481+ let python_exe = bin_dir. join ( "python" ) ;
482+
483+ fs:: write ( & python_exe, "fake python" ) . unwrap ( ) ;
484+
485+ match find_executable_or_broken ( & temp_dir) {
486+ ExecutableResult :: Found ( path) => assert_eq ! ( path, python_exe) ,
487+ other => panic ! ( "Expected Found, got {:?}" , other) ,
488+ }
489+
490+ let _ = fs:: remove_dir_all ( & temp_dir) ;
491+ }
492+
493+ #[ test]
494+ #[ cfg( unix) ]
495+ fn test_find_executable_or_broken_broken_symlink ( ) {
496+ use std:: os:: unix:: fs:: symlink;
497+
498+ let temp_dir = std:: env:: temp_dir ( ) . join ( "pet_test_broken_env" ) ;
499+ let bin_dir = temp_dir. join ( "bin" ) ;
500+
501+ let _ = fs:: remove_dir_all ( & temp_dir) ;
502+ fs:: create_dir_all ( & bin_dir) . unwrap ( ) ;
503+
504+ let python_exe = bin_dir. join ( "python" ) ;
505+ let nonexistent_target = PathBuf :: from ( "/nonexistent/python3.10" ) ;
506+
507+ // Create a broken symlink
508+ symlink ( & nonexistent_target, & python_exe) . unwrap ( ) ;
509+
510+ match find_executable_or_broken ( & temp_dir) {
511+ ExecutableResult :: Broken ( path) => assert_eq ! ( path, python_exe) ,
512+ other => panic ! ( "Expected Broken, got {:?}" , other) ,
513+ }
514+
515+ let _ = fs:: remove_dir_all ( & temp_dir) ;
516+ }
309517}
0 commit comments