use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use std::path::Path;
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct Manifest {
#[serde(default)]
pub sources: BTreeMap<String, Vec<String>>,
}
pub fn load(path: &Path) -> Result<Option<Manifest>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let manifest: Manifest = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
Ok(Some(manifest))
}
pub fn stale_classes(
old: &Manifest,
now: Option<&Manifest>,
current_sources: &HashSet<String>,
pre_compile_ignore_prefix: Option<&str>,
) -> Vec<String> {
let mut stale = Vec::new();
for (src, old_classes) in &old.sources {
match now {
None => {
if !current_sources.contains(src) {
if pre_compile_ignore_prefix
.map(|p| src.starts_with(p))
.unwrap_or(false)
{
continue;
}
stale.extend(old_classes.iter().cloned());
}
}
Some(new) => match new.sources.get(src) {
Some(new_classes) => {
let new_set: HashSet<&String> = new_classes.iter().collect();
for c in old_classes {
if !new_set.contains(c) {
stale.push(c.clone());
}
}
}
None => {
stale.extend(old_classes.iter().cloned());
}
},
}
}
stale
}
pub fn delete_classes(classes_dir: &Path, relative: &[String]) -> Result<usize> {
let mut removed = 0;
for rel in relative {
let p = classes_dir.join(rel);
if p.exists() {
std::fs::remove_file(&p)
.with_context(|| format!("failed to remove stale class {}", p.display()))?;
removed += 1;
}
}
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest_with(entries: &[(&str, &[&str])]) -> Manifest {
let mut m = Manifest::default();
for (src, classes) in entries {
m.sources.insert(
(*src).to_string(),
classes.iter().map(|c| (*c).to_string()).collect(),
);
}
m
}
fn sources_set(s: &[&str]) -> HashSet<String> {
s.iter().map(|x| (*x).to_string()).collect()
}
#[test]
fn pre_compile_drops_classes_of_deleted_sources() {
let old = manifest_with(&[
("/a/Foo.java", &["com/Foo.class"]),
("/a/Bar.java", &["com/Bar.class", "com/Bar$Inner.class"]),
]);
let current = sources_set(&["/a/Foo.java"]);
let stale = stale_classes(&old, None, ¤t, None);
assert_eq!(stale, vec!["com/Bar.class", "com/Bar$Inner.class"]);
}
#[test]
fn pre_compile_keeps_classes_of_surviving_sources() {
let old = manifest_with(&[
("/a/Foo.java", &["com/Foo.class", "com/Foo$Inner.class"]),
]);
let current = sources_set(&["/a/Foo.java"]);
let stale = stale_classes(&old, None, ¤t, None);
assert!(stale.is_empty(), "no source deleted → nothing stale");
}
#[test]
fn pre_compile_ignores_paths_under_generated_prefix() {
let old = manifest_with(&[
("/proj/src/com/Foo.java", &["com/Foo.class"]),
(
"/proj/target/generated-sources/annotations/com/AutoValue_Foo.java",
&["com/AutoValue_Foo.class"],
),
]);
let current = sources_set(&["/proj/src/com/Foo.java"]);
let stale_no_carve = stale_classes(&old, None, ¤t, None);
assert_eq!(stale_no_carve, vec!["com/AutoValue_Foo.class"]);
let stale = stale_classes(&old, None, ¤t, Some("/proj/target"));
assert!(
stale.is_empty(),
"AP-generated source under target/ must not be pre-pruned",
);
}
#[test]
fn post_compile_drops_classes_kept_source_no_longer_produces() {
let old = manifest_with(&[
("/a/Foo.java", &["com/Foo.class", "com/Bar.class"]),
]);
let now = manifest_with(&[
("/a/Foo.java", &["com/Foo.class"]),
]);
let stale = stale_classes(&old, Some(&now), &HashSet::new(), None);
assert_eq!(stale, vec!["com/Bar.class"]);
}
#[test]
fn post_compile_drops_everything_for_source_missing_from_new_manifest() {
let old = manifest_with(&[
("/a/Foo.java", &["com/Foo.class", "com/Foo$Inner.class"]),
]);
let now = Manifest::default();
let stale = stale_classes(&old, Some(&now), &HashSet::new(), None);
assert_eq!(stale, vec!["com/Foo.class", "com/Foo$Inner.class"]);
}
#[test]
fn post_compile_no_changes_means_nothing_stale() {
let old = manifest_with(&[
("/a/Foo.java", &["com/Foo.class", "com/Foo$Inner.class"]),
]);
let now = manifest_with(&[
("/a/Foo.java", &["com/Foo.class", "com/Foo$Inner.class"]),
]);
let stale = stale_classes(&old, Some(&now), &HashSet::new(), None);
assert!(stale.is_empty());
}
#[test]
fn post_compile_new_source_contributes_no_staleness() {
let old = manifest_with(&[("/a/Foo.java", &["com/Foo.class"])]);
let now = manifest_with(&[
("/a/Foo.java", &["com/Foo.class"]),
("/a/Bar.java", &["com/Bar.class"]),
]);
let stale = stale_classes(&old, Some(&now), &HashSet::new(), None);
assert!(stale.is_empty());
}
#[test]
fn load_missing_manifest_returns_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.toml");
assert!(load(&path).unwrap().is_none());
}
#[test]
fn load_parses_wrapper_format() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("classes.toml");
std::fs::write(&path, r#"
[sources]
"/abs/Foo.java" = ["com/Foo.class", "com/Foo$Inner.class"]
"/abs/Bar.java" = ["com/Bar.class"]
"#).unwrap();
let m = load(&path).unwrap().unwrap();
assert_eq!(m.sources.len(), 2);
assert_eq!(m.sources["/abs/Foo.java"], vec!["com/Foo.class", "com/Foo$Inner.class"]);
}
#[test]
fn delete_classes_removes_existing_skips_missing() {
let dir = tempfile::tempdir().unwrap();
let classes = dir.path().join("classes");
let a = classes.join("com").join("A.class");
let b = classes.join("com").join("B.class");
std::fs::create_dir_all(a.parent().unwrap()).unwrap();
std::fs::write(&a, b"a").unwrap();
std::fs::write(&b, b"b").unwrap();
let removed = delete_classes(
&classes,
&["com/A.class".to_string(), "com/ghost.class".to_string()],
).unwrap();
assert_eq!(removed, 1);
assert!(!a.exists());
assert!(b.exists(), "unrelated file untouched");
}
}