use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::error::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GeneratedPathClass {
Committed,
Ignored,
Cleaned,
Kept,
}
#[derive(Debug, Clone)]
pub struct GeneratedPath {
pub path: PathBuf,
pub class: GeneratedPathClass,
}
#[derive(Debug, Clone)]
struct PendingPath {
path: PathBuf,
target_root: PathBuf,
}
#[derive(Debug, Default)]
struct CleanupTracker {
pending: Vec<PendingPath>,
}
#[derive(Debug)]
pub struct CleanupGuard {
inner: Mutex<CleanupTracker>,
keep_output: bool,
cleaned: AtomicBool,
}
impl CleanupGuard {
pub fn new(keep_output: bool) -> Self {
Self {
inner: Mutex::new(CleanupTracker::default()),
keep_output,
cleaned: AtomicBool::new(false),
}
}
pub fn track(&self, path: PathBuf, target_root: &Path) {
let absolute = if path.is_absolute() {
path
} else {
target_root.join(&path)
};
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
guard.pending.push(PendingPath {
path: absolute,
target_root: target_root.to_path_buf(),
});
}
pub fn finalize(self) -> Result<Vec<GeneratedPath>, Error> {
self.run_cleanup_once()
}
fn run_cleanup_once(&self) -> Result<Vec<GeneratedPath>, Error> {
if self.cleaned.swap(true, Ordering::SeqCst) {
return Ok(Vec::new());
}
let pending = {
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
std::mem::take(&mut guard.pending)
};
let mut classified: Vec<GeneratedPath> = Vec::with_capacity(pending.len());
let mut first_error: Option<Error> = None;
let mut additional_failed_paths: Vec<PathBuf> = Vec::new();
for entry in pending {
let final_class = classify_entry(&entry, self.keep_output);
if final_class == GeneratedPathClass::Cleaned
&& let Err(err) = remove_path_best_effort(&entry.path)
{
if first_error.is_none() {
first_error = Some(err);
} else {
additional_failed_paths.push(entry.path.clone());
}
}
classified.push(GeneratedPath {
path: entry.path,
class: final_class,
});
}
if let Some(err) = first_error {
return Err(aggregate_cleanup_error(err, additional_failed_paths));
}
classified.sort_by(|a, b| a.path.cmp(&b.path));
Ok(classified)
}
}
fn aggregate_cleanup_error(first_error: Error, additional: Vec<PathBuf>) -> Error {
if additional.is_empty() {
return first_error;
}
match first_error {
Error::Io {
source,
context,
path,
} => {
let suffix = additional
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
Error::Io {
source,
context: format!(
"{context} (and {} other compat-generated path(s) also failed to remove: {suffix})",
additional.len(),
),
path,
}
}
other => other,
}
}
fn classify_entry(entry: &PendingPath, keep_output: bool) -> GeneratedPathClass {
let class = classify(&entry.target_root, &entry.path);
match (class, keep_output) {
(GeneratedPathClass::Cleaned, true) => GeneratedPathClass::Kept,
(other, _) => other,
}
}
impl Drop for CleanupGuard {
fn drop(&mut self) {
if self.cleaned.swap(true, Ordering::SeqCst) {
return;
}
let pending = match self.inner.try_lock() {
Ok(mut guard) => std::mem::take(&mut guard.pending),
Err(std::sync::TryLockError::Poisoned(poisoned)) => {
std::mem::take(&mut poisoned.into_inner().pending)
}
Err(std::sync::TryLockError::WouldBlock) => return,
};
for entry in pending {
let final_class = classify_entry(&entry, self.keep_output);
if final_class == GeneratedPathClass::Cleaned {
let _ = remove_path_best_effort(&entry.path);
}
}
}
}
fn classify(target_root: &Path, path: &Path) -> GeneratedPathClass {
if is_under_cargo_target(target_root, path) {
return GeneratedPathClass::Ignored;
}
if git_is_tracked(target_root, path) {
return GeneratedPathClass::Committed;
}
if git_is_ignored(target_root, path) {
return GeneratedPathClass::Ignored;
}
GeneratedPathClass::Cleaned
}
fn is_under_cargo_target(target_root: &Path, path: &Path) -> bool {
let target_dir = target_root.join("target");
path.starts_with(&target_dir)
}
fn git_is_tracked(target_root: &Path, path: &Path) -> bool {
git_quiet_status(target_root, &["ls-files", "--error-unmatch", "--"], path)
}
fn git_is_ignored(target_root: &Path, path: &Path) -> bool {
git_quiet_status(target_root, &["check-ignore", "--quiet", "--"], path)
}
fn git_quiet_status(target_root: &Path, args: &[&str], path: &Path) -> bool {
let output = Command::new("git")
.args(args)
.arg(path)
.current_dir(target_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match output {
Ok(status) => status.success(),
Err(_) => false,
}
}
fn remove_path_best_effort(path: &Path) -> Result<(), Error> {
crate::util::remove_path_race_free(path, "compat-generated")
}
pub fn install_panic_hook() {
static INSTALLED: AtomicBool = AtomicBool::new(false);
if INSTALLED.swap(true, Ordering::SeqCst) {
return;
}
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
eprintln!(
"lihaaf compat: panic during compat run — Drop guard will attempt cleanup of \
registered paths; see envelope for residue list"
);
previous(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_without_git_falls_through_to_cleaned() {
let tmp = tempfile::tempdir().expect("tempdir for classify-no-git test");
let path = tmp.path().join("artifact.txt");
std::fs::write(&path, b"contents").expect("write artifact");
let class = classify(tmp.path(), &path);
assert_eq!(
class,
GeneratedPathClass::Cleaned,
"non-git tempdir must classify as Cleaned"
);
}
#[test]
fn cargo_target_is_classified_without_filesystem_lookup() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("target").join("lihaaf-compat-converted");
let class = classify(tmp.path(), &target);
assert_eq!(class, GeneratedPathClass::Ignored);
}
#[test]
fn sibling_target_directory_is_not_under_cargo_target() {
let tmp = tempfile::tempdir().expect("tempdir");
let sibling = tmp.path().join("targets").join("file.txt");
assert!(!is_under_cargo_target(tmp.path(), &sibling));
}
#[test]
fn remove_nonexistent_path_is_ok() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("never-existed.txt");
remove_path_best_effort(&path).expect("removing non-existent path must succeed");
}
#[cfg(unix)]
#[test]
fn remove_symlink_to_directory_unix() {
let tmp = tempfile::tempdir().expect("tempdir");
let target = tmp.path().join("real_dir");
std::fs::create_dir_all(&target).expect("create target dir");
std::fs::write(target.join("inside.txt"), b"keep me").expect("write into target");
let link = tmp.path().join("link_to_dir");
std::os::unix::fs::symlink(&target, &link).expect("create symlink-to-dir");
assert!(link.exists(), "symlink must exist before removal");
assert!(
link.symlink_metadata().unwrap().file_type().is_symlink(),
"link_to_dir must be a symlink, not a real dir"
);
remove_path_best_effort(&link).expect("removing the symlink must succeed");
assert!(
!link.exists() && link.symlink_metadata().is_err(),
"symlink must be removed"
);
assert!(target.exists(), "target directory must NOT be removed");
assert!(
target.join("inside.txt").exists(),
"target's contents must NOT be removed"
);
}
#[test]
fn remove_directory_tree() {
let tmp = tempfile::tempdir().expect("tempdir");
let nested = tmp.path().join("dir").join("nested");
std::fs::create_dir_all(&nested).expect("create nested dir");
std::fs::write(nested.join("file.txt"), b"data").expect("write nested file");
let dir = tmp.path().join("dir");
remove_path_best_effort(&dir).expect("recursive removal");
assert!(!dir.exists(), "directory tree must be gone after cleanup");
}
#[cfg(unix)]
#[test]
fn unix_permission_denied_surfaces_as_file_stage_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir for unix-eacces test");
let blocked_parent = tmp.path().join("locked");
std::fs::create_dir(&blocked_parent).expect("create blocked parent");
let inside = blocked_parent.join("inside.txt");
std::fs::write(&inside, b"contents").expect("create file inside locked");
let original_perms = std::fs::metadata(&blocked_parent)
.expect("read parent perms")
.permissions();
std::fs::set_permissions(&blocked_parent, std::fs::Permissions::from_mode(0o555))
.expect("strip parent write permission");
let result = remove_path_best_effort(&inside);
std::fs::set_permissions(&blocked_parent, original_perms).expect("restore parent perms");
let err = result.expect_err("EACCES on file unlink must surface as error");
match err {
Error::Io { context, .. } => {
assert_eq!(
context, "removing compat-generated file/symlink",
"Unix EACCES must surface with the file-stage context, not \
the empty-dir/recursive-dir context; got `{context}`"
);
}
other => panic!("expected Error::Io for EACCES, got {other:?}"),
}
}
}