use std::path::{Path, PathBuf};
use std::process::Command;
use serde::Deserialize;
use crate::error::{Result, ScoopError};
use crate::validate::PythonVersion;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PythonInfo {
pub version: String,
pub path: Option<PathBuf>,
pub installed: bool,
pub implementation: String,
}
#[derive(Debug, Deserialize)]
struct UvPythonEntry {
version: String,
path: Option<PathBuf>,
implementation: String,
}
impl From<UvPythonEntry> for PythonInfo {
fn from(entry: UvPythonEntry) -> Self {
let installed = entry.path.is_some();
Self {
version: entry.version,
path: entry.path,
installed,
implementation: entry.implementation,
}
}
}
pub struct UvClient {
path: PathBuf,
}
impl UvClient {
pub fn new() -> Result<Self> {
let path = which::which("uv").map_err(|_| ScoopError::UvNotFound)?;
Ok(Self { path })
}
pub fn with_path(path: PathBuf) -> Self {
Self { path }
}
pub fn version(&self) -> Result<String> {
let mut cmd = Command::new(&self.path);
cmd.arg("--version");
let stdout = run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: "uv --version".to_string(),
message,
})?;
Ok(String::from_utf8_lossy(&stdout).trim().to_string())
}
pub fn create_venv(&self, path: &Path, python_version: &str) -> Result<()> {
let mut cmd = Command::new(&self.path);
cmd.arg("venv")
.arg(path)
.arg("--python")
.arg(python_version);
let display = format!("uv venv {} --python {}", path.display(), python_version);
run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: display.clone(),
message,
})?;
Ok(())
}
pub fn install_python(&self, version: &str) -> Result<()> {
let mut cmd = Command::new(&self.path);
cmd.arg("python").arg("install").arg(version);
let display = format!("uv python install {version}");
run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: display.clone(),
message,
})?;
Ok(())
}
pub fn list_pythons(&self) -> Result<Vec<PythonInfo>> {
self.run_python_list(false)
}
pub fn list_installed_pythons(&self) -> Result<Vec<PythonInfo>> {
self.run_python_list(true)
}
fn run_python_list(&self, only_installed: bool) -> Result<Vec<PythonInfo>> {
let mut cmd = Command::new(&self.path);
cmd.arg("python").arg("list").arg("--output-format=json");
if only_installed {
cmd.arg("--only-installed");
}
let display = if only_installed {
"uv python list --only-installed --output-format=json"
} else {
"uv python list --output-format=json"
};
let stdout = run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: display.to_string(),
message,
})?;
parse_python_list_json(&String::from_utf8_lossy(&stdout))
}
pub fn cache_prune(&self) -> Result<String> {
let mut cmd = Command::new(&self.path);
cmd.arg("cache").arg("prune");
let stdout = run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: "uv cache prune".to_string(),
message,
})?;
Ok(String::from_utf8_lossy(&stdout).into_owned())
}
pub fn uninstall_python(&self, version: &str) -> Result<()> {
let mut cmd = Command::new(&self.path);
cmd.arg("python").arg("uninstall").arg(version);
run_uv(cmd, |message| ScoopError::PythonUninstallFailed {
version: version.to_string(),
message,
})?;
Ok(())
}
pub fn find_python(&self, version_pattern: &str) -> Result<Option<PythonInfo>> {
let installed = self.list_installed_pythons()?;
if let Some(pattern) = PythonVersion::parse(version_pattern) {
for info in installed {
if let Some(ver) = PythonVersion::parse(&info.version) {
if pattern.matches(&ver) {
return Ok(Some(info));
}
}
}
}
Ok(None)
}
pub fn pip_install(&self, venv_path: &Path, packages: &[String]) -> Result<()> {
if packages.is_empty() {
return Ok(());
}
let mut cmd = Command::new(&self.path);
cmd.arg("pip")
.arg("install")
.arg("--python")
.arg(venv_path.join("bin").join("python"));
for package in packages {
cmd.arg(package);
}
let display = format!("uv pip install (into {})", venv_path.display());
run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: display.clone(),
message,
})?;
Ok(())
}
pub fn pip_install_requirements(
&self,
venv_path: &Path,
requirements_path: &Path,
) -> Result<()> {
let mut cmd = Command::new(&self.path);
cmd.arg("pip")
.arg("install")
.arg("--python")
.arg(venv_path.join("bin").join("python"))
.arg("-r")
.arg(requirements_path);
let display = format!("uv pip install -r {}", requirements_path.display());
run_uv(cmd, |message| ScoopError::UvCommandFailed {
command: display.clone(),
message,
})?;
Ok(())
}
pub fn latest_installed_python(&self) -> Result<Option<PythonInfo>> {
Ok(pick_latest_python(self.list_installed_pythons()?))
}
}
fn run_uv(mut cmd: Command, make_err: impl Fn(String) -> ScoopError) -> Result<Vec<u8>> {
let output = cmd.output().map_err(|e| make_err(e.to_string()))?;
if !output.status.success() {
return Err(make_err(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(output.stdout)
}
fn pick_latest_python(pythons: Vec<PythonInfo>) -> Option<PythonInfo> {
pythons
.into_iter()
.filter_map(|info| PythonVersion::parse(&info.version).map(|v| (v, info)))
.max_by(|(a, _), (b, _)| a.cmp(b))
.map(|(_, info)| info)
}
fn parse_python_list_json(stdout: &str) -> Result<Vec<PythonInfo>> {
let entries: Vec<UvPythonEntry> = serde_json::from_str(stdout)?;
Ok(entries.into_iter().map(PythonInfo::from).collect())
}
impl Default for UvClient {
fn default() -> Self {
Self::new().expect("uv not found in PATH")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uv_client_creation() {
if which::which("uv").is_ok() {
let client = UvClient::new();
assert!(client.is_ok());
}
}
#[test]
fn test_parse_python_list_with_paths() {
let json = r#"[
{"key":"cpython-3.12.0-macos-aarch64-none","version":"3.12.0","version_parts":{"major":3,"minor":12,"patch":0},"path":"/Users/test/.local/share/uv/python/cpython-3.12.0/bin/python3","symlink":null,"url":null,"os":"macos","variant":"default","implementation":"cpython","arch":"aarch64","libc":"none"},
{"key":"cpython-3.11.8-macos-aarch64-none","version":"3.11.8","version_parts":{"major":3,"minor":11,"patch":8},"path":"/Users/test/.local/share/uv/python/cpython-3.11.8/bin/python3","symlink":null,"url":null,"os":"macos","variant":"default","implementation":"cpython","arch":"aarch64","libc":"none"}
]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 2);
assert_eq!(pythons[0].version, "3.12.0");
assert_eq!(pythons[0].implementation, "cpython");
assert!(pythons[0].installed);
assert!(pythons[0].path.is_some());
assert_eq!(pythons[1].version, "3.11.8");
assert_eq!(pythons[1].implementation, "cpython");
}
#[test]
fn test_parse_python_list_without_paths() {
let json = r#"[
{"key":"cpython-3.13.0-macos-aarch64-none","version":"3.13.0","path":null,"implementation":"cpython"},
{"key":"cpython-3.12.0-macos-aarch64-none","version":"3.12.0","path":null,"implementation":"cpython"}
]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 2);
assert_eq!(pythons[0].version, "3.13.0");
assert!(!pythons[0].installed);
assert!(pythons[0].path.is_none());
}
#[test]
fn test_parse_python_list_mixed() {
let json = r#"[
{"version":"3.12.0","path":"/path/to/python3","implementation":"cpython"},
{"version":"3.11.0","path":null,"implementation":"cpython"},
{"version":"3.10.0","path":"/path/to/pypy","implementation":"pypy"}
]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 3);
assert_eq!(pythons[0].implementation, "cpython");
assert!(pythons[0].installed);
assert_eq!(pythons[1].implementation, "cpython");
assert!(!pythons[1].installed);
assert_eq!(pythons[2].implementation, "pypy");
assert!(pythons[2].installed);
}
#[test]
fn test_parse_python_list_empty() {
let pythons = parse_python_list_json("[]").expect("valid json");
assert!(pythons.is_empty());
}
#[test]
fn test_python_info_equality() {
let info1 = PythonInfo {
version: "3.12.0".to_string(),
path: Some(PathBuf::from("/path/to/python")),
installed: true,
implementation: "cpython".to_string(),
};
let info2 = PythonInfo {
version: "3.12.0".to_string(),
path: Some(PathBuf::from("/path/to/python")),
installed: true,
implementation: "cpython".to_string(),
};
assert_eq!(info1, info2);
}
#[test]
fn test_uv_client_with_path() {
let client = UvClient::with_path(PathBuf::from("/usr/bin/uv"));
assert_eq!(client.path, PathBuf::from("/usr/bin/uv"));
}
#[test]
fn test_parse_python_list_json_malformed_is_error() {
assert!(parse_python_list_json("{ not json }").is_err());
}
#[test]
fn test_parse_python_list_json_non_array_is_error() {
assert!(parse_python_list_json(r#"{"version":"3.12.0"}"#).is_err());
}
#[test]
fn test_parse_python_list_path_traversal_attempt() {
let json =
r#"[{"version":"3.12.0","path":"../../../etc/passwd","implementation":"cpython"}]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 1);
assert_eq!(pythons[0].version, "3.12.0");
assert_eq!(pythons[0].path, Some(PathBuf::from("../../../etc/passwd")));
}
#[test]
fn test_parse_python_list_unicode_path() {
let json =
r#"[{"version":"3.12.0","path":"/Users/한글/python","implementation":"cpython"}]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 1);
assert_eq!(pythons[0].path, Some(PathBuf::from("/Users/한글/python")));
}
#[test]
fn test_parse_python_list_missing_path_is_not_installed() {
let json = r#"[{"version":"3.12.0","implementation":"cpython"}]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 1);
assert!(!pythons[0].installed);
assert!(pythons[0].path.is_none());
}
#[test]
fn test_parse_python_list_prerelease_version() {
let json = r#"[{"version":"3.15.0b1","path":null,"implementation":"cpython"}]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 1);
assert_eq!(pythons[0].version, "3.15.0b1");
assert!(!pythons[0].installed);
}
#[test]
fn test_parse_python_list_various_implementations() {
let json = r#"[
{"version":"3.12.0","path":"/path/cpython","implementation":"cpython"},
{"version":"3.10.0","path":"/path/pypy","implementation":"pypy"},
{"version":"3.11.0","path":"/path/graalpy","implementation":"graalpy"}
]"#;
let pythons = parse_python_list_json(json).expect("valid json");
assert_eq!(pythons.len(), 3);
assert_eq!(pythons[0].implementation, "cpython");
assert_eq!(pythons[1].implementation, "pypy");
assert_eq!(pythons[2].implementation, "graalpy");
}
#[test]
fn test_python_info_none_path() {
let info = PythonInfo {
version: "3.12.0".to_string(),
path: None,
installed: false,
implementation: "cpython".to_string(),
};
assert!(info.path.is_none());
assert!(!info.installed);
}
fn py_info(version: &str) -> PythonInfo {
PythonInfo {
version: version.to_string(),
path: None,
installed: true,
implementation: "cpython".to_string(),
}
}
#[test]
fn pick_latest_python_picks_highest_patch() {
let got = pick_latest_python(vec![
py_info("3.12.1"),
py_info("3.12.9"),
py_info("3.12.3"),
]);
assert_eq!(got.unwrap().version, "3.12.9");
}
#[test]
fn pick_latest_python_compares_major_then_minor() {
let got = pick_latest_python(vec![
py_info("3.9.18"),
py_info("3.13.0"),
py_info("3.12.12"),
]);
assert_eq!(got.unwrap().version, "3.13.0");
}
#[test]
fn pick_latest_python_skips_unparseable() {
let got = pick_latest_python(vec![py_info("garbage"), py_info("3.10.1")]);
assert_eq!(got.unwrap().version, "3.10.1");
}
#[test]
fn pick_latest_python_empty_is_none() {
assert!(pick_latest_python(vec![]).is_none());
}
}