use anyhow::{bail, Context, Result};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::OnceLock;
use std::time::SystemTime;
use walkdir::{DirEntry, WalkDir};
pub(crate) fn touch_stamp(path: &Path) -> Result<()> {
std::fs::write(path, b"")
.with_context(|| format!("failed to write stamp file {}", path.display()))
}
pub(crate) fn walk_files(dir: &Path) -> impl Iterator<Item = DirEntry> + '_ {
WalkDir::new(dir)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
}
pub(crate) fn mtime(path: &Path) -> SystemTime {
std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub(crate) fn oldest_class_mtime_in_dir(dir: &Path) -> SystemTime {
walk_files(dir)
.filter(|e| e.file_name().to_string_lossy().ends_with(".class"))
.filter_map(|e| std::fs::metadata(e.path()).and_then(|m| m.modified()).ok())
.min()
.unwrap_or(SystemTime::UNIX_EPOCH)
}
pub(crate) fn newest_mtime_in_dir(dir: &Path) -> SystemTime {
walk_files(dir)
.filter_map(|e| std::fs::metadata(e.path()).and_then(|m| m.modified()).ok())
.max()
.unwrap_or(SystemTime::UNIX_EPOCH)
}
#[derive(Copy, Clone, Debug)]
pub(crate) struct Stamp(Option<SystemTime>);
impl Stamp {
pub(crate) fn of(path: &Path) -> Self {
Self(std::fs::metadata(path).and_then(|m| m.modified()).ok())
}
pub(crate) fn covers(&self, inputs: &Inputs) -> bool {
match (self.0, inputs.newest()) {
(None, _) => false,
(Some(_), None) => true,
(Some(s), Some(i)) => i < s,
}
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) struct Inputs(SystemTime);
impl Inputs {
pub(crate) fn new() -> Self {
Self(SystemTime::UNIX_EPOCH)
}
pub(crate) fn add_file(&mut self, path: &Path) -> &mut Self {
self.bump(mtime(path))
}
pub(crate) fn add_dir(&mut self, dir: &Path) -> &mut Self {
self.bump(newest_mtime_in_dir(dir))
}
pub(crate) fn add_dir_opt(&mut self, dir: Option<&Path>) -> &mut Self {
if let Some(d) = dir {
self.add_dir(d);
}
self
}
pub(crate) fn add_paths(&mut self, paths: &[PathBuf]) -> &mut Self {
self.bump(newest_mtime(paths))
}
fn bump(&mut self, t: SystemTime) -> &mut Self {
if t > self.0 {
self.0 = t;
}
self
}
pub(crate) fn newest(&self) -> Option<SystemTime> {
(self.0 != SystemTime::UNIX_EPOCH).then_some(self.0)
}
}
pub(crate) fn newest_mtime(paths: &[PathBuf]) -> SystemTime {
paths
.iter()
.filter_map(|p| std::fs::metadata(p).and_then(|m| m.modified()).ok())
.max()
.unwrap_or(SystemTime::UNIX_EPOCH)
}
static NEXT_STAGING_SEQ: AtomicUsize = AtomicUsize::new(0);
pub(crate) fn staging_path(dest: &Path) -> PathBuf {
let pid = std::process::id();
let seq = NEXT_STAGING_SEQ.fetch_add(1, Ordering::Relaxed);
let orig = dest
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "out".to_string());
dest.with_file_name(format!("{}.part.{}.{}", orig, pid, seq))
}
pub(crate) fn finalize_staged(part: &Path, dest: &Path) -> Result<()> {
match std::fs::rename(part, dest) {
Ok(()) => Ok(()),
Err(e) => {
if dest.exists() {
let _ = std::fs::remove_file(part);
Ok(())
} else {
Err(e).with_context(|| {
format!("failed to rename {} \u{2192} {}", part.display(), dest.display())
})
}
}
}
}
pub(crate) fn canonical_source_set(sources: &[PathBuf]) -> BTreeSet<String> {
sources
.iter()
.filter_map(|p| p.canonicalize().ok())
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
pub(crate) fn source_set_stamp_path(target_dir: &Path, file_name: &str) -> PathBuf {
target_dir.join(file_name)
}
pub(crate) fn load_source_set(path: &Path) -> Option<BTreeSet<String>> {
let text = std::fs::read_to_string(path).ok()?;
Some(
text.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect(),
)
}
pub(crate) fn write_source_set(path: &Path, set: &BTreeSet<String>) -> Result<()> {
let mut body = String::with_capacity(set.iter().map(|p| p.len() + 1).sum());
for p in set {
body.push_str(p);
body.push('\n');
}
std::fs::write(path, body)
.with_context(|| format!("failed to write {}", path.display()))
}
pub(crate) fn load_u64_stamp(path: &Path) -> Option<u64> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
pub(crate) fn write_u64_stamp(path: &Path, value: u64) -> Result<()> {
std::fs::write(path, value.to_string())
.with_context(|| format!("failed to write {}", path.display()))
}
pub(crate) fn source_set_changed(
previous: Option<&BTreeSet<String>>,
current: &BTreeSet<String>,
) -> bool {
previous.map(|p| p != current).unwrap_or(false)
}
#[derive(Debug, PartialEq)]
pub(crate) enum CompileStatus {
NoClassFiles,
SourceChanged,
SourceSetChanged,
DependencyChanged,
TomlChanged,
StaleClasses,
MissingClasses,
JdkChanged,
UpToDate,
}
impl CompileStatus {
pub(crate) fn needs_recompile(&self) -> bool {
!matches!(self, CompileStatus::UpToDate)
}
pub(crate) fn reason(&self) -> &'static str {
match self {
CompileStatus::NoClassFiles => "no class files",
CompileStatus::SourceChanged => "source changed",
CompileStatus::SourceSetChanged => "source set changed",
CompileStatus::DependencyChanged => "dependency changed",
CompileStatus::TomlChanged => "Curie.toml changed",
CompileStatus::StaleClasses => "stale classes removed",
CompileStatus::MissingClasses => "missing class files",
CompileStatus::JdkChanged => "JDK version changed",
CompileStatus::UpToDate => "up to date",
}
}
}
pub(crate) fn javac_version() -> Result<String> {
static CACHE: OnceLock<Option<String>> = OnceLock::new();
cached_or_init(&CACHE, || detect_javac_version().ok())
.ok_or_else(|| anyhow::anyhow!("failed to invoke javac — is a JDK installed?"))
}
fn cached_or_init(
cache: &OnceLock<Option<String>>,
detect: impl FnOnce() -> Option<String>,
) -> Option<String> {
cache.get_or_init(detect).clone()
}
fn detect_javac_version() -> Result<String> {
let out = Command::new("javac")
.arg("-version")
.output()
.context("failed to invoke javac — is a JDK installed?")?;
let raw = String::from_utf8_lossy(&out.stderr);
let version = raw.trim().to_string();
if version.is_empty() {
let raw_out = String::from_utf8_lossy(&out.stdout);
let version_out = raw_out.trim().to_string();
if version_out.is_empty() {
bail!("javac -version produced no output");
}
return Ok(version_out);
}
Ok(version)
}
pub(crate) fn javac_version_stamp_path(target_dir: &Path) -> PathBuf {
target_dir.join(".javac-version")
}
pub(crate) fn write_javac_version_stamp(target_dir: &Path, version: &str) -> Result<()> {
let path = javac_version_stamp_path(target_dir);
std::fs::write(&path, version)
.with_context(|| format!("failed to write {}", path.display()))
}
pub(crate) fn needs_recompile(
sources: &[PathBuf],
classes_dir: &Path,
toml_path: &Path,
target_dir: &Path,
extra_input_dirs: &[&Path],
) -> CompileStatus {
let oldest_class = oldest_class_mtime_in_dir(classes_dir);
if oldest_class == SystemTime::UNIX_EPOCH {
return CompileStatus::NoClassFiles;
}
if let Ok(current) = javac_version() {
let stamp = javac_version_stamp_path(target_dir);
let stored = std::fs::read_to_string(&stamp).unwrap_or_default();
if stored.trim() != current.trim() {
return CompileStatus::JdkChanged;
}
}
if newest_mtime(sources) >= oldest_class {
return CompileStatus::SourceChanged;
}
for &d in extra_input_dirs {
if newest_mtime_in_dir(d) >= oldest_class {
return CompileStatus::DependencyChanged;
}
}
if mtime(toml_path) >= oldest_class {
return CompileStatus::TomlChanged;
}
CompileStatus::UpToDate
}
pub(crate) fn needs_repackage(
jar_path: &Path,
classes_dir: &Path,
resources_dir: Option<&Path>,
toml_path: &Path,
) -> bool {
let mut inputs = Inputs::new();
inputs
.add_dir(classes_dir)
.add_dir_opt(resources_dir)
.add_file(toml_path);
!Stamp::of(jar_path).covers(&inputs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn javac_version_cache_runs_detector_once() {
let cache = OnceLock::new();
let calls = std::cell::Cell::new(0u32);
let first = cached_or_init(&cache, || {
calls.set(calls.get() + 1);
Some("javac 21".to_string())
});
let second = cached_or_init(&cache, || {
calls.set(calls.get() + 1);
Some("javac 99".to_string())
});
assert_eq!(first.as_deref(), Some("javac 21"));
assert_eq!(second.as_deref(), Some("javac 21"), "second call must return the cached value");
assert_eq!(calls.get(), 1, "detector must run exactly once");
}
#[test]
fn javac_version_cache_caches_none() {
let cache = OnceLock::new();
let calls = std::cell::Cell::new(0u32);
let first = cached_or_init(&cache, || {
calls.set(calls.get() + 1);
None
});
let second = cached_or_init(&cache, || {
calls.set(calls.get() + 1);
Some("javac 21".to_string())
});
assert_eq!(first, None);
assert_eq!(second, None, "a cached None must not be re-detected");
assert_eq!(calls.get(), 1, "detector must run exactly once even for None");
}
fn write_file(path: &Path, content: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn set_mtime(path: &Path, time: SystemTime) {
filetime::set_file_mtime(
path,
filetime::FileTime::from_system_time(time),
)
.unwrap_or_else(|e| panic!("set_mtime({}) failed: {e}", path.display()));
}
#[test]
fn mtime_missing_file_returns_epoch() {
let dir = tempfile::tempdir().unwrap();
let absent = dir.path().join("ghost.txt");
assert_eq!(mtime(&absent), SystemTime::UNIX_EPOCH);
}
#[test]
fn mtime_existing_file_nonzero() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("a.txt");
write_file(&f, b"hi");
assert!(mtime(&f) > SystemTime::UNIX_EPOCH);
}
#[test]
fn oldest_class_mtime_ignores_non_class_files() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let resource = dir.path().join("BenchmarkList");
write_file(&resource, b"resource");
set_mtime(&resource, base);
let class = dir.path().join("Foo.class");
write_file(&class, b"class");
set_mtime(&class, base + Duration::from_secs(120));
assert_eq!(
oldest_class_mtime_in_dir(dir.path()),
base + Duration::from_secs(120),
);
}
#[test]
fn oldest_class_mtime_no_classes_returns_epoch() {
let dir = tempfile::tempdir().unwrap();
let resource = dir.path().join("BenchmarkList");
write_file(&resource, b"resource");
assert_eq!(oldest_class_mtime_in_dir(dir.path()), SystemTime::UNIX_EPOCH);
}
#[test]
fn newest_mtime_empty_slice_returns_epoch() {
assert_eq!(newest_mtime(&[]), SystemTime::UNIX_EPOCH);
}
#[test]
fn newest_mtime_returns_maximum() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(2_000_000);
let a = dir.path().join("a.java");
let b = dir.path().join("b.java");
write_file(&a, b"A");
write_file(&b, b"B");
set_mtime(&a, base);
set_mtime(&b, base + Duration::from_secs(30));
assert_eq!(newest_mtime(&[a, b]), base + Duration::from_secs(30));
}
#[test]
fn needs_recompile_no_class_files() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
let classes_dir = dir.path().join("classes"); let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::NoClassFiles);
}
#[test]
fn needs_recompile_empty_classes_dir() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
let classes_dir = dir.path().join("classes");
std::fs::create_dir_all(&classes_dir).unwrap();
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::NoClassFiles);
}
#[test]
fn needs_recompile_false_when_up_to_date() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
set_mtime(&src, base);
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base + Duration::from_secs(10));
if let Ok(v) = javac_version() {
write_javac_version_stamp(dir.path(), &v).unwrap();
}
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::UpToDate);
}
#[test]
fn needs_recompile_true_when_source_newer_than_class() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
set_mtime(&src, base + Duration::from_secs(5));
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base - Duration::from_secs(10));
if let Ok(v) = javac_version() {
write_javac_version_stamp(dir.path(), &v).unwrap();
}
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::SourceChanged);
}
#[test]
fn needs_recompile_true_when_toml_newer_than_class() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
set_mtime(&src, base - Duration::from_secs(10));
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base + Duration::from_secs(5));
if let Ok(v) = javac_version() {
write_javac_version_stamp(dir.path(), &v).unwrap();
}
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::TomlChanged);
}
#[test]
fn needs_recompile_true_when_jdk_changed() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base + Duration::from_secs(10));
let src = dir.path().join("Foo.java");
write_file(&src, b"class Foo {}");
set_mtime(&src, base);
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base);
write_javac_version_stamp(dir.path(), "javac 99.0.0").unwrap();
assert_eq!(needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[]), CompileStatus::JdkChanged);
}
#[test]
fn needs_recompile_true_when_extra_input_dir_newer_than_output() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let classes_dir = dir.path().join("test-classes");
let class_file = classes_dir.join("FooTest.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let src = dir.path().join("FooTest.java");
write_file(&src, b"class FooTest {}");
set_mtime(&src, base - Duration::from_secs(10));
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base - Duration::from_secs(20));
if let Ok(v) = javac_version() {
write_javac_version_stamp(dir.path(), &v).unwrap();
}
let prod_dir = dir.path().join("classes");
let prod_class = prod_dir.join("Foo.class");
write_file(&prod_class, b"bytecode");
set_mtime(&prod_class, base + Duration::from_secs(5));
assert_eq!(
needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[&prod_dir]),
CompileStatus::DependencyChanged
);
}
#[test]
fn needs_recompile_false_when_extra_input_dir_older_than_output() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(3_000_000);
let classes_dir = dir.path().join("test-classes");
let class_file = classes_dir.join("FooTest.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let src = dir.path().join("FooTest.java");
write_file(&src, b"class FooTest {}");
set_mtime(&src, base - Duration::from_secs(10));
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base - Duration::from_secs(20));
if let Ok(v) = javac_version() {
write_javac_version_stamp(dir.path(), &v).unwrap();
}
let prod_dir = dir.path().join("classes");
let prod_class = prod_dir.join("Foo.class");
write_file(&prod_class, b"bytecode");
set_mtime(&prod_class, base - Duration::from_secs(5));
assert_eq!(
needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[&prod_dir]),
CompileStatus::UpToDate
);
}
#[test]
fn needs_recompile_no_class_files_even_with_extra_dir_present() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("FooTest.java");
write_file(&src, b"class FooTest {}");
let classes_dir = dir.path().join("test-classes"); let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
let prod_dir = dir.path().join("classes");
write_file(&prod_dir.join("Foo.class"), b"bytecode");
assert_eq!(
needs_recompile(&[src], &classes_dir, &toml, dir.path(), &[&prod_dir]),
CompileStatus::NoClassFiles
);
}
fn placeholder_toml(dir: &Path) -> PathBuf {
dir.join("does-not-exist.toml")
}
#[test]
fn needs_repackage_no_jar() {
let dir = tempfile::tempdir().unwrap();
let jar = dir.path().join("app.jar"); let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
let missing_toml = placeholder_toml(dir.path());
assert!(needs_repackage(&jar, &classes_dir, None, &missing_toml));
}
#[test]
fn needs_repackage_false_when_jar_newer_than_classes() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let jar = dir.path().join("app.jar");
write_file(&jar, b"jar");
set_mtime(&jar, base + Duration::from_secs(5));
let missing_toml = placeholder_toml(dir.path());
assert!(!needs_repackage(&jar, &classes_dir, None, &missing_toml));
}
#[test]
fn needs_repackage_true_when_class_newer_than_jar() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
let jar = dir.path().join("app.jar");
write_file(&jar, b"jar");
set_mtime(&jar, base);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base + Duration::from_secs(5));
let missing_toml = placeholder_toml(dir.path());
assert!(needs_repackage(&jar, &classes_dir, None, &missing_toml));
}
#[test]
fn needs_repackage_true_when_resource_newer_than_jar() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
let jar = dir.path().join("app.jar");
write_file(&jar, b"jar");
set_mtime(&jar, base);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base - Duration::from_secs(10));
let resources_dir = dir.path().join("resources");
let res_file = resources_dir.join("data.txt");
write_file(&res_file, b"resource");
set_mtime(&res_file, base + Duration::from_secs(5));
let missing_toml = placeholder_toml(dir.path());
assert!(needs_repackage(&jar, &classes_dir, Some(&resources_dir), &missing_toml));
}
#[test]
fn needs_repackage_false_when_jar_newer_than_classes_and_resources() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base);
let resources_dir = dir.path().join("resources");
let res_file = resources_dir.join("data.txt");
write_file(&res_file, b"resource");
set_mtime(&res_file, base);
let jar = dir.path().join("app.jar");
write_file(&jar, b"jar");
set_mtime(&jar, base + Duration::from_secs(5));
let missing_toml = placeholder_toml(dir.path());
assert!(!needs_repackage(&jar, &classes_dir, Some(&resources_dir), &missing_toml));
}
#[test]
fn needs_repackage_true_when_toml_newer_than_jar() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(4_000_000);
let classes_dir = dir.path().join("classes");
let class_file = classes_dir.join("Foo.class");
write_file(&class_file, b"bytecode");
set_mtime(&class_file, base - Duration::from_secs(10));
let jar = dir.path().join("app.jar");
write_file(&jar, b"jar");
set_mtime(&jar, base);
let toml = dir.path().join("Curie.toml");
write_file(&toml, b"[application]");
set_mtime(&toml, base + Duration::from_secs(5));
assert!(needs_repackage(&jar, &classes_dir, None, &toml));
}
#[test]
fn stamp_missing_never_covers() {
let dir = tempfile::tempdir().unwrap();
let stamp = Stamp::of(&dir.path().join("ghost"));
let mut inputs = Inputs::new();
inputs.add_file(&dir.path().join("also-missing"));
assert!(!stamp.covers(&inputs));
}
#[test]
fn stamp_with_no_inputs_covers() {
let dir = tempfile::tempdir().unwrap();
let s = dir.path().join("stamp");
write_file(&s, b"");
assert!(Stamp::of(&s).covers(&Inputs::new()));
}
#[test]
fn stamp_strictly_newer_covers() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
let src = dir.path().join("src");
write_file(&src, b"");
set_mtime(&src, base);
let stamp = dir.path().join("stamp");
write_file(&stamp, b"");
set_mtime(&stamp, base + Duration::from_secs(1));
let mut inputs = Inputs::new();
inputs.add_file(&src);
assert!(Stamp::of(&stamp).covers(&inputs));
}
#[test]
fn stamp_tied_mtime_does_not_cover() {
let dir = tempfile::tempdir().unwrap();
let same = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
let src = dir.path().join("src");
write_file(&src, b"");
set_mtime(&src, same);
let stamp = dir.path().join("stamp");
write_file(&stamp, b"");
set_mtime(&stamp, same);
let mut inputs = Inputs::new();
inputs.add_file(&src);
assert!(
!Stamp::of(&stamp).covers(&inputs),
"tied input mtime must NOT count as covered (would mask edits on second-resolution fs)",
);
}
#[test]
fn inputs_add_dir_picks_newest_in_dir() {
let dir = tempfile::tempdir().unwrap();
let base = SystemTime::UNIX_EPOCH + Duration::from_secs(5_000_000);
let sub = dir.path().join("d");
write_file(&sub.join("a"), b"");
set_mtime(&sub.join("a"), base);
write_file(&sub.join("b"), b"");
set_mtime(&sub.join("b"), base + Duration::from_secs(7));
let mut inputs = Inputs::new();
inputs.add_dir(&sub);
assert_eq!(inputs.newest(), Some(base + Duration::from_secs(7)));
}
#[test]
fn inputs_add_dir_opt_none_is_noop() {
let mut inputs = Inputs::new();
inputs.add_dir_opt(None);
assert_eq!(inputs.newest(), None);
}
fn set_of(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn source_set_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = source_set_stamp_path(dir.path(), ".sources");
let set = set_of(&["/a/Foo.java", "/a/Bar.kt"]);
write_source_set(&path, &set).unwrap();
assert_eq!(load_source_set(&path).unwrap(), set);
}
#[test]
fn source_set_load_missing_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert!(load_source_set(&source_set_stamp_path(dir.path(), ".sources")).is_none());
}
#[test]
fn source_set_load_ignores_blank_lines_and_whitespace() {
let dir = tempfile::tempdir().unwrap();
let path = source_set_stamp_path(dir.path(), ".sources");
std::fs::write(&path, "\n /a/Foo.java \n\n/a/Bar.java\n").unwrap();
assert_eq!(load_source_set(&path).unwrap(), set_of(&["/a/Foo.java", "/a/Bar.java"]));
}
#[test]
fn source_set_changed_detects_addition() {
let prev = set_of(&["/a/Foo.java"]);
let now = set_of(&["/a/Foo.java", "/a/Bar.java"]); assert!(source_set_changed(Some(&prev), &now));
}
#[test]
fn source_set_changed_detects_deletion() {
let prev = set_of(&["/a/Foo.java", "/a/Bar.java"]);
let now = set_of(&["/a/Foo.java"]); assert!(source_set_changed(Some(&prev), &now));
}
#[test]
fn source_set_changed_false_when_equal() {
let set = set_of(&["/a/Foo.java", "/a/Bar.java"]);
assert!(!source_set_changed(Some(&set), &set.clone()));
}
#[test]
fn source_set_changed_false_when_no_previous_stamp() {
let now = set_of(&["/a/Foo.java"]);
assert!(!source_set_changed(None, &now));
}
#[test]
fn canonical_source_set_drops_uncanonicalizable_paths() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("Real.java");
write_file(&real, b"class Real {}");
let ghost = dir.path().join("Ghost.java"); let set = canonical_source_set(&[real.clone(), ghost]);
assert_eq!(set.len(), 1, "only the existing file canonicalizes");
assert!(set.iter().next().unwrap().ends_with("Real.java"));
}
#[test]
fn compile_status_source_set_changed_reason_and_needs_recompile() {
assert_eq!(CompileStatus::SourceSetChanged.reason(), "source set changed");
assert!(CompileStatus::SourceSetChanged.needs_recompile());
}
#[test]
fn compile_status_missing_classes_reason_and_needs_recompile() {
assert_eq!(CompileStatus::MissingClasses.reason(), "missing class files");
assert!(CompileStatus::MissingClasses.needs_recompile());
}
#[test]
fn staging_path_is_sibling_and_unique() {
let dest = Path::new("/tmp/target/foo.jar");
let p1 = staging_path(dest);
let p2 = staging_path(dest);
assert!(p1.starts_with("/tmp/target"));
assert!(p1.file_name().unwrap().to_string_lossy().contains("foo.jar.part."));
assert_ne!(p1, p2, "consecutive calls must produce distinct staging names");
}
#[test]
fn finalize_staged_success_moves_file() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out.jar");
let part = dir.path().join("out.jar.part.test");
write_file(&part, b"complete");
finalize_staged(&part, &dest).unwrap();
assert!(dest.exists());
assert_eq!(std::fs::read(&dest).unwrap(), b"complete");
assert!(!part.exists());
}
#[test]
fn finalize_staged_tolerates_dest_already_exists() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out.jar");
write_file(&dest, b"winner");
let part = dir.path().join("out.jar.part.loser");
write_file(&part, b"loser-content");
finalize_staged(&part, &dest).unwrap();
let final_bytes = std::fs::read(&dest).unwrap();
assert!(final_bytes == b"winner" || final_bytes == b"loser-content");
assert!(!part.exists());
}
#[test]
fn finalize_staged_errors_if_neither_exists_after_failure() {
let dir = tempfile::tempdir().unwrap();
let part = dir.path().join("ghost.part");
let dest = dir.path().join("never-created");
let res = finalize_staged(&part, &dest);
assert!(res.is_err());
}
}