rusty_beads/context/
store.rs

1//! Context store implementation with SQLite backend.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::{Arc, Mutex};
6
7use anyhow::{Context as AnyhowContext, Result};
8use chrono::{DateTime, Utc};
9use rusqlite::{params, Connection, OptionalExtension};
10use serde_json::Value;
11
12use super::invalidation::InvalidationChecker;
13use super::types::*;
14
15/// The context store schema.
16const SCHEMA: &str = r#"
17CREATE TABLE IF NOT EXISTS context (
18    key TEXT PRIMARY KEY,
19    value TEXT NOT NULL,
20    namespace TEXT NOT NULL,
21    created_at TEXT NOT NULL,
22    updated_at TEXT NOT NULL,
23    expires_at TEXT,
24    git_commit TEXT,
25    file_path TEXT,
26    file_mtime INTEGER,
27    metadata TEXT
28);
29
30CREATE INDEX IF NOT EXISTS idx_context_namespace ON context(namespace);
31CREATE INDEX IF NOT EXISTS idx_context_file_path ON context(file_path);
32CREATE INDEX IF NOT EXISTS idx_context_expires_at ON context(expires_at);
33"#;
34
35/// A key-value context store for AI coding agents.
36///
37/// Provides namespaced storage with git-aware invalidation for caching
38/// file summaries, symbol indexes, project metadata, and session context.
39pub struct ContextStore {
40    conn: Arc<Mutex<Connection>>,
41    invalidation: Arc<Mutex<InvalidationChecker>>,
42}
43
44impl ContextStore {
45    /// Open or create a context store at the given path.
46    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
47        let conn = Connection::open(path.as_ref())
48            .with_context(|| format!("Failed to open context store: {:?}", path.as_ref()))?;
49
50        // Initialize schema
51        conn.execute_batch(SCHEMA)?;
52
53        // Enable WAL mode for better concurrent access
54        conn.execute_batch(
55            "PRAGMA journal_mode = WAL;
56             PRAGMA busy_timeout = 5000;
57             PRAGMA synchronous = NORMAL;"
58        )?;
59
60        Ok(Self {
61            conn: Arc::new(Mutex::new(conn)),
62            invalidation: Arc::new(Mutex::new(InvalidationChecker::new())),
63        })
64    }
65
66    /// Create an in-memory context store (for testing).
67    pub fn in_memory() -> Result<Self> {
68        let conn = Connection::open_in_memory()?;
69        conn.execute_batch(SCHEMA)?;
70
71        Ok(Self {
72            conn: Arc::new(Mutex::new(conn)),
73            invalidation: Arc::new(Mutex::new(InvalidationChecker::new())),
74        })
75    }
76
77    /// Initialize git-aware invalidation from a repository.
78    pub fn with_git_repo(mut self, repo_path: impl AsRef<Path>) -> Result<Self> {
79        let checker = InvalidationChecker::from_git_repo(repo_path)?;
80        self.invalidation = Arc::new(Mutex::new(checker));
81        Ok(self)
82    }
83
84    /// Refresh the git state for invalidation checks.
85    pub fn refresh_git_state(&self) -> Result<()> {
86        let mut invalidation = self.invalidation.lock()
87            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
88        invalidation.refresh()
89    }
90
91    // ========== Core KV Operations ==========
92
93    /// Get a value by key.
94    pub fn get(&self, key: &str) -> Result<Option<ContextEntry>> {
95        let conn = self.conn.lock()
96            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
97
98        let entry = conn.query_row(
99            "SELECT key, value, namespace, created_at, updated_at, expires_at,
100                    git_commit, file_path, file_mtime, metadata
101             FROM context WHERE key = ?",
102            [key],
103            |row| row_to_entry(row),
104        ).optional()?;
105
106        // Check validity
107        if let Some(ref entry) = entry {
108            let invalidation = self.invalidation.lock()
109                .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
110
111            if !invalidation.is_valid(entry) {
112                // Entry is invalid, delete it
113                drop(invalidation);
114                drop(conn);
115                self.delete(key)?;
116                return Ok(None);
117            }
118        }
119
120        Ok(entry)
121    }
122
123    /// Get a value, returning None if invalid (without deleting).
124    pub fn get_if_valid(&self, key: &str) -> Result<Option<ContextEntry>> {
125        let conn = self.conn.lock()
126            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
127
128        let entry = conn.query_row(
129            "SELECT key, value, namespace, created_at, updated_at, expires_at,
130                    git_commit, file_path, file_mtime, metadata
131             FROM context WHERE key = ?",
132            [key],
133            |row| row_to_entry(row),
134        ).optional()?;
135
136        if let Some(ref entry) = entry {
137            let invalidation = self.invalidation.lock()
138                .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
139
140            if !invalidation.is_valid(entry) {
141                return Ok(None);
142            }
143        }
144
145        Ok(entry)
146    }
147
148    /// Set a value.
149    pub fn set(&self, entry: ContextEntry) -> Result<()> {
150        let conn = self.conn.lock()
151            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
152
153        let (namespace, _) = Namespace::from_key(&entry.key);
154        let metadata_json = serde_json::to_string(&entry.metadata)?;
155
156        conn.execute(
157            "INSERT OR REPLACE INTO context
158             (key, value, namespace, created_at, updated_at, expires_at,
159              git_commit, file_path, file_mtime, metadata)
160             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
161            params![
162                entry.key,
163                entry.value.to_string(),
164                namespace.prefix(),
165                entry.created_at.to_rfc3339(),
166                entry.updated_at.to_rfc3339(),
167                entry.expires_at.map(|t| t.to_rfc3339()),
168                entry.git_commit,
169                entry.file_path,
170                entry.file_mtime,
171                metadata_json,
172            ],
173        )?;
174
175        Ok(())
176    }
177
178    /// Set a simple key-value pair.
179    pub fn set_value(&self, key: &str, value: Value) -> Result<()> {
180        let entry = ContextEntry::new(key, value);
181        self.set(entry)
182    }
183
184    /// Delete a value by key.
185    pub fn delete(&self, key: &str) -> Result<bool> {
186        let conn = self.conn.lock()
187            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
188
189        let rows = conn.execute("DELETE FROM context WHERE key = ?", [key])?;
190        Ok(rows > 0)
191    }
192
193    /// Delete all entries matching a prefix.
194    pub fn delete_prefix(&self, prefix: &str) -> Result<usize> {
195        let conn = self.conn.lock()
196            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
197
198        let pattern = format!("{}%", prefix);
199        let rows = conn.execute("DELETE FROM context WHERE key LIKE ?", [&pattern])?;
200        Ok(rows)
201    }
202
203    /// Check if a key exists.
204    pub fn exists(&self, key: &str) -> Result<bool> {
205        let conn = self.conn.lock()
206            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
207
208        let exists: bool = conn.query_row(
209            "SELECT 1 FROM context WHERE key = ?",
210            [key],
211            |_| Ok(true),
212        ).optional()?.unwrap_or(false);
213
214        Ok(exists)
215    }
216
217    /// List entries matching a query.
218    pub fn list(&self, query: &ContextQuery) -> Result<Vec<ContextEntry>> {
219        let conn = self.conn.lock()
220            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
221
222        let mut sql = String::from(
223            "SELECT key, value, namespace, created_at, updated_at, expires_at,
224                    git_commit, file_path, file_mtime, metadata
225             FROM context WHERE 1=1"
226        );
227
228        let mut params: Vec<String> = Vec::new();
229
230        if let Some(ref ns) = query.namespace {
231            sql.push_str(" AND namespace = ?");
232            params.push(ns.prefix().to_string());
233        }
234
235        if let Some(ref prefix) = query.prefix {
236            sql.push_str(" AND key LIKE ?");
237            params.push(format!("{}%", prefix));
238        }
239
240        if !query.include_expired {
241            sql.push_str(" AND (expires_at IS NULL OR expires_at > ?)");
242            params.push(Utc::now().to_rfc3339());
243        }
244
245        sql.push_str(" ORDER BY updated_at DESC");
246
247        if let Some(limit) = query.limit {
248            sql.push_str(&format!(" LIMIT {}", limit));
249        }
250        if let Some(offset) = query.offset {
251            sql.push_str(&format!(" OFFSET {}", offset));
252        }
253
254        let mut stmt = conn.prepare(&sql)?;
255        let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter()
256            .map(|s| s as &dyn rusqlite::ToSql)
257            .collect();
258
259        let entries: Vec<ContextEntry> = stmt.query_map(params_refs.as_slice(), |row| row_to_entry(row))?
260            .filter_map(|r| r.ok())
261            .collect();
262
263        // Filter by validity if we have an invalidation checker
264        let invalidation = self.invalidation.lock()
265            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
266
267        let valid_entries: Vec<ContextEntry> = entries
268            .into_iter()
269            .filter(|e| invalidation.is_valid(e))
270            .collect();
271
272        Ok(valid_entries)
273    }
274
275    /// List all keys in a namespace.
276    pub fn keys(&self, namespace: Namespace) -> Result<Vec<String>> {
277        let conn = self.conn.lock()
278            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
279
280        let mut stmt = conn.prepare(
281            "SELECT key FROM context WHERE namespace = ? ORDER BY key"
282        )?;
283
284        let keys: Vec<String> = stmt.query_map([namespace.prefix()], |row| row.get(0))?
285            .filter_map(|r| r.ok())
286            .collect();
287
288        Ok(keys)
289    }
290
291    // ========== File Context API ==========
292
293    /// Get file context.
294    pub fn get_file_context(&self, path: &str) -> Result<Option<FileContext>> {
295        let key = format!("file:{}", path);
296        if let Some(entry) = self.get(&key)? {
297            let ctx: FileContext = serde_json::from_value(entry.value)?;
298            Ok(Some(ctx))
299        } else {
300            Ok(None)
301        }
302    }
303
304    /// Set file context with automatic mtime tracking.
305    pub fn set_file_context(&self, path: &str, ctx: &FileContext) -> Result<()> {
306        let key = format!("file:{}", path);
307        let value = serde_json::to_value(ctx)?;
308
309        // Get current file mtime
310        let mtime = self.invalidation.lock()
311            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?
312            .get_mtime(path);
313
314        let mut entry = ContextEntry::new(&key, value)
315            .with_metadata("type", "file_context");
316
317        entry.file_path = Some(path.to_string());
318        entry.file_mtime = mtime;
319
320        // Add current git commit
321        if let Some(commit) = self.invalidation.lock()
322            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?
323            .head_commit()
324        {
325            entry.git_commit = Some(commit.to_string());
326        }
327
328        self.set(entry)
329    }
330
331    /// Get a specific attribute of file context.
332    pub fn get_file_attr(&self, path: &str, attr: &str) -> Result<Option<Value>> {
333        let key = format!("file:{}:{}", path, attr);
334        if let Some(entry) = self.get(&key)? {
335            Ok(Some(entry.value))
336        } else {
337            Ok(None)
338        }
339    }
340
341    /// Set a specific attribute of file context.
342    pub fn set_file_attr(&self, path: &str, attr: &str, value: Value) -> Result<()> {
343        let key = format!("file:{}:{}", path, attr);
344
345        let mtime = self.invalidation.lock()
346            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?
347            .get_mtime(path);
348
349        let mut entry = ContextEntry::new(&key, value);
350        entry.file_path = Some(path.to_string());
351        entry.file_mtime = mtime;
352
353        self.set(entry)
354    }
355
356    // ========== Symbol Context API ==========
357
358    /// Get symbol information.
359    pub fn get_symbol(&self, name: &str) -> Result<Option<SymbolInfo>> {
360        let key = format!("symbol:{}", name);
361        if let Some(entry) = self.get(&key)? {
362            let info: SymbolInfo = serde_json::from_value(entry.value)?;
363            Ok(Some(info))
364        } else {
365            Ok(None)
366        }
367    }
368
369    /// Set symbol information.
370    pub fn set_symbol(&self, info: &SymbolInfo, file_path: Option<&str>) -> Result<()> {
371        let key = format!("symbol:{}", info.name);
372        let value = serde_json::to_value(info)?;
373
374        let mut entry = ContextEntry::new(&key, value);
375
376        if let Some(path) = file_path {
377            entry.file_path = Some(path.to_string());
378            entry.file_mtime = self.invalidation.lock()
379                .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?
380                .get_mtime(path);
381        }
382
383        self.set(entry)
384    }
385
386    /// Find symbols by prefix.
387    pub fn find_symbols(&self, prefix: &str) -> Result<Vec<SymbolInfo>> {
388        let query = ContextQuery::new()
389            .namespace(Namespace::Symbol)
390            .prefix(&format!("symbol:{}", prefix));
391
392        let entries = self.list(&query)?;
393        let symbols: Vec<SymbolInfo> = entries
394            .into_iter()
395            .filter_map(|e| serde_json::from_value(e.value).ok())
396            .collect();
397
398        Ok(symbols)
399    }
400
401    // ========== Project Context API ==========
402
403    /// Get project context.
404    pub fn get_project_context(&self) -> Result<Option<ProjectContext>> {
405        let key = "project:info";
406        if let Some(entry) = self.get(key)? {
407            let ctx: ProjectContext = serde_json::from_value(entry.value)?;
408            Ok(Some(ctx))
409        } else {
410            Ok(None)
411        }
412    }
413
414    /// Set project context.
415    pub fn set_project_context(&self, ctx: &ProjectContext) -> Result<()> {
416        let key = "project:info";
417        let value = serde_json::to_value(ctx)?;
418
419        let mut entry = ContextEntry::new(key, value);
420
421        // Track git commit for project-level context
422        if let Some(commit) = self.invalidation.lock()
423            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?
424            .head_commit()
425        {
426            entry.git_commit = Some(commit.to_string());
427        }
428
429        self.set(entry)
430    }
431
432    /// Get a project attribute.
433    pub fn get_project_attr(&self, attr: &str) -> Result<Option<Value>> {
434        let key = format!("project:{}", attr);
435        if let Some(entry) = self.get(&key)? {
436            Ok(Some(entry.value))
437        } else {
438            Ok(None)
439        }
440    }
441
442    /// Set a project attribute.
443    pub fn set_project_attr(&self, attr: &str, value: Value) -> Result<()> {
444        let key = format!("project:{}", attr);
445        let entry = ContextEntry::new(&key, value);
446        self.set(entry)
447    }
448
449    // ========== Session Context API ==========
450
451    /// Get session context.
452    pub fn get_session(&self, session_id: &str) -> Result<Option<SessionContext>> {
453        let key = format!("session:{}", session_id);
454        if let Some(entry) = self.get(&key)? {
455            let ctx: SessionContext = serde_json::from_value(entry.value)?;
456            Ok(Some(ctx))
457        } else {
458            Ok(None)
459        }
460    }
461
462    /// Set session context.
463    pub fn set_session(&self, ctx: &SessionContext) -> Result<()> {
464        let key = format!("session:{}", ctx.session_id);
465        let value = serde_json::to_value(ctx)?;
466        let entry = ContextEntry::new(&key, value);
467        self.set(entry)
468    }
469
470    /// Update session's working files.
471    pub fn update_working_files(&self, session_id: &str, files: Vec<String>) -> Result<()> {
472        if let Some(mut ctx) = self.get_session(session_id)? {
473            ctx.working_files = files;
474            ctx.last_activity = Utc::now();
475            self.set_session(&ctx)
476        } else {
477            let ctx = SessionContext {
478                session_id: session_id.to_string(),
479                working_files: files,
480                started_at: Utc::now(),
481                last_activity: Utc::now(),
482                ..Default::default()
483            };
484            self.set_session(&ctx)
485        }
486    }
487
488    /// Add a decision to session context.
489    pub fn add_decision(
490        &self,
491        session_id: &str,
492        decision: &str,
493        rationale: Option<&str>,
494        context: Vec<String>,
495    ) -> Result<()> {
496        let mut ctx = self.get_session(session_id)?
497            .unwrap_or_else(|| SessionContext {
498                session_id: session_id.to_string(),
499                started_at: Utc::now(),
500                last_activity: Utc::now(),
501                ..Default::default()
502            });
503
504        ctx.decisions.push(Decision {
505            decision: decision.to_string(),
506            rationale: rationale.map(|s| s.to_string()),
507            timestamp: Utc::now(),
508            context,
509        });
510        ctx.last_activity = Utc::now();
511
512        self.set_session(&ctx)
513    }
514
515    // ========== Batch Operations ==========
516
517    /// Get multiple values by keys.
518    pub fn get_batch(&self, keys: &[String]) -> Result<HashMap<String, ContextEntry>> {
519        let mut results = HashMap::new();
520        for key in keys {
521            if let Some(entry) = self.get(key)? {
522                results.insert(key.clone(), entry);
523            }
524        }
525        Ok(results)
526    }
527
528    /// Set multiple entries.
529    pub fn set_batch(&self, entries: Vec<ContextEntry>) -> Result<()> {
530        let conn = self.conn.lock()
531            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
532
533        let tx = conn.unchecked_transaction()?;
534
535        for entry in entries {
536            let (namespace, _) = Namespace::from_key(&entry.key);
537            let metadata_json = serde_json::to_string(&entry.metadata)?;
538
539            tx.execute(
540                "INSERT OR REPLACE INTO context
541                 (key, value, namespace, created_at, updated_at, expires_at,
542                  git_commit, file_path, file_mtime, metadata)
543                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
544                params![
545                    entry.key,
546                    entry.value.to_string(),
547                    namespace.prefix(),
548                    entry.created_at.to_rfc3339(),
549                    entry.updated_at.to_rfc3339(),
550                    entry.expires_at.map(|t| t.to_rfc3339()),
551                    entry.git_commit,
552                    entry.file_path,
553                    entry.file_mtime,
554                    metadata_json,
555                ],
556            )?;
557        }
558
559        tx.commit()?;
560        Ok(())
561    }
562
563    // ========== Maintenance ==========
564
565    /// Delete all expired entries.
566    pub fn cleanup_expired(&self) -> Result<usize> {
567        let conn = self.conn.lock()
568            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
569
570        let now = Utc::now().to_rfc3339();
571        let rows = conn.execute(
572            "DELETE FROM context WHERE expires_at IS NOT NULL AND expires_at < ?",
573            [&now],
574        )?;
575
576        Ok(rows)
577    }
578
579    /// Delete all invalid entries based on git state.
580    pub fn cleanup_invalid(&self) -> Result<usize> {
581        let entries = self.list(&ContextQuery::new().include_expired())?;
582
583        let invalidation = self.invalidation.lock()
584            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
585
586        let invalid_keys: Vec<String> = entries
587            .iter()
588            .filter(|e| !invalidation.is_valid(e))
589            .map(|e| e.key.clone())
590            .collect();
591
592        drop(invalidation);
593
594        let mut deleted = 0;
595        for key in invalid_keys {
596            if self.delete(&key)? {
597                deleted += 1;
598            }
599        }
600
601        Ok(deleted)
602    }
603
604    /// Get statistics about the context store.
605    pub fn stats(&self) -> Result<ContextStats> {
606        let conn = self.conn.lock()
607            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
608
609        let total: usize = conn.query_row(
610            "SELECT COUNT(*) FROM context",
611            [],
612            |row| row.get(0),
613        )?;
614
615        let by_namespace: HashMap<String, usize> = {
616            let mut stmt = conn.prepare(
617                "SELECT namespace, COUNT(*) FROM context GROUP BY namespace"
618            )?;
619            let rows = stmt.query_map([], |row| {
620                Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
621            })?;
622            rows.filter_map(|r| r.ok()).collect()
623        };
624
625        let expired: usize = conn.query_row(
626            "SELECT COUNT(*) FROM context WHERE expires_at IS NOT NULL AND expires_at < ?",
627            [Utc::now().to_rfc3339()],
628            |row| row.get(0),
629        )?;
630
631        Ok(ContextStats {
632            total_entries: total,
633            by_namespace,
634            expired_entries: expired,
635        })
636    }
637
638    /// Clear all entries (for testing).
639    pub fn clear(&self) -> Result<()> {
640        let conn = self.conn.lock()
641            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
642        conn.execute("DELETE FROM context", [])?;
643        Ok(())
644    }
645
646    /// Clear all entries and return count.
647    pub fn clear_all(&self) -> Result<usize> {
648        let conn = self.conn.lock()
649            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
650        let count: usize = conn.query_row(
651            "SELECT COUNT(*) FROM context",
652            [],
653            |row| row.get(0),
654        )?;
655        conn.execute("DELETE FROM context", [])?;
656        Ok(count)
657    }
658
659    /// Clear entries in a specific namespace.
660    pub fn clear_namespace(&self, namespace: Namespace) -> Result<usize> {
661        let conn = self.conn.lock()
662            .map_err(|e| anyhow::anyhow!("Lock error: {}", e))?;
663        let count: usize = conn.query_row(
664            "SELECT COUNT(*) FROM context WHERE namespace = ?",
665            [namespace.prefix()],
666            |row| row.get(0),
667        )?;
668        conn.execute(
669            "DELETE FROM context WHERE namespace = ?",
670            [namespace.prefix()],
671        )?;
672        Ok(count)
673    }
674
675    /// Get the modification time of a file.
676    pub fn get_file_mtime(&self, path: &str) -> Option<i64> {
677        let invalidation = self.invalidation.lock().ok()?;
678        invalidation.get_mtime(path)
679    }
680
681    /// Refresh invalidation state from git.
682    pub fn refresh_invalidation(&self) -> Result<()> {
683        self.refresh_git_state()
684    }
685
686    /// List entries with simplified parameters.
687    pub fn list_simple(&self, namespace: Option<Namespace>, prefix: Option<&str>) -> Result<Vec<ContextEntry>> {
688        let mut query = ContextQuery::new();
689        if let Some(ns) = namespace {
690            query = query.namespace(ns);
691        }
692        if let Some(p) = prefix {
693            query = query.prefix(p);
694        }
695        self.list(&query)
696    }
697}
698
699/// Statistics about the context store.
700#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
701pub struct ContextStats {
702    pub total_entries: usize,
703    pub by_namespace: HashMap<String, usize>,
704    pub expired_entries: usize,
705}
706
707/// Convert a database row to a ContextEntry.
708fn row_to_entry(row: &rusqlite::Row) -> rusqlite::Result<ContextEntry> {
709    let value_str: String = row.get(1)?;
710    let metadata_str: String = row.get(9)?;
711
712    Ok(ContextEntry {
713        key: row.get(0)?,
714        value: serde_json::from_str(&value_str).unwrap_or(Value::Null),
715        created_at: parse_datetime(&row.get::<_, String>(3)?),
716        updated_at: parse_datetime(&row.get::<_, String>(4)?),
717        expires_at: row.get::<_, Option<String>>(5)?.map(|s| parse_datetime(&s)),
718        git_commit: row.get(6)?,
719        file_path: row.get(7)?,
720        file_mtime: row.get(8)?,
721        metadata: serde_json::from_str(&metadata_str).unwrap_or_default(),
722    })
723}
724
725fn parse_datetime(s: &str) -> DateTime<Utc> {
726    DateTime::parse_from_rfc3339(s)
727        .map(|dt| dt.with_timezone(&Utc))
728        .unwrap_or_else(|_| Utc::now())
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use serde_json::json;
735
736    #[test]
737    fn test_basic_kv_operations() {
738        let store = ContextStore::in_memory().unwrap();
739
740        // Set and get
741        store.set_value("test:key1", json!({"data": "value1"})).unwrap();
742        let entry = store.get("test:key1").unwrap().unwrap();
743        assert_eq!(entry.value, json!({"data": "value1"}));
744
745        // Delete
746        assert!(store.delete("test:key1").unwrap());
747        assert!(store.get("test:key1").unwrap().is_none());
748    }
749
750    #[test]
751    fn test_file_context() {
752        let store = ContextStore::in_memory().unwrap();
753
754        let ctx = FileContext {
755            path: "src/main.rs".to_string(),
756            summary: Some("Main entry point".to_string()),
757            language: Some("rust".to_string()),
758            ..Default::default()
759        };
760
761        store.set_file_context("src/main.rs", &ctx).unwrap();
762
763        let retrieved = store.get_file_context("src/main.rs").unwrap().unwrap();
764        assert_eq!(retrieved.summary, Some("Main entry point".to_string()));
765    }
766
767    #[test]
768    fn test_session_context() {
769        let store = ContextStore::in_memory().unwrap();
770
771        // Create session
772        store.update_working_files("session-1", vec!["file1.rs".to_string()]).unwrap();
773
774        // Add decision
775        store.add_decision(
776            "session-1",
777            "Use async/await for IO",
778            Some("Better concurrency"),
779            vec!["src/io.rs".to_string()],
780        ).unwrap();
781
782        let session = store.get_session("session-1").unwrap().unwrap();
783        assert_eq!(session.working_files, vec!["file1.rs"]);
784        assert_eq!(session.decisions.len(), 1);
785        assert_eq!(session.decisions[0].decision, "Use async/await for IO");
786    }
787
788    #[test]
789    fn test_namespace_listing() {
790        let store = ContextStore::in_memory().unwrap();
791
792        store.set_value("file:a.rs", json!({})).unwrap();
793        store.set_value("file:b.rs", json!({})).unwrap();
794        store.set_value("project:info", json!({})).unwrap();
795
796        let file_keys = store.keys(Namespace::File).unwrap();
797        assert_eq!(file_keys.len(), 2);
798
799        let project_keys = store.keys(Namespace::Project).unwrap();
800        assert_eq!(project_keys.len(), 1);
801    }
802
803    #[test]
804    fn test_ttl_expiration() {
805        let store = ContextStore::in_memory().unwrap();
806
807        // Set with TTL (already expired)
808        let mut entry = ContextEntry::new("test:expired", json!({}));
809        entry.expires_at = Some(Utc::now() - chrono::Duration::hours(1));
810        store.set(entry).unwrap();
811
812        // Should not be returned
813        assert!(store.get("test:expired").unwrap().is_none());
814    }
815}