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