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(
150 "CREATE TABLE IF NOT EXISTS files (
151 id INTEGER PRIMARY KEY AUTOINCREMENT,
152 path TEXT NOT NULL UNIQUE,
153 last_indexed INTEGER NOT NULL,
154 language TEXT NOT NULL,
155 token_count INTEGER DEFAULT 0,
156 line_count INTEGER DEFAULT 0,
157 size INTEGER NOT NULL DEFAULT 0,
158 mtime_ns INTEGER NOT NULL DEFAULT 0,
159 hash TEXT NOT NULL DEFAULT '',
160 dirty_at_index INTEGER NOT NULL DEFAULT 0
161 )",
162 [],
163 )?;
164 Self::migrate_files_columns(&conn)?;
165
166 conn.execute(
167 "CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)",
168 [],
169 )?;
170
171 conn.execute(
173 "CREATE TABLE IF NOT EXISTS statistics (
174 key TEXT PRIMARY KEY,
175 value TEXT NOT NULL,
176 updated_at INTEGER NOT NULL
177 )",
178 [],
179 )?;
180
181 let now = chrono::Utc::now().timestamp();
183 conn.execute(
184 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
185 ["total_files", "0", &now.to_string()],
186 )?;
187 conn.execute(
191 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
192 [
193 "writer_version",
194 env!("CARGO_PKG_VERSION"),
195 &now.to_string(),
196 ],
197 )?;
198 if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
199 conn.execute(
200 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
201 ["writer_git_sha", sha, &now.to_string()],
202 )?;
203 }
204
205 let schema_hash = env!("CACHE_SCHEMA_HASH");
208 conn.execute(
209 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
210 ["schema_hash", schema_hash, &now.to_string()],
211 )?;
212
213 conn.execute(
215 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
216 ["last_compaction", "0", &now.to_string()],
217 )?;
218
219 conn.execute(
221 "CREATE TABLE IF NOT EXISTS config (
222 key TEXT PRIMARY KEY,
223 value TEXT NOT NULL
224 )",
225 [],
226 )?;
227
228 conn.execute(
230 "CREATE TABLE IF NOT EXISTS file_branches (
231 file_id INTEGER NOT NULL,
232 branch_id INTEGER NOT NULL,
233 hash TEXT NOT NULL,
234 last_indexed INTEGER NOT NULL,
235 PRIMARY KEY (file_id, branch_id),
236 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
237 FOREIGN KEY (branch_id) REFERENCES branches(id) ON DELETE CASCADE
238 )",
239 [],
240 )?;
241
242 conn.execute(
243 "CREATE INDEX IF NOT EXISTS idx_branch_lookup ON file_branches(branch_id, file_id)",
244 [],
245 )?;
246
247 conn.execute(
248 "CREATE INDEX IF NOT EXISTS idx_hash_lookup ON file_branches(hash)",
249 [],
250 )?;
251
252 conn.execute(
254 "CREATE TABLE IF NOT EXISTS branches (
255 id INTEGER PRIMARY KEY AUTOINCREMENT,
256 name TEXT NOT NULL UNIQUE,
257 commit_sha TEXT NOT NULL,
258 last_indexed INTEGER NOT NULL,
259 file_count INTEGER DEFAULT 0,
260 is_dirty INTEGER DEFAULT 0
261 )",
262 [],
263 )?;
264
265 conn.execute(
267 "CREATE TABLE IF NOT EXISTS file_dependencies (
268 id INTEGER PRIMARY KEY AUTOINCREMENT,
269 file_id INTEGER NOT NULL,
270 imported_path TEXT NOT NULL,
271 resolved_file_id INTEGER,
272 import_type TEXT NOT NULL,
273 line_number INTEGER NOT NULL,
274 imported_symbols TEXT,
275 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
276 FOREIGN KEY (resolved_file_id) REFERENCES files(id) ON DELETE SET NULL
277 )",
278 [],
279 )?;
280
281 conn.execute(
282 "CREATE INDEX IF NOT EXISTS idx_deps_file ON file_dependencies(file_id)",
283 [],
284 )?;
285
286 conn.execute(
287 "CREATE INDEX IF NOT EXISTS idx_deps_resolved ON file_dependencies(resolved_file_id)",
288 [],
289 )?;
290
291 conn.execute(
292 "CREATE INDEX IF NOT EXISTS idx_deps_type ON file_dependencies(import_type)",
293 [],
294 )?;
295
296 conn.execute(
298 "CREATE TABLE IF NOT EXISTS file_exports (
299 id INTEGER PRIMARY KEY AUTOINCREMENT,
300 file_id INTEGER NOT NULL,
301 exported_symbol TEXT,
302 source_path TEXT NOT NULL,
303 resolved_source_id INTEGER,
304 line_number INTEGER NOT NULL,
305 FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE,
306 FOREIGN KEY (resolved_source_id) REFERENCES files(id) ON DELETE SET NULL
307 )",
308 [],
309 )?;
310
311 conn.execute(
312 "CREATE INDEX IF NOT EXISTS idx_exports_file ON file_exports(file_id)",
313 [],
314 )?;
315
316 conn.execute(
317 "CREATE INDEX IF NOT EXISTS idx_exports_resolved ON file_exports(resolved_source_id)",
318 [],
319 )?;
320
321 conn.execute(
322 "CREATE INDEX IF NOT EXISTS idx_exports_symbol ON file_exports(exported_symbol)",
323 [],
324 )?;
325
326 conn.execute_batch("COMMIT")
327 .context("Failed to commit meta.db schema transaction")?;
328
329 log::debug!("Created meta.db with schema");
330 Ok(())
331 }
332
333 fn migrate_files_columns(conn: &Connection) -> Result<()> {
340 const WANTED: [(&str, &str); 4] = [
341 ("size", "INTEGER NOT NULL DEFAULT 0"),
342 ("mtime_ns", "INTEGER NOT NULL DEFAULT 0"),
343 ("hash", "TEXT NOT NULL DEFAULT ''"),
344 ("dirty_at_index", "INTEGER NOT NULL DEFAULT 0"),
345 ];
346 let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('files')")?;
347 let present: std::collections::HashSet<String> = stmt
348 .query_map([], |row| row.get::<_, String>(0))?
349 .collect::<Result<_, _>>()?;
350 for (name, decl) in WANTED {
351 if !present.contains(name) {
352 log::info!("meta.db: adding files.{} (pre-2.0.0 cache)", name);
353 conn.execute(
354 &format!("ALTER TABLE files ADD COLUMN {} {}", name, decl),
355 [],
356 )?;
357 }
358 }
359 Ok(())
360 }
361
362 pub fn fingerprints_for(&self, paths: &[&str]) -> Result<HashMap<String, FileFingerprint>> {
367 let db_path = self.cache_path.join(META_DB);
368 if paths.is_empty() || !db_path.exists() {
369 return Ok(HashMap::new());
370 }
371 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
372 const BATCH_SIZE: usize = 900;
373 let mut out = HashMap::with_capacity(paths.len());
374 for chunk in paths.chunks(BATCH_SIZE) {
375 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
376 let sql = format!(
377 "SELECT path, size, mtime_ns, hash FROM files WHERE path IN ({})",
378 placeholders
379 );
380 let mut stmt = conn.prepare(&sql)?;
381 let rows = stmt.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
382 Ok((
383 row.get::<_, String>(0)?,
384 FileFingerprint {
385 size: row.get::<_, i64>(1)? as u64,
386 mtime_ns: row.get::<_, i64>(2)?,
387 hash: row.get::<_, String>(3)?,
388 },
389 ))
390 })?;
391 for row in rows {
392 let (path, fp) = row?;
393 out.insert(path, fp);
394 }
395 }
396 Ok(out)
397 }
398
399 pub fn load_fingerprints(&self) -> Result<HashMap<String, FileFingerprint>> {
404 let db_path = self.cache_path.join(META_DB);
405 if !db_path.exists() {
406 return Ok(HashMap::new());
407 }
408 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
409 let mut stmt = conn.prepare("SELECT path, size, mtime_ns, hash FROM files")?;
410 let rows = stmt.query_map([], |row| {
411 Ok((
412 row.get::<_, String>(0)?,
413 FileFingerprint {
414 size: row.get::<_, i64>(1)? as u64,
415 mtime_ns: row.get::<_, i64>(2)?,
416 hash: row.get::<_, String>(3)?,
417 },
418 ))
419 })?;
420 rows.collect::<Result<HashMap<_, _>, _>>()
421 .context("Failed to read file fingerprints")
422 }
423
424 pub fn refresh_fingerprints(
432 &self,
433 rows: &[(String, u64, i64)],
434 dirty: &std::collections::HashSet<String>,
435 ) -> Result<()> {
436 let db_path = self.cache_path.join(META_DB);
437 let mut conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
438 let tx = conn.transaction()?;
439 {
440 let mut stmt = tx.prepare(
441 "UPDATE files SET size = ?, mtime_ns = ?, dirty_at_index = ? WHERE path = ?",
442 )?;
443 for (path, size, mtime_ns) in rows {
444 let is_dirty = dirty.contains(path) as i64;
445 stmt.execute(rusqlite::params![*size as i64, mtime_ns, is_dirty, path])?;
446 }
447 }
448 tx.commit()?;
449 Ok(())
450 }
451
452 fn init_config_toml(&self) -> Result<()> {
454 let config_path = self.cache_path.join(CONFIG_TOML);
455
456 if config_path.exists() {
457 return Ok(());
458 }
459
460 let default_config = r#"[index]
461languages = [] # Empty = all supported languages
462text_tier = true # Also index docs, config and every other non-binary file
463# "tracked" (default): every non-binary file that is not gitignored and not under a
464# dot-directory — ripgrep's defaults (hidden = true walks dot-directories). Lock and generated files are indexed but excluded from
465# searches unless asked for (include_locks / include_generated / lang).
466# "allowlist": the pre-2.0.0 rule — code plus a fixed docs/config extension list.
467mode = "tracked"
468hidden = false # true also walks dot-directories (.githooks/), never .git/ or .reflex/
469max_file_size = 10485760 # 10 MB
470follow_symlinks = false
471
472[index.include]
473patterns = []
474
475[index.exclude]
476patterns = []
477
478[search]
479default_limit = 100
480fuzzy_threshold = 0.8
481
482[performance]
483parallel_threads = 0 # 0 = auto (80% of available cores), or set a specific number
484compression_level = 3 # zstd level
485
486[semantic]
487# Semantic query generation using LLMs
488# Translate natural language questions into rfx query commands
489provider = "openrouter" # Options: openai, anthropic, openrouter
490# model = "openai/gpt-4o-mini" # Optional: override provider default model
491# auto_execute = false # Optional: auto-execute queries without confirmation
492"#;
493
494 std::fs::write(&config_path, default_config)?;
495
496 log::debug!("Created default config.toml");
497 Ok(())
498 }
499
500 pub fn exists(&self) -> bool {
502 self.cache_path.exists() && self.cache_path.join(META_DB).exists()
503 }
504
505 pub fn validate(&self) -> Result<()> {
514 let start = std::time::Instant::now();
515
516 if !self.cache_path.exists() {
518 anyhow::bail!(
519 "Cache directory does not exist: {}",
520 self.cache_path.display()
521 );
522 }
523
524 let db_path = self.cache_path.join(META_DB);
526 if !db_path.exists() {
527 anyhow::bail!("Database file missing: {}", db_path.display());
528 }
529
530 let conn =
532 open_meta_db(&db_path).context("Failed to open meta.db - database may be corrupted")?;
533
534 let tables: Result<Vec<String>, _> = conn
536 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
537 .and_then(|mut stmt| {
538 stmt.query_map([], |row| row.get(0))
539 .map(|rows| rows.collect())
540 })
541 .and_then(|result| result);
542
543 match tables {
544 Ok(table_list) => {
545 let required_tables = vec![
547 "files",
548 "statistics",
549 "config",
550 "file_branches",
551 "branches",
552 "file_dependencies",
553 "file_exports",
554 ];
555 for table in &required_tables {
556 if !table_list.iter().any(|t| t == table) {
557 anyhow::bail!("Required table '{}' missing from database schema", table);
558 }
559 }
560 }
561 Err(e) => {
562 anyhow::bail!("Failed to read database schema: {}", e);
563 }
564 }
565
566 let integrity_result: String =
569 conn.query_row("PRAGMA quick_check", [], |row| row.get(0))?;
570
571 if integrity_result != "ok" {
572 log::warn!("Database integrity check failed: {}", integrity_result);
573 anyhow::bail!(
574 "Database integrity check failed: {}. Cache may be corrupted. \
575 Run 'rfx index' to rebuild cache.",
576 integrity_result
577 );
578 }
579
580 let trigrams_path = self.cache_path.join("trigrams.bin");
582 if trigrams_path.exists() {
583 use std::io::Read;
584
585 match File::open(&trigrams_path) {
586 Ok(mut file) => {
587 let mut header = [0u8; 4];
588 match file.read_exact(&mut header) {
589 Ok(_) => {
590 if &header != b"RFTG" {
592 log::warn!(
593 "trigrams.bin has invalid magic bytes - may be corrupted"
594 );
595 anyhow::bail!(
596 "trigrams.bin appears to be corrupted (invalid magic bytes)"
597 );
598 }
599 }
600 Err(_) => {
601 anyhow::bail!("trigrams.bin is too small - appears to be corrupted");
602 }
603 }
604 }
605 Err(e) => {
606 anyhow::bail!("Failed to open trigrams.bin: {}", e);
607 }
608 }
609 }
610
611 let content_path = self.cache_path.join("content.bin");
613 if content_path.exists() {
614 use std::io::Read;
615
616 match File::open(&content_path) {
617 Ok(mut file) => {
618 let mut header = [0u8; 4];
619 match file.read_exact(&mut header) {
620 Ok(_) => {
621 if &header != b"RFCT" {
623 log::warn!(
624 "content.bin has invalid magic bytes - may be corrupted"
625 );
626 anyhow::bail!(
627 "content.bin appears to be corrupted (invalid magic bytes)"
628 );
629 }
630 }
631 Err(_) => {
632 anyhow::bail!("content.bin is too small - appears to be corrupted");
633 }
634 }
635 }
636 Err(e) => {
637 anyhow::bail!("Failed to open content.bin: {}", e);
638 }
639 }
640 }
641
642 log::debug!("Cache validation passed (took {:?})", start.elapsed());
658 Ok(())
659 }
660
661 pub fn path(&self) -> &Path {
663 &self.cache_path
664 }
665
666 pub fn workspace_root(&self) -> PathBuf {
668 self.cache_path
669 .parent()
670 .expect(".reflex directory should have a parent")
671 .to_path_buf()
672 }
673
674 pub fn load_index_config(&self) -> Result<crate::models::IndexConfig> {
680 use crate::models::{IndexConfig, Language};
681
682 let config_path = self.cache_path.join(CONFIG_TOML);
683 if !config_path.exists() {
684 return Ok(IndexConfig::default());
685 }
686
687 let raw = std::fs::read_to_string(&config_path)
688 .with_context(|| format!("Failed to read {}", config_path.display()))?;
689
690 let toml_val: toml::Value = toml::from_str(&raw)
691 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
692
693 let mut cfg = IndexConfig::default();
694
695 if let Some(index_tbl) = toml_val.get("index") {
696 if let Some(langs) = index_tbl.get("languages").and_then(|v| v.as_array()) {
697 let parsed: Vec<Language> = langs
698 .iter()
699 .filter_map(|v| v.as_str())
700 .filter_map(|s| {
701 Language::from_name(s).or_else(|| {
702 log::warn!(
703 "Unknown language '{}' in config.toml [index] section — ignoring",
704 s
705 );
706 None
707 })
708 })
709 .collect();
710 if !parsed.is_empty() {
711 cfg.languages = parsed;
712 }
713 }
714 if let Some(text_tier) = index_tbl.get("text_tier").and_then(|v| v.as_bool()) {
715 cfg.text_tier = text_tier;
716 }
717 if let Some(mode) = index_tbl.get("mode").and_then(|v| v.as_str()) {
718 match crate::models::IndexMode::from_name(mode) {
719 Some(m) => cfg.mode = m,
720 None => log::warn!(
721 "Unknown [index] mode '{}' in config.toml (expected \"tracked\" or \
722 \"allowlist\") — using \"tracked\"",
723 mode
724 ),
725 }
726 }
727 if let Some(hidden) = index_tbl.get("hidden").and_then(|v| v.as_bool()) {
728 cfg.hidden = hidden;
729 }
730
731 if let Some(max_size) = index_tbl.get("max_file_size").and_then(|v| v.as_integer()) {
732 cfg.max_file_size = max_size as usize;
733 }
734 if let Some(follow) = index_tbl.get("follow_symlinks").and_then(|v| v.as_bool()) {
735 cfg.follow_symlinks = follow;
736 }
737 if let Some(include) = index_tbl
738 .get("include")
739 .and_then(|v| v.get("patterns"))
740 .and_then(|v| v.as_array())
741 {
742 cfg.include_patterns = include
743 .iter()
744 .filter_map(|v| v.as_str().map(String::from))
745 .collect();
746 }
747 if let Some(exclude) = index_tbl
748 .get("exclude")
749 .and_then(|v| v.get("patterns"))
750 .and_then(|v| v.as_array())
751 {
752 cfg.exclude_patterns = exclude
753 .iter()
754 .filter_map(|v| v.as_str().map(String::from))
755 .collect();
756 }
757 }
758
759 if let Some(perf) = toml_val.get("performance")
760 && let Some(threads) = perf.get("parallel_threads").and_then(|v| v.as_integer())
761 {
762 cfg.parallel_threads = threads as usize;
763 }
764 if let Some(perf) = toml_val.get("performance")
765 && let Some(threads) = perf.get("symbol_threads").and_then(|v| v.as_integer())
766 {
767 cfg.symbol_threads = threads.max(0) as usize;
768 }
769
770 log::debug!("Loaded IndexConfig from config.toml: {:?}", cfg);
771 Ok(cfg)
772 }
773
774 pub fn clear(&self) -> Result<()> {
776 log::info!("Clearing cache at {:?}", self.cache_path);
777
778 if !self.cache_path.exists() {
779 return Ok(());
780 }
781
782 crate::query::invalidate_caches(&self.workspace_root());
784
785 let lock =
791 crate::atomic_write::IndexLock::try_acquire(&self.cache_path)?.ok_or_else(|| {
792 crate::errors::ReflexError::IndexLocked(
793 crate::atomic_write::IndexLock::lock_path(&self.cache_path)
794 .display()
795 .to_string(),
796 )
797 })?;
798
799 for entry in std::fs::read_dir(&self.cache_path)? {
800 let entry = entry?;
801 let path = entry.path();
802 if path.file_name().and_then(|n| n.to_str())
803 == Some(crate::atomic_write::INDEX_LOCK_FILE)
804 {
805 continue;
806 }
807 if path.is_dir() {
808 std::fs::remove_dir_all(&path)?;
809 } else {
810 std::fs::remove_file(&path)?;
811 }
812 }
813
814 let lock_path = lock.path().to_path_buf();
815 drop(lock);
816 let _ = std::fs::remove_file(&lock_path);
817 let _ = std::fs::remove_dir(&self.cache_path);
818
819 Ok(())
820 }
821
822 pub fn checkpoint_wal(&self) -> Result<()> {
830 let db_path = self.cache_path.join(META_DB);
831
832 if !db_path.exists() {
833 return Ok(());
835 }
836
837 let conn = open_meta_db(&db_path).context("Failed to open meta.db for WAL checkpoint")?;
838
839 conn.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
843 let busy: i64 = row.get(0)?;
844 let log_pages: i64 = row.get(1)?;
845 let checkpointed: i64 = row.get(2)?;
846 log::debug!(
847 "WAL checkpoint completed: busy={}, log_pages={}, checkpointed_pages={}",
848 busy,
849 log_pages,
850 checkpointed
851 );
852 Ok(())
853 })
854 .context("Failed to execute WAL checkpoint")?;
855
856 log::debug!("Executed WAL checkpoint (TRUNCATE) on meta.db");
857 Ok(())
858 }
859
860 pub fn load_all_file_rows(&self) -> Result<HashMap<String, (i64, String)>> {
869 let db_path = self.cache_path.join(META_DB);
870 if !db_path.exists() {
871 return Ok(HashMap::new());
872 }
873 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
874 let mut stmt = conn.prepare(
875 "SELECT f.path, f.id, fb.hash
876 FROM file_branches fb
877 JOIN files f ON fb.file_id = f.id",
878 )?;
879 let rows: HashMap<String, (i64, String)> = stmt
880 .query_map([], |row| {
881 Ok((
882 row.get(0)?,
883 (row.get::<_, i64>(1)?, row.get::<_, String>(2)?),
884 ))
885 })?
886 .collect::<Result<HashMap<_, _>, _>>()?;
887 Ok(rows)
888 }
889
890 pub fn load_all_hashes(&self) -> Result<HashMap<String, String>> {
891 let db_path = self.cache_path.join(META_DB);
892
893 if !db_path.exists() {
894 return Ok(HashMap::new());
895 }
896
897 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
898
899 let mut stmt = conn.prepare(
903 "SELECT f.path, fb.hash
904 FROM file_branches fb
905 JOIN files f ON fb.file_id = f.id",
906 )?;
907 let hashes: HashMap<String, String> = stmt
908 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
909 .collect::<Result<HashMap<_, _>, _>>()?;
910
911 log::debug!(
912 "Loaded {} file hashes across all branches from SQLite",
913 hashes.len()
914 );
915 Ok(hashes)
916 }
917
918 pub fn load_hashes_for_branch(&self, branch: &str) -> Result<HashMap<String, String>> {
923 let db_path = self.cache_path.join(META_DB);
924
925 if !db_path.exists() {
926 return Ok(HashMap::new());
927 }
928
929 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
930
931 let mut stmt = conn.prepare(
933 "SELECT f.path, fb.hash
934 FROM file_branches fb
935 JOIN files f ON fb.file_id = f.id
936 JOIN branches b ON fb.branch_id = b.id
937 WHERE b.name = ?",
938 )?;
939 let hashes: HashMap<String, String> = stmt
940 .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
941 .collect::<Result<HashMap<_, _>, _>>()?;
942
943 log::debug!(
944 "Loaded {} file hashes for branch '{}' from SQLite",
945 hashes.len(),
946 branch
947 );
948 Ok(hashes)
949 }
950
951 pub fn branch_file_rows_on(
958 conn: &Connection,
959 branch: &str,
960 paths: &[String],
961 ) -> Result<HashMap<String, (i64, String)>> {
962 const BATCH_SIZE: usize = 900;
963 let mut out = HashMap::with_capacity(paths.len());
964 for chunk in paths.chunks(BATCH_SIZE) {
965 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
966 let sql = format!(
967 "SELECT f.path, f.id, fb.hash
968 FROM files f
969 JOIN file_branches fb ON fb.file_id = f.id
970 JOIN branches b ON fb.branch_id = b.id
971 WHERE b.name = ? AND f.path IN ({})",
972 placeholders
973 );
974 let mut stmt = conn.prepare(&sql)?;
975 let params = std::iter::once(branch).chain(chunk.iter().map(String::as_str));
976 let rows = stmt.query_map(rusqlite::params_from_iter(params), |row| {
977 Ok((
978 row.get::<_, String>(0)?,
979 row.get::<_, i64>(1)?,
980 row.get::<_, String>(2)?,
981 ))
982 })?;
983 for row in rows {
984 let (path, id, hash) = row?;
985 out.insert(path, (id, hash));
986 }
987 }
988 Ok(out)
989 }
990
991 #[deprecated(note = "Hashes are now stored in file_branches table via record_branch_file()")]
996 pub fn save_hashes(&self, _hashes: &HashMap<String, String>) -> Result<()> {
997 Ok(())
999 }
1000
1001 pub fn update_file(&self, path: &str, language: &str, line_count: usize) -> Result<()> {
1006 let db_path = self.cache_path.join(META_DB);
1007 let conn = open_meta_db(&db_path).context("Failed to open meta.db for file update")?;
1008
1009 let now = chrono::Utc::now().timestamp();
1010
1011 conn.execute(
1012 "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
1013 VALUES (?, ?, ?, ?)",
1014 [path, &now.to_string(), language, &line_count.to_string()],
1015 )?;
1016
1017 Ok(())
1018 }
1019
1020 pub fn batch_update_files(&self, files: &[(String, String, usize)]) -> Result<()> {
1025 let db_path = self.cache_path.join(META_DB);
1026 let mut conn = open_meta_db(&db_path).context("Failed to open meta.db for batch update")?;
1027
1028 let now = chrono::Utc::now().timestamp();
1029 let now_str = now.to_string();
1030
1031 let tx = conn.transaction()?;
1033
1034 for (path, language, line_count) in files {
1035 tx.execute(
1036 "INSERT OR REPLACE INTO files (path, last_indexed, language, line_count)
1037 VALUES (?, ?, ?, ?)",
1038 [
1039 path.as_str(),
1040 &now_str,
1041 language.as_str(),
1042 &line_count.to_string(),
1043 ],
1044 )?;
1045 }
1046
1047 tx.commit()?;
1048 Ok(())
1049 }
1050
1051 pub fn batch_update_files_and_branch(
1056 &self,
1057 files: &[FileRow],
1058 branch: &str,
1059 commit_sha: Option<&str>,
1060 ) -> Result<()> {
1061 log::info!(
1062 "batch_update_files_and_branch: Processing {} files for branch '{}'",
1063 files.len(),
1064 branch
1065 );
1066
1067 let db_path = self.cache_path.join(META_DB);
1068 let mut conn = open_meta_db(&db_path)
1069 .context("Failed to open meta.db for batch update and branch recording")?;
1070
1071 let now = chrono::Utc::now().timestamp();
1072
1073 let tx = conn.transaction()?;
1075
1076 {
1078 let mut stmt = tx.prepare(
1079 "INSERT OR REPLACE INTO files
1080 (path, last_indexed, language, line_count, size, mtime_ns, hash, dirty_at_index)
1081 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1082 )?;
1083 for row in files {
1084 stmt.execute(rusqlite::params![
1085 row.path,
1086 now,
1087 row.language,
1088 row.line_count as i64,
1089 row.size as i64,
1090 row.mtime_ns,
1091 row.hash,
1092 row.dirty as i64,
1093 ])?;
1094 }
1095 }
1096 log::info!("Inserted {} files into files table", files.len());
1097
1098 let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
1100 log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
1101
1102 let mut inserted = 0;
1104 for row in files {
1105 let file_id: i64 = tx
1107 .query_row(
1108 "SELECT id FROM files WHERE path = ?",
1109 [row.path.as_str()],
1110 |r| r.get(0),
1111 )
1112 .context(format!(
1113 "File not found in index after insert: {}",
1114 row.path
1115 ))?;
1116
1117 tx.execute(
1119 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1120 VALUES (?, ?, ?, ?)",
1121 rusqlite::params![file_id, branch_id, row.hash.as_str(), now],
1122 )?;
1123 inserted += 1;
1124 }
1125 log::info!("Inserted {} file_branches entries", inserted);
1126
1127 let pruned = {
1139 tx.execute_batch(
1140 "CREATE TEMP TABLE IF NOT EXISTS current_paths (path TEXT PRIMARY KEY);
1141 DELETE FROM current_paths;",
1142 )?;
1143 {
1144 let mut stmt =
1145 tx.prepare("INSERT OR IGNORE INTO current_paths (path) VALUES (?)")?;
1146 for row in files {
1147 stmt.execute([row.path.as_str()])?;
1148 }
1149 }
1150
1151 let unlinked = tx.execute(
1153 "DELETE FROM file_branches
1154 WHERE branch_id = ?
1155 AND file_id NOT IN (SELECT id FROM files WHERE path IN (SELECT path FROM current_paths))",
1156 rusqlite::params![branch_id],
1157 )?;
1158
1159 let orphaned = tx.execute(
1162 "DELETE FROM files WHERE id NOT IN (SELECT file_id FROM file_branches)",
1163 [],
1164 )?;
1165
1166 tx.execute_batch("DROP TABLE IF EXISTS current_paths;")?;
1167 (unlinked, orphaned)
1168 };
1169 if pruned.0 > 0 || pruned.1 > 0 {
1170 log::info!(
1171 "Pruned {} stale file_branches rows and {} orphaned files rows",
1172 pruned.0,
1173 pruned.1
1174 );
1175 }
1176
1177 tx.commit()?;
1179 log::info!("Transaction committed successfully (files + file_branches)");
1180
1181 let verify_conn =
1184 open_meta_db(&db_path).context("Failed to open meta.db for verification")?;
1185
1186 let actual_file_count: i64 = verify_conn.query_row(
1188 "SELECT COUNT(*) FROM files WHERE path IN (SELECT path FROM files ORDER BY id DESC LIMIT ?)",
1189 [files.len()],
1190 |row| row.get(0)
1191 ).unwrap_or(0);
1192
1193 let actual_fb_count: i64 = verify_conn
1195 .query_row(
1196 "SELECT COUNT(*) FROM file_branches fb
1197 JOIN branches b ON fb.branch_id = b.id
1198 WHERE b.name = ?",
1199 [branch],
1200 |row| row.get(0),
1201 )
1202 .unwrap_or(0);
1203
1204 log::info!(
1205 "Post-commit verification: {} files in files table (expected {}), {} file_branches entries for '{}' (expected {})",
1206 actual_file_count,
1207 files.len(),
1208 actual_fb_count,
1209 branch,
1210 inserted
1211 );
1212
1213 if actual_file_count < files.len() as i64 {
1215 log::warn!(
1216 "MISMATCH: Expected {} files in database, but only found {}! Data may not have persisted.",
1217 files.len(),
1218 actual_file_count
1219 );
1220 }
1221 if actual_fb_count < inserted as i64 {
1222 log::warn!(
1223 "MISMATCH: Expected {} file_branches entries for branch '{}', but only found {}! Data may not have persisted.",
1224 inserted,
1225 branch,
1226 actual_fb_count
1227 );
1228 }
1229
1230 Ok(())
1231 }
1232
1233 pub fn update_stats(&self, branch: &str) -> Result<()> {
1237 let db_path = self.cache_path.join(META_DB);
1238 let conn = open_meta_db(&db_path).context("Failed to open meta.db for stats update")?;
1239
1240 let total_files: usize = conn
1242 .query_row(
1243 "SELECT COUNT(DISTINCT fb.file_id)
1244 FROM file_branches fb
1245 JOIN branches b ON fb.branch_id = b.id
1246 WHERE b.name = ?",
1247 [branch],
1248 |row| row.get(0),
1249 )
1250 .unwrap_or(0);
1251
1252 let now = chrono::Utc::now().timestamp();
1253
1254 conn.execute(
1255 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1256 ["total_files", &total_files.to_string(), &now.to_string()],
1257 )?;
1258
1259 log::debug!(
1260 "Updated statistics for branch '{}': {} files",
1261 branch,
1262 total_files
1263 );
1264 Ok(())
1265 }
1266
1267 pub fn check_schema_hash(&self) -> Result<bool> {
1270 let db_path = self.cache_path.join(META_DB);
1271 if !db_path.exists() {
1272 return Ok(false);
1273 }
1274 let conn = open_meta_db(&db_path)?;
1275 Self::check_schema_hash_on(&conn)
1276 }
1277
1278 fn check_schema_hash_on(conn: &Connection) -> Result<bool> {
1279 let current = env!("CACHE_SCHEMA_HASH");
1280 let stored: Option<String> = conn
1281 .query_row(
1282 "SELECT value FROM statistics WHERE key = 'schema_hash'",
1283 [],
1284 |row| row.get(0),
1285 )
1286 .optional()?;
1287 Ok(stored.as_deref() == Some(current))
1288 }
1289
1290 pub fn status_reads(&self, branch: Option<&str>) -> Result<StatusReads> {
1296 let db_path = self.cache_path.join(META_DB);
1297 if !db_path.exists() {
1298 return Ok(StatusReads {
1299 schema_ok: false,
1300 owner: None,
1301 branch_indexed: false,
1302 branch_info: None,
1303 dirty_at_index: Vec::new(),
1304 });
1305 }
1306 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1307
1308 let schema_ok = Self::check_schema_hash_on(&conn).unwrap_or(true);
1309 if !schema_ok {
1310 return Ok(StatusReads {
1311 schema_ok,
1312 owner: Self::cache_owner_on(&conn),
1313 branch_indexed: false,
1314 branch_info: None,
1315 dirty_at_index: Vec::new(),
1316 });
1317 }
1318
1319 let Some(branch) = branch else {
1320 return Ok(StatusReads {
1321 schema_ok,
1322 owner: None,
1323 branch_indexed: false,
1324 branch_info: None,
1325 dirty_at_index: Vec::new(),
1326 });
1327 };
1328
1329 let branch_indexed = Self::branch_exists_on(&conn, branch);
1330 let branch_info = if branch_indexed {
1335 Self::get_branch_info_on(&conn, branch).ok()
1336 } else {
1337 Self::latest_branch_info_on(&conn).ok()
1338 };
1339
1340 Ok(StatusReads {
1341 schema_ok,
1342 owner: None,
1343 branch_indexed,
1344 branch_info,
1345 dirty_at_index: Self::dirty_at_index_on(&conn).unwrap_or_default(),
1346 })
1347 }
1348
1349 fn dirty_at_index_on(conn: &Connection) -> Result<Vec<String>> {
1351 let mut stmt = conn.prepare("SELECT path FROM files WHERE dirty_at_index = 1")?;
1352 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
1353 Ok(rows.collect::<Result<Vec<_>, _>>()?)
1354 }
1355
1356 pub fn cache_owner(&self) -> Option<(String, Option<String>)> {
1360 let db_path = self.cache_path.join(META_DB);
1361 if !db_path.exists() {
1362 return None;
1363 }
1364 let conn = open_meta_db(&db_path).ok()?;
1365 Self::cache_owner_on(&conn)
1366 }
1367
1368 fn cache_owner_on(conn: &Connection) -> Option<(String, Option<String>)> {
1369 let get = |key: &str| -> Option<String> {
1370 conn.query_row("SELECT value FROM statistics WHERE key = ?", [key], |row| {
1371 row.get(0)
1372 })
1373 .optional()
1374 .ok()
1375 .flatten()
1376 };
1377 get("writer_version").map(|v| (v, get("writer_git_sha")))
1378 }
1379
1380 pub fn assert_writable(&self, force: bool) -> Result<()> {
1402 if force || std::env::var("REFLEX_ALLOW_SCHEMA_REBUILD").is_ok() {
1403 return Ok(());
1404 }
1405
1406 if !self.cache_path.join(META_DB).exists() {
1407 return Ok(());
1408 }
1409
1410 let Some((owner_version, owner_sha)) = self.cache_owner() else {
1411 return Ok(());
1413 };
1414
1415 if owner_version == env!("CARGO_PKG_VERSION") {
1416 return Ok(());
1417 }
1418
1419 Err(crate::errors::ReflexError::CacheVersionMismatch {
1420 owner_version,
1421 owner_sha: owner_sha
1422 .map(|s| format!(" (sha {})", &s[..s.len().min(7)]))
1423 .unwrap_or_default(),
1424 this_version: env!("CARGO_PKG_VERSION").to_string(),
1425 }
1426 .into())
1427 }
1428
1429 pub fn update_schema_hash(&self) -> Result<()> {
1434 let db_path = self.cache_path.join(META_DB);
1435 let conn =
1436 open_meta_db(&db_path).context("Failed to open meta.db for schema hash update")?;
1437
1438 let schema_hash = env!("CACHE_SCHEMA_HASH");
1439 let now = chrono::Utc::now().timestamp();
1440
1441 conn.execute(
1442 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1443 ["schema_hash", schema_hash, &now.to_string()],
1444 )?;
1445 conn.execute(
1447 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1448 [
1449 "writer_version",
1450 env!("CARGO_PKG_VERSION"),
1451 &now.to_string(),
1452 ],
1453 )?;
1454 if let Some(sha) = option_env!("REFLEX_GIT_SHA") {
1455 conn.execute(
1456 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
1457 ["writer_git_sha", sha, &now.to_string()],
1458 )?;
1459 }
1460
1461 log::debug!("Updated schema hash to: {}", schema_hash);
1462 Ok(())
1463 }
1464
1465 pub fn list_files(&self) -> Result<Vec<IndexedFile>> {
1467 let db_path = self.cache_path.join(META_DB);
1468
1469 if !db_path.exists() {
1470 return Ok(Vec::new());
1471 }
1472
1473 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1474
1475 let mut stmt =
1476 conn.prepare("SELECT path, language, last_indexed FROM files ORDER BY path")?;
1477
1478 let files = stmt
1479 .query_map([], |row| {
1480 let path: String = row.get(0)?;
1481 let language: String = row.get(1)?;
1482 let last_indexed: i64 = row.get(2)?;
1483
1484 Ok(IndexedFile {
1485 path,
1486 language,
1487 last_indexed: chrono::DateTime::from_timestamp(last_indexed, 0)
1488 .unwrap_or_else(chrono::Utc::now)
1489 .to_rfc3339(),
1490 })
1491 })?
1492 .collect::<Result<Vec<_>, _>>()?;
1493
1494 Ok(files)
1495 }
1496
1497 pub fn stats(&self) -> Result<crate::models::IndexStats> {
1502 let db_path = self.cache_path.join(META_DB);
1503
1504 if !db_path.exists() {
1505 return Ok(crate::models::IndexStats {
1507 total_files: 0,
1508 index_size_bytes: 0,
1509 last_updated: chrono::Utc::now().to_rfc3339(),
1510 files_by_language: std::collections::HashMap::new(),
1511 lines_by_language: std::collections::HashMap::new(),
1512 ..Default::default()
1513 });
1514 }
1515
1516 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1517
1518 let workspace_root = self.workspace_root();
1520 let current_branch = if crate::git::is_git_repo(&workspace_root) {
1521 crate::git::get_git_state(&workspace_root)
1522 .ok()
1523 .map(|state| state.branch)
1524 } else {
1525 Some("_default".to_string())
1526 };
1527
1528 log::debug!("stats(): current_branch = {:?}", current_branch);
1529
1530 let total_files: usize = if let Some(ref branch) = current_branch {
1532 log::debug!("stats(): Counting files for branch '{}'", branch);
1533
1534 let branches: Vec<(i64, String, i64)> = conn
1536 .prepare("SELECT id, name, file_count FROM branches")
1537 .and_then(|mut stmt| {
1538 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
1539 .map(|rows| rows.collect())
1540 })
1541 .and_then(|result| result)
1542 .unwrap_or_default();
1543
1544 for (id, name, count) in &branches {
1545 log::debug!(
1546 "stats(): Branch ID={}, Name='{}', FileCount={}",
1547 id,
1548 name,
1549 count
1550 );
1551 }
1552
1553 let fb_counts: Vec<(String, i64)> = conn
1555 .prepare(
1556 "SELECT b.name, COUNT(*) FROM file_branches fb
1557 JOIN branches b ON fb.branch_id = b.id
1558 GROUP BY b.name",
1559 )
1560 .and_then(|mut stmt| {
1561 stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
1562 .map(|rows| rows.collect())
1563 })
1564 .and_then(|result| result)
1565 .unwrap_or_default();
1566
1567 for (name, count) in &fb_counts {
1568 log::debug!(
1569 "stats(): file_branches count for branch '{}': {}",
1570 name,
1571 count
1572 );
1573 }
1574
1575 let count: usize = conn
1577 .query_row(
1578 "SELECT COUNT(DISTINCT fb.file_id)
1579 FROM file_branches fb
1580 JOIN branches b ON fb.branch_id = b.id
1581 WHERE b.name = ?",
1582 [branch],
1583 |row| row.get(0),
1584 )
1585 .unwrap_or(0);
1586
1587 log::debug!("stats(): Query returned total_files = {}", count);
1588 count
1589 } else {
1590 log::warn!("stats(): No current_branch detected!");
1592 0
1593 };
1594
1595 let last_updated: String = conn
1597 .query_row(
1598 "SELECT updated_at FROM statistics WHERE key = 'total_files'",
1599 [],
1600 |row| {
1601 let timestamp: i64 = row.get(0)?;
1602 Ok(chrono::DateTime::from_timestamp(timestamp, 0)
1603 .unwrap_or_else(chrono::Utc::now)
1604 .to_rfc3339())
1605 },
1606 )
1607 .unwrap_or_else(|_| chrono::Utc::now().to_rfc3339());
1608
1609 let mut index_size_bytes: u64 = 0;
1611 let mut trigram_index_bytes: u64 = 0;
1612
1613 for file_name in [
1614 META_DB,
1615 TOKENS_BIN,
1616 CONFIG_TOML,
1617 "content.bin",
1618 "trigrams.bin",
1619 ] {
1620 let file_path = self.cache_path.join(file_name);
1621 if let Ok(metadata) = std::fs::metadata(&file_path) {
1622 index_size_bytes += metadata.len();
1623 if file_name == "trigrams.bin" {
1624 trigram_index_bytes = metadata.len();
1625 }
1626 }
1627 }
1628
1629 let corpus_bytes: u64 = {
1633 use std::io::Read;
1634 std::fs::File::open(self.cache_path.join("content.bin"))
1635 .ok()
1636 .and_then(|mut f| {
1637 let mut header = [0u8; 32];
1638 f.read_exact(&mut header).ok()?;
1639 if &header[..4] != b"RFCT" {
1640 return None;
1641 }
1642 let index_offset = u64::from_le_bytes(header[16..24].try_into().ok()?);
1643 Some(index_offset.saturating_sub(32))
1644 })
1645 .unwrap_or(0)
1646 };
1647
1648 let mut files_by_language = std::collections::HashMap::new();
1650 if let Some(ref branch) = current_branch {
1651 let mut stmt = conn.prepare(
1653 "SELECT f.language, COUNT(DISTINCT f.id)
1654 FROM files f
1655 JOIN file_branches fb ON f.id = fb.file_id
1656 JOIN branches b ON fb.branch_id = b.id
1657 WHERE b.name = ?
1658 GROUP BY f.language",
1659 )?;
1660 let lang_counts = stmt.query_map([branch], |row| {
1661 let language: String = row.get(0)?;
1662 let count: i64 = row.get(1)?;
1663 Ok((language, count as usize))
1664 })?;
1665
1666 for result in lang_counts {
1667 let (language, count) = result?;
1668 files_by_language.insert(language, count);
1669 }
1670 } else {
1671 let mut stmt =
1673 conn.prepare("SELECT language, COUNT(*) FROM files GROUP BY language")?;
1674 let lang_counts = stmt.query_map([], |row| {
1675 let language: String = row.get(0)?;
1676 let count: i64 = row.get(1)?;
1677 Ok((language, count as usize))
1678 })?;
1679
1680 for result in lang_counts {
1681 let (language, count) = result?;
1682 files_by_language.insert(language, count);
1683 }
1684 }
1685
1686 let mut lines_by_language = std::collections::HashMap::new();
1688 if let Some(ref branch) = current_branch {
1689 let mut stmt = conn.prepare(
1691 "SELECT f.language, SUM(f.line_count)
1692 FROM files f
1693 JOIN file_branches fb ON f.id = fb.file_id
1694 JOIN branches b ON fb.branch_id = b.id
1695 WHERE b.name = ?
1696 GROUP BY f.language",
1697 )?;
1698 let line_counts = stmt.query_map([branch], |row| {
1699 let language: String = row.get(0)?;
1700 let count: i64 = row.get(1)?;
1701 Ok((language, count as usize))
1702 })?;
1703
1704 for result in line_counts {
1705 let (language, count) = result?;
1706 lines_by_language.insert(language, count);
1707 }
1708 } else {
1709 let mut stmt =
1711 conn.prepare("SELECT language, SUM(line_count) FROM files GROUP BY language")?;
1712 let line_counts = stmt.query_map([], |row| {
1713 let language: String = row.get(0)?;
1714 let count: i64 = row.get(1)?;
1715 Ok((language, count as usize))
1716 })?;
1717
1718 for result in line_counts {
1719 let (language, count) = result?;
1720 lines_by_language.insert(language, count);
1721 }
1722 }
1723
1724 Ok(crate::models::IndexStats {
1725 total_files,
1726 index_size_bytes,
1727 last_updated,
1728 files_by_language,
1729 lines_by_language,
1730 corpus_bytes,
1731 trigram_index_bytes,
1732 ..Default::default()
1733 })
1734 }
1735
1736 fn get_or_create_branch_id(
1742 &self,
1743 conn: &Connection,
1744 branch_name: &str,
1745 commit_sha: Option<&str>,
1746 ) -> Result<i64> {
1747 let existing_id: Option<i64> = conn
1749 .query_row(
1750 "SELECT id FROM branches WHERE name = ?",
1751 [branch_name],
1752 |row| row.get(0),
1753 )
1754 .optional()?;
1755
1756 if let Some(id) = existing_id {
1757 return Ok(id);
1758 }
1759
1760 let now = chrono::Utc::now().timestamp();
1762 conn.execute(
1763 "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
1764 VALUES (?, ?, ?, 0, 0)",
1765 [
1766 branch_name,
1767 commit_sha.unwrap_or("unknown"),
1768 &now.to_string(),
1769 ],
1770 )?;
1771
1772 let id: i64 = conn.last_insert_rowid();
1774 Ok(id)
1775 }
1776
1777 pub fn record_branch_file(
1779 &self,
1780 path: &str,
1781 branch: &str,
1782 hash: &str,
1783 commit_sha: Option<&str>,
1784 ) -> Result<()> {
1785 let db_path = self.cache_path.join(META_DB);
1786 let conn =
1787 open_meta_db(&db_path).context("Failed to open meta.db for branch file recording")?;
1788
1789 let file_id: i64 = conn
1791 .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
1792 row.get(0)
1793 })
1794 .context(format!("File not found in index: {}", path))?;
1795
1796 let branch_id = self.get_or_create_branch_id(&conn, branch, commit_sha)?;
1798
1799 let now = chrono::Utc::now().timestamp();
1800
1801 conn.execute(
1803 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1804 VALUES (?, ?, ?, ?)",
1805 rusqlite::params![file_id, branch_id, hash, now],
1806 )?;
1807
1808 Ok(())
1809 }
1810
1811 pub fn batch_record_branch_files(
1816 &self,
1817 files: &[(String, String)], branch: &str,
1819 commit_sha: Option<&str>,
1820 ) -> Result<()> {
1821 log::info!(
1822 "batch_record_branch_files: Processing {} files for branch '{}'",
1823 files.len(),
1824 branch
1825 );
1826
1827 let db_path = self.cache_path.join(META_DB);
1828 let mut conn =
1829 open_meta_db(&db_path).context("Failed to open meta.db for batch branch recording")?;
1830
1831 let now = chrono::Utc::now().timestamp();
1832
1833 let tx = conn.transaction()?;
1835
1836 let branch_id = self.get_or_create_branch_id(&tx, branch, commit_sha)?;
1838 log::debug!("Got branch_id={} for branch '{}'", branch_id, branch);
1839
1840 let mut inserted = 0;
1841 for (path, hash) in files {
1842 log::trace!("Looking up file_id for path: {}", path);
1844 let file_id: i64 = tx
1845 .query_row(
1846 "SELECT id FROM files WHERE path = ?",
1847 [path.as_str()],
1848 |row| row.get(0),
1849 )
1850 .context(format!("File not found in index: {}", path))?;
1851 log::trace!("Found file_id={} for path: {}", file_id, path);
1852
1853 tx.execute(
1855 "INSERT OR REPLACE INTO file_branches (file_id, branch_id, hash, last_indexed)
1856 VALUES (?, ?, ?, ?)",
1857 rusqlite::params![file_id, branch_id, hash.as_str(), now],
1858 )?;
1859 inserted += 1;
1860 }
1861
1862 log::info!("Inserted {} file_branches entries", inserted);
1863 tx.commit()?;
1864 log::info!("Transaction committed successfully");
1865 Ok(())
1866 }
1867
1868 pub fn get_branch_files(&self, branch: &str) -> Result<HashMap<String, String>> {
1872 let db_path = self.cache_path.join(META_DB);
1873
1874 if !db_path.exists() {
1875 return Ok(HashMap::new());
1876 }
1877
1878 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1879
1880 let mut stmt = conn.prepare(
1881 "SELECT f.path, fb.hash
1882 FROM file_branches fb
1883 JOIN files f ON fb.file_id = f.id
1884 JOIN branches b ON fb.branch_id = b.id
1885 WHERE b.name = ?",
1886 )?;
1887 let files: HashMap<String, String> = stmt
1888 .query_map([branch], |row| Ok((row.get(0)?, row.get(1)?)))?
1889 .collect::<Result<HashMap<_, _>, _>>()?;
1890
1891 log::debug!(
1892 "Loaded {} files for branch '{}' from file_branches table",
1893 files.len(),
1894 branch
1895 );
1896 Ok(files)
1897 }
1898
1899 pub fn branch_exists(&self, branch: &str) -> Result<bool> {
1903 let db_path = self.cache_path.join(META_DB);
1904
1905 if !db_path.exists() {
1906 return Ok(false);
1907 }
1908
1909 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1910 Ok(Self::branch_exists_on(&conn, branch))
1911 }
1912
1913 fn branch_exists_on(conn: &Connection, branch: &str) -> bool {
1914 let count: i64 = conn
1915 .query_row(
1916 "SELECT COUNT(*)
1917 FROM file_branches fb
1918 JOIN branches b ON fb.branch_id = b.id
1919 WHERE b.name = ?
1920 LIMIT 1",
1921 [branch],
1922 |row| row.get(0),
1923 )
1924 .unwrap_or(0);
1925 count > 0
1926 }
1927
1928 pub fn get_branch_info(&self, branch: &str) -> Result<BranchInfo> {
1930 let db_path = self.cache_path.join(META_DB);
1931
1932 if !db_path.exists() {
1933 anyhow::bail!("Database not initialized");
1934 }
1935
1936 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
1937 Self::get_branch_info_on(&conn, branch)
1938 }
1939
1940 fn latest_branch_info_on(conn: &Connection) -> Result<BranchInfo> {
1942 let info = conn.query_row(
1943 "SELECT name, commit_sha, last_indexed, file_count, is_dirty FROM branches
1944 ORDER BY last_indexed DESC LIMIT 1",
1945 [],
1946 |row| {
1947 Ok(BranchInfo {
1948 branch: row.get(0)?,
1949 commit_sha: row.get(1)?,
1950 last_indexed: row.get(2)?,
1951 file_count: row.get(3)?,
1952 is_dirty: row.get::<_, i64>(4)? != 0,
1953 })
1954 },
1955 )?;
1956 Ok(info)
1957 }
1958
1959 fn get_branch_info_on(conn: &Connection, branch: &str) -> Result<BranchInfo> {
1960 let info = conn.query_row(
1961 "SELECT commit_sha, last_indexed, file_count, is_dirty FROM branches WHERE name = ?",
1962 [branch],
1963 |row| {
1964 Ok(BranchInfo {
1965 branch: branch.to_string(),
1966 commit_sha: row.get(0)?,
1967 last_indexed: row.get(1)?,
1968 file_count: row.get(2)?,
1969 is_dirty: row.get::<_, i64>(3)? != 0,
1970 })
1971 },
1972 )?;
1973
1974 Ok(info)
1975 }
1976
1977 pub fn update_branch_metadata(
1982 &self,
1983 branch: &str,
1984 commit_sha: Option<&str>,
1985 file_count: usize,
1986 is_dirty: bool,
1987 ) -> Result<()> {
1988 let db_path = self.cache_path.join(META_DB);
1989 let conn =
1990 open_meta_db(&db_path).context("Failed to open meta.db for branch metadata update")?;
1991
1992 let now = chrono::Utc::now().timestamp();
1993 let is_dirty_int = if is_dirty { 1 } else { 0 };
1994
1995 let rows_updated = conn.execute(
1997 "UPDATE branches
1998 SET commit_sha = ?, last_indexed = ?, file_count = ?, is_dirty = ?
1999 WHERE name = ?",
2000 rusqlite::params![
2001 commit_sha.unwrap_or("unknown"),
2002 now,
2003 file_count,
2004 is_dirty_int,
2005 branch
2006 ],
2007 )?;
2008
2009 if rows_updated == 0 {
2011 conn.execute(
2012 "INSERT INTO branches (name, commit_sha, last_indexed, file_count, is_dirty)
2013 VALUES (?, ?, ?, ?, ?)",
2014 rusqlite::params![
2015 branch,
2016 commit_sha.unwrap_or("unknown"),
2017 now,
2018 file_count,
2019 is_dirty_int
2020 ],
2021 )?;
2022 }
2023
2024 log::debug!(
2025 "Updated branch metadata for '{}': commit={}, files={}, dirty={}",
2026 branch,
2027 commit_sha.unwrap_or("unknown"),
2028 file_count,
2029 is_dirty
2030 );
2031 Ok(())
2032 }
2033
2034 pub fn find_file_with_hash(&self, hash: &str) -> Result<Option<(String, String)>> {
2039 let db_path = self.cache_path.join(META_DB);
2040
2041 if !db_path.exists() {
2042 return Ok(None);
2043 }
2044
2045 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
2046
2047 let result = conn
2048 .query_row(
2049 "SELECT f.path, b.name
2050 FROM file_branches fb
2051 JOIN files f ON fb.file_id = f.id
2052 JOIN branches b ON fb.branch_id = b.id
2053 WHERE fb.hash = ?
2054 LIMIT 1",
2055 [hash],
2056 |row| Ok((row.get(0)?, row.get(1)?)),
2057 )
2058 .optional()?;
2059
2060 Ok(result)
2061 }
2062
2063 pub fn get_file_id(&self, path: &str) -> Result<Option<i64>> {
2067 let db_path = self.cache_path.join(META_DB);
2068
2069 if !db_path.exists() {
2070 return Ok(None);
2071 }
2072
2073 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
2074
2075 let result = conn
2076 .query_row("SELECT id FROM files WHERE path = ?", [path], |row| {
2077 row.get(0)
2078 })
2079 .optional()?;
2080
2081 Ok(result)
2082 }
2083
2084 pub fn batch_get_file_ids(&self, paths: &[String]) -> Result<HashMap<String, i64>> {
2091 let db_path = self.cache_path.join(META_DB);
2092
2093 if !db_path.exists() {
2094 return Ok(HashMap::new());
2095 }
2096
2097 let conn = open_meta_db(&db_path).context("Failed to open meta.db")?;
2098
2099 const BATCH_SIZE: usize = 900;
2102
2103 let mut results = HashMap::new();
2104
2105 for chunk in paths.chunks(BATCH_SIZE) {
2106 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2108
2109 let query = format!(
2110 "SELECT path, id FROM files WHERE path IN ({})",
2111 placeholders
2112 );
2113
2114 let params: Vec<&str> = chunk.iter().map(|s| s.as_str()).collect();
2115 let mut stmt = conn.prepare(&query)?;
2116
2117 let chunk_results = stmt
2118 .query_map(rusqlite::params_from_iter(params), |row| {
2119 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2120 })?
2121 .collect::<Result<HashMap<_, _>, _>>()?;
2122
2123 results.extend(chunk_results);
2124 }
2125
2126 log::debug!(
2127 "Batch loaded {} file IDs (out of {} requested, {} chunks)",
2128 results.len(),
2129 paths.len(),
2130 paths.len().div_ceil(BATCH_SIZE)
2131 );
2132 Ok(results)
2133 }
2134
2135 pub fn should_compact(&self) -> Result<bool> {
2142 let db_path = self.cache_path.join(META_DB);
2143
2144 if !db_path.exists() {
2145 return Ok(false);
2147 }
2148
2149 let conn = open_meta_db(&db_path).context("Failed to open meta.db for compaction check")?;
2150
2151 let last_compaction: i64 = conn
2153 .query_row(
2154 "SELECT value FROM statistics WHERE key = 'last_compaction'",
2155 [],
2156 |row| {
2157 let value: String = row.get(0)?;
2158 Ok(value.parse::<i64>().unwrap_or(0))
2159 },
2160 )
2161 .unwrap_or(0);
2162
2163 let now = chrono::Utc::now().timestamp();
2165
2166 const COMPACTION_THRESHOLD_SECS: i64 = 86400;
2168
2169 let elapsed_secs = now - last_compaction;
2170 let should_run = elapsed_secs >= COMPACTION_THRESHOLD_SECS;
2171
2172 log::debug!(
2173 "Compaction check: last={}, now={}, elapsed={}s, should_compact={}",
2174 last_compaction,
2175 now,
2176 elapsed_secs,
2177 should_run
2178 );
2179
2180 Ok(should_run)
2181 }
2182
2183 pub fn update_compaction_timestamp(&self) -> Result<()> {
2187 let db_path = self.cache_path.join(META_DB);
2188 let conn = open_meta_db(&db_path)
2189 .context("Failed to open meta.db for compaction timestamp update")?;
2190
2191 let now = chrono::Utc::now().timestamp();
2192
2193 conn.execute(
2194 "INSERT OR REPLACE INTO statistics (key, value, updated_at) VALUES (?, ?, ?)",
2195 ["last_compaction", &now.to_string(), &now.to_string()],
2196 )?;
2197
2198 log::debug!("Updated last_compaction timestamp to: {}", now);
2199 Ok(())
2200 }
2201
2202 pub fn compact(&self) -> Result<crate::models::CompactionReport> {
2213 let start_time = std::time::Instant::now();
2214 log::info!("Starting cache compaction...");
2215
2216 let size_before = self.calculate_cache_size()?;
2218
2219 let deleted_files = self.identify_deleted_files()?;
2221 log::info!(
2222 "Found {} deleted files to remove from cache",
2223 deleted_files.len()
2224 );
2225
2226 if deleted_files.is_empty() {
2227 log::info!("No deleted files to compact - cache is clean");
2228 self.update_compaction_timestamp()?;
2230
2231 return Ok(crate::models::CompactionReport {
2232 files_removed: 0,
2233 space_saved_bytes: 0,
2234 duration_ms: start_time.elapsed().as_millis() as u64,
2235 });
2236 }
2237
2238 self.delete_files_from_db(&deleted_files)?;
2240 log::info!("Deleted {} files from database", deleted_files.len());
2241
2242 self.vacuum_database()?;
2244 log::info!("Completed VACUUM operation");
2245
2246 let size_after = self.calculate_cache_size()?;
2248 let space_saved = size_before.saturating_sub(size_after);
2249
2250 self.update_compaction_timestamp()?;
2252
2253 let duration_ms = start_time.elapsed().as_millis() as u64;
2254
2255 log::info!(
2256 "Cache compaction completed: {} files removed, {} bytes saved ({:.2} MB), took {}ms",
2257 deleted_files.len(),
2258 space_saved,
2259 space_saved as f64 / 1_048_576.0,
2260 duration_ms
2261 );
2262
2263 Ok(crate::models::CompactionReport {
2264 files_removed: deleted_files.len(),
2265 space_saved_bytes: space_saved,
2266 duration_ms,
2267 })
2268 }
2269
2270 pub(crate) fn identify_deleted_files(&self) -> Result<Vec<i64>> {
2274 let db_path = self.cache_path.join(META_DB);
2275 let conn = open_meta_db(&db_path)
2276 .context("Failed to open meta.db for deleted file identification")?;
2277
2278 let workspace_root = self.workspace_root();
2279
2280 let mut stmt = conn.prepare("SELECT id, path FROM files")?;
2282 let files = stmt
2283 .query_map([], |row| {
2284 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
2285 })?
2286 .collect::<Result<Vec<_>, _>>()?;
2287
2288 log::debug!("Checking {} files for deletion status", files.len());
2289
2290 let mut deleted_file_ids = Vec::new();
2292 for (file_id, file_path) in files {
2293 let full_path = workspace_root.join(&file_path);
2294 if !full_path.exists() {
2295 log::trace!("File no longer exists: {} (id={})", file_path, file_id);
2296 deleted_file_ids.push(file_id);
2297 }
2298 }
2299
2300 Ok(deleted_file_ids)
2301 }
2302
2303 pub(crate) fn delete_files_from_db(&self, file_ids: &[i64]) -> Result<()> {
2310 if file_ids.is_empty() {
2311 return Ok(());
2312 }
2313
2314 let db_path = self.cache_path.join(META_DB);
2315 let mut conn =
2316 open_meta_db(&db_path).context("Failed to open meta.db for file deletion")?;
2317
2318 let tx = conn.transaction()?;
2319
2320 const BATCH_SIZE: usize = 900;
2322
2323 for chunk in file_ids.chunks(BATCH_SIZE) {
2324 let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
2325
2326 let delete_query = format!("DELETE FROM files WHERE id IN ({})", placeholders);
2327
2328 let params: Vec<i64> = chunk.to_vec();
2329 tx.execute(&delete_query, rusqlite::params_from_iter(params))?;
2330 }
2331
2332 tx.commit()?;
2333 log::debug!(
2334 "Deleted {} files from database (CASCADE handled related tables)",
2335 file_ids.len()
2336 );
2337 Ok(())
2338 }
2339
2340 fn vacuum_database(&self) -> Result<()> {
2345 let db_path = self.cache_path.join(META_DB);
2346 let conn = open_meta_db(&db_path).context("Failed to open meta.db for VACUUM")?;
2347
2348 conn.execute("VACUUM", [])?;
2351
2352 log::debug!("VACUUM completed successfully");
2353 Ok(())
2354 }
2355
2356 fn calculate_cache_size(&self) -> Result<u64> {
2364 let mut total_size: u64 = 0;
2365
2366 for file_name in [
2367 META_DB,
2368 TOKENS_BIN,
2369 CONFIG_TOML,
2370 "content.bin",
2371 "trigrams.bin",
2372 ] {
2373 let file_path = self.cache_path.join(file_name);
2374 if let Ok(metadata) = std::fs::metadata(&file_path) {
2375 total_size += metadata.len();
2376 }
2377 }
2378
2379 Ok(total_size)
2380 }
2381}
2382
2383#[derive(Debug, Clone)]
2385pub struct StatusReads {
2386 pub schema_ok: bool,
2388 pub owner: Option<(String, Option<String>)>,
2390 pub branch_indexed: bool,
2392 pub branch_info: Option<BranchInfo>,
2394 pub dirty_at_index: Vec<String>,
2397}
2398
2399#[derive(Debug, Clone)]
2401pub struct FileRow {
2402 pub path: String,
2404 pub hash: String,
2406 pub language: String,
2408 pub line_count: usize,
2409 pub size: u64,
2410 pub mtime_ns: i64,
2412 pub dirty: bool,
2414}
2415
2416#[derive(Debug, Clone, PartialEq, Eq)]
2418pub struct FileFingerprint {
2419 pub size: u64,
2420 pub mtime_ns: i64,
2421 pub hash: String,
2422}
2423
2424impl FileFingerprint {
2425 pub fn stat_matches(&self, md: &std::fs::Metadata) -> bool {
2430 self.mtime_ns != 0 && self.size == md.len() && self.mtime_ns == mtime_ns(md)
2431 }
2432}
2433
2434pub fn mtime_ns(md: &std::fs::Metadata) -> i64 {
2436 md.modified()
2437 .ok()
2438 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
2439 .map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
2440 .unwrap_or(0)
2441}
2442
2443pub fn recorded_mtime_ns(md: &std::fs::Metadata, run_start: std::time::SystemTime) -> i64 {
2450 match md.modified() {
2451 Ok(t) if t < run_start => mtime_ns(md),
2452 _ => 0,
2453 }
2454}
2455
2456#[derive(Debug, Clone)]
2458pub struct BranchInfo {
2459 pub branch: String,
2460 pub commit_sha: String,
2461 pub last_indexed: i64,
2462 pub file_count: usize,
2463 pub is_dirty: bool,
2464}
2465
2466#[cfg(test)]
2472mod tests {
2473 use super::*;
2474 use tempfile::TempDir;
2475
2476 #[test]
2477 fn test_cache_init() {
2478 let temp = TempDir::new().unwrap();
2479 let cache = CacheManager::new(temp.path());
2480
2481 assert!(!cache.exists());
2482 cache.init().unwrap();
2483 assert!(cache.exists());
2484 assert!(cache.path().exists());
2485
2486 assert!(cache.path().join(META_DB).exists());
2488 assert!(cache.path().join(CONFIG_TOML).exists());
2489 }
2490
2491 #[test]
2492 fn test_cache_init_idempotent() {
2493 let temp = TempDir::new().unwrap();
2494 let cache = CacheManager::new(temp.path());
2495
2496 cache.init().unwrap();
2498 cache.init().unwrap();
2499
2500 assert!(cache.exists());
2501 }
2502
2503 #[test]
2504 fn test_cache_clear() {
2505 let temp = TempDir::new().unwrap();
2506 let cache = CacheManager::new(temp.path());
2507
2508 cache.init().unwrap();
2509 assert!(cache.exists());
2510
2511 cache.clear().unwrap();
2512 assert!(!cache.exists());
2513 }
2514
2515 #[test]
2516 fn test_cache_clear_nonexistent() {
2517 let temp = TempDir::new().unwrap();
2518 let cache = CacheManager::new(temp.path());
2519
2520 assert!(!cache.exists());
2522 cache.clear().unwrap();
2523 assert!(!cache.exists());
2524 }
2525
2526 #[test]
2527 fn test_load_all_hashes_empty() {
2528 let temp = TempDir::new().unwrap();
2529 let cache = CacheManager::new(temp.path());
2530
2531 cache.init().unwrap();
2532 let hashes = cache.load_all_hashes().unwrap();
2533 assert_eq!(hashes.len(), 0);
2534 }
2535
2536 #[test]
2537 fn test_load_all_hashes_before_init() {
2538 let temp = TempDir::new().unwrap();
2539 let cache = CacheManager::new(temp.path());
2540
2541 let hashes = cache.load_all_hashes().unwrap();
2543 assert_eq!(hashes.len(), 0);
2544 }
2545
2546 #[test]
2547 fn test_load_hashes_for_branch_empty() {
2548 let temp = TempDir::new().unwrap();
2549 let cache = CacheManager::new(temp.path());
2550
2551 cache.init().unwrap();
2552 let hashes = cache.load_hashes_for_branch("main").unwrap();
2553 assert_eq!(hashes.len(), 0);
2554 }
2555
2556 #[test]
2557 fn test_update_file() {
2558 let temp = TempDir::new().unwrap();
2559 let cache = CacheManager::new(temp.path());
2560
2561 cache.init().unwrap();
2562 cache.update_file("src/main.rs", "rust", 100).unwrap();
2563
2564 let files = cache.list_files().unwrap();
2566 assert_eq!(files.len(), 1);
2567 assert_eq!(files[0].path, "src/main.rs");
2568 assert_eq!(files[0].language, "rust");
2569 }
2570
2571 #[test]
2572 fn test_update_file_multiple() {
2573 let temp = TempDir::new().unwrap();
2574 let cache = CacheManager::new(temp.path());
2575
2576 cache.init().unwrap();
2577 cache.update_file("src/main.rs", "rust", 100).unwrap();
2578 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2579 cache.update_file("README.md", "markdown", 50).unwrap();
2580
2581 let files = cache.list_files().unwrap();
2583 assert_eq!(files.len(), 3);
2584 }
2585
2586 #[test]
2587 fn test_update_file_replace() {
2588 let temp = TempDir::new().unwrap();
2589 let cache = CacheManager::new(temp.path());
2590
2591 cache.init().unwrap();
2592 cache.update_file("src/main.rs", "rust", 100).unwrap();
2593 cache.update_file("src/main.rs", "rust", 150).unwrap();
2594
2595 let files = cache.list_files().unwrap();
2597 assert_eq!(files.len(), 1);
2598 assert_eq!(files[0].path, "src/main.rs");
2599 }
2600
2601 #[test]
2602 fn test_batch_update_files() {
2603 let temp = TempDir::new().unwrap();
2604 let cache = CacheManager::new(temp.path());
2605
2606 cache.init().unwrap();
2607
2608 let files = vec![
2609 ("src/main.rs".to_string(), "rust".to_string(), 100),
2610 ("src/lib.rs".to_string(), "rust".to_string(), 200),
2611 ("test.py".to_string(), "python".to_string(), 50),
2612 ];
2613
2614 cache.batch_update_files(&files).unwrap();
2615
2616 let stored_files = cache.list_files().unwrap();
2618 assert_eq!(stored_files.len(), 3);
2619 }
2620
2621 #[test]
2622 fn test_update_stats() {
2623 let temp = TempDir::new().unwrap();
2624 let cache = CacheManager::new(temp.path());
2625
2626 cache.init().unwrap();
2627 cache.update_file("src/main.rs", "rust", 100).unwrap();
2628 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2629
2630 cache
2632 .record_branch_file("src/main.rs", "_default", "hash1", None)
2633 .unwrap();
2634 cache
2635 .record_branch_file("src/lib.rs", "_default", "hash2", None)
2636 .unwrap();
2637 cache.update_stats("_default").unwrap();
2638
2639 let stats = cache.stats().unwrap();
2640 assert_eq!(stats.total_files, 2);
2641 }
2642
2643 #[test]
2644 fn test_stats_empty_cache() {
2645 let temp = TempDir::new().unwrap();
2646 let cache = CacheManager::new(temp.path());
2647
2648 cache.init().unwrap();
2649 let stats = cache.stats().unwrap();
2650
2651 assert_eq!(stats.total_files, 0);
2652 assert_eq!(stats.files_by_language.len(), 0);
2653 }
2654
2655 #[test]
2656 fn test_stats_before_init() {
2657 let temp = TempDir::new().unwrap();
2658 let cache = CacheManager::new(temp.path());
2659
2660 let stats = cache.stats().unwrap();
2662 assert_eq!(stats.total_files, 0);
2663 }
2664
2665 #[test]
2666 fn test_stats_by_language() {
2667 let temp = TempDir::new().unwrap();
2668 let cache = CacheManager::new(temp.path());
2669
2670 cache.init().unwrap();
2671 cache.update_file("main.rs", "Rust", 100).unwrap();
2672 cache.update_file("lib.rs", "Rust", 200).unwrap();
2673 cache.update_file("script.py", "Python", 50).unwrap();
2674 cache.update_file("test.py", "Python", 80).unwrap();
2675
2676 cache
2678 .record_branch_file("main.rs", "_default", "hash1", None)
2679 .unwrap();
2680 cache
2681 .record_branch_file("lib.rs", "_default", "hash2", None)
2682 .unwrap();
2683 cache
2684 .record_branch_file("script.py", "_default", "hash3", None)
2685 .unwrap();
2686 cache
2687 .record_branch_file("test.py", "_default", "hash4", None)
2688 .unwrap();
2689 cache.update_stats("_default").unwrap();
2690
2691 let stats = cache.stats().unwrap();
2692 assert_eq!(stats.files_by_language.get("Rust"), Some(&2));
2693 assert_eq!(stats.files_by_language.get("Python"), Some(&2));
2694 assert_eq!(stats.lines_by_language.get("Rust"), Some(&300)); assert_eq!(stats.lines_by_language.get("Python"), Some(&130)); }
2697
2698 #[test]
2699 fn test_list_files_empty() {
2700 let temp = TempDir::new().unwrap();
2701 let cache = CacheManager::new(temp.path());
2702
2703 cache.init().unwrap();
2704 let files = cache.list_files().unwrap();
2705 assert_eq!(files.len(), 0);
2706 }
2707
2708 #[test]
2709 fn test_list_files() {
2710 let temp = TempDir::new().unwrap();
2711 let cache = CacheManager::new(temp.path());
2712
2713 cache.init().unwrap();
2714 cache.update_file("src/main.rs", "rust", 100).unwrap();
2715 cache.update_file("src/lib.rs", "rust", 200).unwrap();
2716
2717 let files = cache.list_files().unwrap();
2718 assert_eq!(files.len(), 2);
2719
2720 assert_eq!(files[0].path, "src/lib.rs");
2722 assert_eq!(files[1].path, "src/main.rs");
2723
2724 assert_eq!(files[0].language, "rust");
2725 }
2726
2727 #[test]
2728 fn test_list_files_before_init() {
2729 let temp = TempDir::new().unwrap();
2730 let cache = CacheManager::new(temp.path());
2731
2732 let files = cache.list_files().unwrap();
2734 assert_eq!(files.len(), 0);
2735 }
2736
2737 #[test]
2738 fn test_branch_exists() {
2739 let temp = TempDir::new().unwrap();
2740 let cache = CacheManager::new(temp.path());
2741
2742 cache.init().unwrap();
2743
2744 assert!(!cache.branch_exists("main").unwrap());
2745
2746 cache.update_file("src/main.rs", "rust", 100).unwrap();
2748 cache
2749 .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2750 .unwrap();
2751
2752 assert!(cache.branch_exists("main").unwrap());
2753 assert!(!cache.branch_exists("feature-branch").unwrap());
2754 }
2755
2756 #[test]
2757 fn test_record_branch_file() {
2758 let temp = TempDir::new().unwrap();
2759 let cache = CacheManager::new(temp.path());
2760
2761 cache.init().unwrap();
2762 cache.update_file("src/main.rs", "rust", 100).unwrap();
2764 cache
2765 .record_branch_file("src/main.rs", "main", "hash1", Some("commit123"))
2766 .unwrap();
2767
2768 let files = cache.get_branch_files("main").unwrap();
2769 assert_eq!(files.len(), 1);
2770 assert_eq!(files.get("src/main.rs"), Some(&"hash1".to_string()));
2771 }
2772
2773 #[test]
2774 fn test_get_branch_files_empty() {
2775 let temp = TempDir::new().unwrap();
2776 let cache = CacheManager::new(temp.path());
2777
2778 cache.init().unwrap();
2779 let files = cache.get_branch_files("nonexistent").unwrap();
2780 assert_eq!(files.len(), 0);
2781 }
2782
2783 #[test]
2784 fn test_batch_record_branch_files() {
2785 let temp = TempDir::new().unwrap();
2786 let cache = CacheManager::new(temp.path());
2787
2788 cache.init().unwrap();
2789
2790 let file_metadata = vec![
2792 ("src/main.rs".to_string(), "rust".to_string(), 100),
2793 ("src/lib.rs".to_string(), "rust".to_string(), 200),
2794 ("README.md".to_string(), "markdown".to_string(), 50),
2795 ];
2796 cache.batch_update_files(&file_metadata).unwrap();
2797
2798 let files = vec![
2799 ("src/main.rs".to_string(), "hash1".to_string()),
2800 ("src/lib.rs".to_string(), "hash2".to_string()),
2801 ("README.md".to_string(), "hash3".to_string()),
2802 ];
2803
2804 cache
2805 .batch_record_branch_files(&files, "main", Some("commit123"))
2806 .unwrap();
2807
2808 let branch_files = cache.get_branch_files("main").unwrap();
2809 assert_eq!(branch_files.len(), 3);
2810 assert_eq!(branch_files.get("src/main.rs"), Some(&"hash1".to_string()));
2811 assert_eq!(branch_files.get("src/lib.rs"), Some(&"hash2".to_string()));
2812 assert_eq!(branch_files.get("README.md"), Some(&"hash3".to_string()));
2813 }
2814
2815 #[test]
2816 fn test_update_branch_metadata() {
2817 let temp = TempDir::new().unwrap();
2818 let cache = CacheManager::new(temp.path());
2819
2820 cache.init().unwrap();
2821 cache
2822 .update_branch_metadata("main", Some("commit123"), 10, false)
2823 .unwrap();
2824
2825 let info = cache.get_branch_info("main").unwrap();
2826 assert_eq!(info.branch, "main");
2827 assert_eq!(info.commit_sha, "commit123");
2828 assert_eq!(info.file_count, 10);
2829 assert!(!info.is_dirty);
2830 }
2831
2832 #[test]
2833 fn test_update_branch_metadata_dirty() {
2834 let temp = TempDir::new().unwrap();
2835 let cache = CacheManager::new(temp.path());
2836
2837 cache.init().unwrap();
2838 cache
2839 .update_branch_metadata("feature", Some("commit456"), 5, true)
2840 .unwrap();
2841
2842 let info = cache.get_branch_info("feature").unwrap();
2843 assert!(info.is_dirty);
2844 }
2845
2846 #[test]
2847 fn test_find_file_with_hash() {
2848 let temp = TempDir::new().unwrap();
2849 let cache = CacheManager::new(temp.path());
2850
2851 cache.init().unwrap();
2852 cache.update_file("src/main.rs", "rust", 100).unwrap();
2854 cache
2855 .record_branch_file("src/main.rs", "main", "unique_hash", Some("commit123"))
2856 .unwrap();
2857
2858 let result = cache.find_file_with_hash("unique_hash").unwrap();
2859 assert!(result.is_some());
2860
2861 let (path, branch) = result.unwrap();
2862 assert_eq!(path, "src/main.rs");
2863 assert_eq!(branch, "main");
2864 }
2865
2866 #[test]
2867 fn test_find_file_with_hash_not_found() {
2868 let temp = TempDir::new().unwrap();
2869 let cache = CacheManager::new(temp.path());
2870
2871 cache.init().unwrap();
2872
2873 let result = cache.find_file_with_hash("nonexistent_hash").unwrap();
2874 assert!(result.is_none());
2875 }
2876
2877 #[test]
2878 fn test_config_toml_created() {
2879 let temp = TempDir::new().unwrap();
2880 let cache = CacheManager::new(temp.path());
2881
2882 cache.init().unwrap();
2883
2884 let config_path = cache.path().join(CONFIG_TOML);
2885 let config_content = std::fs::read_to_string(&config_path).unwrap();
2886
2887 assert!(config_content.contains("[index]"));
2889 assert!(config_content.contains("[search]"));
2890 assert!(config_content.contains("[performance]"));
2891 assert!(config_content.contains("max_file_size"));
2892 }
2893
2894 #[test]
2895 fn test_meta_db_schema() {
2896 let temp = TempDir::new().unwrap();
2897 let cache = CacheManager::new(temp.path());
2898
2899 cache.init().unwrap();
2900
2901 let db_path = cache.path().join(META_DB);
2902 let conn = open_meta_db(&db_path).unwrap();
2903
2904 let tables: Vec<String> = conn
2906 .prepare("SELECT name FROM sqlite_master WHERE type='table'")
2907 .unwrap()
2908 .query_map([], |row| row.get(0))
2909 .unwrap()
2910 .collect::<Result<Vec<_>, _>>()
2911 .unwrap();
2912
2913 assert!(tables.contains(&"files".to_string()));
2914 assert!(tables.contains(&"statistics".to_string()));
2915 assert!(tables.contains(&"config".to_string()));
2916 assert!(tables.contains(&"file_branches".to_string()));
2917 assert!(tables.contains(&"branches".to_string()));
2918 assert!(tables.contains(&"file_dependencies".to_string()));
2919 assert!(tables.contains(&"file_exports".to_string()));
2920 }
2921
2922 #[test]
2923 fn test_concurrent_file_updates() {
2924 use std::thread;
2925
2926 let temp = TempDir::new().unwrap();
2927 let cache_path = temp.path().to_path_buf();
2928
2929 let cache = CacheManager::new(&cache_path);
2930 cache.init().unwrap();
2931
2932 let handles: Vec<_> = (0..10)
2934 .map(|i| {
2935 let path = cache_path.clone();
2936 thread::spawn(move || {
2937 let cache = CacheManager::new(&path);
2938 cache
2939 .update_file(&format!("file_{}.rs", i), "rust", i * 10)
2940 .unwrap();
2941 })
2942 })
2943 .collect();
2944
2945 for handle in handles {
2946 handle.join().unwrap();
2947 }
2948
2949 let cache = CacheManager::new(&cache_path);
2950 let files = cache.list_files().unwrap();
2951 assert_eq!(files.len(), 10);
2952 }
2953
2954 #[test]
2957 fn test_validate_corrupted_database() {
2958 use std::io::Write;
2959
2960 let temp = TempDir::new().unwrap();
2961 let cache = CacheManager::new(temp.path());
2962
2963 cache.init().unwrap();
2964
2965 let db_path = cache.path().join(META_DB);
2967 let mut file = File::create(&db_path).unwrap();
2968 file.write_all(b"CORRUPTED DATA").unwrap();
2969
2970 let result = cache.validate();
2972 assert!(result.is_err());
2973 let err_msg = result.unwrap_err().to_string();
2974 eprintln!("Error message: {}", err_msg);
2975 assert!(err_msg.contains("corrupted") || err_msg.contains("not a database"));
2976 }
2977
2978 #[test]
2979 fn test_validate_corrupted_trigrams() {
2980 use std::io::Write;
2981
2982 let temp = TempDir::new().unwrap();
2983 let cache = CacheManager::new(temp.path());
2984
2985 cache.init().unwrap();
2986
2987 let trigrams_path = cache.path().join("trigrams.bin");
2989 let mut file = File::create(&trigrams_path).unwrap();
2990 file.write_all(b"BADM").unwrap(); let result = cache.validate();
2994 assert!(result.is_err());
2995 let err = result.unwrap_err().to_string();
2996 assert!(err.contains("trigrams.bin") && err.contains("corrupted"));
2997 }
2998
2999 #[test]
3000 fn test_validate_corrupted_content() {
3001 use std::io::Write;
3002
3003 let temp = TempDir::new().unwrap();
3004 let cache = CacheManager::new(temp.path());
3005
3006 cache.init().unwrap();
3007
3008 let content_path = cache.path().join("content.bin");
3010 let mut file = File::create(&content_path).unwrap();
3011 file.write_all(b"BADM").unwrap(); let result = cache.validate();
3015 assert!(result.is_err());
3016 let err = result.unwrap_err().to_string();
3017 assert!(err.contains("content.bin") && err.contains("corrupted"));
3018 }
3019
3020 #[test]
3021 fn test_validate_missing_schema_table() {
3022 let temp = TempDir::new().unwrap();
3023 let cache = CacheManager::new(temp.path());
3024
3025 cache.init().unwrap();
3026
3027 let db_path = cache.path().join(META_DB);
3029 let conn = open_meta_db(&db_path).unwrap();
3030 conn.execute("DROP TABLE files", []).unwrap();
3031
3032 let result = cache.validate();
3034 assert!(result.is_err());
3035 let err = result.unwrap_err().to_string();
3036 assert!(err.contains("files") && err.contains("missing"));
3037 }
3038}