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
209pub fn set_status(file: &str, status: &str) {
214 let _ = write_status_file(file, status);
215}
216
217pub fn get_status(file: &str) -> String {
221 get_status_via_file(file)
222}
223
224pub fn is_busy(file: &str) -> bool {
229 get_status(file) != "idle"
230}
231
232pub fn get_status_via_file(file: &str) -> String {
236 let path = status_file_path(file);
237 match std::fs::read_to_string(&path) {
238 Ok(content) => {
239 let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
241 if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
242 let now = std::time::SystemTime::now()
243 .duration_since(std::time::UNIX_EPOCH)
244 .unwrap_or_default()
245 .as_millis();
246 if now.saturating_sub(ts) < 30_000 {
248 return parts[0].to_string();
249 }
250 }
251 "idle".to_string()
252 }
253 Err(_) => "idle".to_string(),
254 }
255}
256
257fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
258 let path = status_file_path(file);
259 if status == "idle" {
260 let _ = std::fs::remove_file(&path);
261 return Ok(());
262 }
263 if let Some(parent) = path.parent() {
264 std::fs::create_dir_all(parent)?;
265 }
266 let now = std::time::SystemTime::now()
267 .duration_since(std::time::UNIX_EPOCH)
268 .unwrap_or_default()
269 .as_millis();
270 std::fs::write(&path, format!("{}:{}", status, now))
271}
272
273fn status_file_path(file: &str) -> PathBuf {
274 use std::hash::{Hash, Hasher};
275 let mut hasher = std::collections::hash_map::DefaultHasher::new();
276 file.hash(&mut hasher);
277 let hash = hasher.finish();
278 let mut dir = PathBuf::from(file);
279 dir.pop(); loop {
281 if dir.join(".agent-doc").is_dir() {
282 return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
283 }
284 if !dir.pop() {
285 let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
286 return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn idle_when_no_changes() {
297 assert!(is_idle("/tmp/test-no-changes.md", 1500));
298 }
299
300 #[test]
301 fn not_idle_after_change() {
302 document_changed("/tmp/test-just-changed.md");
303 assert!(!is_idle("/tmp/test-just-changed.md", 1500));
304 }
305
306 #[test]
307 fn idle_after_debounce_period() {
308 document_changed("/tmp/test-debounce.md");
309 std::thread::sleep(std::time::Duration::from_millis(50));
311 assert!(is_idle("/tmp/test-debounce.md", 10));
312 }
313
314 #[test]
315 fn await_idle_returns_immediately_when_idle() {
316 let start = Instant::now();
317 assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
318 assert!(start.elapsed().as_millis() < 200);
319 }
320
321 #[test]
322 fn await_idle_waits_for_settle() {
323 document_changed("/tmp/test-await-settle.md");
324 let start = Instant::now();
325 assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
326 assert!(start.elapsed().as_millis() >= 200);
327 }
328
329 #[test]
330 fn typing_indicator_written_on_change() {
331 let tmp = tempfile::TempDir::new().unwrap();
332 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
333 std::fs::create_dir_all(&agent_doc_dir).unwrap();
334 let doc = tmp.path().join("test-typing.md");
335 std::fs::write(&doc, "test").unwrap();
336 let doc_str = doc.to_string_lossy().to_string();
337
338 document_changed(&doc_str);
339
340 assert!(is_typing_via_file(&doc_str, 2000));
342 }
343
344 #[test]
345 fn typing_indicator_expires() {
346 let tmp = tempfile::TempDir::new().unwrap();
347 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
348 std::fs::create_dir_all(&agent_doc_dir).unwrap();
349 let doc = tmp.path().join("test-typing-expire.md");
350 std::fs::write(&doc, "test").unwrap();
351 let doc_str = doc.to_string_lossy().to_string();
352
353 document_changed(&doc_str);
354 std::thread::sleep(std::time::Duration::from_millis(50));
355
356 assert!(!is_typing_via_file(&doc_str, 10));
358 }
359
360 #[test]
361 fn no_typing_indicator_means_not_typing() {
362 assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
363 }
364
365 #[test]
370 fn rapid_edits_within_mtime_granularity() {
371 let tmp = tempfile::TempDir::new().unwrap();
372 let doc = tmp.path().join("test-rapid-edits.md");
373 std::fs::write(&doc, "initial").unwrap();
374 let doc_str = doc.to_string_lossy().to_string();
375
376 document_changed(&doc_str);
379 document_changed(&doc_str);
381
382 assert!(!is_idle(&doc_str, 500));
384 }
385
386 #[test]
391 fn is_tracked_distinguishes_untracked_from_idle() {
392 let file_never_tracked = "/tmp/never-tracked.md";
393 let file_tracked_idle = "/tmp/tracked-idle.md";
394
395 assert!(!is_tracked(file_never_tracked));
397 assert!(is_idle(file_never_tracked, 1500)); document_changed(file_tracked_idle);
401 std::thread::sleep(std::time::Duration::from_millis(50));
402 assert!(is_tracked(file_tracked_idle)); assert!(is_idle(file_tracked_idle, 10)); }
405
406 #[test]
407 fn await_idle_on_untracked_file_returns_immediately() {
408 let start = Instant::now();
409 assert!(await_idle("/tmp/untracked-await.md", 1500, 5000));
411 assert!(start.elapsed().as_millis() < 500);
412 }
413
414 #[test]
415 fn await_idle_respects_tracked_state() {
416 let tracked_file = "/tmp/tracked-await.md";
417 document_changed(tracked_file);
418 assert!(is_tracked(tracked_file));
419
420 let start = Instant::now();
422 assert!(await_idle(tracked_file, 200, 5000));
423 assert!(start.elapsed().as_millis() >= 200);
424 }
425
426 #[test]
431 fn hash_collision_handling() {
432 let tmp = tempfile::TempDir::new().unwrap();
433 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
434 std::fs::create_dir_all(&agent_doc_dir).unwrap();
435
436 let doc1 = tmp.path().join("doc1.md");
437 let doc2 = tmp.path().join("doc2.md");
438 std::fs::write(&doc1, "test").unwrap();
439 std::fs::write(&doc2, "test").unwrap();
440
441 let doc1_str = doc1.to_string_lossy().to_string();
442 let doc2_str = doc2.to_string_lossy().to_string();
443
444 document_changed(&doc1_str);
445 let path1 = typing_indicator_path(&doc1_str);
446
447 document_changed(&doc2_str);
448 let path2 = typing_indicator_path(&doc2_str);
449
450 if path1 == path2 {
453 assert!(is_typing_via_file(&doc2_str, 2000)); } else {
457 assert!(is_typing_via_file(&doc1_str, 2000));
459 assert!(is_typing_via_file(&doc2_str, 2000));
460 }
461 }
462
463 #[test]
469 fn reactive_mode_requires_zero_debounce() {
470 let reactive_file = "/tmp/reactive.md";
473 document_changed(reactive_file);
474
475 assert!(is_idle(reactive_file, 0));
478
479 }
482
483 #[test]
488 fn status_file_staleness_timeout() {
489 let tmp = tempfile::TempDir::new().unwrap();
490 let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
491 std::fs::create_dir_all(&agent_doc_dir).unwrap();
492 let doc = tmp.path().join("test-status.md");
493 std::fs::write(&doc, "test").unwrap();
494 let doc_str = doc.to_string_lossy().to_string();
495
496 set_status(&doc_str, "generating");
497 assert_eq!(get_status(&doc_str), "generating");
498
499 assert_eq!(get_status_via_file(&doc_str), "generating");
501
502 }
506
507 #[test]
508 fn status_file_cleared_on_idle() {
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-clear.md");
513 std::fs::write(&doc, "test").unwrap();
514 let doc_str = doc.to_string_lossy().to_string();
515
516 set_status(&doc_str, "writing");
517 assert!(is_busy(&doc_str));
518
519 set_status(&doc_str, "idle");
520 assert!(!is_busy(&doc_str));
521 assert_eq!(get_status(&doc_str), "idle");
522 }
523
524 #[test]
529 fn timing_constants_are_configurable() {
530 let tmp = tempfile::TempDir::new().unwrap();
531 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
532 std::fs::create_dir_all(&agent_doc_dir).unwrap();
533 let doc = tmp.path().join("test-timing.md");
534 std::fs::write(&doc, "test").unwrap();
535 let doc_str = doc.to_string_lossy().to_string();
536
537 document_changed(&doc_str);
538
539 assert!(is_typing_via_file(&doc_str, 2000));
541 assert!(is_typing_via_file(&doc_str, 100));
542
543 let start = Instant::now();
545 let result = await_idle_via_file(&doc_str, 10, 1000);
546 let elapsed = start.elapsed();
547
548 assert!(result);
550 assert!(elapsed.as_millis() >= 10);
551
552 }
555
556 #[test]
557 fn await_idle_via_file_respects_poll_interval() {
558 let tmp = tempfile::TempDir::new().unwrap();
559 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
560 std::fs::create_dir_all(&agent_doc_dir).unwrap();
561 let doc = tmp.path().join("test-poll-interval.md");
562 std::fs::write(&doc, "test").unwrap();
563 let doc_str = doc.to_string_lossy().to_string();
564
565 document_changed(&doc_str);
566
567 let start = Instant::now();
568 assert!(await_idle_via_file(&doc_str, 100, 5000));
570 let elapsed = start.elapsed().as_millis();
571
572 assert!(elapsed >= 100);
574 }
575
576 #[test]
583 fn typing_indicator_found_for_file_one_level_deep() {
584 let tmp = tempfile::TempDir::new().unwrap();
585 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
587 std::fs::create_dir_all(&agent_doc_dir).unwrap();
588 let subdir = tmp.path().join("tasks");
590 std::fs::create_dir_all(&subdir).unwrap();
591 let doc = subdir.join("test-depth1.md");
592 std::fs::write(&doc, "test").unwrap();
593 let doc_str = doc.to_string_lossy().to_string();
594
595 document_changed(&doc_str);
596
597 assert!(is_typing_via_file(&doc_str, 2000));
599 }
600
601 #[test]
602 fn typing_indicator_found_for_file_two_levels_deep() {
603 let tmp = tempfile::TempDir::new().unwrap();
604 let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
605 std::fs::create_dir_all(&agent_doc_dir).unwrap();
606 let subdir = tmp.path().join("tasks").join("software");
608 std::fs::create_dir_all(&subdir).unwrap();
609 let doc = subdir.join("test-depth2.md");
610 std::fs::write(&doc, "test").unwrap();
611 let doc_str = doc.to_string_lossy().to_string();
612
613 document_changed(&doc_str);
614
615 assert!(is_typing_via_file(&doc_str, 2000));
616 }
617
618 #[test]
619 fn status_found_for_file_one_level_deep() {
620 let tmp = tempfile::TempDir::new().unwrap();
621 let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
622 std::fs::create_dir_all(&agent_doc_dir).unwrap();
623 let subdir = tmp.path().join("tasks");
624 std::fs::create_dir_all(&subdir).unwrap();
625 let doc = subdir.join("test-status-depth1.md");
626 std::fs::write(&doc, "test").unwrap();
627 let doc_str = doc.to_string_lossy().to_string();
628
629 set_status(&doc_str, "generating");
630
631 assert_eq!(get_status_via_file(&doc_str), "generating");
633 }
634}