use anyhow::{bail, Context, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
use walkdir::{DirEntry, WalkDir};
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)
}
#[derive(Debug, PartialEq)]
pub(crate) enum CompileStatus {
NoClassFiles,
SourceChanged,
TomlChanged,
StaleClasses,
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::TomlChanged => "Curie.toml changed",
CompileStatus::StaleClasses => "stale classes removed",
CompileStatus::JdkChanged => "JDK version changed",
CompileStatus::UpToDate => "up to date",
}
}
}
pub(crate) fn 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,
) -> 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;
}
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;
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);
}
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);
}
}