Skip to main content

krait/index/
store.rs

1#![allow(clippy::missing_errors_doc)]
2use std::path::Path;
3
4use rusqlite::{params, Connection};
5
6/// A discovered workspace entry from the index.
7#[derive(Debug, Clone)]
8pub struct WorkspaceInfo {
9    /// Path relative to project root (e.g., "packages/api").
10    pub path: String,
11    /// Language name (e.g., "typescript").
12    pub language: String,
13    /// Status: "discovered" or "attached".
14    pub status: String,
15    /// Last time this workspace was used (unix timestamp), for LRU eviction.
16    pub last_used_at: Option<i64>,
17}
18
19/// A cached symbol entry from the index.
20#[derive(Debug, Clone)]
21pub struct CachedSymbol {
22    pub name: String,
23    pub kind: String,
24    pub path: String,
25    pub range_start_line: u32,
26    pub range_start_col: u32,
27    pub range_end_line: u32,
28    pub range_end_col: u32,
29    pub parent_name: Option<String>,
30}
31
32/// SQLite-backed symbol index store.
33pub struct IndexStore {
34    conn: Connection,
35}
36
37impl IndexStore {
38    /// Open (or create) the index database at the given path.
39    ///
40    /// # Errors
41    /// Returns an error if the database can't be opened or schema migration fails.
42    pub fn open(db_path: &Path) -> rusqlite::Result<Self> {
43        let conn = Connection::open(db_path)?;
44        conn.execute_batch(
45            "PRAGMA journal_mode = WAL;
46             PRAGMA synchronous = normal;
47             PRAGMA temp_store = memory;
48             PRAGMA cache_size = -32000;
49             PRAGMA mmap_size = 30000000000;
50             PRAGMA foreign_keys = ON;",
51        )?;
52        let store = Self { conn };
53        store.create_tables()?;
54        Ok(store)
55    }
56
57    /// Open an in-memory database (for testing).
58    #[cfg(test)]
59    pub fn open_in_memory() -> rusqlite::Result<Self> {
60        let conn = Connection::open_in_memory()?;
61        conn.execute_batch("PRAGMA foreign_keys=ON;")?;
62        let store = Self { conn };
63        store.create_tables()?;
64        Ok(store)
65    }
66
67    fn create_tables(&self) -> rusqlite::Result<()> {
68        self.conn.execute_batch(
69            "CREATE TABLE IF NOT EXISTS files (
70                path TEXT PRIMARY KEY,
71                blake3_hash TEXT NOT NULL,
72                indexed_at INTEGER NOT NULL
73            );
74
75            CREATE TABLE IF NOT EXISTS symbols (
76                id INTEGER PRIMARY KEY AUTOINCREMENT,
77                name TEXT NOT NULL,
78                kind TEXT NOT NULL,
79                path TEXT NOT NULL,
80                range_start_line INTEGER NOT NULL,
81                range_start_col INTEGER NOT NULL,
82                range_end_line INTEGER NOT NULL,
83                range_end_col INTEGER NOT NULL,
84                parent_name TEXT,
85                FOREIGN KEY (path) REFERENCES files(path) ON DELETE CASCADE
86            );
87
88            CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name);
89            CREATE INDEX IF NOT EXISTS idx_symbols_path ON symbols(path);
90
91            CREATE TABLE IF NOT EXISTS lsp_cache (
92                request_hash TEXT PRIMARY KEY,
93                response_json TEXT NOT NULL,
94                created_at INTEGER NOT NULL
95            );
96
97            CREATE TABLE IF NOT EXISTS workspaces (
98                path TEXT PRIMARY KEY,
99                language TEXT NOT NULL,
100                status TEXT NOT NULL DEFAULT 'discovered',
101                last_used_at INTEGER
102            );
103
104            CREATE TABLE IF NOT EXISTS server_capabilities (
105                server_name TEXT NOT NULL PRIMARY KEY,
106                workspace_folders_supported INTEGER NOT NULL DEFAULT 0,
107                work_done_progress INTEGER NOT NULL DEFAULT 0
108            );",
109        )
110    }
111
112    /// Insert or update a file entry.
113    pub fn upsert_file(&self, path: &str, hash: &str) -> rusqlite::Result<()> {
114        let now = std::time::SystemTime::now()
115            .duration_since(std::time::UNIX_EPOCH)
116            .unwrap_or_default()
117            .as_secs();
118
119        self.conn.execute(
120            "INSERT INTO files (path, blake3_hash, indexed_at)
121             VALUES (?1, ?2, ?3)
122             ON CONFLICT(path) DO UPDATE SET blake3_hash=?2, indexed_at=?3",
123            params![path, hash, now.cast_signed()],
124        )?;
125        Ok(())
126    }
127
128    /// Insert symbols for a file, replacing any existing ones.
129    pub fn insert_symbols(&self, path: &str, symbols: &[CachedSymbol]) -> rusqlite::Result<()> {
130        // Delete existing symbols for this file
131        self.conn
132            .execute("DELETE FROM symbols WHERE path = ?1", params![path])?;
133
134        let mut stmt = self.conn.prepare(
135            "INSERT INTO symbols (name, kind, path, range_start_line, range_start_col, range_end_line, range_end_col, parent_name)
136             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
137        )?;
138
139        for sym in symbols {
140            stmt.execute(params![
141                sym.name,
142                sym.kind,
143                path,
144                sym.range_start_line,
145                sym.range_start_col,
146                sym.range_end_line,
147                sym.range_end_col,
148                sym.parent_name,
149            ])?;
150        }
151        Ok(())
152    }
153
154    /// Write all index results in a single transaction for performance.
155    ///
156    /// This is ~100x faster than individual inserts because it avoids
157    /// per-statement autocommit overhead.
158    pub fn batch_commit(
159        &self,
160        results: &[(String, String, Vec<CachedSymbol>)],
161    ) -> rusqlite::Result<usize> {
162        let now = std::time::SystemTime::now()
163            .duration_since(std::time::UNIX_EPOCH)
164            .unwrap_or_default()
165            .as_secs()
166            .cast_signed();
167
168        self.conn.execute_batch("BEGIN")?;
169
170        let upsert_result = (|| {
171            let mut upsert_stmt = self.conn.prepare(
172                "INSERT INTO files (path, blake3_hash, indexed_at)
173                 VALUES (?1, ?2, ?3)
174                 ON CONFLICT(path) DO UPDATE SET blake3_hash=?2, indexed_at=?3",
175            )?;
176            let mut delete_stmt = self.conn.prepare("DELETE FROM symbols WHERE path = ?1")?;
177            let mut insert_stmt = self.conn.prepare(
178                "INSERT INTO symbols (name, kind, path, range_start_line, range_start_col, range_end_line, range_end_col, parent_name)
179                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
180            )?;
181
182            let mut symbols_total = 0;
183            for (rel_path, hash, symbols) in results {
184                upsert_stmt.execute(params![rel_path, hash, now])?;
185                delete_stmt.execute(params![rel_path])?;
186                for sym in symbols {
187                    insert_stmt.execute(params![
188                        sym.name,
189                        sym.kind,
190                        rel_path,
191                        sym.range_start_line,
192                        sym.range_start_col,
193                        sym.range_end_line,
194                        sym.range_end_col,
195                        sym.parent_name,
196                    ])?;
197                }
198                symbols_total += symbols.len();
199            }
200            Ok(symbols_total)
201        })();
202
203        match upsert_result {
204            Ok(total) => {
205                self.conn.execute_batch("COMMIT")?;
206                Ok(total)
207            }
208            Err(e) => {
209                let _ = self.conn.execute_batch("ROLLBACK");
210                Err(e)
211            }
212        }
213    }
214
215    /// Find symbols by name (exact match).
216    pub fn find_symbols_by_name(&self, name: &str) -> rusqlite::Result<Vec<CachedSymbol>> {
217        // Also match Go receiver-qualified names: "(*ReceiverType).MethodName"
218        // The pattern `%).{name}` catches e.g. "(*knowledgeService).CreateKnowledgeFromFile".
219        let qualified_pattern = format!("%).\"{name}\"");
220        let go_pattern = format!("%).{name}");
221        let mut stmt = self.conn.prepare(
222            "SELECT name, kind, path, range_start_line, range_start_col, range_end_line, range_end_col, parent_name
223             FROM symbols WHERE name = ?1 OR name LIKE ?2 OR name LIKE ?3",
224        )?;
225
226        let rows = stmt.query_map(params![name, go_pattern, qualified_pattern], |row| {
227            Ok(CachedSymbol {
228                name: row.get(0)?,
229                kind: row.get(1)?,
230                path: row.get(2)?,
231                range_start_line: row.get(3)?,
232                range_start_col: row.get(4)?,
233                range_end_line: row.get(5)?,
234                range_end_col: row.get(6)?,
235                parent_name: row.get(7)?,
236            })
237        })?;
238
239        rows.collect()
240    }
241
242    /// Find symbols by file path.
243    pub fn find_symbols_by_path(&self, path: &str) -> rusqlite::Result<Vec<CachedSymbol>> {
244        let mut stmt = self.conn.prepare(
245            "SELECT name, kind, path, range_start_line, range_start_col, range_end_line, range_end_col, parent_name
246             FROM symbols WHERE path = ?1
247             ORDER BY range_start_line",
248        )?;
249
250        let rows = stmt.query_map(params![path], |row| {
251            Ok(CachedSymbol {
252                name: row.get(0)?,
253                kind: row.get(1)?,
254                path: row.get(2)?,
255                range_start_line: row.get(3)?,
256                range_start_col: row.get(4)?,
257                range_end_line: row.get(5)?,
258                range_end_col: row.get(6)?,
259                parent_name: row.get(7)?,
260            })
261        })?;
262
263        rows.collect()
264    }
265
266    /// Get the stored BLAKE3 hash for a file.
267    pub fn get_file_hash(&self, path: &str) -> rusqlite::Result<Option<String>> {
268        let mut stmt = self
269            .conn
270            .prepare("SELECT blake3_hash FROM files WHERE path = ?1")?;
271
272        let mut rows = stmt.query(params![path])?;
273        match rows.next()? {
274            Some(row) => Ok(Some(row.get(0)?)),
275            None => Ok(None),
276        }
277    }
278
279    /// Get a cached LSP response by request hash.
280    pub fn cache_get(&self, request_hash: &str) -> rusqlite::Result<Option<String>> {
281        let mut stmt = self
282            .conn
283            .prepare("SELECT response_json FROM lsp_cache WHERE request_hash = ?1")?;
284
285        let mut rows = stmt.query(params![request_hash])?;
286        match rows.next()? {
287            Some(row) => Ok(Some(row.get(0)?)),
288            None => Ok(None),
289        }
290    }
291
292    /// Store a cached LSP response.
293    pub fn cache_put(&self, request_hash: &str, response_json: &str) -> rusqlite::Result<()> {
294        let now = std::time::SystemTime::now()
295            .duration_since(std::time::UNIX_EPOCH)
296            .unwrap_or_default()
297            .as_secs();
298
299        self.conn.execute(
300            "INSERT OR REPLACE INTO lsp_cache (request_hash, response_json, created_at)
301             VALUES (?1, ?2, ?3)",
302            params![request_hash, response_json, now.cast_signed()],
303        )?;
304        Ok(())
305    }
306
307    // ── Workspace registry ──────────────────────────────────────────
308
309    /// Insert or update a workspace entry.
310    pub fn upsert_workspace(&self, path: &str, language: &str) -> rusqlite::Result<()> {
311        self.conn.execute(
312            "INSERT INTO workspaces (path, language)
313             VALUES (?1, ?2)
314             ON CONFLICT(path) DO UPDATE SET language=?2",
315            params![path, language],
316        )?;
317        Ok(())
318    }
319
320    /// Mark a workspace as attached (LSP folder added to server).
321    pub fn set_workspace_attached(&self, path: &str) -> rusqlite::Result<()> {
322        let now = now_unix();
323        self.conn.execute(
324            "UPDATE workspaces SET status='attached', last_used_at=?2 WHERE path=?1",
325            params![path, now],
326        )?;
327        Ok(())
328    }
329
330    /// Mark a workspace as detached (LSP folder removed from server).
331    pub fn set_workspace_detached(&self, path: &str) -> rusqlite::Result<()> {
332        self.conn.execute(
333            "UPDATE workspaces SET status='discovered' WHERE path=?1",
334            params![path],
335        )?;
336        Ok(())
337    }
338
339    /// Update `last_used_at` for a workspace (called on every query).
340    pub fn touch_workspace(&self, path: &str) -> rusqlite::Result<()> {
341        let now = now_unix();
342        self.conn.execute(
343            "UPDATE workspaces SET last_used_at=?2 WHERE path=?1",
344            params![path, now],
345        )?;
346        Ok(())
347    }
348
349    /// List all workspaces.
350    pub fn list_workspaces(&self) -> rusqlite::Result<Vec<WorkspaceInfo>> {
351        let mut stmt = self
352            .conn
353            .prepare("SELECT path, language, status, last_used_at FROM workspaces ORDER BY path")?;
354        let rows = stmt.query_map([], |row| {
355            Ok(WorkspaceInfo {
356                path: row.get(0)?,
357                language: row.get(1)?,
358                status: row.get(2)?,
359                last_used_at: row.get(3)?,
360            })
361        })?;
362        rows.collect()
363    }
364
365    /// Get the oldest attached workspace for a language (for LRU eviction).
366    pub fn get_lru_attached(&self, language: &str) -> rusqlite::Result<Option<String>> {
367        let mut stmt = self.conn.prepare(
368            "SELECT path FROM workspaces
369             WHERE language=?1 AND status='attached'
370             ORDER BY last_used_at ASC NULLS FIRST
371             LIMIT 1",
372        )?;
373        let mut rows = stmt.query(params![language])?;
374        match rows.next()? {
375            Some(row) => Ok(Some(row.get(0)?)),
376            None => Ok(None),
377        }
378    }
379
380    /// Count workspaces by status.
381    pub fn workspace_counts(&self) -> rusqlite::Result<(usize, usize)> {
382        let total: usize = self
383            .conn
384            .query_row("SELECT COUNT(*) FROM workspaces", [], |r| r.get(0))?;
385        let attached: usize = self.conn.query_row(
386            "SELECT COUNT(*) FROM workspaces WHERE status='attached'",
387            [],
388            |r| r.get(0),
389        )?;
390        Ok((total, attached))
391    }
392
393    /// Remove all workspaces (used before re-populating on init).
394    pub fn clear_workspaces(&self) -> rusqlite::Result<()> {
395        self.conn.execute("DELETE FROM workspaces", [])?;
396        Ok(())
397    }
398
399    /// Run post-index optimization pragmas (call after krait init completes).
400    /// Count the total number of symbols in the database.
401    pub fn count_all_symbols(&self) -> rusqlite::Result<u64> {
402        self.conn
403            .query_row("SELECT COUNT(*) FROM symbols", [], |row| {
404                row.get::<_, u64>(0)
405            })
406    }
407
408    pub fn optimize(&self) -> rusqlite::Result<()> {
409        self.conn.execute_batch(
410            "PRAGMA analysis_limit = 400;
411             PRAGMA optimize;
412             PRAGMA wal_checkpoint(TRUNCATE);",
413        )
414    }
415
416    /// Get file hashes for multiple paths in a single query.
417    pub fn get_file_hashes_batch(
418        &self,
419        paths: &[&str],
420    ) -> rusqlite::Result<std::collections::HashMap<String, String>> {
421        if paths.is_empty() {
422            return Ok(std::collections::HashMap::new());
423        }
424        let placeholders: Vec<String> = (1..=paths.len()).map(|i| format!("?{i}")).collect();
425        let sql = format!(
426            "SELECT path, blake3_hash FROM files WHERE path IN ({})",
427            placeholders.join(",")
428        );
429        let mut stmt = self.conn.prepare(&sql)?;
430        let params: Vec<&dyn rusqlite::ToSql> =
431            paths.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
432        let rows = stmt.query_map(params.as_slice(), |row| {
433            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
434        })?;
435        let mut map = std::collections::HashMap::new();
436        for row in rows {
437            let (path, hash) = row?;
438            map.insert(path, hash);
439        }
440        Ok(map)
441    }
442
443    // ── Server capabilities cache ─────────────────────────────────
444
445    /// Persist server capability flags (survives daemon restarts).
446    pub fn upsert_server_capabilities(
447        &self,
448        server_name: &str,
449        workspace_folders_supported: bool,
450        work_done_progress: bool,
451    ) -> rusqlite::Result<()> {
452        self.conn.execute(
453            "INSERT INTO server_capabilities (server_name, workspace_folders_supported, work_done_progress)
454             VALUES (?1, ?2, ?3)
455             ON CONFLICT(server_name) DO UPDATE
456             SET workspace_folders_supported=?2, work_done_progress=?3",
457            params![
458                server_name,
459                i32::from(workspace_folders_supported),
460                i32::from(work_done_progress)
461            ],
462        )?;
463        Ok(())
464    }
465
466    /// Read persisted capability flags for a server. Returns `None` if not found.
467    pub fn get_server_capabilities(
468        &self,
469        server_name: &str,
470    ) -> rusqlite::Result<Option<(bool, bool)>> {
471        let mut stmt = self.conn.prepare(
472            "SELECT workspace_folders_supported, work_done_progress
473             FROM server_capabilities WHERE server_name=?1",
474        )?;
475        let mut rows = stmt.query(params![server_name])?;
476        match rows.next()? {
477            Some(row) => {
478                let wf: i32 = row.get(0)?;
479                let wdp: i32 = row.get(1)?;
480                Ok(Some((wf != 0, wdp != 0)))
481            }
482            None => Ok(None),
483        }
484    }
485
486    // ── File management ───────────────────────────────────────────
487
488    /// Delete a file and all its symbols (via CASCADE).
489    pub fn delete_file(&self, path: &str) -> rusqlite::Result<()> {
490        self.conn
491            .execute("DELETE FROM files WHERE path = ?1", params![path])?;
492        Ok(())
493    }
494}
495
496fn now_unix() -> i64 {
497    std::time::SystemTime::now()
498        .duration_since(std::time::UNIX_EPOCH)
499        .unwrap_or_default()
500        .as_secs()
501        .cast_signed()
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    #[test]
509    fn open_creates_tables() {
510        let store = IndexStore::open_in_memory().unwrap();
511        // Verify tables exist by querying them
512        let count: i64 = store
513            .conn
514            .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
515            .unwrap();
516        assert_eq!(count, 0);
517
518        let count: i64 = store
519            .conn
520            .query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))
521            .unwrap();
522        assert_eq!(count, 0);
523
524        let count: i64 = store
525            .conn
526            .query_row("SELECT COUNT(*) FROM lsp_cache", [], |r| r.get(0))
527            .unwrap();
528        assert_eq!(count, 0);
529    }
530
531    #[test]
532    fn upsert_file_and_retrieve() {
533        let store = IndexStore::open_in_memory().unwrap();
534        store.upsert_file("src/lib.rs", "abc123").unwrap();
535
536        let hash = store.get_file_hash("src/lib.rs").unwrap();
537        assert_eq!(hash, Some("abc123".to_string()));
538
539        // Update hash
540        store.upsert_file("src/lib.rs", "def456").unwrap();
541        let hash = store.get_file_hash("src/lib.rs").unwrap();
542        assert_eq!(hash, Some("def456".to_string()));
543    }
544
545    #[test]
546    fn insert_and_find_symbols_by_name() {
547        let store = IndexStore::open_in_memory().unwrap();
548        store.upsert_file("src/lib.rs", "abc").unwrap();
549
550        let symbols = vec![
551            CachedSymbol {
552                name: "Config".into(),
553                kind: "struct".into(),
554                path: "src/lib.rs".into(),
555                range_start_line: 5,
556                range_start_col: 0,
557                range_end_line: 10,
558                range_end_col: 1,
559                parent_name: None,
560            },
561            CachedSymbol {
562                name: "new".into(),
563                kind: "method".into(),
564                path: "src/lib.rs".into(),
565                range_start_line: 6,
566                range_start_col: 4,
567                range_end_line: 8,
568                range_end_col: 5,
569                parent_name: Some("Config".into()),
570            },
571        ];
572        store.insert_symbols("src/lib.rs", &symbols).unwrap();
573
574        let found = store.find_symbols_by_name("Config").unwrap();
575        assert_eq!(found.len(), 1);
576        assert_eq!(found[0].kind, "struct");
577        assert_eq!(found[0].range_start_line, 5);
578
579        let found = store.find_symbols_by_name("new").unwrap();
580        assert_eq!(found.len(), 1);
581        assert_eq!(found[0].parent_name, Some("Config".to_string()));
582    }
583
584    #[test]
585    fn insert_and_find_symbols_by_path() {
586        let store = IndexStore::open_in_memory().unwrap();
587        store.upsert_file("src/lib.rs", "abc").unwrap();
588
589        let symbols = vec![CachedSymbol {
590            name: "greet".into(),
591            kind: "function".into(),
592            path: "src/lib.rs".into(),
593            range_start_line: 1,
594            range_start_col: 0,
595            range_end_line: 3,
596            range_end_col: 1,
597            parent_name: None,
598        }];
599        store.insert_symbols("src/lib.rs", &symbols).unwrap();
600
601        let found = store.find_symbols_by_path("src/lib.rs").unwrap();
602        assert_eq!(found.len(), 1);
603        assert_eq!(found[0].name, "greet");
604    }
605
606    #[test]
607    fn delete_file_cascades_to_symbols() {
608        let store = IndexStore::open_in_memory().unwrap();
609        store.upsert_file("src/lib.rs", "abc").unwrap();
610
611        let symbols = vec![CachedSymbol {
612            name: "Config".into(),
613            kind: "struct".into(),
614            path: "src/lib.rs".into(),
615            range_start_line: 1,
616            range_start_col: 0,
617            range_end_line: 5,
618            range_end_col: 1,
619            parent_name: None,
620        }];
621        store.insert_symbols("src/lib.rs", &symbols).unwrap();
622        assert_eq!(store.find_symbols_by_name("Config").unwrap().len(), 1);
623
624        store.delete_file("src/lib.rs").unwrap();
625        assert_eq!(store.find_symbols_by_name("Config").unwrap().len(), 0);
626        assert!(store.get_file_hash("src/lib.rs").unwrap().is_none());
627    }
628
629    #[test]
630    fn cache_put_and_get() {
631        let store = IndexStore::open_in_memory().unwrap();
632        store.cache_put("hash123", r#"{"result": "ok"}"#).unwrap();
633
634        let cached = store.cache_get("hash123").unwrap();
635        assert_eq!(cached, Some(r#"{"result": "ok"}"#.to_string()));
636    }
637
638    #[test]
639    fn cache_miss_returns_none() {
640        let store = IndexStore::open_in_memory().unwrap();
641        let cached = store.cache_get("nonexistent").unwrap();
642        assert!(cached.is_none());
643    }
644
645    #[test]
646    fn open_existing_db_preserves_data() {
647        let dir = tempfile::tempdir().unwrap();
648        let db_path = dir.path().join("index.db");
649
650        // Create and populate
651        {
652            let store = IndexStore::open(&db_path).unwrap();
653            store.upsert_file("src/lib.rs", "abc").unwrap();
654            store
655                .insert_symbols(
656                    "src/lib.rs",
657                    &[CachedSymbol {
658                        name: "Config".into(),
659                        kind: "struct".into(),
660                        path: "src/lib.rs".into(),
661                        range_start_line: 1,
662                        range_start_col: 0,
663                        range_end_line: 5,
664                        range_end_col: 1,
665                        parent_name: None,
666                    }],
667                )
668                .unwrap();
669        }
670
671        // Reopen and verify
672        let store = IndexStore::open(&db_path).unwrap();
673        let hash = store.get_file_hash("src/lib.rs").unwrap();
674        assert_eq!(hash, Some("abc".to_string()));
675
676        let found = store.find_symbols_by_name("Config").unwrap();
677        assert_eq!(found.len(), 1);
678    }
679
680    #[test]
681    fn workspace_upsert_and_list() {
682        let store = IndexStore::open_in_memory().unwrap();
683        store
684            .upsert_workspace("packages/api", "typescript")
685            .unwrap();
686        store
687            .upsert_workspace("packages/web", "typescript")
688            .unwrap();
689        store.upsert_workspace(".", "go").unwrap();
690
691        let workspaces = store.list_workspaces().unwrap();
692        assert_eq!(workspaces.len(), 3);
693        assert_eq!(workspaces[0].path, ".");
694        assert_eq!(workspaces[0].status, "discovered");
695        assert_eq!(workspaces[1].path, "packages/api");
696        assert_eq!(workspaces[2].path, "packages/web");
697    }
698
699    #[test]
700    fn workspace_status_transitions() {
701        let store = IndexStore::open_in_memory().unwrap();
702        store
703            .upsert_workspace("packages/api", "typescript")
704            .unwrap();
705
706        let ws = &store.list_workspaces().unwrap()[0];
707        assert_eq!(ws.status, "discovered");
708        assert!(ws.last_used_at.is_none());
709
710        store.set_workspace_attached("packages/api").unwrap();
711        let ws = &store.list_workspaces().unwrap()[0];
712        assert_eq!(ws.status, "attached");
713        assert!(ws.last_used_at.is_some());
714
715        store.set_workspace_detached("packages/api").unwrap();
716        let ws = &store.list_workspaces().unwrap()[0];
717        assert_eq!(ws.status, "discovered");
718    }
719
720    #[test]
721    fn workspace_touch_updates_timestamp() {
722        let store = IndexStore::open_in_memory().unwrap();
723        store
724            .upsert_workspace("packages/api", "typescript")
725            .unwrap();
726        store.set_workspace_attached("packages/api").unwrap();
727
728        let t1 = store.list_workspaces().unwrap()[0].last_used_at.unwrap();
729        // Touch again (same second, but verifies no error)
730        store.touch_workspace("packages/api").unwrap();
731        let t2 = store.list_workspaces().unwrap()[0].last_used_at.unwrap();
732        assert!(t2 >= t1);
733    }
734
735    #[test]
736    fn workspace_counts() {
737        let store = IndexStore::open_in_memory().unwrap();
738        store
739            .upsert_workspace("packages/api", "typescript")
740            .unwrap();
741        store
742            .upsert_workspace("packages/web", "typescript")
743            .unwrap();
744        store.upsert_workspace(".", "go").unwrap();
745
746        let (total, attached) = store.workspace_counts().unwrap();
747        assert_eq!(total, 3);
748        assert_eq!(attached, 0);
749
750        store.set_workspace_attached("packages/api").unwrap();
751        let (total, attached) = store.workspace_counts().unwrap();
752        assert_eq!(total, 3);
753        assert_eq!(attached, 1);
754    }
755
756    #[test]
757    fn workspace_lru_returns_oldest() {
758        let store = IndexStore::open_in_memory().unwrap();
759        store
760            .upsert_workspace("packages/api", "typescript")
761            .unwrap();
762        store
763            .upsert_workspace("packages/web", "typescript")
764            .unwrap();
765        store.upsert_workspace(".", "go").unwrap();
766
767        // Nothing attached → no LRU
768        assert!(store.get_lru_attached("typescript").unwrap().is_none());
769
770        // Attach both — api first, then web
771        store.set_workspace_attached("packages/api").unwrap();
772        store.set_workspace_attached("packages/web").unwrap();
773        // Touch web so api is older
774        store.touch_workspace("packages/web").unwrap();
775
776        let lru = store.get_lru_attached("typescript").unwrap();
777        assert_eq!(lru, Some("packages/api".to_string()));
778
779        // Go workspaces should be independent
780        assert!(store.get_lru_attached("go").unwrap().is_none());
781    }
782
783    #[test]
784    fn workspace_clear() {
785        let store = IndexStore::open_in_memory().unwrap();
786        store
787            .upsert_workspace("packages/api", "typescript")
788            .unwrap();
789        store
790            .upsert_workspace("packages/web", "typescript")
791            .unwrap();
792
793        store.clear_workspaces().unwrap();
794        let workspaces = store.list_workspaces().unwrap();
795        assert!(workspaces.is_empty());
796    }
797
798    #[test]
799    fn workspace_upsert_updates_language() {
800        let store = IndexStore::open_in_memory().unwrap();
801        store.upsert_workspace("frontend", "javascript").unwrap();
802        store.upsert_workspace("frontend", "typescript").unwrap();
803
804        let ws = &store.list_workspaces().unwrap()[0];
805        assert_eq!(ws.language, "typescript");
806    }
807
808    #[test]
809    fn get_file_hashes_batch_empty() {
810        let store = IndexStore::open_in_memory().unwrap();
811        let result = store.get_file_hashes_batch(&[]).unwrap();
812        assert!(result.is_empty());
813    }
814
815    #[test]
816    fn get_file_hashes_batch_returns_stored_hashes() {
817        let store = IndexStore::open_in_memory().unwrap();
818        store.upsert_file("src/a.rs", "hash_a").unwrap();
819        store.upsert_file("src/b.rs", "hash_b").unwrap();
820        store.upsert_file("src/c.rs", "hash_c").unwrap();
821
822        let result = store
823            .get_file_hashes_batch(&["src/a.rs", "src/b.rs", "src/missing.rs"])
824            .unwrap();
825
826        assert_eq!(result.get("src/a.rs").map(String::as_str), Some("hash_a"));
827        assert_eq!(result.get("src/b.rs").map(String::as_str), Some("hash_b"));
828        assert!(!result.contains_key("src/missing.rs"));
829    }
830
831    #[test]
832    fn server_capabilities_roundtrip() {
833        let store = IndexStore::open_in_memory().unwrap();
834
835        // Not found initially
836        let caps = store.get_server_capabilities("vtsls").unwrap();
837        assert!(caps.is_none());
838
839        // Upsert and retrieve
840        store
841            .upsert_server_capabilities("vtsls", true, false)
842            .unwrap();
843        let caps = store.get_server_capabilities("vtsls").unwrap().unwrap();
844        assert_eq!(caps, (true, false));
845
846        // Update
847        store
848            .upsert_server_capabilities("vtsls", true, true)
849            .unwrap();
850        let caps = store.get_server_capabilities("vtsls").unwrap().unwrap();
851        assert_eq!(caps, (true, true));
852    }
853}