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 if let Err(e) = write_typing_indicator(file) {
67 eprintln!("[debounce] typing indicator write failed for {:?}: {}", file, e);
68 }
69}
70
71pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
77 let path = PathBuf::from(file);
78 with_state(|map| {
79 match map.get(&path) {
80 None => true, Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
82 }
83 })
84}
85
86pub fn is_tracked(file: &str) -> bool {
91 let path = PathBuf::from(file);
92 with_state(|map| map.contains_key(&path))
93}
94
95pub fn tracked_count() -> usize {
97 with_state(|map| map.len())
98}
99
100pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
106 let start = Instant::now();
107 let timeout = std::time::Duration::from_millis(timeout_ms);
108 let poll_interval = std::time::Duration::from_millis(100);
109
110 loop {
111 if is_idle(file, debounce_ms) {
112 return true;
113 }
114 if start.elapsed() >= timeout {
115 return false;
116 }
117 std::thread::sleep(poll_interval);
118 }
119}
120
121const TYPING_DIR: &str = ".agent-doc/typing";
125
126fn write_typing_indicator(file: &str) -> std::io::Result<()> {
129 let typing_path = typing_indicator_path(file);
130 if let Some(parent) = typing_path.parent() {
131 std::fs::create_dir_all(parent)?;
132 }
133 let now = std::time::SystemTime::now()
134 .duration_since(std::time::UNIX_EPOCH)
135 .unwrap_or_default()
136 .as_millis();
137 std::fs::write(&typing_path, now.to_string())
138}
139
140fn typing_indicator_path(file: &str) -> PathBuf {
142 use std::hash::{Hash, Hasher};
143 let mut hasher = std::collections::hash_map::DefaultHasher::new();
144 file.hash(&mut hasher);
145 let hash = hasher.finish();
146 let mut dir = PathBuf::from(file);
148 dir.pop(); loop {
150 if dir.join(".agent-doc").is_dir() {
151 return dir.join(TYPING_DIR).join(format!("{:016x}", hash));
152 }
153 if !dir.pop() {
154 let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
156 return parent.join(TYPING_DIR).join(format!("{:016x}", hash));
157 }
158 }
159}
160
161pub fn is_typing_via_file(file: &str, debounce_ms: u64) -> bool {
167 let path = typing_indicator_path(file);
168 match std::fs::read_to_string(&path) {
169 Ok(content) => {
170 if let Ok(ts_ms) = content.trim().parse::<u128>() {
171 let now = std::time::SystemTime::now()
172 .duration_since(std::time::UNIX_EPOCH)
173 .unwrap_or_default()
174 .as_millis();
175 now.saturating_sub(ts_ms) < debounce_ms as u128
176 } else {
177 false
178 }
179 }
180 Err(_) => false, }
182}
183
184pub fn await_idle_via_file(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
189 let start = Instant::now();
190 let timeout = std::time::Duration::from_millis(timeout_ms);
191 let poll_interval = std::time::Duration::from_millis(100);
192
193 loop {
194 if !is_typing_via_file(file, debounce_ms) {
195 return true;
196 }
197 if start.elapsed() >= timeout {
198 return false;
199 }
200 std::thread::sleep(poll_interval);
201 }
202}
203
204const STATUS_DIR: &str = ".agent-doc/status";
208
209static STATUS: Mutex<Option<HashMap<PathBuf, String>>> = Mutex::new(None);
211
212fn with_status<R>(f: impl FnOnce(&mut HashMap<PathBuf, String>) -> R) -> R {
213 let mut guard = STATUS.lock().unwrap();
214 let map = guard.get_or_insert_with(HashMap::new);
215 f(map)
216}
217
218pub fn set_status(file: &str, status: &str) {
223 let path = PathBuf::from(file);
224 with_status(|map| {
225 if status == "idle" {
226 map.remove(&path);
227 } else {
228 map.insert(path, status.to_string());
229 }
230 });
231 let _ = write_status_file(file, status);
232}
233
234pub fn get_status(file: &str) -> String {
238 let path = PathBuf::from(file);
239 with_status(|map| {
240 map.get(&path).cloned().unwrap_or_else(|| "idle".to_string())
241 })
242}
243
244pub fn is_busy(file: &str) -> bool {
249 get_status(file) != "idle"
250}
251
252pub fn get_status_via_file(file: &str) -> String {
256 let path = status_file_path(file);
257 match std::fs::read_to_string(&path) {
258 Ok(content) => {
259 let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
261 if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
262 let now = std::time::SystemTime::now()
263 .duration_since(std::time::UNIX_EPOCH)
264 .unwrap_or_default()
265 .as_millis();
266 if now.saturating_sub(ts) < 30_000 {
268 return parts[0].to_string();
269 }
270 }
271 "idle".to_string()
272 }
273 Err(_) => "idle".to_string(),
274 }
275}
276
277fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
278 let path = status_file_path(file);
279 if status == "idle" {
280 let _ = std::fs::remove_file(&path);
281 return Ok(());
282 }
283 if let Some(parent) = path.parent() {
284 std::fs::create_dir_all(parent)?;
285 }
286 let now = std::time::SystemTime::now()
287 .duration_since(std::time::UNIX_EPOCH)
288 .unwrap_or_default()
289 .as_millis();
290 std::fs::write(&path, format!("{}:{}", status, now))
291}
292
293fn status_file_path(file: &str) -> PathBuf {
294 use std::hash::{Hash, Hasher};
295 let mut hasher = std::collections::hash_map::DefaultHasher::new();
296 file.hash(&mut hasher);
297 let hash = hasher.finish();
298 let mut dir = PathBuf::from(file);
299 dir.pop(); loop {
301 if dir.join(".agent-doc").is_dir() {
302 return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
303 }
304 if !dir.pop() {
305 let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
306 return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn idle_when_no_changes() {
317 assert!(is_idle("/tmp/test-no-changes.md", 1500));
318 }
319
320 #[test]
321 fn not_idle_after_change() {
322 document_changed("/tmp/test-just-changed.md");
323 assert!(!is_idle("/tmp/test-just-changed.md", 1500));
324 }
325
326 #[test]
327 fn idle_after_debounce_period() {
328 document_changed("/tmp/test-debounce.md");
329 std::thread::sleep(std::time::Duration::from_millis(50));
331 assert!(is_idle("/tmp/test-debounce.md", 10));
332 }
333
334 #[test]
335 fn await_idle_returns_immediately_when_idle() {
336 let start = Instant::now();
337 assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
338 assert!(start.elapsed().as_millis() < 200);
339 }
340
341 #[test]
342 fn await_idle_waits_for_settle() {
343 document_changed("/tmp/test-await-settle.md");
344 let start = Instant::now();
345 assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
346 assert!(start.elapsed().as_millis() >= 200);
347 }
348
349 #[test]
350 fn typing_indicator_written_on_change() {
351 let tmp = tempfile::TempDir::new().unwrap();
352 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
353 std::fs::create_dir_all(&agent_doc_dir).unwrap();
354 let doc = tmp.path().join("test-typing.md");
355 std::fs::write(&doc, "test").unwrap();
356 let doc_str = doc.to_string_lossy().to_string();
357
358 document_changed(&doc_str);
359
360 assert!(is_typing_via_file(&doc_str, 2000));
362 }
363
364 #[test]
365 fn typing_indicator_expires() {
366 let tmp = tempfile::TempDir::new().unwrap();
367 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
368 std::fs::create_dir_all(&agent_doc_dir).unwrap();
369 let doc = tmp.path().join("test-typing-expire.md");
370 std::fs::write(&doc, "test").unwrap();
371 let doc_str = doc.to_string_lossy().to_string();
372
373 document_changed(&doc_str);
374 std::thread::sleep(std::time::Duration::from_millis(50));
375
376 assert!(!is_typing_via_file(&doc_str, 10));
378 }
379
380 #[test]
381 fn no_typing_indicator_means_not_typing() {
382 assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
383 }
384
385 #[test]
390 fn rapid_edits_within_mtime_granularity() {
391 let tmp = tempfile::TempDir::new().unwrap();
392 let doc = tmp.path().join("test-rapid-edits.md");
393 std::fs::write(&doc, "initial").unwrap();
394 let doc_str = doc.to_string_lossy().to_string();
395
396 document_changed(&doc_str);
399 document_changed(&doc_str);
401
402 assert!(!is_idle(&doc_str, 500));
404 }
405
406 #[test]
411 fn is_tracked_distinguishes_untracked_from_idle() {
412 let file_never_tracked = "/tmp/never-tracked.md";
413 let file_tracked_idle = "/tmp/tracked-idle.md";
414
415 assert!(!is_tracked(file_never_tracked));
417 assert!(is_idle(file_never_tracked, 1500)); document_changed(file_tracked_idle);
421 std::thread::sleep(std::time::Duration::from_millis(50));
422 assert!(is_tracked(file_tracked_idle)); assert!(is_idle(file_tracked_idle, 10)); }
425
426 #[test]
427 fn await_idle_on_untracked_file_returns_immediately() {
428 let start = Instant::now();
429 assert!(await_idle("/tmp/untracked-await.md", 1500, 5000));
431 assert!(start.elapsed().as_millis() < 500);
432 }
433
434 #[test]
435 fn await_idle_respects_tracked_state() {
436 let tracked_file = "/tmp/tracked-await.md";
437 document_changed(tracked_file);
438 assert!(is_tracked(tracked_file));
439
440 let start = Instant::now();
442 assert!(await_idle(tracked_file, 200, 5000));
443 assert!(start.elapsed().as_millis() >= 200);
444 }
445
446 #[test]
451 fn hash_collision_handling() {
452 let tmp = tempfile::TempDir::new().unwrap();
453 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
454 std::fs::create_dir_all(&agent_doc_dir).unwrap();
455
456 let doc1 = tmp.path().join("doc1.md");
457 let doc2 = tmp.path().join("doc2.md");
458 std::fs::write(&doc1, "test").unwrap();
459 std::fs::write(&doc2, "test").unwrap();
460
461 let doc1_str = doc1.to_string_lossy().to_string();
462 let doc2_str = doc2.to_string_lossy().to_string();
463
464 document_changed(&doc1_str);
465 let path1 = typing_indicator_path(&doc1_str);
466
467 document_changed(&doc2_str);
468 let path2 = typing_indicator_path(&doc2_str);
469
470 if path1 == path2 {
473 assert!(is_typing_via_file(&doc2_str, 2000)); } else {
477 assert!(is_typing_via_file(&doc1_str, 2000));
479 assert!(is_typing_via_file(&doc2_str, 2000));
480 }
481 }
482
483 #[test]
489 fn reactive_mode_requires_zero_debounce() {
490 let reactive_file = "/tmp/reactive.md";
493 document_changed(reactive_file);
494
495 assert!(is_idle(reactive_file, 0));
498
499 }
502
503 #[test]
508 fn status_file_staleness_timeout() {
509 let tmp = tempfile::TempDir::new().unwrap();
510 let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
511 std::fs::create_dir_all(&agent_doc_dir).unwrap();
512 let doc = tmp.path().join("test-status.md");
513 std::fs::write(&doc, "test").unwrap();
514 let doc_str = doc.to_string_lossy().to_string();
515
516 set_status(&doc_str, "generating");
517 assert_eq!(get_status(&doc_str), "generating");
518
519 assert_eq!(get_status_via_file(&doc_str), "generating");
521
522 }
526
527 #[test]
528 fn status_file_cleared_on_idle() {
529 let tmp = tempfile::TempDir::new().unwrap();
530 let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
531 std::fs::create_dir_all(&agent_doc_dir).unwrap();
532 let doc = tmp.path().join("test-status-clear.md");
533 std::fs::write(&doc, "test").unwrap();
534 let doc_str = doc.to_string_lossy().to_string();
535
536 set_status(&doc_str, "writing");
537 assert!(is_busy(&doc_str));
538
539 set_status(&doc_str, "idle");
540 assert!(!is_busy(&doc_str));
541 assert_eq!(get_status(&doc_str), "idle");
542 }
543
544 #[test]
549 fn timing_constants_are_configurable() {
550 let tmp = tempfile::TempDir::new().unwrap();
551 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
552 std::fs::create_dir_all(&agent_doc_dir).unwrap();
553 let doc = tmp.path().join("test-timing.md");
554 std::fs::write(&doc, "test").unwrap();
555 let doc_str = doc.to_string_lossy().to_string();
556
557 document_changed(&doc_str);
558
559 assert!(is_typing_via_file(&doc_str, 2000));
561 assert!(is_typing_via_file(&doc_str, 100));
562
563 let start = Instant::now();
565 let result = await_idle_via_file(&doc_str, 10, 1000);
566 let elapsed = start.elapsed();
567
568 assert!(result);
570 assert!(elapsed.as_millis() >= 10);
571
572 }
575
576 #[test]
577 fn await_idle_via_file_respects_poll_interval() {
578 let tmp = tempfile::TempDir::new().unwrap();
579 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
580 std::fs::create_dir_all(&agent_doc_dir).unwrap();
581 let doc = tmp.path().join("test-poll-interval.md");
582 std::fs::write(&doc, "test").unwrap();
583 let doc_str = doc.to_string_lossy().to_string();
584
585 document_changed(&doc_str);
586
587 let start = Instant::now();
588 assert!(await_idle_via_file(&doc_str, 100, 5000));
590 let elapsed = start.elapsed().as_millis();
591
592 assert!(elapsed >= 100);
594 }
595
596 #[test]
603 fn typing_indicator_found_for_file_one_level_deep() {
604 let tmp = tempfile::TempDir::new().unwrap();
605 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
607 std::fs::create_dir_all(&agent_doc_dir).unwrap();
608 let subdir = tmp.path().join("tasks");
610 std::fs::create_dir_all(&subdir).unwrap();
611 let doc = subdir.join("test-depth1.md");
612 std::fs::write(&doc, "test").unwrap();
613 let doc_str = doc.to_string_lossy().to_string();
614
615 document_changed(&doc_str);
616
617 assert!(is_typing_via_file(&doc_str, 2000));
619 }
620
621 #[test]
622 fn typing_indicator_found_for_file_two_levels_deep() {
623 let tmp = tempfile::TempDir::new().unwrap();
624 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
625 std::fs::create_dir_all(&agent_doc_dir).unwrap();
626 let subdir = tmp.path().join("tasks").join("software");
628 std::fs::create_dir_all(&subdir).unwrap();
629 let doc = subdir.join("test-depth2.md");
630 std::fs::write(&doc, "test").unwrap();
631 let doc_str = doc.to_string_lossy().to_string();
632
633 document_changed(&doc_str);
634
635 assert!(is_typing_via_file(&doc_str, 2000));
636 }
637
638 #[test]
639 fn status_found_for_file_one_level_deep() {
640 let tmp = tempfile::TempDir::new().unwrap();
641 let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
642 std::fs::create_dir_all(&agent_doc_dir).unwrap();
643 let subdir = tmp.path().join("tasks");
644 std::fs::create_dir_all(&subdir).unwrap();
645 let doc = subdir.join("test-status-depth1.md");
646 std::fs::write(&doc, "test").unwrap();
647 let doc_str = doc.to_string_lossy().to_string();
648
649 set_status(&doc_str, "generating");
650
651 assert_eq!(get_status_via_file(&doc_str), "generating");
654 }
655}