Skip to main content

ctx/index/
mod.rs

1//! Code indexing module.
2//!
3//! This module provides functionality to:
4//! - Walk the codebase and discover source files
5//! - Parse files and extract symbols/edges (in parallel)
6//! - Store extracted data in SQLite
7//! - Support incremental updates
8
9use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicUsize, Ordering};
13use std::time::Instant;
14
15use flate2::write::GzEncoder;
16use flate2::Compression;
17use rayon::prelude::*;
18use sha2::{Digest, Sha256};
19
20use crate::db::{Database, FileRecord, ParseResult};
21use crate::parser::CodeParser;
22use crate::walker::{discover_files, WalkerConfig};
23
24// --- Helper functions for store_file ---
25
26/// Convert database error to io::Error.
27fn db_error<E: std::fmt::Display>(e: E) -> io::Error {
28    io::Error::other(e.to_string())
29}
30
31/// Extract parent name from an ID string (format: "path::parent::name").
32fn extract_parent_name(parent_id: Option<&str>) -> Option<&str> {
33    parent_id.and_then(|p| {
34        let parts: Vec<&str> = p.split("::").collect();
35        if parts.len() >= 2 {
36            Some(parts[parts.len() - 1])
37        } else {
38            None
39        }
40    })
41}
42
43/// Rewrite an ID using the mapping, or fallback to path rewriting.
44fn rewrite_id(
45    id: &str,
46    rel_path: &str,
47    id_map: &std::collections::HashMap<String, String>,
48) -> String {
49    if let Some(new_id) = id_map.get(id) {
50        new_id.clone()
51    } else if let Some((_, rest)) = id.split_once("::") {
52        format!("{}::{}", rel_path, rest)
53    } else {
54        id.to_string()
55    }
56}
57
58/// Default directory name for storing the database.
59pub const CTX_DIR: &str = ".ctx";
60
61/// Default database filename.
62pub const DB_FILE: &str = "codebase.sqlite";
63
64/// Result of indexing operation.
65#[derive(Debug)]
66pub struct IndexResult {
67    pub files_indexed: usize,
68    pub files_skipped: usize,
69    pub files_failed: usize,
70    pub symbols_extracted: usize,
71    pub edges_extracted: usize,
72    pub elapsed_ms: u128,
73}
74
75/// Result of parsing a single file (used for parallel indexing).
76struct ParsedFile {
77    rel_path: String,
78    content: String,
79    hash: String,
80    compressed: Vec<u8>,
81    parse_result: ParseResult,
82}
83
84/// Indexer for building the code intelligence database.
85pub struct Indexer {
86    /// The database connection (pub for watch mode access).
87    pub db: Database,
88    parser: CodeParser,
89    root: PathBuf,
90    verbose: bool,
91    /// Walker configuration for file discovery.
92    walker_config: WalkerConfig,
93}
94
95impl Indexer {
96    /// Create a new indexer with custom walker configuration.
97    pub fn with_config(
98        root: &Path,
99        verbose: bool,
100        walker_config: WalkerConfig,
101    ) -> io::Result<Self> {
102        let root = root.canonicalize()?;
103
104        // Create .ctx directory if needed
105        let ctx_dir = root.join(CTX_DIR);
106        if !ctx_dir.exists() {
107            fs::create_dir_all(&ctx_dir)?;
108        }
109
110        // Open database
111        let db_path = ctx_dir.join(DB_FILE);
112        let db = Database::open(&db_path).map_err(|e| io::Error::other(e.to_string()))?;
113
114        Ok(Self {
115            db,
116            parser: CodeParser::new(),
117            root,
118            verbose,
119            walker_config,
120        })
121    }
122
123    /// Create an indexer with an in-memory database (for testing).
124    #[allow(dead_code)]
125    pub fn new_in_memory(root: &Path) -> io::Result<Self> {
126        let root = root.canonicalize()?;
127        let db = Database::open_in_memory().map_err(|e| io::Error::other(e.to_string()))?;
128
129        Ok(Self {
130            db,
131            parser: CodeParser::new(),
132            root,
133            verbose: false,
134            walker_config: WalkerConfig::default(),
135        })
136    }
137
138    /// Index the codebase.
139    pub fn index(&mut self) -> io::Result<IndexResult> {
140        let start = Instant::now();
141
142        // Discover files using the configured walker
143        let entries = discover_files(&self.root, &self.walker_config)?;
144
145        let mut result = IndexResult {
146            files_indexed: 0,
147            files_skipped: 0,
148            files_failed: 0,
149            symbols_extracted: 0,
150            edges_extracted: 0,
151            elapsed_ms: 0,
152        };
153
154        // Track files we've seen for cleanup
155        let mut seen_files: Vec<String> = Vec::new();
156
157        for entry in &entries {
158            let rel_path = entry.relative_path.to_string_lossy().replace('\\', "/");
159
160            // Only process supported languages
161            if !self.parser.is_supported(&entry.relative_path) {
162                result.files_skipped += 1;
163                continue;
164            }
165
166            // Read file content
167            let content = match fs::read_to_string(&entry.absolute_path) {
168                Ok(c) => c,
169                Err(e) => {
170                    if self.verbose {
171                        eprintln!("Warning: could not read {}: {}", rel_path, e);
172                    }
173                    result.files_failed += 1;
174                    continue;
175                }
176            };
177
178            // Calculate hash
179            let hash = compute_hash(&content);
180
181            // Check if file needs updating
182            let needs_update = self
183                .db
184                .needs_update(&rel_path, &hash)
185                .map_err(|e| io::Error::other(e.to_string()))?;
186
187            if !needs_update {
188                seen_files.push(rel_path.clone());
189                result.files_skipped += 1;
190                continue;
191            }
192
193            if self.verbose {
194                eprintln!("Indexing: {}", rel_path);
195            }
196
197            // Parse the file
198            let parse_result = match self.parser.parse(&entry.absolute_path, &content) {
199                Some(r) => r,
200                None => {
201                    if self.verbose {
202                        eprintln!("Warning: failed to parse {}", rel_path);
203                    }
204                    result.files_failed += 1;
205                    continue;
206                }
207            };
208
209            // Store in database
210            if let Err(e) = self.store_file(&rel_path, &content, &hash, &parse_result) {
211                if self.verbose {
212                    eprintln!("Warning: failed to store {}: {}", rel_path, e);
213                }
214                result.files_failed += 1;
215                continue;
216            }
217
218            seen_files.push(rel_path);
219            result.files_indexed += 1;
220            result.symbols_extracted += parse_result.symbols.len();
221            result.edges_extracted += parse_result.edges.len();
222        }
223
224        // Clean up deleted files
225        if let Err(e) = self.cleanup_deleted_files(&seen_files) {
226            if self.verbose {
227                eprintln!("Warning: cleanup failed: {}", e);
228            }
229        }
230
231        // Resolve cross-file edge targets
232        match self.db.resolve_edge_targets() {
233            Ok(resolved) => {
234                if self.verbose && resolved > 0 {
235                    eprintln!("Resolved {} cross-file edge targets", resolved);
236                }
237            }
238            Err(e) => {
239                if self.verbose {
240                    eprintln!("Warning: edge resolution failed: {}", e);
241                }
242            }
243        }
244
245        // The graph changed: invalidate the cached PageRank scores
246        // (`ctx map` recomputes them lazily).
247        if result.files_indexed > 0 {
248            if let Err(e) = self.db.clear_symbol_rank() {
249                if self.verbose {
250                    eprintln!("Warning: failed to clear rank cache: {}", e);
251                }
252            }
253        }
254
255        result.elapsed_ms = start.elapsed().as_millis();
256        Ok(result)
257    }
258
259    /// Index the codebase using parallel parsing.
260    ///
261    /// This method uses rayon to parse files in parallel, then batch-inserts
262    /// the results into the database. This is significantly faster for large
263    /// codebases on multi-core systems.
264    pub fn index_parallel(&mut self) -> io::Result<IndexResult> {
265        let start = Instant::now();
266
267        // Discover files using the configured walker
268        let entries = discover_files(&self.root, &self.walker_config)?;
269
270        // Counters for statistics (atomic for parallel access)
271        let files_skipped = AtomicUsize::new(0);
272        let files_failed = AtomicUsize::new(0);
273
274        // First pass: determine which files need updating (sequential, requires DB)
275        let files_to_index: Vec<_> = entries
276            .iter()
277            .filter_map(|entry| {
278                let rel_path = entry.relative_path.to_string_lossy().replace('\\', "/");
279
280                // Only process supported languages
281                if !CodeParser::is_supported_static(&entry.relative_path) {
282                    files_skipped.fetch_add(1, Ordering::Relaxed);
283                    return None;
284                }
285
286                // Check if file needs updating (read content for hash)
287                let content = match fs::read_to_string(&entry.absolute_path) {
288                    Ok(c) => c,
289                    Err(_) => {
290                        files_failed.fetch_add(1, Ordering::Relaxed);
291                        return None;
292                    }
293                };
294
295                let hash = compute_hash(&content);
296
297                // Check if file needs updating
298                match self.db.needs_update(&rel_path, &hash) {
299                    Ok(true) => Some((entry.clone(), rel_path, content, hash)),
300                    Ok(false) => {
301                        files_skipped.fetch_add(1, Ordering::Relaxed);
302                        None
303                    }
304                    Err(_) => {
305                        files_failed.fetch_add(1, Ordering::Relaxed);
306                        None
307                    }
308                }
309            })
310            .collect();
311
312        let verbose = self.verbose;
313
314        // Parallel parse phase: parse all files that need updating
315        let parsed_files: Vec<ParsedFile> = files_to_index
316            .par_iter()
317            .filter_map(|(entry, rel_path, content, hash)| {
318                // Create thread-local parser
319                let mut parser = CodeParser::new();
320
321                if verbose {
322                    eprintln!("Indexing: {}", rel_path);
323                }
324
325                // Parse the file
326                let parse_result = parser.parse(&entry.absolute_path, content)?;
327
328                // Compress content
329                let compressed = compress_source(content);
330
331                Some(ParsedFile {
332                    rel_path: rel_path.clone(),
333                    content: content.clone(),
334                    hash: hash.clone(),
335                    compressed,
336                    parse_result,
337                })
338            })
339            .collect();
340
341        // Sequential store phase: batch insert into database
342        let mut result = IndexResult {
343            files_indexed: 0,
344            files_skipped: files_skipped.load(Ordering::Relaxed),
345            files_failed: files_failed.load(Ordering::Relaxed),
346            symbols_extracted: 0,
347            edges_extracted: 0,
348            elapsed_ms: 0,
349        };
350
351        // Track files we've seen for cleanup (both indexed and skipped)
352        let seen_files: Vec<String> = entries
353            .iter()
354            .filter_map(|e| {
355                let rel = e.relative_path.to_string_lossy().replace('\\', "/");
356                if CodeParser::is_supported_static(&e.relative_path) {
357                    Some(rel)
358                } else {
359                    None
360                }
361            })
362            .collect();
363
364        // Store parsed files (single funnel shared with the serial path)
365        for parsed in &parsed_files {
366            if let Err(e) = self.store_file_impl(
367                &parsed.rel_path,
368                &parsed.content,
369                &parsed.hash,
370                &parsed.compressed,
371                &parsed.parse_result,
372            ) {
373                if self.verbose {
374                    eprintln!("Warning: failed to store {}: {}", parsed.rel_path, e);
375                }
376                result.files_failed += 1;
377                continue;
378            }
379
380            result.files_indexed += 1;
381            result.symbols_extracted += parsed.parse_result.symbols.len();
382            result.edges_extracted += parsed.parse_result.edges.len();
383        }
384
385        // Clean up deleted files
386        if let Err(e) = self.cleanup_deleted_files(&seen_files) {
387            if self.verbose {
388                eprintln!("Warning: cleanup failed: {}", e);
389            }
390        }
391
392        // Resolve cross-file edge targets
393        match self.db.resolve_edge_targets() {
394            Ok(resolved) => {
395                if self.verbose && resolved > 0 {
396                    eprintln!("Resolved {} cross-file edge targets", resolved);
397                }
398            }
399            Err(e) => {
400                if self.verbose {
401                    eprintln!("Warning: edge resolution failed: {}", e);
402                }
403            }
404        }
405
406        // The graph changed: invalidate the cached PageRank scores
407        // (`ctx map` recomputes them lazily).
408        if result.files_indexed > 0 {
409            if let Err(e) = self.db.clear_symbol_rank() {
410                if self.verbose {
411                    eprintln!("Warning: failed to clear rank cache: {}", e);
412                }
413            }
414        }
415
416        result.elapsed_ms = start.elapsed().as_millis();
417        Ok(result)
418    }
419
420    /// Index a single file.
421    pub fn index_file(&mut self, path: &Path) -> io::Result<bool> {
422        let abs_path = if path.is_absolute() {
423            path.to_path_buf()
424        } else {
425            self.root.join(path)
426        };
427
428        let rel_path = abs_path
429            .strip_prefix(&self.root)
430            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Path not in root"))?
431            .to_string_lossy()
432            .replace('\\', "/");
433
434        // Check if supported
435        if !self.parser.is_supported(path) {
436            return Ok(false);
437        }
438
439        // Read content
440        let content = fs::read_to_string(&abs_path)?;
441        let hash = compute_hash(&content);
442
443        // Check if needs update
444        let needs_update = self
445            .db
446            .needs_update(&rel_path, &hash)
447            .map_err(|e| io::Error::other(e.to_string()))?;
448
449        if !needs_update {
450            return Ok(false);
451        }
452
453        // Parse
454        let parse_result = self
455            .parser
456            .parse(&abs_path, &content)
457            .ok_or_else(|| io::Error::other("Parse failed"))?;
458
459        // Store
460        self.store_file(&rel_path, &content, &hash, &parse_result)?;
461
462        // The graph changed: invalidate the cached PageRank scores
463        // (`ctx map` recomputes them lazily).
464        self.db.clear_symbol_rank().map_err(db_error)?;
465
466        Ok(true)
467    }
468
469    /// Store a parsed file in the database.
470    fn store_file(
471        &self,
472        rel_path: &str,
473        content: &str,
474        hash: &str,
475        parse_result: &crate::db::ParseResult,
476    ) -> io::Result<()> {
477        let compressed = compress_source(content);
478        self.store_file_impl(rel_path, content, hash, &compressed, parse_result)
479    }
480
481    /// Store a parsed file, reusing an already-compressed source blob.
482    ///
483    /// This is the single funnel for the serial, parallel, and watch
484    /// indexing paths, so per-file bookkeeping (symbols, edges, modules,
485    /// fingerprints) stays consistent between them.
486    fn store_file_impl(
487        &self,
488        rel_path: &str,
489        content: &str,
490        hash: &str,
491        compressed: &[u8],
492        parse_result: &crate::db::ParseResult,
493    ) -> io::Result<()> {
494        let file_record = FileRecord {
495            path: rel_path.to_string(),
496            content_hash: hash.to_string(),
497            size_bytes: content.len() as i64,
498            language: Some(parse_result.language.clone()),
499            last_indexed: 0,
500        };
501
502        // Store file FIRST (before symbols, due to foreign key constraint)
503        self.db
504            .upsert_file(&file_record, Some(compressed))
505            .map_err(db_error)?;
506        self.db
507            .delete_symbols_for_file(rel_path)
508            .map_err(db_error)?;
509
510        // Build ID mapping and store symbols
511        let id_map = self.store_symbols(rel_path, &parse_result.symbols)?;
512
513        // Store edges with rewritten IDs
514        self.store_edges(rel_path, &parse_result.edges, &id_map)?;
515
516        // Store module info
517        if let Some(ref module) = parse_result.module {
518            let mut m = module.clone();
519            m.file_path = rel_path.to_string();
520            self.db.upsert_module(&m).map_err(db_error)?;
521        }
522
523        // Compute and store MinHash fingerprints for this file's
524        // function/method symbols (incremental: only changed files reach
525        // store_file, and delete_symbols_for_file cascaded old rows away).
526        let lang = crate::parser::Language::from_path(Path::new(rel_path));
527        let fingerprints = crate::fingerprint::file_fingerprints(
528            lang,
529            content,
530            rel_path,
531            &parse_result.symbols,
532            &id_map,
533        );
534        self.db
535            .insert_fingerprints_batch(&fingerprints)
536            .map_err(db_error)?;
537        if self.verbose {
538            eprintln!(
539                "Fingerprinted {} functions in {}",
540                fingerprints.len(),
541                rel_path
542            );
543        }
544
545        Ok(())
546    }
547
548    /// Store symbols and build ID mapping from old to new IDs.
549    fn store_symbols(
550        &self,
551        rel_path: &str,
552        symbols: &[crate::db::Symbol],
553    ) -> io::Result<std::collections::HashMap<String, String>> {
554        let mut id_map = std::collections::HashMap::new();
555
556        for symbol in symbols {
557            let parent_name = extract_parent_name(symbol.parent_id.as_deref());
558            let new_id = crate::db::Symbol::make_id_with_line(
559                rel_path,
560                &symbol.name,
561                parent_name,
562                symbol.line_start,
563            );
564            id_map.insert(symbol.id.clone(), new_id.clone());
565
566            let mut sym = symbol.clone();
567            sym.file_path = rel_path.to_string();
568            sym.id = new_id;
569            if symbol.parent_id.is_some() {
570                if let Some(pn) = parent_name {
571                    sym.parent_id = Some(crate::db::Symbol::make_id(rel_path, pn, None));
572                }
573            }
574            self.db.insert_symbol(&sym).map_err(db_error)?;
575        }
576
577        Ok(id_map)
578    }
579
580    /// Store edges with rewritten source/target IDs.
581    fn store_edges(
582        &self,
583        rel_path: &str,
584        edges: &[crate::db::Edge],
585        id_map: &std::collections::HashMap<String, String>,
586    ) -> io::Result<()> {
587        for edge in edges {
588            let mut e = edge.clone();
589            e.source_id = rewrite_id(&e.source_id, rel_path, id_map);
590            if let Some(ref target_id) = edge.target_id {
591                e.target_id = Some(rewrite_id(target_id, rel_path, id_map));
592            }
593            self.db.insert_edge(&e).map_err(db_error)?;
594        }
595        Ok(())
596    }
597
598    /// Remove files from database that no longer exist.
599    fn cleanup_deleted_files(&self, seen_files: &[String]) -> io::Result<()> {
600        let indexed_files = self
601            .db
602            .get_indexed_files()
603            .map_err(|e| io::Error::other(e.to_string()))?;
604
605        let mut deleted_any = false;
606        for file in indexed_files {
607            if !seen_files.contains(&file) {
608                if self.verbose {
609                    eprintln!("Removing: {}", file);
610                }
611                self.db
612                    .delete_file(&file)
613                    .map_err(|e| io::Error::other(e.to_string()))?;
614                deleted_any = true;
615            }
616        }
617
618        // Deleting files changes the graph: invalidate the cached PageRank
619        // scores (`ctx map` recomputes them lazily).
620        if deleted_any {
621            self.db.clear_symbol_rank().map_err(db_error)?;
622        }
623
624        Ok(())
625    }
626
627    /// Get a reference to the database.
628    pub fn database(&self) -> &Database {
629        &self.db
630    }
631}
632
633/// Compute SHA256 hash of content.
634fn compute_hash(content: &str) -> String {
635    let mut hasher = Sha256::new();
636    hasher.update(content.as_bytes());
637    let result = hasher.finalize();
638    format!("{:x}", result)
639}
640
641/// Compress source code using gzip.
642fn compress_source(content: &str) -> Vec<u8> {
643    use std::io::Write;
644
645    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
646    encoder.write_all(content.as_bytes()).ok();
647    encoder.finish().unwrap_or_default()
648}
649
650/// Open the database for a project.
651pub fn open_database(root: &Path) -> crate::error::Result<Database> {
652    let ctx_dir = root.join(CTX_DIR);
653    let db_path = ctx_dir.join(DB_FILE);
654
655    if !db_path.exists() {
656        return Err(crate::error::CtxError::IndexNotFound(format!(
657            "run 'ctx index' first (expected {})",
658            db_path.display()
659        )));
660    }
661
662    Database::open(&db_path)
663}
664
665/// Watch mode for automatic reindexing.
666pub mod watch {
667    use std::path::Path;
668    use std::sync::mpsc::channel;
669    use std::time::Duration;
670
671    use notify::RecursiveMode;
672    use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
673
674    use super::Indexer;
675    use crate::parser::Language;
676    use crate::walker::{FileFilter, WalkerConfig};
677
678    /// Start watching the codebase for changes and reindex automatically.
679    pub fn watch_and_index(
680        root: &Path,
681        verbose: bool,
682        walker_config: WalkerConfig,
683    ) -> std::io::Result<()> {
684        let root = root.canonicalize()?;
685
686        // Build file filter once for efficient watch-mode filtering
687        // This handles .gitignore, .contextignore, default ignores, custom ignores, and include patterns
688        let file_filter = FileFilter::new(&root, &walker_config)?;
689
690        // Do initial index
691        eprintln!("Performing initial index...");
692        let mut indexer = Indexer::with_config(&root, verbose, walker_config)?;
693        let result = indexer.index()?;
694        eprintln!(
695            "Initial index complete: {} files, {} symbols",
696            result.files_indexed + result.files_skipped,
697            result.symbols_extracted
698        );
699
700        // Set up file watcher with debouncing
701        let (tx, rx) = channel();
702
703        let mut debouncer = new_debouncer(Duration::from_millis(500), tx)
704            .map_err(|e| std::io::Error::other(e.to_string()))?;
705
706        debouncer
707            .watcher()
708            .watch(&root, RecursiveMode::Recursive)
709            .map_err(|e| std::io::Error::other(e.to_string()))?;
710
711        eprintln!("\nWatching for changes... (press Ctrl+C to stop)");
712
713        // Process file change events
714        loop {
715            match rx.recv() {
716                Ok(Ok(events)) => {
717                    let mut reindex_needed = false;
718
719                    for event in events {
720                        // Handle both Any and AnyContinuous events
721                        // AnyContinuous signals ongoing/rapid changes that should also trigger reindex
722                        if matches!(
723                            event.kind,
724                            DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
725                        ) {
726                            let path = &event.path;
727
728                            // Skip .ctx directory
729                            if path.starts_with(root.join(super::CTX_DIR)) {
730                                continue;
731                            }
732
733                            // Check if it's a supported source file
734                            let lang = Language::from_path(path);
735                            if lang == Language::Unknown {
736                                continue;
737                            }
738
739                            // Check if file should be included based on walker config
740                            // (respects .gitignore, .contextignore, --ignore, --pattern, etc.)
741                            if !file_filter.should_include(path) {
742                                continue;
743                            }
744
745                            // Check if file exists (handle deletions)
746                            if !path.exists() {
747                                let rel_path = path
748                                    .strip_prefix(&root)
749                                    .map(|p| p.to_string_lossy().replace('\\', "/"))
750                                    .unwrap_or_default();
751
752                                if verbose {
753                                    eprintln!("Removed: {}", rel_path);
754                                }
755
756                                // Delete from database and invalidate the
757                                // cached PageRank scores
758                                if let Err(e) = indexer.db.delete_file(&rel_path) {
759                                    eprintln!("Warning: failed to remove {}: {}", rel_path, e);
760                                } else if let Err(e) = indexer.db.clear_symbol_rank() {
761                                    eprintln!("Warning: failed to clear rank cache: {}", e);
762                                }
763                                continue;
764                            }
765
766                            // Index the changed file
767                            match indexer.index_file(path) {
768                                Ok(true) => {
769                                    let rel_path = path
770                                        .strip_prefix(&root)
771                                        .map(|p| p.to_string_lossy().to_string())
772                                        .unwrap_or_else(|_| path.display().to_string());
773
774                                    // Resolve edge targets after reindexing to maintain accurate analytics
775                                    if let Err(e) = indexer.db.resolve_edge_targets() {
776                                        if verbose {
777                                            eprintln!("Warning: edge resolution failed: {}", e);
778                                        }
779                                    }
780
781                                    if verbose {
782                                        eprintln!("Reindexed: {}", rel_path);
783                                    } else {
784                                        eprint!(".");
785                                    }
786                                    reindex_needed = true;
787                                }
788                                Ok(false) => {
789                                    // File unchanged
790                                }
791                                Err(e) => {
792                                    let rel_path = path
793                                        .strip_prefix(&root)
794                                        .map(|p| p.to_string_lossy().to_string())
795                                        .unwrap_or_else(|_| path.display().to_string());
796                                    eprintln!("\nWarning: failed to index {}: {}", rel_path, e);
797                                }
798                            }
799                        }
800                    }
801
802                    if reindex_needed && !verbose {
803                        eprintln!(); // Newline after dots
804                    }
805                }
806                Ok(Err(error)) => {
807                    eprintln!("Watch error: {:?}", error);
808                }
809                Err(e) => {
810                    eprintln!("Channel error: {}", e);
811                    break;
812                }
813            }
814        }
815
816        Ok(())
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use std::fs;
824    use tempfile::TempDir;
825
826    #[test]
827    fn test_compute_hash() {
828        let hash1 = compute_hash("hello");
829        let hash2 = compute_hash("hello");
830        let hash3 = compute_hash("world");
831
832        assert_eq!(hash1, hash2);
833        assert_ne!(hash1, hash3);
834    }
835
836    #[test]
837    fn test_compress_source() {
838        let content = "fn main() { println!(\"Hello, world!\"); }";
839        let compressed = compress_source(content);
840        assert!(!compressed.is_empty());
841        assert!(compressed.len() < content.len() * 2); // Reasonable compression
842    }
843
844    #[test]
845    fn test_index_simple_project() {
846        let temp = TempDir::new().unwrap();
847        let root = temp.path();
848
849        // Create a simple Rust file
850        let src_dir = root.join("src");
851        fs::create_dir_all(&src_dir).unwrap();
852        fs::write(
853            src_dir.join("main.rs"),
854            r#"
855/// Main entry point
856fn main() {
857    println!("Hello, world!");
858}
859
860/// A helper function
861fn helper() -> i32 {
862    42
863}
864"#,
865        )
866        .unwrap();
867
868        // Create indexer
869        let mut indexer = Indexer::new_in_memory(root).unwrap();
870        let result = indexer.index().unwrap();
871
872        assert_eq!(result.files_indexed, 1);
873        assert!(result.symbols_extracted >= 2); // main and helper
874
875        // Check database
876        let stats = indexer.database().get_stats().unwrap();
877        assert_eq!(stats.files, 1);
878        assert!(stats.symbols >= 2);
879    }
880
881    /// A function with > 50 normalized tokens.
882    const DUPE_A: &str = r#"
883pub fn process_orders(items: &[i64]) -> i64 {
884    let mut total = 0;
885    for item in items {
886        if *item > 10 {
887            total += *item * 2;
888        } else {
889            total += *item + 1;
890        }
891    }
892    println!("processed the batch: {}", total);
893    total
894}
895"#;
896
897    /// A structural copy of [`DUPE_A`] with every identifier renamed and
898    /// every literal (numbers and the string) changed.
899    const DUPE_B: &str = r#"
900pub fn sum_invoices(entries: &[i64]) -> i64 {
901    let mut acc = 0;
902    for entry in entries {
903        if *entry > 99 {
904            acc += *entry * 7;
905        } else {
906            acc += *entry + 3;
907        }
908    }
909    println!("done with invoices: {}", acc);
910    acc
911}
912"#;
913
914    /// A structurally unrelated function, also > 50 tokens.
915    const UNRELATED: &str = r#"
916pub fn render_table(headers: &[String], widths: &[usize]) -> String {
917    let mut out = String::new();
918    for (header, width) in headers.iter().zip(widths.iter()) {
919        out.push('|');
920        out.push_str(header);
921        while out.len() < *width {
922            out.push(' ');
923        }
924    }
925    out.push('\n');
926    for width in widths {
927        out.push_str(&"-".repeat(*width));
928        out.push('+');
929    }
930    out
931}
932"#;
933
934    fn write_fixture(root: &std::path::Path) {
935        let src = root.join("src");
936        fs::create_dir_all(&src).unwrap();
937        fs::write(src.join("a.rs"), DUPE_A).unwrap();
938        fs::write(src.join("b.rs"), DUPE_B).unwrap();
939        fs::write(src.join("c.rs"), UNRELATED).unwrap();
940    }
941
942    #[test]
943    fn test_near_duplicates_end_to_end() {
944        let temp = TempDir::new().unwrap();
945        write_fixture(temp.path());
946
947        let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
948        indexer.index().unwrap();
949
950        // Renamed variables + different string literals are still detected
951        // at the default threshold; the unrelated function is not.
952        let pairs =
953            crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 50, None).unwrap();
954        assert_eq!(pairs.len(), 1, "expected exactly the renamed-copy pair");
955        let pair = &pairs[0];
956        let names = [pair.a.name.as_str(), pair.b.name.as_str()];
957        assert!(names.contains(&"process_orders"), "names: {:?}", names);
958        assert!(names.contains(&"sum_invoices"), "names: {:?}", names);
959        assert!(pair.similarity >= 0.85);
960        assert!(pair.token_count_a >= 50);
961        assert!(pair.token_count_b >= 50);
962        // Pairs are canonical: no self-pairs, endpoints ordered by id.
963        assert!(pair.a.id < pair.b.id);
964
965        // --against filter: pair survives when one endpoint changed...
966        let changed: std::collections::HashSet<String> =
967            std::iter::once("src/b.rs".to_string()).collect();
968        let filtered =
969            crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 50, Some(&changed))
970                .unwrap();
971        assert_eq!(filtered.len(), 1);
972
973        // ...and is dropped when neither endpoint changed.
974        let unrelated_change: std::collections::HashSet<String> =
975            std::iter::once("src/c.rs".to_string()).collect();
976        let filtered = crate::fingerprint::find_near_duplicates(
977            indexer.database(),
978            0.85,
979            50,
980            Some(&unrelated_change),
981        )
982        .unwrap();
983        assert!(filtered.is_empty());
984
985        // min-tokens above the fixture size filters everything out.
986        let none = crate::fingerprint::find_near_duplicates(indexer.database(), 0.85, 10_000, None)
987            .unwrap();
988        assert!(none.is_empty());
989    }
990
991    #[test]
992    fn test_parallel_indexing_also_fingerprints() {
993        let temp = TempDir::new().unwrap();
994        write_fixture(temp.path());
995
996        let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
997        indexer.index_parallel().unwrap();
998
999        let fingerprints = indexer.database().get_fingerprints(0).unwrap();
1000        assert_eq!(fingerprints.len(), 3, "one fingerprint per function");
1001    }
1002
1003    #[test]
1004    fn test_incremental_reindex_preserves_untouched_fingerprints() {
1005        let temp = TempDir::new().unwrap();
1006        let root = temp.path();
1007        write_fixture(root);
1008
1009        let mut indexer = Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
1010        indexer.index().unwrap();
1011
1012        let before = indexer.database().get_fingerprints(0).unwrap();
1013        let b_before: Vec<_> = before
1014            .iter()
1015            .filter(|f| f.file_path == "src/b.rs")
1016            .cloned()
1017            .collect();
1018        assert!(!b_before.is_empty());
1019
1020        // Modify only a.rs; the incremental pass must re-fingerprint just
1021        // that file and leave b.rs's fingerprint BLOBs byte-identical.
1022        fs::write(
1023            root.join("src/a.rs"),
1024            DUPE_A.replace("process_orders", "process_orders_v2"),
1025        )
1026        .unwrap();
1027        let result = indexer.index().unwrap();
1028        assert_eq!(result.files_indexed, 1, "only the edited file re-indexes");
1029
1030        let after = indexer.database().get_fingerprints(0).unwrap();
1031        let b_after: Vec<_> = after
1032            .iter()
1033            .filter(|f| f.file_path == "src/b.rs")
1034            .cloned()
1035            .collect();
1036        assert_eq!(
1037            b_before, b_after,
1038            "untouched fingerprints must be byte-identical"
1039        );
1040        assert!(after
1041            .iter()
1042            .any(|f| f.symbol_id.contains("process_orders_v2")));
1043        assert!(!after
1044            .iter()
1045            .any(|f| f.symbol_id.contains("process_orders@")));
1046    }
1047
1048    #[test]
1049    fn test_solidity_files_produce_fingerprints() {
1050        let temp = TempDir::new().unwrap();
1051        fs::write(
1052            temp.path().join("Token.sol"),
1053            "pragma solidity ^0.8.0;\ncontract Token {\n    function transfer(address to, uint256 amount) public returns (bool) {\n        return amount > 0;\n    }\n}\n",
1054        )
1055        .unwrap();
1056
1057        let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
1058        let result = indexer.index().unwrap();
1059        assert_eq!(result.files_indexed, 1);
1060
1061        // Solidity is tokenized with the solang-parser lexer, so its
1062        // functions are fingerprinted like any other language.
1063        assert!(!indexer.database().get_fingerprints(0).unwrap().is_empty());
1064    }
1065
1066    /// AGE-5: a Solidity qualified call `ChessPureLib.isKingInCheck(...)` must
1067    /// resolve to the library's method even when the bare name `isKingInCheck`
1068    /// is ambiguous (also defined in another file). Before the fix the resolver
1069    /// bailed on the ambiguous bare name and left `target_id` NULL, silently
1070    /// zeroing the callee's fan-in/complexity/reachability metrics.
1071    #[test]
1072    fn test_solidity_qualified_call_resolves_across_ambiguous_bare_name() {
1073        let temp = TempDir::new().unwrap();
1074
1075        // Library whose method we expect the qualified call to resolve to.
1076        fs::write(
1077            temp.path().join("ChessPureLib.sol"),
1078            r#"pragma solidity ^0.8.0;
1079library ChessPureLib {
1080    function isKingInCheck(uint256 tb, bool forBlack) internal pure returns (bool) {
1081        return tb > 0 && forBlack;
1082    }
1083}
1084"#,
1085        )
1086        .unwrap();
1087
1088        // A second library defining a colliding bare name, so a plain
1089        // name lookup for `isKingInCheck` is ambiguous (COUNT > 1).
1090        fs::write(
1091            temp.path().join("OtherLib.sol"),
1092            r#"pragma solidity ^0.8.0;
1093library OtherLib {
1094    function isKingInCheck(uint256 tb, bool forBlack) internal pure returns (bool) {
1095        return tb < 0 || forBlack;
1096    }
1097}
1098"#,
1099        )
1100        .unwrap();
1101
1102        // Caller making the qualified call `ChessPureLib.isKingInCheck(...)`.
1103        fs::write(
1104            temp.path().join("Game.sol"),
1105            r#"pragma solidity ^0.8.0;
1106contract Game {
1107    function check(uint256 tb, bool forBlack) public pure returns (bool) {
1108        return ChessPureLib.isKingInCheck(tb, forBlack);
1109    }
1110}
1111"#,
1112        )
1113        .unwrap();
1114
1115        let mut indexer = Indexer::new_in_memory(temp.path()).unwrap();
1116        indexer.index().unwrap();
1117
1118        let db = indexer.database();
1119
1120        // The bare name is ambiguous: two library methods share it.
1121        let candidates: Vec<_> = db
1122            .find_symbols("isKingInCheck", 50)
1123            .unwrap()
1124            .into_iter()
1125            .filter(|s| s.kind == crate::db::SymbolKind::Function)
1126            .collect();
1127        assert!(
1128            candidates.len() >= 2,
1129            "expected the bare name to be ambiguous, got {} candidate(s)",
1130            candidates.len()
1131        );
1132
1133        // The intended target: ChessPureLib.isKingInCheck.
1134        let chess_lib_method = candidates
1135            .iter()
1136            .find(|s| s.qualified_name.as_deref() == Some("ChessPureLib.isKingInCheck"))
1137            .expect("expected a ChessPureLib.isKingInCheck symbol");
1138
1139        // The edge for the qualified call must resolve to the ChessPureLib method,
1140        // not be left NULL and not point at the OtherLib method.
1141        let call_edge = db
1142            .get_incoming_edges("isKingInCheck")
1143            .unwrap()
1144            .into_iter()
1145            .find(|e| e.kind == crate::db::EdgeKind::Calls)
1146            .expect("expected a calls edge for isKingInCheck");
1147
1148        assert_eq!(
1149            call_edge.context.as_deref(),
1150            Some("ChessPureLib.isKingInCheck"),
1151            "qualifier should be captured on the edge context"
1152        );
1153        assert_eq!(
1154            call_edge.target_id.as_deref(),
1155            Some(chess_lib_method.id.as_str()),
1156            "qualified call must resolve to ChessPureLib.isKingInCheck, not NULL or the OtherLib symbol"
1157        );
1158    }
1159}