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 -- Raw pre-compression bytes so `sqz expand <prefix>` can serve
86 -- truly uncompressed content to agents that cannot parse
87 -- `§ref:…§` dedup tokens. Nullable because the column was added
88 -- in an additive migration; rows written before that migration
89 -- (or via callers that don't have the original bytes) have NULL.
90 original BLOB,
91 -- Pinned entries bypass the TTL freshness check. Used for stable
92 -- knowledge-base content (a wiki, project documentation, system
93 -- prompts) that's persistently re-injected into every agent and
94 -- therefore never gets compacted out of the LLM's context. See
95 -- `sqz pin` for the user-facing CLI.
96 pinned INTEGER NOT NULL DEFAULT 0
97);
98
99CREATE TABLE IF NOT EXISTS compression_log (
100 id INTEGER PRIMARY KEY AUTOINCREMENT,
101 tokens_original INTEGER NOT NULL,
102 tokens_compressed INTEGER NOT NULL,
103 stages_applied TEXT NOT NULL,
104 mode TEXT NOT NULL DEFAULT 'auto',
105 created_at TEXT NOT NULL
106);
107
108CREATE TABLE IF NOT EXISTS known_files (
109 path TEXT PRIMARY KEY,
110 added_at TEXT NOT NULL
111);
112
113-- Small key/value store for engine-wide state that needs to persist across
114-- short-lived sqz processes (each shell-hook invocation is a new process).
115-- Initially used only for the last_compaction_at marker: cache entries with
116-- `accessed_at < last_compaction_at` are treated as stale even if still
117-- within the normal TTL. See cache_manager.rs for the freshness model.
118CREATE TABLE IF NOT EXISTS metadata (
119 key TEXT PRIMARY KEY,
120 value TEXT NOT NULL
121);
122"#;
123
124pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
127 conn.execute_batch(SCHEMA)?;
128 let has_original: bool = conn
140 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'original'")?
141 .query_row([], |_| Ok(()))
142 .is_ok();
143 if !has_original {
144 conn.execute("ALTER TABLE cache_entries ADD COLUMN original BLOB", [])?;
145 }
146
147 let has_project_dir: bool = conn
152 .prepare("SELECT 1 FROM pragma_table_info('compression_log') WHERE name = 'project_dir'")?
153 .query_row([], |_| Ok(()))
154 .is_ok();
155 if !has_project_dir {
156 conn.execute("ALTER TABLE compression_log ADD COLUMN project_dir TEXT", [])?;
157 }
158
159 let has_pinned: bool = conn
163 .prepare("SELECT 1 FROM pragma_table_info('cache_entries') WHERE name = 'pinned'")?
164 .query_row([], |_| Ok(()))
165 .is_ok();
166 if !has_pinned {
167 conn.execute(
168 "ALTER TABLE cache_entries ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0",
169 [],
170 )?;
171 }
172
173 Ok(())
174}
175
176fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
177 let conn = Connection::open(path)?;
178 apply_schema(&conn)?;
179 Ok(conn)
180}
181
182fn row_to_summary(
183 id: String,
184 project_dir: String,
185 compressed_summary: String,
186 created_at: String,
187 updated_at: String,
188) -> Result<SessionSummary> {
189 let created_at = created_at
190 .parse::<DateTime<Utc>>()
191 .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
192 let updated_at = updated_at
193 .parse::<DateTime<Utc>>()
194 .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
195 Ok(SessionSummary {
196 id,
197 project_dir: PathBuf::from(project_dir),
198 compressed_summary,
199 created_at,
200 updated_at,
201 })
202}
203
204impl SessionStore {
207 #[cfg(test)]
210 pub(crate) fn from_connection(conn: Connection) -> Self {
211 Self { db: conn }
212 }
213
214 pub fn open(path: &Path) -> Result<Self> {
217 let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
218 apply_schema(&conn)?;
219 Ok(Self { db: conn })
220 }
221
222 pub fn open_or_create(path: &Path) -> Result<Self> {
226 match open_connection(path) {
227 Ok(conn) => Ok(Self { db: conn }),
228 Err(e) => {
229 eprintln!(
230 "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
231 Creating a new database. Prior session data has been lost.",
232 path.display()
233 );
234 if path.exists() {
236 let _ = std::fs::remove_file(path);
237 }
238 let conn = open_connection(path)
239 .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
240 Ok(Self { db: conn })
241 }
242 }
243 }
244
245 pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
249 let data = serde_json::to_vec(session)?;
250 let project_dir = session.project_dir.to_string_lossy().to_string();
251 let created_at = session.created_at.to_rfc3339();
252 let updated_at = session.updated_at.to_rfc3339();
253
254 self.db.execute(
255 r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
256 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
257 ON CONFLICT(id) DO UPDATE SET
258 project_dir = excluded.project_dir,
259 compressed_summary = excluded.compressed_summary,
260 created_at = excluded.created_at,
261 updated_at = excluded.updated_at,
262 data = excluded.data"#,
263 params![
264 session.id,
265 project_dir,
266 session.compressed_summary,
267 created_at,
268 updated_at,
269 data,
270 ],
271 )?;
272
273 Ok(session.id.clone())
274 }
275
276 pub fn save_dashboard_session(&self, id: &str, summary: &str) -> Result<()> {
278 let now = Utc::now().to_rfc3339();
279 let project_dir = std::env::current_dir()
280 .unwrap_or_else(|_| std::path::PathBuf::from("."))
281 .to_string_lossy()
282 .to_string();
283 let empty_data = serde_json::to_vec(&serde_json::json!({"conversation":[]}))?;
284
285 self.db
286 .execute(
287 "INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data) \
288 VALUES (?1, ?2, ?3, ?4, ?5, ?6) \
289 ON CONFLICT(id) DO UPDATE SET \
290 compressed_summary = excluded.compressed_summary, \
291 updated_at = excluded.updated_at",
292 params![id, project_dir, summary, now, now, empty_data],
293 )
294 .map_err(SqzError::SessionStore)?;
295 Ok(())
296 }
297
298 pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
300 let data: Vec<u8> = self.db.query_row(
301 "SELECT data FROM sessions WHERE id = ?1",
302 params![id],
303 |row| row.get(0),
304 )?;
305 let session: SessionState = serde_json::from_slice(&data)?;
306 Ok(session)
307 }
308
309 pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
313 let mut stmt = self.db.prepare(
314 r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
315 FROM sessions s
316 JOIN sessions_fts f ON s.rowid = f.rowid
317 WHERE sessions_fts MATCH ?1
318 ORDER BY rank"#,
319 )?;
320
321 let rows = stmt.query_map(params![query], |row| {
322 Ok((
323 row.get::<_, String>(0)?,
324 row.get::<_, String>(1)?,
325 row.get::<_, String>(2)?,
326 row.get::<_, String>(3)?,
327 row.get::<_, String>(4)?,
328 ))
329 })?;
330
331 let mut results = Vec::new();
332 for row in rows {
333 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
334 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
335 }
336 Ok(results)
337 }
338
339 pub fn search_by_date(
341 &self,
342 from: DateTime<Utc>,
343 to: DateTime<Utc>,
344 ) -> Result<Vec<SessionSummary>> {
345 let mut stmt = self.db.prepare(
346 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
347 FROM sessions
348 WHERE updated_at >= ?1 AND updated_at <= ?2
349 ORDER BY updated_at DESC"#,
350 )?;
351
352 let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
353 Ok((
354 row.get::<_, String>(0)?,
355 row.get::<_, String>(1)?,
356 row.get::<_, String>(2)?,
357 row.get::<_, String>(3)?,
358 row.get::<_, String>(4)?,
359 ))
360 })?;
361
362 let mut results = Vec::new();
363 for row in rows {
364 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
365 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
366 }
367 Ok(results)
368 }
369
370 pub fn latest_session(&self) -> Result<Option<SessionSummary>> {
372 let mut stmt = self.db.prepare(
373 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
374 FROM sessions
375 ORDER BY updated_at DESC
376 LIMIT 1"#,
377 ).map_err(SqzError::SessionStore)?;
378
379 let rows = stmt.query_map([], |row| {
380 Ok((
381 row.get::<_, String>(0)?,
382 row.get::<_, String>(1)?,
383 row.get::<_, String>(2)?,
384 row.get::<_, String>(3)?,
385 row.get::<_, String>(4)?,
386 ))
387 }).map_err(SqzError::SessionStore)?;
388
389 for row in rows {
390 let (id, project_dir, compressed_summary, created_at, updated_at) =
391 row.map_err(SqzError::SessionStore)?;
392 return Ok(Some(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?));
393 }
394 Ok(None)
395 }
396
397 pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
399 let dir_str = dir.to_string_lossy().to_string();
400 let mut stmt = self.db.prepare(
401 r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
402 FROM sessions
403 WHERE project_dir = ?1
404 ORDER BY updated_at DESC"#,
405 )?;
406
407 let rows = stmt.query_map(params![dir_str], |row| {
408 Ok((
409 row.get::<_, String>(0)?,
410 row.get::<_, String>(1)?,
411 row.get::<_, String>(2)?,
412 row.get::<_, String>(3)?,
413 row.get::<_, String>(4)?,
414 ))
415 })?;
416
417 let mut results = Vec::new();
418 for row in rows {
419 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
420 results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
421 }
422 Ok(results)
423 }
424
425 pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
438 self.save_cache_entry_with_original(hash, compressed, None)
439 }
440
441 pub fn save_cache_entry_with_original(
448 &self,
449 hash: &str,
450 compressed: &CompressedContent,
451 original: Option<&[u8]>,
452 ) -> Result<()> {
453 let data = serde_json::to_string(compressed)?;
454 let now = Utc::now().to_rfc3339();
455 self.db.execute(
456 r#"INSERT INTO cache_entries (hash, data, accessed_at, original)
457 VALUES (?1, ?2, ?3, ?4)
458 ON CONFLICT(hash) DO UPDATE
459 SET data = excluded.data,
460 accessed_at = excluded.accessed_at,
461 -- Don't overwrite a previously-stored `original`
462 -- with NULL. Older callers (that go through
463 -- save_cache_entry rather than the _with_original
464 -- variant) shouldn't erase the expand-able bytes.
465 original = COALESCE(excluded.original, original)"#,
466 params![hash, data, now, original],
467 )?;
468 Ok(())
469 }
470
471 pub fn get_cache_entry_original(&self, hash: &str) -> Result<Option<Vec<u8>>> {
480 let result: rusqlite::Result<Option<Vec<u8>>> = self.db.query_row(
481 "SELECT original FROM cache_entries WHERE hash = ?1",
482 params![hash],
483 |row| row.get(0),
484 );
485 match result {
486 Ok(v) => Ok(v),
487 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
488 Err(e) => Err(SqzError::SessionStore(e)),
489 }
490 }
491
492 pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
494 self.db.execute(
495 "DELETE FROM cache_entries WHERE hash = ?1",
496 params![hash],
497 )?;
498 Ok(())
499 }
500
501 pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
505 let mut stmt = self.db.prepare(
506 "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
507 )?;
508 let rows = stmt.query_map([], |row| {
509 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
510 })?;
511 let mut entries = Vec::new();
512 for row in rows {
513 let (hash, size) = row?;
514 entries.push((hash, size as u64));
515 }
516 Ok(entries)
517 }
518
519 pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
521 let result: rusqlite::Result<String> = self.db.query_row(
522 "SELECT data FROM cache_entries WHERE hash = ?1",
523 params![hash],
524 |row| row.get(0),
525 );
526
527 match result {
528 Ok(data) => {
529 let now = Utc::now().to_rfc3339();
531 let _ = self.db.execute(
532 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
533 params![now, hash],
534 );
535 let entry: CompressedContent = serde_json::from_str(&data)?;
536 Ok(Some(entry))
537 }
538 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
539 Err(e) => Err(SqzError::SessionStore(e)),
540 }
541 }
542
543 pub fn get_cache_entry_by_prefix(
563 &self,
564 prefix: &str,
565 ) -> Result<Option<(String, CompressedContent)>> {
566 if prefix.is_empty() || !prefix.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) {
571 return Ok(None);
572 }
573 let pattern = format!("{prefix}%");
574 let mut stmt = self
575 .db
576 .prepare("SELECT hash, data FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
577 let mut rows = stmt.query(params![pattern])?;
578
579 let first = match rows.next()? {
580 Some(r) => {
581 let hash: String = r.get(0)?;
582 let data: String = r.get(1)?;
583 (hash, data)
584 }
585 None => return Ok(None),
586 };
587
588 if rows.next()?.is_some() {
592 return Err(SqzError::Other(format!(
593 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
594 )));
595 }
596 drop(rows);
597 drop(stmt);
598
599 let now = Utc::now().to_rfc3339();
601 let _ = self.db.execute(
602 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
603 params![now, first.0],
604 );
605 let entry: CompressedContent = serde_json::from_str(&first.1)?;
606 Ok(Some((first.0, entry)))
607 }
608
609 pub fn get_cache_entry_accessed_at(&self, hash: &str) -> Result<Option<DateTime<Utc>>> {
616 let result: rusqlite::Result<String> = self.db.query_row(
617 "SELECT accessed_at FROM cache_entries WHERE hash = ?1",
618 params![hash],
619 |row| row.get(0),
620 );
621 match result {
622 Ok(s) => {
623 let ts = s
624 .parse::<DateTime<Utc>>()
625 .map_err(|e| SqzError::Other(format!("invalid accessed_at: {e}")))?;
626 Ok(Some(ts))
627 }
628 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
629 Err(e) => Err(SqzError::SessionStore(e)),
630 }
631 }
632
633 pub fn cache_entry_exists(&self, hash: &str) -> Result<bool> {
635 let result: rusqlite::Result<i64> = self.db.query_row(
636 "SELECT 1 FROM cache_entries WHERE hash = ?1",
637 params![hash],
638 |row| row.get(0),
639 );
640 match result {
641 Ok(_) => Ok(true),
642 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
643 Err(e) => Err(SqzError::SessionStore(e)),
644 }
645 }
646
647 pub fn touch_cache_entry(&self, hash: &str) -> Result<()> {
651 let now = Utc::now().to_rfc3339();
652 self.db.execute(
653 "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
654 params![now, hash],
655 )?;
656 Ok(())
657 }
658
659 pub fn resolve_cache_hash_by_prefix(&self, prefix: &str) -> Result<Option<String>> {
665 if prefix.is_empty()
666 || !prefix
667 .chars()
668 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
669 {
670 return Ok(None);
671 }
672 let pattern = format!("{prefix}%");
673 let mut stmt = self
674 .db
675 .prepare("SELECT hash FROM cache_entries WHERE hash LIKE ?1 LIMIT 2")?;
676 let mut rows = stmt.query(params![pattern])?;
677 let first: Option<String> = match rows.next()? {
678 Some(r) => Some(r.get(0)?),
679 None => None,
680 };
681 if rows.next()?.is_some() {
682 return Err(SqzError::Other(format!(
683 "cache: prefix '{prefix}' matches multiple entries — use a longer prefix"
684 )));
685 }
686 Ok(first)
687 }
688
689 pub fn pin_cache_entry(&self, hash: &str) -> Result<bool> {
693 let n = self.db.execute(
694 "UPDATE cache_entries SET pinned = 1 WHERE hash = ?1",
695 params![hash],
696 )?;
697 Ok(n > 0)
698 }
699
700 pub fn unpin_cache_entry(&self, hash: &str) -> Result<bool> {
703 let n = self.db.execute(
704 "UPDATE cache_entries SET pinned = 0 WHERE hash = ?1",
705 params![hash],
706 )?;
707 Ok(n > 0)
708 }
709
710 pub fn unpin_all_cache_entries(&self) -> Result<usize> {
713 let n = self
714 .db
715 .execute("UPDATE cache_entries SET pinned = 0 WHERE pinned = 1", [])?;
716 Ok(n)
717 }
718
719 pub fn is_cache_entry_pinned(&self, hash: &str) -> Result<bool> {
722 let result: rusqlite::Result<i64> = self.db.query_row(
723 "SELECT pinned FROM cache_entries WHERE hash = ?1",
724 params![hash],
725 |row| row.get(0),
726 );
727 match result {
728 Ok(v) => Ok(v != 0),
729 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
730 Err(e) => Err(SqzError::SessionStore(e)),
731 }
732 }
733
734 pub fn list_pinned_cache_entries(
737 &self,
738 ) -> Result<Vec<(String, DateTime<Utc>, u64)>> {
739 let mut stmt = self
740 .db
741 .prepare(
742 "SELECT hash, accessed_at, length(data) FROM cache_entries \
743 WHERE pinned = 1 ORDER BY accessed_at DESC",
744 )
745 .map_err(SqzError::SessionStore)?;
746 let rows = stmt
747 .query_map([], |row| {
748 Ok((
749 row.get::<_, String>(0)?,
750 row.get::<_, String>(1)?,
751 row.get::<_, i64>(2)? as u64,
752 ))
753 })
754 .map_err(SqzError::SessionStore)?;
755 let mut out = Vec::new();
756 for row in rows {
757 let (hash, accessed_at_str, size) = row?;
758 let accessed_at = accessed_at_str.parse::<DateTime<Utc>>().map_err(|e| {
759 SqzError::Other(format!("invalid accessed_at on pinned entry: {e}"))
760 })?;
761 out.push((hash, accessed_at, size));
762 }
763 Ok(out)
764 }
765
766 pub fn set_metadata(&self, key: &str, value: &str) -> Result<()> {
769 self.db.execute(
770 "INSERT INTO metadata (key, value) VALUES (?1, ?2)
771 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
772 params![key, value],
773 )?;
774 Ok(())
775 }
776
777 pub fn get_metadata(&self, key: &str) -> Result<Option<String>> {
779 let result: rusqlite::Result<String> = self.db.query_row(
780 "SELECT value FROM metadata WHERE key = ?1",
781 params![key],
782 |row| row.get(0),
783 );
784 match result {
785 Ok(v) => Ok(Some(v)),
786 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
787 Err(e) => Err(SqzError::SessionStore(e)),
788 }
789 }
790
791 pub fn log_compression(
793 &self,
794 tokens_original: u32,
795 tokens_compressed: u32,
796 stages: &[String],
797 mode: &str,
798 ) -> Result<()> {
799 self.log_compression_with_project(tokens_original, tokens_compressed, stages, mode, None)
800 }
801
802 pub fn log_compression_with_project(
804 &self,
805 tokens_original: u32,
806 tokens_compressed: u32,
807 stages: &[String],
808 mode: &str,
809 project_dir: Option<&str>,
810 ) -> Result<()> {
811 let now = Utc::now().to_rfc3339();
812 let stages_str = stages.join(",");
813 self.db.execute(
814 "INSERT INTO compression_log (tokens_original, tokens_compressed, stages_applied, mode, created_at, project_dir) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
815 params![tokens_original, tokens_compressed, stages_str, mode, now, project_dir],
816 ).map_err(SqzError::SessionStore)?;
817 Ok(())
818 }
819
820 pub fn compression_stats(&self) -> Result<CompressionStats> {
822 let mut stmt = self.db.prepare(
823 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) FROM compression_log",
824 ).map_err(SqzError::SessionStore)?;
825
826 let stats = stmt.query_row([], |row| {
827 Ok(CompressionStats {
828 total_compressions: row.get::<_, u32>(0)?,
829 total_tokens_in: row.get::<_, u64>(1)?,
830 total_tokens_out: row.get::<_, u64>(2)?,
831 })
832 }).map_err(SqzError::SessionStore)?;
833
834 Ok(stats)
835 }
836
837 pub fn daily_gains(&self, days: u32) -> Result<Vec<DailyGain>> {
839 let mut stmt = self.db.prepare(
840 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
841 FROM compression_log \
842 WHERE created_at >= date('now', ?1) \
843 GROUP BY d ORDER BY d",
844 ).map_err(SqzError::SessionStore)?;
845
846 let offset = format!("-{days} days");
847 let rows = stmt.query_map(params![offset], |row| {
848 let tokens_in: u64 = row.get(2)?;
849 let tokens_out: u64 = row.get(3)?;
850 Ok(DailyGain {
851 date: row.get(0)?,
852 compressions: row.get(1)?,
853 tokens_in,
854 tokens_saved: tokens_in.saturating_sub(tokens_out),
855 })
856 }).map_err(SqzError::SessionStore)?;
857
858 let mut gains = Vec::new();
859 for row in rows {
860 gains.push(row.map_err(SqzError::SessionStore)?);
861 }
862 Ok(gains)
863 }
864
865 pub fn compression_stats_for_project(&self, project_dir: &str) -> Result<CompressionStats> {
867 let mut stmt = self.db.prepare(
868 "SELECT COUNT(*), COALESCE(SUM(tokens_original), 0), COALESCE(SUM(tokens_compressed), 0) \
869 FROM compression_log WHERE project_dir = ?1",
870 ).map_err(SqzError::SessionStore)?;
871
872 let stats = stmt.query_row(params![project_dir], |row| {
873 Ok(CompressionStats {
874 total_compressions: row.get::<_, u32>(0)?,
875 total_tokens_in: row.get::<_, u64>(1)?,
876 total_tokens_out: row.get::<_, u64>(2)?,
877 })
878 }).map_err(SqzError::SessionStore)?;
879
880 Ok(stats)
881 }
882
883 pub fn daily_gains_for_project(&self, days: u32, project_dir: &str) -> Result<Vec<DailyGain>> {
885 let mut stmt = self.db.prepare(
886 "SELECT date(created_at) as d, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
887 FROM compression_log \
888 WHERE created_at >= date('now', ?1) AND project_dir = ?2 \
889 GROUP BY d ORDER BY d",
890 ).map_err(SqzError::SessionStore)?;
891
892 let offset = format!("-{days} days");
893 let rows = stmt.query_map(params![offset, project_dir], |row| {
894 let tokens_in: u64 = row.get(2)?;
895 let tokens_out: u64 = row.get(3)?;
896 Ok(DailyGain {
897 date: row.get(0)?,
898 compressions: row.get(1)?,
899 tokens_in,
900 tokens_saved: tokens_in.saturating_sub(tokens_out),
901 })
902 }).map_err(SqzError::SessionStore)?;
903
904 let mut gains = Vec::new();
905 for row in rows {
906 gains.push(row.map_err(SqzError::SessionStore)?);
907 }
908 Ok(gains)
909 }
910
911 pub fn list_projects(&self) -> Result<Vec<(String, u32, u64)>> {
913 let mut stmt = self.db.prepare(
914 "SELECT project_dir, COUNT(*), COALESCE(SUM(tokens_original) - SUM(tokens_compressed), 0) \
915 FROM compression_log \
916 WHERE project_dir IS NOT NULL \
917 GROUP BY project_dir \
918 ORDER BY COUNT(*) DESC",
919 ).map_err(SqzError::SessionStore)?;
920
921 let rows = stmt.query_map([], |row| {
922 Ok((
923 row.get::<_, String>(0)?,
924 row.get::<_, u32>(1)?,
925 row.get::<_, u64>(2)?,
926 ))
927 }).map_err(SqzError::SessionStore)?;
928
929 let mut projects = Vec::new();
930 for row in rows {
931 projects.push(row.map_err(SqzError::SessionStore)?);
932 }
933 Ok(projects)
934 }
935
936 pub fn command_breakdown(&self, limit: u32) -> Result<Vec<CommandStats>> {
940 let mut stmt = self.db.prepare(
941 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
942 FROM compression_log \
943 GROUP BY mode \
944 ORDER BY SUM(tokens_original) DESC \
945 LIMIT ?1",
946 ).map_err(SqzError::SessionStore)?;
947
948 let rows = stmt.query_map(params![limit], |row| {
949 let tokens_in: u64 = row.get(2)?;
950 let tokens_out: u64 = row.get(3)?;
951 Ok(CommandStats {
952 command: row.get(0)?,
953 invocations: row.get(1)?,
954 tokens_in,
955 tokens_out,
956 tokens_saved: tokens_in.saturating_sub(tokens_out),
957 })
958 }).map_err(SqzError::SessionStore)?;
959
960 let mut results = Vec::new();
961 for row in rows {
962 results.push(row.map_err(SqzError::SessionStore)?);
963 }
964 Ok(results)
965 }
966
967 pub fn command_breakdown_for_project(&self, limit: u32, project_dir: &str) -> Result<Vec<CommandStats>> {
969 let mut stmt = self.db.prepare(
970 "SELECT mode, COUNT(*), SUM(tokens_original), SUM(tokens_compressed) \
971 FROM compression_log \
972 WHERE project_dir = ?1 \
973 GROUP BY mode \
974 ORDER BY SUM(tokens_original) DESC \
975 LIMIT ?2",
976 ).map_err(SqzError::SessionStore)?;
977
978 let rows = stmt.query_map(params![project_dir, limit], |row| {
979 let tokens_in: u64 = row.get(2)?;
980 let tokens_out: u64 = row.get(3)?;
981 Ok(CommandStats {
982 command: row.get(0)?,
983 invocations: row.get(1)?,
984 tokens_in,
985 tokens_out,
986 tokens_saved: tokens_in.saturating_sub(tokens_out),
987 })
988 }).map_err(SqzError::SessionStore)?;
989
990 let mut results = Vec::new();
991 for row in rows {
992 results.push(row.map_err(SqzError::SessionStore)?);
993 }
994 Ok(results)
995 }
996
997 pub fn session_pressure(&self, since_minutes: u32) -> Result<u64> {
1001 let mut stmt = self.db.prepare(
1002 "SELECT COALESCE(SUM(tokens_compressed), 0) \
1003 FROM compression_log \
1004 WHERE created_at >= datetime('now', ?1)",
1005 ).map_err(SqzError::SessionStore)?;
1006
1007 let offset = format!("-{since_minutes} minutes");
1008 let total: u64 = stmt.query_row(params![offset], |row| {
1009 row.get(0)
1010 }).map_err(SqzError::SessionStore)?;
1011
1012 Ok(total)
1013 }
1014
1015 pub fn add_known_file(&self, path: &str) -> Result<()> {
1020 let now = Utc::now().to_rfc3339();
1021 self.db.execute(
1022 "INSERT OR REPLACE INTO known_files (path, added_at) VALUES (?1, ?2)",
1023 params![path, now],
1024 ).map_err(SqzError::SessionStore)?;
1025 Ok(())
1026 }
1027
1028 pub fn known_files(&self) -> Result<Vec<String>> {
1030 let mut stmt = self.db.prepare(
1031 "SELECT path FROM known_files ORDER BY added_at DESC",
1032 ).map_err(SqzError::SessionStore)?;
1033
1034 let rows = stmt.query_map([], |row| {
1035 row.get::<_, String>(0)
1036 }).map_err(SqzError::SessionStore)?;
1037
1038 let mut files = Vec::new();
1039 for row in rows {
1040 files.push(row.map_err(SqzError::SessionStore)?);
1041 }
1042 Ok(files)
1043 }
1044 pub fn clear_known_files(&self) -> Result<()> {
1046 self.db
1047 .execute("DELETE FROM known_files", [])
1048 .map_err(SqzError::SessionStore)?;
1049 Ok(())
1050 }
1051
1052 pub fn cache_entry_count(&self) -> Result<u64> {
1054 self.db.query_row(
1055 "SELECT COUNT(*) FROM cache_entries", [],
1056 |row| row.get(0),
1057 ).map_err(SqzError::SessionStore)
1058 }
1059
1060 pub fn record_cache_hit(&self) -> Result<()> {
1062 let count: u64 = self.db.query_row(
1063 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1064 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1065 ).unwrap_or(0);
1066 let new = count + 1;
1067 self.db.execute(
1068 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_hits', ?1)",
1069 params![new.to_string()],
1070 )?;
1071 Ok(())
1072 }
1073
1074 pub fn record_cache_miss(&self) -> Result<()> {
1076 let count: u64 = self.db.query_row(
1077 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1078 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1079 ).unwrap_or(0);
1080 let new = count + 1;
1081 self.db.execute(
1082 "INSERT OR REPLACE INTO metadata (key, value) VALUES ('cache_misses', ?1)",
1083 params![new.to_string()],
1084 )?;
1085 Ok(())
1086 }
1087
1088 pub fn cache_hit_miss_counts(&self) -> Result<(u64, u64)> {
1090 let hits: u64 = self.db.query_row(
1091 "SELECT value FROM metadata WHERE key = 'cache_hits'", [],
1092 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1093 ).unwrap_or(0);
1094 let misses: u64 = self.db.query_row(
1095 "SELECT value FROM metadata WHERE key = 'cache_misses'", [],
1096 |row| row.get::<_, String>(0).map(|s| s.parse::<u64>().unwrap_or(0)),
1097 ).unwrap_or(0);
1098 Ok((hits, misses))
1099 }
1100
1101 pub fn clear_cache(&self) -> Result<u64> {
1105 let count: u64 = self.db.query_row(
1106 "SELECT COUNT(*) FROM cache_entries", [],
1107 |row| row.get(0),
1108 ).unwrap_or(0);
1109 self.db.execute("DELETE FROM cache_entries", [])
1110 .map_err(SqzError::SessionStore)?;
1111 let _ = self.db.execute(
1114 "DELETE FROM metadata WHERE key = 'last_compaction_at'", [],
1115 );
1116 Ok(count)
1117 }
1118
1119 pub fn clear_stats(&self) -> Result<u64> {
1123 let count: u64 = self.db.query_row(
1124 "SELECT COUNT(*) FROM compression_log", [],
1125 |row| row.get(0),
1126 ).unwrap_or(0);
1127 self.db.execute("DELETE FROM compression_log", [])
1128 .map_err(SqzError::SessionStore)?;
1129 Ok(count)
1130 }
1131
1132 pub fn clear_stats_for_project(&self, project_dir: &str) -> Result<u64> {
1134 let count: u64 = self.db.query_row(
1135 "SELECT COUNT(*) FROM compression_log WHERE project_dir = ?1",
1136 rusqlite::params![project_dir],
1137 |row| row.get(0),
1138 ).unwrap_or(0);
1139 self.db.execute(
1140 "DELETE FROM compression_log WHERE project_dir = ?1",
1141 rusqlite::params![project_dir],
1142 ).map_err(SqzError::SessionStore)?;
1143 Ok(count)
1144 }
1145
1146 pub fn reset_all(&self) -> Result<()> {
1149 self.db.execute("DELETE FROM cache_entries", []).map_err(SqzError::SessionStore)?;
1150 self.db.execute("DELETE FROM compression_log", []).map_err(SqzError::SessionStore)?;
1151 self.db.execute("DELETE FROM sessions", []).map_err(SqzError::SessionStore)?;
1152 self.db.execute("DELETE FROM known_files", []).map_err(SqzError::SessionStore)?;
1153 self.db.execute("DELETE FROM metadata", []).map_err(SqzError::SessionStore)?;
1154 let _ = self.db.execute("VACUUM", []);
1156 Ok(())
1157 }
1158
1159 pub fn list_sessions(&self, limit: u32) -> Result<Vec<SessionSummary>> {
1161 let mut stmt = self
1162 .db
1163 .prepare(
1164 "SELECT id, project_dir, compressed_summary, created_at, updated_at \
1165 FROM sessions ORDER BY updated_at DESC LIMIT ?1",
1166 )
1167 .map_err(SqzError::SessionStore)?;
1168
1169 let rows = stmt
1170 .query_map(params![limit], |row| {
1171 Ok((
1172 row.get::<_, String>(0)?,
1173 row.get::<_, String>(1)?,
1174 row.get::<_, String>(2)?,
1175 row.get::<_, String>(3)?,
1176 row.get::<_, String>(4)?,
1177 ))
1178 })
1179 .map_err(SqzError::SessionStore)?;
1180
1181 let mut results = Vec::new();
1182 for row in rows {
1183 let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
1184 results.push(row_to_summary(
1185 id,
1186 project_dir,
1187 compressed_summary,
1188 created_at,
1189 updated_at,
1190 )?);
1191 }
1192 Ok(results)
1193 }
1194
1195 pub fn per_tool_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1197 let mut stmt = self
1198 .db
1199 .prepare(
1200 "SELECT stages_applied, SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1201 FROM compression_log GROUP BY stages_applied ORDER BY COUNT(*) DESC LIMIT 20",
1202 )
1203 .map_err(SqzError::SessionStore)?;
1204
1205 let rows = stmt
1206 .query_map([], |row| {
1207 Ok((
1208 row.get::<_, String>(0)?,
1209 row.get::<_, u64>(1)?,
1210 row.get::<_, u64>(2)?,
1211 row.get::<_, u32>(3)?,
1212 ))
1213 })
1214 .map_err(SqzError::SessionStore)?;
1215
1216 let mut tool_map: std::collections::HashMap<String, (u64, u64, u32)> =
1217 std::collections::HashMap::new();
1218 for row in rows {
1219 let (stages, tokens_in, tokens_out, count) = row?;
1220 for stage in stages.split(',') {
1221 let stage = stage.trim();
1222 if stage.is_empty() {
1223 continue;
1224 }
1225 let entry = tool_map.entry(stage.to_string()).or_default();
1226 entry.0 += tokens_in;
1227 entry.1 += tokens_out;
1228 entry.2 += count;
1229 }
1230 }
1231
1232 let mut result: Vec<_> = tool_map.into_iter().collect();
1233 result.sort_by(|a, b| b.1 .2.cmp(&a.1 .2));
1234 Ok(result
1235 .into_iter()
1236 .map(|(name, (tin, tout, cnt))| (name, tin, tout, cnt))
1237 .collect())
1238 }
1239
1240 pub fn per_command_breakdown(&self) -> Result<Vec<(String, u64, u64, u32)>> {
1242 let mut stmt = self
1243 .db
1244 .prepare(
1245 "SELECT COALESCE(mode, 'auto'), SUM(tokens_original), SUM(tokens_compressed), COUNT(*) \
1246 FROM compression_log GROUP BY mode ORDER BY COUNT(*) DESC LIMIT 20",
1247 )
1248 .map_err(SqzError::SessionStore)?;
1249
1250 let rows = stmt
1251 .query_map([], |row| {
1252 Ok((
1253 row.get::<_, String>(0)?,
1254 row.get::<_, u64>(1)?,
1255 row.get::<_, u64>(2)?,
1256 row.get::<_, u32>(3)?,
1257 ))
1258 })
1259 .map_err(SqzError::SessionStore)?;
1260
1261 let mut result = Vec::new();
1262 for row in rows {
1263 result.push(row?);
1264 }
1265 Ok(result)
1266 }
1267}
1268
1269#[derive(Debug, Clone, Default)]
1271pub struct CompressionStats {
1272 pub total_compressions: u32,
1273 pub total_tokens_in: u64,
1274 pub total_tokens_out: u64,
1275}
1276
1277impl CompressionStats {
1278 pub fn tokens_saved(&self) -> u64 {
1279 self.total_tokens_in.saturating_sub(self.total_tokens_out)
1280 }
1281
1282 pub fn reduction_pct(&self) -> f64 {
1283 if self.total_tokens_in == 0 {
1284 0.0
1285 } else {
1286 (1.0 - self.total_tokens_out as f64 / self.total_tokens_in as f64) * 100.0
1287 }
1288 }
1289}
1290
1291#[derive(Debug, Clone)]
1293pub struct DailyGain {
1294 pub date: String,
1295 pub compressions: u32,
1296 pub tokens_saved: u64,
1297 pub tokens_in: u64,
1298}
1299
1300#[derive(Debug, Clone)]
1302pub struct CommandStats {
1303 pub command: String,
1304 pub invocations: u32,
1305 pub tokens_in: u64,
1306 pub tokens_out: u64,
1307 pub tokens_saved: u64,
1308}
1309
1310impl CommandStats {
1311 pub fn reduction_pct(&self) -> f64 {
1312 if self.tokens_in == 0 {
1313 0.0
1314 } else {
1315 (1.0 - self.tokens_out as f64 / self.tokens_in as f64) * 100.0
1316 }
1317 }
1318}
1319
1320#[cfg(test)]
1323mod tests {
1324 use super::*;
1325 use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
1326 use chrono::Utc;
1327 use proptest::prelude::*;
1328 use std::path::PathBuf;
1329
1330 fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
1331 let now = Utc::now();
1332 SessionState {
1333 id: id.to_string(),
1334 project_dir: PathBuf::from(project_dir),
1335 conversation: vec![],
1336 corrections: CorrectionLog::default(),
1337 pins: vec![],
1338 learnings: vec![],
1339 compressed_summary: summary.to_string(),
1340 budget: BudgetState {
1341 window_size: 200_000,
1342 consumed: 0,
1343 pinned: 0,
1344 model_family: ModelFamily::AnthropicClaude,
1345 },
1346 tool_usage: vec![],
1347 created_at: now,
1348 updated_at: now,
1349 }
1350 }
1351
1352 fn in_memory_store() -> SessionStore {
1353 let conn = Connection::open_in_memory().unwrap();
1354 apply_schema(&conn).unwrap();
1355 SessionStore { db: conn }
1356 }
1357
1358 #[test]
1359 fn test_save_and_load_session() {
1360 let store = in_memory_store();
1361 let session = make_session("sess-1", "/home/user/project", "REST API refactor");
1362
1363 let id = store.save_session(&session).unwrap();
1364 assert_eq!(id, "sess-1");
1365
1366 let loaded = store.load_session("sess-1".to_string()).unwrap();
1367 assert_eq!(loaded.id, session.id);
1368 assert_eq!(loaded.compressed_summary, session.compressed_summary);
1369 assert_eq!(loaded.project_dir, session.project_dir);
1370 }
1371
1372 #[test]
1373 fn test_save_session_upsert() {
1374 let store = in_memory_store();
1375 let mut session = make_session("sess-2", "/proj", "initial summary");
1376 store.save_session(&session).unwrap();
1377
1378 session.compressed_summary = "updated summary".to_string();
1379 store.save_session(&session).unwrap();
1380
1381 let loaded = store.load_session("sess-2".to_string()).unwrap();
1382 assert_eq!(loaded.compressed_summary, "updated summary");
1383 }
1384
1385 #[test]
1386 fn test_load_nonexistent_session_errors() {
1387 let store = in_memory_store();
1388 let result = store.load_session("does-not-exist".to_string());
1389 assert!(result.is_err());
1390 }
1391
1392 #[test]
1393 fn test_search_fts() {
1394 let store = in_memory_store();
1395 store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
1396 store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
1397
1398 let results = store.search("authentication").unwrap();
1399 assert_eq!(results.len(), 1);
1400 assert_eq!(results[0].id, "s1");
1401 }
1402
1403 #[test]
1404 fn test_search_by_date() {
1405 let store = in_memory_store();
1406 let now = Utc::now();
1407 let past = now - chrono::Duration::hours(2);
1408 let future = now + chrono::Duration::hours(2);
1409
1410 store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
1411
1412 let results = store.search_by_date(past, future).unwrap();
1413 assert!(!results.is_empty());
1414 assert!(results.iter().any(|r| r.id == "s1"));
1415 }
1416
1417 #[test]
1418 fn test_search_by_project() {
1419 let store = in_memory_store();
1420 store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
1421 store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
1422
1423 let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
1424 assert_eq!(results.len(), 1);
1425 assert_eq!(results[0].id, "s1");
1426 }
1427
1428 #[test]
1429 fn test_cache_entry_round_trip() {
1430 let store = in_memory_store();
1431 let entry = CompressedContent {
1432 data: "compressed data".to_string(),
1433 tokens_compressed: 10,
1434 tokens_original: 50,
1435 stages_applied: vec!["strip_nulls".to_string()],
1436 compression_ratio: 0.2,
1437 provenance: crate::types::Provenance::default(),
1438 verify: None,
1439 };
1440
1441 store.save_cache_entry("abc123", &entry).unwrap();
1442
1443 let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
1444 assert_eq!(loaded.data, entry.data);
1445 assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
1446 assert_eq!(loaded.tokens_original, entry.tokens_original);
1447 }
1448
1449 #[test]
1450 fn test_get_cache_entry_missing_returns_none() {
1451 let store = in_memory_store();
1452 let result = store.get_cache_entry("nonexistent").unwrap();
1453 assert!(result.is_none());
1454 }
1455
1456 #[test]
1457 fn test_open_or_create_corrupted_db() {
1458 let dir = tempfile::tempdir().unwrap();
1459 let path = dir.path().join("store.db");
1460
1461 std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
1463
1464 let store = SessionStore::open_or_create(&path).unwrap();
1466 let session = make_session("s1", "/proj", "after corruption");
1467 store.save_session(&session).unwrap();
1468 let loaded = store.load_session("s1".to_string()).unwrap();
1469 assert_eq!(loaded.id, "s1");
1470 }
1471
1472 fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
1476 let now = Utc::now();
1477 SessionState {
1478 id: id.to_string(),
1479 project_dir: PathBuf::from("/proj"),
1480 conversation: vec![],
1481 corrections: CorrectionLog::default(),
1482 pins: vec![],
1483 learnings: vec![],
1484 compressed_summary: summary.to_string(),
1485 budget: BudgetState {
1486 window_size: 200_000,
1487 consumed: 0,
1488 pinned: 0,
1489 model_family: ModelFamily::AnthropicClaude,
1490 },
1491 tool_usage: vec![],
1492 created_at: now,
1493 updated_at,
1494 }
1495 }
1496
1497 proptest! {
1505 #[test]
1511 fn prop_search_correctness(
1512 keyword in "[b-df-hj-np-tv-z]{5,8}",
1515 matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
1517 non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
1519 ) {
1520 for s in &non_matching {
1522 prop_assume!(!s.contains(keyword.as_str()));
1523 }
1524
1525 let store = in_memory_store();
1526
1527 let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1529 for (i, suffix) in matching_suffixes.iter().enumerate() {
1530 let id = format!("match-{i}");
1531 let summary = format!("{} {} end", suffix, keyword);
1532 store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
1533 matching_ids.insert(id);
1534 }
1535
1536 let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1538 for (i, summary) in non_matching.iter().enumerate() {
1539 let id = format!("nomatch-{i}");
1540 store.save_session(&make_session(&id, "/proj", summary)).unwrap();
1541 non_matching_ids.insert(id);
1542 }
1543
1544 let results = store.search(&keyword).unwrap();
1545 let result_ids: std::collections::HashSet<String> =
1546 results.iter().map(|r| r.id.clone()).collect();
1547
1548 for id in &matching_ids {
1550 prop_assert!(
1551 result_ids.contains(id),
1552 "matching session '{}' not found in search results for keyword '{}'",
1553 id, keyword
1554 );
1555 }
1556
1557 for id in &non_matching_ids {
1559 prop_assert!(
1560 !result_ids.contains(id),
1561 "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
1562 id, keyword
1563 );
1564 }
1565 }
1566 }
1567
1568 proptest! {
1576 #[test]
1582 fn prop_search_by_date_correctness(
1583 offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
1585 window_start_delta in 0i64..=3600i64,
1587 window_end_delta in 3600i64..=7200i64,
1588 ) {
1589 use chrono::TimeZone;
1590
1591 let mut unique_offsets: Vec<i64> = offsets.clone();
1593 unique_offsets.sort_unstable();
1594 unique_offsets.dedup();
1595 prop_assume!(unique_offsets.len() >= 2);
1596
1597 let base_offset = unique_offsets[0];
1598 let from_offset = base_offset + window_start_delta;
1599 let to_offset = base_offset + window_end_delta;
1600
1601 let from = Utc.timestamp_opt(from_offset, 0).unwrap();
1602 let to = Utc.timestamp_opt(to_offset, 0).unwrap();
1603
1604 let store = in_memory_store();
1605
1606 let mut in_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1607 let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
1608
1609 for (i, &offset) in unique_offsets.iter().enumerate() {
1610 let ts = Utc.timestamp_opt(offset, 0).unwrap();
1611 let id = format!("sess-{i}");
1612 let session = make_session_at(&id, "some summary", ts);
1613 store.save_session(&session).unwrap();
1614
1615 if ts >= from && ts <= to {
1616 in_range_ids.insert(id);
1617 } else {
1618 out_range_ids.insert(id);
1619 }
1620 }
1621
1622 let results = store.search_by_date(from, to).unwrap();
1623 let result_ids: std::collections::HashSet<String> =
1624 results.iter().map(|r| r.id.clone()).collect();
1625
1626 for id in &in_range_ids {
1628 prop_assert!(
1629 result_ids.contains(id),
1630 "in-range session '{}' missing from search_by_date results",
1631 id
1632 );
1633 }
1634
1635 for id in &out_range_ids {
1637 prop_assert!(
1638 !result_ids.contains(id),
1639 "out-of-range session '{}' incorrectly appeared in search_by_date results",
1640 id
1641 );
1642 }
1643 }
1644 }
1645}