use std::{
io::{BufRead, BufReader},
process::{Command, Stdio},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use serde_json::Value;
use tempfile::TempDir;
use super::heddle;
fn spawn_watch(
cwd: &std::path::Path,
args: &[&str],
) -> (std::process::Child, mpsc::Receiver<String>) {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_heddle"));
cmd.arg("watch");
for a in args {
cmd.arg(a);
}
cmd.current_dir(cwd)
.env("HEDDLE_CONFIG", cwd.join(".heddle-user/config.toml"))
.env("NO_COLOR", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn().expect("spawn heddle watch");
let stdout = child.stdout.take().expect("stdout pipe");
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if tx.send(line).is_err() {
break;
}
}
});
(child, rx)
}
fn collect_lines_until<F>(
rx: &mpsc::Receiver<String>,
deadline: Instant,
mut predicate: F,
) -> Vec<String>
where
F: FnMut(&str) -> bool,
{
let mut out = Vec::new();
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
match rx.recv_timeout(remaining.min(Duration::from_millis(250))) {
Ok(line) => {
let matched = predicate(&line);
out.push(line);
if matched {
return out;
}
}
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
out
}
#[test]
fn watch_streams_snapshot_text_mode() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
std::fs::write(temp.path().join("seed.txt"), "seed").unwrap();
heddle(&["capture", "-m", "seed"], Some(temp.path())).unwrap();
let (mut child, rx) = spawn_watch(
temp.path(),
&["--max-iterations", "5", "--poll-interval-ms", "50"],
);
thread::sleep(Duration::from_millis(250));
std::fs::write(temp.path().join("hello.txt"), "world").unwrap();
let snap_output = heddle(&["capture", "-m", "feat: hello world"], Some(temp.path()))
.expect("snapshot succeeds");
let change_id_short = snap_output
.lines()
.find_map(|line| {
line.split_whitespace()
.find(|tok| tok.starts_with("hd-"))
.map(str::to_string)
})
.expect("snapshot output should contain hd-… short id");
let deadline = Instant::now() + Duration::from_secs(8);
let lines = collect_lines_until(&rx, deadline, |line| {
line.contains(&change_id_short) || line.contains("snapshot")
});
let _ = child.kill();
let _ = child.wait();
let combined = lines.join("\n");
assert!(
combined.contains("snapshot"),
"watch output missing snapshot kind: {combined}"
);
assert!(
combined.contains(&change_id_short),
"watch output missing change_id {change_id_short}: {combined}"
);
}
#[test]
fn watch_emits_json_per_line() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
std::fs::write(temp.path().join("seed.txt"), "seed").unwrap();
heddle(&["capture", "-m", "seed"], Some(temp.path())).unwrap();
let (mut child, rx) = spawn_watch(
temp.path(),
&[
"--json",
"--max-iterations",
"3",
"--poll-interval-ms",
"50",
],
);
thread::sleep(Duration::from_millis(250));
std::fs::write(temp.path().join("a.txt"), "a").unwrap();
heddle(&["capture", "-m", "json mode test"], Some(temp.path())).unwrap();
let deadline = Instant::now() + Duration::from_secs(8);
let lines = collect_lines_until(&rx, deadline, |line| {
line.starts_with('{') && line.contains("\"kind\":\"snapshot\"")
});
let _ = child.kill();
let _ = child.wait();
let json_line = lines
.iter()
.find(|line| line.starts_with('{'))
.unwrap_or_else(|| {
panic!(
"no JSON line in watch output (got {} lines): {}",
lines.len(),
lines.join("\n")
)
});
let value: Value = serde_json::from_str(json_line)
.unwrap_or_else(|err| panic!("invalid JSON {json_line:?}: {err}"));
assert!(value["ts"].is_string(), "ts missing");
assert_eq!(value["kind"], "snapshot");
assert!(value["change_id"].is_string(), "change_id missing");
assert!(value.get("intent").is_some(), "intent field missing");
assert!(value["id"].is_u64(), "id missing");
}
#[test]
fn watch_filter_drops_non_matching_kinds() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
std::fs::write(temp.path().join("seed.txt"), "seed").unwrap();
heddle(&["capture", "-m", "seed"], Some(temp.path())).unwrap();
let (mut child, rx) = spawn_watch(
temp.path(),
&[
"--json",
"--filter",
"thread_create",
"--max-iterations",
"5",
"--poll-interval-ms",
"50",
],
);
thread::sleep(Duration::from_millis(250));
std::fs::write(temp.path().join("filtered.txt"), "x").unwrap();
heddle(&["capture", "-m", "should be filtered"], Some(temp.path())).unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
let lines = collect_lines_until(&rx, deadline, |_| false);
let _ = child.kill();
let _ = child.wait();
let snapshot_lines: Vec<_> = lines
.iter()
.filter(|l| l.contains("\"kind\":\"snapshot\""))
.collect();
assert!(
snapshot_lines.is_empty(),
"filter should drop snapshot kind, got: {snapshot_lines:?}"
);
}
#[test]
fn watch_rejects_unknown_filter_kind() {
let temp = TempDir::new().unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
let mut cmd = Command::new(env!("CARGO_BIN_EXE_heddle"));
cmd.args(["watch", "--filter", "totally_bogus_kind"])
.current_dir(temp.path())
.env(
"HEDDLE_CONFIG",
temp.path().join(".heddle-user/config.toml"),
)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let output = cmd.output().expect("spawn");
assert!(
!output.status.success(),
"expected nonzero exit on bogus filter, got status {:?}",
output.status
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown event kind") || stderr.contains("totally_bogus_kind"),
"stderr should explain rejection: {stderr}"
);
}