Skip to main content

r2x_python/
utils.rs

1//! Utility adapters for Python virtual environment path handling.
2//!
3//! The actual platform-specific resolver lives in `r2x-config` so the CLI,
4//! manifest/discovery code, and PyO3 bridge do not drift apart on Python
5//! version or platform assumptions.
6
7use crate::errors::BridgeError;
8use r2x_config::venv_paths::{resolve_python_exe, resolve_site_packages, VenvPathError};
9use std::path::{Path, PathBuf};
10
11pub const PYTHON_LIB_DIR: &str = r2x_config::venv_paths::PYTHON_LIB_DIR;
12pub const PYTHON_BIN_DIR: &str = r2x_config::venv_paths::PYTHON_BIN_DIR;
13
14pub fn resolve_site_package_path(venv_path: &Path) -> Result<PathBuf, BridgeError> {
15    resolve_site_packages(venv_path).map_err(venv_path_error_to_bridge_error)
16}
17
18pub fn resolve_python_path(venv_path: &Path) -> Result<PathBuf, BridgeError> {
19    resolve_python_exe(venv_path).map_err(venv_path_error_to_bridge_error)
20}
21
22fn venv_path_error_to_bridge_error(error: VenvPathError) -> BridgeError {
23    match error {
24        VenvPathError::VenvNotFound(path) => BridgeError::VenvNotFound(path),
25        VenvPathError::PathResolution(message) => BridgeError::Initialization(message),
26    }
27}
28
29#[cfg(test)]
30mod tests {
31    use crate::errors::BridgeError;
32    use crate::utils::*;
33    use std::fs;
34    use std::path::PathBuf;
35    use tempfile::TempDir;
36
37    #[cfg(not(windows))]
38    fn create_mock_venv_unix(python_version: &str) -> Option<TempDir> {
39        let temp_dir = TempDir::new().ok()?;
40        let venv_path = temp_dir.path();
41
42        let site_packages = venv_path
43            .join(PYTHON_LIB_DIR)
44            .join(python_version)
45            .join("site-packages");
46        fs::create_dir_all(&site_packages).ok()?;
47
48        let bin_dir = venv_path.join(PYTHON_BIN_DIR);
49        fs::create_dir_all(&bin_dir).ok()?;
50        fs::write(bin_dir.join("python3"), "").ok()?;
51
52        Some(temp_dir)
53    }
54
55    #[cfg(windows)]
56    fn create_mock_venv_windows(python_exe: &str) -> Option<TempDir> {
57        let temp_dir = TempDir::new().ok()?;
58        let venv_path = temp_dir.path();
59
60        let site_packages = venv_path.join(PYTHON_LIB_DIR).join("site-packages");
61        fs::create_dir_all(&site_packages).ok()?;
62
63        let scripts_dir = venv_path.join(PYTHON_BIN_DIR);
64        fs::create_dir_all(&scripts_dir).ok()?;
65        fs::write(scripts_dir.join(python_exe), "").ok()?;
66
67        Some(temp_dir)
68    }
69
70    #[test]
71    #[cfg(not(windows))]
72    fn test_resolve_site_package_path_unix() {
73        let Some(temp_venv) = create_mock_venv_unix("python3.12") else {
74            return;
75        };
76
77        let result = resolve_site_package_path(temp_venv.path());
78        assert!(result.is_ok_and(|sp| sp.ends_with("lib/python3.12/site-packages")));
79    }
80
81    #[test]
82    #[cfg(not(windows))]
83    fn test_resolve_site_package_path_unix_different_version() {
84        let Some(temp_venv) = create_mock_venv_unix("python3.11") else {
85            return;
86        };
87
88        let result = resolve_site_package_path(temp_venv.path());
89        assert!(result.is_ok_and(|sp| sp.ends_with("lib/python3.11/site-packages")));
90    }
91
92    #[test]
93    #[cfg(windows)]
94    fn test_resolve_site_package_path_windows() {
95        let Some(temp_venv) = create_mock_venv_windows("python.exe") else {
96            return;
97        };
98
99        let result = resolve_site_package_path(temp_venv.path());
100        assert!(result.is_ok_and(|sp| sp.ends_with("Lib\\site-packages")));
101    }
102
103    #[test]
104    fn test_resolve_site_package_path_venv_not_found() {
105        let non_existent_path = PathBuf::from("/tmp/non_existent_venv_12345");
106
107        let result = resolve_site_package_path(&non_existent_path);
108        assert!(matches!(
109            result,
110            Err(BridgeError::VenvNotFound(path)) if path == non_existent_path
111        ));
112    }
113
114    #[test]
115    #[cfg(not(windows))]
116    fn test_resolve_site_package_path_missing_python_dir() {
117        let Ok(temp_dir) = TempDir::new() else {
118            return;
119        };
120        let lib_dir = temp_dir.path().join(PYTHON_LIB_DIR);
121        if fs::create_dir_all(&lib_dir).is_err() {
122            return;
123        }
124
125        let result = resolve_site_package_path(temp_dir.path());
126        assert!(result.is_err_and(|e| {
127            matches!(e, BridgeError::Initialization(msg) if msg.contains("No python3.X directory found"))
128        }));
129    }
130
131    #[test]
132    #[cfg(not(windows))]
133    fn test_resolve_python_path_unix() {
134        let Some(temp_venv) = create_mock_venv_unix("python3.12") else {
135            return;
136        };
137
138        let result = resolve_python_path(temp_venv.path());
139        assert!(result.is_ok_and(|pp| pp.ends_with("bin/python3")));
140    }
141
142    #[test]
143    #[cfg(not(windows))]
144    fn test_resolve_python_path_unix_versioned_fallback() {
145        let Some(temp_venv) = create_mock_venv_unix("python3.13") else {
146            return;
147        };
148        let bin_dir = temp_venv.path().join(PYTHON_BIN_DIR);
149        if fs::remove_file(bin_dir.join("python3")).is_err() {
150            return;
151        }
152        if fs::write(bin_dir.join("python3.13"), "").is_err() {
153            return;
154        }
155
156        let result = resolve_python_path(temp_venv.path());
157        assert!(result.is_ok_and(|pp| pp.ends_with("bin/python3.13")));
158    }
159
160    #[test]
161    #[cfg(windows)]
162    fn test_resolve_python_path_windows() {
163        let Some(temp_venv) = create_mock_venv_windows("python.exe") else {
164            return;
165        };
166
167        let result = resolve_python_path(temp_venv.path());
168        assert!(result.is_ok_and(|pp| pp.ends_with("Scripts\\python.exe")));
169    }
170
171    #[test]
172    #[cfg(windows)]
173    fn test_resolve_python_path_windows_versioned_fallback() {
174        let Some(temp_venv) = create_mock_venv_windows("python3.13.exe") else {
175            return;
176        };
177
178        let result = resolve_python_path(temp_venv.path());
179        assert!(result.is_ok_and(|pp| pp.ends_with("Scripts\\python3.13.exe")));
180    }
181
182    #[test]
183    fn test_python_lib_dir_constant() {
184        #[cfg(not(windows))]
185        assert_eq!(PYTHON_LIB_DIR, "lib");
186
187        #[cfg(windows)]
188        assert_eq!(PYTHON_LIB_DIR, "Lib");
189    }
190
191    #[test]
192    fn test_python_bin_dir_constant() {
193        #[cfg(not(windows))]
194        assert_eq!(PYTHON_BIN_DIR, "bin");
195
196        #[cfg(windows)]
197        assert_eq!(PYTHON_BIN_DIR, "Scripts");
198    }
199
200    #[test]
201    #[cfg(not(windows))]
202    fn test_resolve_site_package_path_with_multiple_python_versions() {
203        let Ok(temp_dir) = TempDir::new() else {
204            return;
205        };
206        let lib_dir = temp_dir.path().join(PYTHON_LIB_DIR);
207        if fs::create_dir_all(&lib_dir).is_err() {
208            return;
209        }
210
211        for python_version in ["python3.11", "python3.12"] {
212            let site_packages = lib_dir.join(python_version).join("site-packages");
213            if fs::create_dir_all(&site_packages).is_err() {
214                return;
215            }
216        }
217
218        let result = resolve_site_package_path(temp_dir.path());
219        assert!(result.is_ok_and(
220            |sp| sp.to_string_lossy().contains("python3.1") && sp.ends_with("site-packages")
221        ));
222    }
223}