use std::fs;
use std::path::{Path, PathBuf};
use crate::core::Metadata;
use crate::error::{Result, ScoopError};
use crate::paths;
use crate::uv::UvClient;
use crate::validate;
#[derive(Debug, Clone)]
pub struct VirtualenvInfo {
pub name: String,
pub path: PathBuf,
pub python_version: Option<String>,
}
pub struct VirtualenvService {
uv: UvClient,
}
impl VirtualenvService {
pub fn new(uv: UvClient) -> Self {
Self { uv }
}
pub fn auto() -> Result<Self> {
Ok(Self::new(UvClient::new()?))
}
pub fn list(&self) -> Result<Vec<VirtualenvInfo>> {
let venvs_dir = paths::virtualenvs_dir()?;
if !venvs_dir.exists() {
return Ok(Vec::new());
}
let mut envs = Vec::new();
for entry in fs::read_dir(&venvs_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let metadata = self.read_metadata(&path);
envs.push(VirtualenvInfo {
name: name.to_string(),
path: path.clone(),
python_version: metadata.map(|m| m.python_version),
});
}
}
}
envs.sort_by(|a, b| a.name.cmp(&b.name));
Ok(envs)
}
pub fn create(&self, name: &str, python_version: &str) -> Result<PathBuf> {
self.create_inner(name, python_version, None)
}
pub fn create_with_python_path(
&self,
name: &str,
python_version: &str,
python_path: &Path,
) -> Result<PathBuf> {
self.create_inner(
name,
&python_path.display().to_string(),
Some((python_version, python_path)),
)
}
fn create_inner(
&self,
name: &str,
uv_python_arg: &str,
python_path_info: Option<(&str, &Path)>,
) -> Result<PathBuf> {
validate::validate_env_name(name)?;
let path = paths::virtualenv_path(name)?;
if path.exists() {
return Err(ScoopError::VirtualenvExists {
name: name.to_string(),
});
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
self.uv.create_venv(&path, uv_python_arg)?;
let uv_version = self.uv.version().ok();
let actual_version = super::parse_pyvenv_version(&path)
.or_else(|| python_path_info.map(|(ver, _)| ver.to_string()))
.unwrap_or_else(|| uv_python_arg.to_string());
let mut metadata = Metadata::new(name.to_string(), actual_version, uv_version);
if let Some((_, pp)) = python_path_info {
metadata = metadata.with_python_path(pp.display().to_string());
}
self.write_metadata(&path, &metadata)?;
Ok(path)
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = paths::virtualenv_path(name)?;
if !path.exists() {
return Err(ScoopError::VirtualenvNotFound {
name: name.to_string(),
});
}
fs::remove_dir_all(&path)?;
Ok(())
}
pub fn exists(&self, name: &str) -> Result<bool> {
let path = paths::virtualenv_path(name)?;
Ok(path.exists())
}
pub fn get_path(&self, name: &str) -> Result<PathBuf> {
let path = paths::virtualenv_path(name)?;
if !path.exists() {
return Err(ScoopError::VirtualenvNotFound {
name: name.to_string(),
});
}
Ok(path)
}
pub fn read_metadata(&self, path: &Path) -> Option<Metadata> {
let metadata_path = path.join(Metadata::FILE_NAME);
let content = fs::read_to_string(metadata_path).ok()?;
serde_json::from_str(&content).ok()
}
fn write_metadata(&self, path: &Path, metadata: &Metadata) -> Result<()> {
let metadata_path = path.join(Metadata::FILE_NAME);
let content = serde_json::to_string_pretty(metadata)?;
fs::write(metadata_path, content)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{create_mock_venv, with_temp_scoop_home};
use serial_test::serial;
fn get_service() -> Option<VirtualenvService> {
crate::uv::UvClient::new().ok().map(VirtualenvService::new)
}
macro_rules! require_uv {
() => {
match get_service() {
Some(service) => service,
None => {
eprintln!("SKIPPED: uv not installed");
return;
}
}
};
}
#[test]
fn test_virtualenv_info_struct() {
let info = VirtualenvInfo {
name: "testenv".to_string(),
path: PathBuf::from("/path/to/env"),
python_version: Some("3.12".to_string()),
};
assert_eq!(info.name, "testenv");
assert_eq!(info.path, PathBuf::from("/path/to/env"));
assert_eq!(info.python_version, Some("3.12".to_string()));
}
#[test]
#[serial]
fn test_list_empty_when_no_venvs_dir() {
with_temp_scoop_home(|_temp_dir| {
let service = require_uv!();
let result = service.list().unwrap();
assert!(result.is_empty());
});
}
#[test]
#[serial]
fn test_list_returns_envs_sorted() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv(temp_dir, "zeta", Some("3.11"));
create_mock_venv(temp_dir, "alpha", Some("3.12"));
create_mock_venv(temp_dir, "beta", None);
let service = require_uv!();
let envs = service.list().unwrap();
assert_eq!(envs.len(), 3);
assert_eq!(envs[0].name, "alpha");
assert_eq!(envs[1].name, "beta");
assert_eq!(envs[2].name, "zeta");
});
}
#[test]
#[serial]
fn test_list_reads_python_version_from_metadata() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv(temp_dir, "withversion", Some("3.12.1"));
create_mock_venv(temp_dir, "noversion", None);
let service = require_uv!();
let envs = service.list().unwrap();
let with_ver = envs.iter().find(|e| e.name == "withversion").unwrap();
let no_ver = envs.iter().find(|e| e.name == "noversion").unwrap();
assert_eq!(with_ver.python_version, Some("3.12.1".to_string()));
assert_eq!(no_ver.python_version, None);
});
}
#[test]
#[serial]
fn test_exists_returns_false_for_nonexistent() {
with_temp_scoop_home(|_temp_dir| {
let service = require_uv!();
assert!(!service.exists("nonexistent").unwrap());
});
}
#[test]
#[serial]
fn test_exists_returns_true_for_existing() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv(temp_dir, "exists", None);
let service = require_uv!();
assert!(service.exists("exists").unwrap());
});
}
#[test]
#[serial]
fn test_get_path_returns_error_for_nonexistent() {
with_temp_scoop_home(|_temp_dir| {
let service = require_uv!();
let result = service.get_path("nonexistent");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ScoopError::VirtualenvNotFound { .. }));
});
}
#[test]
#[serial]
fn test_get_path_returns_path_for_existing() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv(temp_dir, "myenv", None);
let service = require_uv!();
let path = service.get_path("myenv").unwrap();
assert!(path.ends_with("myenv"));
assert!(path.exists());
});
}
#[test]
#[serial]
fn test_delete_removes_directory() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv(temp_dir, "todelete", Some("3.12"));
let venv_path = temp_dir.path().join("virtualenvs").join("todelete");
assert!(venv_path.exists());
let service = require_uv!();
service.delete("todelete").unwrap();
assert!(!venv_path.exists());
});
}
#[test]
#[serial]
fn test_delete_returns_error_for_nonexistent() {
with_temp_scoop_home(|temp_dir| {
fs::create_dir_all(temp_dir.path().join("virtualenvs")).unwrap();
let service = require_uv!();
let result = service.delete("nonexistent");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ScoopError::VirtualenvNotFound { .. }));
});
}
#[test]
#[serial]
fn test_list_ignores_files() {
with_temp_scoop_home(|temp_dir| {
let venvs_dir = temp_dir.path().join("virtualenvs");
fs::create_dir_all(&venvs_dir).unwrap();
fs::write(venvs_dir.join("notadir"), "test").unwrap();
create_mock_venv(temp_dir, "realenv", None);
let service = require_uv!();
let envs = service.list().unwrap();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].name, "realenv");
});
}
}