use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use hh_core::blob::BlobStore;
use hh_core::event::{ChangeKind, Event, EventKind, FileChange};
use hh_core::store::EventWriter;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
const BUILTIN_IGNORE: &[&str] = &[".git/", "node_modules/", "target/"];
#[cfg(test)]
static INJECT_PANIC_FOR_TEST: AtomicBool = AtomicBool::new(false);
#[cfg(test)]
static REAL_RUN_LOOP_MUTEX: Mutex<()> = Mutex::new(());
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
const GRACE_QUIET: Duration = Duration::from_millis(150);
const GRACE_MAX: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct WatchOptions {
pub cwd: PathBuf,
pub max_file_size: u64,
pub record_binary: bool,
pub extra_ignore: Vec<String>,
pub internal_exclude: Vec<PathBuf>,
}
pub struct WatcherHandle {
pub thread: Option<JoinHandle<()>>,
stop: Arc<AtomicBool>,
}
impl WatcherHandle {
pub fn stop_and_join(mut self) {
self.stop.store(true, Ordering::Release);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
#[allow(clippy::too_many_arguments)] pub fn spawn_watcher(
opts: WatchOptions,
writer: Arc<Mutex<EventWriter>>,
blobs: Arc<BlobStore>,
session_id: String,
start: Instant,
) -> crate::Result<Option<WatcherHandle>> {
let matcher = match build_matcher(&opts.cwd, &opts.extra_ignore) {
Ok(m) => m,
Err(e) => {
eprintln!(
"hh: warning: could not build ignore matcher ({e}); \
file-change recording disabled for this session (recording continues)"
);
return Ok(None);
}
};
let stop = Arc::new(AtomicBool::new(false));
let (ntx, nrx) = std::sync::mpsc::channel();
let mut watcher = match notify::recommended_watcher(
move |res: std::result::Result<notify::Event, notify::Error>| {
let _ = ntx.send(res);
},
) {
Ok(w) => w,
Err(e) => {
eprintln!(
"hh: warning: could not initialize filesystem watcher ({e}); \
file-change recording disabled for this session (recording continues)"
);
return Ok(None);
}
};
let cwd_watched = match watcher.watch(&opts.cwd, RecursiveMode::Recursive) {
Ok(()) => true,
Err(e) => {
eprintln!(
"hh: warning: recursive watch of {} failed ({e}); \
falling back to per-directory watches \
(changes in unreadable or newly-created subdirectories may be missed)",
opts.cwd.display()
);
add_per_dir_watches(&mut watcher, &opts.cwd, &matcher, &opts.internal_exclude)
}
};
if !cwd_watched {
eprintln!(
"hh: warning: could not watch {} at all; file changes will not be \
recorded for this session (recording continues — run `hh doctor`)",
opts.cwd.display()
);
return Ok(None);
}
let mut existing: HashMap<PathBuf, String> = HashMap::new();
scan_baseline_hashes(
&opts.cwd,
&opts.cwd,
&matcher,
&opts.internal_exclude,
&opts,
&mut existing,
);
let stop_for_thread = Arc::clone(&stop);
let thread = std::thread::Builder::new()
.name("hh-fs-watcher".into())
.spawn(move || {
run_loop(
watcher,
nrx,
matcher,
opts,
writer,
blobs,
session_id,
start,
stop_for_thread,
existing,
);
})
.map_err(|e| crate::RecordError::Pty(format!("spawn watcher thread: {e}")))?;
Ok(Some(WatcherHandle {
thread: Some(thread),
stop,
}))
}
fn add_per_dir_watches(
watcher: &mut RecommendedWatcher,
root: &Path,
matcher: &Gitignore,
internal_exclude: &[PathBuf],
) -> bool {
let mut stack = vec![root.to_path_buf()];
let mut dirs_watched = 0usize;
let mut warned_skip = false;
while let Some(dir) = stack.pop() {
match watcher.watch(&dir, RecursiveMode::NonRecursive) {
Ok(()) => dirs_watched += 1,
Err(e) => {
if dir == root {
return false; }
if !warned_skip {
eprintln!(
"hh: warning: could not watch {} ({e}); \
skipping this directory and its children",
dir.display()
);
warned_skip = true;
}
continue; }
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
continue;
}
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let Ok(ft) = e.file_type() else {
continue;
};
if !ft.is_dir() {
continue;
}
if matcher.matched_path_or_any_parents(rel, true).is_ignore() {
continue;
}
stack.push(path);
}
}
dirs_watched > 0
}
struct TempProbe(PathBuf);
impl Drop for TempProbe {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[allow(clippy::missing_errors_doc)] pub fn watcher_smoke_test() -> std::result::Result<(), String> {
use std::sync::mpsc;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let root =
std::env::temp_dir().join(format!("hh-doctor-smoke-{}-{}", std::process::id(), stamp));
std::fs::create_dir_all(&root).map_err(|e| format!("create temp dir: {e}"))?;
let _probe = TempProbe(root.clone());
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res| {
let _ = tx.send(res);
})
.map_err(|e| format!("notify init failed: {e}"))?;
watcher
.watch(&root, RecursiveMode::Recursive)
.map_err(|e| format!("watch failed: {e}"))?;
std::fs::write(root.join("probe.txt"), b"hh doctor smoke test")
.map_err(|e| format!("write probe file: {e}"))?;
let outcome = match rx.recv_timeout(Duration::from_secs(2)) {
Ok(Ok(_event)) => Ok(()),
Ok(Err(e)) => Err(format!("watcher delivered an error: {e}")),
Err(mpsc::RecvTimeoutError::Timeout) => Err(
"no file-change event within 2s — the watcher is not delivering events on this platform"
.to_string(),
),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err("watcher channel closed before any event".to_string())
}
};
drop(watcher);
outcome
}
fn build_matcher(cwd: &Path, extra_ignore: &[String]) -> std::result::Result<Gitignore, String> {
let mut builder = GitignoreBuilder::new(cwd);
for line in BUILTIN_IGNORE {
builder
.add_line(None, line)
.map_err(|e| format!("built-in ignore `{line}`: {e}"))?;
}
let root_gitignore = cwd.join(".gitignore");
if root_gitignore.is_file() {
if let Some(e) = builder.add(&root_gitignore) {
eprintln!(
"hh: warning: failed to read {}: {e}",
root_gitignore.display()
);
}
}
for line in extra_ignore {
builder
.add_line(None, line)
.map_err(|e| format!("extra ignore `{line}`: {e}"))?;
}
builder.build().map_err(|e| format!("build gitignore: {e}"))
}
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] fn run_loop(
watcher: RecommendedWatcher,
nrx: std::sync::mpsc::Receiver<std::result::Result<notify::Event, notify::Error>>,
matcher: Gitignore,
opts: WatchOptions,
writer: Arc<Mutex<EventWriter>>,
blobs: Arc<BlobStore>,
session_id: String,
start: Instant,
stop: Arc<AtomicBool>,
mut existing: HashMap<PathBuf, String>,
) {
let mut known: HashMap<PathBuf, String> = HashMap::new();
let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
while !stop.load(Ordering::Acquire) {
#[cfg(test)]
assert!(
!INJECT_PANIC_FOR_TEST.swap(false, Ordering::SeqCst),
"hh-record test: injected watcher panic"
);
match nrx.recv_timeout(POLL_INTERVAL) {
Ok(Ok(event)) => fold_event(&event, &matcher, &opts, &mut existing, &mut pending),
Ok(Err(e)) => eprintln!("hh: warning: fs watcher error: {e}"),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
flush_due(
&mut pending,
&opts,
&writer,
&blobs,
&session_id,
start,
&mut known,
);
}
let drain_start = Instant::now();
loop {
let cap_left = GRACE_MAX
.checked_sub(drain_start.elapsed())
.unwrap_or(Duration::ZERO);
if cap_left.is_zero() {
break;
}
let wait = if GRACE_QUIET < cap_left {
GRACE_QUIET
} else {
cap_left
};
match nrx.recv_timeout(wait) {
Ok(Ok(event)) => fold_event(&event, &matcher, &opts, &mut existing, &mut pending),
Ok(Err(e)) => eprintln!("hh: warning: fs watcher error: {e}"),
Err(
std::sync::mpsc::RecvTimeoutError::Timeout
| std::sync::mpsc::RecvTimeoutError::Disconnected,
) => break,
}
flush_due(
&mut pending,
&opts,
&writer,
&blobs,
&session_id,
start,
&mut known,
);
}
flush_all(
&mut pending,
&opts,
&writer,
&blobs,
&session_id,
start,
&mut known,
);
rescan_for_missed_changes(
&opts,
&matcher,
&writer,
&blobs,
&session_id,
start,
&mut known,
&existing,
);
drop(watcher);
}
fn fold_event(
event: ¬ify::Event,
matcher: &Gitignore,
opts: &WatchOptions,
existing: &mut HashMap<PathBuf, String>,
pending: &mut HashMap<PathBuf, Pending>,
) {
for path in &event.paths {
if let Some(kind) = classify(path, event.kind, matcher, opts) {
let kind = resolve_first_seen(kind, path, existing);
coalesce(pending, path.clone(), kind);
}
}
}
#[allow(clippy::too_many_arguments)] fn rescan_for_missed_changes(
opts: &WatchOptions,
matcher: &Gitignore,
writer: &Arc<Mutex<EventWriter>>,
blobs: &Arc<BlobStore>,
session_id: &str,
start: Instant,
known: &mut HashMap<PathBuf, String>,
existing: &HashMap<PathBuf, String>,
) {
let mut current: HashSet<PathBuf> = HashSet::new();
scan_existing_files(
&opts.cwd,
&opts.cwd,
matcher,
&opts.internal_exclude,
&mut current,
);
for path in current {
if known.contains_key(&path) {
continue;
}
if let Some(baseline_hash) = existing.get(&path) {
let Some(current_hash) = read_hash_if_capturable(&path, opts) else {
continue;
};
if current_hash == *baseline_hash {
continue;
}
known.insert(path.clone(), baseline_hash.clone());
if let Err(e) = process(
&path,
ChangeKind::Modified,
opts,
writer,
blobs,
session_id,
start,
known,
) {
eprintln!(
"hh: warning: file change capture failed for {}: {e}",
path.display()
);
}
} else {
if let Err(e) = process(
&path,
ChangeKind::Created,
opts,
writer,
blobs,
session_id,
start,
known,
) {
eprintln!(
"hh: warning: file change capture failed for {}: {e}",
path.display()
);
}
}
}
}
fn read_hash_if_capturable(path: &Path, opts: &WatchOptions) -> Option<String> {
let meta = std::fs::metadata(path).ok()?;
if meta.len() > opts.max_file_size {
return None;
}
let mut f = std::fs::File::open(path).ok()?;
let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
f.read_to_end(&mut content).ok()?;
Some(BlobStore::hash(&content))
}
#[derive(Debug, Clone, Copy)]
struct Pending {
kind: ChangeKind,
deadline: Instant,
suppress: bool,
}
fn flush_due(
pending: &mut HashMap<PathBuf, Pending>,
opts: &WatchOptions,
writer: &Arc<Mutex<EventWriter>>,
blobs: &Arc<BlobStore>,
session_id: &str,
start: Instant,
known: &mut HashMap<PathBuf, String>,
) {
let now = Instant::now();
let due: Vec<PathBuf> = pending
.iter()
.filter(|(_, p)| p.deadline <= now)
.map(|(k, _)| k.clone())
.collect();
for path in due {
flush_one(
pending, &path, opts, writer, blobs, session_id, start, known,
);
}
}
fn flush_all(
pending: &mut HashMap<PathBuf, Pending>,
opts: &WatchOptions,
writer: &Arc<Mutex<EventWriter>>,
blobs: &Arc<BlobStore>,
session_id: &str,
start: Instant,
known: &mut HashMap<PathBuf, String>,
) {
let paths: Vec<PathBuf> = pending.keys().cloned().collect();
for path in paths {
flush_one(
pending, &path, opts, writer, blobs, session_id, start, known,
);
}
}
#[allow(clippy::too_many_arguments)] fn flush_one(
pending: &mut HashMap<PathBuf, Pending>,
path: &Path,
opts: &WatchOptions,
writer: &Arc<Mutex<EventWriter>>,
blobs: &Arc<BlobStore>,
session_id: &str,
start: Instant,
known: &mut HashMap<PathBuf, String>,
) {
let Some(p) = pending.remove(path) else {
return;
};
if p.suppress {
return;
}
if let Err(e) = process(path, p.kind, opts, writer, blobs, session_id, start, known) {
eprintln!(
"hh: warning: file change capture failed for {}: {e}",
path.display()
);
}
}
fn classify(
path: &Path,
kind: notify::EventKind,
matcher: &Gitignore,
opts: &WatchOptions,
) -> Option<ChangeKind> {
for excl in &opts.internal_exclude {
if path.starts_with(excl) {
return None;
}
}
let rel = path.strip_prefix(&opts.cwd).ok()?;
if path.is_dir() {
return None;
}
if matcher.matched_path_or_any_parents(rel, false).is_ignore() {
return None;
}
Some(match kind {
notify::EventKind::Create(_) => ChangeKind::Created,
notify::EventKind::Modify(_) => ChangeKind::Modified,
notify::EventKind::Remove(_) => ChangeKind::Deleted,
notify::EventKind::Any | notify::EventKind::Other | notify::EventKind::Access(_) => {
return None;
}
})
}
fn scan_existing_files(
dir: &Path,
cwd: &Path,
matcher: &Gitignore,
internal_exclude: &[PathBuf],
out: &mut HashSet<PathBuf>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
continue;
}
let Ok(rel) = path.strip_prefix(cwd) else {
continue;
};
let Ok(file_type) = entry.file_type() else {
continue;
};
let is_dir = file_type.is_dir();
if matcher.matched_path_or_any_parents(rel, is_dir).is_ignore() {
continue;
}
if is_dir {
scan_existing_files(&path, cwd, matcher, internal_exclude, out);
} else {
out.insert(path);
}
}
}
fn scan_baseline_hashes(
dir: &Path,
cwd: &Path,
matcher: &Gitignore,
internal_exclude: &[PathBuf],
opts: &WatchOptions,
out: &mut HashMap<PathBuf, String>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if internal_exclude.iter().any(|excl| path.starts_with(excl)) {
continue;
}
let Ok(rel) = path.strip_prefix(cwd) else {
continue;
};
let Ok(file_type) = entry.file_type() else {
continue;
};
let is_dir = file_type.is_dir();
if matcher.matched_path_or_any_parents(rel, is_dir).is_ignore() {
continue;
}
if is_dir {
scan_baseline_hashes(&path, cwd, matcher, internal_exclude, opts, out);
} else {
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > opts.max_file_size {
continue;
}
if let Ok(mut f) = std::fs::File::open(&path) {
let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
if f.read_to_end(&mut content).is_ok() {
out.insert(path, BlobStore::hash(&content));
}
}
}
}
}
}
fn resolve_first_seen(
kind: ChangeKind,
path: &Path,
existing: &mut HashMap<PathBuf, String>,
) -> ChangeKind {
match kind {
ChangeKind::Created if existing.contains_key(path) => ChangeKind::Modified,
ChangeKind::Created | ChangeKind::Modified => {
existing.entry(path.to_path_buf()).or_default();
kind
}
ChangeKind::Deleted => {
existing.remove(path);
ChangeKind::Deleted
}
}
}
fn coalesce(pending: &mut HashMap<PathBuf, Pending>, path: PathBuf, kind: ChangeKind) {
let now = Instant::now();
let deadline = now + DEBOUNCE_WINDOW;
match pending.get_mut(&path) {
None => {
pending.insert(
path,
Pending {
kind,
deadline,
suppress: false,
},
);
}
Some(p) => {
p.deadline = deadline;
let (merged, suppress) = merge_kind(p.kind, kind);
p.kind = merged;
p.suppress = p.suppress || suppress;
}
}
}
fn merge_kind(prior: ChangeKind, new: ChangeKind) -> (ChangeKind, bool) {
match (prior, new) {
(ChangeKind::Created, ChangeKind::Deleted) => (ChangeKind::Deleted, true),
(ChangeKind::Created, ChangeKind::Modified) | (_, ChangeKind::Created) => {
(ChangeKind::Created, false)
}
(_, ChangeKind::Deleted) | (ChangeKind::Deleted, ChangeKind::Modified) => {
(ChangeKind::Deleted, false)
}
_ => (ChangeKind::Modified, false),
}
}
#[allow(clippy::too_many_arguments)] fn process(
path: &Path,
change_kind: ChangeKind,
opts: &WatchOptions,
writer: &Arc<Mutex<EventWriter>>,
blobs: &Arc<BlobStore>,
session_id: &str,
start: Instant,
known: &mut HashMap<PathBuf, String>,
) -> std::result::Result<(), String> {
let Ok(rel) = path.strip_prefix(&opts.cwd) else {
return Ok(());
};
let ts_ms = i64::try_from(start.elapsed().as_millis()).unwrap_or(i64::MAX);
let rel_str = rel.to_string_lossy().to_string();
let (before_hash, after_hash, blob_hash, blob_size, is_binary) =
if change_kind == ChangeKind::Deleted {
let before = known.remove(path);
(before, None, None, None, false)
} else {
let meta = match std::fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("stat {}: {e}", path.display())),
};
if meta.len() > opts.max_file_size {
return Ok(());
}
let mut content = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
let mut f = match std::fs::File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(format!("open {}: {e}", path.display())),
};
f.read_to_end(&mut content)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let binary = is_binary(&content);
let hash = BlobStore::hash(&content);
let size = u64::try_from(content.len()).unwrap_or(u64::MAX);
let before = known.get(path).cloned();
let (stored_hash, stored_size) = if binary && !opts.record_binary {
(None, Some(size))
} else {
let outcome = blobs
.put(&content)
.map_err(|e| format!("blob put {}: {e}", path.display()))?;
(Some(outcome.hash), Some(outcome.size))
};
let effective_hash = stored_hash.clone().unwrap_or(hash);
known.insert(path.to_path_buf(), effective_hash.clone());
(
before,
Some(effective_hash),
stored_hash,
stored_size,
binary,
)
};
let event = Event {
session_id: session_id.to_string(),
ts_ms,
kind: EventKind::FileChange,
step: None, summary: truncate_summary(&format!("{rel_str} {change_kind}")),
body_json: Some(serde_json::json!({
"path": rel_str,
"change_kind": change_kind.to_string(),
})),
blob_hash: blob_hash.clone(),
blob_size,
correlates: None,
};
let file_change = FileChange {
event_id: 0, path: rel_str,
change_kind,
before_hash,
after_hash,
is_binary,
};
let writer = writer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
writer
.append_file_change(event, file_change)
.map_err(|e| format!("append file_change: {e}"))?;
Ok(())
}
fn is_binary(content: &[u8]) -> bool {
content.iter().take(8192).any(|&b| b == 0)
}
fn truncate_summary(s: &str) -> String {
const LIMIT: usize = 120;
if s.chars().count() <= LIMIT {
return s.to_string();
}
let truncated: String = s.chars().take(LIMIT - 1).collect();
format!("{truncated}…")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nul_byte_is_binary() {
assert!(is_binary(b"hello\x00world"));
assert!(!is_binary(b"plain text only"));
let mut big = vec![b'a'; 9000];
big[9000 - 1] = 0;
assert!(!is_binary(&big));
}
#[test]
fn truncate_summary_respects_limit() {
let short = "a1b2c3";
assert_eq!(truncate_summary(short), short);
let long: String = "x".repeat(200);
let t = truncate_summary(&long);
assert!(t.chars().count() <= 120);
assert!(t.ends_with('…'));
}
#[test]
fn build_matcher_skips_builtin() {
let tmp = tempfile::tempdir().unwrap();
let m = build_matcher(tmp.path(), &[]).unwrap();
assert!(m
.matched_path_or_any_parents("node_modules/foo.rs", false)
.is_ignore());
assert!(m
.matched_path_or_any_parents(".git/HEAD", false)
.is_ignore());
assert!(m
.matched_path_or_any_parents("target/debug/hh", false)
.is_ignore());
assert!(!m
.matched_path_or_any_parents("src/main.rs", false)
.is_ignore());
}
#[test]
fn build_matcher_extra_patterns() {
let tmp = tempfile::tempdir().unwrap();
let m = build_matcher(tmp.path(), &["dist/".into(), "*.lock".into()]).unwrap();
assert!(m
.matched_path_or_any_parents("dist/app.js", false)
.is_ignore());
assert!(m
.matched_path_or_any_parents("Cargo.lock", false)
.is_ignore());
assert!(!m
.matched_path_or_any_parents("src/main.rs", false)
.is_ignore());
}
#[test]
fn merge_kind_collapse_editor_double_write() {
let (k, s) = merge_kind(ChangeKind::Modified, ChangeKind::Modified);
assert_eq!(k, ChangeKind::Modified);
assert!(!s);
let (k, s) = merge_kind(ChangeKind::Created, ChangeKind::Modified);
assert_eq!(k, ChangeKind::Created);
assert!(!s);
}
#[test]
fn merge_kind_create_then_delete_is_noop() {
let (k, s) = merge_kind(ChangeKind::Created, ChangeKind::Deleted);
assert_eq!(k, ChangeKind::Deleted);
assert!(s);
}
#[test]
fn merge_kind_modify_then_delete_is_delete() {
let (k, s) = merge_kind(ChangeKind::Modified, ChangeKind::Deleted);
assert_eq!(k, ChangeKind::Deleted);
assert!(!s);
}
#[test]
fn merge_kind_delete_then_recreate_is_created() {
let (k, s) = merge_kind(ChangeKind::Deleted, ChangeKind::Created);
assert_eq!(k, ChangeKind::Created);
assert!(!s);
}
#[test]
fn merge_kind_delete_then_stale_modify_stays_deleted() {
let (k, s) = merge_kind(ChangeKind::Deleted, ChangeKind::Modified);
assert_eq!(k, ChangeKind::Deleted);
assert!(!s);
}
#[test]
fn coalesce_extends_window_and_suppresses_create_delete_burst() {
let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
let path = PathBuf::from("/tmp/x");
coalesce(&mut pending, path.clone(), ChangeKind::Created);
let first_deadline = pending[&path].deadline;
coalesce(&mut pending, path.clone(), ChangeKind::Deleted);
let entry = &pending[&path];
assert!(entry.deadline >= first_deadline);
assert!(entry.suppress);
}
#[test]
fn coalesce_keeps_separate_paths_independent() {
let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
coalesce(&mut pending, PathBuf::from("/tmp/a"), ChangeKind::Created);
coalesce(&mut pending, PathBuf::from("/tmp/b"), ChangeKind::Modified);
assert_eq!(pending.len(), 2);
assert_eq!(pending[&PathBuf::from("/tmp/a")].kind, ChangeKind::Created);
assert_eq!(pending[&PathBuf::from("/tmp/b")].kind, ChangeKind::Modified);
}
#[test]
fn resolve_first_seen_downgrades_created_for_known_existing_path() {
let path = PathBuf::from("/tmp/modified.txt");
let mut existing: HashMap<PathBuf, String> = [(path.clone(), "baseline-hash".into())]
.into_iter()
.collect();
let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
assert_eq!(kind, ChangeKind::Modified);
assert_eq!(
existing.get(&path).map(String::as_str),
Some("baseline-hash")
);
}
#[test]
fn resolve_first_seen_keeps_created_for_new_path_and_tracks_it() {
let path = PathBuf::from("/tmp/created.txt");
let mut existing: HashMap<PathBuf, String> = HashMap::new();
let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
assert_eq!(kind, ChangeKind::Created);
assert!(existing.contains_key(&path));
}
#[test]
fn resolve_first_seen_treats_recreate_after_delete_as_created() {
let path = PathBuf::from("/tmp/doomed.txt");
let mut existing: HashMap<PathBuf, String> = [(path.clone(), "baseline-hash".into())]
.into_iter()
.collect();
assert_eq!(
resolve_first_seen(ChangeKind::Deleted, &path, &mut existing),
ChangeKind::Deleted
);
assert!(!existing.contains_key(&path));
assert_eq!(
resolve_first_seen(ChangeKind::Created, &path, &mut existing),
ChangeKind::Created
);
}
#[test]
fn scan_existing_files_finds_pre_existing_files_respecting_ignores() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
std::fs::write(cwd.join("modified.txt"), "orig\n").unwrap();
std::fs::create_dir_all(cwd.join("sub")).unwrap();
std::fs::write(cwd.join("sub").join("nested.txt"), "orig\n").unwrap();
std::fs::create_dir_all(cwd.join("node_modules")).unwrap();
std::fs::write(cwd.join("node_modules").join("ignored.txt"), "x\n").unwrap();
let matcher = build_matcher(cwd, &[]).unwrap();
let mut existing = HashSet::new();
scan_existing_files(cwd, cwd, &matcher, &[], &mut existing);
assert!(existing.contains(&cwd.join("modified.txt")));
assert!(existing.contains(&cwd.join("sub").join("nested.txt")));
assert!(!existing.contains(&cwd.join("node_modules").join("ignored.txt")));
}
#[test]
fn scan_existing_files_honors_internal_exclude() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
std::fs::create_dir_all(cwd.join(".hh")).unwrap();
std::fs::write(cwd.join(".hh").join("hh.db"), "x\n").unwrap();
let matcher = build_matcher(cwd, &[]).unwrap();
let mut existing = HashSet::new();
scan_existing_files(cwd, cwd, &matcher, &[cwd.join(".hh")], &mut existing);
assert!(!existing.contains(&cwd.join(".hh").join("hh.db")));
}
#[test]
fn watcher_panic_does_not_abort_recording() {
use hh_core::event::{AdapterStatus, AgentKind, NewSession};
use hh_core::store::Store;
let _real_run_guard = REAL_RUN_LOOP_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().join("work");
std::fs::create_dir_all(&cwd).unwrap();
let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
let created = store
.create_session(&NewSession {
id: hh_core::event::now_v7(),
started_at: 0,
agent_kind: AgentKind::Generic,
adapter_status: AdapterStatus::None,
command: vec!["test".into()],
cwd: cwd.clone(),
hostname: None,
hh_version: "test".into(),
model: None,
git_branch: None,
git_sha: None,
git_dirty: None,
})
.unwrap();
let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
writer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.append_event(Event {
session_id: created.id.clone(),
ts_ms: 0,
kind: EventKind::Lifecycle,
step: None,
summary: "before panic".into(),
body_json: None,
blob_hash: None,
blob_size: None,
correlates: None,
})
.unwrap();
let opts = WatchOptions {
cwd: cwd.clone(),
max_file_size: 4 * 1024 * 1024,
record_binary: false,
extra_ignore: Vec::new(),
internal_exclude: Vec::new(),
};
let handle = spawn_watcher(
opts,
Arc::clone(&writer),
Arc::clone(&blobs),
created.id.clone(),
Instant::now(),
)
.unwrap()
.expect("watcher should initialize on a writable temp dir");
INJECT_PANIC_FOR_TEST.store(true, Ordering::SeqCst);
std::thread::sleep(POLL_INTERVAL * 20);
handle.stop_and_join();
let index = store.list_event_index(&created.id).unwrap();
assert!(
index.iter().any(|e| e.summary == "before panic"),
"the write from before the injected panic must still be durable"
);
let w = writer
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
w.append_event(Event {
session_id: created.id.clone(),
ts_ms: 1,
kind: EventKind::Lifecycle,
step: None,
summary: "after panic".into(),
body_json: None,
blob_hash: None,
blob_size: None,
correlates: None,
})
.expect("the writer must still accept appends after the watcher thread panicked");
}
#[test]
fn watcher_captures_write_then_immediate_stop() {
use hh_core::event::{AdapterStatus, AgentKind, EventKind, NewSession};
use hh_core::store::Store;
let _real_run_guard = REAL_RUN_LOOP_MUTEX
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().join("work");
std::fs::create_dir_all(&cwd).unwrap();
let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
let created = store
.create_session(&NewSession {
id: hh_core::event::now_v7(),
started_at: 0,
agent_kind: AgentKind::Generic,
adapter_status: AdapterStatus::None,
command: vec!["test".into()],
cwd: cwd.clone(),
hostname: None,
hh_version: "test".into(),
model: None,
git_branch: None,
git_sha: None,
git_dirty: None,
})
.unwrap();
let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
let opts = WatchOptions {
cwd: cwd.clone(),
max_file_size: 4 * 1024 * 1024,
record_binary: false,
extra_ignore: Vec::new(),
internal_exclude: Vec::new(),
};
let handle = spawn_watcher(
opts,
Arc::clone(&writer),
Arc::clone(&blobs),
created.id.clone(),
Instant::now(),
)
.unwrap()
.expect("watcher should initialize on a writable temp dir");
std::fs::write(cwd.join("race.txt"), b"written just before stop\n").unwrap();
handle.stop_and_join();
let index = store.list_event_index(&created.id).unwrap();
assert!(
index
.iter()
.any(|e| { e.kind == EventKind::FileChange && e.summary.contains("race.txt") }),
"a write immediately before stop must be captured (grace drain + \
rescan backstop); events seen: {:?}",
index
.iter()
.map(|e| (e.kind, e.summary.clone()))
.collect::<Vec<_>>()
);
}
#[test]
fn rescan_for_missed_changes_records_unobserved_creates_and_modifies() {
use hh_core::event::{AdapterStatus, AgentKind, EventKind, NewSession};
use hh_core::store::Store;
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path().join("work");
std::fs::create_dir_all(&cwd).unwrap();
let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
let created = store
.create_session(&NewSession {
id: hh_core::event::now_v7(),
started_at: 0,
agent_kind: AgentKind::Generic,
adapter_status: AdapterStatus::None,
command: vec!["test".into()],
cwd: cwd.clone(),
hostname: None,
hh_version: "test".into(),
model: None,
git_branch: None,
git_sha: None,
git_dirty: None,
})
.unwrap();
let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
let baseline_bytes = b"already here\n";
std::fs::write(cwd.join("baseline.txt"), baseline_bytes).unwrap();
std::fs::write(cwd.join("changed.txt"), b"modified\n").unwrap();
std::fs::write(cwd.join("recorded.txt"), b"captured via event\n").unwrap();
std::fs::write(cwd.join("missed.txt"), b"the watcher missed this\n").unwrap();
let existing: HashMap<PathBuf, String> = [
(cwd.join("baseline.txt"), BlobStore::hash(baseline_bytes)),
(cwd.join("changed.txt"), BlobStore::hash(b"original\n")),
]
.into_iter()
.collect();
let mut known: HashMap<PathBuf, String> = [(cwd.join("recorded.txt"), "dummyhash".into())]
.into_iter()
.collect();
let opts = WatchOptions {
cwd: cwd.clone(),
max_file_size: 4 * 1024 * 1024,
record_binary: false,
extra_ignore: Vec::new(),
internal_exclude: Vec::new(),
};
let matcher = build_matcher(&cwd, &[]).unwrap();
rescan_for_missed_changes(
&opts,
&matcher,
&writer,
&blobs,
&created.id,
Instant::now(),
&mut known,
&existing,
);
let index = store.list_event_index(&created.id).unwrap();
let summaries: Vec<String> = index.iter().map(|e| e.summary.clone()).collect();
assert!(
index
.iter()
.any(|e| e.kind == EventKind::FileChange && e.summary.contains("missed.txt")),
"the unobserved new file must be recorded by the backstop; events: {summaries:?}"
);
assert!(
index.iter().any(|e| {
e.kind == EventKind::FileChange
&& e.summary.contains("changed.txt")
&& e.summary.contains("modified")
}),
"a baseline file whose current hash differs must be recorded as Modified; events: {summaries:?}"
);
assert!(
!summaries.iter().any(|s| s.contains("baseline.txt")),
"an unchanged baseline file must not be recorded; events: {summaries:?}"
);
assert!(
!summaries.iter().any(|s| s.contains("recorded.txt")),
"an already-recorded file must not be duplicated; events: {summaries:?}"
);
}
#[test]
fn spawn_watcher_degrades_when_cwd_unwatchable() {
use hh_core::store::Store;
let tmp = tempfile::tempdir().unwrap();
let store = Store::open(&tmp.path().join("hh.db"), &tmp.path().join("blobs")).unwrap();
let writer = Arc::new(Mutex::new(store.event_writer().unwrap()));
let blobs = Arc::new(BlobStore::new(store.blobs().root().to_path_buf()));
let opts = WatchOptions {
cwd: tmp.path().join("does-not-exist"),
max_file_size: 4 * 1024 * 1024,
record_binary: false,
extra_ignore: Vec::new(),
internal_exclude: Vec::new(),
};
let outcome = spawn_watcher(opts, writer, blobs, "session".into(), Instant::now());
assert!(
outcome.is_ok(),
"watcher init failure must not abort the recording"
);
assert!(
outcome.unwrap().is_none(),
"an unwatchable cwd must degrade to Ok(None), not a handle"
);
}
#[test]
fn add_per_dir_watches_skips_ignored_dirs() {
let tmp = tempfile::tempdir().unwrap();
let cwd = tmp.path();
std::fs::create_dir_all(cwd.join("keep")).unwrap();
std::fs::create_dir_all(cwd.join("node_modules").join("deep")).unwrap();
std::fs::create_dir_all(cwd.join("target")).unwrap();
let matcher = build_matcher(cwd, &[]).unwrap();
let (ntx, _nrx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(
move |r: std::result::Result<notify::Event, notify::Error>| {
let _ = ntx.send(r);
},
)
.expect("notify watcher init");
assert!(add_per_dir_watches(&mut watcher, cwd, &matcher, &[]));
}
}