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