280 lines
9.7 KiB
Rust
280 lines
9.7 KiB
Rust
use std::collections::HashMap;
|
|
use std::io;
|
|
use std::os::unix::io::RawFd;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// A content change detected by the watcher.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ContentChange {
|
|
Created(String), // sys_title
|
|
Modified(String), // sys_title
|
|
Deleted(String), // sys_title
|
|
}
|
|
|
|
/// Watches a content directory for article file changes using inotify.
|
|
///
|
|
/// Expects the directory structure:
|
|
/// ```text
|
|
/// content_dir/
|
|
/// {sys_title}/
|
|
/// {sys_title}.json
|
|
/// ```
|
|
pub struct ContentWatcher {
|
|
inotify_fd: RawFd,
|
|
/// Maps inotify watch descriptor → directory path.
|
|
wd_to_path: HashMap<i32, PathBuf>,
|
|
/// Maps directory path → watch descriptor.
|
|
path_to_wd: HashMap<PathBuf, i32>,
|
|
content_dir: PathBuf,
|
|
}
|
|
|
|
const EVENT_MASK: u32 =
|
|
(libc::IN_CREATE | libc::IN_MODIFY | libc::IN_DELETE | libc::IN_MOVED_TO | libc::IN_MOVED_FROM)
|
|
as u32;
|
|
|
|
impl ContentWatcher {
|
|
/// Create a new watcher on the given content directory.
|
|
///
|
|
/// Adds inotify watches on the content dir itself and all subdirectories.
|
|
pub fn new(content_dir: &Path) -> io::Result<Self> {
|
|
let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
|
|
if fd < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
|
|
let mut watcher = Self {
|
|
inotify_fd: fd,
|
|
wd_to_path: HashMap::new(),
|
|
path_to_wd: HashMap::new(),
|
|
content_dir: content_dir.to_path_buf(),
|
|
};
|
|
|
|
// Watch the root content directory (for new article dirs).
|
|
watcher.add_watch(content_dir)?;
|
|
|
|
// Watch each existing subdirectory.
|
|
if content_dir.exists() {
|
|
for entry in std::fs::read_dir(content_dir)? {
|
|
let entry = entry?;
|
|
if entry.path().is_dir() {
|
|
watcher.add_watch(&entry.path())?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(watcher)
|
|
}
|
|
|
|
/// The inotify file descriptor, for registration on an event loop.
|
|
pub fn fd(&self) -> RawFd {
|
|
self.inotify_fd
|
|
}
|
|
|
|
/// Read and process pending inotify events.
|
|
///
|
|
/// Returns a list of content changes. Call this when the event loop
|
|
/// signals that the inotify fd is readable.
|
|
pub fn process_events(&mut self) -> io::Result<Vec<ContentChange>> {
|
|
let mut buf = [0u8; 4096];
|
|
let n = unsafe {
|
|
libc::read(
|
|
self.inotify_fd,
|
|
buf.as_mut_ptr() as *mut libc::c_void,
|
|
buf.len(),
|
|
)
|
|
};
|
|
|
|
if n < 0 {
|
|
let err = io::Error::last_os_error();
|
|
if err.raw_os_error() == Some(libc::EAGAIN) {
|
|
return Ok(vec![]);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
let mut changes = Vec::new();
|
|
let mut offset = 0usize;
|
|
|
|
while offset < n as usize {
|
|
let event = unsafe { &*(buf.as_ptr().add(offset) as *const libc::inotify_event) };
|
|
let name_len = event.len as usize;
|
|
let name = if name_len > 0 {
|
|
let name_ptr = unsafe { buf.as_ptr().add(offset + std::mem::size_of::<libc::inotify_event>()) };
|
|
let name_bytes = unsafe { std::slice::from_raw_parts(name_ptr, name_len) };
|
|
let end = name_bytes.iter().position(|&b| b == 0).unwrap_or(name_len);
|
|
Some(String::from_utf8_lossy(&name_bytes[..end]).to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
offset += std::mem::size_of::<libc::inotify_event>() + name_len;
|
|
|
|
let Some(name) = name else { continue };
|
|
let mask = event.mask;
|
|
|
|
// Event on the root content directory: subdirectory created/deleted.
|
|
if let Some(dir_path) = self.wd_to_path.get(&event.wd) {
|
|
let dir_path = dir_path.clone();
|
|
|
|
if dir_path == self.content_dir {
|
|
let sub_path = self.content_dir.join(&name);
|
|
if (mask & libc::IN_CREATE as u32) != 0 || (mask & libc::IN_MOVED_TO as u32) != 0 {
|
|
if sub_path.is_dir() {
|
|
let _ = self.add_watch(&sub_path);
|
|
changes.push(ContentChange::Created(name.clone()));
|
|
}
|
|
}
|
|
if (mask & libc::IN_DELETE as u32) != 0 || (mask & libc::IN_MOVED_FROM as u32) != 0 {
|
|
self.remove_watch(&sub_path);
|
|
changes.push(ContentChange::Deleted(name));
|
|
}
|
|
} else {
|
|
// Event inside an article subdirectory.
|
|
if name.ends_with(".json") || name.ends_with(".md") {
|
|
let sys_title = dir_path
|
|
.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
|
|
if (mask & libc::IN_MODIFY as u32) != 0 {
|
|
changes.push(ContentChange::Modified(sys_title));
|
|
} else if (mask & libc::IN_CREATE as u32) != 0
|
|
|| (mask & libc::IN_MOVED_TO as u32) != 0
|
|
{
|
|
changes.push(ContentChange::Created(sys_title));
|
|
} else if (mask & libc::IN_DELETE as u32) != 0
|
|
|| (mask & libc::IN_MOVED_FROM as u32) != 0
|
|
{
|
|
changes.push(ContentChange::Deleted(sys_title));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deduplicate: keep only the last change per sys_title.
|
|
let mut seen = HashMap::new();
|
|
for change in changes {
|
|
let key = match &change {
|
|
ContentChange::Created(t) | ContentChange::Modified(t) | ContentChange::Deleted(t) => {
|
|
t.clone()
|
|
}
|
|
};
|
|
seen.insert(key, change);
|
|
}
|
|
|
|
Ok(seen.into_values().collect())
|
|
}
|
|
|
|
fn add_watch(&mut self, path: &Path) -> io::Result<()> {
|
|
let c_path = std::ffi::CString::new(path.to_str().unwrap_or("")).map_err(|_| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "invalid path for inotify")
|
|
})?;
|
|
|
|
let wd = unsafe { libc::inotify_add_watch(self.inotify_fd, c_path.as_ptr(), EVENT_MASK) };
|
|
if wd < 0 {
|
|
return Err(io::Error::last_os_error());
|
|
}
|
|
|
|
self.wd_to_path.insert(wd, path.to_path_buf());
|
|
self.path_to_wd.insert(path.to_path_buf(), wd);
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_watch(&mut self, path: &Path) {
|
|
if let Some(wd) = self.path_to_wd.remove(path) {
|
|
unsafe { libc::inotify_rm_watch(self.inotify_fd, wd) };
|
|
self.wd_to_path.remove(&wd);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for ContentWatcher {
|
|
fn drop(&mut self) {
|
|
unsafe { libc::close(self.inotify_fd) };
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn detect_json_modification() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let content = tmp.path().join("content");
|
|
let art_dir = content.join("my-article");
|
|
fs::create_dir_all(&art_dir).unwrap();
|
|
fs::write(art_dir.join("my-article.json"), r#"{"test": true}"#).unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&content).unwrap();
|
|
|
|
// Modify the file.
|
|
fs::write(art_dir.join("my-article.json"), r#"{"test": false}"#).unwrap();
|
|
|
|
// Give inotify a moment.
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let changes = watcher.process_events().unwrap();
|
|
assert!(!changes.is_empty());
|
|
assert!(changes.iter().any(|c| matches!(c, ContentChange::Modified(t) if t == "my-article")));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_new_directory() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let content = tmp.path().join("content");
|
|
fs::create_dir_all(&content).unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&content).unwrap();
|
|
|
|
// Create a new article directory.
|
|
let art_dir = content.join("new-article");
|
|
fs::create_dir_all(&art_dir).unwrap();
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let changes = watcher.process_events().unwrap();
|
|
assert!(changes.iter().any(|c| matches!(c, ContentChange::Created(t) if t == "new-article")));
|
|
}
|
|
|
|
#[test]
|
|
fn detect_json_delete() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let content = tmp.path().join("content");
|
|
let art_dir = content.join("delete-me");
|
|
fs::create_dir_all(&art_dir).unwrap();
|
|
fs::write(art_dir.join("delete-me.json"), "{}").unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&content).unwrap();
|
|
|
|
// Delete the JSON file.
|
|
fs::remove_file(art_dir.join("delete-me.json")).unwrap();
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let changes = watcher.process_events().unwrap();
|
|
assert!(changes.iter().any(|c| matches!(c, ContentChange::Deleted(t) if t == "delete-me")));
|
|
}
|
|
|
|
#[test]
|
|
fn ignore_non_json_files() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let content = tmp.path().join("content");
|
|
let art_dir = content.join("my-article");
|
|
fs::create_dir_all(&art_dir).unwrap();
|
|
|
|
let mut watcher = ContentWatcher::new(&content).unwrap();
|
|
|
|
// Create a non-JSON file.
|
|
fs::write(art_dir.join("notes.txt"), "not json").unwrap();
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let changes = watcher.process_events().unwrap();
|
|
// Non-JSON files should not trigger Modified events.
|
|
assert!(changes.iter().all(|c| !matches!(c, ContentChange::Modified(_))));
|
|
}
|
|
}
|