Skip to main content

reflex/
cache.rs

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