agentstatedeveloper_cli/commands/
watch.rs1use 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 #[arg(default_value = ".")]
22 pub path: PathBuf,
23
24 #[arg(long, default_value_t = 1500)]
26 pub debounce_ms: u64,
27}
28
29pub fn is_relevant_change(path: &Path) -> bool {
33 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); 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(_)) => {} 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}