#![allow(missing_docs)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use parking_lot::RwLock;
pub struct FileTracker {
records: RwLock<HashMap<PathBuf, FileRecord>>,
}
struct FileRecord {
modified_at: Option<SystemTime>,
}
impl Default for FileTracker {
fn default() -> Self {
Self::new()
}
}
impl FileTracker {
pub fn new() -> Self {
Self {
records: RwLock::new(HashMap::new()),
}
}
pub fn record_read(&self, path: &Path) -> std::io::Result<()> {
let modified_at = match std::fs::metadata(path) {
Ok(meta) => meta.modified().ok(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(e),
};
let canonical = std::fs::canonicalize(path)
.or_else(|_| std::path::absolute(path))
.unwrap_or_else(|_| path.to_path_buf());
self.records
.write()
.insert(canonical, FileRecord { modified_at });
Ok(())
}
pub fn check_unmodified(&self, path: &Path) -> Result<(), String> {
let canonical = std::fs::canonicalize(path)
.or_else(|_| std::path::absolute(path))
.unwrap_or_else(|_| path.to_path_buf());
let records = self.records.read();
let record = records.get(&canonical).ok_or_else(|| {
format!(
"File {} has not been read yet. Read it first before editing.",
path.display()
)
})?;
let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
match (record.modified_at, current_mtime) {
(Some(recorded), Some(current)) if recorded == current => Ok(()),
(None, None) => Ok(()),
_ => Err(format!(
"File {} has been modified since it was last read. Read it again before editing.",
path.display()
)),
}
}
pub fn was_read(&self, path: &Path) -> bool {
let canonical = std::fs::canonicalize(path)
.or_else(|_| std::path::absolute(path))
.unwrap_or_else(|_| path.to_path_buf());
self.records.read().contains_key(&canonical)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn record_read_and_was_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello").unwrap();
let tracker = FileTracker::new();
assert!(!tracker.was_read(&path));
tracker.record_read(&path).unwrap();
assert!(tracker.was_read(&path));
}
#[test]
fn check_unmodified_passes_when_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello").unwrap();
let tracker = FileTracker::new();
tracker.record_read(&path).unwrap();
assert!(tracker.check_unmodified(&path).is_ok());
}
#[test]
fn check_unmodified_fails_when_never_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello").unwrap();
let tracker = FileTracker::new();
let err = tracker.check_unmodified(&path).unwrap_err();
assert!(err.contains("has not been read yet"), "got: {err}");
}
#[test]
fn check_unmodified_fails_when_modified_externally() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello").unwrap();
let tracker = FileTracker::new();
tracker.record_read(&path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
let mut f = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&path)
.unwrap();
f.write_all(b"modified").unwrap();
f.sync_all().unwrap();
let err = tracker.check_unmodified(&path).unwrap_err();
assert!(err.contains("has been modified"), "got: {err}");
}
#[test]
fn record_read_updates_mtime_after_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.txt");
std::fs::write(&path, "hello").unwrap();
let tracker = FileTracker::new();
tracker.record_read(&path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&path, "changed").unwrap();
tracker.record_read(&path).unwrap();
assert!(tracker.check_unmodified(&path).is_ok());
}
#[test]
fn record_read_nonexistent_file_ok() {
let tracker = FileTracker::new();
let path = Path::new("/tmp/nonexistent_heartbit_test_file_12345");
tracker.record_read(path).unwrap();
}
#[test]
fn check_unmodified_fails_when_file_deleted_after_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("will_delete.txt");
std::fs::write(&path, "content").unwrap();
let tracker = FileTracker::new();
tracker.record_read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
let err = tracker.check_unmodified(&path).unwrap_err();
assert!(err.contains("has been modified"), "got: {err}");
}
#[test]
fn check_unmodified_fails_when_file_created_after_nonexistent_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("will_appear.txt");
let tracker = FileTracker::new();
tracker.record_read(&path).unwrap();
std::fs::write(&path, "surprise").unwrap();
let err = tracker.check_unmodified(&path).unwrap_err();
assert!(err.contains("has been modified"), "got: {err}");
}
}