use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
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;
use super::duration::parse_duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum EnvGcReason {
#[serde(rename = "missing_metadata")]
OrphanMissingMetadata,
#[serde(rename = "broken_python")]
OrphanBrokenPython,
Stale,
}
#[derive(Debug, Serialize)]
struct OrphanEnv {
name: String,
path: String,
reason: EnvGcReason,
#[serde(skip_serializing_if = "Option::is_none")]
age_days: Option<u64>,
}
#[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,
SkippedRecentlyUsed,
SkippedNoData,
Failed,
}
#[derive(Debug, Serialize)]
struct EnvRecord {
name: String,
path: String,
reason: EnvGcReason,
#[serde(skip_serializing_if = "Option::is_none")]
age_days: Option<u64>,
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,
older_than: Option<&str>,
) -> Result<()> {
let stale_cutoff = match older_than {
Some(s) => {
let d = parse_duration(s)?;
Some(Utc::now().checked_sub_signed(d).ok_or_else(|| {
crate::error::ScoopError::InvalidArgument {
message: format!("cutoff arithmetic overflowed for --older-than {s}"),
}
})?)
}
None => None,
};
let mut envs = scan_orphan_envs()?;
if let Some(cutoff) = stale_cutoff {
envs.extend(scan_stale_envs(cutoff)?);
envs.sort_by(|a, b| a.name.cmp(&b.name));
}
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,
age_days: o.age_days,
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,
stale_cutoff,
);
}
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,
age_days: None,
});
}
}
orphans.sort_by(|a, b| a.name.cmp(&b.name));
Ok(orphans)
}
fn scan_stale_envs(cutoff: DateTime<Utc>) -> Result<Vec<OrphanEnv>> {
let service = match VirtualenvService::auto() {
Ok(s) => s,
Err(_) => return Ok(Vec::new()),
};
let envs = match service.list() {
Ok(v) => v,
Err(_) => return Ok(Vec::new()),
};
let mut stale = Vec::new();
for info in envs {
if classify(&info.path).is_some() {
continue;
}
let Some(last_used) = info.last_used else {
continue;
};
if last_used >= cutoff {
continue;
}
let age_days = (Utc::now() - last_used).num_days().max(0) as u64;
stale.push(OrphanEnv {
name: info.name.clone(),
path: info.path.display().to_string(),
reason: EnvGcReason::Stale,
age_days: Some(age_days),
});
}
Ok(stale)
}
fn classify(path: &Path) -> Option<EnvGcReason> {
if !path.join(".scoop-metadata.json").exists() {
return Some(EnvGcReason::OrphanMissingMetadata);
}
let bin = if cfg!(windows) {
path.join("Scripts").join("python.exe")
} else {
path.join("bin").join("python")
};
if !bin.exists() {
return Some(EnvGcReason::OrphanBrokenPython);
}
None
}
fn recheck_stale(name: &str, cutoff: DateTime<Utc>) -> Option<EnvOutcome> {
if crate::validate::validate_env_name(name).is_err() {
return Some(EnvOutcome::SkippedNoData);
}
let path = match paths::virtualenv_path(name) {
Ok(p) => p,
Err(_) => return Some(EnvOutcome::SkippedNoData),
};
let service = match VirtualenvService::auto() {
Ok(s) => s,
Err(_) => return Some(EnvOutcome::SkippedNoData),
};
let meta = match service.read_metadata_result(&path) {
Ok(Some(m)) => m,
_ => return Some(EnvOutcome::SkippedNoData),
};
let Some(last_used) = meta.last_used else {
return Some(EnvOutcome::SkippedNoData);
};
if last_used >= cutoff {
return Some(EnvOutcome::SkippedRecentlyUsed);
}
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],
stale_cutoff: Option<DateTime<Utc>>,
) {
for (env, record) in envs.iter().zip(env_records.iter_mut()) {
let path = PathBuf::from(&env.path);
match env.reason {
EnvGcReason::OrphanMissingMetadata | EnvGcReason::OrphanBrokenPython => {
if classify(&path).is_none() {
output.warn(&t!("gc.skipped_now_healthy", name = &env.name));
record.outcome = EnvOutcome::SkippedHealthy;
continue;
}
}
EnvGcReason::Stale => {
let cutoff = match stale_cutoff {
Some(c) => c,
None => {
record.outcome = EnvOutcome::SkippedNoData;
continue;
}
};
if let Some(skip_outcome) = recheck_stale(&env.name, cutoff) {
let msg_key = match skip_outcome {
EnvOutcome::SkippedRecentlyUsed => "gc.skipped_recently_used",
EnvOutcome::SkippedNoData => "gc.skipped_no_data",
_ => "gc.skipped_now_healthy",
};
output.warn(&t!(msg_key, name = &env.name));
record.outcome = skip_outcome;
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) if e.kind() == std::io::ErrorKind::NotFound => {
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 {
EnvGcReason::OrphanMissingMetadata => t!("gc.reason_missing_metadata").to_string(),
EnvGcReason::OrphanBrokenPython => t!("gc.reason_broken_python").to_string(),
EnvGcReason::Stale => {
let days = env.age_days.map(|n| n.to_string()).unwrap_or("?".into());
t!("gc.reason_stale", days = days).to_string()
}
};
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;