Skip to main content

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: &notify::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}