use notify::{EventKind, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const PENDING_FOR: Duration = Duration::from_secs(120);
const MAX_PENDING: usize = 256;
pub struct Watch {
_watcher: notify::RecommendedWatcher,
structural: Arc<AtomicBool>,
pending: Arc<Mutex<HashMap<PathBuf, Instant>>>,
}
impl Watch {
pub fn start() -> Option<Self> {
let structural = Arc::new(AtomicBool::new(false));
let pending = Arc::new(Mutex::new(HashMap::new()));
let flag = Arc::clone(&structural);
let noted = Arc::clone(&pending);
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res
&& matches!(event.kind, EventKind::Create(_) | EventKind::Remove(_))
{
flag.store(true, Ordering::Relaxed);
note(¬ed, &event);
}
})
.ok()?;
let mut watching = 0usize;
for root in roots() {
if watcher.watch(&root, RecursiveMode::Recursive).is_ok() {
watching += 1;
}
}
(watching > 0).then_some(Watch {
_watcher: watcher,
structural,
pending,
})
}
pub fn took_structural_change(&self) -> bool {
self.structural.swap(false, Ordering::Relaxed)
}
pub fn awaiting_discovery(&self, discovered: impl Fn(&Path) -> bool) -> bool {
let Ok(mut pending) = self.pending.lock() else {
return false;
};
pending.retain(|path, at| at.elapsed() < PENDING_FOR && path.exists() && !discovered(path));
!pending.is_empty()
}
}
fn note(pending: &Mutex<HashMap<PathBuf, Instant>>, event: ¬ify::Event) {
let Ok(mut pending) = pending.lock() else {
return;
};
for path in &event.paths {
match event.kind {
EventKind::Create(_) if path.is_file() => {
if pending.len() < MAX_PENDING {
pending.insert(path.clone(), Instant::now());
}
}
_ => {
pending.remove(path);
}
}
}
}
fn roots() -> Vec<PathBuf> {
let mut roots = vec![
crate::config::CLAUDE_PROJECTS_ROOT.clone(),
crate::config::CODEX_SESSIONS_ROOT.clone(),
crate::config::CURSOR_PROJECTS_ROOT.clone(),
crate::config::PI_SESSIONS_ROOT.clone(),
crate::config::OPENCODE_DATA_DIR.clone(),
];
roots.extend(crate::config::CLAUDE_MAC_COWORK_ROOT.clone());
roots.extend(crate::config::CLAUDE_MAC_CODE_ROOT.clone());
roots.retain(|r| r.is_dir());
roots
}
#[cfg(test)]
mod tests {
use super::*;
fn watch_dir(dir: &Path) -> Watch {
let structural = Arc::new(AtomicBool::new(false));
let pending = Arc::new(Mutex::new(HashMap::new()));
let flag = Arc::clone(&structural);
let noted = Arc::clone(&pending);
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res
&& matches!(event.kind, EventKind::Create(_) | EventKind::Remove(_))
{
flag.store(true, Ordering::Relaxed);
note(¬ed, &event);
}
})
.unwrap();
watcher.watch(dir, RecursiveMode::Recursive).unwrap();
Watch {
_watcher: watcher,
structural,
pending,
}
}
fn eventually(f: impl Fn() -> bool) -> bool {
(0..50).any(|_| {
std::thread::sleep(Duration::from_millis(100));
f()
})
}
#[test]
fn a_created_file_arms_the_flag_once() {
let dir = tempfile::tempdir().unwrap();
let watch = watch_dir(dir.path());
std::fs::write(dir.path().join("new-session.jsonl"), b"{}\n").unwrap();
let armed = eventually(|| watch.took_structural_change());
assert!(armed, "a created file did not reach the flag");
assert!(
!watch.took_structural_change(),
"reading the flag must disarm it"
);
}
#[test]
fn a_create_keeps_asking_until_it_is_discovered() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let watch = watch_dir(&root);
let transcript = root.join("new-session.jsonl");
std::fs::write(&transcript, b"{}\n").unwrap();
let waiting = eventually(|| watch.awaiting_discovery(|_| false));
assert!(waiting, "a created file was not remembered");
assert!(
watch.awaiting_discovery(|_| false),
"an undiscovered create must survive being read"
);
assert!(
!watch.awaiting_discovery(|path| path == transcript),
"a discovered create must stop asking for walks"
);
assert!(
!watch.awaiting_discovery(|_| false),
"a discovered create must be forgotten, not re-armed"
);
}
#[test]
fn a_removed_create_stops_asking() {
let dir = tempfile::tempdir().unwrap();
let watch = watch_dir(dir.path());
let transcript = dir.path().join("doomed.jsonl");
std::fs::write(&transcript, b"{}\n").unwrap();
assert!(
eventually(|| watch.awaiting_discovery(|_| false)),
"a created file was not remembered"
);
std::fs::remove_file(&transcript).unwrap();
assert!(
!watch.awaiting_discovery(|_| false),
"a file that no longer exists must not keep asking for walks"
);
}
}