use std::path::{Path, PathBuf};
use rust_i18n::t;
use serde::Serialize;
use crate::core::VirtualenvService;
use crate::error::Result;
use crate::output::Output;
use crate::paths::{self, abbreviate_home};
use crate::uv::UvClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum OrphanReason {
MissingMetadata,
BrokenPython,
}
#[derive(Debug, Serialize)]
struct OrphanEnv {
name: String,
path: String,
reason: OrphanReason,
}
#[derive(Debug, Serialize)]
struct UnusedPython {
version: String,
path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum EnvOutcome {
Pending,
Removed,
SkippedHealthy,
Failed,
}
#[derive(Debug, Serialize)]
struct EnvRecord {
name: String,
path: String,
reason: OrphanReason,
outcome: EnvOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum PythonOutcome {
Pending,
Removed,
SkippedInUse,
SkippedNoUv,
Failed,
}
#[derive(Debug, Serialize)]
struct PythonRecord {
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
outcome: PythonOutcome,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Debug, Serialize)]
struct GcData {
dry_run: bool,
envs: Vec<EnvRecord>,
pythons: Vec<PythonRecord>,
}
pub fn execute(output: &Output, yes: bool, aggressive: bool) -> Result<()> {
let envs = scan_orphan_envs()?;
let (pythons, unreadable_envs) = if aggressive {
scan_unused_pythons(&envs)?
} else {
(Vec::new(), 0)
};
if aggressive && unreadable_envs > 0 {
output.warn(&t!(
"gc.unreadable_metadata_warn",
count = unreadable_envs.to_string()
));
}
let mut env_records: Vec<EnvRecord> = envs
.iter()
.map(|o| EnvRecord {
name: o.name.clone(),
path: o.path.clone(),
reason: o.reason,
outcome: EnvOutcome::Pending,
error: None,
})
.collect();
let mut python_records: Vec<PythonRecord> = pythons
.iter()
.map(|p| PythonRecord {
version: p.version.clone(),
path: p.path.clone(),
outcome: PythonOutcome::Pending,
error: None,
})
.collect();
if yes {
remove_orphans(
output,
&envs,
&pythons,
&mut env_records,
&mut python_records,
);
}
let data = GcData {
dry_run: !yes,
envs: env_records,
pythons: python_records,
};
if output.is_json() {
output.json_success("gc", data);
return Ok(());
}
render_human(output, &data, aggressive);
Ok(())
}
fn scan_orphan_envs() -> Result<Vec<OrphanEnv>> {
let dir = paths::virtualenvs_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let mut orphans = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let ft = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if !ft.is_dir() || ft.is_symlink() {
continue;
}
let path = entry.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('.') {
continue;
}
if let Some(reason) = classify(&path) {
orphans.push(OrphanEnv {
name,
path: path.display().to_string(),
reason,
});
}
}
orphans.sort_by(|a, b| a.name.cmp(&b.name));
Ok(orphans)
}
fn classify(path: &Path) -> Option<OrphanReason> {
if !path.join(".scoop-metadata.json").exists() {
return Some(OrphanReason::MissingMetadata);
}
let bin = if cfg!(windows) {
path.join("Scripts").join("python.exe")
} else {
path.join("bin").join("python")
};
if !bin.exists() {
return Some(OrphanReason::BrokenPython);
}
None
}
fn scan_unused_pythons(orphans: &[OrphanEnv]) -> Result<(Vec<UnusedPython>, usize)> {
let uv = match UvClient::new() {
Ok(u) => u,
Err(_) => return Ok((Vec::new(), 0)),
};
let installed = uv.list_installed_pythons().unwrap_or_default();
if installed.is_empty() {
return Ok((Vec::new(), 0));
}
let service = VirtualenvService::auto().ok();
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut unreadable_envs: usize = 0;
if let Some(svc) = service {
for info in svc.list().unwrap_or_default() {
if orphans.iter().any(|o| o.name == info.name) {
continue;
}
let path = match paths::virtualenv_path(&info.name) {
Ok(p) => p,
Err(_) => {
unreadable_envs += 1;
continue;
}
};
match svc.read_metadata(&path) {
Some(meta) => {
used.insert(meta.python_version);
}
None => {
unreadable_envs += 1;
}
}
}
}
if unreadable_envs > 0 {
return Ok((Vec::new(), unreadable_envs));
}
Ok((
installed
.into_iter()
.filter(|p| !used.contains(&p.version))
.map(|p| UnusedPython {
version: p.version,
path: p.path.map(|p| p.display().to_string()),
})
.collect(),
0,
))
}
fn remove_orphans(
output: &Output,
envs: &[OrphanEnv],
pythons: &[UnusedPython],
env_records: &mut [EnvRecord],
python_records: &mut [PythonRecord],
) {
for (env, record) in envs.iter().zip(env_records.iter_mut()) {
let path = PathBuf::from(&env.path);
if classify(&path).is_none() {
output.warn(&t!("gc.skipped_now_healthy", name = &env.name));
record.outcome = EnvOutcome::SkippedHealthy;
continue;
}
match std::fs::remove_dir_all(&path) {
Ok(()) => {
if !output.is_json() {
output.info(&t!("gc.removed_env", name = &env.name));
}
record.outcome = EnvOutcome::Removed;
}
Err(e) => {
let detail = e.to_string();
output.warn(&t!(
"gc.remove_env_failed",
name = &env.name,
error = detail.clone()
));
record.outcome = EnvOutcome::Failed;
record.error = Some(detail);
}
}
}
if !pythons.is_empty() {
let uv = match UvClient::new() {
Ok(u) => u,
Err(_) => {
for rec in python_records.iter_mut() {
rec.outcome = PythonOutcome::SkippedNoUv;
}
return;
}
};
let still_unused: std::collections::HashSet<String> = scan_unused_pythons(envs)
.map_or_else(
|_| pythons.iter().map(|p| p.version.clone()).collect(),
|(current, _)| current.into_iter().map(|p| p.version).collect(),
);
for (py, record) in pythons.iter().zip(python_records.iter_mut()) {
if !still_unused.contains(&py.version) {
output.warn(&t!("gc.skipped_python_now_in_use", version = &py.version));
record.outcome = PythonOutcome::SkippedInUse;
continue;
}
match uv.uninstall_python(&py.version) {
Ok(()) => {
if !output.is_json() {
output.info(&t!("gc.removed_python", version = &py.version));
}
record.outcome = PythonOutcome::Removed;
}
Err(e) => {
let detail = e.to_string();
output.warn(&t!(
"gc.remove_python_failed",
version = &py.version,
error = detail.clone()
));
record.outcome = PythonOutcome::Failed;
record.error = Some(detail);
}
}
}
}
}
fn render_human(output: &Output, data: &GcData, aggressive: bool) {
if data.envs.is_empty() && data.pythons.is_empty() {
output.success(&t!("gc.nothing_to_remove"));
return;
}
if !data.envs.is_empty() {
output.info(&t!("gc.envs_header", count = data.envs.len().to_string()));
for env in &data.envs {
let reason = match env.reason {
OrphanReason::MissingMetadata => t!("gc.reason_missing_metadata"),
OrphanReason::BrokenPython => t!("gc.reason_broken_python"),
};
println!(
" - {} ({}) {}",
env.name,
reason,
abbreviate_home(Path::new(&env.path))
);
}
}
if aggressive && !data.pythons.is_empty() {
output.info(&t!(
"gc.pythons_header",
count = data.pythons.len().to_string()
));
for py in &data.pythons {
println!(" - Python {}", py.version);
}
}
if data.dry_run {
output.info(&t!("gc.dry_run_hint"));
} else {
output.success(&t!("gc.done"));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::with_temp_scoop_home;
use serial_test::serial;
use std::fs;
fn make_env(dir: &Path, name: &str, with_metadata: bool, with_python: bool) {
let env_dir = dir.join(name);
fs::create_dir_all(&env_dir).unwrap();
if with_metadata {
fs::write(env_dir.join(".scoop-metadata.json"), "{}").unwrap();
}
if with_python {
let bin = if cfg!(windows) {
env_dir.join("Scripts")
} else {
env_dir.join("bin")
};
fs::create_dir_all(&bin).unwrap();
let py = if cfg!(windows) {
bin.join("python.exe")
} else {
bin.join("python")
};
fs::write(&py, "").unwrap();
}
}
#[test]
#[serial]
fn classifies_missing_metadata_as_orphan() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "no-meta", false, true);
let orphans = scan_orphan_envs().unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].name, "no-meta");
assert_eq!(orphans[0].reason, OrphanReason::MissingMetadata);
});
}
#[test]
#[serial]
fn classifies_broken_python_as_orphan() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "no-python", true, false);
let orphans = scan_orphan_envs().unwrap();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0].name, "no-python");
assert_eq!(orphans[0].reason, OrphanReason::BrokenPython);
});
}
#[test]
#[serial]
fn healthy_env_is_not_an_orphan() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "ok", true, true);
let orphans = scan_orphan_envs().unwrap();
assert_eq!(orphans.len(), 0);
});
}
#[test]
#[serial]
fn dotfile_entries_are_skipped() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(dir.join(".cache")).unwrap();
let orphans = scan_orphan_envs().unwrap();
assert!(orphans.is_empty());
});
}
#[test]
#[serial]
fn dry_run_does_not_remove() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "no-meta", false, true);
let output = Output::new(0, true, true, false);
execute(&output, false, false).unwrap();
assert!(
dir.join("no-meta").exists(),
"dry-run must not delete orphans"
);
});
}
#[test]
#[serial]
fn yes_actually_removes_orphans() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "no-meta", false, true);
let output = Output::new(0, true, true, false);
execute(&output, true, false).unwrap();
assert!(!dir.join("no-meta").exists(), "--yes should remove orphans");
});
}
#[cfg(unix)]
#[test]
#[serial]
fn scan_skips_symlink_entries() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
let outside = tempfile::TempDir::new().unwrap();
let canary = outside.path().join("important.txt");
fs::write(&canary, b"do not delete").unwrap();
std::os::unix::fs::symlink(outside.path(), dir.join("evil")).unwrap();
let orphans = scan_orphan_envs().unwrap();
assert!(
orphans.iter().all(|o| o.name != "evil"),
"symlink must not be classified as an orphan: {:?}",
orphans
);
let output = Output::new(0, true, true, false);
execute(&output, true, false).unwrap();
assert!(
canary.exists(),
"gc --yes followed the symlink and deleted the target"
);
});
}
#[test]
#[serial]
fn aggressive_bails_when_metadata_unreadable() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
let env_path = dir.join("corrupt");
fs::create_dir_all(env_path.join("bin")).unwrap();
fs::write(env_path.join("bin/python"), "").unwrap();
fs::write(env_path.join(".scoop-metadata.json"), "{ not json").unwrap();
let orphans = scan_orphan_envs().unwrap();
assert!(orphans.iter().all(|o| o.name != "corrupt"));
let (pythons, unreadable_envs) = scan_unused_pythons(&orphans).unwrap();
assert_eq!(unreadable_envs, 1, "should count one unreadable env");
assert!(
pythons.is_empty(),
"must not claim any Python is unused when metadata is unreadable; got {:?}",
pythons
);
});
}
#[test]
#[serial]
fn remove_skips_env_that_became_healthy() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
let env_path = dir.join("racy");
fs::create_dir_all(env_path.join("bin")).unwrap();
fs::write(env_path.join("bin/python"), "").unwrap();
fs::write(env_path.join(".scoop-metadata.json"), "{}").unwrap();
let stale_orphan = OrphanEnv {
name: "racy".to_string(),
path: env_path.display().to_string(),
reason: OrphanReason::MissingMetadata,
};
let mut env_records = vec![EnvRecord {
name: stale_orphan.name.clone(),
path: stale_orphan.path.clone(),
reason: stale_orphan.reason,
outcome: EnvOutcome::Pending,
error: None,
}];
let output = Output::new(0, true, true, false);
remove_orphans(
&output,
&[stale_orphan],
&[],
&mut env_records,
&mut Vec::<PythonRecord>::new(),
);
assert!(
env_path.exists(),
"remove_orphans deleted an env that re-classified as healthy"
);
assert_eq!(
env_records[0].outcome,
EnvOutcome::SkippedHealthy,
"outcome must be SkippedHealthy so JSON consumers see the skip"
);
});
}
#[test]
#[serial]
fn remove_records_actual_outcomes_for_each_env() {
with_temp_scoop_home(|_| {
let dir = paths::virtualenvs_dir().unwrap();
fs::create_dir_all(&dir).unwrap();
make_env(&dir, "ghost-a", false, true);
make_env(&dir, "ghost-b", false, true);
let orphans = scan_orphan_envs().unwrap();
assert_eq!(orphans.len(), 2);
let mut env_records: Vec<EnvRecord> = orphans
.iter()
.map(|o| EnvRecord {
name: o.name.clone(),
path: o.path.clone(),
reason: o.reason,
outcome: EnvOutcome::Pending,
error: None,
})
.collect();
let output = Output::new(0, true, true, false);
remove_orphans(
&output,
&orphans,
&[],
&mut env_records,
&mut Vec::<PythonRecord>::new(),
);
for record in &env_records {
assert_eq!(
record.outcome,
EnvOutcome::Removed,
"expected Removed outcome for {}, got {:?}",
record.name,
record.outcome
);
assert!(record.error.is_none());
}
});
}
}