1use std::path::Path;
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OpenFlags};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Result, SqzError};
9use crate::types::{CompressedContent, SessionId, SessionState};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionSummary {
15 pub id: SessionId,
16 pub project_dir: PathBuf,
17 pub compressed_summary: String,
18 pub created_at: DateTime<Utc>,
19 pub updated_at: DateTime<Utc>,
20}
21
22pub struct SessionStore {
38 db: Connection,
39}
40
41const SCHEMA: &str = r#"
44PRAGMA journal_mode = WAL;
45
46CREATE TABLE IF NOT EXISTS sessions (
47 id TEXT PRIMARY KEY,
48 project_dir TEXT NOT NULL,
49 compressed_summary TEXT NOT NULL,
50 created_at TEXT NOT NULL,
51 updated_at TEXT NOT NULL,
52 data BLOB NOT NULL
53);
54
55CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
56 id,
57 project_dir,
58 compressed_summary,
59 content='sessions',
60 content_rowid='rowid',
61 tokenize='porter ascii'
62);
63
64CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
65 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
66 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
67END;
68
69CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
70 INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
71 VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
72END;
73
74CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
75 INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
76 VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
77 INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
78 VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
79END;
80
81CREATE TABLE IF NOT EXISTS cache_entries (
82 hash TEXT PRIMARY KEY,
83 data TEXT NOT NULL,
84 accessed_at TEXT NOT NULL
85);
86
87CREATE TABLE IF NOT EXISTS compression_log (
88 id INTEGER PRIMARY KEY AUTOINCREMENT,
89 tokens_original INTEGER NOT NULL,
90 tokens_compressed INTEGER NOT NULL,
91 stages_applied TEXT NOT NULL,
92 mode TEXT NOT NULL DEFAULT 'auto',
93 created_at TEXT NOT NULL
94);
95
96CREATE TABLE IF NOT EXISTS known_files (
97 path TEXT PRIMARY KEY,
98 added_at TEXT NOT NULL
99);
100"#;
101
102pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
105 conn.execute_batch(SCHEMA)
106}
107
108fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
109 let conn = Connection::open(path)?;
110 apply_schema(&conn)?;
111 Ok(conn)
112}
113
114fn row_to_summary(
115 id: String,
116 project_dir: String,
117 compressed_summary: String,
118 created_at: String,
119 updated_at: String,
120) -> Result<SessionSummary> {
121 let created_at = created_at
122 .parse::<DateTime<Utc>>()
123 .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
124 let updated_at = updated_at
125 .parse::<DateTime<Utc>>()
126 .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
127 Ok(SessionSummary {
128 id,
129 project_dir: PathBuf::from(project_dir),
130 compressed_summary,
131 created_at,
132 updated_at,
133 })
134}
135
136impl SessionStore {
139 #[cfg(test)]
142 pub(crate) fn from_connection(conn: Connection) -> Self {
143 Self { db: conn }
144 }
145
146 pub fn open(path: &Path) -> Result<Self> {
149 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
150 apply_schema(&conn)?;
151 Ok(Self { db: conn })
152 }
153
154 pub fn open_or_create(path: &Path) -> Result<Self> {
158 match open_connection(path) {
159 Ok(conn) => Ok(Self { db: conn }),
160 Err(e) => {
161 eprintln!(
162 "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
163 Creating a new database. Prior session data has been lost.",
164 path.display()
165 );
166 if path.exists() {
168 let _ = std::fs::remove_file(path);
169 }
170 let conn = open_connection(path)
171 .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
172 Ok(Self { db: conn })
173 }
174 }
175 }
176
177 pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
181 let data = serde_json::to_vec(session)?;
182 let project_dir = session.project_dir.to_string_lossy().to_string();
183 let created_at = session.created_at.to_rfc3339();
184 let updated_at = session.updated_at.to_rfc3339();
185
186 self.db.execute(
187 r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
188 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
189 ON CONFLICT(id) DO UPDATE SET
190 project_dir = excluded.project_dir,
191 compressed_summary = excluded.compressed_summary,
192 created_at = excluded.created_at,
193 updated_at = excluded.updated_at,
194 data = excluded.data"#,
195 params![
196 session.id,
197 project_dir,
198 session.compressed_summary,
199 created_at,
200 updated_at,
201 data,
202 ],
203 )?;
204
205 Ok(session.id.clone())
206 }
207
208 pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
210 let data: Vec<u8> = self.db.query_row(
211 "SELECT data FROM sessions WHERE id = ?1",
212 params![id],
213 |row| row.get(0),
214 )?;
215 let session: SessionState = serde_json::from_slice(&data)?;
216 Ok(session)
217 }
218
219 pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
223 let mut stmt = self.db.prepare(
224 r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
225 FROM sessions s
226 JOIN sessions_fts f ON s.rowid = f.rowid
227 WHERE sessions_fts MATCH ?1
228 ORDER BY rank"#,
229 )?;
230
231 let rows = stmt.query_map(params![query], |row| {
232 Ok((
233 row.get::<_, String>(0)?,
234 row.get::<_, String>(1)?,
235 row.get::<_, String>(2)?,
236 row.get::<_, String>(3)?,
237 row.get::<_, String>(4)?,
238 ))
239 })?;
240
241 let mut results = Vec::new();
242 for row in rows {
243 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
244 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
245 }
246 Ok(results)
247 }
248
249 pub fn search_by_date(
251 &self,
252 from: DateTime<Utc>,
253 to: DateTime<Utc>,
254 ) -> Result<Vec<SessionSummary>> {
255 let mut stmt = self.db.prepare(
256 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
257 FROM sessions
258 WHERE updated_at >= ?1 AND updated_at <= ?2
259 ORDER BY updated_at DESC"#,
260 )?;
261
262 let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
263 Ok((
264 row.get::<_, String>(0)?,
265 row.get::<_, String>(1)?,
266 row.get::<_, String>(2)?,
267 row.get::<_, String>(3)?,
268 row.get::<_, String>(4)?,
269 ))
270 })?;
271
272 let mut results = Vec::new();
273 for row in rows {
274 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
275 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
276 }
277 Ok(results)
278 }
279
280 pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
282 let mut stmt = self.db.prepare(
283 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
284 FROM sessions
285 ORDER BY updated_at DESC
286 LIMIT 1"#,
287 ).map_err(SqzError::SessionStore)?;
288
289 let rows = stmt.query_map([], |row| {
290 Ok((
291 row.get::<_, String>(0)?,
292 row.get::<_, String>(1)?,
293 row.get::<_, String>(2)?,
294 row.get::<_, String>(3)?,
295 row.get::<_, String>(4)?,
296 ))
297 }).map_err(SqzError::SessionStore)?;
298
299 for row in rows {
300 let (id, project_dir, compressed_summary, created_at, updated_at) =
301 row.map_err(SqzError::SessionStore)?;
302 return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
303 }
304 Ok(None)
305 }
306
307 pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
309 let dir_str = dir.to_string_lossy().to_string();
310 let mut stmt = self.db.prepare(
311 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
312 FROM sessions
313 WHERE project_dir = ?1
314 ORDER BY updated_at DESC"#,
315 )?;
316
317 let rows = stmt.query_map(params![dir_str], |row| {
318 Ok((
319 row.get::<_, String>(0)?,
320 row.get::<_, String>(1)?,
321 row.get::<_, String>(2)?,
322 row.get::<_, String>(3)?,
323 row.get::<_, String>(4)?,
324 ))
325 })?;
326
327 let mut results = Vec::new();
328 for row in rows {
329 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
330 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
331 }
332 Ok(results)
333 }
334
335 pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
339 let data = serde_json::to_string(compressed)?;
340 let now = Utc::now().to_rfc3339();
341 self.db.execute(
342 r#"INSERT INTO cache_entries (hash, data, accessed_at)
343 VALUES (?1, ?2, ?3)
344 ON CONFLICT(hash) DO UPDATE SET data = excluded.data, accessed_at = excluded.accessed_at"#,
345 params![hash, data, now],
346 )?;
347 Ok(())
348 }
349
350 pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
352 self.db.execute(
353 "DELETE FROM cache_entries WHERE hash = ?1",
354 params![hash],
355 )?;
356 Ok(())
357 }
358
359 pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
363 let mut stmt = self.db.prepare(
364 "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
365 )?;
366 let rows = stmt.query_map([], |row| {
367 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
368 })?;
369 let mut entries = Vec::new();
370 for row in rows {
371 let (hash, size) = row?;
372 entries.push((hash, size as u64));
373 }
374 Ok(entries)
375 }
376
377 pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
379 let result: rusqlite::Result<String> = self.db.query_row(
380 "SELECT data FROM cache_entries WHERE hash = ?1",
381 params![hash],
382 |row| row.get(0),
383 );
384
385 match result {
386 Ok(data) => {
387 let now = Utc::now().to_rfc3339();
389 let _ = self.db.execute(
390 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
391 params![now, hash],
392 );
393 let entry: CompressedContent = serde_json::from_str(&data)?;
394 Ok(Some(entry))
395 }
396 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
397 Err(e) => Err(SqzError::SessionStore(e)),
398 }
399 }
400
401 pub fn log_compression(
403 &self,
404 tokens_original: u32,
405 tokens_compressed: u32,
406 stages: &[String],
407 mode: &str,
408 ) -> Result<()> {
409 let now = Utc::now().to_rfc3339();
410 let stages_str = stages.join(",");
411 self.db.execute(
412 "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
413 params![tokens_original, tokens_compressed, stages_str, mode, now],
414 ).map_err(SqzError::SessionStore)?;
415 Ok(())
416 }
417
418 pub fn compression_stats(&self) -> Result<CompressionStats> {
420 let mut stmt = self.db.prepare(
421 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
422 ).map_err(SqzError::SessionStore)?;
423
424 let stats = stmt.query_row([], |row| {
425 Ok(CompressionStats {
426 total_compressions: row.get::<_, u32>(0)?,
427 total_tokens_in: row.get::<_, u64>(1)?,
428 total_tokens_out: row.get::<_, u64>(2)?,
429 })
430 }).map_err(SqzError::SessionStore)?;
431
432 Ok(stats)
433 }
434
435 pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
437 let mut stmt = self.db.prepare(
438 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
439 FROM compression_log \
440 WHERE created_at >= date('now', ?1) \
441 GROUP BY d ORDER BY d",
442 ).map_err(SqzError::SessionStore)?;
443
444 let offset = format!("-{days} days");
445 let rows = stmt.query_map(params![offset], |row| {
446 let tokens_in: u64 = row.get(2)?;
447 let tokens_out: u64 = row.get(3)?;
448 Ok(DailyGain {
449 date: row.get(0)?,
450 compressions: row.get(1)?,
451 tokens_in,
452 tokens_saved: tokens_in.saturating_sub(tokens_out),
453 })
454 }).map_err(SqzError::SessionStore)?;
455
456 let mut gains = Vec::new();
457 for row in rows {
458 gains.push(row.map_err(SqzError::SessionStore)?);
459 }
460 Ok(gains)
461 }
462
463 pub fn add_known_file(&self, path: &str) -> Result<()> {
468 let now = Utc::now().to_rfc3339();
469 self.db.execute(
470 "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
471 params![path, now],
472 ).map_err(SqzError::SessionStore)?;
473 Ok(())
474 }
475
476 pub fn known_files(&self) -> Result<Vec<String>> {
478 let mut stmt = self.db.prepare(
479 "SELECT path FROM known_files ORDER BY added_at DESC",
480 ).map_err(SqzError::SessionStore)?;
481
482 let rows = stmt.query_map([], |row| {
483 row.get::<_, String>(0)
484 }).map_err(SqzError::SessionStore)?;
485
486 let mut files = Vec::new();
487 for row in rows {
488 files.push(row.map_err(SqzError::SessionStore)?);
489 }
490 Ok(files)
491 }
492
493 pub fn clear_known_files(&self) -> Result<()> {
495 self.db.execute("DELETE FROM known_files", [])
496 .map_err(SqzError::SessionStore)?;
497 Ok(())
498 }
499}
500
501#[derive(Debug, Clone, Default)]
503pub struct CompressionStats {
504 pub total_compressions: u32,
505 pub total_tokens_in: u64,
506 pub total_tokens_out: u64,
507}
508
509impl CompressionStats {
510 pub fn tokens_saved(&self) -> u64 {
511 self.total_tokens_in.saturating_sub(self.total_tokens_out)
512 }
513
514 pub fn reduction_pct(&self) -> f64 {
515 if self.total_tokens_in == 0 {
516 0.0
517 } else {
518 (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
519 }
520 }
521}
522
523#[derive(Debug, Clone)]
525pub struct DailyGain {
526 pub date: String,
527 pub compressions: u32,
528 pub tokens_saved: u64,
529 pub tokens_in: u64,
530}
531
532#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
538 use chrono::Utc;
539 use proptest::prelude::*;
540 use std::path::PathBuf;
541
542 fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
543 let now = Utc::now();
544 SessionState {
545 id: id.to_string(),
546 project_dir: PathBuf::from(project_dir),
547 conversation: vec![],
548 corrections: CorrectionLog::default(),
549 pins: vec![],
550 learnings: vec![],
551 compressed_summary: summary.to_string(),
552 budget: BudgetState {
553 window_size: 200_000,
554 consumed: 0,
555 pinned: 0,
556 model_family: ModelFamily::AnthropicClaude,
557 },
558 tool_usage: vec![],
559 created_at: now,
560 updated_at: now,
561 }
562 }
563
564 fn in_memory_store() -> SessionStore {
565 let conn = Connection::open_in_memory().unwrap();
566 apply_schema(&conn).unwrap();
567 SessionStore { db: conn }
568 }
569
570 #[test]
571 fn test_save_and_load_session() {
572 let store = in_memory_store();
573 let session = make_session("sess-1", "/home/user/project", "REST API refactor");
574
575 let id = store.save_session(&session).unwrap();
576 assert_eq!(id, "sess-1");
577
578 let loaded = store.load_session("sess-1".to_string()).unwrap();
579 assert_eq!(loaded.id, session.id);
580 assert_eq!(loaded.compressed_summary, session.compressed_summary);
581 assert_eq!(loaded.project_dir, session.project_dir);
582 }
583
584 #[test]
585 fn test_save_session_upsert() {
586 let store = in_memory_store();
587 let mut session = make_session("sess-2", "/proj", "initial summary");
588 store.save_session(&session).unwrap();
589
590 session.compressed_summary = "updated summary".to_string();
591 store.save_session(&session).unwrap();
592
593 let loaded = store.load_session("sess-2".to_string()).unwrap();
594 assert_eq!(loaded.compressed_summary, "updated summary");
595 }
596
597 #[test]
598 fn test_load_nonexistent_session_errors() {
599 let store = in_memory_store();
600 let result = store.load_session("does-not-exist".to_string());
601 assert!(result.is_err());
602 }
603
604 #[test]
605 fn test_search_fts() {
606 let store = in_memory_store();
607 store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
608 store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
609
610 let results = store.search("authentication").unwrap();
611 assert_eq!(results.len(), 1);
612 assert_eq!(results[0].id, "s1");
613 }
614
615 #[test]
616 fn test_search_by_date() {
617 let store = in_memory_store();
618 let now = Utc::now();
619 let past = now - chrono::Duration::hours(2);
620 let future = now + chrono::Duration::hours(2);
621
622 store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
623
624 let results = store.search_by_date(past, future).unwrap();
625 assert!(!results.is_empty());
626 assert!(results.iter().any(|r| r.id == "s1"));
627 }
628
629 #[test]
630 fn test_search_by_project() {
631 let store = in_memory_store();
632 store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
633 store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
634
635 let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
636 assert_eq!(results.len(), 1);
637 assert_eq!(results[0].id, "s1");
638 }
639
640 #[test]
641 fn test_cache_entry_round_trip() {
642 let store = in_memory_store();
643 let entry = CompressedContent {
644 data: "compressed data".to_string(),
645 tokens_compressed: 10,
646 tokens_original: 50,
647 stages_applied: vec!["strip_nulls".to_string()],
648 compression_ratio: 0.2,
649 provenance: crate::types::Provenance::default(),
650 verify: None,
651 };
652
653 store.save_cache_entry("abc123", &entry).unwrap();
654
655 let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
656 assert_eq!(loaded.data, entry.data);
657 assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
658 assert_eq!(loaded.tokens_original, entry.tokens_original);
659 }
660
661 #[test]
662 fn test_get_cache_entry_missing_returns_none() {
663 let store = in_memory_store();
664 let result = store.get_cache_entry("nonexistent").unwrap();
665 assert!(result.is_none());
666 }
667
668 #[test]
669 fn test_open_or_create_corrupted_db() {
670 let dir = tempfile::tempdir().unwrap();
671 let path = dir.path().join("store.db");
672
673 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
675
676 let store = SessionStore::open_or_create(&path).unwrap();
678 let session = make_session("s1", "/proj", "after corruption");
679 store.save_session(&session).unwrap();
680 let loaded = store.load_session("s1".to_string()).unwrap();
681 assert_eq!(loaded.id, "s1");
682 }
683
684 fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
688 let now = Utc::now();
689 SessionState {
690 id: id.to_string(),
691 project_dir: PathBuf::from("/proj"),
692 conversation: vec![],
693 corrections: CorrectionLog::default(),
694 pins: vec![],
695 learnings: vec![],
696 compressed_summary: summary.to_string(),
697 budget: BudgetState {
698 window_size: 200_000,
699 consumed: 0,
700 pinned: 0,
701 model_family: ModelFamily::AnthropicClaude,
702 },
703 tool_usage: vec![],
704 created_at: now,
705 updated_at,
706 }
707 }
708
709 proptest! {
717 #[test]
723 fn prop_search_correctness(
724 keyword in "[b-df-hj-np-tv-z]{5,8}",
727 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
729 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
731 ) {
732 for s in &non_matching {
734 prop_assume!(!s.contains(keyword.as_str()));
735 }
736
737 let store = in_memory_store();
738
739 let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
741 for (i, suffix) in matching_suffixes.iter().enumerate() {
742 let id = format!("match-{i}");
743 let summary = format!("{} {} end", suffix, keyword);
744 store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
745 matching_ids.insert(id);
746 }
747
748 let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
750 for (i, summary) in non_matching.iter().enumerate() {
751 let id = format!("nomatch-{i}");
752 store.save_session(&make_session(&id, "/proj", summary)).unwrap();
753 non_matching_ids.insert(id);
754 }
755
756 let results = store.search(&keyword).unwrap();
757 let result_ids: std::collections::HashSet<String> =
758 results.iter().map(|r| r.id.clone()).collect();
759
760 for id in &matching_ids {
762 prop_assert!(
763 result_ids.contains(id),
764 "matching session '{}' not found in search results for keyword '{}'",
765 id, keyword
766 );
767 }
768
769 for id in &non_matching_ids {
771 prop_assert!(
772 !result_ids.contains(id),
773 "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
774 id, keyword
775 );
776 }
777 }
778 }
779
780 proptest! {
788 #[test]
794 fn prop_search_by_date_correctness(
795 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
797 window_start_delta in 0i64..=3600i64,
799 window_end_delta in 3600i64..=7200i64,
800 ) {
801 use chrono::TimeZone;
802
803 let mut unique_offsets: Vec<i64> = offsets.clone();
805 unique_offsets.sort_unstable();
806 unique_offsets.dedup();
807 prop_assume!(unique_offsets.len() >= 2);
808
809 let base_offset = unique_offsets[0];
810 let from_offset = base_offset + window_start_delta;
811 let to_offset = base_offset + window_end_delta;
812
813 let from = Utc.timestamp_opt(from_offset, 0).unwrap();
814 let to = Utc.timestamp_opt(to_offset, 0).unwrap();
815
816 let store = in_memory_store();
817
818 let mut in_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
819 let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
820
821 for (i, &offset) in unique_offsets.iter().enumerate() {
822 let ts = Utc.timestamp_opt(offset, 0).unwrap();
823 let id = format!("sess-{i}");
824 let session = make_session_at(&id, "some summary", ts);
825 store.save_session(&session).unwrap();
826
827 if ts >= from && ts <= to {
828 in_range_ids.insert(id);
829 } else {
830 out_range_ids.insert(id);
831 }
832 }
833
834 let results = store.search_by_date(from, to).unwrap();
835 let result_ids: std::collections::HashSet<String> =
836 results.iter().map(|r| r.id.clone()).collect();
837
838 for id in &in_range_ids {
840 prop_assert!(
841 result_ids.contains(id),
842 "in-range session '{}' missing from search_by_date results",
843 id
844 );
845 }
846
847 for id in &out_range_ids {
849 prop_assert!(
850 !result_ids.contains(id),
851 "out-of-range session '{}' incorrectly appeared in search_by_date results",
852 id
853 );
854 }
855 }
856 }
857}