From 33bc78f9a543eeb2190266e35310f9af977ef7f5 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Fri, 27 Mar 2026 20:41:30 +0100 Subject: [PATCH] feat(status): add status command to show venv information This pull request adds a new `status` command that displays detailed information about the current virtual environment, including its location, Python version, activation state, and installed package count. The most important changes are grouped below. **New status command functionality:** * Added a new `status()` function in `src/main.rs` that searches for a virtual environment in the current directory or parent directories, displays venv path, Python version from `pyvenv.cfg`, prompt name, activation status (by checking `$VIRTUAL_ENV`), and the count of installed packages. * Implemented `count_packages()` helper function that counts installed packages by enumerating `.dist-info` directories in the site-packages folder, with cross-platform support for both Unix and Windows directory structures. * Integrated the status command into the CLI argument parser with proper help text, and exits with non-zero status code when no venv is found. **Testing improvements:** * Added comprehensive integration tests in `tests/integration.rs` covering three scenarios: displaying venv info when present, exiting with error when no venv exists, and finding venv in parent directories when run from subdirectories. --- src/main.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++ tests/integration.rs | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/main.rs b/src/main.rs index fabce83..a6385e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -149,6 +149,70 @@ fn update_prompt(venv_path: &PathBuf, prefix: Option<&str>) { let _ = fs::write(&cfg_path, new_content); } +fn status() { + let cwd = env::current_dir().expect("failed to get current directory"); + let venv_name = resolve_venv_name(None); + let venv_path = match find_venv_upward(&cwd, &venv_name) { + Some(p) => p, + None => { + eprintln!("uv-shell status: no {} found in {} or any parent directory", venv_name, cwd.display()); + std::process::exit(1); + } + }; + + let cfg_path = venv_path.join("pyvenv.cfg"); + let cfg = fs::read_to_string(&cfg_path).unwrap_or_default(); + + // Parse version_info and prompt from pyvenv.cfg + let python_version = cfg.lines() + .find_map(|l| l.strip_prefix("version_info = ")) + .unwrap_or("unknown"); + let prompt = cfg.lines() + .find_map(|l| l.strip_prefix("prompt = ")) + .unwrap_or("(none)"); + + // Activated if $VIRTUAL_ENV points at this venv + let active_venv = env::var("VIRTUAL_ENV").ok(); + let activated = active_venv + .as_ref() + .map(|v| PathBuf::from(v) == venv_path) + .unwrap_or(false); + + // Count packages by .dist-info dirs in site-packages + let package_count = count_packages(&venv_path); + + println!("venv: {}", venv_path.display()); + println!("python: {python_version}"); + println!("prompt: {prompt}"); + println!("activated: {}", if activated { "yes" } else { "no" }); + println!("packages: {package_count}"); +} + +/// Count installed packages by .dist-info directories in site-packages. +fn count_packages(venv_path: &PathBuf) -> usize { + // Unix: lib/pythonX.Y/site-packages, Windows: Lib/site-packages + let lib_dir = if cfg!(windows) { + venv_path.join("Lib").join("site-packages") + } else { + // Find the first lib/pythonX.Y directory + let lib = venv_path.join("lib"); + fs::read_dir(&lib) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .find(|e| e.file_name().to_string_lossy().starts_with("python")) + .map(|e| e.path().join("site-packages")) + .unwrap_or_else(|| lib.join("site-packages")) + }; + + fs::read_dir(&lib_dir) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".dist-info")) + .count() +} + fn activate_venv(venv_path: &PathBuf) { let bin_dir = get_bin_dir(); let venv_bin = venv_path.join(bin_dir); @@ -293,6 +357,7 @@ Usage: uv-shell [OPTIONS] uv-shell completions Commands: + status Show venv info: path, python version, activated state, package count anchor Print export commands for shell rc eval (auto-detects shell) bash/zsh : eval \"$(uv-shell anchor)\" fish : uv-shell anchor | source @@ -411,6 +476,10 @@ fn main() { println!("uv-shell {}", env!("CARGO_PKG_VERSION")); return; } + "status" => { + status(); + return; + } "anchor" => { let anchor_args = &user_args[1..]; let shell_override = parse_anchor_shell(anchor_args); diff --git a/tests/integration.rs b/tests/integration.rs index a943b96..d7b759c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -396,6 +396,62 @@ fn python_version_forwarded() { // ── completions ───────────────────────────────────────────────── +// ── status ─────────────────────────────────────────────────────── + +#[test] +fn status_shows_venv_info() { + let dir = tmpdir_with_venv("status-basic"); + + let out = Command::new(bin()) + .arg("status") + .current_dir(&dir) + .output() + .unwrap(); + assert!(out.status.success()); + + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains("venv:"), "missing venv line"); + assert!(stdout.contains("python:"), "missing python line"); + assert!(stdout.contains("activated:"), "missing activated line"); + assert!(stdout.contains("packages:"), "missing packages line"); + assert!(stdout.contains("no"), "should not be activated"); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn status_no_venv_exits_nonzero() { + let dir = tmpdir("status-none"); + + let out = Command::new(bin()) + .arg("status") + .current_dir(&dir) + .output() + .unwrap(); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("no .venv found")); + let _ = fs::remove_dir_all(&dir); +} + +#[test] +fn status_finds_venv_in_parent() { + let dir = tmpdir_with_venv("status-traversal"); + let subdir = dir.join("src"); + fs::create_dir_all(&subdir).unwrap(); + + let out = Command::new(bin()) + .arg("status") + .current_dir(&subdir) + .output() + .unwrap(); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.contains(dir.join(".venv").to_str().unwrap())); + let _ = fs::remove_dir_all(&dir); +} + +// ── completions ────────────────────────────────────────────────── + #[test] fn completions_bash() { let out = Command::new(bin())