1use std::collections::HashMap;
22use std::path::PathBuf;
23use std::sync::{Mutex, MutexGuard, PoisonError};
24use std::time::Duration;
25
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29const DEFAULT_TTL: Duration = Duration::from_secs(30);
34
35const MAX_WINDOWS: usize = 256;
43
44#[derive(Debug, Clone, Deserialize)]
50pub struct RegisterRequest {
51 pub key: String,
53 #[serde(default)]
55 pub folders: Vec<PathBuf>,
56 #[serde(default)]
58 pub repo: Option<String>,
59 #[serde(default)]
61 pub title: Option<String>,
62 #[serde(default)]
64 pub pid: Option<u32>,
65}
66
67#[derive(Debug, Clone, Serialize)]
70pub struct WindowEntry {
71 pub key: String,
73 pub folders: Vec<PathBuf>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub repo: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub title: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub pid: Option<u32>,
84 pub last_seen: DateTime<Utc>,
86}
87
88pub struct WorktreesRegistry {
92 windows: Mutex<HashMap<String, WindowEntry>>,
94 ttl: Duration,
96}
97
98impl WorktreesRegistry {
99 #[must_use]
101 pub fn new() -> Self {
102 Self {
103 windows: Mutex::new(HashMap::new()),
104 ttl: DEFAULT_TTL,
105 }
106 }
107
108 fn lock(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
111 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
112 }
113
114 pub fn register(&self, req: RegisterRequest) {
119 let now = Utc::now();
120 let mut windows = self.lock();
121 reap(&mut windows, self.ttl, now);
122 if !windows.contains_key(&req.key) && windows.len() >= MAX_WINDOWS {
125 evict_oldest(&mut windows);
126 }
127 windows.insert(
128 req.key.clone(),
129 WindowEntry {
130 key: req.key,
131 folders: req.folders,
132 repo: req.repo,
133 title: req.title,
134 pid: req.pid,
135 last_seen: now,
136 },
137 );
138 }
139
140 pub fn heartbeat(&self, key: &str) -> bool {
145 let now = Utc::now();
146 let mut windows = self.lock();
147 reap(&mut windows, self.ttl, now);
148 match windows.get_mut(key) {
149 Some(entry) => {
150 entry.last_seen = now;
151 true
152 }
153 None => false,
154 }
155 }
156
157 pub fn unregister(&self, key: &str) -> bool {
159 let now = Utc::now();
160 let mut windows = self.lock();
161 let removed = windows.remove(key).is_some();
162 reap(&mut windows, self.ttl, now);
163 removed
164 }
165
166 pub fn list(&self) -> Vec<WindowEntry> {
169 let now = Utc::now();
170 let mut windows = self.lock();
171 reap(&mut windows, self.ttl, now);
172 sorted_entries(&windows)
173 }
174
175 pub fn first_folder(&self, key: &str) -> Option<PathBuf> {
180 let windows = self.lock();
181 windows.get(key).and_then(|e| e.folders.first().cloned())
182 }
183}
184
185impl Default for WorktreesRegistry {
186 fn default() -> Self {
187 Self::new()
188 }
189}
190
191fn reap(windows: &mut HashMap<String, WindowEntry>, ttl: Duration, now: DateTime<Utc>) {
194 let max_age = ttl.as_secs() as i64;
195 windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
196}
197
198fn evict_oldest(windows: &mut HashMap<String, WindowEntry>) {
203 let oldest = windows
204 .values()
205 .min_by(|a, b| {
206 a.last_seen
207 .cmp(&b.last_seen)
208 .then_with(|| a.key.cmp(&b.key))
209 })
210 .map(|e| e.key.clone());
211 if let Some(key) = oldest {
212 windows.remove(&key);
213 }
214}
215
216fn sorted_entries(windows: &HashMap<String, WindowEntry>) -> Vec<WindowEntry> {
219 let mut entries: Vec<WindowEntry> = windows.values().cloned().collect();
220 entries.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.key.cmp(&b.key)));
221 entries
222}
223
224#[cfg(test)]
225#[allow(clippy::unwrap_used, clippy::expect_used)]
226mod tests {
227 use super::*;
228
229 fn register_request(key: &str, repo: Option<&str>, folder: &str) -> RegisterRequest {
230 RegisterRequest {
231 key: key.to_string(),
232 folders: vec![PathBuf::from(folder)],
233 repo: repo.map(str::to_string),
234 title: Some(format!("{key}-title")),
235 pid: Some(1234),
236 }
237 }
238
239 #[test]
240 fn list_is_empty_initially() {
241 let reg = WorktreesRegistry::new();
242 assert!(reg.list().is_empty());
243 }
244
245 #[test]
246 fn register_then_list_round_trips() {
247 let reg = WorktreesRegistry::new();
248 reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
249 let windows = reg.list();
250 assert_eq!(windows.len(), 1);
251 assert_eq!(windows[0].key, "w1");
252 assert_eq!(windows[0].repo.as_deref(), Some("repo-a"));
253 }
254
255 #[test]
256 fn register_is_idempotent_upsert() {
257 let reg = WorktreesRegistry::new();
258 reg.register(register_request("w1", Some("repo-a"), "/tmp/a"));
259 reg.register(register_request("w1", Some("repo-b"), "/tmp/b"));
261 let windows = reg.list();
262 assert_eq!(windows.len(), 1);
263 assert_eq!(windows[0].repo.as_deref(), Some("repo-b"));
264 }
265
266 #[test]
267 fn heartbeat_reports_known_and_unknown() {
268 let reg = WorktreesRegistry::new();
269 assert!(!reg.heartbeat("w1"));
271 reg.register(register_request("w1", None, "/tmp/a"));
272 assert!(reg.heartbeat("w1"));
273 }
274
275 #[test]
276 fn unregister_removes() {
277 let reg = WorktreesRegistry::new();
278 reg.register(register_request("w1", None, "/tmp/a"));
279 assert!(reg.unregister("w1"));
280 assert!(!reg.unregister("w1"));
282 }
283
284 #[test]
285 fn first_folder_returns_first_folder_or_none() {
286 let reg = WorktreesRegistry::new();
287 assert!(reg.first_folder("missing").is_none());
289 reg.register(register_request("w1", None, "/tmp/a"));
290 assert_eq!(reg.first_folder("w1"), Some(PathBuf::from("/tmp/a")));
291 reg.register(RegisterRequest {
293 key: "w2".to_string(),
294 folders: vec![],
295 repo: None,
296 title: None,
297 pid: None,
298 });
299 assert!(reg.first_folder("w2").is_none());
300 }
301
302 #[test]
303 fn reap_evicts_only_stale_entries() {
304 let now = Utc::now();
305 let mut windows = HashMap::new();
306 windows.insert(
307 "fresh".to_string(),
308 WindowEntry {
309 key: "fresh".to_string(),
310 folders: vec![],
311 repo: None,
312 title: None,
313 pid: None,
314 last_seen: now - chrono::Duration::seconds(5),
315 },
316 );
317 windows.insert(
318 "stale".to_string(),
319 WindowEntry {
320 key: "stale".to_string(),
321 folders: vec![],
322 repo: None,
323 title: None,
324 pid: None,
325 last_seen: now - chrono::Duration::seconds(120),
326 },
327 );
328 reap(&mut windows, DEFAULT_TTL, now);
329 assert!(windows.contains_key("fresh"));
330 assert!(!windows.contains_key("stale"));
331 }
332
333 fn entry_at(key: &str, last_seen: DateTime<Utc>) -> WindowEntry {
336 WindowEntry {
337 key: key.to_string(),
338 folders: vec![],
339 repo: None,
340 title: None,
341 pid: None,
342 last_seen,
343 }
344 }
345
346 #[test]
347 fn evict_oldest_removes_oldest_with_key_tiebreak() {
348 let now = Utc::now();
349 let mut windows = HashMap::new();
350 windows.insert("young".to_string(), entry_at("young", now));
351 windows.insert(
352 "old-b".to_string(),
353 entry_at("old-b", now - chrono::Duration::seconds(10)),
354 );
355 windows.insert(
356 "old-a".to_string(),
357 entry_at("old-a", now - chrono::Duration::seconds(10)),
358 );
359 evict_oldest(&mut windows);
361 assert!(!windows.contains_key("old-a"));
362 assert!(windows.contains_key("old-b"));
363 assert!(windows.contains_key("young"));
364 let mut empty: HashMap<String, WindowEntry> = HashMap::new();
366 evict_oldest(&mut empty);
367 assert!(empty.is_empty());
368 }
369
370 #[test]
371 fn register_at_cap_evicts_only_the_oldest() {
372 let reg = WorktreesRegistry::new();
373 {
377 let mut windows = reg.lock();
378 let base = Utc::now();
379 for i in 0..MAX_WINDOWS {
380 let key = format!("w{i:03}");
381 windows.insert(
382 key.clone(),
383 entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
384 );
385 }
386 }
387 reg.register(register_request("fresh", None, "/tmp/f"));
389 let windows = reg.lock();
390 assert_eq!(windows.len(), MAX_WINDOWS);
391 assert!(windows.contains_key("fresh"));
392 assert!(!windows.contains_key(&format!("w{:03}", MAX_WINDOWS - 1)));
393 assert!(windows.contains_key("w000"));
394 }
395
396 #[test]
397 fn register_upsert_at_cap_does_not_evict() {
398 let reg = WorktreesRegistry::new();
399 {
400 let mut windows = reg.lock();
401 let base = Utc::now();
402 for i in 0..MAX_WINDOWS {
403 let key = format!("w{i:03}");
404 windows.insert(
405 key.clone(),
406 entry_at(&key, base - chrono::Duration::milliseconds(i as i64)),
407 );
408 }
409 }
410 let oldest = format!("w{:03}", MAX_WINDOWS - 1);
413 reg.register(register_request(&oldest, Some("r"), "/tmp/a"));
414 let windows = reg.lock();
415 assert_eq!(windows.len(), MAX_WINDOWS);
416 assert!(windows.contains_key(&oldest));
417 assert!(windows.contains_key("w000"));
418 }
419
420 #[test]
421 fn sorted_entries_orders_by_repo_then_key() {
422 let now = Utc::now();
423 let mut windows = HashMap::new();
424 for (key, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
425 windows.insert(
426 key.to_string(),
427 WindowEntry {
428 key: key.to_string(),
429 folders: vec![],
430 repo: Some(repo.to_string()),
431 title: None,
432 pid: None,
433 last_seen: now,
434 },
435 );
436 }
437 let entries = sorted_entries(&windows);
438 let ordered: Vec<(&str, &str)> = entries
439 .iter()
440 .map(|e| (e.key.as_str(), e.repo.as_deref().unwrap()))
441 .collect();
442 assert_eq!(
443 ordered,
444 vec![("m", "repo-a"), ("z", "repo-a"), ("a", "repo-b")]
445 );
446 }
447
448 #[test]
449 fn default_constructs_an_empty_registry() {
450 let reg = WorktreesRegistry::default();
451 assert!(reg.lock().is_empty());
452 }
453}