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        result.elapsed_ms = start.elapsed().as_millis();
246        Ok(result)
247    }
248
249    /// Index the codebase using parallel parsing.
250    ///
251    /// This method uses rayon to parse files in parallel, then batch-inserts
252    /// the results into the database. This is significantly faster for large
253    /// codebases on multi-core systems.
254    pub fn index_parallel(&mut self) -> io::Result<IndexResult> {
255        let start = Instant::now();
256
257        // Discover files using the configured walker
258        let entries = discover_files(&self.root, &self.walker_config)?;
259
260        // Counters for statistics (atomic for parallel access)
261        let files_skipped = AtomicUsize::new(0);
262        let files_failed = AtomicUsize::new(0);
263
264        // First pass: determine which files need updating (sequential, requires DB)
265        let files_to_index: Vec<_> = entries
266            .iter()
267            .filter_map(|entry| {
268                let rel_path = entry.relative_path.to_string_lossy().replace('\\', "/");
269
270                // Only process supported languages
271                if !CodeParser::is_supported_static(&entry.relative_path) {
272                    files_skipped.fetch_add(1, Ordering::Relaxed);
273                    return None;
274                }
275
276                // Check if file needs updating (read content for hash)
277                let content = match fs::read_to_string(&entry.absolute_path) {
278                    Ok(c) => c,
279                    Err(_) => {
280                        files_failed.fetch_add(1, Ordering::Relaxed);
281                        return None;
282                    }
283                };
284
285                let hash = compute_hash(&content);
286
287                // Check if file needs updating
288                match self.db.needs_update(&rel_path, &hash) {
289                    Ok(true) => Some((entry.clone(), rel_path, content, hash)),
290                    Ok(false) => {
291                        files_skipped.fetch_add(1, Ordering::Relaxed);
292                        None
293                    }
294                    Err(_) => {
295                        files_failed.fetch_add(1, Ordering::Relaxed);
296                        None
297                    }
298                }
299            })
300            .collect();
301
302        let verbose = self.verbose;
303
304        // Parallel parse phase: parse all files that need updating
305        let parsed_files: Vec<ParsedFile> = files_to_index
306            .par_iter()
307            .filter_map(|(entry, rel_path, content, hash)| {
308                // Create thread-local parser
309                let mut parser = CodeParser::new();
310
311                if verbose {
312                    eprintln!("Indexing: {}", rel_path);
313                }
314
315                // Parse the file
316                let parse_result = parser.parse(&entry.absolute_path, content)?;
317
318                // Compress content
319                let compressed = compress_source(content);
320
321                Some(ParsedFile {
322                    rel_path: rel_path.clone(),
323                    content: content.clone(),
324                    hash: hash.clone(),
325                    compressed,
326                    parse_result,
327                })
328            })
329            .collect();
330
331        // Sequential store phase: batch insert into database
332        let mut result = IndexResult {
333            files_indexed: 0,
334            files_skipped: files_skipped.load(Ordering::Relaxed),
335            files_failed: files_failed.load(Ordering::Relaxed),
336            symbols_extracted: 0,
337            edges_extracted: 0,
338            elapsed_ms: 0,
339        };
340
341        // Track files we've seen for cleanup (both indexed and skipped)
342        let seen_files: Vec<String> = entries
343            .iter()
344            .filter_map(|e| {
345                let rel = e.relative_path.to_string_lossy().replace('\\', "/");
346                if CodeParser::is_supported_static(&e.relative_path) {
347                    Some(rel)
348                } else {
349                    None
350                }
351            })
352            .collect();
353
354        // Store parsed files
355        for parsed in &parsed_files {
356            let file_record = FileRecord {
357                path: parsed.rel_path.clone(),
358                content_hash: parsed.hash.clone(),
359                size_bytes: parsed.content.len() as i64,
360                language: Some(parsed.parse_result.language.clone()),
361                last_indexed: 0,
362            };
363
364            // Store file and delete existing symbols
365            if let Err(e) = self.db.upsert_file(&file_record, Some(&parsed.compressed)) {
366                if self.verbose {
367                    eprintln!("Warning: failed to store file {}: {}", parsed.rel_path, e);
368                }
369                result.files_failed += 1;
370                continue;
371            }
372
373            if let Err(e) = self.db.delete_symbols_for_file(&parsed.rel_path) {
374                if self.verbose {
375                    eprintln!(
376                        "Warning: failed to clear symbols for {}: {}",
377                        parsed.rel_path, e
378                    );
379                }
380                result.files_failed += 1;
381                continue;
382            }
383
384            // Store symbols and build ID mapping
385            let id_map = match self.store_symbols(&parsed.rel_path, &parsed.parse_result.symbols) {
386                Ok(map) => map,
387                Err(e) => {
388                    if self.verbose {
389                        eprintln!(
390                            "Warning: failed to store symbols for {}: {}",
391                            parsed.rel_path, e
392                        );
393                    }
394                    result.files_failed += 1;
395                    continue;
396                }
397            };
398
399            // Store edges
400            if let Err(e) = self.store_edges(&parsed.rel_path, &parsed.parse_result.edges, &id_map)
401            {
402                if self.verbose {
403                    eprintln!(
404                        "Warning: failed to store edges for {}: {}",
405                        parsed.rel_path, e
406                    );
407                }
408                result.files_failed += 1;
409                continue;
410            }
411
412            // Store module info
413            if let Some(ref module) = parsed.parse_result.module {
414                let mut m = module.clone();
415                m.file_path = parsed.rel_path.clone();
416                if let Err(e) = self.db.upsert_module(&m) {
417                    if self.verbose {
418                        eprintln!(
419                            "Warning: failed to store module for {}: {}",
420                            parsed.rel_path, e
421                        );
422                    }
423                }
424            }
425
426            result.files_indexed += 1;
427            result.symbols_extracted += parsed.parse_result.symbols.len();
428            result.edges_extracted += parsed.parse_result.edges.len();
429        }
430
431        // Clean up deleted files
432        if let Err(e) = self.cleanup_deleted_files(&seen_files) {
433            if self.verbose {
434                eprintln!("Warning: cleanup failed: {}", e);
435            }
436        }
437
438        // Resolve cross-file edge targets
439        match self.db.resolve_edge_targets() {
440            Ok(resolved) => {
441                if self.verbose && resolved > 0 {
442                    eprintln!("Resolved {} cross-file edge targets", resolved);
443                }
444            }
445            Err(e) => {
446                if self.verbose {
447                    eprintln!("Warning: edge resolution failed: {}", e);
448                }
449            }
450        }
451
452        result.elapsed_ms = start.elapsed().as_millis();
453        Ok(result)
454    }
455
456    /// Index a single file.
457    pub fn index_file(&mut self, path: &Path) -> io::Result<bool> {
458        let abs_path = if path.is_absolute() {
459            path.to_path_buf()
460        } else {
461            self.root.join(path)
462        };
463
464        let rel_path = abs_path
465            .strip_prefix(&self.root)
466            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "Path not in root"))?
467            .to_string_lossy()
468            .replace('\\', "/");
469
470        // Check if supported
471        if !self.parser.is_supported(path) {
472            return Ok(false);
473        }
474
475        // Read content
476        let content = fs::read_to_string(&abs_path)?;
477        let hash = compute_hash(&content);
478
479        // Check if needs update
480        let needs_update = self
481            .db
482            .needs_update(&rel_path, &hash)
483            .map_err(|e| io::Error::other(e.to_string()))?;
484
485        if !needs_update {
486            return Ok(false);
487        }
488
489        // Parse
490        let parse_result = self
491            .parser
492            .parse(&abs_path, &content)
493            .ok_or_else(|| io::Error::other("Parse failed"))?;
494
495        // Store
496        self.store_file(&rel_path, &content, &hash, &parse_result)?;
497
498        Ok(true)
499    }
500
501    /// Store a parsed file in the database.
502    fn store_file(
503        &self,
504        rel_path: &str,
505        content: &str,
506        hash: &str,
507        parse_result: &crate::db::ParseResult,
508    ) -> io::Result<()> {
509        let compressed = compress_source(content);
510        let file_record = FileRecord {
511            path: rel_path.to_string(),
512            content_hash: hash.to_string(),
513            size_bytes: content.len() as i64,
514            language: Some(parse_result.language.clone()),
515            last_indexed: 0,
516        };
517
518        // Store file FIRST (before symbols, due to foreign key constraint)
519        self.db
520            .upsert_file(&file_record, Some(&compressed))
521            .map_err(db_error)?;
522        self.db
523            .delete_symbols_for_file(rel_path)
524            .map_err(db_error)?;
525
526        // Build ID mapping and store symbols
527        let id_map = self.store_symbols(rel_path, &parse_result.symbols)?;
528
529        // Store edges with rewritten IDs
530        self.store_edges(rel_path, &parse_result.edges, &id_map)?;
531
532        // Store module info
533        if let Some(ref module) = parse_result.module {
534            let mut m = module.clone();
535            m.file_path = rel_path.to_string();
536            self.db.upsert_module(&m).map_err(db_error)?;
537        }
538
539        Ok(())
540    }
541
542    /// Store symbols and build ID mapping from old to new IDs.
543    fn store_symbols(
544        &self,
545        rel_path: &str,
546        symbols: &[crate::db::Symbol],
547    ) -> io::Result<std::collections::HashMap<String, String>> {
548        let mut id_map = std::collections::HashMap::new();
549
550        for symbol in symbols {
551            let parent_name = extract_parent_name(symbol.parent_id.as_deref());
552            let new_id = crate::db::Symbol::make_id_with_line(
553                rel_path,
554                &symbol.name,
555                parent_name,
556                symbol.line_start,
557            );
558            id_map.insert(symbol.id.clone(), new_id.clone());
559
560            let mut sym = symbol.clone();
561            sym.file_path = rel_path.to_string();
562            sym.id = new_id;
563            if symbol.parent_id.is_some() {
564                if let Some(pn) = parent_name {
565                    sym.parent_id = Some(crate::db::Symbol::make_id(rel_path, pn, None));
566                }
567            }
568            self.db.insert_symbol(&sym).map_err(db_error)?;
569        }
570
571        Ok(id_map)
572    }
573
574    /// Store edges with rewritten source/target IDs.
575    fn store_edges(
576        &self,
577        rel_path: &str,
578        edges: &[crate::db::Edge],
579        id_map: &std::collections::HashMap<String, String>,
580    ) -> io::Result<()> {
581        for edge in edges {
582            let mut e = edge.clone();
583            e.source_id = rewrite_id(&e.source_id, rel_path, id_map);
584            if let Some(ref target_id) = edge.target_id {
585                e.target_id = Some(rewrite_id(target_id, rel_path, id_map));
586            }
587            self.db.insert_edge(&e).map_err(db_error)?;
588        }
589        Ok(())
590    }
591
592    /// Remove files from database that no longer exist.
593    fn cleanup_deleted_files(&self, seen_files: &[String]) -> io::Result<()> {
594        let indexed_files = self
595            .db
596            .get_indexed_files()
597            .map_err(|e| io::Error::other(e.to_string()))?;
598
599        for file in indexed_files {
600            if !seen_files.contains(&file) {
601                if self.verbose {
602                    eprintln!("Removing: {}", file);
603                }
604                self.db
605                    .delete_file(&file)
606                    .map_err(|e| io::Error::other(e.to_string()))?;
607            }
608        }
609
610        Ok(())
611    }
612
613    /// Get a reference to the database.
614    pub fn database(&self) -> &Database {
615        &self.db
616    }
617}
618
619/// Compute SHA256 hash of content.
620fn compute_hash(content: &str) -> String {
621    let mut hasher = Sha256::new();
622    hasher.update(content.as_bytes());
623    let result = hasher.finalize();
624    format!("{:x}", result)
625}
626
627/// Compress source code using gzip.
628fn compress_source(content: &str) -> Vec<u8> {
629    use std::io::Write;
630
631    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
632    encoder.write_all(content.as_bytes()).ok();
633    encoder.finish().unwrap_or_default()
634}
635
636/// Open the database for a project.
637pub fn open_database(root: &Path) -> io::Result<Database> {
638    let ctx_dir = root.join(CTX_DIR);
639    let db_path = ctx_dir.join(DB_FILE);
640
641    if !db_path.exists() {
642        return Err(io::Error::new(
643            io::ErrorKind::NotFound,
644            format!(
645                "Database not found. Run 'ctx index' first.\nExpected: {}",
646                db_path.display()
647            ),
648        ));
649    }
650
651    Database::open(&db_path).map_err(|e| io::Error::other(e.to_string()))
652}
653
654/// Watch mode for automatic reindexing.
655pub mod watch {
656    use std::path::Path;
657    use std::sync::mpsc::channel;
658    use std::time::Duration;
659
660    use notify::RecursiveMode;
661    use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
662
663    use super::Indexer;
664    use crate::parser::Language;
665    use crate::walker::{FileFilter, WalkerConfig};
666
667    /// Start watching the codebase for changes and reindex automatically.
668    pub fn watch_and_index(
669        root: &Path,
670        verbose: bool,
671        walker_config: WalkerConfig,
672    ) -> std::io::Result<()> {
673        let root = root.canonicalize()?;
674
675        // Build file filter once for efficient watch-mode filtering
676        // This handles .gitignore, .contextignore, default ignores, custom ignores, and include patterns
677        let file_filter = FileFilter::new(&root, &walker_config)?;
678
679        // Do initial index
680        eprintln!("Performing initial index...");
681        let mut indexer = Indexer::with_config(&root, verbose, walker_config)?;
682        let result = indexer.index()?;
683        eprintln!(
684            "Initial index complete: {} files, {} symbols",
685            result.files_indexed + result.files_skipped,
686            result.symbols_extracted
687        );
688
689        // Set up file watcher with debouncing
690        let (tx, rx) = channel();
691
692        let mut debouncer = new_debouncer(Duration::from_millis(500), tx)
693            .map_err(|e| std::io::Error::other(e.to_string()))?;
694
695        debouncer
696            .watcher()
697            .watch(&root, RecursiveMode::Recursive)
698            .map_err(|e| std::io::Error::other(e.to_string()))?;
699
700        eprintln!("\nWatching for changes... (press Ctrl+C to stop)");
701
702        // Process file change events
703        loop {
704            match rx.recv() {
705                Ok(Ok(events)) => {
706                    let mut reindex_needed = false;
707
708                    for event in events {
709                        // Handle both Any and AnyContinuous events
710                        // AnyContinuous signals ongoing/rapid changes that should also trigger reindex
711                        if matches!(
712                            event.kind,
713                            DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
714                        ) {
715                            let path = &event.path;
716
717                            // Skip .ctx directory
718                            if path.starts_with(root.join(super::CTX_DIR)) {
719                                continue;
720                            }
721
722                            // Check if it's a supported source file
723                            let lang = Language::from_path(path);
724                            if lang == Language::Unknown {
725                                continue;
726                            }
727
728                            // Check if file should be included based on walker config
729                            // (respects .gitignore, .contextignore, --ignore, --pattern, etc.)
730                            if !file_filter.should_include(path) {
731                                continue;
732                            }
733
734                            // Check if file exists (handle deletions)
735                            if !path.exists() {
736                                let rel_path = path
737                                    .strip_prefix(&root)
738                                    .map(|p| p.to_string_lossy().replace('\\', "/"))
739                                    .unwrap_or_default();
740
741                                if verbose {
742                                    eprintln!("Removed: {}", rel_path);
743                                }
744
745                                // Delete from database
746                                if let Err(e) = indexer.db.delete_file(&rel_path) {
747                                    eprintln!("Warning: failed to remove {}: {}", rel_path, e);
748                                }
749                                continue;
750                            }
751
752                            // Index the changed file
753                            match indexer.index_file(path) {
754                                Ok(true) => {
755                                    let rel_path = path
756                                        .strip_prefix(&root)
757                                        .map(|p| p.to_string_lossy().to_string())
758                                        .unwrap_or_else(|_| path.display().to_string());
759
760                                    // Resolve edge targets after reindexing to maintain accurate analytics
761                                    if let Err(e) = indexer.db.resolve_edge_targets() {
762                                        if verbose {
763                                            eprintln!("Warning: edge resolution failed: {}", e);
764                                        }
765                                    }
766
767                                    if verbose {
768                                        eprintln!("Reindexed: {}", rel_path);
769                                    } else {
770                                        eprint!(".");
771                                    }
772                                    reindex_needed = true;
773                                }
774                                Ok(false) => {
775                                    // File unchanged
776                                }
777                                Err(e) => {
778                                    let rel_path = path
779                                        .strip_prefix(&root)
780                                        .map(|p| p.to_string_lossy().to_string())
781                                        .unwrap_or_else(|_| path.display().to_string());
782                                    eprintln!("\nWarning: failed to index {}: {}", rel_path, e);
783                                }
784                            }
785                        }
786                    }
787
788                    if reindex_needed && !verbose {
789                        eprintln!(); // Newline after dots
790                    }
791                }
792                Ok(Err(error)) => {
793                    eprintln!("Watch error: {:?}", error);
794                }
795                Err(e) => {
796                    eprintln!("Channel error: {}", e);
797                    break;
798                }
799            }
800        }
801
802        Ok(())
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use std::fs;
810    use tempfile::TempDir;
811
812    #[test]
813    fn test_compute_hash() {
814        let hash1 = compute_hash("hello");
815        let hash2 = compute_hash("hello");
816        let hash3 = compute_hash("world");
817
818        assert_eq!(hash1, hash2);
819        assert_ne!(hash1, hash3);
820    }
821
822    #[test]
823    fn test_compress_source() {
824        let content = "fn main() { println!(\"Hello, world!\"); }";
825        let compressed = compress_source(content);
826        assert!(!compressed.is_empty());
827        assert!(compressed.len() < content.len() * 2); // Reasonable compression
828    }
829
830    #[test]
831    fn test_index_simple_project() {
832        let temp = TempDir::new().unwrap();
833        let root = temp.path();
834
835        // Create a simple Rust file
836        let src_dir = root.join("src");
837        fs::create_dir_all(&src_dir).unwrap();
838        fs::write(
839            src_dir.join("main.rs"),
840            r#"
841/// Main entry point
842fn main() {
843    println!("Hello, world!");
844}
845
846/// A helper function
847fn helper() -> i32 {
848    42
849}
850"#,
851        )
852        .unwrap();
853
854        // Create indexer
855        let mut indexer = Indexer::new_in_memory(root).unwrap();
856        let result = indexer.index().unwrap();
857
858        assert_eq!(result.files_indexed, 1);
859        assert!(result.symbols_extracted >= 2); // main and helper
860
861        // Check database
862        let stats = indexer.database().get_stats().unwrap();
863        assert_eq!(stats.files, 1);
864        assert!(stats.symbols >= 2);
865    }
866}