use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Debug, Clone)]
pub struct DocumentEntry {
pub version: i32,
pub mtime: Option<SystemTime>,
pub size: Option<u64>,
}
#[derive(Debug, Default)]
pub struct DocumentStore {
entries: HashMap<PathBuf, DocumentEntry>,
}
impl DocumentStore {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn is_open(&self, path: &Path) -> bool {
self.entries.contains_key(path)
}
pub fn open(&mut self, path: PathBuf) -> i32 {
let (mtime, size) = read_metadata(&path);
let entry = DocumentEntry {
version: 0,
mtime,
size,
};
self.entries.insert(path, entry);
0
}
pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
let (new_mtime, new_size) = read_metadata(path);
let entry = self.entries.get_mut(path)?;
entry.version += 1;
entry.mtime = new_mtime;
entry.size = new_size;
Some(entry.version)
}
pub fn version(&self, path: &Path) -> Option<i32> {
self.entries.get(path).map(|e| e.version)
}
pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
self.entries.get(path)
}
pub fn close(&mut self, path: &Path) -> Option<i32> {
self.entries.remove(path).map(|e| e.version)
}
pub fn open_documents(&self) -> Vec<&PathBuf> {
self.entries.keys().collect()
}
pub fn is_stale_on_disk(&self, path: &Path) -> bool {
let Some(entry) = self.entries.get(path) else {
return true;
};
let (current_mtime, current_size) = read_metadata(path);
match (entry.mtime, current_mtime) {
(Some(prev), Some(now)) if prev == now => {
entry.size != current_size
}
(Some(_), Some(_)) => true, _ => true,
}
}
}
fn read_metadata(path: &Path) -> (Option<SystemTime>, Option<u64>) {
match std::fs::metadata(path) {
Ok(meta) => {
let mtime = meta.modified().ok();
let size = Some(meta.len());
(mtime, size)
}
Err(_) => (None, None),
}
}
pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
let meta = std::fs::metadata(path)?;
let mtime = meta.modified()?;
let size = meta.len();
Ok((mtime, size))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use std::thread;
use std::time::Duration;
fn write_file(path: &Path, content: &str) {
let mut f = fs::File::create(path).expect("create test file");
f.write_all(content.as_bytes()).expect("write content");
}
#[test]
fn open_and_close_roundtrip() {
let mut store = DocumentStore::new();
let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
assert!(!store.is_open(&path));
let v = store.open(path.clone());
assert_eq!(v, 0);
assert!(store.is_open(&path));
assert_eq!(store.version(&path), Some(0));
let bumped = store.bump_version(&path);
assert_eq!(bumped, Some(1));
assert_eq!(store.version(&path), Some(1));
let closed = store.close(&path);
assert_eq!(closed, Some(1));
assert!(!store.is_open(&path));
}
#[test]
fn nonexistent_path_is_always_stale() {
let store = DocumentStore::new();
let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
assert!(store.is_stale_on_disk(&path));
}
#[test]
fn freshly_opened_real_file_is_not_stale() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("a.txt");
write_file(&path, "hello");
let mut store = DocumentStore::new();
store.open(path.clone());
assert!(!store.is_stale_on_disk(&path));
}
#[test]
fn opened_then_disk_changed_is_stale() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("b.txt");
write_file(&path, "hello");
let mut store = DocumentStore::new();
store.open(path.clone());
assert!(!store.is_stale_on_disk(&path));
thread::sleep(Duration::from_millis(20));
write_file(&path, "hello world!");
assert!(store.is_stale_on_disk(&path));
}
#[test]
fn opened_file_then_deleted_is_stale() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("c.txt");
write_file(&path, "data");
let mut store = DocumentStore::new();
store.open(path.clone());
assert!(!store.is_stale_on_disk(&path));
fs::remove_file(&path).expect("remove file");
assert!(store.is_stale_on_disk(&path));
}
#[test]
fn bump_version_refreshes_mtime() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("d.txt");
write_file(&path, "original");
let mut store = DocumentStore::new();
store.open(path.clone());
thread::sleep(Duration::from_millis(20));
write_file(&path, "updated");
assert!(store.is_stale_on_disk(&path));
store.bump_version(&path);
assert!(!store.is_stale_on_disk(&path));
}
#[test]
fn open_documents_returns_all_paths() {
let mut store = DocumentStore::new();
store.open(PathBuf::from("/tmp/p1"));
store.open(PathBuf::from("/tmp/p2"));
let docs = store.open_documents();
assert_eq!(docs.len(), 2);
}
#[test]
fn entry_returns_full_state() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("e.txt");
write_file(&path, "abc");
let mut store = DocumentStore::new();
store.open(path.clone());
let entry = store.entry(&path).expect("entry");
assert_eq!(entry.version, 0);
assert!(entry.mtime.is_some());
assert_eq!(entry.size, Some(3));
}
}