use crate::common::Drip;
use std::fs;
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::Duration;
fn spawn_watcher(drip: &Drip, root: &std::path::Path) -> std::process::Child {
Command::new(&drip.bin)
.args(["watch"])
.arg(root)
.env("DRIP_DATA_DIR", drip.data_dir.path())
.env("DRIP_SESSION_ID", &drip.session_id)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn drip watch")
}
#[test]
fn watcher_precomputes_diff_after_file_change() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let baseline: String = (0..40).map(|i| format!("line {i}\n")).collect();
let f = dir.path().join("watched.txt");
fs::write(&f, &baseline).unwrap();
drip.read_stdout(&f);
let mut child = spawn_watcher(&drip, dir.path());
sleep(Duration::from_millis(800));
let modified: String = baseline.replace("line 5\n", "line 5 CHANGED\n");
fs::write(&f, &modified).unwrap();
sleep(Duration::from_millis(800));
let out = drip.read_stdout(&f);
assert!(
out.contains("[DRIP: delta only"),
"expected delta read after watcher precompute, got: {out}"
);
assert!(
out.contains("CHANGED"),
"expected diff to contain the new line, got: {out}"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn watcher_skips_dripignore_matched_files() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".dripignore"), "secret.txt\n").unwrap();
let f = dir.path().join("secret.txt");
fs::write(&f, "top-secret-baseline\n".repeat(40)).unwrap();
drip.read_stdout(&f);
fs::write(dir.path().join(".dripignore"), "secret.txt\n").unwrap();
let mut child = spawn_watcher(&drip, dir.path());
sleep(Duration::from_millis(700));
fs::write(&f, "top-secret-MODIFIED\n".repeat(40)).unwrap();
sleep(Duration::from_millis(700));
let conn = rusqlite::Connection::open(drip.data_dir.path().join("sessions.db")).unwrap();
let canonical = f.canonicalize().unwrap().to_string_lossy().into_owned();
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM precomputed_reads WHERE file_path = ?1",
rusqlite::params![canonical],
|r| r.get(0),
)
.unwrap();
assert_eq!(rows, 0, "watcher must skip .dripignore-matched files");
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[test]
fn watcher_ignores_paths_outside_watch_root() {
let drip = Drip::new();
let watch_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let outside_file = outside_dir.path().join("outside.txt");
fs::write(&outside_file, "outside-content\n".repeat(40)).unwrap();
drip.read_stdout(&outside_file);
let link = watch_dir.path().join("link.txt");
std::os::unix::fs::symlink(&outside_file, &link).unwrap();
let mut child = spawn_watcher(&drip, watch_dir.path());
sleep(Duration::from_millis(600));
fs::write(&outside_file, "outside-MODIFIED\n".repeat(40)).unwrap();
sleep(Duration::from_millis(800));
let conn = rusqlite::Connection::open(drip.data_dir.path().join("sessions.db")).unwrap();
let canonical = outside_file
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM precomputed_reads WHERE file_path = ?1",
rusqlite::params![canonical],
|r| r.get(0),
)
.unwrap();
assert_eq!(rows, 0, "watcher must refuse paths outside its watch root");
let _ = child.kill();
let _ = child.wait();
}
#[cfg(unix)]
#[test]
fn watcher_refuses_fifo_at_watched_path() {
use std::os::unix::ffi::OsStrExt;
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let flip = dir.path().join("flip.txt");
let canary = dir.path().join("canary.txt");
let baseline: String = (0..40).map(|i| format!("line {i}\n")).collect();
fs::write(&flip, &baseline).unwrap();
fs::write(&canary, &baseline).unwrap();
drip.read_stdout(&flip);
drip.read_stdout(&canary);
let mut child = spawn_watcher(&drip, dir.path());
sleep(Duration::from_millis(700));
{
let conn = rusqlite::Connection::open(drip.data_dir.path().join("sessions.db")).unwrap();
conn.execute("DELETE FROM precomputed_reads", []).unwrap();
}
fs::remove_file(&flip).unwrap();
let cstr = std::ffi::CString::new(flip.as_os_str().as_bytes()).unwrap();
let rc = unsafe { libc::mkfifo(cstr.as_ptr(), 0o600) };
assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
sleep(Duration::from_millis(600));
let canary_modified: String = baseline.replace("line 0\n", "line 0 CANARY-V2\n");
fs::write(&canary, &canary_modified).unwrap();
sleep(Duration::from_millis(900));
let conn = rusqlite::Connection::open(drip.data_dir.path().join("sessions.db")).unwrap();
let canary_canonical = canary
.canonicalize()
.unwrap()
.to_string_lossy()
.into_owned();
let canary_rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM precomputed_reads WHERE file_path = ?1",
rusqlite::params![canary_canonical],
|r| r.get(0),
)
.unwrap();
assert_eq!(
canary_rows, 1,
"watcher must remain responsive after a FIFO appears (canary precompute missing → \
recompute thread is blocked on fs::read of the FIFO)",
);
let fifo_rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM precomputed_reads WHERE file_path LIKE ?1",
rusqlite::params![format!("%{}", flip.file_name().unwrap().to_string_lossy())],
|r| r.get(0),
)
.unwrap();
assert_eq!(fifo_rows, 0, "watcher must NOT precompute a FIFO");
let _ = fs::remove_file(&flip);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn baseline_change_invalidates_precomputed_cache() {
let drip = Drip::new();
let dir = tempfile::tempdir().unwrap();
let baseline: String = (0..40).map(|i| format!("line {i}\n")).collect();
let f = dir.path().join("e.txt");
fs::write(&f, &baseline).unwrap();
drip.read_stdout(&f);
let mut child = spawn_watcher(&drip, dir.path());
sleep(Duration::from_millis(600));
let modified: String = baseline.replace("line 0\n", "line 0 V2\n");
fs::write(&f, &modified).unwrap();
sleep(Duration::from_millis(600));
use serde_json::json;
use std::io::Write;
let mut hook = Command::new(&drip.bin)
.args(["hook", "claude-post-edit"])
.env("DRIP_DATA_DIR", drip.data_dir.path())
.env("DRIP_SESSION_ID", &drip.session_id)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.spawn()
.unwrap();
hook.stdin
.as_mut()
.unwrap()
.write_all(
json!({
"session_id": &drip.session_id,
"tool_name": "Edit",
"tool_input": { "file_path": f.to_string_lossy() }
})
.to_string()
.as_bytes(),
)
.unwrap();
let _ = hook.wait_with_output();
let conn = rusqlite::Connection::open(drip.data_dir.path().join("sessions.db")).unwrap();
let canonical = f.canonicalize().unwrap().to_string_lossy().into_owned();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM precomputed_reads WHERE file_path = ?1",
rusqlite::params![canonical],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, 0,
"post-edit baseline refresh must invalidate precomputed cache"
);
let _ = child.kill();
let _ = child.wait();
}