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/"];
const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100);
const POLL_INTERVAL: Duration = Duration::from_millis(20);
#[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<WatcherHandle> {
let matcher = build_matcher(&opts.cwd, &opts.extra_ignore).map_err(crate::RecordError::Pty)?;
let stop = Arc::new(AtomicBool::new(false));
let (ntx, nrx) = std::sync::mpsc::channel();
let mut watcher = notify::recommended_watcher(
move |res: std::result::Result<notify::Event, notify::Error>| {
let _ = ntx.send(res);
},
)
.map_err(|e| crate::RecordError::Pty(format!("notify watcher init: {e}")))?;
watcher
.watch(&opts.cwd, RecursiveMode::Recursive)
.map_err(|e| {
crate::RecordError::Pty(format!("notify watch `{}`: {e}", opts.cwd.display()))
})?;
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,
);
})
.map_err(|e| crate::RecordError::Pty(format!("spawn watcher thread: {e}")))?;
Ok(WatcherHandle {
thread: Some(thread),
stop,
})
}
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>,
) {
let mut known: HashMap<PathBuf, String> = HashMap::new();
let mut pending: HashMap<PathBuf, Pending> = HashMap::new();
let mut existing: HashSet<PathBuf> = HashSet::new();
scan_existing_files(
&opts.cwd,
&opts.cwd,
&matcher,
&opts.internal_exclude,
&mut existing,
);
while !stop.load(Ordering::Acquire) {
match nrx.recv_timeout(POLL_INTERVAL) {
Ok(Ok(event)) => {
for path in &event.paths {
if let Some(kind) = classify(path, event.kind, &matcher, &opts) {
let kind = resolve_first_seen(kind, path, &mut existing);
coalesce(&mut pending, path.clone(), kind);
}
}
}
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,
);
}
flush_all(
&mut pending,
&opts,
&writer,
&blobs,
&session_id,
start,
&mut known,
);
drop(watcher);
}
#[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 resolve_first_seen(
kind: ChangeKind,
path: &Path,
existing: &mut HashSet<PathBuf>,
) -> ChangeKind {
match kind {
ChangeKind::Created if existing.contains(path) => ChangeKind::Modified,
ChangeKind::Created | ChangeKind::Modified => {
existing.insert(path.to_path_buf());
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 = if binary && !opts.record_binary {
None
} else {
blobs
.put(&content)
.map_err(|e| format!("blob put {}: {e}", path.display()))?;
Some(hash.clone())
};
known.insert(path.to_path_buf(), hash.clone());
(before, Some(hash), stored_hash, Some(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()
.map_err(|e| format!("writer lock poisoned: {e}"))?;
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: HashSet<PathBuf> = [path.clone()].into_iter().collect();
let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
assert_eq!(kind, ChangeKind::Modified);
}
#[test]
fn resolve_first_seen_keeps_created_for_new_path_and_tracks_it() {
let path = PathBuf::from("/tmp/created.txt");
let mut existing: HashSet<PathBuf> = HashSet::new();
let kind = resolve_first_seen(ChangeKind::Created, &path, &mut existing);
assert_eq!(kind, ChangeKind::Created);
assert!(existing.contains(&path));
}
#[test]
fn resolve_first_seen_treats_recreate_after_delete_as_created() {
let path = PathBuf::from("/tmp/doomed.txt");
let mut existing: HashSet<PathBuf> = [path.clone()].into_iter().collect();
assert_eq!(
resolve_first_seen(ChangeKind::Deleted, &path, &mut existing),
ChangeKind::Deleted
);
assert!(!existing.contains(&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")));
}
}