use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Result, ScoopError};
use super::common;
use super::source::{EnvironmentSource, EnvironmentStatus, SourceEnvironment, SourceType};
#[derive(Debug)]
pub struct PyenvDiscovery {
root: PathBuf,
}
impl PyenvDiscovery {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
pub fn default_root() -> Option<Self> {
let root = std::env::var("PYENV_ROOT")
.map(PathBuf::from)
.ok()
.or_else(|| dirs::home_dir().map(|h| h.join(".pyenv")))?;
if root.exists() {
Some(Self::new(root))
} else {
None
}
}
fn versions_dir(&self) -> PathBuf {
self.root.join("versions")
}
fn parse_pyvenv_cfg(path: &Path) -> Option<String> {
let cfg_path = path.join("pyvenv.cfg");
let content = fs::read_to_string(&cfg_path).ok()?;
if let Some(version) = content
.lines()
.find_map(crate::core::pyvenv_version_from_line)
{
return Some(version);
}
for line in content.lines() {
let line = line.trim();
if let Some(home) = line.strip_prefix("home") {
let home = home.trim_start_matches([' ', '=']).trim();
if let Some(versions_idx) = home.find("versions/") {
let after_versions = &home[versions_idx + 9..];
if let Some(slash_idx) = after_versions.find('/') {
return Some(after_versions[..slash_idx].to_string());
}
}
}
}
None
}
fn parse_environment(
&self,
env_path: &Path,
fallback_version: Option<&str>,
) -> Option<SourceEnvironment> {
let name = env_path.file_name()?.to_str()?.to_string();
let python_version = Self::parse_pyvenv_cfg(env_path)
.or_else(|| fallback_version.map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
let python_bin = env_path.join("bin").join("python");
if !python_bin.exists() {
return Some(SourceEnvironment {
name,
python_version,
path: env_path.to_path_buf(),
source_type: SourceType::Pyenv,
size_bytes: None, status: EnvironmentStatus::Corrupted {
reason: "Python binary not found".to_string(),
},
});
}
let status = common::determine_status(&name, &python_version);
Some(SourceEnvironment {
name,
python_version,
path: env_path.to_path_buf(),
source_type: SourceType::Pyenv,
size_bytes: None, status,
})
}
}
impl EnvironmentSource for PyenvDiscovery {
fn source_type(&self) -> SourceType {
SourceType::Pyenv
}
fn scan_environments(&self) -> Result<Vec<SourceEnvironment>> {
let mut environments = Vec::new();
let versions_dir = self.versions_dir();
if !versions_dir.exists() {
return Ok(environments);
}
let entries = fs::read_dir(&versions_dir).map_err(ScoopError::Io)?;
for entry in entries.flatten() {
let python_version_path = entry.path();
if python_version_path.is_symlink() {
continue;
}
let envs_dir = python_version_path.join("envs");
if !envs_dir.exists() || !envs_dir.is_dir() {
continue;
}
let fallback_version = python_version_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let env_entries = match fs::read_dir(&envs_dir) {
Ok(entries) => entries,
Err(_) => continue,
};
for env_entry in env_entries.flatten() {
let env_path = env_entry.path();
if env_path.is_symlink() || !env_path.is_dir() {
continue;
}
if let Some(env) = self.parse_environment(&env_path, fallback_version.as_deref()) {
environments.push(env);
}
}
}
environments.sort_by(|a, b| a.name.cmp(&b.name));
Ok(environments)
}
fn find_environment(&self, name: &str) -> Result<SourceEnvironment> {
let versions_dir = self.versions_dir();
if !versions_dir.exists() {
return Err(ScoopError::PyenvEnvNotFound {
name: name.to_string(),
});
}
let entries = fs::read_dir(&versions_dir).map_err(ScoopError::Io)?;
for entry in entries.flatten() {
let python_version_path = entry.path();
if python_version_path.is_symlink() {
continue;
}
let env_path = python_version_path.join("envs").join(name);
if env_path.exists() && env_path.is_dir() {
let fallback_version = python_version_path.file_name().and_then(|n| n.to_str());
if let Some(env) = self.parse_environment(&env_path, fallback_version) {
return Ok(env);
}
}
}
Err(ScoopError::PyenvEnvNotFound {
name: name.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
use tempfile::TempDir;
fn setup_mock_pyenv_root(versions: &[&str]) -> TempDir {
let temp = TempDir::new().expect("Failed to create temp dir");
let versions_dir = temp.path().join("versions");
fs::create_dir_all(&versions_dir).expect("Failed to create versions dir");
for ver in versions {
let bin_dir = versions_dir.join(ver).join("bin");
fs::create_dir_all(&bin_dir).expect("Failed to create bin dir");
fs::write(bin_dir.join("python"), "#!/bin/sh\necho mock")
.expect("Failed to write mock python");
}
temp
}
fn create_mock_virtualenv(
pyenv_root: &Path,
python_version: &str,
env_name: &str,
create_python_bin: bool,
) {
let env_path = pyenv_root
.join("versions")
.join(python_version)
.join("envs")
.join(env_name);
let bin_dir = env_path.join("bin");
fs::create_dir_all(&bin_dir).expect("Failed to create env bin dir");
if create_python_bin {
fs::write(bin_dir.join("python"), "#!/bin/sh\necho mock")
.expect("Failed to write mock python");
}
let cfg_path = env_path.join("pyvenv.cfg");
let cfg_content = format!(
"home = {}/versions/{}/bin\n\
include-system-site-packages = false\n\
version = {}\n",
pyenv_root.display(),
python_version,
python_version
);
fs::write(cfg_path, cfg_content).expect("Failed to write pyvenv.cfg");
}
#[test]
fn test_new_creates_instance_with_given_root() {
let path = PathBuf::from("/some/path/.pyenv");
let discovery = PyenvDiscovery::new(path.clone());
assert_eq!(discovery.root, path);
}
#[test]
#[serial]
fn test_default_root_uses_pyenv_root_env_var() {
let temp = TempDir::new().unwrap();
let pyenv_path = temp.path().to_path_buf();
unsafe {
std::env::set_var("PYENV_ROOT", &pyenv_path);
}
let discovery = PyenvDiscovery::default_root();
unsafe {
std::env::remove_var("PYENV_ROOT");
}
assert!(discovery.is_some());
assert_eq!(discovery.unwrap().root, pyenv_path);
}
#[test]
#[serial]
fn test_default_root_returns_none_for_nonexistent_path() {
unsafe {
std::env::set_var("PYENV_ROOT", "/nonexistent/path/that/does/not/exist");
}
let discovery = PyenvDiscovery::default_root();
unsafe {
std::env::remove_var("PYENV_ROOT");
}
assert!(discovery.is_none());
}
#[test]
fn test_parse_pyvenv_cfg_extracts_version() {
let temp_dir = TempDir::new().unwrap();
let cfg_path = temp_dir.path().join("pyvenv.cfg");
let mut file = fs::File::create(&cfg_path).unwrap();
writeln!(file, "home = /Users/test/.pyenv/versions/3.11.0/bin").unwrap();
writeln!(file, "include-system-site-packages = false").unwrap();
writeln!(file, "version = 3.11.0").unwrap();
let version = PyenvDiscovery::parse_pyvenv_cfg(temp_dir.path());
assert_eq!(version, Some("3.11.0".to_string()));
}
#[test]
fn test_parse_pyvenv_cfg_handles_no_spaces() {
let temp_dir = TempDir::new().unwrap();
let cfg_path = temp_dir.path().join("pyvenv.cfg");
fs::write(&cfg_path, "version=3.10.5\nhome=/usr/bin\n").unwrap();
let version = PyenvDiscovery::parse_pyvenv_cfg(temp_dir.path());
assert_eq!(version, Some("3.10.5".to_string()));
}
#[test]
fn test_parse_pyvenv_cfg_fallback_to_home_path() {
let temp_dir = TempDir::new().unwrap();
let cfg_path = temp_dir.path().join("pyvenv.cfg");
let mut file = fs::File::create(&cfg_path).unwrap();
writeln!(file, "home = /Users/test/.pyenv/versions/3.9.7/bin").unwrap();
writeln!(file, "include-system-site-packages = false").unwrap();
let version = PyenvDiscovery::parse_pyvenv_cfg(temp_dir.path());
assert_eq!(version, Some("3.9.7".to_string()));
}
#[test]
fn test_parse_pyvenv_cfg_returns_none_for_missing_file() {
let temp_dir = TempDir::new().unwrap();
let version = PyenvDiscovery::parse_pyvenv_cfg(temp_dir.path());
assert!(version.is_none());
}
#[test]
fn test_parse_pyvenv_cfg_returns_none_for_malformed_content() {
let temp_dir = TempDir::new().unwrap();
let cfg_path = temp_dir.path().join("pyvenv.cfg");
fs::write(&cfg_path, "garbage content without version info").unwrap();
let version = PyenvDiscovery::parse_pyvenv_cfg(temp_dir.path());
assert!(version.is_none());
}
#[test]
fn test_scan_empty_pyenv_returns_empty() {
let temp = TempDir::new().unwrap();
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments();
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_scan_pyenv_with_versions_but_no_envs() {
let temp = setup_mock_pyenv_root(&["3.11.0", "3.12.0"]);
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments();
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_scan_discovers_virtualenvs_in_envs_directory() {
let temp = setup_mock_pyenv_root(&["3.11.0", "3.12.0"]);
create_mock_virtualenv(temp.path(), "3.11.0", "project-a", true);
create_mock_virtualenv(temp.path(), "3.11.0", "project-b", true);
create_mock_virtualenv(temp.path(), "3.12.0", "project-c", true);
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments().unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].name, "project-a");
assert_eq!(result[0].python_version, "3.11.0");
assert!(matches!(result[0].source_type, SourceType::Pyenv));
assert_eq!(result[1].name, "project-b");
assert_eq!(result[1].python_version, "3.11.0");
assert_eq!(result[2].name, "project-c");
assert_eq!(result[2].python_version, "3.12.0");
}
#[test]
fn test_scan_detects_corrupted_envs_without_python_binary() {
let temp = setup_mock_pyenv_root(&["3.11.0"]);
create_mock_virtualenv(temp.path(), "3.11.0", "broken-env", false);
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments().unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "broken-env");
assert!(matches!(
result[0].status,
EnvironmentStatus::Corrupted { .. }
));
}
#[test]
fn test_scan_skips_symlinks_in_versions_directory() {
let temp = setup_mock_pyenv_root(&["3.11.0"]);
create_mock_virtualenv(temp.path(), "3.11.0", "real-env", true);
let symlink_path = temp.path().join("versions").join("real-env");
let target = temp
.path()
.join("versions")
.join("3.11.0")
.join("envs")
.join("real-env");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &symlink_path).unwrap();
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments().unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "real-env");
}
#[test]
fn test_find_environment_returns_correct_env() {
let temp = setup_mock_pyenv_root(&["3.11.0", "3.12.0"]);
create_mock_virtualenv(temp.path(), "3.11.0", "target-env", true);
create_mock_virtualenv(temp.path(), "3.12.0", "other-env", true);
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.find_environment("target-env");
assert!(result.is_ok());
let env = result.unwrap();
assert_eq!(env.name, "target-env");
assert_eq!(env.python_version, "3.11.0");
}
#[test]
fn test_find_environment_returns_error_for_nonexistent() {
let temp = setup_mock_pyenv_root(&["3.11.0"]);
create_mock_virtualenv(temp.path(), "3.11.0", "existing-env", true);
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.find_environment("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_find_environment_returns_error_when_versions_dir_missing() {
let temp = TempDir::new().unwrap();
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.find_environment("any-env");
assert!(result.is_err());
}
#[test]
fn test_determine_status_eol_python() {
let status = common::determine_status("nonexistent_eol_test_env_xyz", "3.7.0");
assert!(matches!(status, EnvironmentStatus::PythonEol { .. }));
let status = common::determine_status("nonexistent_eol_test_env_xyz", "2.7.18");
assert!(matches!(status, EnvironmentStatus::PythonEol { .. }));
}
#[test]
fn test_determine_status_ready() {
let status = common::determine_status("nonexistent_env_name_12345", "3.12.0");
assert!(matches!(status, EnvironmentStatus::Ready));
}
#[test]
fn test_determine_status_eol_boundary() {
let status = common::determine_status("boundary_test_env", "3.8.19");
assert!(matches!(status, EnvironmentStatus::PythonEol { .. }));
let status = common::determine_status("boundary_test_env", "3.9.0");
assert!(matches!(status, EnvironmentStatus::Ready));
}
#[test]
fn test_scan_handles_permission_errors_gracefully() {
let temp = TempDir::new().unwrap();
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
let result = discovery.scan_environments();
assert!(result.is_ok());
}
#[test]
fn test_source_type_returns_pyenv() {
let temp = TempDir::new().unwrap();
let discovery = PyenvDiscovery::new(temp.path().to_path_buf());
assert!(matches!(discovery.source_type(), SourceType::Pyenv));
}
}