use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Instant;
static LAST_CHANGE: Mutex<Option<HashMap<PathBuf, Instant>>> = Mutex::new(None);
fn with_state<R>(f: impl FnOnce(&mut HashMap<PathBuf, Instant>) -> R) -> R {
let mut guard = LAST_CHANGE.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
f(map)
}
pub fn document_changed(file: &str) {
let path = PathBuf::from(file);
with_state(|map| {
map.insert(path.clone(), Instant::now());
});
if let Err(e) = write_typing_indicator(file) {
eprintln!("[debounce] typing indicator write failed for {:?}: {}", file, e);
}
}
pub fn is_idle(file: &str, debounce_ms: u64) -> bool {
let path = PathBuf::from(file);
with_state(|map| {
match map.get(&path) {
None => true, Some(last) => last.elapsed().as_millis() >= debounce_ms as u128,
}
})
}
pub fn is_tracked(file: &str) -> bool {
let path = PathBuf::from(file);
with_state(|map| map.contains_key(&path))
}
pub fn tracked_count() -> usize {
with_state(|map| map.len())
}
pub fn await_idle(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
let start = Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
let poll_interval = std::time::Duration::from_millis(100);
loop {
if is_idle(file, debounce_ms) {
return true;
}
if start.elapsed() >= timeout {
return false;
}
std::thread::sleep(poll_interval);
}
}
const TYPING_DIR: &str = ".agent-doc/typing";
fn write_typing_indicator(file: &str) -> std::io::Result<()> {
let typing_path = typing_indicator_path(file);
if let Some(parent) = typing_path.parent() {
std::fs::create_dir_all(parent)?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
std::fs::write(&typing_path, now.to_string())
}
fn typing_indicator_path(file: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
file.hash(&mut hasher);
let hash = hasher.finish();
let mut dir = PathBuf::from(file);
dir.pop(); loop {
if dir.join(".agent-doc").is_dir() {
return dir.join(TYPING_DIR).join(format!("{:016x}", hash));
}
if !dir.pop() {
let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
return parent.join(TYPING_DIR).join(format!("{:016x}", hash));
}
}
}
pub fn is_typing_via_file(file: &str, debounce_ms: u64) -> bool {
let path = typing_indicator_path(file);
match std::fs::read_to_string(&path) {
Ok(content) => {
if let Ok(ts_ms) = content.trim().parse::<u128>() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
now.saturating_sub(ts_ms) < debounce_ms as u128
} else {
false
}
}
Err(_) => false, }
}
pub fn await_idle_via_file(file: &str, debounce_ms: u64, timeout_ms: u64) -> bool {
let start = Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
let poll_interval = std::time::Duration::from_millis(100);
loop {
if !is_typing_via_file(file, debounce_ms) {
return true;
}
if start.elapsed() >= timeout {
return false;
}
std::thread::sleep(poll_interval);
}
}
const STATUS_DIR: &str = ".agent-doc/status";
static STATUS: Mutex<Option<HashMap<PathBuf, String>>> = Mutex::new(None);
fn with_status<R>(f: impl FnOnce(&mut HashMap<PathBuf, String>) -> R) -> R {
let mut guard = STATUS.lock().unwrap();
let map = guard.get_or_insert_with(HashMap::new);
f(map)
}
pub fn set_status(file: &str, status: &str) {
let path = PathBuf::from(file);
with_status(|map| {
if status == "idle" {
map.remove(&path);
} else {
map.insert(path, status.to_string());
}
});
let _ = write_status_file(file, status);
}
pub fn get_status(file: &str) -> String {
let path = PathBuf::from(file);
with_status(|map| {
map.get(&path).cloned().unwrap_or_else(|| "idle".to_string())
})
}
pub fn is_busy(file: &str) -> bool {
get_status(file) != "idle"
}
pub fn get_status_via_file(file: &str) -> String {
let path = status_file_path(file);
match std::fs::read_to_string(&path) {
Ok(content) => {
let parts: Vec<&str> = content.trim().splitn(2, ':').collect();
if parts.len() == 2 && let Ok(ts) = parts[1].parse::<u128>() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if now.saturating_sub(ts) < 30_000 {
return parts[0].to_string();
}
}
"idle".to_string()
}
Err(_) => "idle".to_string(),
}
}
fn write_status_file(file: &str, status: &str) -> std::io::Result<()> {
let path = status_file_path(file);
if status == "idle" {
let _ = std::fs::remove_file(&path);
return Ok(());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
std::fs::write(&path, format!("{}:{}", status, now))
}
fn status_file_path(file: &str) -> PathBuf {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
file.hash(&mut hasher);
let hash = hasher.finish();
let mut dir = PathBuf::from(file);
dir.pop(); loop {
if dir.join(".agent-doc").is_dir() {
return dir.join(STATUS_DIR).join(format!("{:016x}", hash));
}
if !dir.pop() {
let parent = PathBuf::from(file).parent().unwrap_or(std::path::Path::new(".")).to_path_buf();
return parent.join(STATUS_DIR).join(format!("{:016x}", hash));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn idle_when_no_changes() {
assert!(is_idle("/tmp/test-no-changes.md", 1500));
}
#[test]
fn not_idle_after_change() {
document_changed("/tmp/test-just-changed.md");
assert!(!is_idle("/tmp/test-just-changed.md", 1500));
}
#[test]
fn idle_after_debounce_period() {
document_changed("/tmp/test-debounce.md");
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(is_idle("/tmp/test-debounce.md", 10));
}
#[test]
fn await_idle_returns_immediately_when_idle() {
let start = Instant::now();
assert!(await_idle("/tmp/test-await-idle.md", 100, 5000));
assert!(start.elapsed().as_millis() < 200);
}
#[test]
fn await_idle_waits_for_settle() {
document_changed("/tmp/test-await-settle.md");
let start = Instant::now();
assert!(await_idle("/tmp/test-await-settle.md", 200, 5000));
assert!(start.elapsed().as_millis() >= 200);
}
#[test]
fn typing_indicator_written_on_change() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-typing.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
assert!(is_typing_via_file(&doc_str, 2000));
}
#[test]
fn typing_indicator_expires() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-typing-expire.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(!is_typing_via_file(&doc_str, 10));
}
#[test]
fn no_typing_indicator_means_not_typing() {
assert!(!is_typing_via_file("/tmp/nonexistent-file-xyz.md", 2000));
}
#[test]
fn rapid_edits_within_mtime_granularity() {
let tmp = tempfile::TempDir::new().unwrap();
let doc = tmp.path().join("test-rapid-edits.md");
std::fs::write(&doc, "initial").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
document_changed(&doc_str);
assert!(!is_idle(&doc_str, 500));
}
#[test]
fn is_tracked_distinguishes_untracked_from_idle() {
let file_never_tracked = "/tmp/never-tracked.md";
let file_tracked_idle = "/tmp/tracked-idle.md";
assert!(!is_tracked(file_never_tracked));
assert!(is_idle(file_never_tracked, 1500));
document_changed(file_tracked_idle);
std::thread::sleep(std::time::Duration::from_millis(50));
assert!(is_tracked(file_tracked_idle)); assert!(is_idle(file_tracked_idle, 10)); }
#[test]
fn await_idle_on_untracked_file_returns_immediately() {
let start = Instant::now();
assert!(await_idle("/tmp/untracked-await.md", 1500, 5000));
assert!(start.elapsed().as_millis() < 500);
}
#[test]
fn await_idle_respects_tracked_state() {
let tracked_file = "/tmp/tracked-await.md";
document_changed(tracked_file);
assert!(is_tracked(tracked_file));
let start = Instant::now();
assert!(await_idle(tracked_file, 200, 5000));
assert!(start.elapsed().as_millis() >= 200);
}
#[test]
fn hash_collision_handling() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc1 = tmp.path().join("doc1.md");
let doc2 = tmp.path().join("doc2.md");
std::fs::write(&doc1, "test").unwrap();
std::fs::write(&doc2, "test").unwrap();
let doc1_str = doc1.to_string_lossy().to_string();
let doc2_str = doc2.to_string_lossy().to_string();
document_changed(&doc1_str);
let path1 = typing_indicator_path(&doc1_str);
document_changed(&doc2_str);
let path2 = typing_indicator_path(&doc2_str);
if path1 == path2 {
assert!(is_typing_via_file(&doc2_str, 2000)); } else {
assert!(is_typing_via_file(&doc1_str, 2000));
assert!(is_typing_via_file(&doc2_str, 2000));
}
}
#[test]
fn reactive_mode_requires_zero_debounce() {
let reactive_file = "/tmp/reactive.md";
document_changed(reactive_file);
assert!(is_idle(reactive_file, 0));
}
#[test]
fn status_file_staleness_timeout() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-status.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
set_status(&doc_str, "generating");
assert_eq!(get_status(&doc_str), "generating");
assert_eq!(get_status_via_file(&doc_str), "generating");
}
#[test]
fn status_file_cleared_on_idle() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-status-clear.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
set_status(&doc_str, "writing");
assert!(is_busy(&doc_str));
set_status(&doc_str, "idle");
assert!(!is_busy(&doc_str));
assert_eq!(get_status(&doc_str), "idle");
}
#[test]
fn timing_constants_are_configurable() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-timing.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
assert!(is_typing_via_file(&doc_str, 2000));
assert!(is_typing_via_file(&doc_str, 100));
let start = Instant::now();
let result = await_idle_via_file(&doc_str, 10, 1000);
let elapsed = start.elapsed();
assert!(result);
assert!(elapsed.as_millis() >= 10);
}
#[test]
fn await_idle_via_file_respects_poll_interval() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let doc = tmp.path().join("test-poll-interval.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
let start = Instant::now();
assert!(await_idle_via_file(&doc_str, 100, 5000));
let elapsed = start.elapsed().as_millis();
assert!(elapsed >= 100);
}
#[test]
fn typing_indicator_found_for_file_one_level_deep() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let subdir = tmp.path().join("tasks");
std::fs::create_dir_all(&subdir).unwrap();
let doc = subdir.join("test-depth1.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
assert!(is_typing_via_file(&doc_str, 2000));
}
#[test]
fn typing_indicator_found_for_file_two_levels_deep() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("typing");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let subdir = tmp.path().join("tasks").join("software");
std::fs::create_dir_all(&subdir).unwrap();
let doc = subdir.join("test-depth2.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
document_changed(&doc_str);
assert!(is_typing_via_file(&doc_str, 2000));
}
#[test]
fn status_found_for_file_one_level_deep() {
let tmp = tempfile::TempDir::new().unwrap();
let agent_doc_dir = tmp.path().join(".agent-doc").join("status");
std::fs::create_dir_all(&agent_doc_dir).unwrap();
let subdir = tmp.path().join("tasks");
std::fs::create_dir_all(&subdir).unwrap();
let doc = subdir.join("test-status-depth1.md");
std::fs::write(&doc, "test").unwrap();
let doc_str = doc.to_string_lossy().to_string();
set_status(&doc_str, "generating");
assert_eq!(get_status_via_file(&doc_str), "generating");
}
}