bamboo_plugin_protocol/
publisher.rs1use std::collections::VecDeque;
2use std::sync::{Mutex, TryLockError};
3
4use thiserror::Error;
5
6use crate::{ToolEventBuildError, ToolEventV1};
7
8pub trait ToolEventPublisher: Send + Sync {
13 fn is_enabled(&self) -> bool {
16 true
17 }
18
19 fn try_publish(&self, event: ToolEventV1) -> Result<(), ToolEventPublishError>;
20}
21
22#[derive(Clone, Copy, Debug, Default)]
25pub struct NoopToolEventPublisher;
26
27impl ToolEventPublisher for NoopToolEventPublisher {
28 fn is_enabled(&self) -> bool {
29 false
30 }
31
32 fn try_publish(&self, _event: ToolEventV1) -> Result<(), ToolEventPublishError> {
33 Ok(())
34 }
35}
36
37#[derive(Debug)]
42pub struct InMemoryToolEventRecorder {
43 capacity: usize,
44 events: Mutex<VecDeque<ToolEventV1>>,
45}
46
47impl InMemoryToolEventRecorder {
48 pub fn new(capacity: usize) -> Result<Self, ToolEventPublishError> {
49 if capacity == 0 {
50 return Err(ToolEventPublishError::InvalidCapacity);
51 }
52 Ok(Self {
53 capacity,
54 events: Mutex::new(VecDeque::new()),
57 })
58 }
59
60 pub fn try_snapshot(&self) -> Result<Vec<ToolEventV1>, ToolEventPublishError> {
61 let events = self.try_lock()?;
62 Ok(events.iter().cloned().collect())
63 }
64
65 pub fn try_drain(&self) -> Result<Vec<ToolEventV1>, ToolEventPublishError> {
66 let mut events = self.try_lock()?;
67 Ok(events.drain(..).collect())
68 }
69
70 fn try_lock(
71 &self,
72 ) -> Result<std::sync::MutexGuard<'_, VecDeque<ToolEventV1>>, ToolEventPublishError> {
73 match self.events.try_lock() {
74 Ok(events) => Ok(events),
75 Err(TryLockError::WouldBlock) => Err(ToolEventPublishError::Busy),
76 Err(TryLockError::Poisoned(_)) => Err(ToolEventPublishError::Poisoned),
77 }
78 }
79}
80
81impl ToolEventPublisher for InMemoryToolEventRecorder {
82 fn try_publish(&self, event: ToolEventV1) -> Result<(), ToolEventPublishError> {
83 event
84 .validate_bounds()
85 .map_err(ToolEventPublishError::InvalidEvent)?;
86 let mut events = self.try_lock()?;
87 if events.len() >= self.capacity {
88 return Err(ToolEventPublishError::Full {
89 capacity: self.capacity,
90 });
91 }
92 events.push_back(event);
93 Ok(())
94 }
95}
96
97#[derive(Clone, Debug, Error, PartialEq, Eq)]
98pub enum ToolEventPublishError {
99 #[error("tool event recorder capacity must be greater than zero")]
100 InvalidCapacity,
101 #[error("tool event publisher is busy")]
102 Busy,
103 #[error("tool event publisher is full (capacity {capacity})")]
104 Full { capacity: usize },
105 #[error("tool event publisher state is poisoned")]
106 Poisoned,
107 #[error("invalid tool event: {0}")]
108 InvalidEvent(ToolEventBuildError),
109 #[error("tool event publisher failed: {0}")]
110 Failed(String),
111}
112
113#[cfg(test)]
114mod tests {
115 use crate::{FileChangedV1, ToolEventContextV1};
116
117 use super::*;
118
119 fn event(call_id: &str) -> ToolEventV1 {
120 ToolEventV1::file_changed(
121 ToolEventContextV1::bounded("session", "root-session", "Write", call_id).unwrap(),
122 FileChangedV1::bounded("/root/file.rs").unwrap(),
123 )
124 .unwrap()
125 }
126
127 #[test]
128 fn recorder_is_bounded() {
129 let recorder = InMemoryToolEventRecorder::new(1).unwrap();
130 recorder.try_publish(event("one")).unwrap();
131 assert_eq!(
132 recorder.try_publish(event("two")),
133 Err(ToolEventPublishError::Full { capacity: 1 })
134 );
135 assert_eq!(recorder.try_snapshot().unwrap().len(), 1);
136 }
137
138 #[test]
139 fn contended_recorder_returns_without_waiting() {
140 let recorder = InMemoryToolEventRecorder::new(1).unwrap();
141 let _guard = recorder.events.lock().unwrap();
142 assert_eq!(
143 recorder.try_publish(event("busy")),
144 Err(ToolEventPublishError::Busy)
145 );
146 }
147
148 #[test]
149 fn poisoned_recorder_returns_an_explicit_error() {
150 let recorder = std::sync::Arc::new(InMemoryToolEventRecorder::new(1).unwrap());
151 let poison_target = recorder.clone();
152 let poisoned = std::thread::spawn(move || {
153 let _guard = poison_target.events.lock().unwrap();
154 panic!("poison recorder for deterministic coverage");
155 });
156 assert!(poisoned.join().is_err());
157
158 assert_eq!(
159 recorder.try_publish(event("poisoned")),
160 Err(ToolEventPublishError::Poisoned)
161 );
162 }
163}