1use std::path::{Path, PathBuf};
20use std::process::Command;
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PythonSource {
25 Project,
27 Active,
29 Default,
31}
32
33impl PythonSource {
34 pub fn title(self) -> &'static str {
36 match self {
37 PythonSource::Project => "the project's",
38 PythonSource::Active => "the active one ($VIRTUAL_ENV)",
39 PythonSource::Default => "the app's default",
40 }
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct PythonEnv {
47 pub root: PathBuf,
49 pub python: PathBuf,
51 pub source: PythonSource,
53}
54
55pub fn venv_python(root: &Path) -> Option<PathBuf> {
57 let candidates: &[&str] = if cfg!(windows) { &["Scripts/python.exe"] } else { &["bin/python", "bin/python3"] };
58 candidates.iter().map(|c| root.join(c)).find(|p| p.exists())
59}
60
61impl PythonEnv {
62 pub fn find(scripts: Option<&Path>, default_root: &Path) -> Option<PythonEnv> {
65 let at = |root: PathBuf, source| venv_python(&root).map(|python| PythonEnv { root, python, source });
66 scripts
67 .and_then(|d| at(d.join(".venv"), PythonSource::Project))
68 .or_else(|| std::env::var_os("VIRTUAL_ENV").filter(|v| !v.is_empty()).and_then(|v| at(PathBuf::from(v), PythonSource::Active)))
69 .or_else(|| at(default_root.to_path_buf(), PythonSource::Default))
70 }
71
72 pub fn command(&self) -> Command {
75 let mut c = Command::new(&self.python);
76 c.env("VIRTUAL_ENV", &self.root);
77 if let Some(bin) = self.python.parent() {
78 let path = std::env::var_os("PATH").unwrap_or_default();
79 let joined = std::env::join_paths(std::iter::once(bin.to_path_buf()).chain(std::env::split_paths(&path))).unwrap_or(path);
80 c.env("PATH", joined);
81 }
82 c.env_remove("PYTHONHOME");
83 c
84 }
85
86 pub fn version(&self) -> Option<String> {
88 let cfg = std::fs::read_to_string(self.root.join("pyvenv.cfg")).ok()?;
89 cfg.lines().find_map(|l| {
90 let (k, v) = l.split_once('=')?;
91 matches!(k.trim(), "version" | "version_info").then(|| v.trim().to_string())
92 })
93 }
94
95 pub fn packages(&self) -> Vec<String> {
97 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() };
98 let mut out: Vec<String> = lib
99 .iter()
100 .flat_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten())
101 .filter_map(|e| {
102 let name = e.file_name().to_string_lossy().into_owned();
103 let stem = name.strip_suffix(".dist-info")?;
104 Some(stem.rsplit_once('-').map_or(stem, |(n, _)| n).replace('_', "-").to_lowercase())
105 })
106 .collect();
107 out.sort();
108 out.dedup();
109 out
110 }
111}
112
113pub fn uv() -> Option<PathBuf> {
115 if let Some(p) = std::env::var_os("UV").map(PathBuf::from).filter(|p| p.is_file()) {
116 return Some(p);
117 }
118 let exe = if cfg!(windows) { "uv.exe" } else { "uv" };
119 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()));
120 on_path.or_else(|| {
121 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)?;
122 [home.join(".local/bin").join(exe), home.join(".cargo/bin").join(exe)].into_iter().find(|c| c.is_file())
123 })
124}
125
126fn pyproject(name: &str, packages: &[&str]) -> String {
128 let deps = packages.iter().map(|p| format!(" \"{p}\",")).collect::<Vec<_>>().join("\n");
129 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")
130}
131
132fn run(cmd: &mut Command, what: &str) -> Result<String, String> {
133 let out = cmd.output().map_err(|e| format!("{what}: {e}"))?;
134 let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)).trim().to_string();
135 if out.status.success() { Ok(text) } else { Err(format!("{what} failed: {text}")) }
136}
137
138pub fn sync_python(dir: &Path, name: &str, packages: &[&str]) -> Result<String, String> {
142 let uv = uv().ok_or("uv is not installed (https://docs.astral.sh/uv): it makes and syncs the project's environment")?;
143 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
144 let project = dir.join("pyproject.toml");
145 if !project.exists() {
146 std::fs::write(&project, pyproject(name, packages)).map_err(|e| format!("{}: {e}", project.display()))?;
147 }
148 run(Command::new(&uv).arg("sync").arg("--project").arg(dir).env_remove("VIRTUAL_ENV"), "uv sync")
149}
150
151pub fn add_python(dir: &Path, packages: &[String]) -> Result<String, String> {
154 let uv = uv().ok_or("uv is not installed")?;
155 run(Command::new(&uv).arg("add").arg("--project").arg(dir).args(packages).env_remove("VIRTUAL_ENV"), "uv add")
156}
157
158pub fn rust_toolchain(dir: &Path) -> Option<String> {
160 let text = std::fs::read_to_string(dir.join("rust-toolchain.toml")).ok()?;
161 text.lines().find_map(|l| {
162 let (k, v) = l.split_once('=')?;
163 (k.trim() == "channel").then(|| v.trim().trim_matches('"').to_string())
164 })
165}
166
167pub fn rustc_version() -> Option<String> {
169 let out = Command::new("rustc").arg("--version").output().ok()?;
170 String::from_utf8_lossy(&out.stdout).split_whitespace().nth(1).map(str::to_string)
171}
172
173pub fn ensure_rust(dir: &Path, members: &[&str]) -> Result<Option<String>, String> {
177 std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
178 let manifest = dir.join("Cargo.toml");
179 if !manifest.exists() {
180 let list = members.iter().map(|m| format!("\"{m}\"")).collect::<Vec<_>>().join(", ");
181 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()))?;
182 }
183 let pin = dir.join("rust-toolchain.toml");
184 if !pin.exists()
185 && let Some(v) = rustc_version()
186 {
187 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()))?;
188 }
189 Ok(rust_toolchain(dir))
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn temp(name: &str) -> PathBuf {
197 let d = std::env::temp_dir().join(format!("kitchen-env-{name}-{}", std::process::id()));
198 let _ = std::fs::remove_dir_all(&d);
199 std::fs::create_dir_all(&d).unwrap();
200 d
201 }
202
203 #[test]
206 fn a_project_venv_is_found_and_read() {
207 let d = temp("venv");
208 let venv = d.join(".venv");
209 let bin = if cfg!(windows) { venv.join("Scripts") } else { venv.join("bin") };
210 std::fs::create_dir_all(&bin).unwrap();
211 std::fs::write(bin.join(if cfg!(windows) { "python.exe" } else { "python" }), "").unwrap();
212 std::fs::write(venv.join("pyvenv.cfg"), "home = /usr/bin\nversion_info = 3.13.1\n").unwrap();
213 let site = if cfg!(windows) { venv.join("Lib/site-packages") } else { venv.join("lib/python3.13/site-packages") };
214 std::fs::create_dir_all(site.join("numpy-2.3.1.dist-info")).unwrap();
215 std::fs::create_dir_all(site.join("scikit_image-0.25.2.dist-info")).unwrap();
216 let env = PythonEnv::find(Some(&d), &d.join("default")).expect("the project's venv");
217 assert_eq!(env.source, PythonSource::Project);
218 assert_eq!(env.version().as_deref(), Some("3.13.1"));
219 assert_eq!(env.packages(), vec!["numpy".to_string(), "scikit-image".to_string()]);
220 std::fs::remove_dir_all(&d).ok();
221 }
222
223 #[test]
225 fn a_rust_environment_is_written_once() {
226 let d = temp("rust");
227 let pinned = ensure_rust(&d, &["build/*"]).unwrap();
228 let manifest = std::fs::read_to_string(d.join("Cargo.toml")).unwrap();
229 assert!(manifest.contains("[workspace]") && manifest.contains("\"build/*\""));
230 if let Some(v) = rustc_version() {
231 assert_eq!(pinned.as_deref(), Some(v.as_str()));
232 }
233 std::fs::write(d.join("rust-toolchain.toml"), "[toolchain]\nchannel = \"1.90.0\"\n").unwrap();
234 ensure_rust(&d, &["build/*"]).unwrap();
235 assert_eq!(rust_toolchain(&d).as_deref(), Some("1.90.0"), "an existing pin stays");
236 std::fs::remove_dir_all(&d).ok();
237 }
238
239 #[test]
240 fn a_new_pyproject_is_a_project_of_scripts() {
241 let p = pyproject("case-12-scripts", &["numpy"]);
242 assert!(p.contains("name = \"case-12-scripts\"") && p.contains("\"numpy\",") && p.contains("package = false"));
243 }
244}