use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PythonSource {
Project,
Active,
Default,
}
impl PythonSource {
pub fn title(self) -> &'static str {
match self {
PythonSource::Project => "the project's",
PythonSource::Active => "the active one ($VIRTUAL_ENV)",
PythonSource::Default => "the app's default",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PythonEnv {
pub root: PathBuf,
pub python: PathBuf,
pub source: PythonSource,
}
pub fn venv_python(root: &Path) -> Option<PathBuf> {
let candidates: &[&str] = if cfg!(windows) { &["Scripts/python.exe"] } else { &["bin/python", "bin/python3"] };
candidates.iter().map(|c| root.join(c)).find(|p| p.exists())
}
impl PythonEnv {
pub fn find(scripts: Option<&Path>, default_root: &Path) -> Option<PythonEnv> {
let at = |root: PathBuf, source| venv_python(&root).map(|python| PythonEnv { root, python, source });
scripts
.and_then(|d| at(d.join(".venv"), PythonSource::Project))
.or_else(|| std::env::var_os("VIRTUAL_ENV").filter(|v| !v.is_empty()).and_then(|v| at(PathBuf::from(v), PythonSource::Active)))
.or_else(|| at(default_root.to_path_buf(), PythonSource::Default))
}
pub fn command(&self) -> Command {
let mut c = Command::new(&self.python);
c.env("VIRTUAL_ENV", &self.root);
if let Some(bin) = self.python.parent() {
let path = std::env::var_os("PATH").unwrap_or_default();
let joined = std::env::join_paths(std::iter::once(bin.to_path_buf()).chain(std::env::split_paths(&path))).unwrap_or(path);
c.env("PATH", joined);
}
c.env_remove("PYTHONHOME");
c
}
pub fn version(&self) -> Option<String> {
let cfg = std::fs::read_to_string(self.root.join("pyvenv.cfg")).ok()?;
cfg.lines().find_map(|l| {
let (k, v) = l.split_once('=')?;
matches!(k.trim(), "version" | "version_info").then(|| v.trim().to_string())
})
}
pub fn packages(&self) -> Vec<String> {
let lib = if cfg!(windows) { vec![self.root.join("Lib").join("site-packages")] } else { std::fs::read_dir(self.root.join("lib")).into_iter().flatten().flatten().map(|e| e.path().join("site-packages")).collect() };
let mut out: Vec<String> = lib
.iter()
.flat_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let stem = name.strip_suffix(".dist-info")?;
Some(stem.rsplit_once('-').map_or(stem, |(n, _)| n).replace('_', "-").to_lowercase())
})
.collect();
out.sort();
out.dedup();
out
}
}
pub fn uv() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("UV").map(PathBuf::from).filter(|p| p.is_file()) {
return Some(p);
}
let exe = if cfg!(windows) { "uv.exe" } else { "uv" };
let on_path = std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(exe)).find(|c| c.is_file()));
on_path.or_else(|| {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)?;
[home.join(".local/bin").join(exe), home.join(".cargo/bin").join(exe)].into_iter().find(|c| c.is_file())
})
}
fn pyproject(name: &str, packages: &[&str]) -> String {
let deps = packages.iter().map(|p| format!(" \"{p}\",")).collect::<Vec<_>>().join("\n");
format!("[project]\nname = \"{name}\"\nversion = \"0.0.0\"\ndescription = \"The packages this project's Python scripts use\"\nrequires-python = \">=3.10\"\ndependencies = [\n{deps}\n]\n\n[tool.uv]\npackage = false\n")
}
fn run(cmd: &mut Command, what: &str) -> Result<String, String> {
let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_string();
if out.status.success() { Ok(text) } else { Err(format!("{what} failed: {text}")) }
}
pub fn sync_python(dir: &Path, name: &str, packages: &[&str]) -> Result<String, String> {
let uv = uv().ok_or("uv is not installed (https://docs.astral.sh/uv): it makes and syncs the project's environment")?;
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let project = dir.join("pyproject.toml");
if !project.exists() {
std::fs::write(&project, pyproject(name, packages)).map_err(|e| format!("{}: {e}", project.display()))?;
}
run(Command::new(&uv).arg("sync").arg("--project").arg(dir).env_remove("VIRTUAL_ENV"), "uv sync")
}
pub fn add_python(dir: &Path, packages: &[String]) -> Result<String, String> {
let uv = uv().ok_or("uv is not installed")?;
run(Command::new(&uv).arg("add").arg("--project").arg(dir).args(packages).env_remove("VIRTUAL_ENV"), "uv add")
}
pub fn rust_toolchain(dir: &Path) -> Option<String> {
let text = std::fs::read_to_string(dir.join("rust-toolchain.toml")).ok()?;
text.lines().find_map(|l| {
let (k, v) = l.split_once('=')?;
(k.trim() == "channel").then(|| v.trim().trim_matches('"').to_string())
})
}
pub fn rustc_version() -> Option<String> {
let out = Command::new("rustc").arg("--version").output().ok()?;
String::from_utf8_lossy(&out.stdout).split_whitespace().nth(1).map(str::to_string)
}
pub fn ensure_rust(dir: &Path, members: &[&str]) -> Result<Option<String>, String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let manifest = dir.join("Cargo.toml");
if !manifest.exists() {
let list = members.iter().map(|m| format!("\"{m}\"")).collect::<Vec<_>>().join(", ");
std::fs::write(&manifest, format!("# The crates this project's Rust scripts use; Cargo.lock beside it locks them.\n[workspace]\nresolver = \"3\"\nmembers = [{list}]\n")).map_err(|e| format!("{}: {e}", manifest.display()))?;
}
let pin = dir.join("rust-toolchain.toml");
if !pin.exists()
&& let Some(v) = rustc_version()
{
std::fs::write(&pin, format!("# The compiler this project's Rust scripts are built with (rustup reads it).\n[toolchain]\nchannel = \"{v}\"\n")).map_err(|e| format!("{}: {e}", pin.display()))?;
}
Ok(rust_toolchain(dir))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("kitchen-env-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_project_venv_is_found_and_read() {
let d = temp("venv");
let venv = d.join(".venv");
let bin = if cfg!(windows) { venv.join("Scripts") } else { venv.join("bin") };
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(bin.join(if cfg!(windows) { "python.exe" } else { "python" }), "").unwrap();
std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion_info = 3.13.1\n").unwrap();
let site = if cfg!(windows) { venv.join("Lib/site-packages") } else { venv.join("lib/python3.13/site-packages") };
std::fs::create_dir_all(site.join("numpy-2.3.1.dist-info")).unwrap();
std::fs::create_dir_all(site.join("scikit_image-0.25.2.dist-info")).unwrap();
let env = PythonEnv::find(Some(&d), &d.join("default")).expect("the project's venv");
assert_eq!(env.source, PythonSource::Project);
assert_eq!(env.version().as_deref(), Some("3.13.1"));
assert_eq!(env.packages(), vec!["numpy".to_string(), "scikit-image".to_string()]);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_rust_environment_is_written_once() {
let d = temp("rust");
let pinned = ensure_rust(&d, &["build/*"]).unwrap();
let manifest = std::fs::read_to_string(d.join("Cargo.toml")).unwrap();
assert!(manifest.contains("[workspace]") && manifest.contains("\"build/*\""));
if let Some(v) = rustc_version() {
assert_eq!(pinned.as_deref(), Some(v.as_str()));
}
std::fs::write(d.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.90.0\"\n").unwrap();
ensure_rust(&d, &["build/*"]).unwrap();
assert_eq!(rust_toolchain(&d).as_deref(), Some("1.90.0"), "an existing pin stays");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_new_pyproject_is_a_project_of_scripts() {
let p = pyproject("case-12-scripts", &["numpy"]);
assert!(p.contains("name = \"case-12-scripts\"") && p.contains("\"numpy\",") && p.contains("package = false"));
}
}