Skip to main content

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// ── Response status signal (A: file, B: FFI) ──
198
199/// Status directory for cross-process signals (Option A).
200const STATUS_DIR: &str = ".agent-doc/status";
201
202/// In-process status (Option B: FFI).
203static STATUS: Mutex<Option<HashMap<PathBuf, String>>> = Mutex::new(None);
204
205fn with_status<R>(f: impl FnOnce(&mut HashMap<PathBuf, String>) -> R) -> R {
206    let mut guard = STATUS.lock().unwrap();
207    let map = guard.get_or_insert_with(HashMap::new);
208    f(map)
209}
210
211/// Set the response status for a file.
212///
213/// Status values: "generating", "writing", "routing", "idle"
214/// Sets both in-process state (B) and file signal (A).
215pub fn set_status(file: &str, status: &str) {
216    let path = PathBuf::from(file);
217    with_status(|map| {
218        if status == "idle" {
219            map.remove(&path);
220        } else {
221            map.insert(path, status.to_string());
222        }
223    });
224    let _ = write_status_file(file, status);
225}
226
227/// Get the response status for a file (in-process, Option B).
228///
229/// Returns "idle" if no status is set.
230pub fn get_status(file: &str) -> String {
231    let path = PathBuf::from(file);
232    with_status(|map| {
233        map.get(&path).cloned().unwrap_or_else(|| "idle".to_string())
234    })
235}
236
237/// Check if any operation is in progress for a file (in-process, Option B).
238///
239/// Returns `true` if status is NOT "idle". Used by plugins to avoid
240/// triggering routes during active operations.
241pub fn is_busy(file: &str) -> bool {
242    get_status(file) != "idle"
243}
244
245/// Get status from file signal (cross-process, Option A).
246///
247/// Returns "idle" if no status file exists or it's stale (>30s).
248pub fn get_status_via_file(file: &str) -> String {
249    let path = status_file_path(file);
250    match std::fs::read_to_string(&path) {
251        Ok(content) => {
252            // Format: "status:timestamp_ms"
253            let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
254            if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
255                let now = std::time::SystemTime::now()
256                    .duration_since(std::time::UNIX_EPOCH)
257                    .unwrap_or_default()
258                    .as_millis();
259                // Stale after 30s — operation probably crashed
260                if now.saturating_sub(ts) < 30_000 {
261                    return parts[0].to_string();
262                }
263            }
264            "idle".to_string()
265        }
266        Err(_) => "idle".to_string(),
267    }
268}
269
270fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
271    let path = status_file_path(file);
272    if status == "idle" {
273        let _ = std::fs::remove_file(&path);
274        return Ok(());
275    }
276    if let Some(parent) = path.parent() {
277        std::fs::create_dir_all(parent)?;
278    }
279    let now = std::time::SystemTime::now()
280        .duration_since(std::time::UNIX_EPOCH)
281        .unwrap_or_default()
282        .as_millis();
283    std::fs::write(&path, format!("{}:{}", status, now))
284}
285
286fn status_file_path(file: &str) -> PathBuf {
287    use std::hash::{Hash, Hasher};
288    let mut hasher = std::collections::hash_map::DefaultHasher::new();
289    file.hash(&mut hasher);
290    let hash = hasher.finish();
291    let mut dir = PathBuf::from(file);
292    loop {
293        dir.pop();
294        if dir.join(".agent-doc").is_dir() {
295            return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
296        }
297        if !dir.pop() {
298            let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
299            return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn idle_when_no_changes() {
310        assert!(is_idle("/tmp/test-no-changes.md", 1500));
311    }
312
313    #[test]
314    fn not_idle_after_change() {
315        document_changed("/tmp/test-just-changed.md");
316        assert!(!is_idle("/tmp/test-just-changed.md", 1500));
317    }
318
319    #[test]
320    fn idle_after_debounce_period() {
321        document_changed("/tmp/test-debounce.md");
322        // Use a very short debounce for testing
323        std::thread::sleep(std::time::Duration::from_millis(50));
324        assert!(is_idle("/tmp/test-debounce.md", 10));
325    }
326
327    #[test]
328    fn await_idle_returns_immediately_when_idle() {
329        let start = Instant::now();
330        assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
331        assert!(start.elapsed().as_millis() < 200);
332    }
333
334    #[test]
335    fn await_idle_waits_for_settle() {
336        document_changed("/tmp/test-await-settle.md");
337        let start = Instant::now();
338        assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
339        assert!(start.elapsed().as_millis() >= 200);
340    }
341
342    #[test]
343    fn typing_indicator_written_on_change() {
344        let tmp = tempfile::TempDir::new().unwrap();
345        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
346        std::fs::create_dir_all(&agent_doc_dir).unwrap();
347        let doc = tmp.path().join("test-typing.md");
348        std::fs::write(&doc, "test").unwrap();
349        let doc_str = doc.to_string_lossy().to_string();
350
351        document_changed(&doc_str);
352
353        // Should detect typing within 2000ms window
354        assert!(is_typing_via_file(&doc_str, 2000));
355    }
356
357    #[test]
358    fn typing_indicator_expires() {
359        let tmp = tempfile::TempDir::new().unwrap();
360        let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
361        std::fs::create_dir_all(&agent_doc_dir).unwrap();
362        let doc = tmp.path().join("test-typing-expire.md");
363        std::fs::write(&doc, "test").unwrap();
364        let doc_str = doc.to_string_lossy().to_string();
365
366        document_changed(&doc_str);
367        std::thread::sleep(std::time::Duration::from_millis(50));
368
369        // With a 10ms debounce, 50ms ago should NOT be typing
370        assert!(!is_typing_via_file(&doc_str, 10));
371    }
372
373    #[test]
374    fn no_typing_indicator_means_not_typing() {
375        assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
376    }
377}