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
27pub fn open_meta_db(db_path: impl AsRef<Path>) -> Result<Connection> {
50 let db_path = db_path.as_ref();
51 let conn = Connection::open(db_path)
52 .with_context(|| format!("Failed to open {}", db_path.display()))?;
53
54 conn.busy_timeout(std::time::Duration::from_millis(SQLITE_BUSY_TIMEOUT_MS))
56 .context("Failed to set busy_timeout")?;
57
58 let journal_mode = std::env::var("REFLEX_SQLITE_JOURNAL")
59 .unwrap_or_else(|_| "WAL".to_string())
60 .to_uppercase();
61
62 if let Err(e) = conn.query_row(
64 &format!("PRAGMA journal_mode={}", journal_mode),
65 [],
66 |row| row.get::<_, String>(0),
67 ) {
68 log::warn!(
71 "Could not set journal_mode={} on {}: {} (continuing with the default journal)",
72 journal_mode,
73 db_path.display(),
74 e
75 );
76 }
77
78 conn.execute_batch("PRAGMA foreign_keys=ON;")
79 .context("Failed to enable foreign keys")?;
80
81 Ok(conn)
82}
83
84const SQLITE_BUSY_TIMEOUT_MS: u64 = 5_000;
90
91#[derive(Clone)]
93pub struct CacheManager {
94 cache_path: PathBuf,
95}
96
97impl CacheManager {
98 pub fn new(root: impl AsRef<Path>) -> Self {
100 let cache_path = root.as_ref().join(CACHE_DIR);
101 Self { cache_path }
102 }
103
104 pub fn init(&self) -> Result<()> {
106 log::info!("Initializing cache at {:?}", self.cache_path);
107
108 if !self.cache_path.exists() {
109 std::fs::create_dir_all(&self.cache_path)?;
110 }
111
112 self.init_meta_db()?;
114
115 self.init_config_toml()?;
117
118 log::info!("Cache initialized successfully");
122 Ok(())
123 }
124
125 fn init_meta_db(&self) -> Result<()> {
127 let db_path = self.cache_path.join(META_DB);
128
129 let conn = open_meta_db(&db_path).context("Failed to create meta.db")?;
136 conn.execute_batch("BEGIN IMMEDIATE")
137 .context("Failed to begin meta.db schema transaction")?;
138
139 conn.execute(
141 "CREATE TABLE IF NOT EXISTS files (
142 id INTEGER PRIMARY KEY AUTOINCREMENT,
143 path TEXT NOT NULL UNIQUE,
144 last_indexed INTEGER NOT NULL,
145 language TEXT NOT NULL,
146 token_count INTEGER DEFAULT 0,
147 line_count INTEGER DEFAULT 0
148 )",
149 [],
150 )?;
151
152 conn.execute(
153 "CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)",
154 [],
155 )?;
156
157 conn.execute(
159 "CREATE TABLE IF NOT EXISTS statistics (
160 key TEXT PRIMARY KEY,
161 value TEXT NOT NULL,
162 updated_at INTEGER NOT NULL
163 )",
164 [],
165 )?;
166
167 let now = chrono::Utc::now().timestamp();
169 conn.execute(
170 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
171 ["total_files", "0", &now.to_string()],
172 )?;
173 conn.execute(
177 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
178 [
179 "writer_version",
180 env!("CARGO_PKG_VERSION"),
181 &now.to_string(),
182 ],
183 )?;
184 if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
185 conn.execute(
186 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
187 ["writer_git_sha", sha, &now.to_string()],
188 )?;
189 }
190
191 let schema_hash = env!("CACHE_SCHEMA_HASH");
194 conn.execute(
195 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
196 ["schema_hash", schema_hash, &now.to_string()],
197 )?;
198
199 conn.execute(
201 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
202 ["last_compaction", "0", &now.to_string()],
203 )?;
204
205 conn.execute(
207 "CREATE TABLE IF NOT EXISTS config (
208 key TEXT PRIMARY KEY,
209 value TEXT NOT NULL
210 )",
211 [],
212 )?;
213
214 conn.execute(
216 "CREATE TABLE IF NOT EXISTS file_branches (
217 file_id INTEGER NOT NULL,
218 branch_id INTEGER NOT NULL,
219 hash TEXT NOT NULL,
220 last_indexed INTEGER NOT NULL,
221 PRIMARY KEY (file_id, branch_id),
222 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
223 FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
224 )",
225 [],
226 )?;
227
228 conn.execute(
229 "CREATE INDEX IF NOT EXISTS idx_branch_lookup ON file_branches(branch_id, file_id)",
230 [],
231 )?;
232
233 conn.execute(
234 "CREATE INDEX IF NOT EXISTS idx_hash_lookup ON file_branches(hash)",
235 [],
236 )?;
237
238 conn.execute(
240 "CREATE TABLE IF NOT EXISTS branches (
241 id INTEGER PRIMARY KEY AUTOINCREMENT,
242 name TEXT NOT NULL UNIQUE,
243 commit_sha TEXT NOT NULL,
244 last_indexed INTEGER NOT NULL,
245 file_count INTEGER DEFAULT 0,
246 is_dirty INTEGER DEFAULT 0
247 )",
248 [],
249 )?;
250
251 conn.execute(
253 "CREATE TABLE IF NOT EXISTS file_dependencies (
254 id INTEGER PRIMARY KEY AUTOINCREMENT,
255 file_id INTEGER NOT NULL,
256 imported_path TEXT NOT NULL,
257 resolved_file_id INTEGER,
258 import_type TEXT NOT NULL,
259 line_number INTEGER NOT NULL,
260 imported_symbols TEXT,
261 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
262 FOREIGN KEY (resolved_file_id) REFERENCES files(id) ON DELETE SET NULL
263 )",
264 [],
265 )?;
266
267 conn.execute(
268 "CREATE INDEX IF NOT EXISTS idx_deps_file ON file_dependencies(file_id)",
269 [],
270 )?;
271
272 conn.execute(
273 "CREATE INDEX IF NOT EXISTS idx_deps_resolved ON file_dependencies(resolved_file_id)",
274 [],
275 )?;
276
277 conn.execute(
278 "CREATE INDEX IF NOT EXISTS idx_deps_type ON file_dependencies(import_type)",
279 [],
280 )?;
281
282 conn.execute(
284 "CREATE TABLE IF NOT EXISTS file_exports (
285 id INTEGER PRIMARY KEY AUTOINCREMENT,
286 file_id INTEGER NOT NULL,
287 exported_symbol TEXT,
288 source_path TEXT NOT NULL,
289 resolved_source_id INTEGER,
290 line_number INTEGER NOT NULL,
291 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
292 FOREIGN KEY (resolved_source_id) REFERENCES files(id) ON DELETE SET NULL
293 )",
294 [],
295 )?;
296
297 conn.execute(
298 "CREATE INDEX IF NOT EXISTS idx_exports_file ON file_exports(file_id)",
299 [],
300 )?;
301
302 conn.execute(
303 "CREATE INDEX IF NOT EXISTS idx_exports_resolved ON file_exports(resolved_source_id)",
304 [],
305 )?;
306
307 conn.execute(
308 "CREATE INDEX IF NOT EXISTS idx_exports_symbol ON file_exports(exported_symbol)",
309 [],
310 )?;
311
312 conn.execute_batch("COMMIT")
313 .context("Failed to commit meta.db schema transaction")?;
314
315 log::debug!("Created meta.db with schema");
316 Ok(())
317 }
318
319 fn init_config_toml(&self) -> Result<()> {
321 let config_path = self.cache_path.join(CONFIG_TOML);
322
323 if config_path.exists() {
324 return Ok(());
325 }
326
327 let default_config = r#"[index]
328languages = [] # Empty = all supported languages
329text_tier = true # Also index docs and config: md, yaml, toml, json, proto, html, sh, sql
330max_file_size = 10485760 # 10 MB
331follow_symlinks = false
332
333[index.include]
334patterns = []
335
336[index.exclude]
337patterns = []
338
339[search]
340default_limit = 100
341fuzzy_threshold = 0.8
342
343[performance]
344parallel_threads = 0 # 0 = auto (80% of available cores), or set a specific number
345compression_level = 3 # zstd level
346
347[semantic]
348# Semantic query generation using LLMs
349# Translate natural language questions into rfx query commands
350provider = "openrouter" # Options: openai, anthropic, openrouter
351# model = "openai/gpt-4o-mini" # Optional: override provider default model
352# auto_execute = false # Optional: auto-execute queries without confirmation
353"#;
354
355 std::fs::write(&config_path, default_config)?;
356
357 log::debug!("Created default config.toml");
358 Ok(())
359 }
360
361 pub fn exists(&self) -> bool {
363 self.cache_path.exists() && self.cache_path.join(META_DB).exists()
364 }
365
366 pub fn validate(&self) -> Result<()> {
375 let start = std::time::Instant::now();
376
377 if !self.cache_path.exists() {
379 anyhow::bail!(
380 "Cache directory does not exist: {}",
381 self.cache_path.display()
382 );
383 }
384
385 let db_path = self.cache_path.join(META_DB);
387 if !db_path.exists() {
388 anyhow::bail!("Database file missing: {}", db_path.display());
389 }
390
391 let conn =
393 open_meta_db(&db_path).context("Failed to open meta.db - database may be corrupted")?;
394
395 let tables: Result<Vec<String>, _> = conn
397 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
398 .and_then(|mut stmt| {
399 stmt.query_map([], |row| row.get(0))
400 .map(|rows| rows.collect())
401 })
402 .and_then(|result| result);
403
404 match tables {
405 Ok(table_list) => {
406 let required_tables = vec![
408 "files",
409 "statistics",
410 "config",
411 "file_branches",
412 "branches",
413 "file_dependencies",
414 "file_exports",
415 ];
416 for table in &required_tables {
417 if !table_list.iter().any(|t| t == table) {
418 anyhow::bail!("Required table '{}' missing from database schema", table);
419 }
420 }
421 }
422 Err(e) => {
423 anyhow::bail!("Failed to read database schema: {}", e);
424 }
425 }
426
427 let integrity_result: String =
430 conn.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
431
432 if integrity_result != "ok" {
433 log::warn!("Database integrity check failed: {}", integrity_result);
434 anyhow::bail!(
435 "Database integrity check failed: {}. Cache may be corrupted. \
436 Run 'rfx index' to rebuild cache.",
437 integrity_result
438 );
439 }
440
441 let trigrams_path = self.cache_path.join("trigrams.bin");
443 if trigrams_path.exists() {
444 use std::io::Read;
445
446 match File::open(&trigrams_path) {
447 Ok(mut file) => {
448 let mut header = [0u8; 4];
449 match file.read_exact(&mut header) {
450 Ok(_) => {
451 if &header != b"RFTG" {
453 log::warn!(
454 "trigrams.bin has invalid magic bytes - may be corrupted"
455 );
456 anyhow::bail!(
457 "trigrams.bin appears to be corrupted (invalid magic bytes)"
458 );
459 }
460 }
461 Err(_) => {
462 anyhow::bail!("trigrams.bin is too small - appears to be corrupted");
463 }
464 }
465 }
466 Err(e) => {
467 anyhow::bail!("Failed to open trigrams.bin: {}", e);
468 }
469 }
470 }
471
472 let content_path = self.cache_path.join("content.bin");
474 if content_path.exists() {
475 use std::io::Read;
476
477 match File::open(&content_path) {
478 Ok(mut file) => {
479 let mut header = [0u8; 4];
480 match file.read_exact(&mut header) {
481 Ok(_) => {
482 if &header != b"RFCT" {
484 log::warn!(
485 "content.bin has invalid magic bytes - may be corrupted"
486 );
487 anyhow::bail!(
488 "content.bin appears to be corrupted (invalid magic bytes)"
489 );
490 }
491 }
492 Err(_) => {
493 anyhow::bail!("content.bin is too small - appears to be corrupted");
494 }
495 }
496 }
497 Err(e) => {
498 anyhow::bail!("Failed to open content.bin: {}", e);
499 }
500 }
501 }
502
503 log::debug!("Cache validation passed (took {:?})", start.elapsed());
519 Ok(())
520 }
521
522 pub fn path(&self) -> &Path {
524 &self.cache_path
525 }
526
527 pub fn workspace_root(&self) -> PathBuf {
529 self.cache_path
530 .parent()
531 .expect(".reflex directory should have a parent")
532 .to_path_buf()
533 }
534
535 pub fn load_index_config(&self) -> Result<crate::models::IndexConfig> {
541 use crate::models::{IndexConfig, Language};
542
543 let config_path = self.cache_path.join(CONFIG_TOML);
544 if !config_path.exists() {
545 return Ok(IndexConfig::default());
546 }
547
548 let raw = std::fs::read_to_string(&config_path)
549 .with_context(|| format!("Failed to read {}", config_path.display()))?;
550
551 let toml_val: toml::Value = toml::from_str(&raw)
552 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
553
554 let mut cfg = IndexConfig::default();
555
556 if let Some(index_tbl) = toml_val.get("index") {
557 if let Some(langs) = index_tbl.get("languages").and_then(|v| v.as_array()) {
558 let parsed: Vec<Language> = langs
559 .iter()
560 .filter_map(|v| v.as_str())
561 .filter_map(|s| {
562 Language::from_name(s).or_else(|| {
563 log::warn!(
564 "Unknown language '{}' in config.toml [index] section — ignoring",
565 s
566 );
567 None
568 })
569 })
570 .collect();
571 if !parsed.is_empty() {
572 cfg.languages = parsed;
573 }
574 }
575 if let Some(text_tier) = index_tbl.get("text_tier").and_then(|v| v.as_bool()) {
576 cfg.text_tier = text_tier;
577 }
578
579 if let Some(max_size) = index_tbl.get("max_file_size").and_then(|v| v.as_integer()) {
580 cfg.max_file_size = max_size as usize;
581 }
582 if let Some(follow) = index_tbl.get("follow_symlinks").and_then(|v| v.as_bool()) {
583 cfg.follow_symlinks = follow;
584 }
585 if let Some(include) = index_tbl
586 .get("include")
587 .and_then(|v| v.get("patterns"))
588 .and_then(|v| v.as_array())
589 {
590 cfg.include_patterns = include
591 .iter()
592 .filter_map(|v| v.as_str().map(String::from))
593 .collect();
594 }
595 if let Some(exclude) = index_tbl
596 .get("exclude")
597 .and_then(|v| v.get("patterns"))
598 .and_then(|v| v.as_array())
599 {
600 cfg.exclude_patterns = exclude
601 .iter()
602 .filter_map(|v| v.as_str().map(String::from))
603 .collect();
604 }
605 }
606
607 if let Some(perf) = toml_val.get("performance")
608 && let Some(threads) = perf.get("parallel_threads").and_then(|v| v.as_integer())
609 {
610 cfg.parallel_threads = threads as usize;
611 }
612
613 log::debug!("Loaded IndexConfig from config.toml: {:?}", cfg);
614 Ok(cfg)
615 }
616
617 pub fn clear(&self) -> Result<()> {
619 log::info!("Clearing cache at {:?}", self.cache_path);
620
621 if !self.cache_path.exists() {
622 return Ok(());
623 }
624
625 let lock =
631 crate::atomic_write::IndexLock::try_acquire(&self.cache_path)?.ok_or_else(|| {
632 crate::errors::ReflexError::IndexLocked(
633 crate::atomic_write::IndexLock::lock_path(&self.cache_path)
634 .display()
635 .to_string(),
636 )
637 })?;
638
639 for entry in std::fs::read_dir(&self.cache_path)? {
640 let entry = entry?;
641 let path = entry.path();
642 if path.file_name().and_then(|n| n.to_str())
643 == Some(crate::atomic_write::INDEX_LOCK_FILE)
644 {
645 continue;
646 }
647 if path.is_dir() {
648 std::fs::remove_dir_all(&path)?;
649 } else {
650 std::fs::remove_file(&path)?;
651 }
652 }
653
654 let lock_path = lock.path().to_path_buf();
655 drop(lock);
656 let _ = std::fs::remove_file(&lock_path);
657 let _ = std::fs::remove_dir(&self.cache_path);
658
659 Ok(())
660 }
661
662 pub fn checkpoint_wal(&self) -> Result<()> {
670 let db_path = self.cache_path.join(META_DB);
671
672 if !db_path.exists() {
673 return Ok(());
675 }
676
677 let conn = open_meta_db(&db_path).context("Failed to open meta.db for WAL checkpoint")?;
678
679 conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
683 let busy: i64 = row.get(0)?;
684 let log_pages: i64 = row.get(1)?;
685 let checkpointed: i64 = row.get(2)?;
686 log::debug!(
687 "WAL checkpoint completed: busy={}, log_pages={}, checkpointed_pages={}",
688 busy,
689 log_pages,
690 checkpointed
691 );
692 Ok(())
693 })
694 .context("Failed to execute WAL checkpoint")?;
695
696 log::debug!("Executed WAL checkpoint (TRUNCATE) on meta.db");
697 Ok(())
698 }
699
700 pub fn load_all_hashes(&self) -> Result<HashMap<String, String>> {
705 let db_path = self.cache_path.join(META_DB);
706
707 if !db_path.exists() {
708 return Ok(HashMap::new());
709 }
710
711 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
712
713 let mut stmt = conn.prepare(
717 "SELECT f.path, fb.hash
718 FROM file_branches fb
719 JOIN files f ON fb.file_id = f.id",
720 )?;
721 let hashes: HashMap<String, String> = stmt
722 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
723 .collect::<Result<HashMap<_, _>, _>>()?;
724
725 log::debug!(
726 "Loaded {} file hashes across all branches from SQLite",
727 hashes.len()
728 );
729 Ok(hashes)
730 }
731
732 pub fn load_hashes_for_branch(&self, branch: &str) -> Result<HashMap<String, String>> {
737 let db_path = self.cache_path.join(META_DB);
738
739 if !db_path.exists() {
740 return Ok(HashMap::new());
741 }
742
743 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
744
745 let mut stmt = conn.prepare(
747 "SELECT f.path, fb.hash
748 FROM file_branches fb
749 JOIN files f ON fb.file_id = f.id
750 JOIN branches b ON fb.branch_id = b.id
751 WHERE b.name = ?",
752 )?;
753 let hashes: HashMap<String, String> = stmt
754 .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
755 .collect::<Result<HashMap<_, _>, _>>()?;
756
757 log::debug!(
758 "Loaded {} file hashes for branch '{}' from SQLite",
759 hashes.len(),
760 branch
761 );
762 Ok(hashes)
763 }
764
765 #[deprecated(note = "Hashes are now stored in file_branches table via record_branch_file()")]
770 pub fn save_hashes(&self, _hashes: &HashMap<String, String>) -> Result<()> {
771 Ok(())
773 }
774
775 pub fn update_file(&self, path: &str, language: &str, line_count: usize) -> Result<()> {
780 let db_path = self.cache_path.join(META_DB);
781 let conn = open_meta_db(&db_path).context("Failed to open meta.db for file update")?;
782
783 let now = chrono::Utc::now().timestamp();
784
785 conn.execute(
786 "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
787 VALUES (?, ?, ?, ?)",
788 [path, &now.to_string(), language, &line_count.to_string()],
789 )?;
790
791 Ok(())
792 }
793
794 pub fn batch_update_files(&self, files: &[(String, String, usize)]) -> Result<()> {
799 let db_path = self.cache_path.join(META_DB);
800 let mut conn = open_meta_db(&db_path).context("Failed to open meta.db for batch update")?;
801
802 let now = chrono::Utc::now().timestamp();
803 let now_str = now.to_string();
804
805 let tx = conn.transaction()?;
807
808 for (path, language, line_count) in files {
809 tx.execute(
810 "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
811 VALUES (?, ?, ?, ?)",
812 [
813 path.as_str(),
814 &now_str,
815 language.as_str(),
816 &line_count.to_string(),
817 ],
818 )?;
819 }
820
821 tx.commit()?;
822 Ok(())
823 }
824
825 pub fn batch_update_files_and_branch(
830 &self,
831 files: &[(String, String, usize)], branch_files: &[(String, String)], branch: &str,
834 commit_sha: Option<&str>,
835 ) -> Result<()> {
836 log::info!(
837 "batch_update_files_and_branch: Processing {} files for branch '{}'",
838 files.len(),
839 branch
840 );
841
842 let db_path = self.cache_path.join(META_DB);
843 let mut conn = open_meta_db(&db_path)
844 .context("Failed to open meta.db for batch update and branch recording")?;
845
846 let now = chrono::Utc::now().timestamp();
847 let now_str = now.to_string();
848
849 let tx = conn.transaction()?;
851
852 for (path, language, line_count) in files {
854 tx.execute(
855 "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
856 VALUES (?, ?, ?, ?)",
857 [
858 path.as_str(),
859 &now_str,
860 language.as_str(),
861 &line_count.to_string(),
862 ],
863 )?;
864 }
865 log::info!("Inserted {} files into files table", files.len());
866
867 let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
869 log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
870
871 let mut inserted = 0;
873 for (path, hash) in branch_files {
874 let file_id: i64 = tx
876 .query_row(
877 "SELECT id FROM files WHERE path = ?",
878 [path.as_str()],
879 |row| row.get(0),
880 )
881 .context(format!("File not found in index after insert: {}", path))?;
882
883 tx.execute(
885 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
886 VALUES (?, ?, ?, ?)",
887 rusqlite::params![file_id, branch_id, hash.as_str(), now],
888 )?;
889 inserted += 1;
890 }
891 log::info!("Inserted {} file_branches entries", inserted);
892
893 let pruned = {
905 tx.execute_batch(
906 "CREATE TEMP TABLE IF NOT EXISTS current_paths (path TEXT PRIMARY KEY);
907 DELETE FROM current_paths;",
908 )?;
909 {
910 let mut stmt =
911 tx.prepare("INSERT OR IGNORE INTO current_paths (path) VALUES (?)")?;
912 for (path, _) in branch_files {
913 stmt.execute([path.as_str()])?;
914 }
915 }
916
917 let unlinked = tx.execute(
919 "DELETE FROM file_branches
920 WHERE branch_id = ?
921 AND file_id NOT IN (SELECT id FROM files WHERE path IN (SELECT path FROM current_paths))",
922 rusqlite::params![branch_id],
923 )?;
924
925 let orphaned = tx.execute(
928 "DELETE FROM files WHERE id NOT IN (SELECT file_id FROM file_branches)",
929 [],
930 )?;
931
932 tx.execute_batch("DROP TABLE IF EXISTS current_paths;")?;
933 (unlinked, orphaned)
934 };
935 if pruned.0 > 0 || pruned.1 > 0 {
936 log::info!(
937 "Pruned {} stale file_branches rows and {} orphaned files rows",
938 pruned.0,
939 pruned.1
940 );
941 }
942
943 tx.commit()?;
945 log::info!("Transaction committed successfully (files + file_branches)");
946
947 let verify_conn =
950 open_meta_db(&db_path).context("Failed to open meta.db for verification")?;
951
952 let actual_file_count: i64 = verify_conn.query_row(
954 "SELECT COUNT(*) FROM files WHERE path IN (SELECT path FROM files ORDER BY id DESC LIMIT ?)",
955 [files.len()],
956 |row| row.get(0)
957 ).unwrap_or(0);
958
959 let actual_fb_count: i64 = verify_conn
961 .query_row(
962 "SELECT COUNT(*) FROM file_branches fb
963 JOIN branches b ON fb.branch_id = b.id
964 WHERE b.name = ?",
965 [branch],
966 |row| row.get(0),
967 )
968 .unwrap_or(0);
969
970 log::info!(
971 "Post-commit verification: {} files in files table (expected {}), {} file_branches entries for '{}' (expected {})",
972 actual_file_count,
973 files.len(),
974 actual_fb_count,
975 branch,
976 inserted
977 );
978
979 if actual_file_count < files.len() as i64 {
981 log::warn!(
982 "MISMATCH: Expected {} files in database, but only found {}! Data may not have persisted.",
983 files.len(),
984 actual_file_count
985 );
986 }
987 if actual_fb_count < inserted as i64 {
988 log::warn!(
989 "MISMATCH: Expected {} file_branches entries for branch '{}', but only found {}! Data may not have persisted.",
990 inserted,
991 branch,
992 actual_fb_count
993 );
994 }
995
996 Ok(())
997 }
998
999 pub fn update_stats(&self, branch: &str) -> Result<()> {
1003 let db_path = self.cache_path.join(META_DB);
1004 let conn = open_meta_db(&db_path).context("Failed to open meta.db for stats update")?;
1005
1006 let total_files: usize = conn
1008 .query_row(
1009 "SELECT COUNT(DISTINCT fb.file_id)
1010 FROM file_branches fb
1011 JOIN branches b ON fb.branch_id = b.id
1012 WHERE b.name = ?",
1013 [branch],
1014 |row| row.get(0),
1015 )
1016 .unwrap_or(0);
1017
1018 let now = chrono::Utc::now().timestamp();
1019
1020 conn.execute(
1021 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1022 ["total_files", &total_files.to_string(), &now.to_string()],
1023 )?;
1024
1025 log::debug!(
1026 "Updated statistics for branch '{}': {} files",
1027 branch,
1028 total_files
1029 );
1030 Ok(())
1031 }
1032
1033 pub fn check_schema_hash(&self) -> Result<bool> {
1036 let db_path = self.cache_path.join(META_DB);
1037 if !db_path.exists() {
1038 return Ok(false);
1039 }
1040 let conn = open_meta_db(&db_path)?;
1041 let current = env!("CACHE_SCHEMA_HASH");
1042 let stored: Option<String> = conn
1043 .query_row(
1044 "SELECT value FROM statistics WHERE key = 'schema_hash'",
1045 [],
1046 |row| row.get(0),
1047 )
1048 .optional()?;
1049 Ok(stored.as_deref() == Some(current))
1050 }
1051
1052 pub fn cache_owner(&self) -> Option<(String, Option<String>)> {
1056 let db_path = self.cache_path.join(META_DB);
1057 if !db_path.exists() {
1058 return None;
1059 }
1060 let conn = open_meta_db(&db_path).ok()?;
1061 let get = |key: &str| -> Option<String> {
1062 conn.query_row("SELECT value FROM statistics WHERE key = ?", [key], |row| {
1063 row.get(0)
1064 })
1065 .optional()
1066 .ok()
1067 .flatten()
1068 };
1069 get("writer_version").map(|v| (v, get("writer_git_sha")))
1070 }
1071
1072 pub fn assert_writable(&self, force: bool) -> Result<()> {
1094 if force || std::env::var("REFLEX_ALLOW_SCHEMA_REBUILD").is_ok() {
1095 return Ok(());
1096 }
1097
1098 if !self.cache_path.join(META_DB).exists() {
1099 return Ok(());
1100 }
1101
1102 let Some((owner_version, owner_sha)) = self.cache_owner() else {
1103 return Ok(());
1105 };
1106
1107 if owner_version == env!("CARGO_PKG_VERSION") {
1108 return Ok(());
1109 }
1110
1111 Err(crate::errors::ReflexError::CacheVersionMismatch {
1112 owner_version,
1113 owner_sha: owner_sha
1114 .map(|s| format!(" (sha {})", &s[..s.len().min(7)]))
1115 .unwrap_or_default(),
1116 this_version: env!("CARGO_PKG_VERSION").to_string(),
1117 }
1118 .into())
1119 }
1120
1121 pub fn update_schema_hash(&self) -> Result<()> {
1126 let db_path = self.cache_path.join(META_DB);
1127 let conn =
1128 open_meta_db(&db_path).context("Failed to open meta.db for schema hash update")?;
1129
1130 let schema_hash = env!("CACHE_SCHEMA_HASH");
1131 let now = chrono::Utc::now().timestamp();
1132
1133 conn.execute(
1134 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1135 ["schema_hash", schema_hash, &now.to_string()],
1136 )?;
1137 conn.execute(
1139 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1140 [
1141 "writer_version",
1142 env!("CARGO_PKG_VERSION"),
1143 &now.to_string(),
1144 ],
1145 )?;
1146 if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
1147 conn.execute(
1148 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1149 ["writer_git_sha", sha, &now.to_string()],
1150 )?;
1151 }
1152
1153 log::debug!("Updated schema hash to: {}", schema_hash);
1154 Ok(())
1155 }
1156
1157 pub fn list_files(&self) -> Result<Vec<IndexedFile>> {
1159 let db_path = self.cache_path.join(META_DB);
1160
1161 if !db_path.exists() {
1162 return Ok(Vec::new());
1163 }
1164
1165 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1166
1167 let mut stmt =
1168 conn.prepare("SELECT path, language, last_indexed FROM files ORDER BY path")?;
1169
1170 let files = stmt
1171 .query_map([], |row| {
1172 let path: String = row.get(0)?;
1173 let language: String = row.get(1)?;
1174 let last_indexed: i64 = row.get(2)?;
1175
1176 Ok(IndexedFile {
1177 path,
1178 language,
1179 last_indexed: chrono::DateTime::from_timestamp(last_indexed, 0)
1180 .unwrap_or_else(chrono::Utc::now)
1181 .to_rfc3339(),
1182 })
1183 })?
1184 .collect::<Result<Vec<_>, _>>()?;
1185
1186 Ok(files)
1187 }
1188
1189 pub fn stats(&self) -> Result<crate::models::IndexStats> {
1194 let db_path = self.cache_path.join(META_DB);
1195
1196 if !db_path.exists() {
1197 return Ok(crate::models::IndexStats {
1199 total_files: 0,
1200 index_size_bytes: 0,
1201 last_updated: chrono::Utc::now().to_rfc3339(),
1202 files_by_language: std::collections::HashMap::new(),
1203 lines_by_language: std::collections::HashMap::new(),
1204 ..Default::default()
1205 });
1206 }
1207
1208 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1209
1210 let workspace_root = self.workspace_root();
1212 let current_branch = if crate::git::is_git_repo(&workspace_root) {
1213 crate::git::get_git_state(&workspace_root)
1214 .ok()
1215 .map(|state| state.branch)
1216 } else {
1217 Some("_default".to_string())
1218 };
1219
1220 log::debug!("stats(): current_branch = {:?}", current_branch);
1221
1222 let total_files: usize = if let Some(ref branch) = current_branch {
1224 log::debug!("stats(): Counting files for branch '{}'", branch);
1225
1226 let branches: Vec<(i64, String, i64)> = conn
1228 .prepare("SELECT id, name, file_count FROM branches")
1229 .and_then(|mut stmt| {
1230 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1231 .map(|rows| rows.collect())
1232 })
1233 .and_then(|result| result)
1234 .unwrap_or_default();
1235
1236 for (id, name, count) in &branches {
1237 log::debug!(
1238 "stats(): Branch ID={}, Name='{}', FileCount={}",
1239 id,
1240 name,
1241 count
1242 );
1243 }
1244
1245 let fb_counts: Vec<(String, i64)> = conn
1247 .prepare(
1248 "SELECT b.name, COUNT(*) FROM file_branches fb
1249 JOIN branches b ON fb.branch_id = b.id
1250 GROUP BY b.name",
1251 )
1252 .and_then(|mut stmt| {
1253 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1254 .map(|rows| rows.collect())
1255 })
1256 .and_then(|result| result)
1257 .unwrap_or_default();
1258
1259 for (name, count) in &fb_counts {
1260 log::debug!(
1261 "stats(): file_branches count for branch '{}': {}",
1262 name,
1263 count
1264 );
1265 }
1266
1267 let count: usize = conn
1269 .query_row(
1270 "SELECT COUNT(DISTINCT fb.file_id)
1271 FROM file_branches fb
1272 JOIN branches b ON fb.branch_id = b.id
1273 WHERE b.name = ?",
1274 [branch],
1275 |row| row.get(0),
1276 )
1277 .unwrap_or(0);
1278
1279 log::debug!("stats(): Query returned total_files = {}", count);
1280 count
1281 } else {
1282 log::warn!("stats(): No current_branch detected!");
1284 0
1285 };
1286
1287 let last_updated: String = conn
1289 .query_row(
1290 "SELECT updated_at FROM statistics WHERE key = 'total_files'",
1291 [],
1292 |row| {
1293 let timestamp: i64 = row.get(0)?;
1294 Ok(chrono::DateTime::from_timestamp(timestamp, 0)
1295 .unwrap_or_else(chrono::Utc::now)
1296 .to_rfc3339())
1297 },
1298 )
1299 .unwrap_or_else(|_| chrono::Utc::now().to_rfc3339());
1300
1301 let mut index_size_bytes: u64 = 0;
1303
1304 for file_name in [
1305 META_DB,
1306 TOKENS_BIN,
1307 CONFIG_TOML,
1308 "content.bin",
1309 "trigrams.bin",
1310 ] {
1311 let file_path = self.cache_path.join(file_name);
1312 if let Ok(metadata) = std::fs::metadata(&file_path) {
1313 index_size_bytes += metadata.len();
1314 }
1315 }
1316
1317 let mut files_by_language = std::collections::HashMap::new();
1319 if let Some(ref branch) = current_branch {
1320 let mut stmt = conn.prepare(
1322 "SELECT f.language, COUNT(DISTINCT f.id)
1323 FROM files f
1324 JOIN file_branches fb ON f.id = fb.file_id
1325 JOIN branches b ON fb.branch_id = b.id
1326 WHERE b.name = ?
1327 GROUP BY f.language",
1328 )?;
1329 let lang_counts = stmt.query_map([branch], |row| {
1330 let language: String = row.get(0)?;
1331 let count: i64 = row.get(1)?;
1332 Ok((language, count as usize))
1333 })?;
1334
1335 for result in lang_counts {
1336 let (language, count) = result?;
1337 files_by_language.insert(language, count);
1338 }
1339 } else {
1340 let mut stmt =
1342 conn.prepare("SELECT language, COUNT(*) FROM files GROUP BY language")?;
1343 let lang_counts = stmt.query_map([], |row| {
1344 let language: String = row.get(0)?;
1345 let count: i64 = row.get(1)?;
1346 Ok((language, count as usize))
1347 })?;
1348
1349 for result in lang_counts {
1350 let (language, count) = result?;
1351 files_by_language.insert(language, count);
1352 }
1353 }
1354
1355 let mut lines_by_language = std::collections::HashMap::new();
1357 if let Some(ref branch) = current_branch {
1358 let mut stmt = conn.prepare(
1360 "SELECT f.language, SUM(f.line_count)
1361 FROM files f
1362 JOIN file_branches fb ON f.id = fb.file_id
1363 JOIN branches b ON fb.branch_id = b.id
1364 WHERE b.name = ?
1365 GROUP BY f.language",
1366 )?;
1367 let line_counts = stmt.query_map([branch], |row| {
1368 let language: String = row.get(0)?;
1369 let count: i64 = row.get(1)?;
1370 Ok((language, count as usize))
1371 })?;
1372
1373 for result in line_counts {
1374 let (language, count) = result?;
1375 lines_by_language.insert(language, count);
1376 }
1377 } else {
1378 let mut stmt =
1380 conn.prepare("SELECT language, SUM(line_count) FROM files GROUP BY language")?;
1381 let line_counts = stmt.query_map([], |row| {
1382 let language: String = row.get(0)?;
1383 let count: i64 = row.get(1)?;
1384 Ok((language, count as usize))
1385 })?;
1386
1387 for result in line_counts {
1388 let (language, count) = result?;
1389 lines_by_language.insert(language, count);
1390 }
1391 }
1392
1393 Ok(crate::models::IndexStats {
1394 total_files,
1395 index_size_bytes,
1396 last_updated,
1397 files_by_language,
1398 lines_by_language,
1399 ..Default::default()
1400 })
1401 }
1402
1403 fn get_or_create_branch_id(
1409 &self,
1410 conn: &Connection,
1411 branch_name: &str,
1412 commit_sha: Option<&str>,
1413 ) -> Result<i64> {
1414 let existing_id: Option<i64> = conn
1416 .query_row(
1417 "SELECT id FROM branches WHERE name = ?",
1418 [branch_name],
1419 |row| row.get(0),
1420 )
1421 .optional()?;
1422
1423 if let Some(id) = existing_id {
1424 return Ok(id);
1425 }
1426
1427 let now = chrono::Utc::now().timestamp();
1429 conn.execute(
1430 "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
1431 VALUES (?, ?, ?, 0, 0)",
1432 [
1433 branch_name,
1434 commit_sha.unwrap_or("unknown"),
1435 &now.to_string(),
1436 ],
1437 )?;
1438
1439 let id: i64 = conn.last_insert_rowid();
1441 Ok(id)
1442 }
1443
1444 pub fn record_branch_file(
1446 &self,
1447 path: &str,
1448 branch: &str,
1449 hash: &str,
1450 commit_sha: Option<&str>,
1451 ) -> Result<()> {
1452 let db_path = self.cache_path.join(META_DB);
1453 let conn =
1454 open_meta_db(&db_path).context("Failed to open meta.db for branch file recording")?;
1455
1456 let file_id: i64 = conn
1458 .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
1459 row.get(0)
1460 })
1461 .context(format!("File not found in index: {}", path))?;
1462
1463 let branch_id = self.get_or_create_branch_id(&conn, branch, commit_sha)?;
1465
1466 let now = chrono::Utc::now().timestamp();
1467
1468 conn.execute(
1470 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1471 VALUES (?, ?, ?, ?)",
1472 rusqlite::params![file_id, branch_id, hash, now],
1473 )?;
1474
1475 Ok(())
1476 }
1477
1478 pub fn batch_record_branch_files(
1483 &self,
1484 files: &[(String, String)], branch: &str,
1486 commit_sha: Option<&str>,
1487 ) -> Result<()> {
1488 log::info!(
1489 "batch_record_branch_files: Processing {} files for branch '{}'",
1490 files.len(),
1491 branch
1492 );
1493
1494 let db_path = self.cache_path.join(META_DB);
1495 let mut conn =
1496 open_meta_db(&db_path).context("Failed to open meta.db for batch branch recording")?;
1497
1498 let now = chrono::Utc::now().timestamp();
1499
1500 let tx = conn.transaction()?;
1502
1503 let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
1505 log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
1506
1507 let mut inserted = 0;
1508 for (path, hash) in files {
1509 log::trace!("Looking up file_id for path: {}", path);
1511 let file_id: i64 = tx
1512 .query_row(
1513 "SELECT id FROM files WHERE path = ?",
1514 [path.as_str()],
1515 |row| row.get(0),
1516 )
1517 .context(format!("File not found in index: {}", path))?;
1518 log::trace!("Found file_id={} for path: {}", file_id, path);
1519
1520 tx.execute(
1522 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1523 VALUES (?, ?, ?, ?)",
1524 rusqlite::params![file_id, branch_id, hash.as_str(), now],
1525 )?;
1526 inserted += 1;
1527 }
1528
1529 log::info!("Inserted {} file_branches entries", inserted);
1530 tx.commit()?;
1531 log::info!("Transaction committed successfully");
1532 Ok(())
1533 }
1534
1535 pub fn get_branch_files(&self, branch: &str) -> Result<HashMap<String, String>> {
1539 let db_path = self.cache_path.join(META_DB);
1540
1541 if !db_path.exists() {
1542 return Ok(HashMap::new());
1543 }
1544
1545 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1546
1547 let mut stmt = conn.prepare(
1548 "SELECT f.path, fb.hash
1549 FROM file_branches fb
1550 JOIN files f ON fb.file_id = f.id
1551 JOIN branches b ON fb.branch_id = b.id
1552 WHERE b.name = ?",
1553 )?;
1554 let files: HashMap<String, String> = stmt
1555 .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
1556 .collect::<Result<HashMap<_, _>, _>>()?;
1557
1558 log::debug!(
1559 "Loaded {} files for branch '{}' from file_branches table",
1560 files.len(),
1561 branch
1562 );
1563 Ok(files)
1564 }
1565
1566 pub fn branch_exists(&self, branch: &str) -> Result<bool> {
1570 let db_path = self.cache_path.join(META_DB);
1571
1572 if !db_path.exists() {
1573 return Ok(false);
1574 }
1575
1576 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1577
1578 let count: i64 = conn
1579 .query_row(
1580 "SELECT COUNT(*)
1581 FROM file_branches fb
1582 JOIN branches b ON fb.branch_id = b.id
1583 WHERE b.name = ?
1584 LIMIT 1",
1585 [branch],
1586 |row| row.get(0),
1587 )
1588 .unwrap_or(0);
1589
1590 Ok(count > 0)
1591 }
1592
1593 pub fn get_branch_info(&self, branch: &str) -> Result<BranchInfo> {
1595 let db_path = self.cache_path.join(META_DB);
1596
1597 if !db_path.exists() {
1598 anyhow::bail!("Database not initialized");
1599 }
1600
1601 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1602
1603 let info = conn.query_row(
1604 "SELECT commit_sha, last_indexed, file_count, is_dirty FROM branches WHERE name = ?",
1605 [branch],
1606 |row| {
1607 Ok(BranchInfo {
1608 branch: branch.to_string(),
1609 commit_sha: row.get(0)?,
1610 last_indexed: row.get(1)?,
1611 file_count: row.get(2)?,
1612 is_dirty: row.get::<_, i64>(3)? != 0,
1613 })
1614 },
1615 )?;
1616
1617 Ok(info)
1618 }
1619
1620 pub fn update_branch_metadata(
1625 &self,
1626 branch: &str,
1627 commit_sha: Option<&str>,
1628 file_count: usize,
1629 is_dirty: bool,
1630 ) -> Result<()> {
1631 let db_path = self.cache_path.join(META_DB);
1632 let conn =
1633 open_meta_db(&db_path).context("Failed to open meta.db for branch metadata update")?;
1634
1635 let now = chrono::Utc::now().timestamp();
1636 let is_dirty_int = if is_dirty { 1 } else { 0 };
1637
1638 let rows_updated = conn.execute(
1640 "UPDATE branches
1641 SET commit_sha = ?, last_indexed = ?, file_count = ?, is_dirty = ?
1642 WHERE name = ?",
1643 rusqlite::params![
1644 commit_sha.unwrap_or("unknown"),
1645 now,
1646 file_count,
1647 is_dirty_int,
1648 branch
1649 ],
1650 )?;
1651
1652 if rows_updated == 0 {
1654 conn.execute(
1655 "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
1656 VALUES (?, ?, ?, ?, ?)",
1657 rusqlite::params![
1658 branch,
1659 commit_sha.unwrap_or("unknown"),
1660 now,
1661 file_count,
1662 is_dirty_int
1663 ],
1664 )?;
1665 }
1666
1667 log::debug!(
1668 "Updated branch metadata for '{}': commit={}, files={}, dirty={}",
1669 branch,
1670 commit_sha.unwrap_or("unknown"),
1671 file_count,
1672 is_dirty
1673 );
1674 Ok(())
1675 }
1676
1677 pub fn find_file_with_hash(&self, hash: &str) -> Result<Option<(String, String)>> {
1682 let db_path = self.cache_path.join(META_DB);
1683
1684 if !db_path.exists() {
1685 return Ok(None);
1686 }
1687
1688 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1689
1690 let result = conn
1691 .query_row(
1692 "SELECT f.path, b.name
1693 FROM file_branches fb
1694 JOIN files f ON fb.file_id = f.id
1695 JOIN branches b ON fb.branch_id = b.id
1696 WHERE fb.hash = ?
1697 LIMIT 1",
1698 [hash],
1699 |row| Ok((row.get(0)?, row.get(1)?)),
1700 )
1701 .optional()?;
1702
1703 Ok(result)
1704 }
1705
1706 pub fn get_file_id(&self, path: &str) -> Result<Option<i64>> {
1710 let db_path = self.cache_path.join(META_DB);
1711
1712 if !db_path.exists() {
1713 return Ok(None);
1714 }
1715
1716 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1717
1718 let result = conn
1719 .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
1720 row.get(0)
1721 })
1722 .optional()?;
1723
1724 Ok(result)
1725 }
1726
1727 pub fn batch_get_file_ids(&self, paths: &[String]) -> Result<HashMap<String, i64>> {
1734 let db_path = self.cache_path.join(META_DB);
1735
1736 if !db_path.exists() {
1737 return Ok(HashMap::new());
1738 }
1739
1740 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1741
1742 const BATCH_SIZE: usize = 900;
1745
1746 let mut results = HashMap::new();
1747
1748 for chunk in paths.chunks(BATCH_SIZE) {
1749 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1751
1752 let query = format!(
1753 "SELECT path, id FROM files WHERE path IN ({})",
1754 placeholders
1755 );
1756
1757 let params: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
1758 let mut stmt = conn.prepare(&query)?;
1759
1760 let chunk_results = stmt
1761 .query_map(rusqlite::params_from_iter(params), |row| {
1762 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
1763 })?
1764 .collect::<Result<HashMap<_, _>, _>>()?;
1765
1766 results.extend(chunk_results);
1767 }
1768
1769 log::debug!(
1770 "Batch loaded {} file IDs (out of {} requested, {} chunks)",
1771 results.len(),
1772 paths.len(),
1773 paths.len().div_ceil(BATCH_SIZE)
1774 );
1775 Ok(results)
1776 }
1777
1778 pub fn should_compact(&self) -> Result<bool> {
1785 let db_path = self.cache_path.join(META_DB);
1786
1787 if !db_path.exists() {
1788 return Ok(false);
1790 }
1791
1792 let conn = open_meta_db(&db_path).context("Failed to open meta.db for compaction check")?;
1793
1794 let last_compaction: i64 = conn
1796 .query_row(
1797 "SELECT value FROM statistics WHERE key = 'last_compaction'",
1798 [],
1799 |row| {
1800 let value: String = row.get(0)?;
1801 Ok(value.parse::<i64>().unwrap_or(0))
1802 },
1803 )
1804 .unwrap_or(0);
1805
1806 let now = chrono::Utc::now().timestamp();
1808
1809 const COMPACTION_THRESHOLD_SECS: i64 = 86400;
1811
1812 let elapsed_secs = now - last_compaction;
1813 let should_run = elapsed_secs >= COMPACTION_THRESHOLD_SECS;
1814
1815 log::debug!(
1816 "Compaction check: last={}, now={}, elapsed={}s, should_compact={}",
1817 last_compaction,
1818 now,
1819 elapsed_secs,
1820 should_run
1821 );
1822
1823 Ok(should_run)
1824 }
1825
1826 pub fn update_compaction_timestamp(&self) -> Result<()> {
1830 let db_path = self.cache_path.join(META_DB);
1831 let conn = open_meta_db(&db_path)
1832 .context("Failed to open meta.db for compaction timestamp update")?;
1833
1834 let now = chrono::Utc::now().timestamp();
1835
1836 conn.execute(
1837 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1838 ["last_compaction", &now.to_string(), &now.to_string()],
1839 )?;
1840
1841 log::debug!("Updated last_compaction timestamp to: {}", now);
1842 Ok(())
1843 }
1844
1845 pub fn compact(&self) -> Result<crate::models::CompactionReport> {
1856 let start_time = std::time::Instant::now();
1857 log::info!("Starting cache compaction...");
1858
1859 let size_before = self.calculate_cache_size()?;
1861
1862 let deleted_files = self.identify_deleted_files()?;
1864 log::info!(
1865 "Found {} deleted files to remove from cache",
1866 deleted_files.len()
1867 );
1868
1869 if deleted_files.is_empty() {
1870 log::info!("No deleted files to compact - cache is clean");
1871 self.update_compaction_timestamp()?;
1873
1874 return Ok(crate::models::CompactionReport {
1875 files_removed: 0,
1876 space_saved_bytes: 0,
1877 duration_ms: start_time.elapsed().as_millis() as u64,
1878 });
1879 }
1880
1881 self.delete_files_from_db(&deleted_files)?;
1883 log::info!("Deleted {} files from database", deleted_files.len());
1884
1885 self.vacuum_database()?;
1887 log::info!("Completed VACUUM operation");
1888
1889 let size_after = self.calculate_cache_size()?;
1891 let space_saved = size_before.saturating_sub(size_after);
1892
1893 self.update_compaction_timestamp()?;
1895
1896 let duration_ms = start_time.elapsed().as_millis() as u64;
1897
1898 log::info!(
1899 "Cache compaction completed: {} files removed, {} bytes saved ({:.2} MB), took {}ms",
1900 deleted_files.len(),
1901 space_saved,
1902 space_saved as f64 / 1_048_576.0,
1903 duration_ms
1904 );
1905
1906 Ok(crate::models::CompactionReport {
1907 files_removed: deleted_files.len(),
1908 space_saved_bytes: space_saved,
1909 duration_ms,
1910 })
1911 }
1912
1913 pub(crate) fn identify_deleted_files(&self) -> Result<Vec<i64>> {
1917 let db_path = self.cache_path.join(META_DB);
1918 let conn = open_meta_db(&db_path)
1919 .context("Failed to open meta.db for deleted file identification")?;
1920
1921 let workspace_root = self.workspace_root();
1922
1923 let mut stmt = conn.prepare("SELECT id, path FROM files")?;
1925 let files = stmt
1926 .query_map([], |row| {
1927 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1928 })?
1929 .collect::<Result<Vec<_>, _>>()?;
1930
1931 log::debug!("Checking {} files for deletion status", files.len());
1932
1933 let mut deleted_file_ids = Vec::new();
1935 for (file_id, file_path) in files {
1936 let full_path = workspace_root.join(&file_path);
1937 if !full_path.exists() {
1938 log::trace!("File no longer exists: {} (id={})", file_path, file_id);
1939 deleted_file_ids.push(file_id);
1940 }
1941 }
1942
1943 Ok(deleted_file_ids)
1944 }
1945
1946 pub(crate) fn delete_files_from_db(&self, file_ids: &[i64]) -> Result<()> {
1953 if file_ids.is_empty() {
1954 return Ok(());
1955 }
1956
1957 let db_path = self.cache_path.join(META_DB);
1958 let mut conn =
1959 open_meta_db(&db_path).context("Failed to open meta.db for file deletion")?;
1960
1961 let tx = conn.transaction()?;
1962
1963 const BATCH_SIZE: usize = 900;
1965
1966 for chunk in file_ids.chunks(BATCH_SIZE) {
1967 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
1968
1969 let delete_query = format!("DELETE FROM files WHERE id IN ({})", placeholders);
1970
1971 let params: Vec<i64> = chunk.to_vec();
1972 tx.execute(&delete_query, rusqlite::params_from_iter(params))?;
1973 }
1974
1975 tx.commit()?;
1976 log::debug!(
1977 "Deleted {} files from database (CASCADE handled related tables)",
1978 file_ids.len()
1979 );
1980 Ok(())
1981 }
1982
1983 fn vacuum_database(&self) -> Result<()> {
1988 let db_path = self.cache_path.join(META_DB);
1989 let conn = open_meta_db(&db_path).context("Failed to open meta.db for VACUUM")?;
1990
1991 conn.execute("VACUUM", [])?;
1994
1995 log::debug!("VACUUM completed successfully");
1996 Ok(())
1997 }
1998
1999 fn calculate_cache_size(&self) -> Result<u64> {
2007 let mut total_size: u64 = 0;
2008
2009 for file_name in [
2010 META_DB,
2011 TOKENS_BIN,
2012 CONFIG_TOML,
2013 "content.bin",
2014 "trigrams.bin",
2015 ] {
2016 let file_path = self.cache_path.join(file_name);
2017 if let Ok(metadata) = std::fs::metadata(&file_path) {
2018 total_size += metadata.len();
2019 }
2020 }
2021
2022 Ok(total_size)
2023 }
2024}
2025
2026#[derive(Debug, Clone)]
2028pub struct BranchInfo {
2029 pub branch: String,
2030 pub commit_sha: String,
2031 pub last_indexed: i64,
2032 pub file_count: usize,
2033 pub is_dirty: bool,
2034}
2035
2036#[cfg(test)]
2042mod tests {
2043 use super::*;
2044 use tempfile::TempDir;
2045
2046 #[test]
2047 fn test_cache_init() {
2048 let temp = TempDir::new().unwrap();
2049 let cache = CacheManager::new(temp.path());
2050
2051 assert!(!cache.exists());
2052 cache.init().unwrap();
2053 assert!(cache.exists());
2054 assert!(cache.path().exists());
2055
2056 assert!(cache.path().join(META_DB).exists());
2058 assert!(cache.path().join(CONFIG_TOML).exists());
2059 }
2060
2061 #[test]
2062 fn test_cache_init_idempotent() {
2063 let temp = TempDir::new().unwrap();
2064 let cache = CacheManager::new(temp.path());
2065
2066 cache.init().unwrap();
2068 cache.init().unwrap();
2069
2070 assert!(cache.exists());
2071 }
2072
2073 #[test]
2074 fn test_cache_clear() {
2075 let temp = TempDir::new().unwrap();
2076 let cache = CacheManager::new(temp.path());
2077
2078 cache.init().unwrap();
2079 assert!(cache.exists());
2080
2081 cache.clear().unwrap();
2082 assert!(!cache.exists());
2083 }
2084
2085 #[test]
2086 fn test_cache_clear_nonexistent() {
2087 let temp = TempDir::new().unwrap();
2088 let cache = CacheManager::new(temp.path());
2089
2090 assert!(!cache.exists());
2092 cache.clear().unwrap();
2093 assert!(!cache.exists());
2094 }
2095
2096 #[test]
2097 fn test_load_all_hashes_empty() {
2098 let temp = TempDir::new().unwrap();
2099 let cache = CacheManager::new(temp.path());
2100
2101 cache.init().unwrap();
2102 let hashes = cache.load_all_hashes().unwrap();
2103 assert_eq!(hashes.len(), 0);
2104 }
2105
2106 #[test]
2107 fn test_load_all_hashes_before_init() {
2108 let temp = TempDir::new().unwrap();
2109 let cache = CacheManager::new(temp.path());
2110
2111 let hashes = cache.load_all_hashes().unwrap();
2113 assert_eq!(hashes.len(), 0);
2114 }
2115
2116 #[test]
2117 fn test_load_hashes_for_branch_empty() {
2118 let temp = TempDir::new().unwrap();
2119 let cache = CacheManager::new(temp.path());
2120
2121 cache.init().unwrap();
2122 let hashes = cache.load_hashes_for_branch("main").unwrap();
2123 assert_eq!(hashes.len(), 0);
2124 }
2125
2126 #[test]
2127 fn test_update_file() {
2128 let temp = TempDir::new().unwrap();
2129 let cache = CacheManager::new(temp.path());
2130
2131 cache.init().unwrap();
2132 cache.update_file("src/main.rs", "rust", 100).unwrap();
2133
2134 let files = cache.list_files().unwrap();
2136 assert_eq!(files.len(), 1);
2137 assert_eq!(files[0].path, "src/main.rs");
2138 assert_eq!(files[0].language, "rust");
2139 }
2140
2141 #[test]
2142 fn test_update_file_multiple() {
2143 let temp = TempDir::new().unwrap();
2144 let cache = CacheManager::new(temp.path());
2145
2146 cache.init().unwrap();
2147 cache.update_file("src/main.rs", "rust", 100).unwrap();
2148 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2149 cache.update_file("README.md", "markdown", 50).unwrap();
2150
2151 let files = cache.list_files().unwrap();
2153 assert_eq!(files.len(), 3);
2154 }
2155
2156 #[test]
2157 fn test_update_file_replace() {
2158 let temp = TempDir::new().unwrap();
2159 let cache = CacheManager::new(temp.path());
2160
2161 cache.init().unwrap();
2162 cache.update_file("src/main.rs", "rust", 100).unwrap();
2163 cache.update_file("src/main.rs", "rust", 150).unwrap();
2164
2165 let files = cache.list_files().unwrap();
2167 assert_eq!(files.len(), 1);
2168 assert_eq!(files[0].path, "src/main.rs");
2169 }
2170
2171 #[test]
2172 fn test_batch_update_files() {
2173 let temp = TempDir::new().unwrap();
2174 let cache = CacheManager::new(temp.path());
2175
2176 cache.init().unwrap();
2177
2178 let files = vec![
2179 ("src/main.rs".to_string(), "rust".to_string(), 100),
2180 ("src/lib.rs".to_string(), "rust".to_string(), 200),
2181 ("test.py".to_string(), "python".to_string(), 50),
2182 ];
2183
2184 cache.batch_update_files(&files).unwrap();
2185
2186 let stored_files = cache.list_files().unwrap();
2188 assert_eq!(stored_files.len(), 3);
2189 }
2190
2191 #[test]
2192 fn test_update_stats() {
2193 let temp = TempDir::new().unwrap();
2194 let cache = CacheManager::new(temp.path());
2195
2196 cache.init().unwrap();
2197 cache.update_file("src/main.rs", "rust", 100).unwrap();
2198 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2199
2200 cache
2202 .record_branch_file("src/main.rs", "_default", "hash1", None)
2203 .unwrap();
2204 cache
2205 .record_branch_file("src/lib.rs", "_default", "hash2", None)
2206 .unwrap();
2207 cache.update_stats("_default").unwrap();
2208
2209 let stats = cache.stats().unwrap();
2210 assert_eq!(stats.total_files, 2);
2211 }
2212
2213 #[test]
2214 fn test_stats_empty_cache() {
2215 let temp = TempDir::new().unwrap();
2216 let cache = CacheManager::new(temp.path());
2217
2218 cache.init().unwrap();
2219 let stats = cache.stats().unwrap();
2220
2221 assert_eq!(stats.total_files, 0);
2222 assert_eq!(stats.files_by_language.len(), 0);
2223 }
2224
2225 #[test]
2226 fn test_stats_before_init() {
2227 let temp = TempDir::new().unwrap();
2228 let cache = CacheManager::new(temp.path());
2229
2230 let stats = cache.stats().unwrap();
2232 assert_eq!(stats.total_files, 0);
2233 }
2234
2235 #[test]
2236 fn test_stats_by_language() {
2237 let temp = TempDir::new().unwrap();
2238 let cache = CacheManager::new(temp.path());
2239
2240 cache.init().unwrap();
2241 cache.update_file("main.rs", "Rust", 100).unwrap();
2242 cache.update_file("lib.rs", "Rust", 200).unwrap();
2243 cache.update_file("script.py", "Python", 50).unwrap();
2244 cache.update_file("test.py", "Python", 80).unwrap();
2245
2246 cache
2248 .record_branch_file("main.rs", "_default", "hash1", None)
2249 .unwrap();
2250 cache
2251 .record_branch_file("lib.rs", "_default", "hash2", None)
2252 .unwrap();
2253 cache
2254 .record_branch_file("script.py", "_default", "hash3", None)
2255 .unwrap();
2256 cache
2257 .record_branch_file("test.py", "_default", "hash4", None)
2258 .unwrap();
2259 cache.update_stats("_default").unwrap();
2260
2261 let stats = cache.stats().unwrap();
2262 assert_eq!(stats.files_by_language.get("Rust"), Some(&2));
2263 assert_eq!(stats.files_by_language.get("Python"), Some(&2));
2264 assert_eq!(stats.lines_by_language.get("Rust"), Some(&300)); assert_eq!(stats.lines_by_language.get("Python"), Some(&130)); }
2267
2268 #[test]
2269 fn test_list_files_empty() {
2270 let temp = TempDir::new().unwrap();
2271 let cache = CacheManager::new(temp.path());
2272
2273 cache.init().unwrap();
2274 let files = cache.list_files().unwrap();
2275 assert_eq!(files.len(), 0);
2276 }
2277
2278 #[test]
2279 fn test_list_files() {
2280 let temp = TempDir::new().unwrap();
2281 let cache = CacheManager::new(temp.path());
2282
2283 cache.init().unwrap();
2284 cache.update_file("src/main.rs", "rust", 100).unwrap();
2285 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2286
2287 let files = cache.list_files().unwrap();
2288 assert_eq!(files.len(), 2);
2289
2290 assert_eq!(files[0].path, "src/lib.rs");
2292 assert_eq!(files[1].path, "src/main.rs");
2293
2294 assert_eq!(files[0].language, "rust");
2295 }
2296
2297 #[test]
2298 fn test_list_files_before_init() {
2299 let temp = TempDir::new().unwrap();
2300 let cache = CacheManager::new(temp.path());
2301
2302 let files = cache.list_files().unwrap();
2304 assert_eq!(files.len(), 0);
2305 }
2306
2307 #[test]
2308 fn test_branch_exists() {
2309 let temp = TempDir::new().unwrap();
2310 let cache = CacheManager::new(temp.path());
2311
2312 cache.init().unwrap();
2313
2314 assert!(!cache.branch_exists("main").unwrap());
2315
2316 cache.update_file("src/main.rs", "rust", 100).unwrap();
2318 cache
2319 .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2320 .unwrap();
2321
2322 assert!(cache.branch_exists("main").unwrap());
2323 assert!(!cache.branch_exists("feature-branch").unwrap());
2324 }
2325
2326 #[test]
2327 fn test_record_branch_file() {
2328 let temp = TempDir::new().unwrap();
2329 let cache = CacheManager::new(temp.path());
2330
2331 cache.init().unwrap();
2332 cache.update_file("src/main.rs", "rust", 100).unwrap();
2334 cache
2335 .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2336 .unwrap();
2337
2338 let files = cache.get_branch_files("main").unwrap();
2339 assert_eq!(files.len(), 1);
2340 assert_eq!(files.get("src/main.rs"), Some(&"hash1".to_string()));
2341 }
2342
2343 #[test]
2344 fn test_get_branch_files_empty() {
2345 let temp = TempDir::new().unwrap();
2346 let cache = CacheManager::new(temp.path());
2347
2348 cache.init().unwrap();
2349 let files = cache.get_branch_files("nonexistent").unwrap();
2350 assert_eq!(files.len(), 0);
2351 }
2352
2353 #[test]
2354 fn test_batch_record_branch_files() {
2355 let temp = TempDir::new().unwrap();
2356 let cache = CacheManager::new(temp.path());
2357
2358 cache.init().unwrap();
2359
2360 let file_metadata = vec![
2362 ("src/main.rs".to_string(), "rust".to_string(), 100),
2363 ("src/lib.rs".to_string(), "rust".to_string(), 200),
2364 ("README.md".to_string(), "markdown".to_string(), 50),
2365 ];
2366 cache.batch_update_files(&file_metadata).unwrap();
2367
2368 let files = vec![
2369 ("src/main.rs".to_string(), "hash1".to_string()),
2370 ("src/lib.rs".to_string(), "hash2".to_string()),
2371 ("README.md".to_string(), "hash3".to_string()),
2372 ];
2373
2374 cache
2375 .batch_record_branch_files(&files, "main", Some("commit123"))
2376 .unwrap();
2377
2378 let branch_files = cache.get_branch_files("main").unwrap();
2379 assert_eq!(branch_files.len(), 3);
2380 assert_eq!(branch_files.get("src/main.rs"), Some(&"hash1".to_string()));
2381 assert_eq!(branch_files.get("src/lib.rs"), Some(&"hash2".to_string()));
2382 assert_eq!(branch_files.get("README.md"), Some(&"hash3".to_string()));
2383 }
2384
2385 #[test]
2386 fn test_update_branch_metadata() {
2387 let temp = TempDir::new().unwrap();
2388 let cache = CacheManager::new(temp.path());
2389
2390 cache.init().unwrap();
2391 cache
2392 .update_branch_metadata("main", Some("commit123"), 10, false)
2393 .unwrap();
2394
2395 let info = cache.get_branch_info("main").unwrap();
2396 assert_eq!(info.branch, "main");
2397 assert_eq!(info.commit_sha, "commit123");
2398 assert_eq!(info.file_count, 10);
2399 assert!(!info.is_dirty);
2400 }
2401
2402 #[test]
2403 fn test_update_branch_metadata_dirty() {
2404 let temp = TempDir::new().unwrap();
2405 let cache = CacheManager::new(temp.path());
2406
2407 cache.init().unwrap();
2408 cache
2409 .update_branch_metadata("feature", Some("commit456"), 5, true)
2410 .unwrap();
2411
2412 let info = cache.get_branch_info("feature").unwrap();
2413 assert!(info.is_dirty);
2414 }
2415
2416 #[test]
2417 fn test_find_file_with_hash() {
2418 let temp = TempDir::new().unwrap();
2419 let cache = CacheManager::new(temp.path());
2420
2421 cache.init().unwrap();
2422 cache.update_file("src/main.rs", "rust", 100).unwrap();
2424 cache
2425 .record_branch_file("src/main.rs", "main", "unique_hash", Some("commit123"))
2426 .unwrap();
2427
2428 let result = cache.find_file_with_hash("unique_hash").unwrap();
2429 assert!(result.is_some());
2430
2431 let (path, branch) = result.unwrap();
2432 assert_eq!(path, "src/main.rs");
2433 assert_eq!(branch, "main");
2434 }
2435
2436 #[test]
2437 fn test_find_file_with_hash_not_found() {
2438 let temp = TempDir::new().unwrap();
2439 let cache = CacheManager::new(temp.path());
2440
2441 cache.init().unwrap();
2442
2443 let result = cache.find_file_with_hash("nonexistent_hash").unwrap();
2444 assert!(result.is_none());
2445 }
2446
2447 #[test]
2448 fn test_config_toml_created() {
2449 let temp = TempDir::new().unwrap();
2450 let cache = CacheManager::new(temp.path());
2451
2452 cache.init().unwrap();
2453
2454 let config_path = cache.path().join(CONFIG_TOML);
2455 let config_content = std::fs::read_to_string(&config_path).unwrap();
2456
2457 assert!(config_content.contains("[index]"));
2459 assert!(config_content.contains("[search]"));
2460 assert!(config_content.contains("[performance]"));
2461 assert!(config_content.contains("max_file_size"));
2462 }
2463
2464 #[test]
2465 fn test_meta_db_schema() {
2466 let temp = TempDir::new().unwrap();
2467 let cache = CacheManager::new(temp.path());
2468
2469 cache.init().unwrap();
2470
2471 let db_path = cache.path().join(META_DB);
2472 let conn = open_meta_db(&db_path).unwrap();
2473
2474 let tables: Vec<String> = conn
2476 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
2477 .unwrap()
2478 .query_map([], |row| row.get(0))
2479 .unwrap()
2480 .collect::<Result<Vec<_>, _>>()
2481 .unwrap();
2482
2483 assert!(tables.contains(&"files".to_string()));
2484 assert!(tables.contains(&"statistics".to_string()));
2485 assert!(tables.contains(&"config".to_string()));
2486 assert!(tables.contains(&"file_branches".to_string()));
2487 assert!(tables.contains(&"branches".to_string()));
2488 assert!(tables.contains(&"file_dependencies".to_string()));
2489 assert!(tables.contains(&"file_exports".to_string()));
2490 }
2491
2492 #[test]
2493 fn test_concurrent_file_updates() {
2494 use std::thread;
2495
2496 let temp = TempDir::new().unwrap();
2497 let cache_path = temp.path().to_path_buf();
2498
2499 let cache = CacheManager::new(&cache_path);
2500 cache.init().unwrap();
2501
2502 let handles: Vec<_> = (0..10)
2504 .map(|i| {
2505 let path = cache_path.clone();
2506 thread::spawn(move || {
2507 let cache = CacheManager::new(&path);
2508 cache
2509 .update_file(&format!("file_{}.rs", i), "rust", i * 10)
2510 .unwrap();
2511 })
2512 })
2513 .collect();
2514
2515 for handle in handles {
2516 handle.join().unwrap();
2517 }
2518
2519 let cache = CacheManager::new(&cache_path);
2520 let files = cache.list_files().unwrap();
2521 assert_eq!(files.len(), 10);
2522 }
2523
2524 #[test]
2527 fn test_validate_corrupted_database() {
2528 use std::io::Write;
2529
2530 let temp = TempDir::new().unwrap();
2531 let cache = CacheManager::new(temp.path());
2532
2533 cache.init().unwrap();
2534
2535 let db_path = cache.path().join(META_DB);
2537 let mut file = File::create(&db_path).unwrap();
2538 file.write_all(b"CORRUPTED DATA").unwrap();
2539
2540 let result = cache.validate();
2542 assert!(result.is_err());
2543 let err_msg = result.unwrap_err().to_string();
2544 eprintln!("Error message: {}", err_msg);
2545 assert!(err_msg.contains("corrupted") || err_msg.contains("not a database"));
2546 }
2547
2548 #[test]
2549 fn test_validate_corrupted_trigrams() {
2550 use std::io::Write;
2551
2552 let temp = TempDir::new().unwrap();
2553 let cache = CacheManager::new(temp.path());
2554
2555 cache.init().unwrap();
2556
2557 let trigrams_path = cache.path().join("trigrams.bin");
2559 let mut file = File::create(&trigrams_path).unwrap();
2560 file.write_all(b"BADM").unwrap(); let result = cache.validate();
2564 assert!(result.is_err());
2565 let err = result.unwrap_err().to_string();
2566 assert!(err.contains("trigrams.bin") && err.contains("corrupted"));
2567 }
2568
2569 #[test]
2570 fn test_validate_corrupted_content() {
2571 use std::io::Write;
2572
2573 let temp = TempDir::new().unwrap();
2574 let cache = CacheManager::new(temp.path());
2575
2576 cache.init().unwrap();
2577
2578 let content_path = cache.path().join("content.bin");
2580 let mut file = File::create(&content_path).unwrap();
2581 file.write_all(b"BADM").unwrap(); let result = cache.validate();
2585 assert!(result.is_err());
2586 let err = result.unwrap_err().to_string();
2587 assert!(err.contains("content.bin") && err.contains("corrupted"));
2588 }
2589
2590 #[test]
2591 fn test_validate_missing_schema_table() {
2592 let temp = TempDir::new().unwrap();
2593 let cache = CacheManager::new(temp.path());
2594
2595 cache.init().unwrap();
2596
2597 let db_path = cache.path().join(META_DB);
2599 let conn = open_meta_db(&db_path).unwrap();
2600 conn.execute("DROP TABLE files", []).unwrap();
2601
2602 let result = cache.validate();
2604 assert!(result.is_err());
2605 let err = result.unwrap_err().to_string();
2606 assert!(err.contains("files") && err.contains("missing"));
2607 }
2608}