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#[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}