Skip to main content

sqz_engine/
session_store.rs

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/// A lightweight summary of a session for search results. Doesn't include
12/// the full conversation — just enough to identify and filter sessions.
13#[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
22/// SQLite-backed persistent session and cache store with FTS5 full-text search.
23///
24/// Stores sessions, cache entries, compression logs, and known files in a
25/// single SQLite database. Uses WAL mode for concurrent read access.
26///
27/// ```rust,no_run
28/// use sqz_engine::SessionStore;
29/// use std::path::Path;
30///
31/// let store = SessionStore::open_or_create(Path::new("~/.sqz/sessions.db")).unwrap();
32/// let results = store.search("authentication refactor").unwrap();
33/// for session in &results {
34///     println!("{}: {}", session.id, session.compressed_summary);
35/// }
36/// ```
37pub struct SessionStore {
38    db: Connection,
39}
40
41// ── Schema ────────────────────────────────────────────────────────────────────
42
43const 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
102// ── Helpers ───────────────────────────────────────────────────────────────────
103
104pub(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
136// ── SessionStore ──────────────────────────────────────────────────────────────
137
138impl SessionStore {
139    /// Construct a `SessionStore` from an already-open `Connection`.
140    /// Intended for testing (e.g., in-memory databases).
141    #[cfg(test)]
142    pub(crate) fn from_connection(conn: Connection) -> Self {
143        Self { db: conn }
144    }
145
146    /// Open an existing database at `path`. Returns an error if the file does
147    /// not exist or cannot be opened.
148    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    /// Open the database at `path`, creating it if it does not exist.
155    /// If the database is corrupted, a fresh database is created at the same
156    /// path and a warning is logged to stderr.
157    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                // Remove the corrupted file so we can start fresh.
167                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    // ── Session CRUD ──────────────────────────────────────────────────────────
178
179    /// Persist a session. Returns the session id.
180    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    /// Load a session by id.
209    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    // ── Search ────────────────────────────────────────────────────────────────
220
221    /// Full-text search using FTS5 (porter stemmer, ASCII tokenizer).
222    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    /// Query sessions whose `updated_at` falls within `[from, to]`.
250    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    /// Return the most recently updated session, or `None` if no sessions exist.
281    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    /// Query sessions whose `project_dir` matches `dir` exactly.
308    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    // ── Cache entries ─────────────────────────────────────────────────────────
336
337    /// Persist a cache entry keyed by content hash.
338    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    /// Delete a cache entry by content hash.
351    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    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
360    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
361    /// the stored JSON data.
362    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    /// Retrieve a cache entry by content hash, updating `accessed_at`.
378    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                // Touch accessed_at for LRU tracking.
388                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    /// Log a compression event for cumulative stats tracking.
402    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    /// Get cumulative compression stats from the log.
419    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    /// Get daily compression gains for the last N days.
436    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    // ── Known files (persistent cross-command context tracking) ───────────
464
465    /// Record a file path as "known" (its content is in the dedup cache).
466    /// Used by cross-command context refs to annotate error messages.
467    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    /// Load all known file paths from the persistent store.
477    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    /// Clear all known files (e.g. on session reset).
494    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/// Cumulative compression statistics.
502#[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/// A single day's compression gain.
524#[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// ── Tests ─────────────────────────────────────────────────────────────────────
533
534#[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        // Write garbage bytes to simulate a corrupted database.
674        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
675
676        // Should succeed by creating a fresh database.
677        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    // ── Property-based tests ──────────────────────────────────────────────────
685
686    /// Build a `SessionState` with a specific `updated_at` timestamp.
687    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    // ── Property 26: Session store search correctness ─────────────────────────
710    // **Validates: Requirements 20.2, 20.3, 20.4**
711    //
712    // For any set of sessions saved to the store, a keyword search SHALL return
713    // all sessions whose compressed_summary contains the keyword, and no
714    // sessions that don't contain it.
715
716    proptest! {
717        /// **Validates: Requirements 20.2, 20.3, 20.4**
718        ///
719        /// For any set of sessions saved to the store, a keyword search SHALL
720        /// return all sessions whose `compressed_summary` contains the keyword,
721        /// and no sessions that don't contain it.
722        #[test]
723        fn prop_search_correctness(
724            // A simple ASCII keyword: 5-8 lowercase letters, no common English
725            // words that the porter stemmer might conflate with other terms.
726            keyword in "[b-df-hj-np-tv-z]{5,8}",
727            // 1-6 summaries that embed the keyword
728            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
729            // 1-6 summaries that do NOT contain the keyword
730            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
731        ) {
732            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
733            for s in &non_matching {
734                prop_assume!(!s.contains(keyword.as_str()));
735            }
736
737            let store = in_memory_store();
738
739            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
740            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            // Save non-matching sessions
749            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            // Every matching session must appear in results.
761            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            // No non-matching session may appear in results.
770            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    // ── Property: search_by_date correctness ─────────────────────────────────
781    // **Validates: Requirements 20.4**
782    //
783    // For any set of sessions with different timestamps, searching by a date
784    // range SHALL return exactly the sessions whose `updated_at` falls within
785    // [from, to], and no sessions outside that range.
786
787    proptest! {
788        /// **Validates: Requirements 20.4**
789        ///
790        /// For any set of sessions with distinct timestamps, `search_by_date`
791        /// SHALL return exactly the sessions whose `updated_at` is within
792        /// `[from, to]`, and no sessions outside that range.
793        #[test]
794        fn prop_search_by_date_correctness(
795            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
796            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
797            // The search window: start and end offsets (relative to the minimum offset)
798            window_start_delta in 0i64..=3600i64,
799            window_end_delta   in 3600i64..=7200i64,
800        ) {
801            use chrono::TimeZone;
802
803            // Deduplicate offsets so each session has a unique timestamp.
804            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            // Every in-range session must appear.
839            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            // No out-of-range session may appear.
848            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}