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