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