use anyhow::{bail, Result};
use std::collections::HashMap;
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
const PROTECTED_PREFIXES: &[&str] = &[
"/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/root",
"/run", "/sbin", "/sys", "/usr", "/var/lib", "/var/log",
];
const NEVER_CLEAN_FRAGMENTS: &[&str] = &[
"/.steam/",
"/Steam/",
"/.local/share/Steam/",
"/lutris/",
"/.local/share/lutris/",
"/.local/share/flatpak/",
"/.var/app/",
"/snap/",
"/.local/share/snap/",
"/var/lib/snapd/",
];
pub fn is_game_or_store_dir(path: &Path) -> bool {
let s = path.to_string_lossy();
NEVER_CLEAN_FRAGMENTS.iter().any(|f| s.contains(f))
}
pub fn is_protected(path: &Path) -> bool {
let canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if canon.components().count() <= 1 {
return true;
}
PROTECTED_PREFIXES.iter().any(|p| canon.starts_with(Path::new(p)))
}
pub struct OpenBy {
pub pid: i32,
pub process: String,
}
fn current_uid() -> u32 {
std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| s.lines().find(|l| l.starts_with("Uid:")).map(|l| l.to_string()))
.and_then(|l| l.split_whitespace().nth(1).map(|s| s.to_string()))
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn scan_open_files() -> HashMap<PathBuf, Vec<OpenBy>> {
let mut open: HashMap<PathBuf, Vec<OpenBy>> = HashMap::new();
let Ok(procs) = fs::read_dir("/proc") else { return open };
for entry in procs.flatten() {
let pid_dir = entry.path();
let Some(pid_str) = pid_dir.file_name().and_then(|n| n.to_str()) else { continue };
let Ok(pid) = pid_str.parse::<i32>() else { continue }; let process = fs::read_to_string(pid_dir.join("comm")).unwrap_or_else(|_| "?".into()).trim().to_string();
let mut targets: Vec<PathBuf> = Vec::new();
if let Ok(fds) = fs::read_dir(pid_dir.join("fd")) {
for fd in fds.flatten() {
if let Ok(t) = fs::read_link(fd.path()) { targets.push(t); }
}
}
if let Ok(t) = fs::read_link(pid_dir.join("exe")) { targets.push(t); }
if let Ok(t) = fs::read_link(pid_dir.join("cwd")) { targets.push(t); }
for t in targets {
if t.is_absolute() {
open.entry(t).or_default().push(OpenBy { pid, process: process.clone() });
}
}
}
open
}
fn who_has_open(path: &Path, open: &HashMap<PathBuf, Vec<OpenBy>>) -> Option<String> {
let mut names: Vec<String> = Vec::new();
for (p, users) in open {
if p == path || p.starts_with(path) {
for u in users {
let label = format!("{} (pid {})", u.process, u.pid);
if !names.contains(&label) { names.push(label); }
}
}
}
if names.is_empty() { None } else { Some(names.join(", ")) }
}
pub struct AtRiskEntry {
pub path: PathBuf,
pub reason: String,
}
pub struct CleanPlan {
pub keep: Vec<PathBuf>,
pub remove: Vec<PathBuf>,
pub total_size: u64,
pub blocked: Vec<PathBuf>,
pub at_risk: Vec<AtRiskEntry>,
}
pub fn build_plan(candidates: Vec<(PathBuf, u64)>) -> CleanPlan {
let my_uid = current_uid();
let open_files = scan_open_files();
let mut remove = Vec::new();
let mut blocked = Vec::new();
let mut at_risk = Vec::new();
let mut total_size = 0u64;
for (path, size) in candidates {
if is_protected(&path) {
blocked.push(path);
continue;
}
if let Ok(meta) = fs::metadata(&path) {
if meta.uid() != my_uid {
at_risk.push(AtRiskEntry { path, reason: format!("owned by another user (uid {})", meta.uid()) });
continue;
}
}
if let Some(who) = who_has_open(&path, &open_files) {
at_risk.push(AtRiskEntry { path, reason: format!("currently open by {who}") });
continue;
}
total_size += size;
remove.push(path);
}
CleanPlan { keep: Vec::new(), remove, total_size, blocked, at_risk }
}
pub fn execute_plan(plan: &CleanPlan, dry_run: bool) -> Result<usize> {
if dry_run {
return Ok(plan.remove.len());
}
if !plan.blocked.is_empty() {
bail!("{} protected paths were excluded from this plan and must not be removed", plan.blocked.len());
}
let refs: Vec<&Path> = plan.remove.iter().map(|p| p.as_path()).collect();
super::trash::move_to_trash(&refs)
}
pub fn dedup_with_hardlink(keep: &Path, remove: &Path) -> Result<()> {
if is_protected(keep) || is_protected(remove) {
bail!("refusing to touch a protected path");
}
fs::remove_file(remove)?;
fs::hard_link(keep, remove)?;
Ok(())
}