1use std::collections::{HashMap, HashSet, VecDeque};
8use std::sync::Mutex;
9
10use par_term_emu_core_rust::observer::TerminalObserver;
11use par_term_emu_core_rust::terminal::TerminalEvent;
12
13use super::protocol::{ScriptEvent, ScriptEventData};
14
15const MAX_BUFFERED_EVENTS: usize = 1024;
24
25pub const EVENT_KINDS: &[&str] = &[
34 "bell_rang",
35 "title_changed",
36 "size_changed",
37 "mode_changed",
38 "graphics_added",
39 "hyperlink_added",
40 "dirty_region",
41 "cwd_changed",
42 "trigger_matched",
43 "user_var_changed",
44 "progress_bar_changed",
45 "badge_changed",
46 "command_complete",
47 "zone_opened",
48 "zone_closed",
49 "zone_scrolled_out",
50 "environment_changed",
51 "remote_host_transition",
52 "sub_shell_detected",
53 "file_transfer_started",
54 "file_transfer_progress",
55 "file_transfer_completed",
56 "file_transfer_failed",
57 "upload_requested",
58 "screen_cleared",
59];
60
61struct EventBuffer {
63 events: VecDeque<ScriptEvent>,
64 overflow_reported: bool,
69}
70
71pub struct ScriptEventForwarder {
77 subscription_filter: Option<HashSet<String>>,
80 event_buffer: Mutex<EventBuffer>,
83}
84
85impl ScriptEventForwarder {
86 pub fn new(subscriptions: Option<HashSet<String>>) -> Self {
92 Self {
93 subscription_filter: subscriptions,
94 event_buffer: Mutex::new(EventBuffer {
95 events: VecDeque::new(),
96 overflow_reported: false,
97 }),
98 }
99 }
100
101 pub fn drain_events(&self) -> Vec<ScriptEvent> {
103 let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
104 buf.events.drain(..).collect()
105 }
106
107 fn event_kind_name(event: &TerminalEvent) -> String {
109 match event {
110 TerminalEvent::BellRang(_) => "bell_rang".to_string(),
111 TerminalEvent::TitleChanged(_) => "title_changed".to_string(),
112 TerminalEvent::SizeChanged(_, _) => "size_changed".to_string(),
113 TerminalEvent::ModeChanged(_, _) => "mode_changed".to_string(),
114 TerminalEvent::GraphicsAdded(_) => "graphics_added".to_string(),
115 TerminalEvent::HyperlinkAdded { .. } => "hyperlink_added".to_string(),
116 TerminalEvent::DirtyRegion(_, _) => "dirty_region".to_string(),
117 TerminalEvent::CwdChanged(_) => "cwd_changed".to_string(),
118 TerminalEvent::TriggerMatched(_) => "trigger_matched".to_string(),
119 TerminalEvent::UserVarChanged { .. } => "user_var_changed".to_string(),
120 TerminalEvent::ProgressBarChanged { .. } => "progress_bar_changed".to_string(),
121 TerminalEvent::BadgeChanged(_) => "badge_changed".to_string(),
122 TerminalEvent::ShellIntegrationEvent { .. } => "command_complete".to_string(),
123 TerminalEvent::ZoneOpened { .. } => "zone_opened".to_string(),
124 TerminalEvent::ZoneClosed { .. } => "zone_closed".to_string(),
125 TerminalEvent::ZoneScrolledOut { .. } => "zone_scrolled_out".to_string(),
126 TerminalEvent::EnvironmentChanged { .. } => "environment_changed".to_string(),
127 TerminalEvent::RemoteHostTransition { .. } => "remote_host_transition".to_string(),
128 TerminalEvent::SubShellDetected { .. } => "sub_shell_detected".to_string(),
129 TerminalEvent::FileTransferStarted { .. } => "file_transfer_started".to_string(),
130 TerminalEvent::FileTransferProgress { .. } => "file_transfer_progress".to_string(),
131 TerminalEvent::FileTransferCompleted { .. } => "file_transfer_completed".to_string(),
132 TerminalEvent::FileTransferFailed { .. } => "file_transfer_failed".to_string(),
133 TerminalEvent::UploadRequested { .. } => "upload_requested".to_string(),
134 TerminalEvent::ScreenCleared { .. } => "screen_cleared".to_string(),
135
136 #[allow(unreachable_patterns)]
144 _ => "unknown".to_string(),
145 }
146 }
147
148 fn convert_event(event: &TerminalEvent) -> ScriptEvent {
150 let kind = Self::event_kind_name(event);
151
152 let data = match event {
153 TerminalEvent::BellRang(_) => ScriptEventData::Empty {},
154
155 TerminalEvent::TitleChanged(title) => ScriptEventData::TitleChanged {
156 title: title.clone(),
157 },
158
159 TerminalEvent::SizeChanged(cols, rows) => ScriptEventData::SizeChanged {
160 cols: *cols,
161 rows: *rows,
162 },
163
164 TerminalEvent::CwdChanged(cwd_change) => ScriptEventData::CwdChanged {
165 cwd: cwd_change.new_cwd.clone(),
166 },
167
168 TerminalEvent::UserVarChanged {
169 name,
170 value,
171 old_value,
172 } => ScriptEventData::VariableChanged {
173 name: name.clone(),
174 value: value.clone(),
175 old_value: old_value.clone(),
176 },
177
178 TerminalEvent::EnvironmentChanged {
179 key,
180 value,
181 old_value,
182 } => ScriptEventData::EnvironmentChanged {
183 key: key.clone(),
184 value: value.clone(),
185 old_value: old_value.clone(),
186 },
187
188 TerminalEvent::BadgeChanged(text) => {
189 ScriptEventData::BadgeChanged { text: text.clone() }
190 }
191
192 TerminalEvent::ShellIntegrationEvent {
193 command, exit_code, ..
194 } => ScriptEventData::CommandComplete {
195 command: command.clone().unwrap_or_default(),
196 exit_code: *exit_code,
197 },
198
199 TerminalEvent::TriggerMatched(trigger_match) => ScriptEventData::TriggerMatched {
200 pattern: format!("trigger:{}", trigger_match.trigger_id),
201 matched_text: trigger_match.text.clone(),
202 line: trigger_match.row,
203 },
204
205 TerminalEvent::ZoneOpened {
206 zone_id, zone_type, ..
207 } => ScriptEventData::ZoneEvent {
208 zone_id: *zone_id as u64,
209 zone_type: zone_type.to_string(),
210 event: "opened".to_string(),
211 },
212
213 TerminalEvent::ZoneClosed {
214 zone_id, zone_type, ..
215 } => ScriptEventData::ZoneEvent {
216 zone_id: *zone_id as u64,
217 zone_type: zone_type.to_string(),
218 event: "closed".to_string(),
219 },
220
221 TerminalEvent::ZoneScrolledOut {
222 zone_id, zone_type, ..
223 } => ScriptEventData::ZoneEvent {
224 zone_id: *zone_id as u64,
225 zone_type: zone_type.to_string(),
226 event: "scrolled_out".to_string(),
227 },
228
229 other => {
231 let mut fields = HashMap::new();
232 fields.insert(
233 "debug".to_string(),
234 serde_json::Value::String(format!("{:?}", other)),
235 );
236 ScriptEventData::Generic { fields }
237 }
238 };
239
240 ScriptEvent { kind, data }
241 }
242}
243
244impl TerminalObserver for ScriptEventForwarder {
255 fn on_event(&self, event: &TerminalEvent) {
256 if let Some(ref filter) = self.subscription_filter {
258 let kind = Self::event_kind_name(event);
259 if !filter.contains(&kind) {
260 return;
261 }
262 }
263
264 let script_event = Self::convert_event(event);
265 let mut buf = self.event_buffer.lock().expect("event_buffer poisoned");
266
267 if buf.events.len() >= MAX_BUFFERED_EVENTS {
268 buf.events.pop_front();
273 if !buf.overflow_reported {
274 buf.overflow_reported = true;
275 log::warn!(
280 "Event buffer reached its {} event cap; discarding oldest \
281 events from here on. Nothing is draining this forwarder — the \
282 subscribing process most likely exited while its observer \
283 stayed registered.",
284 MAX_BUFFERED_EVENTS
285 );
286 }
287 }
288
289 buf.events.push_back(script_event);
290 }
291
292 }
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn test_event_kind_name_bell() {
303 let event =
304 TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
305 assert_eq!(ScriptEventForwarder::event_kind_name(&event), "bell_rang");
306 }
307
308 #[test]
309 fn test_event_kind_name_title() {
310 let event = TerminalEvent::TitleChanged("hello".to_string());
311 assert_eq!(
312 ScriptEventForwarder::event_kind_name(&event),
313 "title_changed"
314 );
315 }
316
317 #[test]
318 fn test_convert_bell_event() {
319 let event =
320 TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
321 let script_event = ScriptEventForwarder::convert_event(&event);
322 assert_eq!(script_event.kind, "bell_rang");
323 assert_eq!(script_event.data, ScriptEventData::Empty {});
324 }
325
326 #[test]
327 fn test_convert_title_event() {
328 let event = TerminalEvent::TitleChanged("My Title".to_string());
329 let script_event = ScriptEventForwarder::convert_event(&event);
330 assert_eq!(script_event.kind, "title_changed");
331 assert_eq!(
332 script_event.data,
333 ScriptEventData::TitleChanged {
334 title: "My Title".to_string(),
335 }
336 );
337 }
338
339 #[test]
340 fn test_convert_size_event() {
341 let event = TerminalEvent::SizeChanged(120, 40);
342 let script_event = ScriptEventForwarder::convert_event(&event);
343 assert_eq!(script_event.kind, "size_changed");
344 assert_eq!(
345 script_event.data,
346 ScriptEventData::SizeChanged {
347 cols: 120,
348 rows: 40,
349 }
350 );
351 }
352
353 #[test]
354 fn event_kinds_has_no_duplicates_and_names_representative_kinds() {
355 let mut sorted = EVENT_KINDS.to_vec();
356 sorted.sort_unstable();
357 let count = sorted.len();
358 sorted.dedup();
359 assert_eq!(sorted.len(), count, "EVENT_KINDS must not repeat a kind");
360 for kind in [
361 "bell_rang",
362 "command_complete",
363 "screen_cleared",
364 "file_transfer_failed",
365 ] {
366 assert!(
367 EVENT_KINDS.contains(&kind),
368 "{kind} missing from EVENT_KINDS"
369 );
370 }
371 }
372
373 #[test]
374 fn test_forwarder_no_filter_captures_all() {
375 let fwd = ScriptEventForwarder::new(None);
376 let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
377 let title = TerminalEvent::TitleChanged("t".to_string());
378
379 fwd.on_event(&bell);
380 fwd.on_event(&title);
381
382 let events = fwd.drain_events();
383 assert_eq!(events.len(), 2);
384 assert_eq!(events[0].kind, "bell_rang");
385 assert_eq!(events[1].kind, "title_changed");
386 }
387
388 #[test]
389 fn test_forwarder_filters_by_subscription() {
390 let filter = HashSet::from(["bell_rang".to_string()]);
391 let fwd = ScriptEventForwarder::new(Some(filter));
392
393 let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
394 let title = TerminalEvent::TitleChanged("t".to_string());
395
396 fwd.on_event(&bell);
397 fwd.on_event(&title);
398
399 let events = fwd.drain_events();
400 assert_eq!(events.len(), 1);
401 assert_eq!(events[0].kind, "bell_rang");
402 }
403
404 fn title_of(event: &ScriptEvent) -> &str {
407 match &event.data {
408 ScriptEventData::TitleChanged { title } => title,
409 other => panic!("expected a title_changed event, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn buffer_stops_growing_at_the_cap() {
415 let fwd = ScriptEventForwarder::new(None);
416 for i in 0..(MAX_BUFFERED_EVENTS * 2) {
417 fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
418 }
419 assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
420 }
421
422 #[test]
423 fn overflow_drops_the_oldest_events_not_the_newest() {
424 let fwd = ScriptEventForwarder::new(None);
425 let overflow = 10;
426 for i in 0..(MAX_BUFFERED_EVENTS + overflow) {
427 fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
428 }
429
430 let events = fwd.drain_events();
431 assert_eq!(events.len(), MAX_BUFFERED_EVENTS);
432 assert_eq!(title_of(&events[0]), overflow.to_string());
434 assert_eq!(
435 title_of(&events[MAX_BUFFERED_EVENTS - 1]),
436 (MAX_BUFFERED_EVENTS + overflow - 1).to_string()
437 );
438 }
439
440 #[test]
441 fn draining_frees_the_capacity_again() {
442 let fwd = ScriptEventForwarder::new(None);
443 for i in 0..MAX_BUFFERED_EVENTS {
444 fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
445 }
446 assert_eq!(fwd.drain_events().len(), MAX_BUFFERED_EVENTS);
447
448 fwd.on_event(&TerminalEvent::TitleChanged("after-drain".to_string()));
450 let events = fwd.drain_events();
451 assert_eq!(events.len(), 1);
452 assert_eq!(title_of(&events[0]), "after-drain");
453 }
454
455 #[test]
456 fn filtered_out_events_do_not_consume_buffer_capacity() {
457 let filter = HashSet::from(["bell_rang".to_string()]);
458 let fwd = ScriptEventForwarder::new(Some(filter));
459 for i in 0..(MAX_BUFFERED_EVENTS * 2) {
460 fwd.on_event(&TerminalEvent::TitleChanged(i.to_string()));
461 }
462 assert!(fwd.drain_events().is_empty());
463 }
464
465 #[test]
466 fn test_drain_clears_buffer() {
467 let fwd = ScriptEventForwarder::new(None);
468 let bell = TerminalEvent::BellRang(par_term_emu_core_rust::terminal::BellEvent::VisualBell);
469
470 fwd.on_event(&bell);
471 let events = fwd.drain_events();
472 assert_eq!(events.len(), 1);
473
474 let events2 = fwd.drain_events();
476 assert!(events2.is_empty());
477 }
478}