Skip to main content

pro_core/venv/
mod.rs

1//! Virtual environment management
2//!
3//! Creates and manages Python virtual environments natively without
4//! requiring the venv module or virtualenv package.
5
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use crate::{Error, Result};
12
13/// Virtual environment manager
14pub struct VenvManager {
15    /// Path to the virtual environment
16    path: PathBuf,
17}
18
19impl VenvManager {
20    /// Create a new venv manager for the given path
21    pub fn new(path: impl Into<PathBuf>) -> Self {
22        Self { path: path.into() }
23    }
24
25    /// Create a new virtual environment
26    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        // Get Python version info
33        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        // Create directory structure
44        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            // Symlink Python executable
62            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            // Create versioned symlinks
68            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            // Create pip symlink if pip exists in base Python
79            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 activation scripts
93            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            // Copy Python executable on Windows
108            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
115        write_pyvenv_cfg(&self.path, &python, major, minor)?;
116
117        tracing::info!("Virtual environment created at {:?}", self.path);
118        Ok(())
119    }
120
121    /// Check if the venv exists and is valid
122    pub fn exists(&self) -> bool {
123        self.path.join("pyvenv.cfg").exists()
124    }
125
126    /// Get the site-packages directory
127    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        // Read pyvenv.cfg to get Python version
135        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    /// Get the bin/Scripts directory
171    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    /// Get the venv path
184    pub fn path(&self) -> &Path {
185        &self.path
186    }
187
188    /// Get the Python executable path in the venv
189    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
202/// Find Python interpreter on the system
203fn find_python() -> Result<PathBuf> {
204    // Try common Python paths in order of preference
205    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        // Also try running the command directly
224        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
236/// Get Python version as (major, minor)
237fn 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
270/// Write pyvenv.cfg file
271fn write_pyvenv_cfg(venv_path: &Path, python: &Path, major: u32, minor: u32) -> Result<()> {
272    let cfg_path = venv_path.join("pyvenv.cfg");
273
274    // Get the base Python home directory
275    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/// Create activation script for bash/zsh
296#[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            // Skip if Python isn't working correctly (may happen in CI)
369            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}