#![cfg(all(feature = "watch", unix))]
use std::fs;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::{Duration, Instant};
const DEADLINE: Duration = Duration::from_secs(30);
struct WatchChild {
child: Child,
stderr: Arc<Mutex<Vec<u8>>>,
}
impl WatchChild {
fn spawn(tree: &Path, cache: &Path, args: &[&str], stdout: Stdio) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_fdu"))
.arg("--watch")
.args(args)
.arg(tree)
.env("XDG_CACHE_HOME", cache)
.env_remove("FDU_CACHE_DIR")
.stdout(stdout)
.stderr(Stdio::piped())
.spawn()
.expect("spawn watching fdu");
let stderr = Arc::new(Mutex::new(Vec::new()));
let mut pipe = child.stderr.take().expect("watch stderr");
let sink = Arc::clone(&stderr);
std::thread::spawn(move || {
let mut chunk = [0_u8; 4096];
while let Ok(read) = pipe.read(&mut chunk) {
if read == 0 {
break;
}
if let Ok(mut buffer) = sink.lock() {
buffer.extend_from_slice(&chunk[..read]);
}
}
});
Self { child, stderr }
}
fn evidence(&mut self) -> String {
let status = match self.child.try_wait() {
Ok(None) => "still running".to_owned(),
Ok(Some(status)) => format!("exited with {status}"),
Err(error) => format!("in an unknown state ({error})"),
};
let stderr = self
.stderr
.lock()
.map(|buffer| String::from_utf8_lossy(&buffer).into_owned())
.unwrap_or_default();
format!("the watcher was {status}; its stderr was {stderr:?}")
}
}
impl Drop for WatchChild {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn snapshot_fingerprint(cache_dir: &Path) -> Option<(u64, std::time::SystemTime)> {
let entries = fs::read_dir(cache_dir.join("fdu")).ok()?;
entries
.flatten()
.filter(|entry| {
entry.file_name().to_str().is_some_and(|name| {
name.strip_suffix(".metadata.bin").is_some_and(|key| {
key.len() == 16 && key.bytes().all(|byte| byte.is_ascii_hexdigit())
})
})
})
.filter_map(|entry| entry.metadata().ok())
.filter(|meta| meta.len() > 0)
.find_map(|meta| Some((meta.len(), meta.modified().ok()?)))
}
fn wait_for_rewrite(cache_dir: &Path, before: Option<(u64, std::time::SystemTime)>) -> bool {
let started = Instant::now();
while started.elapsed() < DEADLINE {
let now = snapshot_fingerprint(cache_dir);
if now.is_some() && now != before {
return true;
}
sleep(Duration::from_millis(100));
}
false
}
fn establish_watch(
cache_dir: &Path,
tree: &Path,
child: &mut WatchChild,
) -> Option<(u64, std::time::SystemTime)> {
let started = Instant::now();
let mut before = snapshot_fingerprint(cache_dir);
while before.is_none() && started.elapsed() < DEADLINE {
sleep(Duration::from_millis(100));
before = snapshot_fingerprint(cache_dir);
}
assert!(
before.is_some(),
"the open never left a snapshot within {DEADLINE:?}, so there is no baseline a \
watch loop's save could be told apart from; {}",
child.evidence(),
);
fs::write(tree.join("warmup.txt"), b"warmup").expect("write warm-up file");
assert!(
wait_for_rewrite(cache_dir, before),
"the watcher never persisted a warm-up change within {DEADLINE:?}, so it was never \
observed to be watching and nothing after this point would be evidence about fdu; \
{}",
child.evidence(),
);
snapshot_fingerprint(cache_dir)
}
fn report(tree: &Path, cache: &Path, args: &[&str]) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_fdu"))
.args(args)
.arg(tree)
.env("XDG_CACHE_HOME", cache)
.env_remove("FDU_CACHE_DIR")
.output()
.expect("run fdu");
assert!(
output.status.success(),
"fdu {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr),
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn a_watch_started_from_a_warm_cache_still_persists_what_it_sees() {
let root = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache tempdir");
let tree = root.path().join("tree");
fs::create_dir(&tree).expect("create tree");
fs::write(tree.join("first.txt"), b"first").expect("write first file");
report(&tree, cache.path(), &["--view", "files", "--format", "json", "--cache", "on"]);
let usable =
report(&tree, cache.path(), &["--view", "files", "--format", "json", "--stale-ok"]);
assert!(
usable.contains("cache_only"),
"expected the priming run to leave a usable snapshot, got: {usable}",
);
let mut child = WatchChild::spawn(
&tree,
cache.path(),
&["--view", "files", "--interval", "1s"],
Stdio::null(),
);
let initial = establish_watch(cache.path(), &tree, &mut child);
fs::write(tree.join("second.txt"), b"second").expect("write second file");
let rewritten = wait_for_rewrite(cache.path(), initial);
let evidence = child.evidence();
let _ = child.child.kill();
let _ = child.child.wait();
assert!(
rewritten,
"a warm-started watch never rewrote the snapshot after a change; {evidence}"
);
let listed =
report(&tree, cache.path(), &["--view", "files", "--format", "jsonl", "--stale-ok"]);
assert!(
listed.contains("second.txt"),
"a warm-started watch did not persist what it observed. Listing was: {listed}",
);
}
#[test]
fn a_projected_controls_off_watch_never_replaces_the_stronger_snapshot() {
let root = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache tempdir");
let tree = root.path().join("tree");
fs::create_dir(&tree).expect("create tree");
fs::write(tree.join(".gitignore"), b"*.log\n").expect("write controls");
fs::write(tree.join("ignored.log"), b"ignored").expect("write ignored file");
fs::write(tree.join("first.txt"), b"first").expect("write first file");
report(&tree, cache.path(), &["--view", "files", "--format", "json", "--cache", "on"]);
let stronger = snapshot_fingerprint(cache.path()).expect("controls-on snapshot");
let mut child = WatchChild::spawn(
&tree,
cache.path(),
&["--no-gitignore", "--view", "files", "--format", "jsonl", "--interval", "1s"],
Stdio::piped(),
);
let stdout = child.child.stdout.take().expect("watch stdout");
let (sent, received) = mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if sent.send(line).is_err() {
break;
}
}
});
let initial = received
.recv_timeout(DEADLINE)
.unwrap_or_else(|_| panic!("watch initial report; {}", child.evidence()))
.expect("read initial report");
assert!(initial.contains("fdu.report/"), "unexpected initial report: {initial}");
let started = Instant::now();
loop {
let line = received
.recv_timeout(DEADLINE.saturating_sub(started.elapsed()))
.unwrap_or_else(|_| panic!("watch initial files section; {}", child.evidence()))
.expect("read initial files section");
if line.contains("\"view\": \"files\"") {
break;
}
}
fs::write(tree.join("warmup.txt"), b"warmup").expect("write warm-up file");
let started = Instant::now();
let mut observed = false;
while started.elapsed() < DEADLINE {
let Ok(line) = received.recv_timeout(DEADLINE.saturating_sub(started.elapsed())) else {
break;
};
let Ok(line) = line else { break };
if line.contains("\"record\": \"change\"") && line.contains("\"path\": \"warmup.txt\"") {
observed = true;
break;
}
}
sleep(Duration::from_millis(1_500));
let after = snapshot_fingerprint(cache.path()).expect("snapshot remains");
let evidence = child.evidence();
let _ = child.child.kill();
let _ = child.child.wait();
assert!(
observed,
"the projected watcher did not report the change used to test its save guard; \
{evidence}"
);
assert_eq!(after, stronger, "the projected watch replaced the controls-on snapshot");
}
#[test]
fn a_killed_watch_still_leaves_a_warm_cache() {
let root = tempfile::tempdir().expect("tempdir");
let cache = tempfile::tempdir().expect("cache tempdir");
let tree = root.path().join("tree");
fs::create_dir(&tree).expect("create tree");
fs::write(tree.join("first.txt"), b"first").expect("write first file");
let mut child = WatchChild::spawn(
&tree,
cache.path(),
&["--view", "files", "--interval", "1s"],
Stdio::null(),
);
let initial = establish_watch(cache.path(), &tree, &mut child);
fs::write(tree.join("second.txt"), b"second").expect("write second file");
let rewritten = wait_for_rewrite(cache.path(), initial);
let evidence = child.evidence();
let _ = child.child.kill();
let _ = child.child.wait();
assert!(
rewritten,
"the watch loop never rewrote the snapshot after a change, so everything observed \
while watching would be lost; {evidence}",
);
let output = Command::new(env!("CARGO_BIN_EXE_fdu"))
.args(["--view", "summary", "--format", "json", "--stale-ok"])
.arg(&tree)
.env("XDG_CACHE_HOME", cache.path())
.env_remove("FDU_CACHE_DIR")
.output()
.expect("run fdu against the saved cache");
assert!(
output.status.success(),
"cache-only read of the watch session's snapshot failed: {}",
String::from_utf8_lossy(&output.stderr),
);
let report = String::from_utf8_lossy(&output.stdout);
assert!(
report.contains("\"source\": \"cache_only\"")
|| report.contains("\"source\":\"cache_only\""),
"expected a cache-only read, got: {report}",
);
let listing = Command::new(env!("CARGO_BIN_EXE_fdu"))
.args(["--view", "files", "--format", "jsonl", "--stale-ok"])
.arg(&tree)
.env("XDG_CACHE_HOME", cache.path())
.env_remove("FDU_CACHE_DIR")
.output()
.expect("list the saved cache");
let listed = String::from_utf8_lossy(&listing.stdout);
assert!(
listed.contains("second.txt"),
"the snapshot predates the watch session: it holds only the initial open's index, \
so incremental saves never ran. Listing was: {listed}",
);
}