Skip to main content

reflex/
cache.rs

1//! Cache management and memory-mapped I/O
2//!
3//! The cache module handles the `.reflex/` directory structure:
4//! - `meta.db`: Metadata, file hashes, and configuration (SQLite)
5//! - `tokens.bin`: Compressed lexical tokens (binary)
6//! - `content.bin`: Memory-mapped file contents (binary)
7//! - `trigrams.bin`: Trigram inverted index (custom varint+zstd binary, V3 format)
8//! - `config.toml`: Index settings (TOML text)
9
10use anyhow::{Context, Result};
11use rusqlite::{Connection, OptionalExtension};
12use std::collections::HashMap;
13use std::fs::File;
14use std::path::{Path, PathBuf};
15
16use crate::models::IndexedFile;
17
18/// Default cache directory name
19pub const CACHE_DIR: &str = ".reflex";
20
21/// File names within the cache directory
22pub const META_DB: &str = "meta.db";
23pub const TOKENS_BIN: &str = "tokens.bin";
24pub const HASHES_JSON: &str = "hashes.json";
25pub const CONFIG_TOML: &str = "config.toml";
26
27/// Open a SQLite database with Reflex's standard pragmas.
28///
29/// Every connection to `meta.db` (and to the symbol cache, which lives in the same
30/// file) MUST go through this helper. Plain `Connection::open` leaves SQLite at its
31/// defaults, which caused three separate production failures:
32///
33/// 1. **No `busy_timeout`** — the default is 0, so `BEGIN IMMEDIATE` returned
34///    `database is locked` *instantly* whenever the background symbol indexer held a
35///    write. Agents saw a raw SQLite error instead of a retry.
36/// 2. **No `journal_mode=WAL`** — readers blocked writers and vice versa, and
37///    [`CacheManager::checkpoint_wal`] was issuing `wal_checkpoint(TRUNCATE)` against a
38///    rollback-journal database, where it does nothing.
39/// 3. **No `foreign_keys=ON`** — SQLite disables foreign keys per connection by
40///    default, so the `ON DELETE CASCADE` clauses in the schema never fired and
41///    deleting a row from `files` orphaned its `file_branches` / `file_dependencies` /
42///    `file_exports` rows.
43///
44/// Pragma order is load-bearing: `journal_mode=WAL` itself can return `SQLITE_BUSY`
45/// when another connection is attached, so `busy_timeout` must be set first.
46///
47/// Set `REFLEX_SQLITE_JOURNAL=delete` to opt out of WAL on network filesystems, where
48/// WAL requires shared-memory support that NFS/SMB do not reliably provide.
49pub fn open_meta_db(db_path: impl AsRef<Path>) -> Result<Connection> {
50    let db_path = db_path.as_ref();
51    let conn = Connection::open(db_path)
52        .with_context(|| format!("Failed to open {}", db_path.display()))?;
53
54    // Must come first: the journal_mode change below can itself hit a busy database.
55    conn.busy_timeout(std::time::Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS))
56        .context("Failed to set busy_timeout")?;
57
58    let journal_mode = std::env::var("REFLEX_SQLITE_JOURNAL")
59        .unwrap_or_else(|_| "WAL".to_string())
60        .to_uppercase();
61
62    // query_row, not execute: `PRAGMA journal_mode` returns the resulting mode as a row.
63    if let Err(e) = conn.query_row(
64        &format!("PRAGMA journal_mode={}", journal_mode),
65        [],
66        |row| row.get::<_, String>(0),
67    ) {
68        // A read-only or network filesystem can refuse WAL. Degrading to the default
69        // journal is correct here — losing concurrency beats failing to open the cache.
70        log::warn!(
71            "Could not set journal_mode={} on {}: {} (continuing with the default journal)",
72            journal_mode,
73            db_path.display(),
74            e
75        );
76    }
77
78    conn.execute_batch("PRAGMA foreign_keys=ON;")
79        .context("Failed to enable foreign keys")?;
80
81    Ok(conn)
82}
83
84/// How long a SQLite connection waits for a competing writer before giving up.
85///
86/// The background symbol indexer writes in batches; 5s comfortably covers one batch.
87/// A pass that holds the database for longer than this is caught earlier and more
88/// clearly by the `BackgroundIndexer::is_running` gate in `Indexer::index`.
89const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000;
90
91/// Manages the Reflex cache directory
92#[derive(Clone)]
93pub struct CacheManager {
94    cache_path: PathBuf,
95}
96
97impl CacheManager {
98    /// Create a new cache manager for the given root directory
99    pub fn new(root: impl AsRef<Path>) -> Self {
100        let cache_path = root.as_ref().join(CACHE_DIR);
101        Self { cache_path }
102    }
103
104    /// Initialize the cache directory structure if it doesn't exist
105    pub fn init(&self) -> Result<()> {
106        log::info!("Initializing cache at {:?}", self.cache_path);
107
108        if !self.cache_path.exists() {
109            std::fs::create_dir_all(&self.cache_path)?;
110        }
111
112        // Create meta.db with schema
113        self.init_meta_db()?;
114
115        // Create default config.toml
116        self.init_config_toml()?;
117
118        // Note: tokens.bin removed - was never used
119        // Note: hashes.json is deprecated - hashes are now stored in meta.db
120
121        log::info!("Cache initialized successfully");
122        Ok(())
123    }
124
125    /// Initialize meta.db with SQLite schema
126    fn init_meta_db(&self) -> Result<()> {
127        let db_path = self.cache_path.join(META_DB);
128
129        // Always run: every statement is `IF NOT EXISTS`, so this is a no-op on
130        // a complete database and a repair on a half-built one. (An indexer
131        // killed during schema creation used to leave meta.db with some tables
132        // missing; the old "skip if the file exists" check then made every later
133        // run fail with `no such table: file_branches`.) One transaction so a
134        // kill mid-way leaves either the old state or the full schema.
135        let conn = open_meta_db(&db_path).context("Failed to create meta.db")?;
136        conn.execute_batch("BEGIN IMMEDIATE")
137            .context("Failed to begin meta.db schema transaction")?;
138
139        // Create files table
140        conn.execute(
141            "CREATE TABLE IF NOT EXISTS files (
142                id INTEGER PRIMARY KEY AUTOINCREMENT,
143                path TEXT NOT NULL UNIQUE,
144                last_indexed INTEGER NOT NULL,
145                language TEXT NOT NULL,
146                token_count INTEGER DEFAULT 0,
147                line_count INTEGER DEFAULT 0
148            )",
149            [],
150        )?;
151
152        conn.execute(
153            "CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)",
154            [],
155        )?;
156
157        // Create statistics table
158        conn.execute(
159            "CREATE TABLE IF NOT EXISTS statistics (
160                key TEXT PRIMARY KEY,
161                value TEXT NOT NULL,
162                updated_at INTEGER NOT NULL
163            )",
164            [],
165        )?;
166
167        // Initialize default statistics
168        let now = chrono::Utc::now().timestamp();
169        conn.execute(
170            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
171            ["total_files", "0", &now.to_string()],
172        )?;
173        // Who wrote this cache. The old `cache_version = "1"` row was never read by
174        // anything; this replaces it with something actionable, so a refusal can name
175        // the version that owns the cache instead of just a hash.
176        conn.execute(
177            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
178            [
179                "writer_version",
180                env!("CARGO_PKG_VERSION"),
181                &now.to_string(),
182            ],
183        )?;
184        if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
185            conn.execute(
186                "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
187                ["writer_git_sha", sha, &now.to_string()],
188            )?;
189        }
190
191        // Store cache schema hash for automatic invalidation detection
192        // This hash is computed at build time from cache-critical source files
193        let schema_hash = env!("CACHE_SCHEMA_HASH");
194        conn.execute(
195            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
196            ["schema_hash", schema_hash, &now.to_string()],
197        )?;
198
199        // Initialize last_compaction timestamp (0 = never compacted)
200        conn.execute(
201            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
202            ["last_compaction", "0", &now.to_string()],
203        )?;
204
205        // Create config table
206        conn.execute(
207            "CREATE TABLE IF NOT EXISTS config (
208                key TEXT PRIMARY KEY,
209                value TEXT NOT NULL
210            )",
211            [],
212        )?;
213
214        // Create branch tracking tables for git-aware indexing
215        conn.execute(
216            "CREATE TABLE IF NOT EXISTS file_branches (
217                file_id INTEGER NOT NULL,
218                branch_id INTEGER NOT NULL,
219                hash TEXT NOT NULL,
220                last_indexed INTEGER NOT NULL,
221                PRIMARY KEY (file_id, branch_id),
222                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
223                FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
224            )",
225            [],
226        )?;
227
228        conn.execute(
229            "CREATE INDEX IF NOT EXISTS idx_branch_lookup ON file_branches(branch_id, file_id)",
230            [],
231        )?;
232
233        conn.execute(
234            "CREATE INDEX IF NOT EXISTS idx_hash_lookup ON file_branches(hash)",
235            [],
236        )?;
237
238        // Create branches metadata table
239        conn.execute(
240            "CREATE TABLE IF NOT EXISTS branches (
241                id INTEGER PRIMARY KEY AUTOINCREMENT,
242                name TEXT NOT NULL UNIQUE,
243                commit_sha TEXT NOT NULL,
244                last_indexed INTEGER NOT NULL,
245                file_count INTEGER DEFAULT 0,
246                is_dirty INTEGER DEFAULT 0
247            )",
248            [],
249        )?;
250
251        // Create file dependencies table for tracking imports/includes
252        conn.execute(
253            "CREATE TABLE IF NOT EXISTS file_dependencies (
254                id INTEGER PRIMARY KEY AUTOINCREMENT,
255                file_id INTEGER NOT NULL,
256                imported_path TEXT NOT NULL,
257                resolved_file_id INTEGER,
258                import_type TEXT NOT NULL,
259                line_number INTEGER NOT NULL,
260                imported_symbols TEXT,
261                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
262                FOREIGN KEY (resolved_file_id) REFERENCES files(id) ON DELETE SET NULL
263            )",
264            [],
265        )?;
266
267        conn.execute(
268            "CREATE INDEX IF NOT EXISTS idx_deps_file ON file_dependencies(file_id)",
269            [],
270        )?;
271
272        conn.execute(
273            "CREATE INDEX IF NOT EXISTS idx_deps_resolved ON file_dependencies(resolved_file_id)",
274            [],
275        )?;
276
277        conn.execute(
278            "CREATE INDEX IF NOT EXISTS idx_deps_type ON file_dependencies(import_type)",
279            [],
280        )?;
281
282        // Create file exports table for tracking barrel re-exports
283        conn.execute(
284            "CREATE TABLE IF NOT EXISTS file_exports (
285                id INTEGER PRIMARY KEY AUTOINCREMENT,
286                file_id INTEGER NOT NULL,
287                exported_symbol TEXT,
288                source_path TEXT NOT NULL,
289                resolved_source_id INTEGER,
290                line_number INTEGER NOT NULL,
291                FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
292                FOREIGN KEY (resolved_source_id) REFERENCES files(id) ON DELETE SET NULL
293            )",
294            [],
295        )?;
296
297        conn.execute(
298            "CREATE INDEX IF NOT EXISTS idx_exports_file ON file_exports(file_id)",
299            [],
300        )?;
301
302        conn.execute(
303            "CREATE INDEX IF NOT EXISTS idx_exports_resolved ON file_exports(resolved_source_id)",
304            [],
305        )?;
306
307        conn.execute(
308            "CREATE INDEX IF NOT EXISTS idx_exports_symbol ON file_exports(exported_symbol)",
309            [],
310        )?;
311
312        conn.execute_batch("COMMIT")
313            .context("Failed to commit meta.db schema transaction")?;
314
315        log::debug!("Created meta.db with schema");
316        Ok(())
317    }
318
319    /// Initialize config.toml with defaults
320    fn init_config_toml(&self) -> Result<()> {
321        let config_path = self.cache_path.join(CONFIG_TOML);
322
323        if config_path.exists() {
324            return Ok(());
325        }
326
327        let default_config = r#"[index]
328languages = []  # Empty = all supported languages
329text_tier = true  # Also index docs and config: md, yaml, toml, json, proto, html, sh, sql
330max_file_size = 10485760  # 10 MB
331follow_symlinks = false
332
333[index.include]
334patterns = []
335
336[index.exclude]
337patterns = []
338
339[search]
340default_limit = 100
341fuzzy_threshold = 0.8
342
343[performance]
344parallel_threads = 0  # 0 = auto (80% of available cores), or set a specific number
345compression_level = 3  # zstd level
346
347[semantic]
348# Semantic query generation using LLMs
349# Translate natural language questions into rfx query commands
350provider = "openrouter"  # Options: openai, anthropic, openrouter
351# model = "openai/gpt-4o-mini"  # Optional: override provider default model
352# auto_execute = false  # Optional: auto-execute queries without confirmation
353"#;
354
355        std::fs::write(&config_path, default_config)?;
356
357        log::debug!("Created default config.toml");
358        Ok(())
359    }
360
361    /// Check if cache exists and is valid
362    pub fn exists(&self) -> bool {
363        self.cache_path.exists() && self.cache_path.join(META_DB).exists()
364    }
365
366    /// Validate cache integrity and detect corruption
367    ///
368    /// Performs basic integrity checks on the cache:
369    /// - Verifies all required files exist
370    /// - Checks SQLite database can be opened
371    /// - Validates binary file headers (trigrams.bin, content.bin)
372    ///
373    /// Returns Ok(()) if cache is valid, Err with details if corrupted.
374    pub fn validate(&self) -> Result<()> {
375        let start = std::time::Instant::now();
376
377        // Check if cache directory exists
378        if !self.cache_path.exists() {
379            anyhow::bail!(
380                "Cache directory does not exist: {}",
381                self.cache_path.display()
382            );
383        }
384
385        // Check meta.db exists and can be opened
386        let db_path = self.cache_path.join(META_DB);
387        if !db_path.exists() {
388            anyhow::bail!("Database file missing: {}", db_path.display());
389        }
390
391        // Try to open database
392        let conn =
393            open_meta_db(&db_path).context("Failed to open meta.db - database may be corrupted")?;
394
395        // Verify schema exists
396        let tables: Result<Vec<String>, _> = conn
397            .prepare("SELECT name FROM sqlite_master WHERE type='table'")
398            .and_then(|mut stmt| {
399                stmt.query_map([], |row| row.get(0))
400                    .map(|rows| rows.collect())
401            })
402            .and_then(|result| result);
403
404        match tables {
405            Ok(table_list) => {
406                // Check for required tables
407                let required_tables = vec![
408                    "files",
409                    "statistics",
410                    "config",
411                    "file_branches",
412                    "branches",
413                    "file_dependencies",
414                    "file_exports",
415                ];
416                for table in &required_tables {
417                    if !table_list.iter().any(|t| t == table) {
418                        anyhow::bail!("Required table '{}' missing from database schema", table);
419                    }
420                }
421            }
422            Err(e) => {
423                anyhow::bail!("Failed to read database schema: {}", e);
424            }
425        }
426
427        // Run SQLite integrity check (fast quick_check)
428        // Use quick_check instead of integrity_check for speed (<10ms vs 100ms+)
429        let integrity_result: String =
430            conn.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
431
432        if integrity_result != "ok" {
433            log::warn!("Database integrity check failed: {}", integrity_result);
434            anyhow::bail!(
435                "Database integrity check failed: {}. Cache may be corrupted. \
436                 Run 'rfx index' to rebuild cache.",
437                integrity_result
438            );
439        }
440
441        // Check trigrams.bin if it exists
442        let trigrams_path = self.cache_path.join("trigrams.bin");
443        if trigrams_path.exists() {
444            use std::io::Read;
445
446            match File::open(&trigrams_path) {
447                Ok(mut file) => {
448                    let mut header = [0u8; 4];
449                    match file.read_exact(&mut header) {
450                        Ok(_) => {
451                            // Check magic bytes
452                            if &header != b"RFTG" {
453                                log::warn!(
454                                    "trigrams.bin has invalid magic bytes - may be corrupted"
455                                );
456                                anyhow::bail!(
457                                    "trigrams.bin appears to be corrupted (invalid magic bytes)"
458                                );
459                            }
460                        }
461                        Err(_) => {
462                            anyhow::bail!("trigrams.bin is too small - appears to be corrupted");
463                        }
464                    }
465                }
466                Err(e) => {
467                    anyhow::bail!("Failed to open trigrams.bin: {}", e);
468                }
469            }
470        }
471
472        // Check content.bin if it exists
473        let content_path = self.cache_path.join("content.bin");
474        if content_path.exists() {
475            use std::io::Read;
476
477            match File::open(&content_path) {
478                Ok(mut file) => {
479                    let mut header = [0u8; 4];
480                    match file.read_exact(&mut header) {
481                        Ok(_) => {
482                            // Check magic bytes
483                            if &header != b"RFCT" {
484                                log::warn!(
485                                    "content.bin has invalid magic bytes - may be corrupted"
486                                );
487                                anyhow::bail!(
488                                    "content.bin appears to be corrupted (invalid magic bytes)"
489                                );
490                            }
491                        }
492                        Err(_) => {
493                            anyhow::bail!("content.bin is too small - appears to be corrupted");
494                        }
495                    }
496                }
497                Err(e) => {
498                    anyhow::bail!("Failed to open content.bin: {}", e);
499                }
500            }
501        }
502
503        // NOT checked here any more: the schema hash.
504        //
505        // `validate()` runs on EVERY search (query/mod.rs), and a bail here becomes
506        // ReflexError::CacheCorrupted, which the MCP layer answers by force-rebuilding
507        // the index. With several Reflex versions sharing one `.reflex/` — three
508        // `rfx mcp` servers from three Claude Code sessions, in the field report —
509        // each one saw a mismatch, each force-rebuilt, and they streamed into
510        // content.bin concurrently. That is what produced `content.bin is too small`.
511        //
512        // A version mismatch is not corruption. Readers are now allowed through and
513        // the mismatch surfaces via `get_index_status` as stale with
514        // can_trust_results: false, naming the owner version. WRITERS refuse — see
515        // `assert_writable`. Structural checks above (magic bytes, short files,
516        // quick_check) still bail, because those really are corruption.
517
518        log::debug!("Cache validation passed (took {:?})", start.elapsed());
519        Ok(())
520    }
521
522    /// Get the path to the cache directory
523    pub fn path(&self) -> &Path {
524        &self.cache_path
525    }
526
527    /// Get the workspace root directory (parent of .reflex/)
528    pub fn workspace_root(&self) -> PathBuf {
529        self.cache_path
530            .parent()
531            .expect(".reflex directory should have a parent")
532            .to_path_buf()
533    }
534
535    /// Load IndexConfig from `.reflex/config.toml` if it exists.
536    ///
537    /// Returns `IndexConfig::default()` when the file is absent or a section
538    /// is missing.  Parse errors are surfaced so the user gets a clear message
539    /// rather than silently falling back to defaults.
540    pub fn load_index_config(&self) -> Result<crate::models::IndexConfig> {
541        use crate::models::{IndexConfig, Language};
542
543        let config_path = self.cache_path.join(CONFIG_TOML);
544        if !config_path.exists() {
545            return Ok(IndexConfig::default());
546        }
547
548        let raw = std::fs::read_to_string(&config_path)
549            .with_context(|| format!("Failed to read {}", config_path.display()))?;
550
551        let toml_val: toml::Value = toml::from_str(&raw)
552            .with_context(|| format!("Failed to parse {}", config_path.display()))?;
553
554        let mut cfg = IndexConfig::default();
555
556        if let Some(index_tbl) = toml_val.get("index") {
557            if let Some(langs) = index_tbl.get("languages").and_then(|v| v.as_array()) {
558                let parsed: Vec<Language> = langs
559                    .iter()
560                    .filter_map(|v| v.as_str())
561                    .filter_map(|s| {
562                        Language::from_name(s).or_else(|| {
563                            log::warn!(
564                                "Unknown language '{}' in config.toml [index] section — ignoring",
565                                s
566                            );
567                            None
568                        })
569                    })
570                    .collect();
571                if !parsed.is_empty() {
572                    cfg.languages = parsed;
573                }
574            }
575            if let Some(text_tier) = index_tbl.get("text_tier").and_then(|v| v.as_bool()) {
576                cfg.text_tier = text_tier;
577            }
578
579            if let Some(max_size) = index_tbl.get("max_file_size").and_then(|v| v.as_integer()) {
580                cfg.max_file_size = max_size as usize;
581            }
582            if let Some(follow) = index_tbl.get("follow_symlinks").and_then(|v| v.as_bool()) {
583                cfg.follow_symlinks = follow;
584            }
585            if let Some(include) = index_tbl
586                .get("include")
587                .and_then(|v| v.get("patterns"))
588                .and_then(|v| v.as_array())
589            {
590                cfg.include_patterns = include
591                    .iter()
592                    .filter_map(|v| v.as_str().map(String::from))
593                    .collect();
594            }
595            if let Some(exclude) = index_tbl
596                .get("exclude")
597                .and_then(|v| v.get("patterns"))
598                .and_then(|v| v.as_array())
599            {
600                cfg.exclude_patterns = exclude
601                    .iter()
602                    .filter_map(|v| v.as_str().map(String::from))
603                    .collect();
604            }
605        }
606
607        if let Some(perf) = toml_val.get("performance")
608            && let Some(threads) = perf.get("parallel_threads").and_then(|v| v.as_integer())
609        {
610            cfg.parallel_threads = threads as usize;
611        }
612
613        log::debug!("Loaded IndexConfig from config.toml: {:?}", cfg);
614        Ok(cfg)
615    }
616
617    /// Clear the entire cache
618    pub fn clear(&self) -> Result<()> {
619        log::info!("Clearing cache at {:?}", self.cache_path);
620
621        if !self.cache_path.exists() {
622            return Ok(());
623        }
624
625        // Hold the workspace index lock while deleting so we never pull
626        // content.bin out from under a running indexer. Everything except the
627        // lock file goes while the lock is held; the lock file and the (now
628        // empty) directory are removed afterwards, best-effort, so callers
629        // that expect `.reflex/` to vanish keep working.
630        let lock =
631            crate::atomic_write::IndexLock::try_acquire(&self.cache_path)?.ok_or_else(|| {
632                crate::errors::ReflexError::IndexLocked(
633                    crate::atomic_write::IndexLock::lock_path(&self.cache_path)
634                        .display()
635                        .to_string(),
636                )
637            })?;
638
639        for entry in std::fs::read_dir(&self.cache_path)? {
640            let entry = entry?;
641            let path = entry.path();
642            if path.file_name().and_then(|n| n.to_str())
643                == Some(crate::atomic_write::INDEX_LOCK_FILE)
644            {
645                continue;
646            }
647            if path.is_dir() {
648                std::fs::remove_dir_all(&path)?;
649            } else {
650                std::fs::remove_file(&path)?;
651            }
652        }
653
654        let lock_path = lock.path().to_path_buf();
655        drop(lock);
656        let _ = std::fs::remove_file(&lock_path);
657        let _ = std::fs::remove_dir(&self.cache_path);
658
659        Ok(())
660    }
661
662    /// Force SQLite WAL (Write-Ahead Log) checkpoint
663    ///
664    /// Ensures all data written in transactions is flushed to the main database file.
665    /// This is critical when spawning background processes that open new connections,
666    /// as they need to see the committed data immediately.
667    ///
668    /// Uses TRUNCATE mode to completely flush and reset the WAL file.
669    pub fn checkpoint_wal(&self) -> Result<()> {
670        let db_path = self.cache_path.join(META_DB);
671
672        if !db_path.exists() {
673            // No database to checkpoint
674            return Ok(());
675        }
676
677        let conn = open_meta_db(&db_path).context("Failed to open meta.db for WAL checkpoint")?;
678
679        // PRAGMA wal_checkpoint(TRUNCATE) forces a full checkpoint and truncates the WAL
680        // This ensures background processes see all committed data
681        // Note: Returns (busy, log_pages, checkpointed_pages) - use query instead of execute
682        conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
683            let busy: i64 = row.get(0)?;
684            let log_pages: i64 = row.get(1)?;
685            let checkpointed: i64 = row.get(2)?;
686            log::debug!(
687                "WAL checkpoint completed: busy={}, log_pages={}, checkpointed_pages={}",
688                busy,
689                log_pages,
690                checkpointed
691            );
692            Ok(())
693        })
694        .context("Failed to execute WAL checkpoint")?;
695
696        log::debug!("Executed WAL checkpoint (TRUNCATE) on meta.db");
697        Ok(())
698    }
699
700    /// Load all file hashes across all branches from SQLite
701    ///
702    /// Used by background indexer to get hashes for all indexed files.
703    /// Returns the most recent hash for each file across all branches.
704    pub fn load_all_hashes(&self) -> Result<HashMap<String, String>> {
705        let db_path = self.cache_path.join(META_DB);
706
707        if !db_path.exists() {
708            return Ok(HashMap::new());
709        }
710
711        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
712
713        // Get all hashes from file_branches, joined with files to get paths
714        // If a file appears in multiple branches, we'll get multiple entries
715        // (HashMap will keep the last one, which is fine for background indexer)
716        let mut stmt = conn.prepare(
717            "SELECT f.path, fb.hash
718             FROM file_branches fb
719             JOIN files f ON fb.file_id = f.id",
720        )?;
721        let hashes: HashMap<String, String> = stmt
722            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
723            .collect::<Result<HashMap<_, _>, _>>()?;
724
725        log::debug!(
726            "Loaded {} file hashes across all branches from SQLite",
727            hashes.len()
728        );
729        Ok(hashes)
730    }
731
732    /// Load file hashes for a specific branch from SQLite
733    ///
734    /// Used by indexer and query engine to get hashes for the current branch.
735    /// This ensures branch-specific incremental indexing and symbol cache lookups.
736    pub fn load_hashes_for_branch(&self, branch: &str) -> Result<HashMap<String, String>> {
737        let db_path = self.cache_path.join(META_DB);
738
739        if !db_path.exists() {
740            return Ok(HashMap::new());
741        }
742
743        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
744
745        // Get hashes for specific branch only
746        let mut stmt = conn.prepare(
747            "SELECT f.path, fb.hash
748             FROM file_branches fb
749             JOIN files f ON fb.file_id = f.id
750             JOIN branches b ON fb.branch_id = b.id
751             WHERE b.name = ?",
752        )?;
753        let hashes: HashMap<String, String> = stmt
754            .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
755            .collect::<Result<HashMap<_, _>, _>>()?;
756
757        log::debug!(
758            "Loaded {} file hashes for branch '{}' from SQLite",
759            hashes.len(),
760            branch
761        );
762        Ok(hashes)
763    }
764
765    /// Save file hashes for incremental indexing
766    ///
767    /// DEPRECATED: Hashes are now saved via record_branch_file() or batch_record_branch_files().
768    /// This method is kept for backward compatibility but does nothing.
769    #[deprecated(note = "Hashes are now stored in file_branches table via record_branch_file()")]
770    pub fn save_hashes(&self, _hashes: &HashMap<String, String>) -> Result<()> {
771        // No-op: hashes are now persisted to SQLite in record_branch_file()
772        Ok(())
773    }
774
775    /// Update file metadata in the files table
776    ///
777    /// Note: File content hashes are stored separately in the file_branches table
778    /// via record_branch_file() or batch_record_branch_files().
779    pub fn update_file(&self, path: &str, language: &str, line_count: usize) -> Result<()> {
780        let db_path = self.cache_path.join(META_DB);
781        let conn = open_meta_db(&db_path).context("Failed to open meta.db for file update")?;
782
783        let now = chrono::Utc::now().timestamp();
784
785        conn.execute(
786            "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
787             VALUES (?, ?, ?, ?)",
788            [path, &now.to_string(), language, &line_count.to_string()],
789        )?;
790
791        Ok(())
792    }
793
794    /// Batch update multiple files in a single transaction for performance
795    ///
796    /// Note: File content hashes are stored separately in the file_branches table
797    /// via batch_update_files_and_branch().
798    pub fn batch_update_files(&self, files: &[(String, String, usize)]) -> Result<()> {
799        let db_path = self.cache_path.join(META_DB);
800        let mut conn = open_meta_db(&db_path).context("Failed to open meta.db for batch update")?;
801
802        let now = chrono::Utc::now().timestamp();
803        let now_str = now.to_string();
804
805        // Use a transaction for batch inserts
806        let tx = conn.transaction()?;
807
808        for (path, language, line_count) in files {
809            tx.execute(
810                "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
811                 VALUES (?, ?, ?, ?)",
812                [
813                    path.as_str(),
814                    &now_str,
815                    language.as_str(),
816                    &line_count.to_string(),
817                ],
818            )?;
819        }
820
821        tx.commit()?;
822        Ok(())
823    }
824
825    /// Batch update files AND record their hashes for a branch in a SINGLE transaction
826    ///
827    /// This is the recommended method for indexing as it ensures atomicity:
828    /// if files are inserted, their branch hashes are guaranteed to be inserted too.
829    pub fn batch_update_files_and_branch(
830        &self,
831        files: &[(String, String, usize)], // (path, language, line_count)
832        branch_files: &[(String, String)], // (path, hash)
833        branch: &str,
834        commit_sha: Option<&str>,
835    ) -> Result<()> {
836        log::info!(
837            "batch_update_files_and_branch: Processing {} files for branch '{}'",
838            files.len(),
839            branch
840        );
841
842        let db_path = self.cache_path.join(META_DB);
843        let mut conn = open_meta_db(&db_path)
844            .context("Failed to open meta.db for batch update and branch recording")?;
845
846        let now = chrono::Utc::now().timestamp();
847        let now_str = now.to_string();
848
849        // Use a SINGLE transaction for both operations
850        let tx = conn.transaction()?;
851
852        // Step 1: Insert/update files table
853        for (path, language, line_count) in files {
854            tx.execute(
855                "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
856                 VALUES (?, ?, ?, ?)",
857                [
858                    path.as_str(),
859                    &now_str,
860                    language.as_str(),
861                    &line_count.to_string(),
862                ],
863            )?;
864        }
865        log::info!("Inserted {} files into files table", files.len());
866
867        // Step 2: Get or create branch_id (within same transaction)
868        let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
869        log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
870
871        // Step 3: Insert file_branches entries (within same transaction)
872        let mut inserted = 0;
873        for (path, hash) in branch_files {
874            // Lookup file_id from path (will find it because we just inserted above)
875            let file_id: i64 = tx
876                .query_row(
877                    "SELECT id FROM files WHERE path = ?",
878                    [path.as_str()],
879                    |row| row.get(0),
880                )
881                .context(format!("File not found in index after insert: {}", path))?;
882
883            // Insert into file_branches using INTEGER values (not strings!)
884            tx.execute(
885                "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
886                 VALUES (?, ?, ?, ?)",
887                rusqlite::params![file_id, branch_id, hash.as_str(), now],
888            )?;
889            inserted += 1;
890        }
891        log::info!("Inserted {} file_branches entries", inserted);
892
893        // Step 4: Drop rows for files that are no longer on disk.
894        //
895        // Until 1.7.2 this method was INSERT OR REPLACE only, so `meta.db` never
896        // shrank. A deleted file kept its `files` and `file_branches` rows forever,
897        // `stats()` counts `file_branches`, and so `total_files` still reported 1027
898        // after a deletion. Pruning lived only in `compact()`, which is throttled to
899        // once a day AND skipped entirely for the `mcp`, `watch` and `serve` commands
900        // — so an MCP-only session never pruned at all.
901        //
902        // A temp table rather than a bound IN-list: SQLite caps a statement at 999
903        // parameters, and a workspace has far more files than that.
904        let pruned = {
905            tx.execute_batch(
906                "CREATE TEMP TABLE IF NOT EXISTS current_paths (path TEXT PRIMARY KEY);
907                 DELETE FROM current_paths;",
908            )?;
909            {
910                let mut stmt =
911                    tx.prepare("INSERT OR IGNORE INTO current_paths (path) VALUES (?)")?;
912                for (path, _) in branch_files {
913                    stmt.execute([path.as_str()])?;
914                }
915            }
916
917            // Detach this branch from files it no longer contains.
918            let unlinked = tx.execute(
919                "DELETE FROM file_branches
920                 WHERE branch_id = ?
921                   AND file_id NOT IN (SELECT id FROM files WHERE path IN (SELECT path FROM current_paths))",
922                rusqlite::params![branch_id],
923            )?;
924
925            // Then sweep files no branch references any more. Scoped this way so a
926            // file that still exists on another branch is never dropped.
927            let orphaned = tx.execute(
928                "DELETE FROM files WHERE id NOT IN (SELECT file_id FROM file_branches)",
929                [],
930            )?;
931
932            tx.execute_batch("DROP TABLE IF EXISTS current_paths;")?;
933            (unlinked, orphaned)
934        };
935        if pruned.0 > 0 || pruned.1 > 0 {
936            log::info!(
937                "Pruned {} stale file_branches rows and {} orphaned files rows",
938                pruned.0,
939                pruned.1
940            );
941        }
942
943        // Commit the entire transaction atomically
944        tx.commit()?;
945        log::info!("Transaction committed successfully (files + file_branches)");
946
947        // DIAGNOSTIC: Verify data was actually persisted after commit
948        // This helps diagnose WAL synchronization issues where commits succeed but data isn't visible
949        let verify_conn =
950            open_meta_db(&db_path).context("Failed to open meta.db for verification")?;
951
952        // Count actual files in database
953        let actual_file_count: i64 = verify_conn.query_row(
954            "SELECT COUNT(*) FROM files WHERE path IN (SELECT path FROM files ORDER BY id DESC LIMIT ?)",
955            [files.len()],
956            |row| row.get(0)
957        ).unwrap_or(0);
958
959        // Count actual file_branches entries for this branch
960        let actual_fb_count: i64 = verify_conn
961            .query_row(
962                "SELECT COUNT(*) FROM file_branches fb
963             JOIN branches b ON fb.branch_id = b.id
964             WHERE b.name = ?",
965                [branch],
966                |row| row.get(0),
967            )
968            .unwrap_or(0);
969
970        log::info!(
971            "Post-commit verification: {} files in files table (expected {}), {} file_branches entries for '{}' (expected {})",
972            actual_file_count,
973            files.len(),
974            actual_fb_count,
975            branch,
976            inserted
977        );
978
979        // DEFENSIVE: Warn if counts don't match expectations
980        if actual_file_count < files.len() as i64 {
981            log::warn!(
982                "MISMATCH: Expected {} files in database, but only found {}! Data may not have persisted.",
983                files.len(),
984                actual_file_count
985            );
986        }
987        if actual_fb_count < inserted as i64 {
988            log::warn!(
989                "MISMATCH: Expected {} file_branches entries for branch '{}', but only found {}! Data may not have persisted.",
990                inserted,
991                branch,
992                actual_fb_count
993            );
994        }
995
996        Ok(())
997    }
998
999    /// Update statistics after indexing by calculating totals from database for a specific branch
1000    ///
1001    /// Counts only files indexed for the given branch, not all files across all branches.
1002    pub fn update_stats(&self, branch: &str) -> Result<()> {
1003        let db_path = self.cache_path.join(META_DB);
1004        let conn = open_meta_db(&db_path).context("Failed to open meta.db for stats update")?;
1005
1006        // Count files for specific branch only (branch-aware statistics)
1007        let total_files: usize = conn
1008            .query_row(
1009                "SELECT COUNT(DISTINCT fb.file_id)
1010             FROM file_branches fb
1011             JOIN branches b ON fb.branch_id = b.id
1012             WHERE b.name = ?",
1013                [branch],
1014                |row| row.get(0),
1015            )
1016            .unwrap_or(0);
1017
1018        let now = chrono::Utc::now().timestamp();
1019
1020        conn.execute(
1021            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1022            ["total_files", &total_files.to_string(), &now.to_string()],
1023        )?;
1024
1025        log::debug!(
1026            "Updated statistics for branch '{}': {} files",
1027            branch,
1028            total_files
1029        );
1030        Ok(())
1031    }
1032
1033    /// Check if the stored schema hash matches the current binary's hash.
1034    /// Returns Ok(true) if they match, Ok(false) if they don't, Err on DB errors.
1035    pub fn check_schema_hash(&self) -> Result<bool> {
1036        let db_path = self.cache_path.join(META_DB);
1037        if !db_path.exists() {
1038            return Ok(false);
1039        }
1040        let conn = open_meta_db(&db_path)?;
1041        let current = env!("CACHE_SCHEMA_HASH");
1042        let stored: Option<String> = conn
1043            .query_row(
1044                "SELECT value FROM statistics WHERE key = 'schema_hash'",
1045                [],
1046                |row| row.get(0),
1047            )
1048            .optional()?;
1049        Ok(stored.as_deref() == Some(current))
1050    }
1051
1052    /// Who wrote this cache: `(version, git_sha)`, when the cache records it.
1053    ///
1054    /// `None` for a cache written before 1.7.2, or none at all.
1055    pub fn cache_owner(&self) -> Option<(String, Option<String>)> {
1056        let db_path = self.cache_path.join(META_DB);
1057        if !db_path.exists() {
1058            return None;
1059        }
1060        let conn = open_meta_db(&db_path).ok()?;
1061        let get = |key: &str| -> Option<String> {
1062            conn.query_row("SELECT value FROM statistics WHERE key = ?", [key], |row| {
1063                row.get(0)
1064            })
1065            .optional()
1066            .ok()
1067            .flatten()
1068        };
1069        get("writer_version").map(|v| (v, get("writer_git_sha")))
1070    }
1071
1072    /// Refuse to write a cache a DIFFERENT RELEASED VERSION owns.
1073    ///
1074    /// Cross-version writers into one `.reflex/` is a corruption vector: the field
1075    /// report had three `rfx mcp` servers at two versions sharing a cache, and 1.6.0
1076    /// had already produced `content.bin is too small`.
1077    ///
1078    /// Scoped deliberately narrowly, to the one case that is actually unsafe and
1079    /// actually detectable:
1080    ///
1081    /// * A differing SCHEMA HASH alone is NOT refused. It flips on any change to
1082    ///   cache-critical sources, so it fires for every user on every upgrade and for
1083    ///   every developer on every branch switch. A full rebuild is what it already
1084    ///   triggers, it happens under the workspace `IndexLock`, and it truncates the
1085    ///   binary stores — which is safe.
1086    /// * An UNSTAMPED cache is adopted, not refused. Everything written before 1.7.2
1087    ///   is unstamped, so refusing would break every upgrade.
1088    /// * A cache stamped by a different released version IS refused, because that is
1089    ///   the multi-version-sharing case, and only there can the error name who owns it.
1090    ///
1091    /// `force` (which clears the cache first) and `REFLEX_ALLOW_SCHEMA_REBUILD=1`
1092    /// always pass — taking ownership is what force means.
1093    pub fn assert_writable(&self, force: bool) -> Result<()> {
1094        if force || std::env::var("REFLEX_ALLOW_SCHEMA_REBUILD").is_ok() {
1095            return Ok(());
1096        }
1097
1098        if !self.cache_path.join(META_DB).exists() {
1099            return Ok(());
1100        }
1101
1102        let Some((owner_version, owner_sha)) = self.cache_owner() else {
1103            // Unstamped: written before 1.7.2. Adopt it.
1104            return Ok(());
1105        };
1106
1107        if owner_version == env!("CARGO_PKG_VERSION") {
1108            return Ok(());
1109        }
1110
1111        Err(crate::errors::ReflexError::CacheVersionMismatch {
1112            owner_version,
1113            owner_sha: owner_sha
1114                .map(|s| format!(" (sha {})", &s[..s.len().min(7)]))
1115                .unwrap_or_default(),
1116            this_version: env!("CARGO_PKG_VERSION").to_string(),
1117        }
1118        .into())
1119    }
1120
1121    /// Update cache schema hash in statistics table
1122    ///
1123    /// This should be called after every index operation to ensure the cache
1124    /// is marked as compatible with the current binary version.
1125    pub fn update_schema_hash(&self) -> Result<()> {
1126        let db_path = self.cache_path.join(META_DB);
1127        let conn =
1128            open_meta_db(&db_path).context("Failed to open meta.db for schema hash update")?;
1129
1130        let schema_hash = env!("CACHE_SCHEMA_HASH");
1131        let now = chrono::Utc::now().timestamp();
1132
1133        conn.execute(
1134            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1135            ["schema_hash", schema_hash, &now.to_string()],
1136        )?;
1137        // Keep ownership in step with the hash, so a refusal can always name a version.
1138        conn.execute(
1139            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1140            [
1141                "writer_version",
1142                env!("CARGO_PKG_VERSION"),
1143                &now.to_string(),
1144            ],
1145        )?;
1146        if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
1147            conn.execute(
1148                "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1149                ["writer_git_sha", sha, &now.to_string()],
1150            )?;
1151        }
1152
1153        log::debug!("Updated schema hash to: {}", schema_hash);
1154        Ok(())
1155    }
1156
1157    /// Get list of all indexed files
1158    pub fn list_files(&self) -> Result<Vec<IndexedFile>> {
1159        let db_path = self.cache_path.join(META_DB);
1160
1161        if !db_path.exists() {
1162            return Ok(Vec::new());
1163        }
1164
1165        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1166
1167        let mut stmt =
1168            conn.prepare("SELECT path, language, last_indexed FROM files ORDER BY path")?;
1169
1170        let files = stmt
1171            .query_map([], |row| {
1172                let path: String = row.get(0)?;
1173                let language: String = row.get(1)?;
1174                let last_indexed: i64 = row.get(2)?;
1175
1176                Ok(IndexedFile {
1177                    path,
1178                    language,
1179                    last_indexed: chrono::DateTime::from_timestamp(last_indexed, 0)
1180                        .unwrap_or_else(chrono::Utc::now)
1181                        .to_rfc3339(),
1182                })
1183            })?
1184            .collect::<Result<Vec<_>, _>>()?;
1185
1186        Ok(files)
1187    }
1188
1189    /// Get statistics about the current cache
1190    ///
1191    /// Returns statistics for the current git branch if in a git repo,
1192    /// or global statistics if not in a git repo.
1193    pub fn stats(&self) -> Result<crate::models::IndexStats> {
1194        let db_path = self.cache_path.join(META_DB);
1195
1196        if !db_path.exists() {
1197            // Cache not initialized
1198            return Ok(crate::models::IndexStats {
1199                total_files: 0,
1200                index_size_bytes: 0,
1201                last_updated: chrono::Utc::now().to_rfc3339(),
1202                files_by_language: std::collections::HashMap::new(),
1203                lines_by_language: std::collections::HashMap::new(),
1204                ..Default::default()
1205            });
1206        }
1207
1208        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1209
1210        // Determine current branch for branch-aware statistics
1211        let workspace_root = self.workspace_root();
1212        let current_branch = if crate::git::is_git_repo(&workspace_root) {
1213            crate::git::get_git_state(&workspace_root)
1214                .ok()
1215                .map(|state| state.branch)
1216        } else {
1217            Some("_default".to_string())
1218        };
1219
1220        log::debug!("stats(): current_branch = {:?}", current_branch);
1221
1222        // Read total files (branch-aware)
1223        let total_files: usize = if let Some(ref branch) = current_branch {
1224            log::debug!("stats(): Counting files for branch '{}'", branch);
1225
1226            // Debug: Check all branches
1227            let branches: Vec<(i64, String, i64)> = conn
1228                .prepare("SELECT id, name, file_count FROM branches")
1229                .and_then(|mut stmt| {
1230                    stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1231                        .map(|rows| rows.collect())
1232                })
1233                .and_then(|result| result)
1234                .unwrap_or_default();
1235
1236            for (id, name, count) in &branches {
1237                log::debug!(
1238                    "stats(): Branch ID={}, Name='{}', FileCount={}",
1239                    id,
1240                    name,
1241                    count
1242                );
1243            }
1244
1245            // Debug: Count file_branches per branch
1246            let fb_counts: Vec<(String, i64)> = conn
1247                .prepare(
1248                    "SELECT b.name, COUNT(*) FROM file_branches fb
1249                 JOIN branches b ON fb.branch_id = b.id
1250                 GROUP BY b.name",
1251                )
1252                .and_then(|mut stmt| {
1253                    stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1254                        .map(|rows| rows.collect())
1255                })
1256                .and_then(|result| result)
1257                .unwrap_or_default();
1258
1259            for (name, count) in &fb_counts {
1260                log::debug!(
1261                    "stats(): file_branches count for branch '{}': {}",
1262                    name,
1263                    count
1264                );
1265            }
1266
1267            // Count files for current branch only
1268            let count: usize = conn
1269                .query_row(
1270                    "SELECT COUNT(DISTINCT fb.file_id)
1271                 FROM file_branches fb
1272                 JOIN branches b ON fb.branch_id = b.id
1273                 WHERE b.name = ?",
1274                    [branch],
1275                    |row| row.get(0),
1276                )
1277                .unwrap_or(0);
1278
1279            log::debug!("stats(): Query returned total_files = {}", count);
1280            count
1281        } else {
1282            // No branch info - should not happen, but return 0
1283            log::warn!("stats(): No current_branch detected!");
1284            0
1285        };
1286
1287        // Read last updated timestamp
1288        let last_updated: String = conn
1289            .query_row(
1290                "SELECT updated_at FROM statistics WHERE key = 'total_files'",
1291                [],
1292                |row| {
1293                    let timestamp: i64 = row.get(0)?;
1294                    Ok(chrono::DateTime::from_timestamp(timestamp, 0)
1295                        .unwrap_or_else(chrono::Utc::now)
1296                        .to_rfc3339())
1297                },
1298            )
1299            .unwrap_or_else(|_| chrono::Utc::now().to_rfc3339());
1300
1301        // Calculate total cache size (all binary files)
1302        let mut index_size_bytes: u64 = 0;
1303
1304        for file_name in [
1305            META_DB,
1306            TOKENS_BIN,
1307            CONFIG_TOML,
1308            "content.bin",
1309            "trigrams.bin",
1310        ] {
1311            let file_path = self.cache_path.join(file_name);
1312            if let Ok(metadata) = std::fs::metadata(&file_path) {
1313                index_size_bytes += metadata.len();
1314            }
1315        }
1316
1317        // Get file count breakdown by language (branch-aware if possible)
1318        let mut files_by_language = std::collections::HashMap::new();
1319        if let Some(ref branch) = current_branch {
1320            // Query files for current branch only
1321            let mut stmt = conn.prepare(
1322                "SELECT f.language, COUNT(DISTINCT f.id)
1323                 FROM files f
1324                 JOIN file_branches fb ON f.id = fb.file_id
1325                 JOIN branches b ON fb.branch_id = b.id
1326                 WHERE b.name = ?
1327                 GROUP BY f.language",
1328            )?;
1329            let lang_counts = stmt.query_map([branch], |row| {
1330                let language: String = row.get(0)?;
1331                let count: i64 = row.get(1)?;
1332                Ok((language, count as usize))
1333            })?;
1334
1335            for result in lang_counts {
1336                let (language, count) = result?;
1337                files_by_language.insert(language, count);
1338            }
1339        } else {
1340            // Fallback: query all files
1341            let mut stmt =
1342                conn.prepare("SELECT language, COUNT(*) FROM files GROUP BY language")?;
1343            let lang_counts = stmt.query_map([], |row| {
1344                let language: String = row.get(0)?;
1345                let count: i64 = row.get(1)?;
1346                Ok((language, count as usize))
1347            })?;
1348
1349            for result in lang_counts {
1350                let (language, count) = result?;
1351                files_by_language.insert(language, count);
1352            }
1353        }
1354
1355        // Get line count breakdown by language (branch-aware if possible)
1356        let mut lines_by_language = std::collections::HashMap::new();
1357        if let Some(ref branch) = current_branch {
1358            // Query lines for current branch only
1359            let mut stmt = conn.prepare(
1360                "SELECT f.language, SUM(f.line_count)
1361                 FROM files f
1362                 JOIN file_branches fb ON f.id = fb.file_id
1363                 JOIN branches b ON fb.branch_id = b.id
1364                 WHERE b.name = ?
1365                 GROUP BY f.language",
1366            )?;
1367            let line_counts = stmt.query_map([branch], |row| {
1368                let language: String = row.get(0)?;
1369                let count: i64 = row.get(1)?;
1370                Ok((language, count as usize))
1371            })?;
1372
1373            for result in line_counts {
1374                let (language, count) = result?;
1375                lines_by_language.insert(language, count);
1376            }
1377        } else {
1378            // Fallback: query all files
1379            let mut stmt =
1380                conn.prepare("SELECT language, SUM(line_count) FROM files GROUP BY language")?;
1381            let line_counts = stmt.query_map([], |row| {
1382                let language: String = row.get(0)?;
1383                let count: i64 = row.get(1)?;
1384                Ok((language, count as usize))
1385            })?;
1386
1387            for result in line_counts {
1388                let (language, count) = result?;
1389                lines_by_language.insert(language, count);
1390            }
1391        }
1392
1393        Ok(crate::models::IndexStats {
1394            total_files,
1395            index_size_bytes,
1396            last_updated,
1397            files_by_language,
1398            lines_by_language,
1399            ..Default::default()
1400        })
1401    }
1402
1403    // ===== Branch-aware indexing methods =====
1404
1405    /// Get or create a branch ID by name
1406    ///
1407    /// Returns the numeric branch ID, creating a new entry if needed.
1408    fn get_or_create_branch_id(
1409        &self,
1410        conn: &Connection,
1411        branch_name: &str,
1412        commit_sha: Option<&str>,
1413    ) -> Result<i64> {
1414        // Try to get existing branch
1415        let existing_id: Option<i64> = conn
1416            .query_row(
1417                "SELECT id FROM branches WHERE name = ?",
1418                [branch_name],
1419                |row| row.get(0),
1420            )
1421            .optional()?;
1422
1423        if let Some(id) = existing_id {
1424            return Ok(id);
1425        }
1426
1427        // Create new branch entry
1428        let now = chrono::Utc::now().timestamp();
1429        conn.execute(
1430            "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
1431             VALUES (?, ?, ?, 0, 0)",
1432            [
1433                branch_name,
1434                commit_sha.unwrap_or("unknown"),
1435                &now.to_string(),
1436            ],
1437        )?;
1438
1439        // Get the ID we just created
1440        let id: i64 = conn.last_insert_rowid();
1441        Ok(id)
1442    }
1443
1444    /// Record a file's hash for a specific branch
1445    pub fn record_branch_file(
1446        &self,
1447        path: &str,
1448        branch: &str,
1449        hash: &str,
1450        commit_sha: Option<&str>,
1451    ) -> Result<()> {
1452        let db_path = self.cache_path.join(META_DB);
1453        let conn =
1454            open_meta_db(&db_path).context("Failed to open meta.db for branch file recording")?;
1455
1456        // Lookup file_id from path
1457        let file_id: i64 = conn
1458            .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
1459                row.get(0)
1460            })
1461            .context(format!("File not found in index: {}", path))?;
1462
1463        // Get or create branch_id
1464        let branch_id = self.get_or_create_branch_id(&conn, branch, commit_sha)?;
1465
1466        let now = chrono::Utc::now().timestamp();
1467
1468        // Insert using proper INTEGER types (not strings!)
1469        conn.execute(
1470            "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1471             VALUES (?, ?, ?, ?)",
1472            rusqlite::params![file_id, branch_id, hash, now],
1473        )?;
1474
1475        Ok(())
1476    }
1477
1478    /// Batch record multiple files for a specific branch in a single transaction
1479    ///
1480    /// IMPORTANT: Files must already exist in the `files` table before calling this method.
1481    /// For atomic insertion of both files and branch hashes, use `batch_update_files_and_branch()` instead.
1482    pub fn batch_record_branch_files(
1483        &self,
1484        files: &[(String, String)], // (path, hash)
1485        branch: &str,
1486        commit_sha: Option<&str>,
1487    ) -> Result<()> {
1488        log::info!(
1489            "batch_record_branch_files: Processing {} files for branch '{}'",
1490            files.len(),
1491            branch
1492        );
1493
1494        let db_path = self.cache_path.join(META_DB);
1495        let mut conn =
1496            open_meta_db(&db_path).context("Failed to open meta.db for batch branch recording")?;
1497
1498        let now = chrono::Utc::now().timestamp();
1499
1500        // Use a transaction for batch inserts
1501        let tx = conn.transaction()?;
1502
1503        // Get or create branch_id (use transaction connection)
1504        let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
1505        log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
1506
1507        let mut inserted = 0;
1508        for (path, hash) in files {
1509            // Lookup file_id from path
1510            log::trace!("Looking up file_id for path: {}", path);
1511            let file_id: i64 = tx
1512                .query_row(
1513                    "SELECT id FROM files WHERE path = ?",
1514                    [path.as_str()],
1515                    |row| row.get(0),
1516                )
1517                .context(format!("File not found in index: {}", path))?;
1518            log::trace!("Found file_id={} for path: {}", file_id, path);
1519
1520            // Insert using proper INTEGER types (not strings!)
1521            tx.execute(
1522                "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1523                 VALUES (?, ?, ?, ?)",
1524                rusqlite::params![file_id, branch_id, hash.as_str(), now],
1525            )?;
1526            inserted += 1;
1527        }
1528
1529        log::info!("Inserted {} file_branches entries", inserted);
1530        tx.commit()?;
1531        log::info!("Transaction committed successfully");
1532        Ok(())
1533    }
1534
1535    /// Get all files indexed for a specific branch
1536    ///
1537    /// Returns a HashMap of path → hash for all files in the branch.
1538    pub fn get_branch_files(&self, branch: &str) -> Result<HashMap<String, String>> {
1539        let db_path = self.cache_path.join(META_DB);
1540
1541        if !db_path.exists() {
1542            return Ok(HashMap::new());
1543        }
1544
1545        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1546
1547        let mut stmt = conn.prepare(
1548            "SELECT f.path, fb.hash
1549             FROM file_branches fb
1550             JOIN files f ON fb.file_id = f.id
1551             JOIN branches b ON fb.branch_id = b.id
1552             WHERE b.name = ?",
1553        )?;
1554        let files: HashMap<String, String> = stmt
1555            .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
1556            .collect::<Result<HashMap<_, _>, _>>()?;
1557
1558        log::debug!(
1559            "Loaded {} files for branch '{}' from file_branches table",
1560            files.len(),
1561            branch
1562        );
1563        Ok(files)
1564    }
1565
1566    /// Check if a branch has any indexed files
1567    ///
1568    /// Fast existence check using LIMIT 1 for O(1) performance.
1569    pub fn branch_exists(&self, branch: &str) -> Result<bool> {
1570        let db_path = self.cache_path.join(META_DB);
1571
1572        if !db_path.exists() {
1573            return Ok(false);
1574        }
1575
1576        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1577
1578        let count: i64 = conn
1579            .query_row(
1580                "SELECT COUNT(*)
1581                 FROM file_branches fb
1582                 JOIN branches b ON fb.branch_id = b.id
1583                 WHERE b.name = ?
1584                 LIMIT 1",
1585                [branch],
1586                |row| row.get(0),
1587            )
1588            .unwrap_or(0);
1589
1590        Ok(count > 0)
1591    }
1592
1593    /// Get branch metadata (commit, last_indexed, file_count, dirty status)
1594    pub fn get_branch_info(&self, branch: &str) -> Result<BranchInfo> {
1595        let db_path = self.cache_path.join(META_DB);
1596
1597        if !db_path.exists() {
1598            anyhow::bail!("Database not initialized");
1599        }
1600
1601        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1602
1603        let info = conn.query_row(
1604            "SELECT commit_sha, last_indexed, file_count, is_dirty FROM branches WHERE name = ?",
1605            [branch],
1606            |row| {
1607                Ok(BranchInfo {
1608                    branch: branch.to_string(),
1609                    commit_sha: row.get(0)?,
1610                    last_indexed: row.get(1)?,
1611                    file_count: row.get(2)?,
1612                    is_dirty: row.get::<_, i64>(3)? != 0,
1613                })
1614            },
1615        )?;
1616
1617        Ok(info)
1618    }
1619
1620    /// Update branch metadata after indexing
1621    ///
1622    /// Uses UPDATE instead of INSERT OR REPLACE to preserve branch_id and prevent
1623    /// CASCADE DELETE on file_branches table.
1624    pub fn update_branch_metadata(
1625        &self,
1626        branch: &str,
1627        commit_sha: Option<&str>,
1628        file_count: usize,
1629        is_dirty: bool,
1630    ) -> Result<()> {
1631        let db_path = self.cache_path.join(META_DB);
1632        let conn =
1633            open_meta_db(&db_path).context("Failed to open meta.db for branch metadata update")?;
1634
1635        let now = chrono::Utc::now().timestamp();
1636        let is_dirty_int = if is_dirty { 1 } else { 0 };
1637
1638        // Try UPDATE first to preserve branch_id (prevents CASCADE DELETE)
1639        let rows_updated = conn.execute(
1640            "UPDATE branches
1641             SET commit_sha = ?, last_indexed = ?, file_count = ?, is_dirty = ?
1642             WHERE name = ?",
1643            rusqlite::params![
1644                commit_sha.unwrap_or("unknown"),
1645                now,
1646                file_count,
1647                is_dirty_int,
1648                branch
1649            ],
1650        )?;
1651
1652        // If no rows updated (branch doesn't exist yet), INSERT new one
1653        if rows_updated == 0 {
1654            conn.execute(
1655                "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
1656                 VALUES (?, ?, ?, ?, ?)",
1657                rusqlite::params![
1658                    branch,
1659                    commit_sha.unwrap_or("unknown"),
1660                    now,
1661                    file_count,
1662                    is_dirty_int
1663                ],
1664            )?;
1665        }
1666
1667        log::debug!(
1668            "Updated branch metadata for '{}': commit={}, files={}, dirty={}",
1669            branch,
1670            commit_sha.unwrap_or("unknown"),
1671            file_count,
1672            is_dirty
1673        );
1674        Ok(())
1675    }
1676
1677    /// Find a file with a specific hash (for symbol reuse optimization)
1678    ///
1679    /// Returns the path and branch where this hash was first seen,
1680    /// enabling reuse of parsed symbols across branches.
1681    pub fn find_file_with_hash(&self, hash: &str) -> Result<Option<(String, String)>> {
1682        let db_path = self.cache_path.join(META_DB);
1683
1684        if !db_path.exists() {
1685            return Ok(None);
1686        }
1687
1688        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1689
1690        let result = conn
1691            .query_row(
1692                "SELECT f.path, b.name
1693                 FROM file_branches fb
1694                 JOIN files f ON fb.file_id = f.id
1695                 JOIN branches b ON fb.branch_id = b.id
1696                 WHERE fb.hash = ?
1697                 LIMIT 1",
1698                [hash],
1699                |row| Ok((row.get(0)?, row.get(1)?)),
1700            )
1701            .optional()?;
1702
1703        Ok(result)
1704    }
1705
1706    /// Get file ID by path
1707    ///
1708    /// Returns the integer ID for a file path, or None if not found.
1709    pub fn get_file_id(&self, path: &str) -> Result<Option<i64>> {
1710        let db_path = self.cache_path.join(META_DB);
1711
1712        if !db_path.exists() {
1713            return Ok(None);
1714        }
1715
1716        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1717
1718        let result = conn
1719            .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
1720                row.get(0)
1721            })
1722            .optional()?;
1723
1724        Ok(result)
1725    }
1726
1727    /// Batch get file IDs for multiple paths
1728    ///
1729    /// Returns a HashMap of path → file_id for all found paths.
1730    /// Paths not in the database are omitted from the result.
1731    ///
1732    /// Automatically chunks large batches to avoid SQLite parameter limits (999 max).
1733    pub fn batch_get_file_ids(&self, paths: &[String]) -> Result<HashMap<String, i64>> {
1734        let db_path = self.cache_path.join(META_DB);
1735
1736        if !db_path.exists() {
1737            return Ok(HashMap::new());
1738        }
1739
1740        let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1741
1742        // SQLite has a limit of 999 parameters by default
1743        // Chunk requests to stay well under that limit
1744        const BATCH_SIZE: usize = 900;
1745
1746        let mut results = HashMap::new();
1747
1748        for chunk in paths.chunks(BATCH_SIZE) {
1749            // Build IN clause for this chunk
1750            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1751
1752            let query = format!(
1753                "SELECT path, id FROM files WHERE path IN ({})",
1754                placeholders
1755            );
1756
1757            let params: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
1758            let mut stmt = conn.prepare(&query)?;
1759
1760            let chunk_results = stmt
1761                .query_map(rusqlite::params_from_iter(params), |row| {
1762                    Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
1763                })?
1764                .collect::<Result<HashMap<_, _>, _>>()?;
1765
1766            results.extend(chunk_results);
1767        }
1768
1769        log::debug!(
1770            "Batch loaded {} file IDs (out of {} requested, {} chunks)",
1771            results.len(),
1772            paths.len(),
1773            paths.len().div_ceil(BATCH_SIZE)
1774        );
1775        Ok(results)
1776    }
1777
1778    // ===== Cache compaction methods =====
1779
1780    /// Check if cache compaction should run
1781    ///
1782    /// Returns true if 24+ hours have passed since last compaction (or never compacted).
1783    /// Compaction threshold: 86400 seconds (24 hours)
1784    pub fn should_compact(&self) -> Result<bool> {
1785        let db_path = self.cache_path.join(META_DB);
1786
1787        if !db_path.exists() {
1788            // No database means no compaction needed
1789            return Ok(false);
1790        }
1791
1792        let conn = open_meta_db(&db_path).context("Failed to open meta.db for compaction check")?;
1793
1794        // Get last_compaction timestamp (defaults to "0" if not found)
1795        let last_compaction: i64 = conn
1796            .query_row(
1797                "SELECT value FROM statistics WHERE key = 'last_compaction'",
1798                [],
1799                |row| {
1800                    let value: String = row.get(0)?;
1801                    Ok(value.parse::<i64>().unwrap_or(0))
1802                },
1803            )
1804            .unwrap_or(0);
1805
1806        // Get current timestamp
1807        let now = chrono::Utc::now().timestamp();
1808
1809        // Compaction threshold: 24 hours (86400 seconds)
1810        const COMPACTION_THRESHOLD_SECS: i64 = 86400;
1811
1812        let elapsed_secs = now - last_compaction;
1813        let should_run = elapsed_secs >= COMPACTION_THRESHOLD_SECS;
1814
1815        log::debug!(
1816            "Compaction check: last={}, now={}, elapsed={}s, should_compact={}",
1817            last_compaction,
1818            now,
1819            elapsed_secs,
1820            should_run
1821        );
1822
1823        Ok(should_run)
1824    }
1825
1826    /// Update last_compaction timestamp in statistics table
1827    ///
1828    /// Called after successful compaction to record when it ran.
1829    pub fn update_compaction_timestamp(&self) -> Result<()> {
1830        let db_path = self.cache_path.join(META_DB);
1831        let conn = open_meta_db(&db_path)
1832            .context("Failed to open meta.db for compaction timestamp update")?;
1833
1834        let now = chrono::Utc::now().timestamp();
1835
1836        conn.execute(
1837            "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1838            ["last_compaction", &now.to_string(), &now.to_string()],
1839        )?;
1840
1841        log::debug!("Updated last_compaction timestamp to: {}", now);
1842        Ok(())
1843    }
1844
1845    /// Compact the cache by removing deleted files and reclaiming disk space
1846    ///
1847    /// This operation:
1848    /// 1. Identifies files in the database that no longer exist on disk
1849    /// 2. Deletes those files from all database tables (CASCADE handles related data)
1850    /// 3. Runs VACUUM to reclaim disk space from deleted rows
1851    /// 4. Updates the last_compaction timestamp
1852    ///
1853    /// Returns a CompactionReport with statistics about the operation.
1854    /// Safe to run concurrently with queries (uses SQLite transactions).
1855    pub fn compact(&self) -> Result<crate::models::CompactionReport> {
1856        let start_time = std::time::Instant::now();
1857        log::info!("Starting cache compaction...");
1858
1859        // Get initial cache size
1860        let size_before = self.calculate_cache_size()?;
1861
1862        // Step 1: Identify deleted files (in DB but not on filesystem)
1863        let deleted_files = self.identify_deleted_files()?;
1864        log::info!(
1865            "Found {} deleted files to remove from cache",
1866            deleted_files.len()
1867        );
1868
1869        if deleted_files.is_empty() {
1870            log::info!("No deleted files to compact - cache is clean");
1871            // Update timestamp anyway to prevent running compaction too frequently
1872            self.update_compaction_timestamp()?;
1873
1874            return Ok(crate::models::CompactionReport {
1875                files_removed: 0,
1876                space_saved_bytes: 0,
1877                duration_ms: start_time.elapsed().as_millis() as u64,
1878            });
1879        }
1880
1881        // Step 2: Delete from database (CASCADE handles file_branches, file_dependencies, file_exports)
1882        self.delete_files_from_db(&deleted_files)?;
1883        log::info!("Deleted {} files from database", deleted_files.len());
1884
1885        // Step 3: Run VACUUM to reclaim disk space
1886        self.vacuum_database()?;
1887        log::info!("Completed VACUUM operation");
1888
1889        // Get final cache size
1890        let size_after = self.calculate_cache_size()?;
1891        let space_saved = size_before.saturating_sub(size_after);
1892
1893        // Step 4: Update last_compaction timestamp
1894        self.update_compaction_timestamp()?;
1895
1896        let duration_ms = start_time.elapsed().as_millis() as u64;
1897
1898        log::info!(
1899            "Cache compaction completed: {} files removed, {} bytes saved ({:.2} MB), took {}ms",
1900            deleted_files.len(),
1901            space_saved,
1902            space_saved as f64 / 1_048_576.0,
1903            duration_ms
1904        );
1905
1906        Ok(crate::models::CompactionReport {
1907            files_removed: deleted_files.len(),
1908            space_saved_bytes: space_saved,
1909            duration_ms,
1910        })
1911    }
1912
1913    /// Identify files in database that no longer exist on filesystem
1914    ///
1915    /// Returns a Vec of file IDs for files that should be removed from the cache.
1916    pub(crate) fn identify_deleted_files(&self) -> Result<Vec<i64>> {
1917        let db_path = self.cache_path.join(META_DB);
1918        let conn = open_meta_db(&db_path)
1919            .context("Failed to open meta.db for deleted file identification")?;
1920
1921        let workspace_root = self.workspace_root();
1922
1923        // Query all files from database (id, path)
1924        let mut stmt = conn.prepare("SELECT id, path FROM files")?;
1925        let files = stmt
1926            .query_map([], |row| {
1927                Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1928            })?
1929            .collect::<Result<Vec<_>, _>>()?;
1930
1931        log::debug!("Checking {} files for deletion status", files.len());
1932
1933        // Check which files no longer exist on disk
1934        let mut deleted_file_ids = Vec::new();
1935        for (file_id, file_path) in files {
1936            let full_path = workspace_root.join(&file_path);
1937            if !full_path.exists() {
1938                log::trace!("File no longer exists: {} (id={})", file_path, file_id);
1939                deleted_file_ids.push(file_id);
1940            }
1941        }
1942
1943        Ok(deleted_file_ids)
1944    }
1945
1946    /// Delete files from database by file ID
1947    ///
1948    /// Uses a transaction for atomicity. CASCADE delete handles:
1949    /// - file_branches entries
1950    /// - file_dependencies entries
1951    /// - file_exports entries
1952    pub(crate) fn delete_files_from_db(&self, file_ids: &[i64]) -> Result<()> {
1953        if file_ids.is_empty() {
1954            return Ok(());
1955        }
1956
1957        let db_path = self.cache_path.join(META_DB);
1958        let mut conn =
1959            open_meta_db(&db_path).context("Failed to open meta.db for file deletion")?;
1960
1961        let tx = conn.transaction()?;
1962
1963        // Delete files in batches to avoid SQLite parameter limit (999 max)
1964        const BATCH_SIZE: usize = 900;
1965
1966        for chunk in file_ids.chunks(BATCH_SIZE) {
1967            let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1968
1969            let delete_query = format!("DELETE FROM files WHERE id IN ({})", placeholders);
1970
1971            let params: Vec<i64> = chunk.to_vec();
1972            tx.execute(&delete_query, rusqlite::params_from_iter(params))?;
1973        }
1974
1975        tx.commit()?;
1976        log::debug!(
1977            "Deleted {} files from database (CASCADE handled related tables)",
1978            file_ids.len()
1979        );
1980        Ok(())
1981    }
1982
1983    /// Run VACUUM on SQLite database to reclaim disk space
1984    ///
1985    /// VACUUM rebuilds the database file, removing free pages and compacting the file.
1986    /// This can take several seconds on large databases but significantly reduces disk usage.
1987    fn vacuum_database(&self) -> Result<()> {
1988        let db_path = self.cache_path.join(META_DB);
1989        let conn = open_meta_db(&db_path).context("Failed to open meta.db for VACUUM")?;
1990
1991        // VACUUM cannot run inside a transaction
1992        // It rebuilds the entire database file
1993        conn.execute("VACUUM", [])?;
1994
1995        log::debug!("VACUUM completed successfully");
1996        Ok(())
1997    }
1998
1999    /// Calculate total cache size in bytes
2000    ///
2001    /// Sums up the size of all cache files:
2002    /// - meta.db (SQLite database)
2003    /// - trigrams.bin (inverted index)
2004    /// - content.bin (file contents)
2005    /// - config.toml (configuration)
2006    fn calculate_cache_size(&self) -> Result<u64> {
2007        let mut total_size: u64 = 0;
2008
2009        for file_name in [
2010            META_DB,
2011            TOKENS_BIN,
2012            CONFIG_TOML,
2013            "content.bin",
2014            "trigrams.bin",
2015        ] {
2016            let file_path = self.cache_path.join(file_name);
2017            if let Ok(metadata) = std::fs::metadata(&file_path) {
2018                total_size += metadata.len();
2019            }
2020        }
2021
2022        Ok(total_size)
2023    }
2024}
2025
2026/// Branch metadata information
2027#[derive(Debug, Clone)]
2028pub struct BranchInfo {
2029    pub branch: String,
2030    pub commit_sha: String,
2031    pub last_indexed: i64,
2032    pub file_count: usize,
2033    pub is_dirty: bool,
2034}
2035
2036// TODO: Implement memory-mapped readers for:
2037// - SymbolReader (reads from symbols.bin)
2038// - TokenReader (reads from tokens.bin)
2039// - MetaReader (reads from meta.db)
2040
2041#[cfg(test)]
2042mod tests {
2043    use super::*;
2044    use tempfile::TempDir;
2045
2046    #[test]
2047    fn test_cache_init() {
2048        let temp = TempDir::new().unwrap();
2049        let cache = CacheManager::new(temp.path());
2050
2051        assert!(!cache.exists());
2052        cache.init().unwrap();
2053        assert!(cache.exists());
2054        assert!(cache.path().exists());
2055
2056        // Verify all expected files were created
2057        assert!(cache.path().join(META_DB).exists());
2058        assert!(cache.path().join(CONFIG_TOML).exists());
2059    }
2060
2061    #[test]
2062    fn test_cache_init_idempotent() {
2063        let temp = TempDir::new().unwrap();
2064        let cache = CacheManager::new(temp.path());
2065
2066        // Initialize twice - should not error
2067        cache.init().unwrap();
2068        cache.init().unwrap();
2069
2070        assert!(cache.exists());
2071    }
2072
2073    #[test]
2074    fn test_cache_clear() {
2075        let temp = TempDir::new().unwrap();
2076        let cache = CacheManager::new(temp.path());
2077
2078        cache.init().unwrap();
2079        assert!(cache.exists());
2080
2081        cache.clear().unwrap();
2082        assert!(!cache.exists());
2083    }
2084
2085    #[test]
2086    fn test_cache_clear_nonexistent() {
2087        let temp = TempDir::new().unwrap();
2088        let cache = CacheManager::new(temp.path());
2089
2090        // Clearing non-existent cache should not error
2091        assert!(!cache.exists());
2092        cache.clear().unwrap();
2093        assert!(!cache.exists());
2094    }
2095
2096    #[test]
2097    fn test_load_all_hashes_empty() {
2098        let temp = TempDir::new().unwrap();
2099        let cache = CacheManager::new(temp.path());
2100
2101        cache.init().unwrap();
2102        let hashes = cache.load_all_hashes().unwrap();
2103        assert_eq!(hashes.len(), 0);
2104    }
2105
2106    #[test]
2107    fn test_load_all_hashes_before_init() {
2108        let temp = TempDir::new().unwrap();
2109        let cache = CacheManager::new(temp.path());
2110
2111        // Loading hashes before init should return empty map
2112        let hashes = cache.load_all_hashes().unwrap();
2113        assert_eq!(hashes.len(), 0);
2114    }
2115
2116    #[test]
2117    fn test_load_hashes_for_branch_empty() {
2118        let temp = TempDir::new().unwrap();
2119        let cache = CacheManager::new(temp.path());
2120
2121        cache.init().unwrap();
2122        let hashes = cache.load_hashes_for_branch("main").unwrap();
2123        assert_eq!(hashes.len(), 0);
2124    }
2125
2126    #[test]
2127    fn test_update_file() {
2128        let temp = TempDir::new().unwrap();
2129        let cache = CacheManager::new(temp.path());
2130
2131        cache.init().unwrap();
2132        cache.update_file("src/main.rs", "rust", 100).unwrap();
2133
2134        // Verify file was stored (check via list_files)
2135        let files = cache.list_files().unwrap();
2136        assert_eq!(files.len(), 1);
2137        assert_eq!(files[0].path, "src/main.rs");
2138        assert_eq!(files[0].language, "rust");
2139    }
2140
2141    #[test]
2142    fn test_update_file_multiple() {
2143        let temp = TempDir::new().unwrap();
2144        let cache = CacheManager::new(temp.path());
2145
2146        cache.init().unwrap();
2147        cache.update_file("src/main.rs", "rust", 100).unwrap();
2148        cache.update_file("src/lib.rs", "rust", 200).unwrap();
2149        cache.update_file("README.md", "markdown", 50).unwrap();
2150
2151        // Verify files were stored
2152        let files = cache.list_files().unwrap();
2153        assert_eq!(files.len(), 3);
2154    }
2155
2156    #[test]
2157    fn test_update_file_replace() {
2158        let temp = TempDir::new().unwrap();
2159        let cache = CacheManager::new(temp.path());
2160
2161        cache.init().unwrap();
2162        cache.update_file("src/main.rs", "rust", 100).unwrap();
2163        cache.update_file("src/main.rs", "rust", 150).unwrap();
2164
2165        // Second update should replace the first
2166        let files = cache.list_files().unwrap();
2167        assert_eq!(files.len(), 1);
2168        assert_eq!(files[0].path, "src/main.rs");
2169    }
2170
2171    #[test]
2172    fn test_batch_update_files() {
2173        let temp = TempDir::new().unwrap();
2174        let cache = CacheManager::new(temp.path());
2175
2176        cache.init().unwrap();
2177
2178        let files = vec![
2179            ("src/main.rs".to_string(), "rust".to_string(), 100),
2180            ("src/lib.rs".to_string(), "rust".to_string(), 200),
2181            ("test.py".to_string(), "python".to_string(), 50),
2182        ];
2183
2184        cache.batch_update_files(&files).unwrap();
2185
2186        // Verify files were stored
2187        let stored_files = cache.list_files().unwrap();
2188        assert_eq!(stored_files.len(), 3);
2189    }
2190
2191    #[test]
2192    fn test_update_stats() {
2193        let temp = TempDir::new().unwrap();
2194        let cache = CacheManager::new(temp.path());
2195
2196        cache.init().unwrap();
2197        cache.update_file("src/main.rs", "rust", 100).unwrap();
2198        cache.update_file("src/lib.rs", "rust", 200).unwrap();
2199
2200        // Record files for a test branch
2201        cache
2202            .record_branch_file("src/main.rs", "_default", "hash1", None)
2203            .unwrap();
2204        cache
2205            .record_branch_file("src/lib.rs", "_default", "hash2", None)
2206            .unwrap();
2207        cache.update_stats("_default").unwrap();
2208
2209        let stats = cache.stats().unwrap();
2210        assert_eq!(stats.total_files, 2);
2211    }
2212
2213    #[test]
2214    fn test_stats_empty_cache() {
2215        let temp = TempDir::new().unwrap();
2216        let cache = CacheManager::new(temp.path());
2217
2218        cache.init().unwrap();
2219        let stats = cache.stats().unwrap();
2220
2221        assert_eq!(stats.total_files, 0);
2222        assert_eq!(stats.files_by_language.len(), 0);
2223    }
2224
2225    #[test]
2226    fn test_stats_before_init() {
2227        let temp = TempDir::new().unwrap();
2228        let cache = CacheManager::new(temp.path());
2229
2230        // Stats before init should return zeros
2231        let stats = cache.stats().unwrap();
2232        assert_eq!(stats.total_files, 0);
2233    }
2234
2235    #[test]
2236    fn test_stats_by_language() {
2237        let temp = TempDir::new().unwrap();
2238        let cache = CacheManager::new(temp.path());
2239
2240        cache.init().unwrap();
2241        cache.update_file("main.rs", "Rust", 100).unwrap();
2242        cache.update_file("lib.rs", "Rust", 200).unwrap();
2243        cache.update_file("script.py", "Python", 50).unwrap();
2244        cache.update_file("test.py", "Python", 80).unwrap();
2245
2246        // Record files for a test branch
2247        cache
2248            .record_branch_file("main.rs", "_default", "hash1", None)
2249            .unwrap();
2250        cache
2251            .record_branch_file("lib.rs", "_default", "hash2", None)
2252            .unwrap();
2253        cache
2254            .record_branch_file("script.py", "_default", "hash3", None)
2255            .unwrap();
2256        cache
2257            .record_branch_file("test.py", "_default", "hash4", None)
2258            .unwrap();
2259        cache.update_stats("_default").unwrap();
2260
2261        let stats = cache.stats().unwrap();
2262        assert_eq!(stats.files_by_language.get("Rust"), Some(&2));
2263        assert_eq!(stats.files_by_language.get("Python"), Some(&2));
2264        assert_eq!(stats.lines_by_language.get("Rust"), Some(&300)); // 100 + 200
2265        assert_eq!(stats.lines_by_language.get("Python"), Some(&130)); // 50 + 80
2266    }
2267
2268    #[test]
2269    fn test_list_files_empty() {
2270        let temp = TempDir::new().unwrap();
2271        let cache = CacheManager::new(temp.path());
2272
2273        cache.init().unwrap();
2274        let files = cache.list_files().unwrap();
2275        assert_eq!(files.len(), 0);
2276    }
2277
2278    #[test]
2279    fn test_list_files() {
2280        let temp = TempDir::new().unwrap();
2281        let cache = CacheManager::new(temp.path());
2282
2283        cache.init().unwrap();
2284        cache.update_file("src/main.rs", "rust", 100).unwrap();
2285        cache.update_file("src/lib.rs", "rust", 200).unwrap();
2286
2287        let files = cache.list_files().unwrap();
2288        assert_eq!(files.len(), 2);
2289
2290        // Files should be sorted by path
2291        assert_eq!(files[0].path, "src/lib.rs");
2292        assert_eq!(files[1].path, "src/main.rs");
2293
2294        assert_eq!(files[0].language, "rust");
2295    }
2296
2297    #[test]
2298    fn test_list_files_before_init() {
2299        let temp = TempDir::new().unwrap();
2300        let cache = CacheManager::new(temp.path());
2301
2302        // Listing files before init should return empty vec
2303        let files = cache.list_files().unwrap();
2304        assert_eq!(files.len(), 0);
2305    }
2306
2307    #[test]
2308    fn test_branch_exists() {
2309        let temp = TempDir::new().unwrap();
2310        let cache = CacheManager::new(temp.path());
2311
2312        cache.init().unwrap();
2313
2314        assert!(!cache.branch_exists("main").unwrap());
2315
2316        // Add file to index first (required for record_branch_file)
2317        cache.update_file("src/main.rs", "rust", 100).unwrap();
2318        cache
2319            .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2320            .unwrap();
2321
2322        assert!(cache.branch_exists("main").unwrap());
2323        assert!(!cache.branch_exists("feature-branch").unwrap());
2324    }
2325
2326    #[test]
2327    fn test_record_branch_file() {
2328        let temp = TempDir::new().unwrap();
2329        let cache = CacheManager::new(temp.path());
2330
2331        cache.init().unwrap();
2332        // Add file to index first (required for record_branch_file)
2333        cache.update_file("src/main.rs", "rust", 100).unwrap();
2334        cache
2335            .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2336            .unwrap();
2337
2338        let files = cache.get_branch_files("main").unwrap();
2339        assert_eq!(files.len(), 1);
2340        assert_eq!(files.get("src/main.rs"), Some(&"hash1".to_string()));
2341    }
2342
2343    #[test]
2344    fn test_get_branch_files_empty() {
2345        let temp = TempDir::new().unwrap();
2346        let cache = CacheManager::new(temp.path());
2347
2348        cache.init().unwrap();
2349        let files = cache.get_branch_files("nonexistent").unwrap();
2350        assert_eq!(files.len(), 0);
2351    }
2352
2353    #[test]
2354    fn test_batch_record_branch_files() {
2355        let temp = TempDir::new().unwrap();
2356        let cache = CacheManager::new(temp.path());
2357
2358        cache.init().unwrap();
2359
2360        // Add files to index first (required for batch_record_branch_files)
2361        let file_metadata = vec![
2362            ("src/main.rs".to_string(), "rust".to_string(), 100),
2363            ("src/lib.rs".to_string(), "rust".to_string(), 200),
2364            ("README.md".to_string(), "markdown".to_string(), 50),
2365        ];
2366        cache.batch_update_files(&file_metadata).unwrap();
2367
2368        let files = vec![
2369            ("src/main.rs".to_string(), "hash1".to_string()),
2370            ("src/lib.rs".to_string(), "hash2".to_string()),
2371            ("README.md".to_string(), "hash3".to_string()),
2372        ];
2373
2374        cache
2375            .batch_record_branch_files(&files, "main", Some("commit123"))
2376            .unwrap();
2377
2378        let branch_files = cache.get_branch_files("main").unwrap();
2379        assert_eq!(branch_files.len(), 3);
2380        assert_eq!(branch_files.get("src/main.rs"), Some(&"hash1".to_string()));
2381        assert_eq!(branch_files.get("src/lib.rs"), Some(&"hash2".to_string()));
2382        assert_eq!(branch_files.get("README.md"), Some(&"hash3".to_string()));
2383    }
2384
2385    #[test]
2386    fn test_update_branch_metadata() {
2387        let temp = TempDir::new().unwrap();
2388        let cache = CacheManager::new(temp.path());
2389
2390        cache.init().unwrap();
2391        cache
2392            .update_branch_metadata("main", Some("commit123"), 10, false)
2393            .unwrap();
2394
2395        let info = cache.get_branch_info("main").unwrap();
2396        assert_eq!(info.branch, "main");
2397        assert_eq!(info.commit_sha, "commit123");
2398        assert_eq!(info.file_count, 10);
2399        assert!(!info.is_dirty);
2400    }
2401
2402    #[test]
2403    fn test_update_branch_metadata_dirty() {
2404        let temp = TempDir::new().unwrap();
2405        let cache = CacheManager::new(temp.path());
2406
2407        cache.init().unwrap();
2408        cache
2409            .update_branch_metadata("feature", Some("commit456"), 5, true)
2410            .unwrap();
2411
2412        let info = cache.get_branch_info("feature").unwrap();
2413        assert!(info.is_dirty);
2414    }
2415
2416    #[test]
2417    fn test_find_file_with_hash() {
2418        let temp = TempDir::new().unwrap();
2419        let cache = CacheManager::new(temp.path());
2420
2421        cache.init().unwrap();
2422        // Add file to index first (required for record_branch_file)
2423        cache.update_file("src/main.rs", "rust", 100).unwrap();
2424        cache
2425            .record_branch_file("src/main.rs", "main", "unique_hash", Some("commit123"))
2426            .unwrap();
2427
2428        let result = cache.find_file_with_hash("unique_hash").unwrap();
2429        assert!(result.is_some());
2430
2431        let (path, branch) = result.unwrap();
2432        assert_eq!(path, "src/main.rs");
2433        assert_eq!(branch, "main");
2434    }
2435
2436    #[test]
2437    fn test_find_file_with_hash_not_found() {
2438        let temp = TempDir::new().unwrap();
2439        let cache = CacheManager::new(temp.path());
2440
2441        cache.init().unwrap();
2442
2443        let result = cache.find_file_with_hash("nonexistent_hash").unwrap();
2444        assert!(result.is_none());
2445    }
2446
2447    #[test]
2448    fn test_config_toml_created() {
2449        let temp = TempDir::new().unwrap();
2450        let cache = CacheManager::new(temp.path());
2451
2452        cache.init().unwrap();
2453
2454        let config_path = cache.path().join(CONFIG_TOML);
2455        let config_content = std::fs::read_to_string(&config_path).unwrap();
2456
2457        // Verify config contains expected sections
2458        assert!(config_content.contains("[index]"));
2459        assert!(config_content.contains("[search]"));
2460        assert!(config_content.contains("[performance]"));
2461        assert!(config_content.contains("max_file_size"));
2462    }
2463
2464    #[test]
2465    fn test_meta_db_schema() {
2466        let temp = TempDir::new().unwrap();
2467        let cache = CacheManager::new(temp.path());
2468
2469        cache.init().unwrap();
2470
2471        let db_path = cache.path().join(META_DB);
2472        let conn = open_meta_db(&db_path).unwrap();
2473
2474        // Verify tables exist
2475        let tables: Vec<String> = conn
2476            .prepare("SELECT name FROM sqlite_master WHERE type='table'")
2477            .unwrap()
2478            .query_map([], |row| row.get(0))
2479            .unwrap()
2480            .collect::<Result<Vec<_>, _>>()
2481            .unwrap();
2482
2483        assert!(tables.contains(&"files".to_string()));
2484        assert!(tables.contains(&"statistics".to_string()));
2485        assert!(tables.contains(&"config".to_string()));
2486        assert!(tables.contains(&"file_branches".to_string()));
2487        assert!(tables.contains(&"branches".to_string()));
2488        assert!(tables.contains(&"file_dependencies".to_string()));
2489        assert!(tables.contains(&"file_exports".to_string()));
2490    }
2491
2492    #[test]
2493    fn test_concurrent_file_updates() {
2494        use std::thread;
2495
2496        let temp = TempDir::new().unwrap();
2497        let cache_path = temp.path().to_path_buf();
2498
2499        let cache = CacheManager::new(&cache_path);
2500        cache.init().unwrap();
2501
2502        // Spawn multiple threads updating different files
2503        let handles: Vec<_> = (0..10)
2504            .map(|i| {
2505                let path = cache_path.clone();
2506                thread::spawn(move || {
2507                    let cache = CacheManager::new(&path);
2508                    cache
2509                        .update_file(&format!("file_{}.rs", i), "rust", i * 10)
2510                        .unwrap();
2511                })
2512            })
2513            .collect();
2514
2515        for handle in handles {
2516            handle.join().unwrap();
2517        }
2518
2519        let cache = CacheManager::new(&cache_path);
2520        let files = cache.list_files().unwrap();
2521        assert_eq!(files.len(), 10);
2522    }
2523
2524    // ===== Corruption Detection Tests =====
2525
2526    #[test]
2527    fn test_validate_corrupted_database() {
2528        use std::io::Write;
2529
2530        let temp = TempDir::new().unwrap();
2531        let cache = CacheManager::new(temp.path());
2532
2533        cache.init().unwrap();
2534
2535        // Corrupt the database by overwriting it with invalid data
2536        let db_path = cache.path().join(META_DB);
2537        let mut file = File::create(&db_path).unwrap();
2538        file.write_all(b"CORRUPTED DATA").unwrap();
2539
2540        // Validation should fail due to database corruption
2541        let result = cache.validate();
2542        assert!(result.is_err());
2543        let err_msg = result.unwrap_err().to_string();
2544        eprintln!("Error message: {}", err_msg);
2545        assert!(err_msg.contains("corrupted") || err_msg.contains("not a database"));
2546    }
2547
2548    #[test]
2549    fn test_validate_corrupted_trigrams() {
2550        use std::io::Write;
2551
2552        let temp = TempDir::new().unwrap();
2553        let cache = CacheManager::new(temp.path());
2554
2555        cache.init().unwrap();
2556
2557        // Create trigrams.bin with invalid magic bytes
2558        let trigrams_path = cache.path().join("trigrams.bin");
2559        let mut file = File::create(&trigrams_path).unwrap();
2560        file.write_all(b"BADM").unwrap(); // Wrong magic bytes (should be "RFTG")
2561
2562        // Validation should fail due to invalid magic bytes
2563        let result = cache.validate();
2564        assert!(result.is_err());
2565        let err = result.unwrap_err().to_string();
2566        assert!(err.contains("trigrams.bin") && err.contains("corrupted"));
2567    }
2568
2569    #[test]
2570    fn test_validate_corrupted_content() {
2571        use std::io::Write;
2572
2573        let temp = TempDir::new().unwrap();
2574        let cache = CacheManager::new(temp.path());
2575
2576        cache.init().unwrap();
2577
2578        // Create content.bin with invalid magic bytes
2579        let content_path = cache.path().join("content.bin");
2580        let mut file = File::create(&content_path).unwrap();
2581        file.write_all(b"BADM").unwrap(); // Wrong magic bytes (should be "RFCT")
2582
2583        // Validation should fail due to invalid magic bytes
2584        let result = cache.validate();
2585        assert!(result.is_err());
2586        let err = result.unwrap_err().to_string();
2587        assert!(err.contains("content.bin") && err.contains("corrupted"));
2588    }
2589
2590    #[test]
2591    fn test_validate_missing_schema_table() {
2592        let temp = TempDir::new().unwrap();
2593        let cache = CacheManager::new(temp.path());
2594
2595        cache.init().unwrap();
2596
2597        // Drop a required table to simulate schema corruption
2598        let db_path = cache.path().join(META_DB);
2599        let conn = open_meta_db(&db_path).unwrap();
2600        conn.execute("DROP TABLE files", []).unwrap();
2601
2602        // Validation should fail due to missing required table
2603        let result = cache.validate();
2604        assert!(result.is_err());
2605        let err = result.unwrap_err().to_string();
2606        assert!(err.contains("files") && err.contains("missing"));
2607    }
2608}