Skip to main content

reflex/
indexer.rs

1//! Indexing engine for parsing source code
2//!
3//! The indexer scans the project directory, parses source files using Tree-sitter,
4//! and builds the symbol/token cache for fast querying.
5
6use anyhow::{Context, Result};
7use ignore::WalkBuilder;
8use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
9use rayon::prelude::*;
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, Mutex};
14use std::time::Instant;
15
16use crate::cache::CacheManager;
17use crate::content_store::{ContentReader, ContentWriter};
18use crate::dependency::DependencyIndex;
19use crate::models::{Dependency, ImportType, IndexConfig, IndexStats, Language};
20#[cfg(unix)]
21use crate::output;
22use crate::parsers::c::CDependencyExtractor;
23use crate::parsers::cpp::CppDependencyExtractor;
24use crate::parsers::csharp::CSharpDependencyExtractor;
25use crate::parsers::go::GoDependencyExtractor;
26use crate::parsers::java::JavaDependencyExtractor;
27use crate::parsers::kotlin::KotlinDependencyExtractor;
28use crate::parsers::php::PhpDependencyExtractor;
29use crate::parsers::python::PythonDependencyExtractor;
30use crate::parsers::ruby::RubyDependencyExtractor;
31use crate::parsers::rust::RustDependencyExtractor;
32use crate::parsers::svelte::SvelteDependencyExtractor;
33use crate::parsers::typescript::TypeScriptDependencyExtractor;
34use crate::parsers::vue::VueDependencyExtractor;
35use crate::parsers::zig::ZigDependencyExtractor;
36use crate::parsers::{DependencyExtractor, ExportInfo, ImportInfo};
37use crate::trigram::TrigramIndex;
38
39/// Progress callback type: (current_file_count, total_file_count, status_message)
40/// Uses Arc to allow cloning for multi-threaded progress updates
41pub type ProgressCallback = Arc<dyn Fn(usize, usize, String) + Send + Sync>;
42
43/// Result of processing a single file (used for parallel processing)
44struct FileProcessingResult {
45    path_str: String,
46    hash: String,
47    content: String,
48    language: Language,
49    line_count: usize,
50    dependencies: Vec<ImportInfo>,
51    exports: Vec<ExportInfo>,
52}
53
54/// Find the nearest tsconfig.json for a given source file
55///
56/// Walks up the directory tree from the source file to find the nearest tsconfig directory.
57/// Returns a reference to the PathAliasMap if found.
58fn find_nearest_tsconfig<'a>(
59    file_path: &str,
60    root: &Path,
61    tsconfigs: &'a HashMap<PathBuf, crate::parsers::tsconfig::PathAliasMap>,
62) -> Option<&'a crate::parsers::tsconfig::PathAliasMap> {
63    // Convert file_path to absolute path (relative to root)
64    let abs_file_path = if Path::new(file_path).is_absolute() {
65        PathBuf::from(file_path)
66    } else {
67        root.join(file_path)
68    };
69
70    // Start from the file's directory and walk up
71    let mut current_dir = abs_file_path.parent()?;
72
73    loop {
74        // Check if we have a tsconfig for this directory
75        if let Some(alias_map) = tsconfigs.get(current_dir) {
76            return Some(alias_map);
77        }
78
79        // Move up one directory
80        current_dir = current_dir.parent()?;
81
82        // Stop if we've reached the root
83        if current_dir == root || !current_dir.starts_with(root) {
84            break;
85        }
86    }
87
88    None
89}
90
91/// Manages the indexing process
92pub struct Indexer {
93    cache: CacheManager,
94    config: IndexConfig,
95}
96
97impl Indexer {
98    /// Create a new indexer with the given cache manager and config
99    pub fn new(cache: CacheManager, config: IndexConfig) -> Self {
100        Self { cache, config }
101    }
102
103    /// Build or update the index for the given root directory
104    pub fn index(&self, root: impl AsRef<Path>, show_progress: bool) -> Result<IndexStats> {
105        self.index_with_callback(root, show_progress, None)
106    }
107
108    /// Build or update the index with progress callback support
109    pub fn index_with_callback(
110        &self,
111        root: impl AsRef<Path>,
112        show_progress: bool,
113        progress_callback: Option<ProgressCallback>,
114    ) -> Result<IndexStats> {
115        let root = root.as_ref();
116        log::info!("Indexing directory: {:?}", root);
117
118        // Get git state (if in git repo)
119        let git_state = crate::git::get_git_state_optional(root)?;
120        let branch = git_state
121            .as_ref()
122            .map(|s| s.branch.clone())
123            .unwrap_or_else(|| "_default".to_string());
124
125        if let Some(ref state) = git_state {
126            log::info!(
127                "Git state: branch='{}', commit='{}', dirty={}",
128                state.branch,
129                state.commit,
130                state.dirty
131            );
132        } else {
133            log::info!("Not a git repository, using default branch");
134        }
135
136        // Configure thread pool for parallel processing
137        // 0 = auto (use 80% of available cores to avoid locking the system)
138        let num_threads = if self.config.parallel_threads == 0 {
139            let available_cores = std::thread::available_parallelism()
140                .map(|n| n.get())
141                .unwrap_or(4);
142            // Use 80% of available cores (minimum 1, maximum 8)
143            // Cap at 8 to prevent diminishing returns from cache contention on high-core systems
144            ((available_cores as f64 * 0.8).ceil() as usize).clamp(1, 8)
145        } else {
146            self.config.parallel_threads
147        };
148
149        log::info!(
150            "Using {} threads for parallel indexing (out of {} available)",
151            num_threads,
152            std::thread::available_parallelism()
153                .map(|n| n.get())
154                .unwrap_or(4)
155        );
156
157        // Ensure cache is initialized
158        self.cache.init()?;
159
160        // Check available disk space after cache is initialized
161        self.check_disk_space(root)?;
162
163        // Load existing hashes for incremental indexing (for current branch)
164        let existing_hashes = self.cache.load_hashes_for_branch(&branch)?;
165        log::debug!(
166            "Loaded {} existing file hashes for branch '{}'",
167            existing_hashes.len(),
168            branch
169        );
170
171        // Step 1: Walk directory tree and collect files
172        let (files, skipped_too_large, skipped_bytes_too_large) = self.discover_files(root)?;
173        let total_files = files.len();
174        log::info!(
175            "Discovered {} files to index ({} skipped: too large)",
176            total_files,
177            skipped_too_large
178        );
179
180        // Step 1.4: Parse tsconfig.json files for TypeScript/Vue path alias resolution
181        // Must be done before parallel processing so it's available during dependency extraction
182        let tsconfigs = crate::parsers::tsconfig::parse_all_tsconfigs(root).unwrap_or_else(|e| {
183            log::warn!("Failed to parse tsconfig.json files: {}", e);
184            HashMap::new()
185        });
186        if !tsconfigs.is_empty() {
187            log::info!("Found {} tsconfig.json files", tsconfigs.len());
188            for (config_dir, alias_map) in &tsconfigs {
189                log::debug!(
190                    "  {} (base_url: {:?}, {} aliases)",
191                    config_dir.display(),
192                    alias_map.base_url,
193                    alias_map.aliases.len()
194                );
195            }
196        }
197
198        // Step 1.5: Quick incremental check - are all files unchanged?
199        // If yes, skip expensive rebuild entirely and return cached stats
200        if !existing_hashes.is_empty() && total_files == existing_hashes.len() {
201            // Same number of files - check if any changed by comparing hashes
202            let mut any_changed = false;
203
204            for file_path in &files {
205                // Normalize path to be relative to root (handles both ./ prefix and absolute paths).
206                // Always use forward slashes so the on-disk index is deterministic across OSes
207                // and downstream string lookups (file_pattern filters, dependency resolvers)
208                // work regardless of the host separator.
209                let path_str = file_path.to_string_lossy().to_string();
210                let normalized_path = if let Ok(rel_path) = file_path.strip_prefix(root) {
211                    // Convert absolute path to relative
212                    rel_path.to_string_lossy().replace('\\', "/")
213                } else {
214                    // Already relative, just strip ./ prefix
215                    path_str.trim_start_matches("./").replace('\\', "/")
216                };
217
218                // Check if file exists in cache
219                if let Some(existing_hash) = existing_hashes.get(&normalized_path) {
220                    // Read and hash file to check if changed
221                    match std::fs::read_to_string(file_path) {
222                        Ok(content) => {
223                            let current_hash = self.hash_content(content.as_bytes());
224                            if &current_hash != existing_hash {
225                                any_changed = true;
226                                log::debug!("File changed: {}", path_str);
227                                break; // Early exit - we know we need to rebuild
228                            }
229                        }
230                        Err(_) => {
231                            any_changed = true;
232                            break;
233                        }
234                    }
235                } else {
236                    // File not in cache - something changed
237                    any_changed = true;
238                    break;
239                }
240            }
241
242            if !any_changed {
243                let content_path = self.cache.path().join("content.bin");
244                let trigrams_path = self.cache.path().join("trigrams.bin");
245
246                // Check if schema hash matches - if not, we need a full rebuild
247                // even though file contents haven't changed (binary format may differ)
248                let schema_ok = self.cache.check_schema_hash().unwrap_or(false);
249
250                if schema_ok && content_path.exists() && trigrams_path.exists() {
251                    // Validate trigrams.bin magic bytes before skipping a rebuild.
252                    // A disk-full mid-write leaves a file that still "exists" but is corrupt;
253                    // checking only existence would cause us to skip a needed rebuild.
254                    // This mirrors the magic-byte check in CacheManager::validate().
255                    let trigrams_ok = {
256                        use std::io::Read;
257                        std::fs::File::open(&trigrams_path)
258                            .and_then(|mut f| {
259                                let mut h = [0u8; 4];
260                                f.read_exact(&mut h).map(|_| h)
261                            })
262                            .map(|h| &h == b"RFTG")
263                            .unwrap_or(false)
264                    };
265                    if !trigrams_ok {
266                        log::warn!(
267                            "trigrams.bin corrupted or too small despite hashes matching - forcing rebuild"
268                        );
269                    } else if let Ok(reader) = ContentReader::open(&content_path) {
270                        if reader.file_count() > 0 {
271                            log::info!("No files changed - skipping index rebuild");
272                            let mut stats = self.cache.stats()?;
273                            stats.unchanged_files = total_files;
274                            stats.skipped_too_large = skipped_too_large;
275                            stats.skipped_bytes_too_large = skipped_bytes_too_large;
276                            return Ok(stats);
277                        }
278                        log::warn!(
279                            "content.bin has no files despite hashes matching - forcing rebuild"
280                        );
281                    } else {
282                        log::warn!("content.bin invalid despite hashes matching - forcing rebuild");
283                    }
284                } else if !schema_ok {
285                    log::info!("Schema hash changed - forcing full rebuild");
286                } else {
287                    log::warn!("Binary index files missing - forcing rebuild");
288                }
289            }
290        } else if total_files != existing_hashes.len() {
291            log::info!(
292                "File count changed ({} -> {}) - full reindex required",
293                existing_hashes.len(),
294                total_files
295            );
296        }
297
298        // Step 2: Build trigram index + content store
299        let mut new_hashes = HashMap::new();
300        let mut files_indexed = 0;
301        let mut new_file_count = 0usize;
302        let mut modified_file_count = 0usize;
303        let mut unchanged_file_count = 0usize;
304        let mut file_metadata: Vec<(String, String, String, usize)> = Vec::new(); // For batch SQLite update
305        let mut all_dependencies: Vec<(String, Vec<ImportInfo>)> = Vec::new(); // For batch dependency insertion
306        let mut all_exports: Vec<(String, Vec<ExportInfo>)> = Vec::new(); // For batch export insertion
307
308        // Initialize trigram index and content store
309        let mut trigram_index = TrigramIndex::new();
310        let mut content_writer = ContentWriter::new();
311
312        // Enable batch-flush mode for trigram index if we have lots of files
313        if total_files > 10000 {
314            let temp_dir = self.cache.path().join("trigram_temp");
315            trigram_index
316                .enable_batch_flush(temp_dir)
317                .context("Failed to enable batch-flush mode for trigram index")?;
318            log::info!("Enabled batch-flush mode for {} files", total_files);
319        }
320
321        // Initialize content writer to start streaming writes immediately
322        let content_path = self.cache.path().join("content.bin");
323        content_writer
324            .init(content_path.clone())
325            .context("Failed to initialize content writer")?;
326
327        // Create progress bar (only if requested via --progress flag)
328        let pb = if show_progress {
329            let pb = ProgressBar::new(total_files as u64);
330            pb.set_draw_target(ProgressDrawTarget::stderr());
331            pb.set_style(
332                ProgressStyle::default_bar()
333                    .template("[{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} files ({percent}%) {msg}")
334                    .unwrap()
335                    .progress_chars("=>-")
336            );
337            // Force updates every 100ms to ensure progress is visible
338            pb.enable_steady_tick(std::time::Duration::from_millis(100));
339            pb
340        } else {
341            ProgressBar::hidden()
342        };
343
344        // Atomic counter for thread-safe progress updates
345        let progress_counter = Arc::new(AtomicU64::new(0));
346        // Shared status message for progress callback
347        let progress_status = Arc::new(Mutex::new("Indexing files...".to_string()));
348
349        let _start_time = Instant::now();
350
351        // Spawn a background thread to update progress bar and call callback during parallel processing
352        let counter_for_thread = Arc::clone(&progress_counter);
353        let status_for_thread = Arc::clone(&progress_status);
354        let pb_clone = pb.clone();
355        let callback_for_thread = progress_callback.clone();
356        let total_files_for_thread = total_files;
357        let progress_thread = if show_progress || callback_for_thread.is_some() {
358            Some(std::thread::spawn(move || {
359                loop {
360                    let count = counter_for_thread.load(Ordering::Relaxed);
361                    pb_clone.set_position(count);
362
363                    // Call progress callback if provided
364                    if let Some(ref callback) = callback_for_thread {
365                        let status = status_for_thread.lock().unwrap().clone();
366                        callback(count as usize, total_files_for_thread, status);
367                    }
368
369                    if count >= total_files_for_thread as u64 {
370                        break;
371                    }
372                    std::thread::sleep(std::time::Duration::from_millis(50));
373                }
374            }))
375        } else {
376            None
377        };
378
379        // Build a custom thread pool with limited threads
380        let pool = rayon::ThreadPoolBuilder::new()
381            .num_threads(num_threads)
382            .build()
383            .context("Failed to create thread pool")?;
384
385        // Process files in batches to avoid OOM on huge codebases
386        // Batch size: process 5000 files at a time to limit memory usage
387        const BATCH_SIZE: usize = 5000;
388        let num_batches = total_files.div_ceil(BATCH_SIZE);
389        log::info!(
390            "Processing {} files in {} batches of up to {} files",
391            total_files,
392            num_batches,
393            BATCH_SIZE
394        );
395
396        for (batch_idx, batch_files) in files.chunks(BATCH_SIZE).enumerate() {
397            log::info!(
398                "Processing batch {}/{} ({} files)",
399                batch_idx + 1,
400                num_batches,
401                batch_files.len()
402            );
403
404            // Process files in parallel using rayon with custom thread pool
405            let counter_clone = Arc::clone(&progress_counter);
406            let results: Vec<Option<FileProcessingResult>> = pool.install(|| {
407                batch_files
408                    .par_iter()
409                    .map(|file_path| {
410                // Normalize path to be relative to root (handles both ./ prefix and absolute paths).
411                // Always emit forward slashes so the persisted path is deterministic across OSes.
412                let path_str = file_path.to_string_lossy().to_string();
413                let normalized_path = if let Ok(rel_path) = file_path.strip_prefix(root) {
414                    // Convert absolute path to relative
415                    rel_path.to_string_lossy().replace('\\', "/")
416                } else {
417                    // Already relative, just strip ./ prefix
418                    path_str.trim_start_matches("./").replace('\\', "/")
419                };
420
421                // Read file content once (used for hashing, trigrams, and parsing)
422                let content = match std::fs::read_to_string(file_path) {
423                    Ok(c) => c,
424                    Err(e) => {
425                        log::warn!("Failed to read {}: {}", path_str, e);
426                        // Update progress
427                        counter_clone.fetch_add(1, Ordering::Relaxed);
428                        return None;
429                    }
430                };
431
432                // Compute hash from content (no duplicate file read!)
433                let hash = self.hash_content(content.as_bytes());
434
435                // Detect language
436                let ext = file_path.extension()
437                    .and_then(|e| e.to_str())
438                    .unwrap_or("");
439                let language = Language::from_extension(ext);
440
441                // Count lines in the file
442                let line_count = content.lines().count();
443
444                // Extract dependencies and exports for supported languages
445                let dependencies = match language {
446                    Language::Rust => {
447                        match RustDependencyExtractor::extract_dependencies(&content) {
448                            Ok(deps) => deps,
449                            Err(e) => {
450                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
451                                Vec::new()
452                            }
453                        }
454                    }
455                    Language::Python => {
456                        match PythonDependencyExtractor::extract_dependencies(&content) {
457                            Ok(deps) => deps,
458                            Err(e) => {
459                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
460                                Vec::new()
461                            }
462                        }
463                    }
464                    Language::TypeScript | Language::JavaScript => {
465                        // Find nearest tsconfig for path alias resolution
466                        let alias_map = find_nearest_tsconfig(&path_str, root, &tsconfigs);
467                        match TypeScriptDependencyExtractor::extract_dependencies_with_alias_map(&content, alias_map) {
468                            Ok(deps) => deps,
469                            Err(e) => {
470                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
471                                Vec::new()
472                            }
473                        }
474                    }
475                    Language::Go => {
476                        match GoDependencyExtractor::extract_dependencies(&content) {
477                            Ok(deps) => deps,
478                            Err(e) => {
479                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
480                                Vec::new()
481                            }
482                        }
483                    }
484                    Language::Java => {
485                        match JavaDependencyExtractor::extract_dependencies(&content) {
486                            Ok(deps) => deps,
487                            Err(e) => {
488                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
489                                Vec::new()
490                            }
491                        }
492                    }
493                    Language::C => {
494                        match CDependencyExtractor::extract_dependencies(&content) {
495                            Ok(deps) => deps,
496                            Err(e) => {
497                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
498                                Vec::new()
499                            }
500                        }
501                    }
502                    Language::Cpp => {
503                        match CppDependencyExtractor::extract_dependencies(&content) {
504                            Ok(deps) => deps,
505                            Err(e) => {
506                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
507                                Vec::new()
508                            }
509                        }
510                    }
511                    Language::CSharp => {
512                        match CSharpDependencyExtractor::extract_dependencies(&content) {
513                            Ok(deps) => deps,
514                            Err(e) => {
515                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
516                                Vec::new()
517                            }
518                        }
519                    }
520                    Language::PHP => {
521                        match PhpDependencyExtractor::extract_dependencies(&content) {
522                            Ok(deps) => deps,
523                            Err(e) => {
524                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
525                                Vec::new()
526                            }
527                        }
528                    }
529                    Language::Ruby => {
530                        match RubyDependencyExtractor::extract_dependencies(&content) {
531                            Ok(deps) => deps,
532                            Err(e) => {
533                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
534                                Vec::new()
535                            }
536                        }
537                    }
538                    Language::Kotlin => {
539                        match KotlinDependencyExtractor::extract_dependencies(&content) {
540                            Ok(deps) => deps,
541                            Err(e) => {
542                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
543                                Vec::new()
544                            }
545                        }
546                    }
547                    Language::Zig => {
548                        match ZigDependencyExtractor::extract_dependencies(&content) {
549                            Ok(deps) => deps,
550                            Err(e) => {
551                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
552                                Vec::new()
553                            }
554                        }
555                    }
556                    Language::Vue => {
557                        // Find nearest tsconfig for path alias resolution
558                        let alias_map = find_nearest_tsconfig(&path_str, root, &tsconfigs);
559                        match VueDependencyExtractor::extract_dependencies_with_alias_map(&content, alias_map) {
560                            Ok(deps) => deps,
561                            Err(e) => {
562                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
563                                Vec::new()
564                            }
565                        }
566                    }
567                    Language::Svelte => {
568                        match SvelteDependencyExtractor::extract_dependencies(&content) {
569                            Ok(deps) => deps,
570                            Err(e) => {
571                                log::warn!("Failed to extract dependencies from {}: {}", path_str, e);
572                                Vec::new()
573                            }
574                        }
575                    }
576                    // Other languages not yet implemented
577                    _ => Vec::new(),
578                };
579
580                // Extract exports (for barrel export tracking)
581                let exports = match language {
582                    Language::TypeScript | Language::JavaScript => {
583                        // Find nearest tsconfig for path alias resolution
584                        let alias_map = find_nearest_tsconfig(&path_str, root, &tsconfigs);
585                        match TypeScriptDependencyExtractor::extract_export_declarations(&content, alias_map) {
586                            Ok(exports) => exports,
587                            Err(e) => {
588                                log::warn!("Failed to extract exports from {}: {}", path_str, e);
589                                Vec::new()
590                            }
591                        }
592                    }
593                    Language::Vue => {
594                        // Find nearest tsconfig for path alias resolution
595                        let alias_map = find_nearest_tsconfig(&path_str, root, &tsconfigs);
596                        match VueDependencyExtractor::extract_export_declarations(&content, alias_map) {
597                            Ok(exports) => exports,
598                            Err(e) => {
599                                log::warn!("Failed to extract exports from {}: {}", path_str, e);
600                                Vec::new()
601                            }
602                        }
603                    }
604                    // Other languages not yet implemented for export tracking
605                    _ => Vec::new(),
606                };
607
608                // Update progress atomically
609                counter_clone.fetch_add(1, Ordering::Relaxed);
610
611                Some(FileProcessingResult {
612                    path_str: normalized_path.to_string(),
613                    hash,
614                    content,
615                    language,
616                    line_count,
617                    dependencies,
618                    exports,
619                })
620                })
621                .collect()
622            });
623
624            // Process batch results immediately (streaming approach to minimize memory)
625            for result in results.into_iter().flatten() {
626                // Use the normalized (forward-slash, relative) path everywhere so
627                // the trigram index and content store agree with what the database
628                // and downstream filters expect, regardless of host separator.
629                let normalized_pathbuf = PathBuf::from(&result.path_str);
630
631                // Add file to trigram index (get file_id)
632                let file_id = trigram_index.add_file(normalized_pathbuf.clone());
633
634                // Index file content directly (avoid accumulating all trigrams)
635                trigram_index.index_file(file_id, &result.content);
636
637                // Add to content store
638                content_writer.add_file(normalized_pathbuf, &result.content);
639
640                files_indexed += 1;
641
642                // Track new / modified / unchanged for the incremental summary
643                match existing_hashes.get(&result.path_str) {
644                    None => new_file_count += 1,
645                    Some(old_hash) if old_hash != &result.hash => modified_file_count += 1,
646                    _ => unchanged_file_count += 1,
647                }
648
649                // Prepare file metadata for batch database update
650                file_metadata.push((
651                    result.path_str.clone(),
652                    result.hash.clone(),
653                    format!("{:?}", result.language),
654                    result.line_count,
655                ));
656
657                // Collect dependencies for batch insertion (if any)
658                if !result.dependencies.is_empty() {
659                    all_dependencies.push((result.path_str.clone(), result.dependencies));
660                }
661
662                // Collect exports for batch insertion (if any)
663                if !result.exports.is_empty() {
664                    all_exports.push((result.path_str.clone(), result.exports));
665                }
666
667                new_hashes.insert(result.path_str, result.hash);
668            }
669
670            // Flush trigram index batch to disk if batch-flush mode is enabled
671            if total_files > 10000 {
672                let flush_msg = format!("Flushing batch {}/{}...", batch_idx + 1, num_batches);
673                if show_progress {
674                    pb.set_message(flush_msg.clone());
675                }
676                *progress_status.lock().unwrap() = flush_msg;
677                trigram_index
678                    .flush_batch()
679                    .context("Failed to flush trigram batch")?;
680            }
681        }
682
683        // Wait for progress thread to finish
684        if let Some(thread) = progress_thread {
685            let _ = thread.join();
686        }
687
688        // Update progress bar to final count
689        if show_progress {
690            let final_count = progress_counter.load(Ordering::Relaxed);
691            pb.set_position(final_count);
692        }
693
694        // Finalize trigram index (sort and deduplicate posting lists)
695        *progress_status.lock().unwrap() = "Finalizing trigram index...".to_string();
696        if show_progress {
697            pb.set_message("Finalizing trigram index...".to_string());
698        }
699        trigram_index.finalize();
700
701        // Update progress bar message for post-processing
702        *progress_status.lock().unwrap() = "Writing file metadata to database...".to_string();
703        if show_progress {
704            pb.set_message("Writing file metadata to database...".to_string());
705        }
706
707        // Batch write file metadata AND branch hashes in a SINGLE atomic transaction
708        // This ensures that if files are inserted, their hashes are guaranteed to be inserted too
709        if !file_metadata.is_empty() {
710            // Prepare files data (path, language, line_count)
711            let files_without_hash: Vec<(String, String, usize)> = file_metadata
712                .iter()
713                .map(|(path, _hash, lang, lines)| (path.clone(), lang.clone(), *lines))
714                .collect();
715
716            // Record files for this branch (for branch-aware indexing)
717            *progress_status.lock().unwrap() = "Recording branch files...".to_string();
718            if show_progress {
719                pb.set_message("Recording branch files...".to_string());
720            }
721
722            // Prepare branch files data (path, hash)
723            let branch_files: Vec<(String, String)> = file_metadata
724                .iter()
725                .map(|(path, hash, _, _)| (path.clone(), hash.clone()))
726                .collect();
727
728            // Use atomic method that combines both operations
729            self.cache
730                .batch_update_files_and_branch(
731                    &files_without_hash,
732                    &branch_files,
733                    &branch,
734                    git_state.as_ref().map(|s| s.commit.as_str()),
735                )
736                .context("Failed to batch update files and branch hashes")?;
737
738            log::info!(
739                "Wrote metadata and hashes for {} files to database",
740                file_metadata.len()
741            );
742        }
743
744        // Update branch metadata
745        self.cache.update_branch_metadata(
746            &branch,
747            git_state.as_ref().map(|s| s.commit.as_str()),
748            file_metadata.len(),
749            git_state.as_ref().map(|s| s.dirty).unwrap_or(false),
750        )?;
751
752        // Force WAL checkpoint to ensure background processes see all committed data
753        // This is critical when spawning background symbol indexer immediately after
754        self.cache
755            .checkpoint_wal()
756            .context("Failed to checkpoint WAL")?;
757        log::debug!("WAL checkpoint completed - database is fully synced");
758
759        // Step 2.5: Insert dependencies (after files are inserted and have IDs)
760        if !all_dependencies.is_empty() {
761            *progress_status.lock().unwrap() = "Extracting dependencies...".to_string();
762            if show_progress {
763                pb.set_message("Extracting dependencies...".to_string());
764            }
765
766            // Find and parse all go.mod files for Go projects (monorepo support)
767            let go_modules = crate::parsers::go::parse_all_go_modules(root).unwrap_or_else(|e| {
768                log::warn!("Failed to parse go.mod files: {}", e);
769                Vec::new()
770            });
771            if !go_modules.is_empty() {
772                log::info!("Found {} Go modules", go_modules.len());
773                for module in &go_modules {
774                    log::debug!("  {} (project: {})", module.name, module.project_root);
775                }
776            }
777
778            // Find and parse all pom.xml/build.gradle files for Java projects (monorepo support)
779            let java_projects =
780                crate::parsers::java::parse_all_java_projects(root).unwrap_or_else(|e| {
781                    log::warn!("Failed to parse Java project configs: {}", e);
782                    Vec::new()
783                });
784            if !java_projects.is_empty() {
785                log::info!("Found {} Java projects", java_projects.len());
786                for project in &java_projects {
787                    log::debug!(
788                        "  {} (project: {})",
789                        project.package_name,
790                        project.project_root
791                    );
792                }
793            }
794
795            // Find and parse all Python package configs for Python projects (monorepo support)
796            let python_packages = crate::parsers::python::parse_all_python_packages(root)
797                .unwrap_or_else(|e| {
798                    log::warn!("Failed to parse Python package configs: {}", e);
799                    Vec::new()
800                });
801            if !python_packages.is_empty() {
802                log::info!("Found {} Python packages", python_packages.len());
803                for package in &python_packages {
804                    log::debug!("  {} (project: {})", package.name, package.project_root);
805                }
806            }
807
808            // Find and parse *.gemspec files for Ruby projects (monorepo support)
809            let ruby_projects =
810                crate::parsers::ruby::parse_all_ruby_projects(root).unwrap_or_else(|e| {
811                    log::warn!("Failed to parse Ruby project configs: {}", e);
812                    Vec::new()
813                });
814            if !ruby_projects.is_empty() {
815                log::info!("Found {} Ruby projects", ruby_projects.len());
816                for project in &ruby_projects {
817                    log::debug!("  {} (project: {})", project.gem_name, project.project_root);
818                }
819            }
820
821            // Find and parse all Cargo.toml files for Rust workspace support
822            let rust_crates =
823                crate::parsers::rust::parse_all_rust_crates(root).unwrap_or_else(|e| {
824                    log::warn!("Failed to parse Cargo.toml files: {}", e);
825                    Vec::new()
826                });
827            if !rust_crates.is_empty() {
828                log::info!("Found {} Rust workspace crates", rust_crates.len());
829                for krate in &rust_crates {
830                    log::debug!("  {} (root: {})", krate.name, krate.root_path.display());
831                }
832            }
833
834            // Note: Kotlin projects use the same java_projects above (same build systems: Maven/Gradle)
835
836            // Find and parse all composer.json files for PHP projects (monorepo support)
837            let php_psr4_mappings = crate::parsers::php::parse_all_composer_psr4(root)
838                .unwrap_or_else(|e| {
839                    log::warn!("Failed to parse composer.json files: {}", e);
840                    Vec::new()
841                });
842            if !php_psr4_mappings.is_empty() {
843                log::info!(
844                    "Found {} PSR-4 mappings from composer.json files",
845                    php_psr4_mappings.len()
846                );
847                for mapping in &php_psr4_mappings {
848                    log::debug!(
849                        "  {} => {} (project: {})",
850                        mapping.namespace_prefix,
851                        mapping.directory,
852                        mapping.project_root
853                    );
854                }
855            }
856
857            // Find and parse all tsconfig.json files for TypeScript/Vue projects (monorepo support)
858            let tsconfigs =
859                crate::parsers::tsconfig::parse_all_tsconfigs(root).unwrap_or_else(|e| {
860                    log::warn!("Failed to parse tsconfig.json files: {}", e);
861                    HashMap::new()
862                });
863            if !tsconfigs.is_empty() {
864                log::info!("Found {} tsconfig.json files", tsconfigs.len());
865                for (config_dir, alias_map) in &tsconfigs {
866                    log::debug!(
867                        "  {} (base_url: {:?}, {} aliases)",
868                        config_dir.display(),
869                        alias_map.base_url,
870                        alias_map.aliases.len()
871                    );
872                }
873            }
874
875            // Create dependency index to resolve paths and insert dependencies
876            let cache_for_deps = CacheManager::new(root);
877            let dep_index = DependencyIndex::new(cache_for_deps);
878
879            let mut total_deps_inserted = 0;
880
881            // Process each file's dependencies
882            for (file_path, import_infos) in all_dependencies {
883                // Get file ID from database
884                let file_id = match dep_index.get_file_id_by_path(&file_path)? {
885                    Some(id) => id,
886                    None => {
887                        log::warn!(
888                            "File not found in database (skipping dependencies): {}",
889                            file_path
890                        );
891                        continue;
892                    }
893                };
894
895                // Reclassify and filter dependencies
896                let mut resolved_deps = Vec::new();
897
898                for mut import_info in import_infos {
899                    // Reclassify Go imports using module names (if Go project)
900                    if file_path.ends_with(".go") {
901                        // Check if the import matches any Go module
902                        let mut reclassified = false;
903                        for module in &go_modules {
904                            import_info.import_type = crate::parsers::go::reclassify_go_import(
905                                &import_info.imported_path,
906                                Some(&module.name),
907                            );
908                            // If it's internal, we've found the right module
909                            if matches!(import_info.import_type, ImportType::Internal) {
910                                reclassified = true;
911                                break;
912                            }
913                        }
914                        // If no module matched, use base classification
915                        if !reclassified {
916                            import_info.import_type = crate::parsers::go::reclassify_go_import(
917                                &import_info.imported_path,
918                                None,
919                            );
920                        }
921                    }
922
923                    // Reclassify Java imports using package names (if Java project)
924                    if file_path.ends_with(".java") {
925                        // Check if the import matches any Java project
926                        let mut reclassified = false;
927                        for project in &java_projects {
928                            import_info.import_type = crate::parsers::java::reclassify_java_import(
929                                &import_info.imported_path,
930                                Some(&project.package_name),
931                            );
932                            // If it's internal, we've found the right project
933                            if matches!(import_info.import_type, ImportType::Internal) {
934                                reclassified = true;
935                                break;
936                            }
937                        }
938                        // If no project matched, use base classification
939                        if !reclassified {
940                            import_info.import_type = crate::parsers::java::reclassify_java_import(
941                                &import_info.imported_path,
942                                None,
943                            );
944                        }
945                    }
946
947                    // Reclassify Python imports using package names (if Python project)
948                    if file_path.ends_with(".py") {
949                        // Check if the import matches any Python package
950                        let mut reclassified = false;
951                        for package in &python_packages {
952                            import_info.import_type =
953                                crate::parsers::python::reclassify_python_import(
954                                    &import_info.imported_path,
955                                    Some(&package.name),
956                                );
957                            // If it's internal, we've found the right package
958                            if matches!(import_info.import_type, ImportType::Internal) {
959                                reclassified = true;
960                                break;
961                            }
962                        }
963                        // If no package matched, use base classification
964                        if !reclassified {
965                            import_info.import_type =
966                                crate::parsers::python::reclassify_python_import(
967                                    &import_info.imported_path,
968                                    None,
969                                );
970                        }
971                    }
972
973                    // Reclassify Ruby imports using gem names (if Ruby project)
974                    if file_path.ends_with(".rb")
975                        || file_path.ends_with(".rake")
976                        || file_path.ends_with(".gemspec")
977                    {
978                        // Check if the import matches any Ruby project
979                        let mut reclassified = false;
980                        for project in &ruby_projects {
981                            let gem_names = vec![project.gem_name.clone()];
982                            import_info.import_type = crate::parsers::ruby::reclassify_ruby_import(
983                                &import_info.imported_path,
984                                &gem_names,
985                            );
986                            // If it's internal, we've found the right project
987                            if matches!(import_info.import_type, ImportType::Internal) {
988                                reclassified = true;
989                                break;
990                            }
991                        }
992                        // If no project matched, use base classification (will be External or Stdlib)
993                        if !reclassified {
994                            import_info.import_type = crate::parsers::ruby::reclassify_ruby_import(
995                                &import_info.imported_path,
996                                &[],
997                            );
998                        }
999                    }
1000
1001                    // Reclassify Kotlin imports using package names (if Kotlin project)
1002                    if file_path.ends_with(".kt") || file_path.ends_with(".kts") {
1003                        // Check if the import matches any Java/Kotlin project (same build systems)
1004                        let mut reclassified = false;
1005                        for project in &java_projects {
1006                            import_info.import_type =
1007                                crate::parsers::kotlin::reclassify_kotlin_import(
1008                                    &import_info.imported_path,
1009                                    Some(&project.package_name),
1010                                );
1011                            // If it's internal, we've found the right project
1012                            if matches!(import_info.import_type, ImportType::Internal) {
1013                                reclassified = true;
1014                                break;
1015                            }
1016                        }
1017                        // If no project matched, use base classification
1018                        if !reclassified {
1019                            import_info.import_type =
1020                                crate::parsers::kotlin::reclassify_kotlin_import(
1021                                    &import_info.imported_path,
1022                                    None,
1023                                );
1024                        }
1025                    }
1026
1027                    // Reclassify Rust imports using workspace crates
1028                    if file_path.ends_with(".rs") && !rust_crates.is_empty() {
1029                        let new_type = crate::parsers::rust::reclassify_rust_import(
1030                            &import_info.imported_path,
1031                            &rust_crates,
1032                        );
1033                        if matches!(new_type, ImportType::Internal) {
1034                            import_info.import_type = new_type;
1035                        }
1036                    }
1037
1038                    // External and Stdlib imports: store with resolved_file_id = None.
1039                    // Graph-analysis queries all filter WHERE resolved_file_id IS NOT NULL,
1040                    // so storing these here only affects the `rfx deps` display path (REF-78).
1041                    if matches!(
1042                        import_info.import_type,
1043                        ImportType::External | ImportType::Stdlib
1044                    ) {
1045                        resolved_deps.push(Dependency {
1046                            file_id,
1047                            imported_path: import_info.imported_path.clone(),
1048                            resolved_file_id: None,
1049                            import_type: import_info.import_type.clone(),
1050                            line_number: import_info.line_number,
1051                            imported_symbols: import_info.imported_symbols.clone(),
1052                        });
1053                        continue;
1054                    }
1055
1056                    // Resolve PHP dependencies using PSR-4 (deterministic)
1057                    let resolved_file_id = if file_path.ends_with(".php")
1058                        && !php_psr4_mappings.is_empty()
1059                    {
1060                        // Use PSR-4 to resolve namespace to file path
1061                        if let Some(resolved_path) =
1062                            crate::parsers::php::resolve_php_namespace_to_path(
1063                                &import_info.imported_path,
1064                                &php_psr4_mappings,
1065                            )
1066                        {
1067                            // Look up file ID in database using exact match
1068                            match dep_index.get_file_id_by_path(&resolved_path) {
1069                                Ok(Some(id)) => {
1070                                    log::trace!(
1071                                        "Resolved PHP dependency: {} -> {} (file_id={})",
1072                                        import_info.imported_path,
1073                                        resolved_path,
1074                                        id
1075                                    );
1076                                    Some(id)
1077                                }
1078                                Ok(None) => {
1079                                    log::trace!(
1080                                        "PHP dependency resolved to path but file not in index: {} -> {}",
1081                                        import_info.imported_path,
1082                                        resolved_path
1083                                    );
1084                                    None
1085                                }
1086                                Err(e) => {
1087                                    log::debug!(
1088                                        "Skipping PHP dependency resolution for '{}': {}",
1089                                        resolved_path,
1090                                        e
1091                                    );
1092                                    None
1093                                }
1094                            }
1095                        } else {
1096                            log::trace!(
1097                                "Could not resolve PHP namespace using PSR-4: {}",
1098                                import_info.imported_path
1099                            );
1100                            None
1101                        }
1102                    } else if file_path.ends_with(".py") && !python_packages.is_empty() {
1103                        // Resolve Python dependencies using package mappings
1104                        if let Some(resolved_path) =
1105                            crate::parsers::python::resolve_python_import_to_path(
1106                                &import_info.imported_path,
1107                                &python_packages,
1108                                Some(&file_path),
1109                            )
1110                        {
1111                            // Look up file ID in database using exact match
1112                            match dep_index.get_file_id_by_path(&resolved_path) {
1113                                Ok(Some(id)) => {
1114                                    log::trace!(
1115                                        "Resolved Python dependency: {} -> {} (file_id={})",
1116                                        import_info.imported_path,
1117                                        resolved_path,
1118                                        id
1119                                    );
1120                                    Some(id)
1121                                }
1122                                Ok(None) => {
1123                                    log::trace!(
1124                                        "Python dependency resolved to path but file not in index: {} -> {}",
1125                                        import_info.imported_path,
1126                                        resolved_path
1127                                    );
1128                                    None
1129                                }
1130                                Err(e) => {
1131                                    log::debug!(
1132                                        "Skipping Python dependency resolution for '{}': {}",
1133                                        resolved_path,
1134                                        e
1135                                    );
1136                                    None
1137                                }
1138                            }
1139                        } else {
1140                            log::trace!(
1141                                "Could not resolve Python import: {}",
1142                                import_info.imported_path
1143                            );
1144                            None
1145                        }
1146                    } else if file_path.ends_with(".go") && !go_modules.is_empty() {
1147                        // Resolve Go dependencies using module mappings
1148                        if let Some(resolved_path) = crate::parsers::go::resolve_go_import_to_path(
1149                            &import_info.imported_path,
1150                            &go_modules,
1151                            Some(&file_path),
1152                        ) {
1153                            // Look up file ID in database using exact match
1154                            match dep_index.get_file_id_by_path(&resolved_path) {
1155                                Ok(Some(id)) => {
1156                                    log::trace!(
1157                                        "Resolved Go dependency: {} -> {} (file_id={})",
1158                                        import_info.imported_path,
1159                                        resolved_path,
1160                                        id
1161                                    );
1162                                    Some(id)
1163                                }
1164                                Ok(None) => {
1165                                    log::trace!(
1166                                        "Go dependency resolved to path but file not in index: {} -> {}",
1167                                        import_info.imported_path,
1168                                        resolved_path
1169                                    );
1170                                    None
1171                                }
1172                                Err(e) => {
1173                                    log::debug!(
1174                                        "Skipping Go dependency resolution for '{}': {}",
1175                                        resolved_path,
1176                                        e
1177                                    );
1178                                    None
1179                                }
1180                            }
1181                        } else {
1182                            log::trace!(
1183                                "Could not resolve Go import: {}",
1184                                import_info.imported_path
1185                            );
1186                            None
1187                        }
1188                    } else if file_path.ends_with(".ts")
1189                        || file_path.ends_with(".tsx")
1190                        || file_path.ends_with(".js")
1191                        || file_path.ends_with(".jsx")
1192                        || file_path.ends_with(".mts")
1193                        || file_path.ends_with(".cts")
1194                        || file_path.ends_with(".mjs")
1195                        || file_path.ends_with(".cjs")
1196                    {
1197                        // Resolve TypeScript/JavaScript dependencies (relative imports and path aliases)
1198                        let alias_map = find_nearest_tsconfig(&file_path, root, &tsconfigs);
1199                        if let Some(candidates_str) =
1200                            crate::parsers::typescript::resolve_ts_import_to_path(
1201                                &import_info.imported_path,
1202                                Some(&file_path),
1203                                alias_map,
1204                            )
1205                        {
1206                            // Parse pipe-delimited candidates (e.g., "path.tsx|path.ts|path.jsx|path.js")
1207                            let candidates: Vec<&str> = candidates_str.split('|').collect();
1208
1209                            // Try each candidate in order until we find one in the database
1210                            let mut resolved_id = None;
1211                            for candidate_path in candidates {
1212                                // Normalize path to be relative to project root
1213                                // Convert absolute paths to relative (without requiring file to exist)
1214                                let normalized_candidate = if let Ok(rel_path) =
1215                                    std::path::Path::new(candidate_path).strip_prefix(root)
1216                                {
1217                                    rel_path.to_string_lossy().replace('\\', "/")
1218                                } else {
1219                                    // Not an absolute path or not under root - use as-is
1220                                    // (still normalize separators so DB lookups match).
1221                                    candidate_path.replace('\\', "/")
1222                                };
1223
1224                                log::debug!(
1225                                    "Looking up TS/JS candidate: '{}' (from '{}')",
1226                                    normalized_candidate,
1227                                    candidate_path
1228                                );
1229                                match dep_index.get_file_id_by_path(&normalized_candidate) {
1230                                    Ok(Some(id)) => {
1231                                        log::debug!(
1232                                            "Resolved TS/JS dependency: {} -> {} (file_id={})",
1233                                            import_info.imported_path,
1234                                            normalized_candidate,
1235                                            id
1236                                        );
1237                                        resolved_id = Some(id);
1238                                        break; // Found a match, stop trying
1239                                    }
1240                                    Ok(None) => {
1241                                        log::trace!(
1242                                            "TS/JS candidate not in index: {}",
1243                                            candidate_path
1244                                        );
1245                                    }
1246                                    Err(e) => {
1247                                        log::debug!(
1248                                            "Skipping TS/JS dependency resolution for '{}': {}",
1249                                            normalized_candidate,
1250                                            e
1251                                        );
1252                                    }
1253                                }
1254                            }
1255
1256                            if resolved_id.is_none() {
1257                                log::trace!(
1258                                    "TS/JS dependency: no matching file found in database for any candidate: {}",
1259                                    candidates_str
1260                                );
1261                            }
1262
1263                            resolved_id
1264                        } else {
1265                            log::trace!(
1266                                "Could not resolve TS/JS import (non-relative or external): {}",
1267                                import_info.imported_path
1268                            );
1269                            None
1270                        }
1271                    } else if file_path.ends_with(".rs") {
1272                        // Resolve Rust dependencies (crate::, super::, self::, mod declarations)
1273                        // Falls back to workspace resolution for cross-crate imports
1274                        let resolved_path_opt = crate::parsers::rust::resolve_rust_use_to_path(
1275                            &import_info.imported_path,
1276                            Some(&file_path),
1277                            Some(root.to_str().unwrap_or("")),
1278                        )
1279                        .or_else(|| {
1280                            crate::parsers::rust::resolve_rust_workspace_path(
1281                                &import_info.imported_path,
1282                                &rust_crates,
1283                            )
1284                        });
1285
1286                        if let Some(resolved_path) = resolved_path_opt {
1287                            // Look up file ID in database using exact match
1288                            match dep_index.get_file_id_by_path(&resolved_path) {
1289                                Ok(Some(id)) => {
1290                                    log::trace!(
1291                                        "Resolved Rust dependency: {} -> {} (file_id={})",
1292                                        import_info.imported_path,
1293                                        resolved_path,
1294                                        id
1295                                    );
1296                                    Some(id)
1297                                }
1298                                Ok(None) => {
1299                                    log::trace!(
1300                                        "Rust dependency resolved to path but file not in index: {} -> {}",
1301                                        import_info.imported_path,
1302                                        resolved_path
1303                                    );
1304                                    None
1305                                }
1306                                Err(e) => {
1307                                    log::debug!(
1308                                        "Skipping Rust dependency resolution for '{}': {}",
1309                                        resolved_path,
1310                                        e
1311                                    );
1312                                    None
1313                                }
1314                            }
1315                        } else {
1316                            log::trace!(
1317                                "Could not resolve Rust import (external or stdlib): {}",
1318                                import_info.imported_path
1319                            );
1320                            None
1321                        }
1322                    } else if file_path.ends_with(".java") && !java_projects.is_empty() {
1323                        // Resolve Java dependencies using project mappings
1324                        if let Some(resolved_path) =
1325                            crate::parsers::java::resolve_java_import_to_path(
1326                                &import_info.imported_path,
1327                                &java_projects,
1328                                Some(&file_path),
1329                            )
1330                        {
1331                            // Look up file ID in database using exact match
1332                            match dep_index.get_file_id_by_path(&resolved_path) {
1333                                Ok(Some(id)) => {
1334                                    log::trace!(
1335                                        "Resolved Java dependency: {} -> {} (file_id={})",
1336                                        import_info.imported_path,
1337                                        resolved_path,
1338                                        id
1339                                    );
1340                                    Some(id)
1341                                }
1342                                Ok(None) => {
1343                                    log::trace!(
1344                                        "Java dependency resolved to path but file not in index: {} -> {}",
1345                                        import_info.imported_path,
1346                                        resolved_path
1347                                    );
1348                                    None
1349                                }
1350                                Err(e) => {
1351                                    log::debug!(
1352                                        "Skipping Java dependency resolution for '{}': {}",
1353                                        resolved_path,
1354                                        e
1355                                    );
1356                                    None
1357                                }
1358                            }
1359                        } else {
1360                            log::trace!(
1361                                "Could not resolve Java import: {}",
1362                                import_info.imported_path
1363                            );
1364                            None
1365                        }
1366                    } else if (file_path.ends_with(".kt") || file_path.ends_with(".kts"))
1367                        && !java_projects.is_empty()
1368                    {
1369                        // Resolve Kotlin dependencies using project mappings (same build systems as Java)
1370                        if let Some(resolved_path) =
1371                            crate::parsers::java::resolve_kotlin_import_to_path(
1372                                &import_info.imported_path,
1373                                &java_projects,
1374                                Some(&file_path),
1375                            )
1376                        {
1377                            // Look up file ID in database using exact match
1378                            match dep_index.get_file_id_by_path(&resolved_path) {
1379                                Ok(Some(id)) => {
1380                                    log::trace!(
1381                                        "Resolved Kotlin dependency: {} -> {} (file_id={})",
1382                                        import_info.imported_path,
1383                                        resolved_path,
1384                                        id
1385                                    );
1386                                    Some(id)
1387                                }
1388                                Ok(None) => {
1389                                    log::trace!(
1390                                        "Kotlin dependency resolved to path but file not in index: {} -> {}",
1391                                        import_info.imported_path,
1392                                        resolved_path
1393                                    );
1394                                    None
1395                                }
1396                                Err(e) => {
1397                                    log::debug!(
1398                                        "Skipping Kotlin dependency resolution for '{}': {}",
1399                                        resolved_path,
1400                                        e
1401                                    );
1402                                    None
1403                                }
1404                            }
1405                        } else {
1406                            log::trace!(
1407                                "Could not resolve Kotlin import: {}",
1408                                import_info.imported_path
1409                            );
1410                            None
1411                        }
1412                    } else if (file_path.ends_with(".rb")
1413                        || file_path.ends_with(".rake")
1414                        || file_path.ends_with(".gemspec"))
1415                        && !ruby_projects.is_empty()
1416                    {
1417                        // Resolve Ruby dependencies using project mappings
1418                        if let Some(resolved_path) =
1419                            crate::parsers::ruby::resolve_ruby_require_to_path(
1420                                &import_info.imported_path,
1421                                &ruby_projects,
1422                                Some(&file_path),
1423                            )
1424                        {
1425                            // Look up file ID in database using exact match
1426                            match dep_index.get_file_id_by_path(&resolved_path) {
1427                                Ok(Some(id)) => {
1428                                    log::trace!(
1429                                        "Resolved Ruby dependency: {} -> {} (file_id={})",
1430                                        import_info.imported_path,
1431                                        resolved_path,
1432                                        id
1433                                    );
1434                                    Some(id)
1435                                }
1436                                Ok(None) => {
1437                                    log::trace!(
1438                                        "Ruby dependency resolved to path but file not in index: {} -> {}",
1439                                        import_info.imported_path,
1440                                        resolved_path
1441                                    );
1442                                    None
1443                                }
1444                                Err(e) => {
1445                                    log::debug!(
1446                                        "Skipping Ruby dependency resolution for '{}': {}",
1447                                        resolved_path,
1448                                        e
1449                                    );
1450                                    None
1451                                }
1452                            }
1453                        } else {
1454                            log::trace!(
1455                                "Could not resolve Ruby require: {}",
1456                                import_info.imported_path
1457                            );
1458                            None
1459                        }
1460                    } else if file_path.ends_with(".c") || file_path.ends_with(".h") {
1461                        // Resolve C dependencies (relative #include paths)
1462                        if let Some(resolved_path) = crate::parsers::c::resolve_c_include_to_path(
1463                            &import_info.imported_path,
1464                            Some(&file_path),
1465                        ) {
1466                            // Look up file ID in database using exact match
1467                            match dep_index.get_file_id_by_path(&resolved_path) {
1468                                Ok(Some(id)) => {
1469                                    log::trace!(
1470                                        "Resolved C dependency: {} -> {} (file_id={})",
1471                                        import_info.imported_path,
1472                                        resolved_path,
1473                                        id
1474                                    );
1475                                    Some(id)
1476                                }
1477                                Ok(None) => {
1478                                    log::trace!(
1479                                        "C dependency resolved to path but file not in index: {} -> {}",
1480                                        import_info.imported_path,
1481                                        resolved_path
1482                                    );
1483                                    None
1484                                }
1485                                Err(e) => {
1486                                    log::debug!(
1487                                        "Skipping C dependency resolution for '{}': {}",
1488                                        resolved_path,
1489                                        e
1490                                    );
1491                                    None
1492                                }
1493                            }
1494                        } else {
1495                            log::trace!(
1496                                "Could not resolve C include (system header): {}",
1497                                import_info.imported_path
1498                            );
1499                            None
1500                        }
1501                    } else if file_path.ends_with(".cpp")
1502                        || file_path.ends_with(".cc")
1503                        || file_path.ends_with(".cxx")
1504                        || file_path.ends_with(".hpp")
1505                        || file_path.ends_with(".hxx")
1506                        || file_path.ends_with(".h++")
1507                        || file_path.ends_with(".C")
1508                        || file_path.ends_with(".H")
1509                    {
1510                        // Resolve C++ dependencies (relative #include paths)
1511                        if let Some(resolved_path) =
1512                            crate::parsers::cpp::resolve_cpp_include_to_path(
1513                                &import_info.imported_path,
1514                                Some(&file_path),
1515                            )
1516                        {
1517                            // Look up file ID in database using exact match
1518                            match dep_index.get_file_id_by_path(&resolved_path) {
1519                                Ok(Some(id)) => {
1520                                    log::trace!(
1521                                        "Resolved C++ dependency: {} -> {} (file_id={})",
1522                                        import_info.imported_path,
1523                                        resolved_path,
1524                                        id
1525                                    );
1526                                    Some(id)
1527                                }
1528                                Ok(None) => {
1529                                    log::trace!(
1530                                        "C++ dependency resolved to path but file not in index: {} -> {}",
1531                                        import_info.imported_path,
1532                                        resolved_path
1533                                    );
1534                                    None
1535                                }
1536                                Err(e) => {
1537                                    log::debug!(
1538                                        "Skipping C++ dependency resolution for '{}': {}",
1539                                        resolved_path,
1540                                        e
1541                                    );
1542                                    None
1543                                }
1544                            }
1545                        } else {
1546                            log::trace!(
1547                                "Could not resolve C++ include (system header): {}",
1548                                import_info.imported_path
1549                            );
1550                            None
1551                        }
1552                    } else if file_path.ends_with(".cs") {
1553                        // Resolve C# dependencies (using namespace-to-path mapping)
1554                        if let Some(resolved_path) =
1555                            crate::parsers::csharp::resolve_csharp_using_to_path(
1556                                &import_info.imported_path,
1557                                Some(&file_path),
1558                            )
1559                        {
1560                            // Look up file ID in database using exact match
1561                            match dep_index.get_file_id_by_path(&resolved_path) {
1562                                Ok(Some(id)) => {
1563                                    log::trace!(
1564                                        "Resolved C# dependency: {} -> {} (file_id={})",
1565                                        import_info.imported_path,
1566                                        resolved_path,
1567                                        id
1568                                    );
1569                                    Some(id)
1570                                }
1571                                Ok(None) => {
1572                                    log::trace!(
1573                                        "C# dependency resolved to path but file not in index: {} -> {}",
1574                                        import_info.imported_path,
1575                                        resolved_path
1576                                    );
1577                                    None
1578                                }
1579                                Err(e) => {
1580                                    log::debug!(
1581                                        "Skipping C# dependency resolution for '{}': {}",
1582                                        resolved_path,
1583                                        e
1584                                    );
1585                                    None
1586                                }
1587                            }
1588                        } else {
1589                            log::trace!(
1590                                "Could not resolve C# using directive: {}",
1591                                import_info.imported_path
1592                            );
1593                            None
1594                        }
1595                    } else if file_path.ends_with(".zig") {
1596                        // Resolve Zig dependencies (relative @import paths)
1597                        if let Some(resolved_path) = crate::parsers::zig::resolve_zig_import_to_path(
1598                            &import_info.imported_path,
1599                            Some(&file_path),
1600                        ) {
1601                            // Look up file ID in database using exact match
1602                            match dep_index.get_file_id_by_path(&resolved_path) {
1603                                Ok(Some(id)) => {
1604                                    log::trace!(
1605                                        "Resolved Zig dependency: {} -> {} (file_id={})",
1606                                        import_info.imported_path,
1607                                        resolved_path,
1608                                        id
1609                                    );
1610                                    Some(id)
1611                                }
1612                                Ok(None) => {
1613                                    log::trace!(
1614                                        "Zig dependency resolved to path but file not in index: {} -> {}",
1615                                        import_info.imported_path,
1616                                        resolved_path
1617                                    );
1618                                    None
1619                                }
1620                                Err(e) => {
1621                                    log::debug!(
1622                                        "Skipping Zig dependency resolution for '{}': {}",
1623                                        resolved_path,
1624                                        e
1625                                    );
1626                                    None
1627                                }
1628                            }
1629                        } else {
1630                            log::trace!(
1631                                "Could not resolve Zig import (external or stdlib): {}",
1632                                import_info.imported_path
1633                            );
1634                            None
1635                        }
1636                    } else if file_path.ends_with(".vue") || file_path.ends_with(".svelte") {
1637                        // Resolve Vue/Svelte dependencies (use TypeScript/JavaScript resolver for imports in <script> blocks)
1638                        let alias_map = find_nearest_tsconfig(&file_path, root, &tsconfigs);
1639                        if let Some(candidates_str) =
1640                            crate::parsers::typescript::resolve_ts_import_to_path(
1641                                &import_info.imported_path,
1642                                Some(&file_path),
1643                                alias_map,
1644                            )
1645                        {
1646                            // Parse pipe-delimited candidates (e.g., "path.tsx|path.ts|path.jsx|path.js")
1647                            let candidates: Vec<&str> = candidates_str.split('|').collect();
1648
1649                            // Try each candidate in order until we find one in the database
1650                            let mut resolved_id = None;
1651                            for candidate_path in candidates {
1652                                // Normalize path to be relative to project root
1653                                // Convert absolute paths to relative (without requiring file to exist)
1654                                let normalized_candidate = if let Ok(rel_path) =
1655                                    std::path::Path::new(candidate_path).strip_prefix(root)
1656                                {
1657                                    rel_path.to_string_lossy().replace('\\', "/")
1658                                } else {
1659                                    // Not an absolute path or not under root - use as-is
1660                                    // (still normalize separators so DB lookups match).
1661                                    candidate_path.replace('\\', "/")
1662                                };
1663
1664                                match dep_index.get_file_id_by_path(&normalized_candidate) {
1665                                    Ok(Some(id)) => {
1666                                        log::trace!(
1667                                            "Resolved Vue/Svelte dependency: {} -> {} (file_id={})",
1668                                            import_info.imported_path,
1669                                            candidate_path,
1670                                            id
1671                                        );
1672                                        resolved_id = Some(id);
1673                                        break; // Found a match, stop trying
1674                                    }
1675                                    Ok(None) => {
1676                                        log::trace!(
1677                                            "Vue/Svelte candidate not in index: {}",
1678                                            candidate_path
1679                                        );
1680                                    }
1681                                    Err(e) => {
1682                                        log::debug!(
1683                                            "Skipping Vue/Svelte dependency resolution for '{}': {}",
1684                                            normalized_candidate,
1685                                            e
1686                                        );
1687                                    }
1688                                }
1689                            }
1690
1691                            if resolved_id.is_none() {
1692                                log::trace!(
1693                                    "Vue/Svelte dependency: no matching file found in database for any candidate: {}",
1694                                    candidates_str
1695                                );
1696                            }
1697
1698                            resolved_id
1699                        } else {
1700                            log::trace!(
1701                                "Could not resolve Vue/Svelte import (non-relative or external): {}",
1702                                import_info.imported_path
1703                            );
1704                            None
1705                        }
1706                    } else {
1707                        None
1708                    };
1709
1710                    // resolved_file_id will be populated using deterministic language-specific resolution
1711                    // All language resolvers have been implemented!
1712                    resolved_deps.push(Dependency {
1713                        file_id,
1714                        imported_path: import_info.imported_path.clone(),
1715                        resolved_file_id,
1716                        import_type: import_info.import_type,
1717                        line_number: import_info.line_number,
1718                        imported_symbols: import_info.imported_symbols.clone(),
1719                    });
1720                }
1721
1722                // Clear existing dependencies for this file (incremental reindex)
1723                dep_index.clear_dependencies(file_id)?;
1724
1725                // Batch insert dependencies
1726                if !resolved_deps.is_empty() {
1727                    dep_index.batch_insert_dependencies(&resolved_deps)?;
1728                    total_deps_inserted += resolved_deps.len();
1729                }
1730            }
1731
1732            log::info!("Extracted {} dependencies", total_deps_inserted);
1733        }
1734
1735        // Step 2.6: Insert exports (after files are inserted and have IDs)
1736        if !all_exports.is_empty() {
1737            *progress_status.lock().unwrap() = "Extracting exports...".to_string();
1738            if show_progress {
1739                pb.set_message("Extracting exports...".to_string());
1740            }
1741
1742            // Reuse the tsconfigs parsed earlier for TypeScript/Vue path alias resolution
1743            let tsconfigs =
1744                crate::parsers::tsconfig::parse_all_tsconfigs(root).unwrap_or_else(|e| {
1745                    log::warn!("Failed to parse tsconfig.json files: {}", e);
1746                    HashMap::new()
1747                });
1748
1749            // Create dependency index to resolve paths and insert exports
1750            let cache_for_exports = CacheManager::new(root);
1751            let dep_index = DependencyIndex::new(cache_for_exports);
1752
1753            let mut total_exports_inserted = 0;
1754
1755            // Process each file's exports
1756            for (file_path, export_infos) in all_exports {
1757                // Get file ID from database
1758                let file_id = match dep_index.get_file_id_by_path(&file_path)? {
1759                    Some(id) => id,
1760                    None => {
1761                        log::warn!(
1762                            "File not found in database (skipping exports): {}",
1763                            file_path
1764                        );
1765                        continue;
1766                    }
1767                };
1768
1769                // Resolve export source paths and insert
1770                for export_info in export_infos {
1771                    // Resolve export source path (same logic as imports)
1772                    let resolved_source_id = if file_path.ends_with(".ts")
1773                        || file_path.ends_with(".tsx")
1774                        || file_path.ends_with(".js")
1775                        || file_path.ends_with(".jsx")
1776                        || file_path.ends_with(".mts")
1777                        || file_path.ends_with(".cts")
1778                        || file_path.ends_with(".mjs")
1779                        || file_path.ends_with(".cjs")
1780                        || file_path.ends_with(".vue")
1781                    {
1782                        // Resolve TypeScript/JavaScript/Vue export paths (relative imports and path aliases)
1783                        let alias_map = find_nearest_tsconfig(&file_path, root, &tsconfigs);
1784                        if let Some(candidates_str) =
1785                            crate::parsers::typescript::resolve_ts_import_to_path(
1786                                &export_info.source_path,
1787                                Some(&file_path),
1788                                alias_map,
1789                            )
1790                        {
1791                            // Parse pipe-delimited candidates (e.g., "path.tsx|path.ts|path.jsx|path.js|path.vue")
1792                            let candidates: Vec<&str> = candidates_str.split('|').collect();
1793
1794                            // Try each candidate in order until we find one in the database
1795                            let mut resolved_id = None;
1796                            for candidate_path in candidates {
1797                                // Normalize path to be relative to project root
1798                                let normalized_candidate = if let Ok(rel_path) =
1799                                    std::path::Path::new(candidate_path).strip_prefix(root)
1800                                {
1801                                    rel_path.to_string_lossy().to_string()
1802                                } else {
1803                                    candidate_path.to_string()
1804                                };
1805
1806                                match dep_index.get_file_id_by_path(&normalized_candidate) {
1807                                    Ok(Some(id)) => {
1808                                        log::trace!(
1809                                            "Resolved export source: {} -> {} (file_id={})",
1810                                            export_info.source_path,
1811                                            normalized_candidate,
1812                                            id
1813                                        );
1814                                        resolved_id = Some(id);
1815                                        break; // Found a match, stop trying
1816                                    }
1817                                    Ok(None) => {
1818                                        log::trace!(
1819                                            "Export source candidate not in index: {}",
1820                                            candidate_path
1821                                        );
1822                                    }
1823                                    Err(e) => {
1824                                        log::debug!(
1825                                            "Skipping export source resolution for '{}': {}",
1826                                            normalized_candidate,
1827                                            e
1828                                        );
1829                                    }
1830                                }
1831                            }
1832
1833                            if resolved_id.is_none() {
1834                                log::trace!(
1835                                    "Export source: no matching file found in database for any candidate: {}",
1836                                    candidates_str
1837                                );
1838                            }
1839
1840                            resolved_id
1841                        } else {
1842                            log::trace!(
1843                                "Could not resolve export source (non-relative or external): {}",
1844                                export_info.source_path
1845                            );
1846                            None
1847                        }
1848                    } else {
1849                        None
1850                    };
1851
1852                    // Insert export into database
1853                    dep_index.insert_export(
1854                        file_id,
1855                        export_info.exported_symbol,
1856                        export_info.source_path,
1857                        resolved_source_id,
1858                        export_info.line_number,
1859                    )?;
1860
1861                    total_exports_inserted += 1;
1862                }
1863            }
1864
1865            log::info!("Extracted {} exports", total_exports_inserted);
1866        }
1867
1868        log::info!("Indexed {} files", files_indexed);
1869
1870        // Step 3: Write trigram index.
1871        // Non-atomic write: trigrams.bin is overwritten in-place (truncate + stream write).
1872        // No temp-file-then-rename is used. A disk-full mid-write leaves a corrupt file;
1873        // the fast-path incremental check above validates the magic bytes on re-index,
1874        // so the next `rfx index` will detect corruption and rebuild from scratch.
1875        *progress_status.lock().unwrap() = "Writing trigram index...".to_string();
1876        if show_progress {
1877            pb.set_message("Writing trigram index...".to_string());
1878        }
1879        let trigrams_path = self.cache.path().join("trigrams.bin");
1880        log::info!(
1881            "Writing trigram index with {} trigrams to trigrams.bin",
1882            trigram_index.trigram_count()
1883        );
1884
1885        trigram_index
1886            .write(&trigrams_path)
1887            .context("Failed to write trigram index")?;
1888        log::info!("Wrote {} files to trigrams.bin", trigram_index.file_count());
1889
1890        // Step 4: Finalize content store (already been writing incrementally)
1891        *progress_status.lock().unwrap() = "Finalizing content store...".to_string();
1892        if show_progress {
1893            pb.set_message("Finalizing content store...".to_string());
1894        }
1895        content_writer
1896            .finalize_if_needed()
1897            .context("Failed to finalize content store")?;
1898        log::info!(
1899            "Wrote {} files ({} bytes) to content.bin",
1900            content_writer.file_count(),
1901            content_writer.content_size()
1902        );
1903
1904        // Step 5: Update SQLite statistics from database totals (branch-aware)
1905        *progress_status.lock().unwrap() = "Updating statistics...".to_string();
1906        if show_progress {
1907            pb.set_message("Updating statistics...".to_string());
1908        }
1909        // Update stats for current branch only
1910        self.cache.update_stats(&branch)?;
1911
1912        // Update schema hash to mark cache as compatible with current binary
1913        self.cache.update_schema_hash()?;
1914
1915        pb.finish_with_message("Indexing complete");
1916
1917        // Return stats with incremental breakdown
1918        let mut stats = self.cache.stats()?;
1919        stats.new_files = new_file_count;
1920        stats.modified_files = modified_file_count;
1921        stats.unchanged_files = unchanged_file_count;
1922        stats.skipped_too_large = skipped_too_large;
1923        stats.skipped_bytes_too_large = skipped_bytes_too_large;
1924        log::info!(
1925            "Indexing complete: {} files (new={}, modified={}, unchanged={})",
1926            stats.total_files,
1927            new_file_count,
1928            modified_file_count,
1929            unchanged_file_count
1930        );
1931
1932        Ok(stats)
1933    }
1934
1935    /// Discover all indexable files in the directory tree.
1936    ///
1937    /// Returns `(files, skipped_too_large_count, skipped_too_large_bytes)`.
1938    fn discover_files(&self, root: &Path) -> Result<(Vec<PathBuf>, usize, u64)> {
1939        let mut files = Vec::new();
1940        let mut skipped_count = 0usize;
1941        let mut skipped_bytes = 0u64;
1942
1943        // WalkBuilder from ignore crate automatically respects:
1944        // - .gitignore (when in a git repo)
1945        // - .ignore files
1946        // - Hidden files (can be configured)
1947        let walker = WalkBuilder::new(root)
1948            .follow_links(self.config.follow_symlinks)
1949            .git_ignore(true) // Explicitly enable gitignore support (enabled by default, but be explicit)
1950            .git_global(false) // Don't use global gitignore
1951            .git_exclude(false) // Don't use .git/info/exclude
1952            .build();
1953
1954        for entry in walker {
1955            let entry = entry?;
1956            let path = entry.path();
1957
1958            // Only process files (not directories)
1959            if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
1960                continue;
1961            }
1962
1963            // Check extension / language eligibility first (cheap)
1964            if !self.should_index_lang(path) {
1965                continue;
1966            }
1967
1968            // Check file size separately so we can report skipped counts
1969            if let Ok(metadata) = std::fs::metadata(path) {
1970                let size = metadata.len();
1971                if size > self.config.max_file_size as u64 {
1972                    log::debug!("Skipping {} (too large: {} bytes)", path.display(), size);
1973                    skipped_count += 1;
1974                    skipped_bytes += size;
1975                    continue;
1976                }
1977            }
1978
1979            files.push(path.to_path_buf());
1980        }
1981
1982        Ok((files, skipped_count, skipped_bytes))
1983    }
1984
1985    /// Check if a file's language/extension is eligible for indexing (without size check).
1986    fn should_index_lang(&self, path: &Path) -> bool {
1987        let ext = match path.extension() {
1988            Some(ext) => ext.to_string_lossy(),
1989            None => return false,
1990        };
1991
1992        let lang = Language::from_extension(&ext);
1993
1994        if !lang.is_supported() {
1995            if !matches!(lang, Language::Unknown) {
1996                log::debug!(
1997                    "Skipping {} ({:?} parser not yet implemented)",
1998                    path.display(),
1999                    lang
2000                );
2001            }
2002            return false;
2003        }
2004
2005        if !self.config.languages.is_empty() && !self.config.languages.contains(&lang) {
2006            log::debug!(
2007                "Skipping {} ({:?} not in configured languages)",
2008                path.display(),
2009                lang
2010            );
2011            return false;
2012        }
2013
2014        true
2015    }
2016
2017    /// Check if a file should be indexed based on config (language + size).
2018    #[allow(dead_code)]
2019    fn should_index(&self, path: &Path) -> bool {
2020        if !self.should_index_lang(path) {
2021            return false;
2022        }
2023
2024        // Check file size limits
2025        if let Ok(metadata) = std::fs::metadata(path)
2026            && metadata.len() > self.config.max_file_size as u64
2027        {
2028            log::debug!(
2029                "Skipping {} (too large: {} bytes)",
2030                path.display(),
2031                metadata.len()
2032            );
2033            return false;
2034        }
2035
2036        // TODO: Check include/exclude patterns when glob support is added
2037        // For now, accept all files with supported language extensions
2038
2039        true
2040    }
2041
2042    /// Compute blake3 hash from file contents for change detection
2043    fn hash_content(&self, content: &[u8]) -> String {
2044        let hash = blake3::hash(content);
2045        hash.to_hex().to_string()
2046    }
2047
2048    /// Check available disk space before indexing
2049    ///
2050    /// Ensures there's enough free space to create the index. Warns if disk space is low.
2051    /// This prevents partial index writes and confusing error messages.
2052    #[cfg_attr(not(unix), allow(unused_variables))]
2053    fn check_disk_space(&self, root: &Path) -> Result<()> {
2054        // Get available space on the filesystem containing the cache directory
2055        let cache_path = self.cache.path();
2056
2057        // Use statvfs on Unix systems
2058        #[cfg(unix)]
2059        {
2060            // On Linux, we can use statvfs to get available space
2061            // For now, we'll use a simple heuristic: warn if we can't write a test file
2062            let test_file = cache_path.join(".space_check");
2063            match std::fs::write(&test_file, b"test") {
2064                Ok(_) => {
2065                    let _ = std::fs::remove_file(&test_file);
2066
2067                    // Try to estimate available space using df command
2068                    if let Ok(output) = std::process::Command::new("df")
2069                        .arg("-k")
2070                        .arg(cache_path.parent().unwrap_or(root))
2071                        .output()
2072                        && let Ok(df_output) = String::from_utf8(output.stdout)
2073                    {
2074                        // Parse df output to get available KB
2075                        if let Some(line) = df_output.lines().nth(1) {
2076                            let parts: Vec<&str> = line.split_whitespace().collect();
2077                            if parts.len() >= 4
2078                                && let Ok(available_kb) = parts[3].parse::<u64>()
2079                            {
2080                                let available_mb = available_kb / 1024;
2081
2082                                // Warn if less than 100MB available
2083                                if available_mb < 100 {
2084                                    log::warn!(
2085                                        "Low disk space: only {}MB available. Indexing may fail.",
2086                                        available_mb
2087                                    );
2088                                    output::warn(&format!(
2089                                        "Low disk space ({}MB available). Consider freeing up space.",
2090                                        available_mb
2091                                    ));
2092                                } else {
2093                                    log::debug!("Available disk space: {}MB", available_mb);
2094                                }
2095                            }
2096                        }
2097                    }
2098
2099                    Ok(())
2100                }
2101                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
2102                    anyhow::bail!(
2103                        "Permission denied writing to cache directory: {}. Check file permissions.",
2104                        cache_path.display()
2105                    )
2106                }
2107                Err(e) => {
2108                    // If we can't write, it might be a disk space issue
2109                    log::warn!(
2110                        "Failed to write test file (possible disk space issue): {}",
2111                        e
2112                    );
2113                    Err(e).context(
2114                        "Failed to verify disk space - indexing may fail due to insufficient space",
2115                    )
2116                }
2117            }
2118        }
2119
2120        #[cfg(not(unix))]
2121        {
2122            // On Windows, try to write a test file
2123            let test_file = cache_path.join(".space_check");
2124            match std::fs::write(&test_file, b"test") {
2125                Ok(_) => {
2126                    let _ = std::fs::remove_file(&test_file);
2127                    Ok(())
2128                }
2129                Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
2130                    anyhow::bail!(
2131                        "Permission denied writing to cache directory: {}. Check file permissions.",
2132                        cache_path.display()
2133                    )
2134                }
2135                Err(e) => {
2136                    log::warn!(
2137                        "Failed to write test file (possible disk space issue): {}",
2138                        e
2139                    );
2140                    Err(e).context(
2141                        "Failed to verify disk space - indexing may fail due to insufficient space",
2142                    )
2143                }
2144            }
2145        }
2146    }
2147}
2148
2149#[cfg(test)]
2150mod tests {
2151    use super::*;
2152    use std::fs;
2153    use tempfile::TempDir;
2154
2155    #[test]
2156    fn test_indexer_creation() {
2157        let temp = TempDir::new().unwrap();
2158        let cache = CacheManager::new(temp.path());
2159        let config = IndexConfig::default();
2160        let indexer = Indexer::new(cache, config);
2161
2162        assert!(indexer.cache.path().ends_with(".reflex"));
2163    }
2164
2165    #[test]
2166    fn test_hash_content() {
2167        let temp = TempDir::new().unwrap();
2168        let cache = CacheManager::new(temp.path());
2169        let config = IndexConfig::default();
2170        let indexer = Indexer::new(cache, config);
2171
2172        let content1 = b"hello world";
2173        let content2 = b"hello world";
2174        let content3 = b"different content";
2175
2176        let hash1 = indexer.hash_content(content1);
2177        let hash2 = indexer.hash_content(content2);
2178        let hash3 = indexer.hash_content(content3);
2179
2180        // Same content should produce same hash
2181        assert_eq!(hash1, hash2);
2182
2183        // Different content should produce different hash
2184        assert_ne!(hash1, hash3);
2185
2186        // Hash should be hex string
2187        assert_eq!(hash1.len(), 64); // blake3 hash is 32 bytes = 64 hex chars
2188    }
2189
2190    #[test]
2191    fn test_should_index_rust_file() {
2192        let temp = TempDir::new().unwrap();
2193        let cache = CacheManager::new(temp.path());
2194        let config = IndexConfig::default();
2195        let indexer = Indexer::new(cache, config);
2196
2197        // Create a small Rust file
2198        let rust_file = temp.path().join("test.rs");
2199        fs::write(&rust_file, "fn main() {}").unwrap();
2200
2201        assert!(indexer.should_index(&rust_file));
2202    }
2203
2204    #[test]
2205    fn test_should_index_unsupported_extension() {
2206        let temp = TempDir::new().unwrap();
2207        let cache = CacheManager::new(temp.path());
2208        let config = IndexConfig::default();
2209        let indexer = Indexer::new(cache, config);
2210
2211        let unsupported_file = temp.path().join("test.txt");
2212        fs::write(&unsupported_file, "plain text").unwrap();
2213
2214        assert!(!indexer.should_index(&unsupported_file));
2215    }
2216
2217    #[test]
2218    fn test_should_index_no_extension() {
2219        let temp = TempDir::new().unwrap();
2220        let cache = CacheManager::new(temp.path());
2221        let config = IndexConfig::default();
2222        let indexer = Indexer::new(cache, config);
2223
2224        let no_ext_file = temp.path().join("Makefile");
2225        fs::write(&no_ext_file, "all:\n\techo hello").unwrap();
2226
2227        assert!(!indexer.should_index(&no_ext_file));
2228    }
2229
2230    #[test]
2231    fn test_should_index_size_limit() {
2232        let temp = TempDir::new().unwrap();
2233        let cache = CacheManager::new(temp.path());
2234
2235        // Config with 100 byte size limit
2236        let config = IndexConfig {
2237            max_file_size: 100,
2238            ..Default::default()
2239        };
2240
2241        let indexer = Indexer::new(cache, config);
2242
2243        // Create small file (should be indexed)
2244        let small_file = temp.path().join("small.rs");
2245        fs::write(&small_file, "fn main() {}").unwrap();
2246        assert!(indexer.should_index(&small_file));
2247
2248        // Create large file (should be skipped)
2249        let large_file = temp.path().join("large.rs");
2250        let large_content = "a".repeat(150);
2251        fs::write(&large_file, large_content).unwrap();
2252        assert!(!indexer.should_index(&large_file));
2253    }
2254
2255    #[test]
2256    fn test_discover_files_empty_dir() {
2257        let temp = TempDir::new().unwrap();
2258        let cache = CacheManager::new(temp.path());
2259        let config = IndexConfig::default();
2260        let indexer = Indexer::new(cache, config);
2261
2262        let (files, _, _) = indexer.discover_files(temp.path()).unwrap();
2263        assert_eq!(files.len(), 0);
2264    }
2265
2266    #[test]
2267    fn test_discover_files_single_file() {
2268        let temp = TempDir::new().unwrap();
2269        let cache = CacheManager::new(temp.path());
2270        let config = IndexConfig::default();
2271        let indexer = Indexer::new(cache, config);
2272
2273        // Create a Rust file
2274        let rust_file = temp.path().join("main.rs");
2275        fs::write(&rust_file, "fn main() {}").unwrap();
2276
2277        let (files, _, _) = indexer.discover_files(temp.path()).unwrap();
2278        assert_eq!(files.len(), 1);
2279        assert!(files[0].ends_with("main.rs"));
2280    }
2281
2282    #[test]
2283    fn test_discover_files_multiple_languages() {
2284        let temp = TempDir::new().unwrap();
2285        let cache = CacheManager::new(temp.path());
2286        let config = IndexConfig::default();
2287        let indexer = Indexer::new(cache, config);
2288
2289        // Create files of different languages
2290        fs::write(temp.path().join("main.rs"), "fn main() {}").unwrap();
2291        fs::write(temp.path().join("script.py"), "print('hello')").unwrap();
2292        fs::write(temp.path().join("app.js"), "console.log('hi')").unwrap();
2293        fs::write(temp.path().join("README.md"), "# Project").unwrap(); // Should be skipped
2294
2295        let (files, _, _) = indexer.discover_files(temp.path()).unwrap();
2296        assert_eq!(files.len(), 3); // Only supported languages
2297    }
2298
2299    #[test]
2300    fn test_discover_files_subdirectories() {
2301        let temp = TempDir::new().unwrap();
2302        let cache = CacheManager::new(temp.path());
2303        let config = IndexConfig::default();
2304        let indexer = Indexer::new(cache, config);
2305
2306        // Create nested directory structure
2307        let src_dir = temp.path().join("src");
2308        fs::create_dir(&src_dir).unwrap();
2309        fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
2310        fs::write(src_dir.join("lib.rs"), "pub mod test {}").unwrap();
2311
2312        let tests_dir = temp.path().join("tests");
2313        fs::create_dir(&tests_dir).unwrap();
2314        fs::write(tests_dir.join("test.rs"), "#[test] fn test() {}").unwrap();
2315
2316        let (files, _, _) = indexer.discover_files(temp.path()).unwrap();
2317        assert_eq!(files.len(), 3);
2318    }
2319
2320    #[test]
2321    fn test_discover_files_respects_gitignore() {
2322        let temp = TempDir::new().unwrap();
2323
2324        // Initialize git repo (required for .gitignore to work with WalkBuilder)
2325        std::process::Command::new("git")
2326            .arg("init")
2327            .current_dir(temp.path())
2328            .output()
2329            .expect("Failed to initialize git repo");
2330
2331        let cache = CacheManager::new(temp.path());
2332        let config = IndexConfig::default();
2333        let indexer = Indexer::new(cache, config);
2334
2335        // Create .gitignore - use "ignored/" pattern to ignore the directory
2336        // Note: WalkBuilder respects .gitignore ONLY in git repositories
2337        fs::write(temp.path().join(".gitignore"), "ignored/\n").unwrap();
2338
2339        // Create files
2340        fs::write(temp.path().join("included.rs"), "fn main() {}").unwrap();
2341        fs::write(temp.path().join("also_included.py"), "print('hi')").unwrap();
2342
2343        let ignored_dir = temp.path().join("ignored");
2344        fs::create_dir(&ignored_dir).unwrap();
2345        fs::write(ignored_dir.join("excluded.rs"), "fn test() {}").unwrap();
2346
2347        let (files, _, _) = indexer.discover_files(temp.path()).unwrap();
2348
2349        // Verify the expected files are found
2350        assert!(
2351            files.iter().any(|f| f.ends_with("included.rs")),
2352            "Should find included.rs"
2353        );
2354        assert!(
2355            files.iter().any(|f| f.ends_with("also_included.py")),
2356            "Should find also_included.py"
2357        );
2358
2359        // Verify excluded.rs in ignored/ directory is NOT found
2360        // This is the key test - gitignore should filter it out
2361        assert!(
2362            !files.iter().any(|f| {
2363                let path_str = f.to_string_lossy();
2364                path_str.contains("ignored") && f.ends_with("excluded.rs")
2365            }),
2366            "Should NOT find excluded.rs in ignored/ directory (gitignore pattern)"
2367        );
2368
2369        // Should find exactly 2 files (included.rs and also_included.py)
2370        // .gitignore file itself has no supported language extension, so it won't be indexed
2371        assert_eq!(
2372            files.len(),
2373            2,
2374            "Should find exactly 2 files (not including .gitignore or ignored/excluded.rs)"
2375        );
2376    }
2377
2378    #[test]
2379    fn test_index_empty_directory() {
2380        let temp = TempDir::new().unwrap();
2381        let cache = CacheManager::new(temp.path());
2382        let config = IndexConfig::default();
2383        let indexer = Indexer::new(cache, config);
2384
2385        let stats = indexer.index(temp.path(), false).unwrap();
2386
2387        assert_eq!(stats.total_files, 0);
2388    }
2389
2390    #[test]
2391    fn test_index_single_rust_file() {
2392        let temp = TempDir::new().unwrap();
2393        let project_root = temp.path().join("project");
2394        fs::create_dir(&project_root).unwrap();
2395
2396        let cache = CacheManager::new(&project_root);
2397        let config = IndexConfig::default();
2398        let indexer = Indexer::new(cache, config);
2399
2400        // Create a Rust file
2401        fs::write(
2402            project_root.join("main.rs"),
2403            "fn main() { println!(\"Hello\"); }",
2404        )
2405        .unwrap();
2406
2407        let stats = indexer.index(&project_root, false).unwrap();
2408
2409        assert_eq!(stats.total_files, 1);
2410        assert!(stats.files_by_language.contains_key("Rust"));
2411    }
2412
2413    #[test]
2414    fn test_index_multiple_files() {
2415        let temp = TempDir::new().unwrap();
2416        let project_root = temp.path().join("project");
2417        fs::create_dir(&project_root).unwrap();
2418
2419        let cache = CacheManager::new(&project_root);
2420        let config = IndexConfig::default();
2421        let indexer = Indexer::new(cache, config);
2422
2423        // Create multiple files
2424        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2425        fs::write(project_root.join("lib.rs"), "pub fn test() {}").unwrap();
2426        fs::write(project_root.join("script.py"), "def main(): pass").unwrap();
2427
2428        let stats = indexer.index(&project_root, false).unwrap();
2429
2430        assert_eq!(stats.total_files, 3);
2431        assert_eq!(stats.files_by_language.get("Rust"), Some(&2));
2432        assert_eq!(stats.files_by_language.get("Python"), Some(&1));
2433    }
2434
2435    #[test]
2436    fn test_index_creates_trigram_index() {
2437        let temp = TempDir::new().unwrap();
2438        let project_root = temp.path().join("project");
2439        fs::create_dir(&project_root).unwrap();
2440
2441        let cache = CacheManager::new(&project_root);
2442        let config = IndexConfig::default();
2443        let indexer = Indexer::new(cache, config);
2444
2445        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2446
2447        indexer.index(&project_root, false).unwrap();
2448
2449        // Verify trigrams.bin was created
2450        let trigrams_path = project_root.join(".reflex/trigrams.bin");
2451        assert!(trigrams_path.exists());
2452    }
2453
2454    #[test]
2455    fn test_index_creates_content_store() {
2456        let temp = TempDir::new().unwrap();
2457        let project_root = temp.path().join("project");
2458        fs::create_dir(&project_root).unwrap();
2459
2460        let cache = CacheManager::new(&project_root);
2461        let config = IndexConfig::default();
2462        let indexer = Indexer::new(cache, config);
2463
2464        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2465
2466        indexer.index(&project_root, false).unwrap();
2467
2468        // Verify content.bin was created
2469        let content_path = project_root.join(".reflex/content.bin");
2470        assert!(content_path.exists());
2471    }
2472
2473    #[test]
2474    fn test_index_incremental_no_changes() {
2475        let temp = TempDir::new().unwrap();
2476        let project_root = temp.path().join("project");
2477        fs::create_dir(&project_root).unwrap();
2478
2479        let cache = CacheManager::new(&project_root);
2480        let config = IndexConfig::default();
2481        let indexer = Indexer::new(cache, config);
2482
2483        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2484
2485        // First index
2486        let stats1 = indexer.index(&project_root, false).unwrap();
2487        assert_eq!(stats1.total_files, 1);
2488
2489        // Second index without changes
2490        let stats2 = indexer.index(&project_root, false).unwrap();
2491        assert_eq!(stats2.total_files, 1);
2492    }
2493
2494    #[test]
2495    fn test_index_incremental_with_changes() {
2496        let temp = TempDir::new().unwrap();
2497        let project_root = temp.path().join("project");
2498        fs::create_dir(&project_root).unwrap();
2499
2500        let cache = CacheManager::new(&project_root);
2501        let config = IndexConfig::default();
2502        let indexer = Indexer::new(cache, config);
2503
2504        let main_path = project_root.join("main.rs");
2505        fs::write(&main_path, "fn main() {}").unwrap();
2506
2507        // First index
2508        indexer.index(&project_root, false).unwrap();
2509
2510        // Modify file
2511        fs::write(&main_path, "fn main() { println!(\"changed\"); }").unwrap();
2512
2513        // Second index should detect change
2514        let stats = indexer.index(&project_root, false).unwrap();
2515        assert_eq!(stats.total_files, 1);
2516    }
2517
2518    #[test]
2519    fn test_index_incremental_new_file() {
2520        let temp = TempDir::new().unwrap();
2521        let project_root = temp.path().join("project");
2522        fs::create_dir(&project_root).unwrap();
2523
2524        let cache = CacheManager::new(&project_root);
2525        let config = IndexConfig::default();
2526        let indexer = Indexer::new(cache, config);
2527
2528        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2529
2530        // First index
2531        let stats1 = indexer.index(&project_root, false).unwrap();
2532        assert_eq!(stats1.total_files, 1);
2533
2534        // Add new file
2535        fs::write(project_root.join("lib.rs"), "pub fn test() {}").unwrap();
2536
2537        // Second index should include new file
2538        let stats2 = indexer.index(&project_root, false).unwrap();
2539        assert_eq!(stats2.total_files, 2);
2540    }
2541
2542    #[test]
2543    fn test_index_parallel_threads_config() {
2544        let temp = TempDir::new().unwrap();
2545        let project_root = temp.path().join("project");
2546        fs::create_dir(&project_root).unwrap();
2547
2548        let cache = CacheManager::new(&project_root);
2549
2550        // Test with explicit thread count
2551        let config = IndexConfig {
2552            parallel_threads: 2,
2553            ..Default::default()
2554        };
2555
2556        let indexer = Indexer::new(cache, config);
2557
2558        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2559
2560        let stats = indexer.index(&project_root, false).unwrap();
2561        assert_eq!(stats.total_files, 1);
2562    }
2563
2564    #[test]
2565    fn test_index_parallel_threads_auto() {
2566        let temp = TempDir::new().unwrap();
2567        let project_root = temp.path().join("project");
2568        fs::create_dir(&project_root).unwrap();
2569
2570        let cache = CacheManager::new(&project_root);
2571
2572        // Test with auto thread count (0 = auto)
2573        let config = IndexConfig {
2574            parallel_threads: 0,
2575            ..Default::default()
2576        };
2577
2578        let indexer = Indexer::new(cache, config);
2579
2580        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2581
2582        let stats = indexer.index(&project_root, false).unwrap();
2583        assert_eq!(stats.total_files, 1);
2584    }
2585
2586    #[test]
2587    fn test_index_respects_size_limit() {
2588        let temp = TempDir::new().unwrap();
2589        let project_root = temp.path().join("project");
2590        fs::create_dir(&project_root).unwrap();
2591
2592        let cache = CacheManager::new(&project_root);
2593
2594        // Very small size limit
2595        let config = IndexConfig {
2596            max_file_size: 50,
2597            ..Default::default()
2598        };
2599
2600        let indexer = Indexer::new(cache, config);
2601
2602        // Small file (should be indexed)
2603        fs::write(project_root.join("small.rs"), "fn a() {}").unwrap();
2604
2605        // Large file (should be skipped)
2606        let large_content = "fn main() {}\n".repeat(10);
2607        fs::write(project_root.join("large.rs"), large_content).unwrap();
2608
2609        let stats = indexer.index(&project_root, false).unwrap();
2610
2611        // Only small file should be indexed
2612        assert_eq!(stats.total_files, 1);
2613    }
2614
2615    #[test]
2616    fn test_index_mixed_languages() {
2617        let temp = TempDir::new().unwrap();
2618        let project_root = temp.path().join("project");
2619        fs::create_dir(&project_root).unwrap();
2620
2621        let cache = CacheManager::new(&project_root);
2622        let config = IndexConfig::default();
2623        let indexer = Indexer::new(cache, config);
2624
2625        // Create files in multiple languages
2626        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2627        fs::write(project_root.join("test.py"), "def test(): pass").unwrap();
2628        fs::write(project_root.join("app.js"), "function main() {}").unwrap();
2629        fs::write(project_root.join("lib.go"), "func main() {}").unwrap();
2630
2631        let stats = indexer.index(&project_root, false).unwrap();
2632
2633        assert_eq!(stats.total_files, 4);
2634        assert!(stats.files_by_language.contains_key("Rust"));
2635        assert!(stats.files_by_language.contains_key("Python"));
2636        assert!(stats.files_by_language.contains_key("JavaScript"));
2637        assert!(stats.files_by_language.contains_key("Go"));
2638    }
2639
2640    #[test]
2641    fn test_index_updates_cache_stats() {
2642        let temp = TempDir::new().unwrap();
2643        let project_root = temp.path().join("project");
2644        fs::create_dir(&project_root).unwrap();
2645
2646        let cache = CacheManager::new(&project_root);
2647        let config = IndexConfig::default();
2648        let indexer = Indexer::new(cache, config);
2649
2650        fs::write(project_root.join("main.rs"), "fn main() {}").unwrap();
2651
2652        indexer.index(&project_root, false).unwrap();
2653
2654        // Verify cache stats were updated
2655        let cache = CacheManager::new(&project_root);
2656        let stats = cache.stats().unwrap();
2657
2658        assert_eq!(stats.total_files, 1);
2659        assert!(stats.index_size_bytes > 0);
2660    }
2661}