use std::collections::BTreeMap;
use std::time::Instant;
use crate::activity::{ActivityConfig, WtActivity, WtLifecycle};
use crate::repo::watch::WtId;
#[derive(Debug, Clone)]
pub struct ActivityTracker {
cfg: ActivityConfig,
wts: BTreeMap<WtId, WtLifecycle>,
}
impl ActivityTracker {
pub fn new(cfg: ActivityConfig) -> Self {
Self {
cfg,
wts: BTreeMap::new(),
}
}
pub fn touch(&mut self, wt: impl Into<WtId>, now: Instant) -> bool {
let wt = wt.into();
if let Some(lc) = self.wts.get_mut(&wt) {
let reactivated = lc.state() == WtActivity::Deactivated;
lc.touch(now);
return reactivated;
}
self.wts.insert(wt, WtLifecycle::new(now, self.cfg));
false
}
pub fn tick(&mut self, now: Instant) -> Vec<WtId> {
let mut newly_deactivated = Vec::new();
for (wt, lc) in self.wts.iter_mut() {
let was_deactivated = lc.state() == WtActivity::Deactivated;
let post = lc.tick(now);
if post == WtActivity::Deactivated && !was_deactivated {
newly_deactivated.push(wt.clone());
}
}
newly_deactivated
}
pub fn state(&self, wt: &WtId) -> Option<WtActivity> {
self.wts.get(wt).map(WtLifecycle::state)
}
pub fn tracked(&self) -> usize {
self.wts.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
fn cfg() -> ActivityConfig {
ActivityConfig {
idle_after: Duration::from_secs(10),
deactivate_after: Duration::from_secs(20),
}
}
fn wt(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn touch_creates_active_first_seen_returns_false() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
assert!(!t.touch(wt("/r/a"), t0));
assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Active));
assert_eq!(t.tracked(), 1);
assert!(!t.touch(wt("/r/a"), t0 + Duration::from_secs(1)));
}
#[test]
fn tick_advances_active_idle_deactivated_and_emits_edge_once() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
t.touch(wt("/r/a"), t0);
assert!(t.tick(t0 + Duration::from_secs(5)).is_empty());
assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Active));
assert!(t.tick(t0 + Duration::from_secs(12)).is_empty());
assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Idle));
let dz = t.tick(t0 + Duration::from_secs(31));
assert_eq!(dz, vec![wt("/r/a")]);
assert!(
t.tick(t0 + Duration::from_secs(40)).is_empty(),
"Deactivated edge must fire exactly once"
);
assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Deactivated));
}
#[test]
fn touch_after_deactivated_is_the_reactivation_edge() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
t.touch(wt("/r/a"), t0);
t.tick(t0 + Duration::from_secs(31)); assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Deactivated));
assert!(
t.touch(wt("/r/a"), t0 + Duration::from_secs(35)),
"Deactivated→Active is the re-activation edge"
);
assert_eq!(t.state(&wt("/r/a")), Some(WtActivity::Active));
assert!(!t.touch(wt("/r/a"), t0 + Duration::from_secs(36)));
}
#[test]
fn per_wt_isolation_independent_timelines() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
t.touch(wt("/r/v"), t0);
t.touch(wt("/r/w"), t0);
t.touch(wt("/r/w"), t0 + Duration::from_secs(25));
let dz = t.tick(t0 + Duration::from_secs(31));
assert_eq!(dz, vec![wt("/r/v")]);
assert_eq!(t.state(&wt("/r/v")), Some(WtActivity::Deactivated));
assert_eq!(t.state(&wt("/r/w")), Some(WtActivity::Active));
}
#[test]
fn tick_emits_deactivations_in_deterministic_sorted_order() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
t.touch(wt("/r/z"), t0);
t.touch(wt("/r/a"), t0);
t.touch(wt("/r/m"), t0);
let dz = t.tick(t0 + Duration::from_secs(31));
assert_eq!(
dz,
vec![wt("/r/a"), wt("/r/m"), wt("/r/z")],
"BTreeMap ⇒ sorted WtId order regardless of touch order"
);
}
#[test]
fn untracked_state_is_none_and_no_phantom_edges() {
let mut t = ActivityTracker::new(cfg());
let t0 = Instant::now();
assert_eq!(t.state(&wt("/never")), None);
assert!(t.tick(t0 + Duration::from_secs(99)).is_empty());
assert_eq!(t.tracked(), 0);
}
}