1use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::{Error, Result};
12
13pub struct VenvManager {
15 path: PathBuf,
17}
18
19impl VenvManager {
20 pub fn new(path: impl Into<PathBuf>) -> Self {
22 Self { path: path.into() }
23 }
24
25 pub fn create(&self, python_path: Option<&Path>) -> Result<()> {
27 let python = match python_path {
28 Some(p) => p.to_path_buf(),
29 None => find_python()?,
30 };
31
32 let version_info = get_python_version(&python)?;
34 let (major, minor) = version_info;
35
36 tracing::info!(
37 "Creating venv at {:?} with Python {}.{}",
38 self.path,
39 major,
40 minor
41 );
42
43 fs::create_dir_all(&self.path).map_err(Error::Io)?;
45
46 #[cfg(unix)]
47 {
48 let bin_dir = self.path.join("bin");
49 fs::create_dir_all(&bin_dir).map_err(Error::Io)?;
50
51 let lib_dir = self
52 .path
53 .join("lib")
54 .join(format!("python{}.{}", major, minor))
55 .join("site-packages");
56 fs::create_dir_all(&lib_dir).map_err(Error::Io)?;
57
58 let include_dir = self.path.join("include");
59 fs::create_dir_all(&include_dir).map_err(Error::Io)?;
60
61 let python_link = bin_dir.join("python");
63 if !python_link.exists() {
64 std::os::unix::fs::symlink(&python, &python_link).map_err(Error::Io)?;
65 }
66
67 let python_versioned = bin_dir.join(format!("python{}", major));
69 if !python_versioned.exists() {
70 std::os::unix::fs::symlink(&python, &python_versioned).map_err(Error::Io)?;
71 }
72
73 let python_full = bin_dir.join(format!("python{}.{}", major, minor));
74 if !python_full.exists() {
75 std::os::unix::fs::symlink(&python, &python_full).map_err(Error::Io)?;
76 }
77
78 let base_bin = python.parent().unwrap_or(Path::new("/usr/bin"));
80 let base_pip = base_bin.join("pip3");
81 if base_pip.exists() {
82 let pip_link = bin_dir.join("pip");
83 if !pip_link.exists() {
84 std::os::unix::fs::symlink(&base_pip, &pip_link).map_err(Error::Io)?;
85 }
86 let pip3_link = bin_dir.join("pip3");
87 if !pip3_link.exists() {
88 std::os::unix::fs::symlink(&base_pip, &pip3_link).map_err(Error::Io)?;
89 }
90 }
91
92 create_activate_script(&bin_dir, &self.path)?;
94 }
95
96 #[cfg(windows)]
97 {
98 let scripts_dir = self.path.join("Scripts");
99 fs::create_dir_all(&scripts_dir).map_err(Error::Io)?;
100
101 let lib_dir = self.path.join("Lib").join("site-packages");
102 fs::create_dir_all(&lib_dir).map_err(Error::Io)?;
103
104 let include_dir = self.path.join("Include");
105 fs::create_dir_all(&include_dir).map_err(Error::Io)?;
106
107 let python_exe = scripts_dir.join("python.exe");
109 if !python_exe.exists() {
110 fs::copy(&python, &python_exe).map_err(Error::Io)?;
111 }
112 }
113
114 write_pyvenv_cfg(&self.path, &python, major, minor)?;
116
117 tracing::info!("Virtual environment created at {:?}", self.path);
118 Ok(())
119 }
120
121 pub fn exists(&self) -> bool {
123 self.path.join("pyvenv.cfg").exists()
124 }
125
126 pub fn site_packages(&self) -> Result<PathBuf> {
128 if !self.exists() {
129 return Err(Error::VenvError(
130 "Virtual environment does not exist".into(),
131 ));
132 }
133
134 let cfg_path = self.path.join("pyvenv.cfg");
136 let content = fs::read_to_string(&cfg_path).map_err(Error::Io)?;
137
138 let mut version = None;
139 for line in content.lines() {
140 if let Some(v) = line.strip_prefix("version = ") {
141 version = Some(v.trim().to_string());
142 break;
143 }
144 }
145
146 let version =
147 version.ok_or_else(|| Error::VenvError("Cannot determine Python version".into()))?;
148 let parts: Vec<&str> = version.split('.').collect();
149 if parts.len() < 2 {
150 return Err(Error::VenvError("Invalid version in pyvenv.cfg".into()));
151 }
152 let major = parts[0];
153 let minor = parts[1];
154
155 #[cfg(unix)]
156 {
157 Ok(self
158 .path
159 .join("lib")
160 .join(format!("python{}.{}", major, minor))
161 .join("site-packages"))
162 }
163
164 #[cfg(windows)]
165 {
166 Ok(self.path.join("Lib").join("site-packages"))
167 }
168 }
169
170 pub fn bin_dir(&self) -> PathBuf {
172 #[cfg(unix)]
173 {
174 self.path.join("bin")
175 }
176
177 #[cfg(windows)]
178 {
179 self.path.join("Scripts")
180 }
181 }
182
183 pub fn path(&self) -> &Path {
185 &self.path
186 }
187
188 pub fn python(&self) -> PathBuf {
190 #[cfg(unix)]
191 {
192 self.bin_dir().join("python")
193 }
194
195 #[cfg(windows)]
196 {
197 self.bin_dir().join("python.exe")
198 }
199 }
200}
201
202fn find_python() -> Result<PathBuf> {
204 let candidates = [
206 "python3",
207 "python",
208 "/usr/bin/python3",
209 "/usr/local/bin/python3",
210 "/opt/homebrew/bin/python3",
211 ];
212
213 for candidate in candidates {
214 if let Ok(output) = Command::new("which").arg(candidate).output() {
215 if output.status.success() {
216 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
217 if !path.is_empty() {
218 return Ok(PathBuf::from(path));
219 }
220 }
221 }
222
223 if let Ok(output) = Command::new(candidate).arg("--version").output() {
225 if output.status.success() {
226 return Ok(PathBuf::from(candidate));
227 }
228 }
229 }
230
231 Err(Error::VenvError(
232 "Could not find Python interpreter. Please install Python 3.8+".into(),
233 ))
234}
235
236fn get_python_version(python: &Path) -> Result<(u32, u32)> {
238 let output = Command::new(python)
239 .args([
240 "-c",
241 "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
242 ])
243 .output()
244 .map_err(|e| Error::VenvError(format!("Failed to run Python: {}", e)))?;
245
246 if !output.status.success() {
247 return Err(Error::VenvError("Failed to get Python version".into()));
248 }
249
250 let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
251 let parts: Vec<&str> = version.split('.').collect();
252
253 if parts.len() < 2 {
254 return Err(Error::VenvError(format!(
255 "Invalid Python version: {}",
256 version
257 )));
258 }
259
260 let major: u32 = parts[0]
261 .parse()
262 .map_err(|_| Error::VenvError(format!("Invalid major version: {}", parts[0])))?;
263 let minor: u32 = parts[1]
264 .parse()
265 .map_err(|_| Error::VenvError(format!("Invalid minor version: {}", parts[1])))?;
266
267 Ok((major, minor))
268}
269
270fn write_pyvenv_cfg(venv_path: &Path, python: &Path, major: u32, minor: u32) -> Result<()> {
272 let cfg_path = venv_path.join("pyvenv.cfg");
273
274 let python_home = python
276 .parent()
277 .and_then(|p| p.parent())
278 .unwrap_or(Path::new("/usr"));
279
280 let content = format!(
281 "home = {}\n\
282 include-system-site-packages = false\n\
283 version = {}.{}\n",
284 python_home.display(),
285 major,
286 minor
287 );
288
289 let mut file = fs::File::create(&cfg_path).map_err(Error::Io)?;
290 file.write_all(content.as_bytes()).map_err(Error::Io)?;
291
292 Ok(())
293}
294
295#[cfg(unix)]
297fn create_activate_script(bin_dir: &Path, venv_path: &Path) -> Result<()> {
298 let activate_path = bin_dir.join("activate");
299 let venv_name = venv_path.file_name().unwrap_or_default().to_string_lossy();
300
301 let content = format!(
302 r#"# This file must be used with "source bin/activate" *from bash*
303# You cannot run it directly
304
305deactivate () {{
306 if [ -n "${{_OLD_VIRTUAL_PATH:-}}" ] ; then
307 PATH="${{_OLD_VIRTUAL_PATH:-}}"
308 export PATH
309 unset _OLD_VIRTUAL_PATH
310 fi
311
312 if [ -n "${{_OLD_VIRTUAL_PYTHONHOME:-}}" ] ; then
313 PYTHONHOME="${{_OLD_VIRTUAL_PYTHONHOME:-}}"
314 export PYTHONHOME
315 unset _OLD_VIRTUAL_PYTHONHOME
316 fi
317
318 if [ -n "${{_OLD_VIRTUAL_PS1:-}}" ] ; then
319 PS1="${{_OLD_VIRTUAL_PS1:-}}"
320 export PS1
321 unset _OLD_VIRTUAL_PS1
322 fi
323
324 unset VIRTUAL_ENV
325 if [ ! "${{1:-}}" = "nondestructive" ] ; then
326 unset -f deactivate
327 fi
328}}
329
330deactivate nondestructive
331
332VIRTUAL_ENV="{venv_path}"
333export VIRTUAL_ENV
334
335_OLD_VIRTUAL_PATH="$PATH"
336PATH="$VIRTUAL_ENV/bin:$PATH"
337export PATH
338
339if [ -z "${{VIRTUAL_ENV_DISABLE_PROMPT:-}}" ] ; then
340 _OLD_VIRTUAL_PS1="${{PS1:-}}"
341 PS1="({name}) ${{PS1:-}}"
342 export PS1
343fi
344"#,
345 venv_path = venv_path.display(),
346 name = venv_name
347 );
348
349 let mut file = fs::File::create(&activate_path).map_err(Error::Io)?;
350 file.write_all(content.as_bytes()).map_err(Error::Io)?;
351
352 Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn test_find_python() {
361 let python = find_python();
362 assert!(python.is_ok(), "Should find Python on most systems");
363 }
364
365 #[test]
366 fn test_get_python_version() {
367 if let Ok(python) = find_python() {
368 if let Ok((major, minor)) = get_python_version(&python) {
370 assert!(major >= 3, "Should be Python 3+");
371 assert!(minor >= 8 || major > 3, "Should be Python 3.8+");
372 }
373 }
374 }
375
376 #[test]
377 fn test_venv_manager_new() {
378 let manager = VenvManager::new("/tmp/test-venv");
379 assert_eq!(manager.path(), Path::new("/tmp/test-venv"));
380 }
381}