1use std::collections::VecDeque;
4use std::fs::{File, OpenOptions};
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, VersionedThreadEvent};
12
13use crate::error::SessionStoreError;
14use crate::manifest::ManifestStore;
15use crate::session_dir;
16
17pub const DEFAULT_MAX_EVENTS: usize = 10_000;
20
21const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
24
25#[derive(Debug, Deserialize)]
31struct VersionedEventKind<'a> {
32 #[serde(rename = "schema_version", borrow)]
33 _schema_version: &'a str,
34 #[serde(borrow)]
35 event: EventKind<'a>,
36}
37
38#[derive(Debug, Deserialize)]
39struct EventKind<'a> {
40 #[serde(rename = "type", borrow)]
41 kind: &'a str,
42}
43
44#[derive(Serialize)]
51struct BorrowedVersionedEvent<'a> {
52 schema_version: &'a str,
53 event: &'a ThreadEvent,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum LifecycleKind {
62 TurnStarted,
63 TurnCompleted,
64 TurnFailed,
65 Other,
66}
67
68impl LifecycleKind {
69 #[inline]
71 fn from_event(event: &ThreadEvent) -> Self {
72 match event {
73 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
74 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
75 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
76 _ => Self::Other,
77 }
78 }
79
80 #[inline]
82 fn from_kind(kind: &str) -> Self {
83 match kind {
84 "turn.started" => Self::TurnStarted,
85 "turn.completed" => Self::TurnCompleted,
86 "turn.failed" => Self::TurnFailed,
87 _ => Self::Other,
88 }
89 }
90}
91
92struct LogState {
95 manifest: SessionManifest,
96 index: TurnIndex,
97 in_turn: bool,
101 next_offset: u64,
105 write_buf: Vec<u8>,
108}
109
110impl LogState {
111 fn new(session_id: &str) -> Self {
112 Self {
113 manifest: SessionManifest::new(session_id),
114 index: TurnIndex::default(),
115 in_turn: false,
116 next_offset: 0,
117 write_buf: Vec::with_capacity(65536),
118 }
119 }
120
121 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
130 let start = self.next_offset;
131 let buf_len_before = self.write_buf.len();
132 if let Err(err) = serde_json::to_writer(
133 &mut self.write_buf,
134 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
135 ) {
136 self.write_buf.truncate(buf_len_before);
137 return Err(err.into());
138 }
139 self.write_buf.push(b'\n');
140 let written = self.write_buf.len() - buf_len_before;
141 let end = start + written as u64;
142 self.next_offset = end;
143 Ok((start, end))
144 }
145
146 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
159 match kind {
160 LifecycleKind::TurnStarted => {
161 self.in_turn = true;
162 let n = self.manifest.turn_count + 1;
163 self.index.entries.push_back(TurnIndexEntry {
164 turn_number: n,
165 start_offset: start,
166 end_offset: end,
167 event_count: 1,
168 ts: now_rfc3339(),
169 });
170 false
171 }
172 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
173 if self.in_turn {
174 if let Some(entry) = self.index.entries.back_mut() {
175 entry.end_offset = end;
176 entry.event_count += 1;
177 }
178 self.in_turn = false;
179 self.manifest.turn_count = self.index.entries.len() as u64;
180 }
181 self.manifest.status = if kind == LifecycleKind::TurnCompleted {
182 "completed"
183 } else {
184 "failed"
185 }
186 .to_string();
187 true
188 }
189 LifecycleKind::Other => {
190 if self.in_turn
191 && let Some(entry) = self.index.entries.back_mut()
192 {
193 entry.end_offset = end;
194 entry.event_count += 1;
195 }
196 false
197 }
198 }
199 }
200
201 fn plan_cap_eviction(&mut self, max_events: usize) -> Option<(u64, u64)> {
209 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
210 return None;
211 }
212 let mut evicted_event_count = 0u64;
213 let mut truncate_offset = 0u64;
214 while self.manifest.event_count - evicted_event_count > max_events as u64
215 && let Some(oldest) = self.index.entries.front()
216 {
217 truncate_offset = oldest.end_offset;
218 evicted_event_count += oldest.event_count;
219 self.index.entries.pop_front();
220 }
221 if truncate_offset == 0 {
222 None
223 } else {
224 Some((truncate_offset, evicted_event_count))
225 }
226 }
227}
228
229pub struct SessionEventLog {
235 events_path: PathBuf,
236 manifest_store: ManifestStore,
237 file: Mutex<File>,
238 state: Mutex<LogState>,
239 max_events: usize,
240}
241
242impl SessionEventLog {
243 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
246 let dir = session_dir(workspace, session_id);
247 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
248 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
249 std::fs::create_dir_all(dir.join("index"))
250 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
251 let events_path = dir.join("events.jsonl");
252 let file = OpenOptions::new()
253 .create(true)
254 .read(true)
255 .append(true)
256 .open(&events_path)
257 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
258 let manifest_store = ManifestStore::new(dir.clone());
259 let log = Self {
260 events_path: events_path.clone(),
261 manifest_store,
262 file: Mutex::new(file),
263 state: Mutex::new(LogState::new(session_id)),
264 max_events,
265 };
266 let manifest_opt = log.manifest_store.load_manifest()?;
269 let index_opt = log.manifest_store.load_turn_index()?;
270 let file_len = std::fs::metadata(&events_path)
271 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
272 .len();
273 match (manifest_opt, index_opt) {
274 (Some(manifest), Some(index)) => {
275 let mut st = log.state.lock().map_err(poison)?;
276 st.manifest = manifest;
277 st.index = index;
278 st.next_offset = file_len;
279 }
280 _ => {
281 log.scan()?;
282 let mut st = log.state.lock().map_err(poison)?;
283 st.next_offset = file_len;
284 }
285 }
286 Ok(log)
287 }
288
289 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
291 let mut st = self.state.lock().map_err(poison)?;
292
293 let (start, end) = st.serialize_event(event)?;
297
298 st.manifest.event_count += 1;
299 st.manifest.updated_at = now_rfc3339();
300
301 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
305 if is_turn_boundary {
306 self.persist_meta_locked(&mut st)?;
307 }
308
309 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
310 self.persist_meta_locked(&mut st)?;
314 }
315 drop(st);
316 self.enforce_event_cap()
317 }
318
319 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
323 let mut st = self.state.lock().map_err(poison)?;
324
325 let Some((truncate_offset, evicted_event_count)) = st.plan_cap_eviction(self.max_events) else {
328 return Ok(());
329 };
330
331 self.flush_write_buf_locked(&mut st)?;
335
336 {
337 let mut file = self.file.lock().map_err(poison)?;
338 file.seek(SeekFrom::Start(truncate_offset))
339 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
340 let mut remaining = Vec::new();
341 file.read_to_end(&mut remaining)
342 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
343 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
344 file.seek(SeekFrom::Start(0))
345 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
346 file.write_all(&remaining)
347 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
348 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
349 }
350
351 for entry in &mut st.index.entries {
352 entry.start_offset -= truncate_offset;
353 entry.end_offset -= truncate_offset;
354 }
355 st.next_offset -= truncate_offset;
356 st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
357 self.persist_meta_locked(&mut st)?;
360 Ok(())
361 }
362
363 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
365 let entry = {
366 let st = self.state.lock().map_err(poison)?;
367 st.index
368 .entries
369 .iter()
370 .find(|e| e.turn_number == turn)
371 .cloned()
372 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
373 };
374 {
375 let mut st = self.state.lock().map_err(poison)?;
376 self.flush_write_buf_locked(&mut st)?;
377 }
378 let buf = {
379 let mut file = self.file.lock().map_err(poison)?;
380 file.seek(SeekFrom::Start(entry.start_offset))
381 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
382 let len = (entry.end_offset - entry.start_offset) as usize;
383 let mut buf = vec![0u8; len];
384 file.read_exact(&mut buf)
385 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
386 buf
387 };
388 let text = String::from_utf8_lossy(&buf);
389 let mut events = Vec::new();
390 for line in text.lines() {
391 let line = line.trim();
392 if line.is_empty() {
393 continue;
394 }
395 let v: VersionedThreadEvent = match serde_json::from_str(line) {
401 Ok(v) => v,
402 Err(_) => continue,
403 };
404 events.push(v.into_event());
405 }
406 Ok(events)
407 }
408
409 #[must_use]
411 pub(crate) fn turn_count(&self) -> u64 {
412 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
413 }
414
415 #[must_use]
417 pub fn event_count(&self) -> u64 {
418 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
419 }
420
421 pub fn flush(&self) -> Result<(), SessionStoreError> {
423 let mut st = self.state.lock().map_err(poison)?;
424 self.persist_meta_locked(&mut st)
425 }
426
427 #[must_use]
429 pub fn manifest(&self) -> SessionManifest {
430 self.state
431 .lock()
432 .map_err(poison)
433 .map(|s| s.manifest.clone())
434 .unwrap_or_else(|_| SessionManifest::new(""))
435 }
436
437 #[must_use]
439 pub fn turn_index(&self) -> TurnIndex {
440 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
441 }
442
443 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
445 let mut st = self.state.lock().map_err(poison)?;
446 st.manifest.status = "completed".to_string();
447 st.manifest.updated_at = now_rfc3339();
448 self.persist_meta_locked(&mut st)
449 }
450
451 fn scan(&self) -> Result<(), SessionStoreError> {
457 let mut st = self.state.lock().map_err(poison)?;
458 if !self.events_path.exists() {
459 return Ok(());
460 }
461 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
462 let mut reader = std::io::BufReader::new(file);
463 let mut buf = Vec::new();
464 let mut pos = 0u64;
465 let mut first_ts: Option<String> = None;
466 loop {
467 buf.clear();
468 let n = reader
469 .read_until(b'\n', &mut buf)
470 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
471 if n == 0 {
472 break;
473 }
474 let line_end = pos + n as u64;
475 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
476 if !trimmed.is_empty()
477 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
478 {
479 let kind = v.event.kind;
480 if requires_full_lifecycle_validation(kind)
481 && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
482 {
483 pos = line_end;
484 continue;
485 }
486 st.manifest.event_count += 1;
487 if kind == "thread.started" && first_ts.is_none() {
490 first_ts = Some(now_rfc3339());
491 }
492 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
495 }
496 pos = line_end;
497 }
498 st.in_turn = false;
503 if let Some(ts) = first_ts
504 && st.manifest.created_at.is_empty()
505 {
506 st.manifest.created_at = ts;
507 }
508 Ok(())
509 }
510
511 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
512 self.flush_write_buf_locked(st)?;
513 self.manifest_store.write_manifest(&st.manifest)?;
514 self.manifest_store.write_turn_index(&st.index)?;
515 Ok(())
516 }
517
518 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
520 if st.write_buf.is_empty() {
521 return Ok(());
522 }
523 let mut file = self.file.lock().map_err(poison)?;
524 file.write_all(&st.write_buf)
525 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
526 st.write_buf.clear();
527 Ok(())
528 }
529}
530
531fn requires_full_lifecycle_validation(kind: &str) -> bool {
532 matches!(kind, "thread.started" | "turn.started" | "turn.completed" | "turn.failed")
533}
534
535impl Drop for SessionEventLog {
536 fn drop(&mut self) {
537 if let Ok(mut st) = self.state.lock() {
538 let _ = self.flush_write_buf_locked(&mut st);
544 }
545 }
546}
547
548fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
550 SessionStoreError::Io {
551 path: PathBuf::new(),
552 source: std::io::Error::other("session store lock poisoned"),
553 }
554}
555
556fn now_rfc3339() -> String {
557 Utc::now().to_rfc3339()
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562pub struct SessionManifest {
563 pub session_id: String,
565 schema_version: u32,
567 pub created_at: String,
569 pub updated_at: String,
571 pub turn_count: u64,
573 pub event_count: u64,
575 pub status: String,
577}
578
579impl SessionManifest {
580 #[must_use]
582 pub(crate) fn new(session_id: &str) -> Self {
583 let ts = now_rfc3339();
584 Self {
585 session_id: session_id.to_string(),
586 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
587 created_at: ts.clone(),
588 updated_at: ts,
589 turn_count: 0,
590 event_count: 0,
591 status: "active".to_string(),
592 }
593 }
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
598pub struct TurnIndexEntry {
599 turn_number: u64,
601 start_offset: u64,
603 end_offset: u64,
605 event_count: u64,
607 ts: String,
609}
610
611#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
613pub struct TurnIndex {
614 entries: VecDeque<TurnIndexEntry>,
616}
617
618impl TurnIndex {
619 #[must_use]
621 pub fn len(&self) -> usize {
622 self.entries.len()
623 }
624
625 #[must_use]
627 pub fn is_empty(&self) -> bool {
628 self.entries.is_empty()
629 }
630}
631
632#[cfg(test)]
633mod borrowed_envelope_tests {
634 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
635 use vtcode_exec_events::{
636 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
637 };
638
639 #[test]
643 fn borrowed_envelope_matches_versioned_envelope() {
644 for event in [
645 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
646 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
647 ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
648 ] {
649 let canonical =
650 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
651 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
652 schema_version: EVENT_SCHEMA_VERSION,
653 event: &event,
654 })
655 .expect("borrowed serialize");
656 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
657 }
658 }
659}
660
661#[cfg(test)]
662mod lifecycle_state_machine_tests {
663 use super::{LifecycleKind, LogState};
664 use vtcode_exec_events::{
665 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnFailedEvent, TurnStartedEvent, Usage,
666 };
667
668 fn fresh_state() -> LogState {
669 LogState::new("test-session")
670 }
671
672 #[test]
673 fn lifecycle_kind_from_event_covers_all_variants() {
674 assert_eq!(
675 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
676 LifecycleKind::TurnStarted
677 );
678 assert_eq!(
679 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
680 LifecycleKind::TurnCompleted
681 );
682 assert_eq!(
683 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
684 message: "err".to_string(),
685 usage: None,
686 })),
687 LifecycleKind::TurnFailed
688 );
689 assert_eq!(
691 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
692 LifecycleKind::Other
693 );
694 }
695
696 #[test]
697 fn lifecycle_kind_from_str_matches_event_discriminator() {
698 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
699 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
700 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
701 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
702 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::Other);
703 }
704
705 #[test]
706 fn turn_started_pushes_index_entry_and_sets_in_turn() {
707 let mut st = fresh_state();
708 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
709 assert!(!is_boundary, "TurnStarted is not a turn boundary");
710 assert!(st.in_turn);
711 assert_eq!(st.index.entries.len(), 1);
712 let entry = &st.index.entries[0];
713 assert_eq!(entry.turn_number, 1);
714 assert_eq!(entry.start_offset, 0);
715 assert_eq!(entry.end_offset, 100);
716 assert_eq!(entry.event_count, 1);
717 }
718
719 #[test]
720 fn intermediate_events_extend_current_turn() {
721 let mut st = fresh_state();
722 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
723 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
725 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
726 assert!(!is_b1 && !is_b2);
727 assert!(st.in_turn);
728 assert_eq!(st.index.entries.len(), 1);
729 let entry = &st.index.entries[0];
730 assert_eq!(entry.end_offset, 300);
731 assert_eq!(entry.event_count, 3);
732 }
733
734 #[test]
735 fn turn_completed_closes_turn_and_returns_boundary() {
736 let mut st = fresh_state();
737 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
738 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
739 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
740 assert!(is_boundary);
741 assert!(!st.in_turn);
742 assert_eq!(st.manifest.turn_count, 1);
743 assert_eq!(st.manifest.status, "completed");
744 let entry = &st.index.entries[0];
745 assert_eq!(entry.end_offset, 300);
746 assert_eq!(entry.event_count, 3);
747 }
748
749 #[test]
750 fn turn_failed_closes_turn_and_sets_status() {
751 let mut st = fresh_state();
752 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
753 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
754 assert!(is_boundary);
755 assert!(!st.in_turn);
756 assert_eq!(st.manifest.turn_count, 1);
757 assert_eq!(st.manifest.status, "failed");
758 }
759
760 #[test]
761 fn turn_completed_without_turn_started_is_idempotent() {
762 let mut st = fresh_state();
763 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
766 assert!(is_boundary);
767 assert!(!st.in_turn);
768 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
769 assert_eq!(st.manifest.status, "completed");
770 assert!(st.index.entries.is_empty());
771 }
772
773 #[test]
774 fn multiple_turns_get_incrementing_ordinals() {
775 let mut st = fresh_state();
776 for n in 1..=3 {
777 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
778 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
779 }
780 assert_eq!(st.index.entries.len(), 3);
781 for (i, entry) in st.index.entries.iter().enumerate() {
782 assert_eq!(entry.turn_number, (i + 1) as u64);
783 }
784 assert_eq!(st.manifest.turn_count, 3);
785 }
786}
787
788#[cfg(test)]
789mod cap_eviction_tests {
790 use super::{LogState, TurnIndexEntry};
791
792 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
795 let mut st = LogState::new("cap-test");
796 st.manifest.event_count = (turns as u64) * events_per_turn;
797 let mut offset = 0u64;
798 for n in 1..=turns {
799 st.index.entries.push_back(TurnIndexEntry {
800 turn_number: n as u64,
801 start_offset: offset,
802 end_offset: offset + events_per_turn * 10,
803 event_count: events_per_turn,
804 ts: "2026-01-01T00:00:00Z".to_string(),
805 });
806 offset += events_per_turn * 10;
807 }
808 st
809 }
810
811 #[test]
812 fn no_eviction_when_under_cap() {
813 let mut st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
815 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
816 }
817
818 #[test]
819 fn no_eviction_when_cap_disabled() {
820 let mut st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
822 assert_eq!(st.index.entries.len(), 5);
823 }
824
825 #[test]
826 fn evicts_oldest_turns_to_meet_cap() {
827 let mut st = state_with_turns(5, 2);
829 let (truncate_offset, evicted) = st.plan_cap_eviction(6).expect("eviction planned");
830 assert_eq!(evicted, 4, "should evict 4 events (2 turns)");
831 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
832 assert_eq!(truncate_offset, 40); assert_eq!(st.index.entries[0].turn_number, 3);
836 assert_eq!(st.index.entries[2].turn_number, 5);
837 }
838
839 #[test]
840 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
841 let mut st = state_with_turns(3, 5);
845 let (_truncate_offset, evicted) = st.plan_cap_eviction(3).expect("eviction planned");
846 assert_eq!(evicted, 15, "all events evicted");
847 assert_eq!(st.index.entries.len(), 0);
848 }
849}