1use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::{Mutex, MutexGuard, PoisonError};
32use std::time::Duration;
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37pub mod watcher;
38
39const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
49
50const ENDED_SESSION_TTL: Duration = Duration::from_secs(10);
56
57const DEFAULT_WINDOW_TTL: Duration = Duration::from_secs(30);
62
63const MAX_SESSIONS: usize = 512;
69
70const MAX_WINDOWS: usize = 256;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum SessionState {
83 Starting,
85 Working,
88 Idle,
90 WaitingForInput,
92 WaitingForPermission,
94 Ended,
97}
98
99impl SessionState {
100 #[must_use]
118 pub fn for_event(event: &SessionEvent, current: Option<Self>) -> Self {
119 match event {
120 SessionEvent::SessionStart => Self::Starting,
121 SessionEvent::UserPromptSubmit
122 | SessionEvent::PreToolUse
123 | SessionEvent::PostToolUse
124 | SessionEvent::TranscriptGrew => Self::Working,
125 SessionEvent::Stop => Self::Idle,
126 SessionEvent::Notification(NotificationKind::PermissionPrompt) => {
127 Self::WaitingForPermission
128 }
129 SessionEvent::Notification(
130 NotificationKind::IdlePrompt | NotificationKind::AgentNeedsInput,
131 ) => Self::WaitingForInput,
132 SessionEvent::Notification(NotificationKind::Other)
136 | SessionEvent::TranscriptDiscovered => current.unwrap_or(Self::Idle),
137 }
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum NotificationKind {
149 PermissionPrompt,
152 IdlePrompt,
154 AgentNeedsInput,
156 Other,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum SessionEvent {
168 SessionStart,
170 UserPromptSubmit,
172 PreToolUse,
174 PostToolUse,
176 Stop,
178 Notification(NotificationKind),
180 TranscriptGrew,
183 TranscriptDiscovered,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(tag = "kind", rename_all = "snake_case")]
199pub enum Source {
200 Terminal,
202 VsCode {
205 window_key: String,
207 },
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ObserveRequest {
218 pub session_id: String,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub cwd: Option<PathBuf>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub transcript_path: Option<PathBuf>,
228 pub event: SessionEvent,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub repo: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub model: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct WindowReport {
247 pub key: String,
249 #[serde(default)]
251 pub folders: Vec<PathBuf>,
252 #[serde(default)]
254 pub tabs: usize,
255 #[serde(default)]
257 pub terminals: usize,
258}
259
260impl WindowReport {
261 #[must_use]
264 fn has_embedding(&self) -> bool {
265 self.tabs > 0 || self.terminals > 0
266 }
267}
268
269#[derive(Debug, Clone, Serialize)]
276pub struct SessionEntry {
277 pub session_id: String,
279 #[serde(skip_serializing_if = "Option::is_none")]
281 pub cwd: Option<PathBuf>,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub transcript_path: Option<PathBuf>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub repo: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
290 pub model: Option<String>,
291 pub state: SessionState,
293 pub source: Source,
295 pub last_event: SessionEvent,
297 pub started_at: DateTime<Utc>,
299 pub last_seen: DateTime<Utc>,
301}
302
303#[derive(Debug, Clone)]
305struct WindowEntry {
306 report: WindowReport,
308 last_seen: DateTime<Utc>,
310}
311
312pub struct SessionsRegistry {
318 sessions: Mutex<HashMap<String, SessionEntry>>,
320 windows: Mutex<HashMap<String, WindowEntry>>,
323 session_ttl: Duration,
325 ended_ttl: Duration,
327 window_ttl: Duration,
329}
330
331impl SessionsRegistry {
332 #[must_use]
334 pub fn new() -> Self {
335 Self {
336 sessions: Mutex::new(HashMap::new()),
337 windows: Mutex::new(HashMap::new()),
338 session_ttl: DEFAULT_SESSION_TTL,
339 ended_ttl: ENDED_SESSION_TTL,
340 window_ttl: DEFAULT_WINDOW_TTL,
341 }
342 }
343
344 fn lock_sessions(&self) -> MutexGuard<'_, HashMap<String, SessionEntry>> {
347 self.sessions.lock().unwrap_or_else(PoisonError::into_inner)
348 }
349
350 fn lock_windows(&self) -> MutexGuard<'_, HashMap<String, WindowEntry>> {
352 self.windows.lock().unwrap_or_else(PoisonError::into_inner)
353 }
354
355 pub fn observe(&self, req: ObserveRequest) {
364 let now = Utc::now();
365 let mut sessions = self.lock_sessions();
366 reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
367 if let Some(entry) = sessions.get_mut(&req.session_id) {
368 entry.state = SessionState::for_event(&req.event, Some(entry.state));
369 entry.last_event = req.event;
370 entry.last_seen = now;
371 fill(&mut entry.cwd, req.cwd);
372 fill(&mut entry.transcript_path, req.transcript_path);
373 fill(&mut entry.repo, req.repo);
374 fill(&mut entry.model, req.model);
375 } else {
376 if sessions.len() >= MAX_SESSIONS {
377 evict_oldest_session(&mut sessions);
378 }
379 let state = SessionState::for_event(&req.event, None);
380 sessions.insert(
381 req.session_id.clone(),
382 SessionEntry {
383 session_id: req.session_id,
384 cwd: req.cwd,
385 transcript_path: req.transcript_path,
386 repo: req.repo,
387 model: req.model,
388 state,
389 source: Source::Terminal,
390 last_event: req.event,
391 started_at: now,
392 last_seen: now,
393 },
394 );
395 }
396 }
397
398 pub fn end(&self, session_id: &str, _reason: Option<&str>) -> bool {
403 let now = Utc::now();
404 let mut sessions = self.lock_sessions();
405 reap_sessions(&mut sessions, self.session_ttl, self.ended_ttl, now);
406 match sessions.get_mut(session_id) {
407 Some(entry) => {
408 entry.state = SessionState::Ended;
409 entry.last_event = SessionEvent::Stop;
410 entry.last_seen = now;
411 true
412 }
413 None => false,
414 }
415 }
416
417 pub fn report_window(&self, report: WindowReport) {
420 let now = Utc::now();
421 let mut windows = self.lock_windows();
422 reap_windows(&mut windows, self.window_ttl, now);
423 if !windows.contains_key(&report.key) && windows.len() >= MAX_WINDOWS {
424 evict_oldest_window(&mut windows);
425 }
426 windows.insert(
427 report.key.clone(),
428 WindowEntry {
429 report,
430 last_seen: now,
431 },
432 );
433 }
434
435 pub fn unregister_window(&self, key: &str) -> bool {
438 let mut windows = self.lock_windows();
439 windows.remove(key).is_some()
440 }
441
442 pub fn list(&self) -> Vec<SessionEntry> {
451 let now = Utc::now();
452 let mut sessions: Vec<SessionEntry> = {
453 let mut guard = self.lock_sessions();
454 reap_sessions(&mut guard, self.session_ttl, self.ended_ttl, now);
455 guard.values().cloned().collect()
456 };
457 let windows: Vec<WindowReport> = {
458 let mut guard = self.lock_windows();
459 reap_windows(&mut guard, self.window_ttl, now);
460 guard
461 .values()
462 .map(|e| e.report.clone())
463 .filter(WindowReport::has_embedding)
464 .collect()
465 };
466 for session in &mut sessions {
467 session.source = resolve_source(session.cwd.as_deref(), &windows);
468 }
469 sessions.sort_by(|a, b| {
470 a.repo
471 .cmp(&b.repo)
472 .then_with(|| a.session_id.cmp(&b.session_id))
473 });
474 sessions
475 }
476
477 pub fn focus_folder(&self, session_id: &str) -> Option<PathBuf> {
482 let cwd = {
483 let sessions = self.lock_sessions();
484 sessions.get(session_id).and_then(|e| e.cwd.clone())
485 }?;
486 let now = Utc::now();
487 let mut windows = self.lock_windows();
488 reap_windows(&mut windows, self.window_ttl, now);
489 windows
490 .values()
491 .map(|e| &e.report)
492 .filter(|w| w.has_embedding())
493 .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
494 .find_map(|w| w.folders.first().cloned())
495 }
496}
497
498impl Default for SessionsRegistry {
499 fn default() -> Self {
500 Self::new()
501 }
502}
503
504fn fill<T>(slot: &mut Option<T>, incoming: Option<T>) {
507 if let Some(value) = incoming {
508 *slot = Some(value);
509 }
510}
511
512fn resolve_source(cwd: Option<&Path>, windows: &[WindowReport]) -> Source {
519 let Some(cwd) = cwd else {
520 return Source::Terminal;
521 };
522 let matched = windows
523 .iter()
524 .filter(|w| w.folders.iter().any(|f| cwd.starts_with(f)))
525 .min_by(|a, b| a.key.cmp(&b.key));
526 match matched {
527 Some(window) => Source::VsCode {
528 window_key: window.key.clone(),
529 },
530 None => Source::Terminal,
531 }
532}
533
534fn reap_sessions(
539 sessions: &mut HashMap<String, SessionEntry>,
540 session_ttl: Duration,
541 ended_ttl: Duration,
542 now: DateTime<Utc>,
543) -> usize {
544 let session_max = session_ttl.as_secs() as i64;
545 let ended_max = ended_ttl.as_secs() as i64;
546 let before = sessions.len();
547 sessions.retain(|_, e| {
548 let max_age = if e.state == SessionState::Ended {
549 ended_max
550 } else {
551 session_max
552 };
553 (now - e.last_seen).num_seconds() <= max_age
554 });
555 before - sessions.len()
556}
557
558fn reap_windows(
560 windows: &mut HashMap<String, WindowEntry>,
561 ttl: Duration,
562 now: DateTime<Utc>,
563) -> usize {
564 let max_age = ttl.as_secs() as i64;
565 let before = windows.len();
566 windows.retain(|_, e| (now - e.last_seen).num_seconds() <= max_age);
567 before - windows.len()
568}
569
570fn evict_oldest_session(sessions: &mut HashMap<String, SessionEntry>) {
574 let oldest = sessions
575 .values()
576 .min_by(|a, b| {
577 a.last_seen
578 .cmp(&b.last_seen)
579 .then_with(|| a.session_id.cmp(&b.session_id))
580 })
581 .map(|e| e.session_id.clone());
582 if let Some(key) = oldest {
583 sessions.remove(&key);
584 }
585}
586
587fn evict_oldest_window(windows: &mut HashMap<String, WindowEntry>) {
590 let oldest = windows
591 .iter()
592 .min_by(|a, b| a.1.last_seen.cmp(&b.1.last_seen).then_with(|| a.0.cmp(b.0)))
593 .map(|(k, _)| k.clone());
594 if let Some(key) = oldest {
595 windows.remove(&key);
596 }
597}
598
599#[cfg(test)]
600#[allow(clippy::unwrap_used, clippy::expect_used)]
601mod tests {
602 use super::*;
603
604 fn observe_request(session_id: &str, event: SessionEvent, cwd: Option<&str>) -> ObserveRequest {
605 ObserveRequest {
606 session_id: session_id.to_string(),
607 cwd: cwd.map(PathBuf::from),
608 transcript_path: None,
609 event,
610 repo: None,
611 model: None,
612 }
613 }
614
615 #[test]
616 fn list_is_empty_initially() {
617 let reg = SessionsRegistry::new();
618 assert!(reg.list().is_empty());
619 }
620
621 #[test]
622 fn observe_then_list_round_trips_and_infers_state() {
623 let reg = SessionsRegistry::new();
624 reg.observe(observe_request(
625 "s1",
626 SessionEvent::SessionStart,
627 Some("/tmp/a"),
628 ));
629 let sessions = reg.list();
630 assert_eq!(sessions.len(), 1);
631 assert_eq!(sessions[0].session_id, "s1");
632 assert_eq!(sessions[0].state, SessionState::Starting);
633 assert_eq!(sessions[0].source, Source::Terminal);
635 }
636
637 #[test]
638 fn observe_is_idempotent_upsert_advancing_state() {
639 let reg = SessionsRegistry::new();
640 reg.observe(observe_request(
641 "s1",
642 SessionEvent::SessionStart,
643 Some("/tmp/a"),
644 ));
645 reg.observe(observe_request("s1", SessionEvent::PreToolUse, None));
646 let sessions = reg.list();
647 assert_eq!(sessions.len(), 1, "same session_id upserts, not duplicates");
648 assert_eq!(sessions[0].state, SessionState::Working);
649 assert_eq!(sessions[0].cwd.as_deref(), Some(Path::new("/tmp/a")));
651 }
652
653 #[test]
654 fn state_machine_covers_every_event() {
655 use NotificationKind::*;
656 use SessionEvent::*;
657 let cases = [
658 (SessionStart, SessionState::Starting),
659 (UserPromptSubmit, SessionState::Working),
660 (PreToolUse, SessionState::Working),
661 (PostToolUse, SessionState::Working),
662 (Stop, SessionState::Idle),
663 (
664 Notification(PermissionPrompt),
665 SessionState::WaitingForPermission,
666 ),
667 (Notification(IdlePrompt), SessionState::WaitingForInput),
668 (Notification(AgentNeedsInput), SessionState::WaitingForInput),
669 (TranscriptGrew, SessionState::Working),
670 (TranscriptDiscovered, SessionState::Idle),
671 ];
672 for (event, expected) in cases {
673 assert_eq!(
674 SessionState::for_event(&event, None),
675 expected,
676 "event {event:?}"
677 );
678 }
679 assert_eq!(
681 SessionState::for_event(&Notification(Other), Some(SessionState::Working)),
682 SessionState::Working
683 );
684 assert_eq!(
686 SessionState::for_event(&TranscriptDiscovered, Some(SessionState::Working)),
687 SessionState::Working
688 );
689 }
690
691 #[test]
692 fn end_marks_ended_and_reaps_quickly() {
693 let reg = SessionsRegistry::new();
694 reg.observe(observe_request(
695 "s1",
696 SessionEvent::PreToolUse,
697 Some("/tmp/a"),
698 ));
699 assert!(reg.end("s1", Some("clear")));
700 assert!(!reg.end("ghost", None));
702 let sessions = reg.list();
703 assert_eq!(sessions.len(), 1);
704 assert_eq!(sessions[0].state, SessionState::Ended);
705 {
707 let mut guard = reg.lock_sessions();
708 guard.get_mut("s1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(30);
709 }
710 assert!(reg.list().is_empty(), "ended entry reaps after ended TTL");
711 }
712
713 #[test]
714 fn stale_working_session_reaps_but_recent_survives() {
715 let reg = SessionsRegistry::new();
716 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
717 reg.observe(observe_request("stale", SessionEvent::PreToolUse, None));
718 {
719 let mut guard = reg.lock_sessions();
720 guard.get_mut("stale").unwrap().last_seen =
721 Utc::now() - chrono::Duration::seconds(1000);
722 }
723 let ids: Vec<String> = reg.list().into_iter().map(|s| s.session_id).collect();
724 assert_eq!(ids, vec!["fresh".to_string()]);
725 }
726
727 #[test]
728 fn source_is_vscode_when_cwd_is_under_a_reporting_window() {
729 let reg = SessionsRegistry::new();
730 reg.observe(observe_request(
731 "s1",
732 SessionEvent::PreToolUse,
733 Some("/home/me/proj/sub"),
734 ));
735 reg.report_window(WindowReport {
737 key: "w1".to_string(),
738 folders: vec![PathBuf::from("/home/me/proj")],
739 tabs: 1,
740 terminals: 0,
741 });
742 let sessions = reg.list();
743 assert_eq!(
744 sessions[0].source,
745 Source::VsCode {
746 window_key: "w1".to_string()
747 }
748 );
749 }
750
751 #[test]
752 fn source_is_terminal_when_window_has_no_embedding() {
753 let reg = SessionsRegistry::new();
754 reg.observe(observe_request(
755 "s1",
756 SessionEvent::PreToolUse,
757 Some("/home/me/proj"),
758 ));
759 reg.report_window(WindowReport {
761 key: "w1".to_string(),
762 folders: vec![PathBuf::from("/home/me/proj")],
763 tabs: 0,
764 terminals: 0,
765 });
766 assert_eq!(reg.list()[0].source, Source::Terminal);
767 }
768
769 #[test]
770 fn window_report_is_upsert_and_unregister_removes() {
771 let reg = SessionsRegistry::new();
772 reg.report_window(WindowReport {
773 key: "w1".to_string(),
774 folders: vec![PathBuf::from("/p")],
775 tabs: 1,
776 terminals: 0,
777 });
778 reg.report_window(WindowReport {
780 key: "w1".to_string(),
781 folders: vec![PathBuf::from("/p")],
782 tabs: 2,
783 terminals: 1,
784 });
785 assert!(reg.unregister_window("w1"));
786 assert!(!reg.unregister_window("w1"));
787 }
788
789 #[test]
790 fn stale_window_stops_tagging_source() {
791 let reg = SessionsRegistry::new();
792 reg.observe(observe_request(
793 "s1",
794 SessionEvent::PreToolUse,
795 Some("/p/sub"),
796 ));
797 reg.report_window(WindowReport {
798 key: "w1".to_string(),
799 folders: vec![PathBuf::from("/p")],
800 tabs: 1,
801 terminals: 0,
802 });
803 {
805 let mut guard = reg.lock_windows();
806 guard.get_mut("w1").unwrap().last_seen = Utc::now() - chrono::Duration::seconds(120);
807 }
808 assert_eq!(reg.list()[0].source, Source::Terminal);
809 }
810
811 #[test]
812 fn resolve_source_prefers_lowest_key_on_overlap() {
813 let windows = vec![
815 WindowReport {
816 key: "w2".to_string(),
817 folders: vec![PathBuf::from("/p")],
818 tabs: 1,
819 terminals: 0,
820 },
821 WindowReport {
822 key: "w1".to_string(),
823 folders: vec![PathBuf::from("/p")],
824 tabs: 1,
825 terminals: 0,
826 },
827 ];
828 assert_eq!(
829 resolve_source(Some(Path::new("/p/x")), &windows),
830 Source::VsCode {
831 window_key: "w1".to_string()
832 }
833 );
834 assert_eq!(resolve_source(None, &windows), Source::Terminal);
836 }
837
838 #[test]
839 fn focus_folder_resolves_matching_window_folder() {
840 let reg = SessionsRegistry::new();
841 reg.observe(observe_request(
842 "s1",
843 SessionEvent::PreToolUse,
844 Some("/home/me/proj/sub"),
845 ));
846 assert!(reg.focus_folder("s1").is_none(), "no window yet");
847 reg.report_window(WindowReport {
848 key: "w1".to_string(),
849 folders: vec![PathBuf::from("/home/me/proj")],
850 tabs: 1,
851 terminals: 0,
852 });
853 assert_eq!(reg.focus_folder("s1"), Some(PathBuf::from("/home/me/proj")));
854 assert!(reg.focus_folder("ghost").is_none());
856 }
857
858 #[test]
859 fn evict_oldest_session_drops_the_longest_silent() {
860 let now = Utc::now();
861 let mut sessions = HashMap::new();
862 for (id, age) in [("young", 0), ("old", 100), ("older", 200)] {
863 sessions.insert(
864 id.to_string(),
865 SessionEntry {
866 session_id: id.to_string(),
867 cwd: None,
868 transcript_path: None,
869 repo: None,
870 model: None,
871 state: SessionState::Working,
872 source: Source::Terminal,
873 last_event: SessionEvent::PreToolUse,
874 started_at: now,
875 last_seen: now - chrono::Duration::seconds(age),
876 },
877 );
878 }
879 evict_oldest_session(&mut sessions);
880 assert!(!sessions.contains_key("older"));
881 assert!(sessions.contains_key("young"));
882 assert!(sessions.contains_key("old"));
883 }
884
885 #[test]
886 fn list_sorts_by_repo_then_session_id() {
887 let reg = SessionsRegistry::new();
888 for (id, repo) in [("z", "repo-a"), ("a", "repo-b"), ("m", "repo-a")] {
889 reg.observe(ObserveRequest {
890 session_id: id.to_string(),
891 cwd: None,
892 transcript_path: None,
893 event: SessionEvent::PreToolUse,
894 repo: Some(repo.to_string()),
895 model: None,
896 });
897 }
898 let ordered: Vec<(String, String)> = reg
899 .list()
900 .into_iter()
901 .map(|s| (s.session_id, s.repo.unwrap()))
902 .collect();
903 assert_eq!(
904 ordered,
905 vec![
906 ("m".to_string(), "repo-a".to_string()),
907 ("z".to_string(), "repo-a".to_string()),
908 ("a".to_string(), "repo-b".to_string()),
909 ]
910 );
911 }
912
913 #[test]
914 fn serialized_session_shapes_are_stable() {
915 let reg = SessionsRegistry::new();
918 reg.observe(ObserveRequest {
919 session_id: "s1".to_string(),
920 cwd: Some(PathBuf::from("/p")),
921 transcript_path: None,
922 event: SessionEvent::Notification(NotificationKind::PermissionPrompt),
923 repo: Some("proj".to_string()),
924 model: None,
925 });
926 let value = serde_json::to_value(®.list()[0]).unwrap();
927 assert_eq!(value["state"], "waiting_for_permission");
928 assert_eq!(value["source"]["kind"], "terminal");
929 assert_eq!(value["repo"], "proj");
930 assert!(value.get("model").is_none());
932 assert!(value.get("transcript_path").is_none());
933 }
934
935 #[test]
936 fn default_constructs_an_empty_registry() {
937 let reg = SessionsRegistry::default();
938 assert!(reg.list().is_empty());
939 }
940
941 #[test]
942 fn fill_only_overwrites_with_a_present_value() {
943 let mut slot = Some("keep");
946 fill(&mut slot, None);
947 assert_eq!(slot, Some("keep"));
948 fill(&mut slot, Some("new"));
949 assert_eq!(slot, Some("new"));
950 let mut empty: Option<&str> = None;
952 fill(&mut empty, Some("filled"));
953 assert_eq!(empty, Some("filled"));
954 }
955
956 #[test]
957 fn observe_at_session_cap_evicts_the_longest_silent() {
958 let reg = SessionsRegistry::new();
959 {
962 let mut sessions = reg.lock_sessions();
963 let base = Utc::now();
964 for i in 0..MAX_SESSIONS {
965 let id = format!("s{i:04}");
966 sessions.insert(
967 id.clone(),
968 SessionEntry {
969 session_id: id.clone(),
970 cwd: None,
971 transcript_path: None,
972 repo: None,
973 model: None,
974 state: SessionState::Working,
975 source: Source::Terminal,
976 last_event: SessionEvent::PreToolUse,
977 started_at: base,
978 last_seen: base - chrono::Duration::milliseconds(i as i64),
979 },
980 );
981 }
982 }
983 reg.observe(observe_request("fresh", SessionEvent::PreToolUse, None));
985 let sessions = reg.lock_sessions();
986 assert_eq!(sessions.len(), MAX_SESSIONS);
987 assert!(sessions.contains_key("fresh"));
988 assert!(!sessions.contains_key(&format!("s{:04}", MAX_SESSIONS - 1)));
989 assert!(sessions.contains_key("s0000"));
990 }
991
992 #[test]
993 fn report_window_at_cap_evicts_the_longest_silent() {
994 let reg = SessionsRegistry::new();
995 {
996 let mut windows = reg.lock_windows();
997 let base = Utc::now();
998 for i in 0..MAX_WINDOWS {
999 let key = format!("w{i:04}");
1000 windows.insert(
1001 key.clone(),
1002 WindowEntry {
1003 report: WindowReport {
1004 key: key.clone(),
1005 folders: vec![],
1006 tabs: 1,
1007 terminals: 0,
1008 },
1009 last_seen: base - chrono::Duration::milliseconds(i as i64),
1010 },
1011 );
1012 }
1013 }
1014 reg.report_window(WindowReport {
1015 key: "fresh".to_string(),
1016 folders: vec![],
1017 tabs: 1,
1018 terminals: 0,
1019 });
1020 let windows = reg.lock_windows();
1021 assert_eq!(windows.len(), MAX_WINDOWS);
1022 assert!(windows.contains_key("fresh"));
1023 assert!(!windows.contains_key(&format!("w{:04}", MAX_WINDOWS - 1)));
1024 assert!(windows.contains_key("w0000"));
1025 }
1026
1027 #[test]
1028 fn evict_oldest_window_breaks_ties_by_key() {
1029 let now = Utc::now();
1030 let mut windows = HashMap::new();
1031 let at = |key: &str, secs: i64| WindowEntry {
1032 report: WindowReport {
1033 key: key.to_string(),
1034 folders: vec![],
1035 tabs: 1,
1036 terminals: 0,
1037 },
1038 last_seen: now - chrono::Duration::seconds(secs),
1039 };
1040 windows.insert("young".to_string(), at("young", 0));
1041 windows.insert("old-b".to_string(), at("old-b", 10));
1042 windows.insert("old-a".to_string(), at("old-a", 10));
1043 evict_oldest_window(&mut windows);
1045 assert!(!windows.contains_key("old-a"));
1046 assert!(windows.contains_key("old-b"));
1047 assert!(windows.contains_key("young"));
1048 let mut empty: HashMap<String, WindowEntry> = HashMap::new();
1050 evict_oldest_window(&mut empty);
1051 assert!(empty.is_empty());
1052 }
1053}