use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;
const ALEF_HEADER_MARKERS: &[&str] = &[
"auto-generated by alef",
"AUTO-GENERATED by alef",
"Generated by alef",
"DO NOT EDIT",
];
pub fn cleanup_orphaned_files(current_gen_paths: &HashSet<PathBuf>) -> anyhow::Result<usize> {
if current_gen_paths.is_empty() {
return Ok(0);
}
let normalized: HashSet<PathBuf> = current_gen_paths
.iter()
.map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
.collect();
let touched_dirs: BTreeSet<PathBuf> = current_gen_paths
.iter()
.filter_map(|p| p.parent().map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())))
.collect();
let mut removed_count = 0;
let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
for dir in &touched_dirs {
if !dir.exists() {
continue;
}
let canonical_dir = dir.canonicalize().unwrap_or_else(|_| dir.clone());
if !visited_dirs.insert(canonical_dir.clone()) {
continue;
}
removed_count += cleanup_dir_recursive(&canonical_dir, &normalized, &touched_dirs)?;
}
Ok(removed_count)
}
fn cleanup_dir_recursive(
dir: &Path,
normalized_gen_paths: &HashSet<PathBuf>,
touched_dirs: &BTreeSet<PathBuf>,
) -> anyhow::Result<usize> {
let mut removed_count = 0;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let canonical_sub = path.canonicalize().unwrap_or_else(|_| path.clone());
let descend = touched_dirs
.iter()
.any(|td| td == &canonical_sub || td.starts_with(&canonical_sub) || canonical_sub.starts_with(td));
if descend {
removed_count += cleanup_dir_recursive(&path, normalized_gen_paths, touched_dirs)?;
}
continue;
}
if !has_alef_header(&path)? {
continue;
}
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
if !normalized_gen_paths.contains(&canonical_path) {
info!("Removing stale alef-generated file: {}", path.display());
fs::remove_file(&path)?;
removed_count += 1;
}
}
Ok(removed_count)
}
fn has_alef_header(path: &Path) -> anyhow::Result<bool> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => {
return Ok(false);
}
};
let first_lines = content.lines().take(5).collect::<Vec<_>>().join("\n");
for marker in ALEF_HEADER_MARKERS {
if first_lines.contains(marker) {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::cleanup_orphaned_files;
use std::collections::HashSet;
use std::fs;
#[test]
fn cleanup_removes_orphan_with_generated_by_alef_header() {
let tempdir = tempfile::tempdir().expect("tempdir");
let package_dir = tempdir.path().join("packages/kotlin/src/main/kotlin/dev/demo");
fs::create_dir_all(&package_dir).expect("create package dir");
let current_file = package_dir.join("GraphQLRouteConfig.kt");
let stale_file = package_dir.join("DefaultClient.kt");
fs::write(
¤t_file,
"// Generated by alef. Do not edit by hand.\n\nclass GraphQLRouteConfig\n",
)
.expect("write current file");
fs::write(
&stale_file,
"// Generated by alef. Do not edit by hand.\n\nclass GraphQLRouteConfig\n",
)
.expect("write stale file");
let current_gen_paths = HashSet::from([current_file.clone()]);
let removed = cleanup_orphaned_files(¤t_gen_paths).expect("cleanup");
assert_eq!(removed, 1);
assert!(current_file.exists());
assert!(!stale_file.exists());
}
#[test]
fn cleanup_removes_orphan_in_sibling_subtree_of_touched_dir() {
let tempdir = tempfile::tempdir().expect("tempdir");
let package_root = tempdir.path().join("packages/kotlin-android");
let kotlin_dir = package_root.join("src/main/kotlin/dev/demo/android");
let java_dir = package_root.join("src/main/java/dev/demo");
fs::create_dir_all(&kotlin_dir).expect("create kotlin dir");
fs::create_dir_all(&java_dir).expect("create java dir");
let build_gradle = package_root.join("build.gradle.kts");
let bridge_kt = kotlin_dir.join("DemoBridge.kt");
let stale_java = java_dir.join("CrawlEngineHandle.java");
let user_java = java_dir.join("UserCode.java");
fs::write(
&build_gradle,
"// Generated by alef. Do not edit by hand.\n\nplugins {}\n",
)
.expect("write build.gradle.kts");
fs::write(
&bridge_kt,
"// Generated by alef. Do not edit by hand.\n\nobject DemoBridge\n",
)
.expect("write bridge.kt");
fs::write(
&stale_java,
"// This file is auto-generated by alef — DO NOT EDIT.\n\npublic class CrawlEngineHandle {}\n",
)
.expect("write stale java");
fs::write(&user_java, "// hand-written\npublic class UserCode {}\n").expect("write user java");
let current_gen_paths = HashSet::from([build_gradle.clone(), bridge_kt.clone()]);
let removed = cleanup_orphaned_files(¤t_gen_paths).expect("cleanup");
assert_eq!(removed, 1, "exactly the alef-marked orphan must be removed");
assert!(build_gradle.exists(), "current build.gradle.kts must survive");
assert!(bridge_kt.exists(), "current bridge.kt must survive");
assert!(!stale_java.exists(), "stale java orphan must be removed");
assert!(user_java.exists(), "user-written java must survive (no alef header)");
}
}