1use std::collections::HashSet;
13use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15
16use anyhow::{Context, Result};
17use inotify::{EventMask, Inotify, WatchMask};
18
19pub struct Watcher {
20 inotify: Inotify,
21 names: Vec<(inotify::WatchDescriptor, HashSet<OsString>)>,
23 buffer: Vec<u8>,
24}
25
26impl Watcher {
27 pub fn new(files: &[PathBuf]) -> Result<Self> {
31 let inotify = Inotify::init().context("inotify init failed")?;
32 let mut names: Vec<(inotify::WatchDescriptor, HashSet<OsString>)> = Vec::new();
33 for file in files {
34 let (Some(dir), Some(name)) = (file.parent(), file.file_name()) else {
35 continue;
36 };
37 if !dir.is_dir() {
38 continue;
39 }
40 match inotify.watches().add(
41 dir,
42 WatchMask::CLOSE_WRITE
43 | WatchMask::MOVED_TO
44 | WatchMask::CREATE
45 | WatchMask::DELETE,
46 ) {
47 Ok(wd) => match names.iter_mut().find(|(w, _)| *w == wd) {
48 Some((_, set)) => {
49 set.insert(name.to_owned());
50 }
51 None => {
52 names.push((wd, HashSet::from([name.to_owned()])));
53 }
54 },
55 Err(err) => {
56 return Err(err).with_context(|| format!("cannot watch {}", dir.display()));
57 }
58 }
59 }
60 Ok(Self {
61 inotify,
62 names,
63 buffer: vec![0; 4096],
64 })
65 }
66
67 pub fn changed(&mut self) -> bool {
70 let Ok(events) = self.inotify.read_events(&mut self.buffer) else {
71 return false;
72 };
73 let mut changed = false;
74 for event in events {
75 if event.mask.contains(EventMask::Q_OVERFLOW) {
76 changed = true;
77 continue;
78 }
79 let Some(name) = event.name else { continue };
80 if self
81 .names
82 .iter()
83 .any(|(wd, set)| *wd == event.wd && set.contains(name))
84 {
85 changed = true;
86 }
87 }
88 changed
89 }
90
91 pub fn files_for(config: Option<&Path>) -> Vec<PathBuf> {
93 let mut files = vec![
94 PathBuf::from(crate::model::ADMIN_PATH),
95 PathBuf::from(crate::model::SHIPPED_PATH),
96 ];
97 if let Some(config) = config {
98 files.push(config.to_path_buf());
99 }
100 files
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use std::fs;
108
109 #[test]
110 fn a_rename_into_a_watched_name_is_a_change_and_other_names_are_not() {
111 let dir = std::env::temp_dir().join(format!("scxtui-watch-{}", std::process::id()));
112 let _ = fs::remove_dir_all(&dir);
113 fs::create_dir_all(&dir).unwrap();
114 let file = dir.join("catalog.json");
115 let mut watcher = Watcher::new(&[file.clone(), dir.join("missing-dir").join("x")]).unwrap();
116 assert!(!watcher.changed());
117
118 fs::write(dir.join("other"), "x").unwrap();
119 assert!(!watcher.changed(), "an unrelated file is ignored");
120
121 let tmp = dir.join(".scratch");
122 fs::write(&tmp, "{}").unwrap();
123 fs::rename(&tmp, &file).unwrap();
124 assert!(watcher.changed(), "a rename into place is seen");
125 assert!(!watcher.changed(), "and reported once");
126
127 fs::write(&file, "{ }").unwrap();
128 assert!(watcher.changed(), "an in-place write is seen");
129 let _ = fs::remove_dir_all(&dir);
130 }
131}