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