agent_doc/debounce.rs
1//! # Module: debounce
2//!
3//! ## Spec
4//! - Provides a shared typing-debounce mechanism used by all editor plugins (JetBrains, VS Code,
5//! Neovim, Zed) so they share identical timing logic via the agent-doc FFI layer.
6//! - In-process state: a `Mutex<HashMap<PathBuf, Instant>>` (`LAST_CHANGE`) records the last
7//! edit timestamp per file path.
8//! - Cross-process state: each `document_changed` call also writes a millisecond Unix timestamp
9//! to `.agent-doc/typing/<hash>` so CLI invocations running in a separate process can detect
10//! active typing. The hash is derived from the file path string via `DefaultHasher`.
11//! Cross-process writes are best-effort and never block the caller.
12//! - `is_idle` / `await_idle` operate on in-process state (same process as the plugin).
13//! - `is_typing_via_file` / `await_idle_via_file` operate on the file-based indicator (CLI use).
14//! - Files with no recorded `document_changed` call are considered idle by `is_idle`; this
15//! prevents `await_idle` from blocking forever on untracked documents.
16//! - `is_tracked` distinguishes "never seen" from "seen and idle" for non-blocking probes.
17//! - `await_idle` polls every 100 ms and returns `false` if `timeout_ms` expires before idle.
18//!
19//! ## Agentic Contracts
20//! - `document_changed(file: &str)` — records now as last-change time; writes typing indicator
21//! file (best-effort); never panics.
22//! - `is_idle(file, debounce_ms) -> bool` — `true` if elapsed ≥ `debounce_ms` or file untracked.
23//! - `is_tracked(file) -> bool` — `true` if at least one `document_changed` was recorded.
24//! - `await_idle(file, debounce_ms, timeout_ms) -> bool` — blocks until idle or timeout; 100 ms
25//! poll interval.
26//! - `is_typing_via_file(file, debounce_ms) -> bool` — reads indicator file; `false` if absent or
27//! timestamp older than `debounce_ms`.
28//! - `await_idle_via_file(file, debounce_ms, timeout_ms) -> bool` — file-based blocking variant.
29//!
30//! ## Evals
31//! - idle_no_changes: file never passed to `document_changed` → `is_idle` returns `true`
32//! - not_idle_after_change: immediately after `document_changed` with 1500 ms window → `false`
33//! - idle_after_debounce: 50 ms sleep with 10 ms debounce → `is_idle` returns `true`
34//! - await_immediate: untracked file, `await_idle` → returns `true` in < 200 ms
35//! - await_settle: `document_changed` then `await_idle` with 200 ms debounce → waits ≥ 200 ms
36//! - typing_indicator_written: `document_changed` on file with `.agent-doc/typing/` dir →
37//! `is_typing_via_file` returns `true` within 2000 ms window
38//! - typing_indicator_expires: 50 ms after change with 10 ms debounce →
39//! `is_typing_via_file` returns `false`
40//! - no_indicator_file: nonexistent path → `is_typing_via_file` returns `false`
41
42use std::collections::HashMap;
43use std::path::PathBuf;
44use std::sync::Mutex;
45use std::time::Instant;
46
47/// Global state: last change timestamp per file.
48static LAST_CHANGE: Mutex<Option<HashMap<PathBuf, Instant>>> = Mutex::new(None);
49
50fn with_state<R>(f: impl FnOnce(&mut HashMap<PathBuf, Instant>) -> R) -> R {
51 let mut guard = LAST_CHANGE.lock().unwrap();
52 let map = guard.get_or_insert_with(HashMap::new);
53 f(map)
54}
55
56/// Record a document change event for the given file.
57///
58/// Called by editor plugins on every document modification.
59/// Also writes a typing indicator file for cross-process visibility.
60pub fn document_changed(file: &str) {
61 let path = PathBuf::from(file);
62 with_state(|map| {
63 map.insert(path.clone(), Instant::now());
64 });
65 // Write cross-process typing indicator (best-effort, never block)
66 let _ = write_typing_indicator(file);
67}
68
69/// Check if the document has been idle (no changes) for at least `debounce_ms`.
70///
71/// Returns `true` if no recent changes (safe to run), `false` if still active.
72/// For untracked files (no `document_changed` ever called), returns `true` —
73/// the blocking `await_idle` relies on this to not wait forever.
74pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
75 let path = PathBuf::from(file);
76 with_state(|map| {
77 match map.get(&path) {
78 None => true, // No recorded changes — idle
79 Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
80 }
81 })
82}
83
84/// Check if the document has been tracked (at least one `document_changed` call recorded).
85///
86/// Used by non-blocking probes to distinguish "never tracked" from "tracked and idle".
87/// If a file is untracked, the probe should be conservative (assume not idle).
88pub fn is_tracked(file: &str) -> bool {
89 let path = PathBuf::from(file);
90 with_state(|map| map.contains_key(&path))
91}
92
93/// Block until the document has been idle for `debounce_ms`, or `timeout_ms` expires.
94///
95/// Returns `true` if idle was reached, `false` if timed out.
96///
97/// Poll interval: 100ms (responsive without busy-waiting).
98pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
99 let start = Instant::now();
100 let timeout = std::time::Duration::from_millis(timeout_ms);
101 let poll_interval = std::time::Duration::from_millis(100);
102
103 loop {
104 if is_idle(file, debounce_ms) {
105 return true;
106 }
107 if start.elapsed() >= timeout {
108 return false;
109 }
110 std::thread::sleep(poll_interval);
111 }
112}
113
114// ── Cross-process typing bridge ──
115
116/// Directory for typing indicator files, relative to project root.
117const TYPING_DIR: &str = ".agent-doc/typing";
118
119/// Write a typing indicator file for the given document path.
120/// The file contains a Unix timestamp (milliseconds) of the last edit.
121fn write_typing_indicator(file: &str) -> std::io::Result<()> {
122 let typing_path = typing_indicator_path(file);
123 if let Some(parent) = typing_path.parent() {
124 std::fs::create_dir_all(parent)?;
125 }
126 let now = std::time::SystemTime::now()
127 .duration_since(std::time::UNIX_EPOCH)
128 .unwrap_or_default()
129 .as_millis();
130 std::fs::write(&typing_path, now.to_string())
131}
132
133/// Compute the typing indicator file path for a document.
134fn typing_indicator_path(file: &str) -> PathBuf {
135 use std::hash::{Hash, Hasher};
136 let mut hasher = std::collections::hash_map::DefaultHasher::new();
137 file.hash(&mut hasher);
138 let hash = hasher.finish();
139 // Walk up to find .agent-doc/ directory
140 let mut dir = PathBuf::from(file);
141 loop {
142 dir.pop();
143 if dir.join(".agent-doc").is_dir() {
144 return dir.join(TYPING_DIR).join(format!("{:016x}", hash));
145 }
146 if !dir.pop() {
147 // Fallback: use file's parent directory
148 let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
149 return parent.join(TYPING_DIR).join(format!("{:016x}", hash));
150 }
151 }
152}
153
154/// Check if the document has a recent typing indicator (cross-process).
155///
156/// Returns `true` if the typing indicator exists and was updated within
157/// `debounce_ms` milliseconds. Used by CLI preflight to detect active typing
158/// from a plugin running in a different process.
159pub fn is_typing_via_file(file: &str, debounce_ms: u64) -> bool {
160 let path = typing_indicator_path(file);
161 match std::fs::read_to_string(&path) {
162 Ok(content) => {
163 if let Ok(ts_ms) = content.trim().parse::<u128>() {
164 let now = std::time::SystemTime::now()
165 .duration_since(std::time::UNIX_EPOCH)
166 .unwrap_or_default()
167 .as_millis();
168 now.saturating_sub(ts_ms) < debounce_ms as u128
169 } else {
170 false
171 }
172 }
173 Err(_) => false, // No indicator file — not typing
174 }
175}
176
177/// Block until the typing indicator shows idle, or timeout.
178///
179/// Used by CLI preflight to wait for plugin-side typing to settle.
180/// Returns `true` if idle was reached, `false` if timed out.
181pub fn await_idle_via_file(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
182 let start = Instant::now();
183 let timeout = std::time::Duration::from_millis(timeout_ms);
184 let poll_interval = std::time::Duration::from_millis(100);
185
186 loop {
187 if !is_typing_via_file(file, debounce_ms) {
188 return true;
189 }
190 if start.elapsed() >= timeout {
191 return false;
192 }
193 std::thread::sleep(poll_interval);
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn idle_when_no_changes() {
203 assert!(is_idle("/tmp/test-no-changes.md", 1500));
204 }
205
206 #[test]
207 fn not_idle_after_change() {
208 document_changed("/tmp/test-just-changed.md");
209 assert!(!is_idle("/tmp/test-just-changed.md", 1500));
210 }
211
212 #[test]
213 fn idle_after_debounce_period() {
214 document_changed("/tmp/test-debounce.md");
215 // Use a very short debounce for testing
216 std::thread::sleep(std::time::Duration::from_millis(50));
217 assert!(is_idle("/tmp/test-debounce.md", 10));
218 }
219
220 #[test]
221 fn await_idle_returns_immediately_when_idle() {
222 let start = Instant::now();
223 assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
224 assert!(start.elapsed().as_millis() < 200);
225 }
226
227 #[test]
228 fn await_idle_waits_for_settle() {
229 document_changed("/tmp/test-await-settle.md");
230 let start = Instant::now();
231 assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
232 assert!(start.elapsed().as_millis() >= 200);
233 }
234
235 #[test]
236 fn typing_indicator_written_on_change() {
237 let tmp = tempfile::TempDir::new().unwrap();
238 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
239 std::fs::create_dir_all(&agent_doc_dir).unwrap();
240 let doc = tmp.path().join("test-typing.md");
241 std::fs::write(&doc, "test").unwrap();
242 let doc_str = doc.to_string_lossy().to_string();
243
244 document_changed(&doc_str);
245
246 // Should detect typing within 2000ms window
247 assert!(is_typing_via_file(&doc_str, 2000));
248 }
249
250 #[test]
251 fn typing_indicator_expires() {
252 let tmp = tempfile::TempDir::new().unwrap();
253 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
254 std::fs::create_dir_all(&agent_doc_dir).unwrap();
255 let doc = tmp.path().join("test-typing-expire.md");
256 std::fs::write(&doc, "test").unwrap();
257 let doc_str = doc.to_string_lossy().to_string();
258
259 document_changed(&doc_str);
260 std::thread::sleep(std::time::Duration::from_millis(50));
261
262 // With a 10ms debounce, 50ms ago should NOT be typing
263 assert!(!is_typing_via_file(&doc_str, 10));
264 }
265
266 #[test]
267 fn no_typing_indicator_means_not_typing() {
268 assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
269 }
270}