1use std::collections::HashMap;
43use std::path::PathBuf;
44use std::sync::Mutex;
45use std::time::Instant;
46
47static 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
56pub fn document_changed(file: &str) {
61 let path = PathBuf::from(file);
62 with_state(|map| {
63 map.insert(path.clone(), Instant::now());
64 });
65 let _ = write_typing_indicator(file);
67}
68
69pub 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, Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
80 }
81 })
82}
83
84pub fn is_tracked(file: &str) -> bool {
89 let path = PathBuf::from(file);
90 with_state(|map| map.contains_key(&path))
91}
92
93pub 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
114const TYPING_DIR: &str = ".agent-doc/typing";
118
119fn 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
133fn 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 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 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
154pub 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, }
175}
176
177pub 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
197const STATUS_DIR: &str = ".agent-doc/status";
201
202static 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
211pub 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
227pub 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
237pub fn is_busy(file: &str) -> bool {
242 get_status(file) != "idle"
243}
244
245pub 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 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 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 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 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 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}