Skip to main content

kitchen_shell/
env.rs

1//! A project's environments for its scripts: what Python runs them, with which packages, and which
2//! Rust toolchain builds them. The project folder holds them, so a project (and a pipeline built
3//! in it) runs the same on another computer:
4//!
5//! ```text
6//! scripts/
7//!   pyproject.toml  uv.lock  .venv/               Python: the packages, locked; the environment
8//!   Cargo.toml  Cargo.lock  rust-toolchain.toml   Rust: the crates, locked; the toolchain
9//! ```
10//!
11//! - **Python** runs with the environment's own interpreter (`.venv/bin/python`): the project's
12//!   `.venv`, else the one activated (`$VIRTUAL_ENV`), else the app's default environment. uv
13//!   makes and syncs it from `pyproject.toml` and `uv.lock` ([`sync_python`]); the lock, not the
14//!   `.venv`, is what travels (a `.venv` names its base interpreter, so it is made again elsewhere).
15//! - **Rust** has no environment to activate: `Cargo.toml` and `Cargo.lock` are the crates, and
16//!   `rust-toolchain.toml` pins the toolchain (rustup reads it from the folder Cargo runs in).
17//!   [`ensure_rust`] writes them; builds share one target folder.
18
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22/// Where a Python environment comes from.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PythonSource {
25    /// The project's own (`scripts/.venv`).
26    Project,
27    /// The one activated in the shell the app started from (`$VIRTUAL_ENV`).
28    Active,
29    /// The app's default environment (for projects that have none).
30    Default,
31}
32
33impl PythonSource {
34    /// What Settings calls it.
35    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/// A Python environment (a venv).
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct PythonEnv {
47    /// The environment's folder (`.venv`).
48    pub root: PathBuf,
49    /// Its interpreter.
50    pub python: PathBuf,
51    /// Where it comes from.
52    pub source: PythonSource,
53}
54
55/// The interpreter inside the venv at `root`, if there is one.
56pub 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    /// The environment for `scripts` (a project's scripts folder; `None` without a project), else
63    /// the active one, else the default one in `default_root` (when it was made).
64    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    /// A command running this environment's Python (as an activated venv would: `VIRTUAL_ENV`
73    /// set, its `bin` first on the `PATH`).
74    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    /// Its Python version (`3.13.1`), from `pyvenv.cfg` (no process started).
87    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    /// The packages installed (their names, sorted), from the `*.dist-info` folders.
96    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
113/// uv, if found: `$UV`, the `PATH`, then where its installer puts it.
114pub 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
126/// The `pyproject.toml` written for a new environment: a project of scripts, not a package.
127fn 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
138/// Makes or updates the Python environment in `dir` with uv: writes `pyproject.toml` first when
139/// there is none (with `packages`, the app's own needs such as NumPy), then `uv sync` (the
140/// `.venv` matching `uv.lock`, made when missing). Returns uv's report.
141pub 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
151/// Adds `packages` to the environment in `dir` (`uv add`: `pyproject.toml`, `uv.lock` and the
152/// `.venv` together).
153pub 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
158/// The Rust toolchain a folder pins (`channel` of its `rust-toolchain.toml`).
159pub 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
167/// The version of the Rust compiler found (`1.95.0`), the one a new pin names.
168pub 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
173/// Makes `dir` a Rust environment when it is not one yet: a Cargo workspace whose members are the
174/// scripts' crates (`members`, globs allowed), and `rust-toolchain.toml` pinning the compiler found
175/// now. The lock (`Cargo.lock`) is written by the first build. Returns the toolchain pinned.
176pub 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    /// A venv laid out by hand: found in the project, its version and packages read without
204    /// running anything.
205    #[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    /// A Rust environment: a workspace manifest and the toolchain pinned, written once.
224    #[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}