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.
12#[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
21/// SQLite FTS5-backed persistent session and cache store.
22pub struct SessionStore {
23    db: Connection,
24}
25
26// ── Schema ────────────────────────────────────────────────────────────────────
27
28const 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
87// ── Helpers ───────────────────────────────────────────────────────────────────
88
89pub(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
121// ── SessionStore ──────────────────────────────────────────────────────────────
122
123impl SessionStore {
124    /// Construct a `SessionStore` from an already-open `Connection`.
125    /// Intended for testing (e.g., in-memory databases).
126    #[cfg(test)]
127    pub(crate) fn from_connection(conn: Connection) -> Self {
128        Self { db: conn }
129    }
130
131    /// Open an existing database at `path`. Returns an error if the file does
132    /// not exist or cannot be opened.
133    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    /// Open the database at `path`, creating it if it does not exist.
140    /// If the database is corrupted, a fresh database is created at the same
141    /// path and a warning is logged to stderr.
142    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                // Remove the corrupted file so we can start fresh.
152                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    // ── Session CRUD ──────────────────────────────────────────────────────────
163
164    /// Persist a session. Returns the session id.
165    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    /// Load a session by id.
194    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    // ── Search ────────────────────────────────────────────────────────────────
205
206    /// Full-text search using FTS5 (porter stemmer, ASCII tokenizer).
207    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    /// Query sessions whose `updated_at` falls within `[from, to]`.
235    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    /// Return the most recently updated session, or `None` if no sessions exist.
266    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    /// Query sessions whose `project_dir` matches `dir` exactly.
293    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    // ── Cache entries ─────────────────────────────────────────────────────────
321
322    /// Persist a cache entry keyed by content hash.
323    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    /// Delete a cache entry by content hash.
336    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    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
345    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
346    /// the stored JSON data.
347    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    /// Retrieve a cache entry by content hash, updating `accessed_at`.
363    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                // Touch accessed_at for LRU tracking.
373                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    /// Log a compression event for cumulative stats tracking.
387    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    /// Get cumulative compression stats from the log.
404    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    /// Get daily compression gains for the last N days.
421    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    // ── Known files (persistent cross-command context tracking) ───────────
449
450    /// Record a file path as "known" (its content is in the dedup cache).
451    /// Used by cross-command context refs to annotate error messages.
452    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    /// Load all known file paths from the persistent store.
462    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    /// Clear all known files (e.g. on session reset).
479    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/// Cumulative compression statistics.
487#[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/// A single day's compression gain.
509#[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// ── Tests ─────────────────────────────────────────────────────────────────────
518
519#[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        // Write garbage bytes to simulate a corrupted database.
659        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
660
661        // Should succeed by creating a fresh database.
662        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    // ── Property-based tests ──────────────────────────────────────────────────
670
671    /// Build a `SessionState` with a specific `updated_at` timestamp.
672    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    // ── Property 26: Session store search correctness ─────────────────────────
695    // **Validates: Requirements 20.2, 20.3, 20.4**
696    //
697    // For any set of sessions saved to the store, a keyword search SHALL return
698    // all sessions whose compressed_summary contains the keyword, and no
699    // sessions that don't contain it.
700
701    proptest! {
702        /// **Validates: Requirements 20.2, 20.3, 20.4**
703        ///
704        /// For any set of sessions saved to the store, a keyword search SHALL
705        /// return all sessions whose `compressed_summary` contains the keyword,
706        /// and no sessions that don't contain it.
707        #[test]
708        fn prop_search_correctness(
709            // A simple ASCII keyword: 5-8 lowercase letters, no common English
710            // words that the porter stemmer might conflate with other terms.
711            keyword in "[b-df-hj-np-tv-z]{5,8}",
712            // 1-6 summaries that embed the keyword
713            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
714            // 1-6 summaries that do NOT contain the keyword
715            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
716        ) {
717            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
718            for s in &non_matching {
719                prop_assume!(!s.contains(keyword.as_str()));
720            }
721
722            let store = in_memory_store();
723
724            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
725            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            // Save non-matching sessions
734            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            // Every matching session must appear in results.
746            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            // No non-matching session may appear in results.
755            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    // ── Property: search_by_date correctness ─────────────────────────────────
766    // **Validates: Requirements 20.4**
767    //
768    // For any set of sessions with different timestamps, searching by a date
769    // range SHALL return exactly the sessions whose `updated_at` falls within
770    // [from, to], and no sessions outside that range.
771
772    proptest! {
773        /// **Validates: Requirements 20.4**
774        ///
775        /// For any set of sessions with distinct timestamps, `search_by_date`
776        /// SHALL return exactly the sessions whose `updated_at` is within
777        /// `[from, to]`, and no sessions outside that range.
778        #[test]
779        fn prop_search_by_date_correctness(
780            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
781            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
782            // The search window: start and end offsets (relative to the minimum offset)
783            window_start_delta in 0i64..=3600i64,
784            window_end_delta   in 3600i64..=7200i64,
785        ) {
786            use chrono::TimeZone;
787
788            // Deduplicate offsets so each session has a unique timestamp.
789            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            // Every in-range session must appear.
824            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            // No out-of-range session may appear.
833            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}