gonzalo_cli/watch.rs
1//! Filesystem-watching driver for `gonzalo index --watch` (gonzalo#100).
2//!
3//! This is the I/O boundary: it bridges the [`notify`] OS watcher and Tokio
4//! timers to the deterministic, unit-tested [`Debouncer`](crate::Debouncer) and
5//! the set-reconciling [`index`](crate::index). A burst of edits is coalesced
6//! into a single incremental re-index, and a slower periodic tick runs a full
7//! reconcile so any event the OS watcher dropped is still converged (safe
8//! because reconciliation is a pure set difference — a missed event is corrected
9//! at the next pass, never lost).
10//!
11//! The event-loop glue here has no deterministic unit test (it owns real OS
12//! notifications, wall-clock timers, and a Ctrl-C signal), so it is excluded
13//! from the coverage gate via `scripts/coverage.sh`'s `IGNORE_REGEX`, exactly
14//! like the daemon/worker entrypoints. The logic worth testing — debounce
15//! coalescing — lives in [`Debouncer`](crate::Debouncer), which is covered.
16
17use crate::{Debouncer, index_with_gc};
18use anyhow::{Context, Result};
19use notify::{RecursiveMode, Watcher};
20use std::path::Path;
21use std::time::{Duration, Instant};
22
23/// Timing knobs for [`watch`].
24#[derive(Clone, Copy, Debug)]
25pub struct WatchConfig {
26 /// Quiet period after the last change before an incremental re-index fires,
27 /// so a burst of edits coalesces into one pass.
28 pub debounce: Duration,
29 /// Interval between full reconciles that self-heal any missed events.
30 pub full_reconcile: Duration,
31}
32
33impl Default for WatchConfig {
34 fn default() -> Self {
35 Self {
36 debounce: Duration::from_millis(500),
37 full_reconcile: Duration::from_secs(300),
38 }
39 }
40}
41
42/// Watch `src` and keep the `(repo, view)` code-graph view in sync until
43/// Ctrl-C. Runs one index up front, then re-indexes on a debounced burst of
44/// filesystem changes and on a periodic full-reconcile tick. Long-running:
45/// returns `Ok(())` on graceful shutdown.
46///
47/// When `gc` is set, every index (the initial prime, each debounced re-index,
48/// and each periodic reconcile) sweeps orphaned slices across all live views —
49/// so `--gc` is honored under `--watch` rather than silently dropped (#157).
50pub async fn watch(
51 root: &Path,
52 src: &Path,
53 repo: &str,
54 view: &str,
55 config: WatchConfig,
56 gc: bool,
57) -> Result<()> {
58 // Prime the view before watching, so a fresh run is immediately queryable.
59 let (summary, swept) = index_with_gc(root, src, repo, view, gc).await?;
60 eprintln!(
61 "gonzalo watch: initial index ({} files, {} added, {} modified, {} deleted{})",
62 summary.files,
63 summary.added,
64 summary.modified,
65 summary.deleted,
66 gc_note(&swept),
67 );
68
69 // Bridge notify's callback thread to the async loop over an unbounded
70 // channel — we only care that *something* changed, not the exact paths
71 // (index() re-derives the changed set itself).
72 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
73 let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
74 if let Ok(event) = res
75 && is_content_change(&event)
76 {
77 let _ = tx.send(());
78 }
79 })
80 .context("creating filesystem watcher")?;
81 watcher
82 .watch(src, RecursiveMode::Recursive)
83 .with_context(|| format!("watching {}", src.display()))?;
84
85 let mut debouncer = Debouncer::new(config.debounce);
86 // Poll the debouncer at its own resolution; fire once the burst settles.
87 let mut debounce_tick = tokio::time::interval(config.debounce);
88 let mut reconcile_tick = tokio::time::interval(config.full_reconcile);
89 // interval fires immediately on first poll; skip that leading tick so the
90 // periodic reconcile doesn't double the initial index.
91 reconcile_tick.tick().await;
92
93 eprintln!("gonzalo watch: watching {} (Ctrl-C to stop)", src.display());
94 loop {
95 tokio::select! {
96 Some(()) = rx.recv() => {
97 debouncer.on_event(Instant::now());
98 }
99 _ = debounce_tick.tick() => {
100 if debouncer.is_due(Instant::now()) {
101 debouncer.clear();
102 reindex(root, src, repo, view, gc, "incremental").await;
103 }
104 }
105 _ = reconcile_tick.tick() => {
106 debouncer.clear(); // a full pass subsumes any pending change
107 reindex(root, src, repo, view, gc, "full reconcile").await;
108 }
109 _ = tokio::signal::ctrl_c() => {
110 eprintln!("gonzalo watch: shutting down");
111 break;
112 }
113 }
114 }
115 Ok(())
116}
117
118/// Run one re-index, logging the outcome. When `gc` is set the run also sweeps
119/// orphaned slices. A failure is logged, not fatal — the watcher keeps running
120/// so a transient error (e.g. a half-written file) is corrected on the next
121/// event or reconcile.
122async fn reindex(root: &Path, src: &Path, repo: &str, view: &str, gc: bool, reason: &str) {
123 match index_with_gc(root, src, repo, view, gc).await {
124 Ok((s, swept)) => eprintln!(
125 "gonzalo watch: re-indexed ({reason}): {} added, {} modified, {} deleted{}",
126 s.added,
127 s.modified,
128 s.deleted,
129 gc_note(&swept),
130 ),
131 Err(e) => eprintln!("gonzalo watch: re-index failed ({reason}): {e:#}"),
132 }
133}
134
135/// Human-readable `", gc freed N"` suffix when a sweep ran, else empty.
136fn gc_note(swept: &Option<crate::GcSummary>) -> String {
137 swept
138 .as_ref()
139 .map(|g| format!(", gc freed {}", g.freed))
140 .unwrap_or_default()
141}
142
143/// Whether a notify event represents a content change worth re-indexing
144/// (create/modify/remove/rename), ignoring pure metadata/access events.
145fn is_content_change(event: ¬ify::Event) -> bool {
146 use notify::EventKind;
147 matches!(
148 event.kind,
149 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
150 )
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use crate::{gc, index};
157 use tempfile::TempDir;
158
159 /// #157: the `gc` flag threaded into the watch loop is honored — a reindex
160 /// with `gc = true` sweeps orphaned slices (the OS-event/timer glue around
161 /// this call is untestable, so we drive `reindex` directly).
162 #[tokio::test]
163 async fn reindex_sweeps_orphans_when_gc_is_set() {
164 let root = TempDir::new().unwrap();
165 let src = TempDir::new().unwrap();
166 std::fs::write(src.path().join("a.rs"), "fn a() {}").unwrap();
167 index(root.path(), src.path(), "r", "main").await.unwrap();
168
169 // Change the file so the pre-edit slice is orphaned, then reindex under
170 // the watch loop's helper with gc enabled.
171 std::fs::write(src.path().join("a.rs"), "fn a() { b(); }").unwrap();
172 reindex(root.path(), src.path(), "r", "main", true, "test").await;
173
174 // The orphan was already swept during the reindex, so an explicit gc
175 // finds nothing left to free.
176 assert_eq!(gc(root.path()).await.unwrap().freed, 0);
177 }
178
179 /// Counterpart: without the flag, the reindex leaves the orphan behind.
180 #[tokio::test]
181 async fn reindex_leaves_orphans_when_gc_is_unset() {
182 let root = TempDir::new().unwrap();
183 let src = TempDir::new().unwrap();
184 std::fs::write(src.path().join("a.rs"), "fn a() {}").unwrap();
185 index(root.path(), src.path(), "r", "main").await.unwrap();
186
187 std::fs::write(src.path().join("a.rs"), "fn a() { b(); }").unwrap();
188 reindex(root.path(), src.path(), "r", "main", false, "test").await;
189
190 // The orphan survived: an explicit gc still has one to free.
191 assert_eq!(gc(root.path()).await.unwrap().freed, 1);
192 }
193}