Skip to main content

agentstatedeveloper_cli/commands/
watch.rs

1//! `asd watch` — keep the index fresh automatically (suite-onboarding t-009).
2//!
3//! Watches the repo for source changes and re-indexes, debounced. Pairs with
4//! the always-on nudge (t-007) and the stale warnings `prepare-change`/`status`
5//! already emit — this is the hands-off option so the index never drifts.
6
7use std::path::{Path, PathBuf};
8use std::sync::mpsc::{RecvTimeoutError, channel};
9use std::time::{Duration, Instant};
10
11use anyhow::{Context, Result};
12use clap::Args;
13use notify::{RecursiveMode, Watcher};
14
15use crate::commands::index::{self, IndexArgs};
16use crate::config::Config;
17
18#[derive(Debug, Args)]
19pub struct WatchArgs {
20    /// Directory to watch and re-index. Defaults to the current directory.
21    #[arg(default_value = ".")]
22    pub path: PathBuf,
23
24    /// Debounce window (ms) — coalesce a burst of edits into one reindex.
25    #[arg(long, default_value_t = 1500)]
26    pub debounce_ms: u64,
27}
28
29/// Should a changed path trigger a reindex? Skips VCS/build/index-internal
30/// paths and non-source files, so editor churn and the index's own writes don't
31/// cause a reindex loop.
32pub fn is_relevant_change(path: &Path) -> bool {
33    // Match on path *components* (not substrings) so a leading `target/` is
34    // caught the same as a nested `/target/`.
35    const SKIP: &[&str] = &[".git", "target", "node_modules", ".asd", ".asd-state.db"];
36    for comp in path.components() {
37        if let std::path::Component::Normal(os) = comp {
38            if os.to_str().is_some_and(|n| SKIP.contains(&n)) {
39                return false;
40            }
41        }
42    }
43    matches!(
44        path.extension().and_then(|e| e.to_str()),
45        Some(
46            "py" | "ts"
47                | "tsx"
48                | "mts"
49                | "cts"
50                | "rs"
51                | "go"
52                | "java"
53                | "cs"
54                | "rb"
55                | "kt"
56                | "kts"
57                | "swift"
58        )
59    )
60}
61
62pub fn run(cfg: &Config, args: WatchArgs) -> Result<()> {
63    let (tx, rx) = channel();
64    let mut watcher = notify::recommended_watcher(move |res| {
65        let _ = tx.send(res);
66    })
67    .context("create filesystem watcher")?;
68    watcher
69        .watch(&args.path, RecursiveMode::Recursive)
70        .with_context(|| format!("watch {}", args.path.display()))?;
71
72    println!(
73        "asd watch: watching {} (Ctrl-C to stop)",
74        args.path.display()
75    );
76    reindex(cfg, &args.path); // initial pass so we start fresh
77
78    let debounce = Duration::from_millis(args.debounce_ms);
79    let mut pending: Option<Instant> = None;
80    loop {
81        let timeout = pending
82            .map(|t| debounce.saturating_sub(t.elapsed()))
83            .unwrap_or_else(|| Duration::from_secs(3600));
84        match rx.recv_timeout(timeout) {
85            Ok(Ok(event)) => {
86                if event.paths.iter().any(|p| is_relevant_change(p)) {
87                    pending = Some(Instant::now());
88                }
89            }
90            Ok(Err(_)) => {} // watcher hiccup — ignore, keep watching
91            Err(RecvTimeoutError::Timeout) => {
92                if pending.take_if(|t| t.elapsed() >= debounce).is_some() {
93                    reindex(cfg, &args.path);
94                }
95            }
96            Err(RecvTimeoutError::Disconnected) => break,
97        }
98    }
99    Ok(())
100}
101
102fn reindex(cfg: &Config, path: &Path) {
103    print!("asd watch: reindexing… ");
104    let _ = std::io::Write::flush(&mut std::io::stdout());
105    match index::run(
106        cfg,
107        IndexArgs {
108            path: path.to_path_buf(),
109            verbose: false,
110        },
111    ) {
112        Ok(()) => println!("done"),
113        Err(e) => println!("failed: {e}"),
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn source_files_are_relevant() {
123        assert!(is_relevant_change(Path::new("src/foo.rs")));
124        assert!(is_relevant_change(Path::new("app/models/user.py")));
125        assert!(is_relevant_change(Path::new("a/b/Handler.kt")));
126    }
127
128    #[test]
129    fn noise_is_ignored() {
130        assert!(!is_relevant_change(Path::new("target/debug/foo.rs")));
131        assert!(!is_relevant_change(Path::new(".git/index")));
132        assert!(!is_relevant_change(Path::new("node_modules/x/y.ts")));
133        assert!(!is_relevant_change(Path::new(".asd/index.log")));
134        assert!(!is_relevant_change(Path::new("./.asd-state.db")));
135        assert!(!is_relevant_change(Path::new("README.md")));
136        assert!(!is_relevant_change(Path::new("Cargo.toml")));
137    }
138}