1use std::collections::VecDeque;
10
11pub const PENDING_MS: i64 = 10 * 60_000;
16
17pub const MAX_PENDING: usize = 64;
20
21pub const START_GRACE_MS: i64 = 10_000;
27
28pub const MAX_TOOL: usize = 64;
30
31#[derive(Clone, Debug, Default)]
32pub struct Hooked {
33 pending: VecDeque<(String, i64)>,
36 pub last_ms: Option<i64>,
38}
39
40impl Hooked {
41 pub fn heard(&mut self, now: i64) {
43 self.last_ms = Some(now);
44 }
45
46 pub fn before(&mut self, tool: &str, now: i64) {
48 self.heard(now);
49 self.expire(now);
50 if self.pending.len() >= MAX_PENDING {
51 self.pending.pop_front();
52 }
53 self.pending.push_back((tool.to_string(), now));
54 }
55
56 pub fn take(&mut self, tool: &str, now: i64) -> bool {
58 self.expire(now);
59 match self.pending.iter().position(|(t, _)| t == tool) {
60 Some(i) => {
61 self.pending.remove(i);
62 true
63 }
64 None => false,
65 }
66 }
67
68 fn expire(&mut self, now: i64) {
69 while self
70 .pending
71 .front()
72 .is_some_and(|(_, at)| now.saturating_sub(*at) > PENDING_MS)
73 {
74 self.pending.pop_front();
75 }
76 }
77
78 #[cfg(test)]
79 fn waiting(&self) -> Vec<&str> {
80 self.pending.iter().map(|(t, _)| t.as_str()).collect()
81 }
82}
83
84pub fn valid_tool(tool: &str) -> bool {
86 !tool.is_empty()
87 && tool.len() <= MAX_TOOL
88 && tool
89 .bytes()
90 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn a_call_takes_one_event_of_its_own_tool_in_order() {
99 let mut h = Hooked::default();
100 assert!(!h.take("propose", 0), "nothing heard");
101 h.before("lease", 1);
102 h.before("propose", 2);
103 h.before("lease", 3);
104 assert!(!h.take("status", 4), "another tool's event is no proof");
105 assert!(h.take("lease", 5));
106 assert_eq!(h.waiting(), ["propose", "lease"], "the oldest went");
107 assert!(h.take("lease", 5) && h.take("propose", 5));
108 assert!(!h.take("propose", 5), "one event, one call");
109 assert_eq!(h.last_ms, Some(3));
110 }
111
112 #[test]
113 fn an_event_waits_for_its_call_only_so_long() {
114 let mut h = Hooked::default();
115 h.before("propose", 1000);
116 assert!(h.take("propose", 1000 + PENDING_MS), "at the limit");
117 h.before("propose", 1000);
118 h.before("lease", 2000);
119 assert!(!h.take("propose", 1001 + PENDING_MS), "past it");
120 assert!(
121 h.take("lease", 1001 + PENDING_MS),
122 "a later one still waits"
123 );
124 h.before("a", 0);
125 h.before("b", PENDING_MS + 1);
126 assert_eq!(h.waiting(), ["b"], "an event clears what expired");
127 }
128
129 #[test]
130 fn a_session_keeps_only_so_many_events() {
131 let mut h = Hooked::default();
132 h.before("first", 0);
133 for _ in 1..MAX_PENDING {
134 h.before("x", 0);
135 }
136 assert_eq!(h.waiting().len(), MAX_PENDING);
137 h.before("last", 0);
138 assert_eq!(h.waiting().len(), MAX_PENDING);
139 assert!(!h.take("first", 0), "the oldest made room");
140 assert!(h.take("last", 0));
141 }
142
143 #[test]
144 fn hearing_anything_counts_as_alive() {
145 let mut h = Hooked::default();
146 assert_eq!(h.last_ms, None);
147 h.heard(7);
148 assert_eq!(h.last_ms, Some(7));
149 assert!(h.waiting().is_empty());
150 }
151
152 #[test]
153 fn a_tool_is_a_short_lowercase_name() {
154 assert!(valid_tool("propose") && valid_tool("change_status") && valid_tool("a1"));
155 let long = "a".repeat(MAX_TOOL);
156 assert!(valid_tool(&long));
157 assert!(!valid_tool(&format!("{long}a")));
158 for bad in ["", "Propose", "mcp__lonca__x-y", "a b", "a\n", "รค"] {
159 assert!(!valid_tool(bad), "{bad:?}");
160 }
161 }
162}