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