use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub type Rect = (f64, f64, f64, f64);
#[derive(Debug, Clone, PartialEq)]
pub struct ElementSnapshot {
pub role: String,
pub label: String,
pub path: Vec<String>,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ElementRef {
pub id: u64,
pub role: String,
pub label: String,
pub path: Vec<String>,
pub bounds: Rect,
pub fingerprint: u64,
pub alive: bool,
pub last_seen_ns: u64,
pub missed_refreshes: u32,
}
impl ElementRef {
#[must_use]
pub fn ref_name(&self) -> String {
format!("ref_{}", self.id)
}
}
pub const GC_THRESHOLD: u32 = 3;
pub struct RefStore {
refs: HashMap<u64, ElementRef>,
by_fingerprint: HashMap<u64, u64>,
next_id: u64,
}
impl RefStore {
#[must_use]
pub fn new() -> Self {
Self {
refs: HashMap::new(),
by_fingerprint: HashMap::new(),
next_id: 1,
}
}
pub fn track(&mut self, snap: ElementSnapshot) -> ElementRef {
let fp = fingerprint(&snap.role, &snap.label, &snap.path);
let now = unix_ns();
if let Some(&existing_id) = self.by_fingerprint.get(&fp) {
if let Some(entry) = self.refs.get_mut(&existing_id) {
entry.bounds = snap.bounds;
entry.alive = true;
entry.last_seen_ns = now;
entry.missed_refreshes = 0;
return entry.clone();
}
}
let id = self.next_id;
self.next_id += 1;
let elem_ref = ElementRef {
id,
role: snap.role,
label: snap.label,
path: snap.path,
bounds: snap.bounds,
fingerprint: fp,
alive: true,
last_seen_ns: now,
missed_refreshes: 0,
};
self.by_fingerprint.insert(fp, id);
self.refs.insert(id, elem_ref.clone());
elem_ref
}
#[must_use]
pub fn resolve(&self, ref_id: u64) -> Option<&ElementRef> {
self.refs.get(&ref_id)
}
#[must_use]
pub fn resolve_by_fingerprint(&self, fingerprint: u64) -> Option<&ElementRef> {
self.by_fingerprint
.get(&fingerprint)
.and_then(|id| self.refs.get(id))
}
#[must_use]
pub fn find_by_label(&self, needle: &str) -> Vec<&ElementRef> {
let needle_lower = needle.to_lowercase();
self.refs
.values()
.filter(|r| r.label.to_lowercase().contains(&needle_lower))
.collect()
}
pub fn refresh(&mut self, current_elements: &[ElementSnapshot]) {
let live_fps: HashMap<u64, &ElementSnapshot> = current_elements
.iter()
.map(|s| (fingerprint(&s.role, &s.label, &s.path), s))
.collect();
let now = unix_ns();
for entry in self.refs.values_mut() {
if let Some(snap) = live_fps.get(&entry.fingerprint) {
entry.bounds = snap.bounds;
entry.alive = true;
entry.last_seen_ns = now;
entry.missed_refreshes = 0;
} else {
entry.alive = false;
entry.missed_refreshes = entry.missed_refreshes.saturating_add(1);
}
}
}
pub fn gc(&mut self) -> usize {
let stale_ids: Vec<u64> = self
.refs
.values()
.filter(|r| r.missed_refreshes >= GC_THRESHOLD)
.map(|r| r.id)
.collect();
for id in &stale_ids {
if let Some(removed) = self.refs.remove(id) {
self.by_fingerprint.remove(&removed.fingerprint);
}
}
stale_ids.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.refs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.refs.is_empty()
}
#[must_use]
pub fn alive_count(&self) -> usize {
self.refs.values().filter(|r| r.alive).count()
}
}
impl Default for RefStore {
fn default() -> Self {
Self::new()
}
}
static GLOBAL_REF_STORE: std::sync::OnceLock<std::sync::Mutex<RefStore>> =
std::sync::OnceLock::new();
pub fn global_ref_store() -> std::sync::MutexGuard<'static, RefStore> {
GLOBAL_REF_STORE
.get_or_init(|| std::sync::Mutex::new(RefStore::new()))
.lock()
.expect("global RefStore mutex poisoned")
}
static GLOBAL_NEXT_ID: AtomicU64 = AtomicU64::new(1);
pub fn next_global_ref_id() -> u64 {
GLOBAL_NEXT_ID.fetch_add(1, Ordering::Relaxed)
}
fn fingerprint(role: &str, label: &str, path: &[String]) -> u64 {
use std::collections::hash_map::DefaultHasher;
let mut h = DefaultHasher::new();
role.hash(&mut h);
label.hash(&mut h);
path.hash(&mut h);
h.finish()
}
#[allow(clippy::cast_possible_truncation)]
fn unix_ns() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
fn button_snap(label: &str) -> ElementSnapshot {
ElementSnapshot {
role: "AXButton".into(),
label: label.into(),
path: vec!["AXWindow:Document".into(), format!("AXButton:{label}")],
bounds: (10.0, 20.0, 80.0, 30.0),
}
}
fn textfield_snap(label: &str) -> ElementSnapshot {
ElementSnapshot {
role: "AXTextField".into(),
label: label.into(),
path: vec!["AXWindow:Document".into(), format!("AXTextField:{label}")],
bounds: (10.0, 60.0, 200.0, 24.0),
}
}
#[test]
fn track_new_element_assigns_sequential_id() {
let mut store = RefStore::new();
let r1 = store.track(button_snap("Save"));
let r2 = store.track(button_snap("Cancel"));
assert_eq!(r1.id, 1);
assert_eq!(r2.id, 2);
}
#[test]
fn track_same_fingerprint_returns_existing_id() {
let mut store = RefStore::new();
let first = store.track(button_snap("Save"));
let second = store.track(button_snap("Save"));
assert_eq!(first.id, second.id);
assert_eq!(store.len(), 1);
}
#[test]
fn track_updates_bounds_on_revisit() {
let mut store = RefStore::new();
store.track(button_snap("Save"));
let moved = ElementSnapshot {
bounds: (999.0, 888.0, 80.0, 30.0),
..button_snap("Save")
};
let updated = store.track(moved);
assert_eq!(updated.bounds.0, 999.0);
assert_eq!(updated.bounds.1, 888.0);
}
#[test]
fn resolve_returns_none_for_unknown_id() {
let store = RefStore::new();
let result = store.resolve(42);
assert!(result.is_none());
}
#[test]
fn resolve_returns_tracked_element() {
let mut store = RefStore::new();
let tracked = store.track(button_snap("OK"));
let resolved = store.resolve(tracked.id);
assert!(resolved.is_some());
assert_eq!(resolved.unwrap().label, "OK");
assert_eq!(resolved.unwrap().role, "AXButton");
}
#[test]
fn find_by_label_is_case_insensitive() {
let mut store = RefStore::new();
store.track(button_snap("Save Document"));
store.track(button_snap("Cancel"));
store.track(textfield_snap("save path"));
let mut results = store.find_by_label("save");
results.sort_by_key(|r| r.id);
assert_eq!(results.len(), 2);
assert!(results.iter().any(|r| r.label == "Save Document"));
assert!(results.iter().any(|r| r.label == "save path"));
}
#[test]
fn find_by_label_returns_empty_when_no_match() {
let mut store = RefStore::new();
store.track(button_snap("OK"));
let results = store.find_by_label("nonexistent");
assert!(results.is_empty());
}
#[test]
fn refresh_marks_missing_elements_not_alive() {
let mut store = RefStore::new();
let save_ref = store.track(button_snap("Save"));
let cancel_ref = store.track(button_snap("Cancel"));
store.refresh(&[button_snap("Save")]);
assert!(store.resolve(save_ref.id).unwrap().alive);
assert!(!store.resolve(cancel_ref.id).unwrap().alive);
}
#[test]
fn refresh_increments_missed_refreshes_for_absent_elements() {
let mut store = RefStore::new();
let r = store.track(button_snap("Vanished"));
store.refresh(&[]);
store.refresh(&[]);
assert_eq!(store.resolve(r.id).unwrap().missed_refreshes, 2);
}
#[test]
fn refresh_resets_missed_refreshes_when_element_reappears() {
let mut store = RefStore::new();
let r = store.track(button_snap("Flicker"));
store.refresh(&[]);
store.refresh(&[button_snap("Flicker")]);
assert_eq!(store.resolve(r.id).unwrap().missed_refreshes, 0);
assert!(store.resolve(r.id).unwrap().alive);
}
#[test]
fn gc_removes_elements_exceeding_threshold() {
let mut store = RefStore::new();
let r = store.track(button_snap("Stale"));
for _ in 0..GC_THRESHOLD {
store.refresh(&[]);
}
let removed = store.gc();
assert_eq!(removed, 1);
assert!(store.is_empty());
assert!(store.resolve(r.id).is_none());
}
#[test]
fn gc_does_not_remove_live_or_below_threshold_elements() {
let mut store = RefStore::new();
let live = store.track(button_snap("Active"));
let near_stale = store.track(button_snap("NearStale"));
store.refresh(&[button_snap("Active")]);
let removed = store.gc();
assert_eq!(removed, 0);
assert!(store.resolve(live.id).is_some());
assert!(store.resolve(near_stale.id).is_some());
}
#[test]
fn fingerprint_differs_for_different_role() {
let fp1 = fingerprint("AXButton", "OK", &["AXWindow".to_string()]);
let fp2 = fingerprint("AXTextField", "OK", &["AXWindow".to_string()]);
assert_ne!(fp1, fp2);
}
#[test]
fn fingerprint_differs_for_different_label() {
let fp1 = fingerprint("AXButton", "Save", &["AXWindow".to_string()]);
let fp2 = fingerprint("AXButton", "Cancel", &["AXWindow".to_string()]);
assert_ne!(fp1, fp2);
}
#[test]
fn fingerprint_differs_for_different_path() {
let fp1 = fingerprint("AXButton", "OK", &["AXWindow:A".to_string()]);
let fp2 = fingerprint("AXButton", "OK", &["AXWindow:B".to_string()]);
assert_ne!(fp1, fp2);
}
#[test]
fn fingerprint_is_stable_for_same_inputs() {
let fp1 = fingerprint(
"AXButton",
"Save",
&["AXWindow".to_string(), "AXToolbar".to_string()],
);
let fp2 = fingerprint(
"AXButton",
"Save",
&["AXWindow".to_string(), "AXToolbar".to_string()],
);
assert_eq!(fp1, fp2);
}
#[test]
fn ref_name_formats_correctly() {
let elem_ref = ElementRef {
id: 7,
role: "AXButton".into(),
label: "Save".into(),
path: vec![],
bounds: (0.0, 0.0, 0.0, 0.0),
fingerprint: 0,
alive: true,
last_seen_ns: 0,
missed_refreshes: 0,
};
assert_eq!(elem_ref.ref_name(), "ref_7");
}
#[test]
fn alive_count_tracks_only_live_elements() {
let mut store = RefStore::new();
store.track(button_snap("A"));
store.track(button_snap("B"));
store.track(button_snap("C"));
store.refresh(&[button_snap("A"), button_snap("B")]);
assert_eq!(store.alive_count(), 2);
assert_eq!(store.len(), 3);
}
#[test]
fn store_default_is_empty() {
let store = RefStore::default();
assert!(store.is_empty());
assert_eq!(store.alive_count(), 0);
}
#[test]
fn gc_cleans_secondary_fingerprint_index() {
let mut store = RefStore::new();
let snap = button_snap("Ephemeral");
store.track(snap.clone());
for _ in 0..GC_THRESHOLD {
store.refresh(&[]);
}
store.gc();
let retracked = store.track(snap);
assert_eq!(retracked.id, 2); }
}