22// Licensed under the MIT License.
33
44use env_variables:: EnvVariables ;
5+ use lazy_static:: lazy_static;
56use log:: trace;
67use manager:: PipenvManager ;
78use pet_core:: env:: PythonEnv ;
@@ -15,13 +16,24 @@ use pet_core::{
1516use pet_fs:: path:: norm_case;
1617use pet_python_utils:: executable:: find_executables;
1718use pet_python_utils:: version;
19+ use regex:: Regex ;
1820use std:: path:: Path ;
1921use std:: sync:: { Arc , RwLock } ;
2022use std:: { fs, path:: PathBuf } ;
2123
2224mod env_variables;
2325pub mod manager;
2426
27+ lazy_static ! {
28+ /// Regex pattern for pipenv environment directory names.
29+ /// Pipenv uses the naming convention: `{sanitized-project-name}-{8-char-hash}`
30+ /// The hash is 8 characters of URL-safe base64 encoding of SHA256.
31+ /// Pattern: one or more name segments (letters, digits, underscores) separated by hyphens,
32+ /// followed by a hyphen and exactly 8 alphanumeric characters (including _ and -).
33+ static ref PIPENV_ENV_NAME_PATTERN : Regex = Regex :: new( r"^.+-[A-Za-z0-9_-]{8}$" )
34+ . expect( "Error creating pipenv environment name pattern regex" ) ;
35+ }
36+
2537/// Returns the list of directories where pipenv stores centralized virtual environments.
2638/// These are the known locations where pipenv creates virtualenvs when not using in-project mode.
2739/// See: https://github.com/pypa/pipenv/blob/main/pipenv/utils/shell.py#L184
@@ -108,7 +120,7 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
108120 for pipenv_dir in & pipenv_dirs {
109121 if let Some ( parent) = prefix. parent ( ) {
110122 if norm_case ( parent) == * pipenv_dir {
111- // Check if there's a .project file (pipenv always creates this for centralized envs)
123+ // Primary check: .project file (pipenv always creates this for centralized envs)
112124 let project_file = prefix. join ( ".project" ) ;
113125 if project_file. exists ( ) {
114126 trace ! (
@@ -117,12 +129,27 @@ fn is_in_pipenv_centralized_dir(env: &PythonEnv, env_vars: &EnvVariables) -> boo
117129 pipenv_dir
118130 ) ;
119131 return true ;
120- } else {
121- trace ! (
122- "Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file" ,
123- prefix,
124- pipenv_dir
125- ) ;
132+ }
133+
134+ // Fallback: Check if directory name matches pipenv naming pattern
135+ // Pattern: {sanitized-project-name}-{8-char-hash}
136+ // This handles edge cases where .project was deleted, corrupted,
137+ // or environments from older pipenv versions.
138+ if let Some ( dir_name) = prefix. file_name ( ) . and_then ( |n| n. to_str ( ) ) {
139+ if PIPENV_ENV_NAME_PATTERN . is_match ( dir_name) {
140+ trace ! (
141+ "Pipenv: Detected centralized pipenv env at {:?} (in {:?}, matched naming pattern, no .project file)" ,
142+ prefix,
143+ pipenv_dir
144+ ) ;
145+ return true ;
146+ } else {
147+ trace ! (
148+ "Pipenv: Env {:?} is in pipenv dir {:?} but missing .project file and name doesn't match pattern" ,
149+ prefix,
150+ pipenv_dir
151+ ) ;
152+ }
126153 }
127154 }
128155 }
@@ -678,4 +705,91 @@ mod tests {
678705 // Cleanup
679706 std:: fs:: remove_dir_all ( & temp_home) . ok ( ) ;
680707 }
708+
709+ #[ test]
710+ fn detect_pipenv_centralized_env_without_project_file_via_naming_pattern ( ) {
711+ // Test fallback detection when .project file is missing but directory name matches
712+ // pipenv's naming pattern: {project-name}-{8-char-hash}
713+ let temp_home = unique_temp_dir ( ) ;
714+ let virtualenvs_dir = temp_home. join ( ".local" ) . join ( "share" ) . join ( "virtualenvs" ) ;
715+ // Use a name that matches pipenv pattern: name + hyphen + 8 alphanumeric chars
716+ let venv_dir = virtualenvs_dir. join ( "myproject-AbC12xYz" ) ;
717+ let bin_dir = if cfg ! ( windows) {
718+ venv_dir. join ( "Scripts" )
719+ } else {
720+ venv_dir. join ( "bin" )
721+ } ;
722+ let python_exe = if cfg ! ( windows) {
723+ bin_dir. join ( "python.exe" )
724+ } else {
725+ bin_dir. join ( "python" )
726+ } ;
727+
728+ // Create the venv WITHOUT a .project file (simulating corrupted/deleted .project)
729+ std:: fs:: create_dir_all ( & bin_dir) . unwrap ( ) ;
730+ std:: fs:: write ( & python_exe, b"" ) . unwrap ( ) ;
731+ std:: fs:: write ( venv_dir. join ( "pyvenv.cfg" ) , b"version = 3.13.0\n " ) . unwrap ( ) ;
732+ // Explicitly NOT creating .project file
733+
734+ // Construct PythonEnv
735+ let env = PythonEnv {
736+ executable : norm_case ( python_exe. clone ( ) ) ,
737+ prefix : Some ( norm_case ( venv_dir. clone ( ) ) ) ,
738+ version : None ,
739+ symlinks : None ,
740+ } ;
741+
742+ let env_vars = EnvVariables {
743+ pipenv_max_depth : 3 ,
744+ pipenv_pipfile : "Pipfile" . to_string ( ) ,
745+ home : Some ( temp_home. clone ( ) ) ,
746+ xdg_data_home : None ,
747+ workon_home : None ,
748+ path : None ,
749+ } ;
750+
751+ // Should be detected via naming pattern fallback
752+ assert ! (
753+ is_in_pipenv_centralized_dir( & env, & env_vars) ,
754+ "Expected env to be detected in centralized dir via naming pattern"
755+ ) ;
756+ assert ! (
757+ is_pipenv( & env, & env_vars) ,
758+ "Expected env to be identified as pipenv via naming pattern"
759+ ) ;
760+
761+ // Locator should return the environment
762+ let locator = PipEnv {
763+ env_vars,
764+ pipenv_executable : Arc :: new ( RwLock :: new ( None ) ) ,
765+ } ;
766+ let result = locator
767+ . try_from ( & env)
768+ . expect ( "expected locator to return environment" ) ;
769+ assert_eq ! ( result. kind, Some ( PythonEnvironmentKind :: Pipenv ) ) ;
770+ // Project should be None since there's no .project file and no Pipfile nearby
771+ assert_eq ! ( result. project, None ) ;
772+
773+ // Cleanup
774+ std:: fs:: remove_dir_all ( & temp_home) . ok ( ) ;
775+ }
776+
777+ #[ test]
778+ fn test_pipenv_naming_pattern_regex ( ) {
779+ // Test that our regex correctly matches pipenv naming patterns
780+ // Valid patterns: {name}-{8-char-hash}
781+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "myproject-AbC12xYz" ) ) ;
782+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "my-project-AbC12xYz" ) ) ;
783+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "my_project-AbC12xYz" ) ) ;
784+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "project123-12345678" ) ) ;
785+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "a-b-c-d-12345678" ) ) ;
786+ // URL-safe base64 can include _ and -
787+ assert ! ( PIPENV_ENV_NAME_PATTERN . is_match( "myproject-AbC_2-Yz" ) ) ;
788+
789+ // Invalid patterns (should NOT match)
790+ assert ! ( !PIPENV_ENV_NAME_PATTERN . is_match( "myproject" ) ) ; // no hash
791+ assert ! ( !PIPENV_ENV_NAME_PATTERN . is_match( "myproject-abc" ) ) ; // hash too short (3 chars)
792+ assert ! ( !PIPENV_ENV_NAME_PATTERN . is_match( "myproject-abcdefg" ) ) ; // hash too short (7 chars)
793+ assert ! ( !PIPENV_ENV_NAME_PATTERN . is_match( "-AbC12xYz" ) ) ; // no project name
794+ }
681795}
0 commit comments