use std::path::{Path, PathBuf};
use tokio::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VenvOutcome {
Created,
Reused,
Recreated,
}
#[derive(Debug, thiserror::Error)]
pub enum VenvError {
#[error(
"`uv` is required to manage CAR's Python runtimes but was not found on PATH; \
install uv (https://docs.astral.sh/uv/)"
)]
UvMissing,
#[error("could not create {root}: {detail}")]
Mkdir { root: PathBuf, detail: String },
#[error("`uv venv` failed for {root}: {detail}")]
Create { root: PathBuf, detail: String },
}
pub fn venv_bin_dir(root: &Path) -> PathBuf {
root.join(if cfg!(windows) { "Scripts" } else { "bin" })
}
pub fn venv_program(root: &Path, stem: &str) -> PathBuf {
let bin_dir = venv_bin_dir(root);
if cfg!(windows) {
bin_dir.join(format!("{stem}.exe"))
} else {
bin_dir.join(stem)
}
}
pub fn interpreter(root: &Path) -> PathBuf {
venv_program(root, "python")
}
pub fn interpreter_healthy(root: &Path) -> bool {
let python = interpreter(root);
if !python.exists() {
return false;
}
std::process::Command::new(&python)
.args(["-c", ""])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub async fn ensure_venv(root: &Path, python_spec: &str) -> Result<VenvOutcome, VenvError> {
if which("uv").is_none() {
return Err(VenvError::UvMissing);
}
if root.exists() {
if interpreter_healthy(root) {
return Ok(VenvOutcome::Reused);
}
create(root, python_spec, true).await?;
return Ok(VenvOutcome::Recreated);
}
std::fs::create_dir_all(root).map_err(|e| VenvError::Mkdir {
root: root.to_path_buf(),
detail: e.to_string(),
})?;
create(root, python_spec, true).await?;
Ok(VenvOutcome::Created)
}
async fn create(root: &Path, python_spec: &str, clear: bool) -> Result<(), VenvError> {
let mut args: Vec<String> = vec!["venv".into()];
if clear {
args.push("--clear".into());
}
args.push("--python".into());
args.push(python_spec.to_string());
args.push(root.display().to_string());
let output = Command::new("uv")
.args(&args)
.kill_on_drop(true)
.output()
.await
.map_err(|e| VenvError::Create {
root: root.to_path_buf(),
detail: e.to_string(),
})?;
if output.status.success() {
Ok(())
} else {
Err(VenvError::Create {
root: root.to_path_buf(),
detail: format!(
"uv exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
})
}
}
fn which(name: &str) -> Option<PathBuf> {
which_in(&std::env::var_os("PATH")?, name)
}
fn which_in(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
let extensions: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string())
.split(';')
.filter(|ext| !ext.is_empty())
.map(str::to_string)
.collect()
} else {
Vec::new()
};
std::env::split_paths(path).find_map(|dir| {
let bare = dir.join(name);
if bare.is_file() {
return Some(bare);
}
extensions.iter().find_map(|ext| {
let candidate = dir.join(format!("{name}{ext}"));
candidate.is_file().then_some(candidate)
})
})
}
#[doc(hidden)]
pub fn seed_ready_venv(root: &Path) {
#[cfg(windows)]
{
let python = ["python.exe", "python3.exe", "py.exe"]
.into_iter()
.find_map(which)
.unwrap_or_else(|| {
panic!(
"seed_ready_venv needs a host Python on PATH: on Windows the \
venv's Scripts\\python.exe must be a real interpreter, and \
none of python.exe / python3.exe / py.exe was found"
)
});
let output = std::process::Command::new(&python)
.args(["-m", "venv", "--without-pip"])
.arg(root)
.output()
.unwrap_or_else(|err| panic!("spawning {} -m venv: {err}", python.display()));
assert!(
output.status.success(),
"{} -m venv failed with {}: {}",
python.display(),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
#[cfg(unix)]
{
let python = interpreter(root);
std::fs::create_dir_all(python.parent().expect("interpreter has a parent")).unwrap();
std::fs::write(&python, b"#!/bin/sh\nexit 0\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&python, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert!(
interpreter_healthy(root),
"fixture failed to produce a runnable interpreter at {}",
interpreter(root).display()
);
}
#[cfg(test)]
mod tests {
use super::*;
fn uv_available() -> bool {
which("uv").is_some()
}
#[test]
fn interpreter_follows_the_platform_venv_layout() {
let p = interpreter(Path::new("/tmp/rt"));
if cfg!(windows) {
assert!(p.ends_with("Scripts/python.exe"), "got {}", p.display());
} else {
assert!(p.ends_with("bin/python"), "got {}", p.display());
}
}
#[test]
fn console_scripts_follow_the_platform_venv_layout() {
let p = venv_program(Path::new("/tmp/rt"), "mlx_audio.stt.generate");
if cfg!(windows) {
assert!(
p.ends_with("Scripts/mlx_audio.stt.generate.exe"),
"got {}",
p.display()
);
} else {
assert!(
p.ends_with("bin/mlx_audio.stt.generate"),
"got {}",
p.display()
);
}
}
#[test]
fn which_resolves_across_the_platform_path_separator() {
let tmp = std::env::temp_dir().join(format!("car-venv-which-{}", std::process::id()));
std::fs::create_dir_all(&tmp).unwrap();
let name = "car-which-probe";
let file = if cfg!(windows) {
tmp.join(format!("{name}.exe"))
} else {
tmp.join(name)
};
std::fs::write(&file, b"").unwrap();
let joined =
std::env::join_paths([PathBuf::from("/nonexistent/car-which"), tmp.clone()]).unwrap();
let found = which_in(&joined, name);
std::fs::remove_dir_all(&tmp).ok();
let found = found.expect("which_in should resolve the probe on PATH");
assert_eq!(found.parent(), file.parent());
assert!(
found
.file_name()
.zip(file.file_name())
.is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)),
"resolved {}, expected {} (case-insensitive)",
found.display(),
file.display()
);
}
#[test]
fn missing_root_is_not_healthy() {
assert!(!interpreter_healthy(Path::new(
"/nonexistent/car-managed-venv"
)));
}
#[cfg(unix)]
#[test]
fn dangling_interpreter_is_not_healthy() {
let tmp = std::env::temp_dir().join(format!("car-venv-dangle-{}", std::process::id()));
let bin = tmp.join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::os::unix::fs::symlink(
"/opt/homebrew/opt/python@0.0/bin/python0.0",
bin.join("python"),
)
.unwrap();
assert!(!interpreter_healthy(&tmp));
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn existing_healthy_venv_is_reused_not_recreated() {
if !uv_available() {
return;
}
let tmp = std::env::temp_dir().join(format!("car-venv-reuse-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let first = ensure_venv(&tmp, "python3").await.expect("first create");
assert_eq!(first, VenvOutcome::Created);
assert!(interpreter_healthy(&tmp));
let second = ensure_venv(&tmp, "python3").await.expect("second call");
assert_eq!(second, VenvOutcome::Reused);
assert!(interpreter_healthy(&tmp));
let _ = std::fs::remove_dir_all(&tmp);
}
#[cfg(unix)]
#[tokio::test]
async fn broken_venv_is_recreated() {
if !uv_available() {
return;
}
let tmp = std::env::temp_dir().join(format!("car-venv-repair-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
ensure_venv(&tmp, "python3").await.expect("create");
let python = interpreter(&tmp);
std::fs::remove_file(&python).unwrap();
std::os::unix::fs::symlink("/opt/homebrew/opt/python@0.0/bin/python0.0", &python).unwrap();
assert!(!interpreter_healthy(&tmp));
let outcome = ensure_venv(&tmp, "python3").await.expect("repair");
assert_eq!(outcome, VenvOutcome::Recreated);
assert!(interpreter_healthy(&tmp));
let _ = std::fs::remove_dir_all(&tmp);
}
}