1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2use std::sync::{Arc, Mutex};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use tokio::sync::broadcast;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[non_exhaustive]
15pub struct WriteProvenance {
16 pub source: String,
18 pub correlation_id: Option<String>,
20 pub note: Option<String>,
22}
23
24impl WriteProvenance {
25 pub fn new(source: impl Into<String>) -> Self {
27 Self {
28 source: source.into(),
29 correlation_id: None,
30 note: None,
31 }
32 }
33
34 pub fn with_correlation_id(mut self, correlation_id: impl Into<String>) -> Self {
36 self.correlation_id = Some(correlation_id.into());
37 self
38 }
39
40 pub fn with_note(mut self, note: impl Into<String>) -> Self {
42 self.note = Some(note.into());
43 self
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case", tag = "kind", content = "provenance")]
53#[non_exhaustive]
54pub enum EventAttribution {
55 Attributed(WriteProvenance),
57 ExternalOrUnknown,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case", tag = "kind")]
64#[non_exhaustive]
65pub enum HookEvent {
66 FileCreated {
68 path: String,
70 },
71 FileModified {
73 path: String,
75 },
76 FileDeleted {
78 path: String,
80 },
81 FileRenamed {
83 from: String,
85 to: String,
87 },
88 ResyncRequired {
90 reason: String,
92 },
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[non_exhaustive]
101pub struct VaultEventEnvelope {
102 pub epoch: String,
107 pub sequence: u64,
111 pub observed_at_ms: u64,
113 pub vault: String,
115 pub event: HookEvent,
117 pub content_hash: Option<String>,
119 pub plugin_id: Option<String>,
127 pub attribution: EventAttribution,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum HookLifecycle {
134 Running,
136 Closed,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
142pub enum PublishError {
143 #[error("hook bus is closed")]
145 Closed,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
150pub enum HookRecvError {
151 #[error("no hook event is currently available")]
153 Empty,
154 #[error("hook subscriber lagged by {skipped} event(s); resync required")]
158 Lagged {
159 skipped: u64,
161 },
162 #[error("hook bus is closed")]
164 Closed,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct EventCursor {
178 epoch: String,
179 sequence: u64,
180}
181
182impl EventCursor {
183 pub fn after(envelope: &VaultEventEnvelope) -> Self {
185 Self {
186 epoch: envelope.epoch.clone(),
187 sequence: envelope.sequence,
188 }
189 }
190
191 pub fn epoch(&self) -> &str {
193 &self.epoch
194 }
195
196 pub fn sequence(&self) -> u64 {
198 self.sequence
199 }
200
201 pub fn resumes_on(&self, bus: &HookBus) -> bool {
211 self.epoch == bus.epoch()
212 }
213}
214
215struct HookBusInner {
216 sender: Mutex<Option<broadcast::Sender<VaultEventEnvelope>>>,
217 epoch: String,
218 sequence: AtomicU64,
219 closed: AtomicBool,
220 capacity: usize,
221}
222
223#[derive(Clone)]
228pub struct HookBus {
229 inner: Arc<HookBusInner>,
230}
231
232impl std::fmt::Debug for HookBus {
233 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234 formatter
235 .debug_struct("HookBus")
236 .field("epoch", &self.epoch())
237 .field("capacity", &self.capacity())
238 .field("lifecycle", &self.lifecycle())
239 .finish()
240 }
241}
242
243impl HookBus {
244 fn sender(&self) -> std::sync::MutexGuard<'_, Option<broadcast::Sender<VaultEventEnvelope>>> {
245 self.inner
246 .sender
247 .lock()
248 .unwrap_or_else(std::sync::PoisonError::into_inner)
249 }
250
251 pub fn new(capacity: usize) -> Self {
255 let capacity = capacity.max(1);
256 let (sender, _) = broadcast::channel(capacity);
257 Self {
258 inner: Arc::new(HookBusInner {
259 sender: Mutex::new(Some(sender)),
260 epoch: uuid::Uuid::new_v4().to_string(),
261 sequence: AtomicU64::new(0),
262 closed: AtomicBool::new(false),
263 capacity,
264 }),
265 }
266 }
267
268 pub fn epoch(&self) -> &str {
277 &self.inner.epoch
278 }
279
280 pub fn capacity(&self) -> usize {
282 self.inner.capacity
283 }
284
285 pub fn lifecycle(&self) -> HookLifecycle {
287 if self.inner.closed.load(Ordering::Acquire) {
288 HookLifecycle::Closed
289 } else {
290 HookLifecycle::Running
291 }
292 }
293
294 pub fn subscribe(&self) -> Result<HookSubscription, PublishError> {
296 let guard = self.sender();
297 let sender = guard.as_ref().ok_or(PublishError::Closed)?;
298 Ok(HookSubscription {
299 receiver: sender.subscribe(),
300 })
301 }
302
303 pub fn publish(
313 &self,
314 vault: impl Into<String>,
315 event: HookEvent,
316 content_hash: Option<String>,
317 plugin_id: Option<String>,
318 attribution: EventAttribution,
319 ) -> Result<VaultEventEnvelope, PublishError> {
320 let guard = self.sender();
321 let sender = guard.as_ref().ok_or(PublishError::Closed)?;
322 let envelope = VaultEventEnvelope {
323 epoch: self.inner.epoch.clone(),
324 sequence: self.inner.sequence.fetch_add(1, Ordering::Relaxed) + 1,
325 observed_at_ms: SystemTime::now()
326 .duration_since(UNIX_EPOCH)
327 .unwrap_or_default()
328 .as_millis()
329 .try_into()
330 .unwrap_or(u64::MAX),
331 vault: vault.into(),
332 event,
333 content_hash,
334 plugin_id,
335 attribution,
336 };
337 let _subscriber_count = sender.send(envelope.clone());
338 Ok(envelope)
339 }
340
341 pub fn close(&self) -> bool {
345 let mut guard = self.sender();
346 let changed = guard.take().is_some();
347 self.inner.closed.store(true, Ordering::Release);
348 changed
349 }
350}
351
352pub struct HookSubscription {
354 receiver: broadcast::Receiver<VaultEventEnvelope>,
355}
356
357impl std::fmt::Debug for HookSubscription {
358 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 formatter
360 .debug_struct("HookSubscription")
361 .finish_non_exhaustive()
362 }
363}
364
365impl HookSubscription {
366 pub async fn recv(&mut self) -> Result<VaultEventEnvelope, HookRecvError> {
368 self.receiver.recv().await.map_err(|error| match error {
369 broadcast::error::RecvError::Closed => HookRecvError::Closed,
370 broadcast::error::RecvError::Lagged(skipped) => HookRecvError::Lagged { skipped },
371 })
372 }
373
374 pub fn try_recv(&mut self) -> Result<VaultEventEnvelope, HookRecvError> {
376 self.receiver.try_recv().map_err(|error| match error {
377 broadcast::error::TryRecvError::Closed => HookRecvError::Closed,
378 broadcast::error::TryRecvError::Lagged(skipped) => HookRecvError::Lagged { skipped },
379 broadcast::error::TryRecvError::Empty => HookRecvError::Empty,
380 })
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 fn publish(bus: &HookBus, path: &str) -> VaultEventEnvelope {
389 bus.publish(
390 "test",
391 HookEvent::FileModified {
392 path: path.to_string(),
393 },
394 None,
395 None,
396 EventAttribution::ExternalOrUnknown,
397 )
398 .expect("publish")
399 }
400
401 #[tokio::test]
402 async fn sequences_events_and_closes_after_buffered_delivery() {
403 const CAPACITY: usize = 2;
405 let bus = HookBus::new(CAPACITY);
406 let mut subscription = bus.subscribe().expect("subscribe");
407 assert_eq!(publish(&bus, "a.md").sequence, 1);
408 assert_eq!(publish(&bus, "b.md").sequence, 2);
409 assert!(bus.close());
410 assert!(!bus.close());
411
412 assert_eq!(subscription.recv().await.expect("first").sequence, 1);
413 assert_eq!(subscription.recv().await.expect("second").sequence, 2);
414 assert_eq!(subscription.recv().await, Err(HookRecvError::Closed));
415 assert_eq!(bus.lifecycle(), HookLifecycle::Closed);
416 }
417
418 #[tokio::test]
419 async fn reports_lag_and_requires_resync() {
420 const CAPACITY: usize = 1;
422 let bus = HookBus::new(CAPACITY);
423 let mut subscription = bus.subscribe().expect("subscribe");
424 publish(&bus, "a.md");
425 publish(&bus, "b.md");
426
427 assert_eq!(
428 subscription.recv().await,
429 Err(HookRecvError::Lagged { skipped: 1 })
430 );
431 assert_eq!(subscription.recv().await.expect("retained").sequence, 2);
432 }
433
434 #[test]
435 fn cursors_do_not_resume_across_a_restart() {
436 let first_run = HookBus::new(4);
438 let second_run = HookBus::new(4);
439 assert_ne!(first_run.epoch(), second_run.epoch());
440
441 let applied = EventCursor::after(&publish(&first_run, "a.md"));
442 assert_eq!(applied.sequence(), 1);
443 assert!(applied.resumes_on(&first_run));
444
445 let after_restart = publish(&second_run, "b.md");
448 assert_eq!(after_restart.sequence, applied.sequence());
449 assert!(!applied.resumes_on(&second_run));
450
451 let encoded = serde_json::to_vec(&applied).expect("serialize cursor");
453 let decoded: EventCursor = serde_json::from_slice(&encoded).expect("deserialize cursor");
454 assert_eq!(decoded, applied);
455 assert!(decoded.resumes_on(&first_run));
456 }
457
458 #[test]
459 fn rejects_new_work_after_close() {
460 let bus = HookBus::new(0);
461 assert_eq!(bus.capacity(), 1);
462 assert!(bus.close());
463 assert!(matches!(bus.subscribe(), Err(PublishError::Closed)));
464 assert_eq!(
465 bus.publish(
466 "test",
467 HookEvent::ResyncRequired {
468 reason: "closed".to_string()
469 },
470 None,
471 None,
472 EventAttribution::ExternalOrUnknown
473 ),
474 Err(PublishError::Closed)
475 );
476 }
477}