use anyhow::{anyhow, bail};
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum TriggerSpec {
#[cfg(unix)]
Fifo(std::path::PathBuf),
File(std::path::PathBuf),
#[cfg(unix)]
Fd(i32),
}
impl std::fmt::Display for TriggerSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
#[cfg(unix)]
TriggerSpec::Fifo(path) => write!(f, "fifo:{}", path.display()),
TriggerSpec::File(path) => write!(f, "file:{}", path.display()),
#[cfg(unix)]
TriggerSpec::Fd(n) => write!(f, "fd:{n}"),
}
}
}
#[cfg(unix)]
const FD_SETSIZE: i32 = 1024;
pub fn parse_trigger(s: &str) -> anyhow::Result<TriggerSpec> {
let teach = || anyhow!("invalid trigger {s:?}: expected fifo:PATH, file:PATH, or fd:N");
let Some((scheme, rest)) = s.split_once(':') else {
return Err(teach());
};
if rest.is_empty() {
return Err(teach());
}
match scheme {
"file" => Ok(TriggerSpec::File(std::path::PathBuf::from(rest))),
#[cfg(unix)]
"fifo" => Ok(TriggerSpec::Fifo(std::path::PathBuf::from(rest))),
#[cfg(unix)]
"fd" => {
let n: i32 = rest
.parse()
.map_err(|_| anyhow!("invalid trigger {s:?}: fd:N takes a number"))?;
if n < 0 {
bail!("invalid trigger {s:?}: fd:N takes a non-negative number");
}
if n >= FD_SETSIZE {
bail!(
"fd:{n} is out of range for select(2); descriptors must be below {FD_SETSIZE}"
);
}
Ok(TriggerSpec::Fd(n))
}
#[cfg(windows)]
"fifo" | "fd" => {
bail!("{scheme}: triggers are unix-only; use file:PATH")
}
_ => Err(teach()),
}
}
pub struct DebounceGate {
window: std::time::Duration,
deadline: Option<std::time::Instant>,
}
impl DebounceGate {
pub fn new(window: std::time::Duration) -> DebounceGate {
DebounceGate {
window,
deadline: None,
}
}
pub fn fire(&mut self, now: std::time::Instant) {
if self.deadline.is_none() {
self.deadline = Some(now + self.window);
}
}
pub fn due(&mut self, now: std::time::Instant) -> bool {
if self.deadline.is_some_and(|deadline| now >= deadline) {
self.deadline = None;
true
} else {
false
}
}
}
pub struct MtimeWatch {
path: std::path::PathBuf,
last: Option<Fingerprint>,
}
type Fingerprint = Option<std::time::SystemTime>;
impl MtimeWatch {
pub fn new(path: std::path::PathBuf) -> MtimeWatch {
MtimeWatch { path, last: None }
}
pub fn fired(&mut self) -> bool {
let current = fingerprint(&self.path);
let changed = self.last.is_some_and(|last| last != current);
self.last = Some(current);
changed
}
}
fn fingerprint(path: &std::path::Path) -> Fingerprint {
let meta = std::fs::metadata(path).ok()?;
let mut newest = meta.modified().ok()?;
if meta.is_dir() {
for entry in std::fs::read_dir(path).ok()?.flatten() {
if let Ok(modified) = entry.metadata().and_then(|m| m.modified()) {
newest = newest.max(modified);
}
}
}
Some(newest)
}
pub struct MtimeWatchSet(Vec<MtimeWatch>);
impl MtimeWatchSet {
pub fn new(paths: Vec<std::path::PathBuf>) -> MtimeWatchSet {
MtimeWatchSet(paths.into_iter().map(MtimeWatch::new).collect())
}
pub fn fired(&mut self) -> bool {
let mut any = false;
for watch in &mut self.0 {
any |= watch.fired();
}
any
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{Duration, Instant};
use super::*;
const D: Duration = Duration::from_millis(250);
#[test]
fn a_fire_becomes_due_when_the_window_closes() {
let t = Instant::now();
let mut g = DebounceGate::new(D);
assert!(!g.due(t)); g.fire(t);
assert!(!g.due(t + Duration::from_millis(100))); assert!(g.due(t + D)); assert!(!g.due(t + D)); }
#[test]
fn fires_inside_the_window_do_not_move_it() {
let t = Instant::now();
let mut g = DebounceGate::new(D);
g.fire(t);
g.fire(t + Duration::from_millis(200));
assert!(g.due(t + D)); }
#[test]
fn a_fire_after_the_window_closed_opens_a_new_one() {
let t = Instant::now();
let mut g = DebounceGate::new(D);
g.fire(t);
assert!(g.due(t + D));
g.fire(t + D * 2);
assert!(!g.due(t + D * 2));
assert!(g.due(t + D * 3));
}
#[test]
fn a_zero_window_is_due_at_the_fire_instant() {
let t = Instant::now();
let mut g = DebounceGate::new(Duration::ZERO);
g.fire(t);
assert!(g.due(t));
assert!(!g.due(t));
}
#[test]
fn sustained_sub_window_fires_never_starve_the_spawn() {
let t = Instant::now();
let mut g = DebounceGate::new(D);
let mut spawns = 0;
for i in 0..20 {
let now = t + Duration::from_millis(50 * i);
g.fire(now);
if g.due(now) {
spawns += 1;
}
}
assert!(spawns >= 3, "starved: {spawns} spawns over 1s at D=250ms");
}
use std::path::Path;
use std::time::SystemTime;
fn touch_at(path: &Path, t: SystemTime) {
std::fs::File::options()
.append(true)
.open(path)
.unwrap()
.set_modified(t)
.unwrap();
}
#[test]
fn the_first_observation_is_a_baseline_not_a_fire() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("state.json");
std::fs::write(&f, b"x").unwrap();
let mut w = MtimeWatch::new(f);
assert!(!w.fired()); assert!(!w.fired()); }
#[test]
fn an_mtime_change_fires_once() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("state.json");
std::fs::write(&f, b"x").unwrap();
let mut w = MtimeWatch::new(f.clone());
w.fired();
touch_at(&f, SystemTime::now() + Duration::from_secs(5));
assert!(w.fired());
assert!(!w.fired());
}
#[test]
fn an_absent_path_is_stable_and_fires_on_appearance() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("not-yet");
let mut w = MtimeWatch::new(f.clone());
assert!(!w.fired()); assert!(!w.fired()); std::fs::write(&f, b"x").unwrap();
assert!(w.fired()); }
#[test]
fn a_directory_fires_on_an_immediate_entrys_edit() {
let dir = tempfile::tempdir().unwrap();
let f = dir.path().join("finding.md");
std::fs::write(&f, b"x").unwrap();
let mut w = MtimeWatch::new(dir.path().to_path_buf());
w.fired();
touch_at(&f, SystemTime::now() + Duration::from_secs(5));
assert!(w.fired());
}
#[test]
fn a_directory_is_not_recursive() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
let deep = sub.join("deep.md");
std::fs::write(&deep, b"x").unwrap();
let mut w = MtimeWatch::new(dir.path().to_path_buf());
w.fired();
touch_at(&deep, SystemTime::now() + Duration::from_secs(5));
assert!(!w.fired()); }
#[test]
fn a_set_fires_when_any_member_fires() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::write(&a, b"x").unwrap();
std::fs::write(&b, b"x").unwrap();
let mut set = MtimeWatchSet::new(vec![a, b.clone()]);
set.fired();
touch_at(&b, SystemTime::now() + Duration::from_secs(5));
assert!(set.fired());
}
#[test]
fn specs_parse_by_scheme() {
assert_eq!(
parse_trigger("file:/tmp/state.json").unwrap(),
TriggerSpec::File(PathBuf::from("/tmp/state.json"))
);
#[cfg(unix)]
{
assert_eq!(
parse_trigger("fifo:/tmp/rat.trigger").unwrap(),
TriggerSpec::Fifo(PathBuf::from("/tmp/rat.trigger"))
);
assert_eq!(parse_trigger("fd:3").unwrap(), TriggerSpec::Fd(3));
}
}
#[test]
fn a_bare_path_teaches_the_schemes() {
let err = parse_trigger("/tmp/state.json").unwrap_err().to_string();
assert!(err.contains("fifo:"), "{err}");
assert!(err.contains("file:"), "{err}");
assert!(err.contains("fd:"), "{err}");
}
#[test]
fn an_empty_path_after_a_scheme_is_rejected() {
assert!(parse_trigger("file:").is_err());
#[cfg(unix)]
assert!(parse_trigger("fifo:").is_err());
}
#[test]
fn a_non_numeric_fd_is_rejected() {
#[cfg(unix)]
assert!(parse_trigger("fd:three").is_err());
#[cfg(unix)]
assert!(parse_trigger("fd:-1").is_err());
}
#[cfg(unix)]
#[test]
fn an_fd_past_the_select_limit_is_rejected_at_parse() {
let err = parse_trigger("fd:1024").unwrap_err().to_string();
assert!(err.contains("select"), "{err}");
assert!(parse_trigger("fd:1023").is_ok());
}
#[cfg(windows)]
#[test]
fn unix_only_schemes_teach_file_on_windows() {
for spec in ["fifo:/tmp/x", "fd:3"] {
let err = parse_trigger(spec).unwrap_err().to_string();
assert!(err.contains("file:"), "{err}");
}
}
}