1use 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
18pub const CACHE_DIR: &str = ".reflex";
20
21pub 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#[derive(Clone)]
29pub struct CacheManager {
30 cache_path: PathBuf,
31}
32
33impl CacheManager {
34 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 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 self.init_meta_db()?;
50
51 self.init_config_toml()?;
53
54 log::info!("Cache initialized successfully");
58 Ok(())
59 }
60
61 fn init_meta_db(&self) -> Result<()> {
63 let db_path = self.cache_path.join(META_DB);
64
65 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 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 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 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 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 conn.execute(
124 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
125 ["last_compaction", "0", &now.to_string()],
126 )?;
127
128 conn.execute(
130 "CREATE TABLE IF NOT EXISTS config (
131 key TEXT PRIMARY KEY,
132 value TEXT NOT NULL
133 )",
134 [],
135 )?;
136
137 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 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 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 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 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 pub fn exists(&self) -> bool {
285 self.cache_path.exists() && self.cache_path.join(META_DB).exists()
286 }
287
288 pub fn validate(&self) -> Result<()> {
297 let start = std::time::Instant::now();
298
299 if !self.cache_path.exists() {
301 anyhow::bail!(
302 "Cache directory does not exist: {}",
303 self.cache_path.display()
304 );
305 }
306
307 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 let conn = Connection::open(&db_path)
315 .context("Failed to open meta.db - database may be corrupted")?;
316
317 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 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 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 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 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 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 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 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 }
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 pub fn path(&self) -> &Path {
478 &self.cache_path
479 }
480
481 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 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 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 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 pub fn checkpoint_wal(&self) -> Result<()> {
620 let db_path = self.cache_path.join(META_DB);
621
622 if !db_path.exists() {
623 return Ok(());
625 }
626
627 let conn =
628 Connection::open(&db_path).context("Failed to open meta.db for WAL checkpoint")?;
629
630 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 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 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 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 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 #[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 Ok(())
724 }
725
726 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 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 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 pub fn batch_update_files_and_branch(
782 &self,
783 files: &[(String, String, usize)], branch_files: &[(String, String)], 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 let tx = conn.transaction()?;
803
804 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 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 let mut inserted = 0;
825 for (path, hash) in branch_files {
826 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 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 tx.commit()?;
847 log::info!("Transaction committed successfully (files + file_branches)");
848
849 let verify_conn =
852 Connection::open(&db_path).context("Failed to open meta.db for verification")?;
853
854 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 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 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 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 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 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 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 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 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 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 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 let total_files: usize = if let Some(ref branch) = current_branch {
1042 log::debug!("stats(): Counting files for branch '{}'", branch);
1043
1044 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 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 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 log::warn!("stats(): No current_branch detected!");
1102 0
1103 };
1104
1105 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 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 let mut files_by_language = std::collections::HashMap::new();
1137 if let Some(ref branch) = current_branch {
1138 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 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 let mut lines_by_language = std::collections::HashMap::new();
1175 if let Some(ref branch) = current_branch {
1176 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 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 fn get_or_create_branch_id(
1227 &self,
1228 conn: &Connection,
1229 branch_name: &str,
1230 commit_sha: Option<&str>,
1231 ) -> Result<i64> {
1232 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 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 let id: i64 = conn.last_insert_rowid();
1259 Ok(id)
1260 }
1261
1262 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 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 let branch_id = self.get_or_create_branch_id(&conn, branch, commit_sha)?;
1283
1284 let now = chrono::Utc::now().timestamp();
1285
1286 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 pub fn batch_record_branch_files(
1301 &self,
1302 files: &[(String, String)], 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 let tx = conn.transaction()?;
1320
1321 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 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 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 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 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 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 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 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 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 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 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 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 const BATCH_SIZE: usize = 900;
1563
1564 let mut results = HashMap::new();
1565
1566 for chunk in paths.chunks(BATCH_SIZE) {
1567 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 pub fn should_compact(&self) -> Result<bool> {
1603 let db_path = self.cache_path.join(META_DB);
1604
1605 if !db_path.exists() {
1606 return Ok(false);
1608 }
1609
1610 let conn =
1611 Connection::open(&db_path).context("Failed to open meta.db for compaction check")?;
1612
1613 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 let now = chrono::Utc::now().timestamp();
1627
1628 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 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 pub fn compact(&self) -> Result<crate::models::CompactionReport> {
1675 let start_time = std::time::Instant::now();
1676 log::info!("Starting cache compaction...");
1677
1678 let size_before = self.calculate_cache_size()?;
1680
1681 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 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 self.delete_files_from_db(&deleted_files)?;
1702 log::info!("Deleted {} files from database", deleted_files.len());
1703
1704 self.vacuum_database()?;
1706 log::info!("Completed VACUUM operation");
1707
1708 let size_after = self.calculate_cache_size()?;
1710 let space_saved = size_before.saturating_sub(size_after);
1711
1712 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 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 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 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 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 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 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 conn.execute("VACUUM", [])?;
1813
1814 log::debug!("VACUUM completed successfully");
1815 Ok(())
1816 }
1817
1818 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#[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#[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 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 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 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 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 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 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 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 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 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 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 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)); assert_eq!(stats.lines_by_language.get("Python"), Some(&130)); }
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 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 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 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 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 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 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 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 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 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 #[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 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 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 let trigrams_path = cache.path().join("trigrams.bin");
2378 let mut file = File::create(&trigrams_path).unwrap();
2379 file.write_all(b"BADM").unwrap(); 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 let content_path = cache.path().join("content.bin");
2399 let mut file = File::create(&content_path).unwrap();
2400 file.write_all(b"BADM").unwrap(); 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 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 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}