use std::{
fs,
path::{Path, PathBuf},
time::SystemTime,
};
#[derive(Debug, PartialEq, Eq)]
pub enum Reconcile {
Unchanged,
Adopt(Vec<u8>),
Conflict,
}
pub fn reconcile(baseline: &[u8], disk: &[u8], in_memory: &[u8]) -> Reconcile {
if disk == baseline {
Reconcile::Unchanged
} else if in_memory == baseline {
Reconcile::Adopt(disk.to_vec())
} else {
Reconcile::Conflict
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum FileChange {
None,
Adopt(Vec<u8>),
Conflict,
}
pub struct FileWatch {
path: PathBuf,
synced_mtime: Option<SystemTime>,
baseline: Vec<u8>,
conflict: bool,
}
impl FileWatch {
pub fn new(path: PathBuf, baseline: Vec<u8>) -> Self {
let synced_mtime = file_mtime(&path);
Self {
path,
synced_mtime,
baseline,
conflict: false,
}
}
pub fn in_conflict(&self) -> bool {
self.conflict
}
pub fn baseline(&self) -> &[u8] {
&self.baseline
}
pub fn mark_synced(&mut self, baseline: Vec<u8>) {
self.baseline = baseline;
self.synced_mtime = file_mtime(&self.path);
self.conflict = false;
}
pub fn poll(&mut self, in_memory: &[u8]) -> FileChange {
let mtime = match file_mtime(&self.path) {
Some(m) => m,
None => return FileChange::None,
};
let advanced = self.synced_mtime.map(|prev| mtime > prev).unwrap_or(true);
if !advanced {
return FileChange::None;
}
self.synced_mtime = Some(mtime);
let disk = match fs::read(&self.path) {
Ok(b) => b,
Err(_) => return FileChange::None,
};
match reconcile(&self.baseline, &disk, in_memory) {
Reconcile::Unchanged => {
self.conflict = false;
FileChange::None
}
Reconcile::Adopt(bytes) => {
self.baseline = bytes.clone();
self.conflict = false;
FileChange::Adopt(bytes)
}
Reconcile::Conflict => {
self.conflict = true;
FileChange::Conflict
}
}
}
}
fn file_mtime(path: &Path) -> Option<SystemTime> {
fs::metadata(path).and_then(|m| m.modified()).ok()
}
pub struct SourceTreeWatch {
dir: PathBuf,
newest: Option<SystemTime>,
}
impl SourceTreeWatch {
pub fn new(dir: &Path) -> Self {
Self {
dir: dir.to_path_buf(),
newest: newest_source_mtime(dir),
}
}
pub fn poll(&mut self) -> bool {
let now = newest_source_mtime(&self.dir);
match (now, self.newest) {
(Some(now), Some(prev)) if now > prev => {
self.newest = Some(now);
true
}
(Some(now), None) => {
self.newest = Some(now);
true
}
_ => false,
}
}
pub fn sync(&mut self) {
self.newest = newest_source_mtime(&self.dir);
}
}
fn newest_source_mtime(dir: &Path) -> Option<SystemTime> {
let mut newest = file_mtime(&dir.join("Cargo.toml"));
visit_rs(&dir.join("src"), &mut |m| {
newest = match (newest, m) {
(Some(a), b) if b > a => Some(b),
(None, b) => Some(b),
(cur, _) => cur,
};
});
newest
}
fn visit_rs(dir: &Path, f: &mut impl FnMut(SystemTime)) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
visit_rs(&path, f);
} else if path.extension().is_some_and(|e| e == "rs") {
if let Some(m) = file_mtime(&path) {
f(m);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unchanged_when_disk_matches_baseline() {
assert_eq!(reconcile(b"a", b"a", b"a"), Reconcile::Unchanged);
assert_eq!(reconcile(b"a", b"a", b"b"), Reconcile::Unchanged);
}
#[test]
fn adopt_when_disk_changed_and_memory_clean() {
assert_eq!(reconcile(b"a", b"b", b"a"), Reconcile::Adopt(b"b".to_vec()));
}
#[test]
fn conflict_when_both_changed() {
assert_eq!(reconcile(b"a", b"b", b"c"), Reconcile::Conflict);
}
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("pixel8-watch-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Self(dir)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn write_newer(path: &Path, contents: &[u8]) {
fs::write(path, contents).unwrap();
let later = SystemTime::now() + std::time::Duration::from_secs(10);
let _ = filetime_set(path, later);
}
fn filetime_set(path: &Path, t: SystemTime) -> std::io::Result<()> {
let f = fs::OpenOptions::new().write(true).open(path)?;
f.set_modified(t)
}
#[test]
fn filewatch_no_event_until_mtime_advances() {
let tmp = TempDir::new("noevent");
let p = tmp.0.join("lib.rs");
fs::write(&p, b"v1").unwrap();
let mut w = FileWatch::new(p.clone(), b"v1".to_vec());
assert_eq!(w.poll(b"v1"), FileChange::None);
}
#[test]
fn filewatch_adopts_external_change_when_memory_clean() {
let tmp = TempDir::new("adopt");
let p = tmp.0.join("lib.rs");
fs::write(&p, b"v1").unwrap();
let mut w = FileWatch::new(p.clone(), b"v1".to_vec());
write_newer(&p, b"v2");
assert_eq!(w.poll(b"v1"), FileChange::Adopt(b"v2".to_vec()));
assert_eq!(w.poll(b"v2"), FileChange::None);
assert!(!w.in_conflict());
}
#[test]
fn filewatch_conflicts_when_both_changed() {
let tmp = TempDir::new("conflict");
let p = tmp.0.join("lib.rs");
fs::write(&p, b"v1").unwrap();
let mut w = FileWatch::new(p.clone(), b"v1".to_vec());
write_newer(&p, b"disk");
assert_eq!(w.poll(b"mem"), FileChange::Conflict);
assert!(w.in_conflict());
}
#[test]
fn filewatch_mark_synced_clears_conflict_and_rebases() {
let tmp = TempDir::new("synced");
let p = tmp.0.join("lib.rs");
fs::write(&p, b"v1").unwrap();
let mut w = FileWatch::new(p.clone(), b"v1".to_vec());
write_newer(&p, b"disk");
assert_eq!(w.poll(b"mem"), FileChange::Conflict);
write_newer(&p, b"mem");
w.mark_synced(b"mem".to_vec());
assert!(!w.in_conflict());
assert_eq!(w.poll(b"mem"), FileChange::None);
}
#[test]
fn source_tree_detects_any_rs_or_cargo_change() {
let tmp = TempDir::new("srctree");
let dir = &tmp.0;
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(dir.join("Cargo.toml"), b"[package]\nname=\"g\"\n").unwrap();
fs::write(dir.join("src/lib.rs"), b"fn a(){}").unwrap();
fs::write(dir.join("src/foo.rs"), b"fn b(){}").unwrap();
let mut w = SourceTreeWatch::new(dir);
assert!(!w.poll(), "no change right after construction");
write_newer(&dir.join("src/foo.rs"), b"fn b(){ }");
assert!(w.poll(), "foo.rs change detected");
assert!(!w.poll(), "absorbed; no repeat");
write_newer(
&dir.join("Cargo.toml"),
b"[package]\nname=\"g\"\nedition=\"2021\"\n",
);
assert!(w.poll(), "Cargo.toml change detected");
}
}