Skip to main content

r2x_python/
utils.rs

1//! Utility constants and functions for platform-specific Python venv path handling
2//!
3//! This module provides compile-time constants for directories and files that differ
4//! between Windows and Unix-like systems in Python virtual environments.
5
6use super::errors::BridgeError;
7use r2x_logger as logger;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// The name of the library directory in a Python venv (e.g., "Lib" on Windows, "lib" on Unix)
12#[cfg(windows)]
13pub const PYTHON_LIB_DIR: &str = "Lib";
14#[cfg(unix)]
15pub const PYTHON_LIB_DIR: &str = "lib";
16
17/// The name of the binaries/scripts directory in a Python venv (e.g., "Scripts" on Windows, "bin" on Unix)
18#[cfg(windows)]
19const PYTHON_BIN_DIR: &str = "Scripts";
20#[cfg(unix)]
21const PYTHON_BIN_DIR: &str = "bin";
22
23/// Candidate executable names in a venv
24#[cfg(unix)]
25const PYTHON_EXE_CANDIDATES: &[&str] = &["python3", "python"];
26#[cfg(windows)]
27const PYTHON_EXE_CANDIDATES: &[&str] = &["python.exe", "python3.exe", "python3.12.exe"];
28
29// Site Packages differences.
30//
31// MacOS
32// .venv/lib/python {version}/site-packages
33//
34// Windows
35// .venv/Lib/site-packages
36
37pub fn resolve_site_package_path(venv_path: &Path) -> Result<PathBuf, BridgeError> {
38    logger::debug(&format!(
39        "Resolving site-packages path for venv: {}",
40        venv_path.display()
41    ));
42
43    // Verify the venv_path exists and is a directory.
44    if !venv_path.is_dir() {
45        logger::debug(&format!(
46            "Venv path does not exist or is not a directory: {}",
47            venv_path.display()
48        ));
49        return Err(BridgeError::VenvNotFound(venv_path.to_path_buf()));
50    }
51
52    #[cfg(windows)]
53    {
54        let site_packages = venv_path.join(PYTHON_LIB_DIR).join("site-packages");
55        logger::debug(&format!(
56            "Windows: Looking for site-packages at: {}",
57            site_packages.display()
58        ));
59
60        // verify site_package_path exists
61        if !site_packages.is_dir() {
62            logger::debug(&format!(
63                "Windows: site-packages directory not found at: {}",
64                site_packages.display()
65            ));
66            return Err(BridgeError::Initialization(format!(
67                "unable to locate package directory: {}",
68                site_packages.display()
69            )));
70        }
71        logger::debug(&format!(
72            "Windows: Successfully resolved site-packages: {}",
73            site_packages.display()
74        ));
75        Ok(site_packages)
76    }
77
78    #[cfg(not(windows))]
79    {
80        let lib_dir = venv_path.join(PYTHON_LIB_DIR);
81        logger::debug(&format!(
82            "Unix: Looking for lib directory at: {}",
83            lib_dir.display()
84        ));
85
86        if !lib_dir.is_dir() {
87            logger::debug(&format!(
88                "Unix: lib directory not found at: {}",
89                lib_dir.display()
90            ));
91            return Err(BridgeError::Initialization(format!(
92                "unable to locate lib directory: {}",
93                lib_dir.display()
94            )));
95        }
96
97        let python_version_dir = fs::read_dir(&lib_dir)
98            .map_err(|e| {
99                logger::debug(&format!("Unix: Failed to read lib directory: {}", e));
100                BridgeError::Initialization(format!("Failed to read lib directory: {}", e))
101            })?
102            .filter_map(|e| e.ok())
103            .find(|e| e.file_name().to_string_lossy().starts_with("python"))
104            .ok_or_else(|| {
105                logger::debug("Unix: No python3.X directory found in venv/lib");
106                BridgeError::Initialization("No python3.X directory found in venv/lib".to_string())
107            })?;
108
109        logger::debug(&format!(
110            "Unix: Found python version directory: {}",
111            python_version_dir.path().display()
112        ));
113
114        let site_packages = python_version_dir.path().join("site-packages");
115        logger::debug(&format!(
116            "Unix: Looking for site-packages at: {}",
117            site_packages.display()
118        ));
119
120        if !site_packages.is_dir() {
121            logger::debug(&format!(
122                "Unix: site-packages directory not found at: {}",
123                site_packages.display()
124            ));
125            return Err(BridgeError::Initialization(format!(
126                "unable to locate package directory: {}",
127                site_packages.display()
128            )));
129        }
130
131        logger::debug(&format!(
132            "Unix: Successfully resolved site-packages: {}",
133            site_packages.display()
134        ));
135        Ok(site_packages)
136    }
137}
138
139pub fn resolve_python_path(venv_path: &Path) -> Result<PathBuf, BridgeError> {
140    // validate venv path is a valid directory
141    if !venv_path.is_dir() {
142        return Err(BridgeError::VenvNotFound(venv_path.to_path_buf()));
143    }
144
145    let bin_dir = venv_path.join(PYTHON_BIN_DIR);
146    if !bin_dir.is_dir() {
147        return Err(BridgeError::Initialization(format!(
148            "Python bin directory missing: {}",
149            bin_dir.display()
150        )));
151    }
152
153    for exe in PYTHON_EXE_CANDIDATES {
154        let candidate = bin_dir.join(exe);
155        if candidate.is_file() {
156            return Ok(candidate);
157        }
158    }
159
160    if let Ok(entries) = fs::read_dir(&bin_dir) {
161        if let Some(candidate) = entries.filter_map(|e| e.ok()).map(|e| e.path()).find(|p| {
162            p.file_name()
163                .and_then(|n| n.to_str())
164                .map(|name| name.contains("python"))
165                .unwrap_or(false)
166                && p.is_file()
167        }) {
168            return Ok(candidate);
169        }
170    }
171
172    Err(BridgeError::Initialization(format!(
173        "Path to python binary is not valid in {}",
174        venv_path.display()
175    )))
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::fs;
182    use tempfile::TempDir;
183
184    /// Helper to create a mock venv structure for testing
185    #[allow(dead_code)]
186    fn create_mock_venv_unix(python_version: &str) -> Option<TempDir> {
187        let temp_dir = TempDir::new().ok()?;
188        let venv_path = temp_dir.path();
189
190        // Create Unix structure: .venv/lib/python3.X/site-packages
191        let lib_dir = venv_path.join("lib");
192        let python_dir = lib_dir.join(python_version);
193        let site_packages = python_dir.join("site-packages");
194        fs::create_dir_all(&site_packages).ok()?;
195
196        // Create bin directory with python executable
197        let bin_dir = venv_path.join("bin");
198        fs::create_dir_all(&bin_dir).ok()?;
199        fs::write(bin_dir.join("python3"), "").ok()?;
200
201        Some(temp_dir)
202    }
203
204    /// Helper to create a mock Windows venv structure for testing
205    #[allow(dead_code)]
206    fn create_mock_venv_windows() -> Option<TempDir> {
207        let temp_dir = TempDir::new().ok()?;
208        let venv_path = temp_dir.path();
209
210        // Create Windows structure: .venv/Lib/site-packages
211        let lib_dir = venv_path.join("Lib");
212        let site_packages = lib_dir.join("site-packages");
213        fs::create_dir_all(&site_packages).ok()?;
214
215        // Create Scripts directory with python executable
216        let scripts_dir = venv_path.join("Scripts");
217        fs::create_dir_all(&scripts_dir).ok()?;
218        fs::write(scripts_dir.join("python.exe"), "").ok()?;
219
220        Some(temp_dir)
221    }
222
223    #[test]
224    #[cfg(unix)]
225    fn test_resolve_site_package_path_unix() {
226        let Some(temp_venv) = create_mock_venv_unix("python3.12") else {
227            return;
228        };
229        let venv_path = temp_venv.path().to_path_buf();
230
231        let result = resolve_site_package_path(&venv_path);
232        assert!(result.is_ok());
233        assert!(result.is_ok_and(|sp| sp.ends_with("lib/python3.12/site-packages") && sp.exists()));
234    }
235
236    #[test]
237    #[cfg(unix)]
238    fn test_resolve_site_package_path_unix_different_version() {
239        let Some(temp_venv) = create_mock_venv_unix("python3.11") else {
240            return;
241        };
242        let venv_path = temp_venv.path().to_path_buf();
243
244        let result = resolve_site_package_path(&venv_path);
245        assert!(result.is_ok());
246        assert!(result.is_ok_and(|sp| sp.ends_with("lib/python3.11/site-packages")));
247    }
248
249    #[test]
250    #[cfg(windows)]
251    fn test_resolve_site_package_path_windows() {
252        let Some(temp_venv) = create_mock_venv_windows() else {
253            return;
254        };
255        let venv_path = temp_venv.path().to_path_buf();
256
257        let result = resolve_site_package_path(&venv_path);
258        assert!(result.is_ok());
259        assert!(result.is_ok_and(|sp| sp.ends_with("Lib\\site-packages") && sp.exists()));
260    }
261
262    #[test]
263    fn test_resolve_site_package_path_venv_not_found() {
264        let non_existent_path = PathBuf::from("/tmp/non_existent_venv_12345");
265
266        let result = resolve_site_package_path(&non_existent_path);
267        assert!(result.is_err());
268
269        match result {
270            Err(BridgeError::VenvNotFound(path)) => {
271                assert_eq!(path, non_existent_path);
272            }
273            _ => panic!("Expected VenvNotFound error"),
274        }
275    }
276
277    #[test]
278    #[cfg(unix)]
279    fn test_resolve_site_package_path_missing_python_dir() {
280        let Ok(temp_dir) = TempDir::new() else {
281            return;
282        };
283        let venv_path = temp_dir.path();
284
285        // Create lib dir but no python3.X subdirectory
286        let lib_dir = venv_path.join("lib");
287        if fs::create_dir_all(&lib_dir).is_err() {
288            return;
289        }
290
291        let result = resolve_site_package_path(venv_path);
292        assert!(result.is_err());
293        assert!(result.is_err_and(|e| matches!(e, BridgeError::Initialization(msg) if msg.contains("No python3.X directory found"))));
294    }
295
296    #[test]
297    #[cfg(unix)]
298    fn test_resolve_python_path_unix() {
299        let Some(temp_venv) = create_mock_venv_unix("python3.12") else {
300            return;
301        };
302        let venv_path = temp_venv.path().to_path_buf();
303
304        let result = resolve_python_path(&venv_path);
305        assert!(result.is_ok());
306        assert!(result.is_ok_and(|pp| pp.ends_with("bin/python3")));
307    }
308
309    #[test]
310    #[cfg(windows)]
311    fn test_resolve_python_path_windows() {
312        let Some(temp_venv) = create_mock_venv_windows() else {
313            return;
314        };
315        let venv_path = temp_venv.path().to_path_buf();
316
317        let result = resolve_python_path(&venv_path);
318        assert!(result.is_ok());
319        assert!(result.is_ok_and(|pp| pp.ends_with("Scripts\\python.exe")));
320    }
321
322    #[test]
323    fn test_python_lib_dir_constant() {
324        // Test that the compile-time constant is correct for the platform
325        #[cfg(unix)]
326        assert_eq!(PYTHON_LIB_DIR, "lib");
327
328        #[cfg(windows)]
329        assert_eq!(PYTHON_LIB_DIR, "Lib");
330    }
331
332    #[test]
333    fn test_python_bin_dir_constant() {
334        // Test that the compile-time constant is correct for the platform
335        #[cfg(unix)]
336        assert_eq!(PYTHON_BIN_DIR, "bin");
337
338        #[cfg(windows)]
339        assert_eq!(PYTHON_BIN_DIR, "Scripts");
340    }
341
342    #[test]
343    #[cfg(unix)]
344    fn test_resolve_site_package_path_with_multiple_python_versions() {
345        let Ok(temp_dir) = TempDir::new() else {
346            return;
347        };
348        let venv_path = temp_dir.path();
349
350        // Create lib dir with multiple python versions
351        let lib_dir = venv_path.join("lib");
352        if fs::create_dir_all(&lib_dir).is_err() {
353            return;
354        }
355
356        // Create python3.11
357        let python_311 = lib_dir.join("python3.11");
358        let site_packages_311 = python_311.join("site-packages");
359        if fs::create_dir_all(&site_packages_311).is_err() {
360            return;
361        }
362
363        // Create python3.12 (should find the first one)
364        let python_312 = lib_dir.join("python3.12");
365        let site_packages_312 = python_312.join("site-packages");
366        if fs::create_dir_all(&site_packages_312).is_err() {
367            return;
368        }
369
370        let result = resolve_site_package_path(venv_path);
371        assert!(result.is_ok());
372        // Should find one of them (implementation finds first match)
373        assert!(result.is_ok_and(|sp| sp.to_string_lossy().contains("python3.1")
374            && sp.ends_with("site-packages")));
375    }
376}