1use 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
24fn db_error<E: std::fmt::Display>(e: E) -> io::Error {
28 io::Error::other(e.to_string())
29}
30
31fn 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
43fn 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
58pub const CTX_DIR: &str = ".ctx";
60
61pub const DB_FILE: &str = "codebase.sqlite";
63
64#[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
75struct ParsedFile {
77 rel_path: String,
78 content: String,
79 hash: String,
80 compressed: Vec<u8>,
81 parse_result: ParseResult,
82}
83
84pub struct Indexer {
86 pub db: Database,
88 parser: CodeParser,
89 root: PathBuf,
90 verbose: bool,
91 walker_config: WalkerConfig,
93}
94
95impl Indexer {
96 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 let ctx_dir = root.join(CTX_DIR);
106 if !ctx_dir.exists() {
107 fs::create_dir_all(&ctx_dir)?;
108 }
109
110 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 #[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 pub fn index(&mut self) -> io::Result<IndexResult> {
140 let start = Instant::now();
141
142 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 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 if !self.parser.is_supported(&entry.relative_path) {
162 result.files_skipped += 1;
163 continue;
164 }
165
166 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 let hash = compute_hash(&content);
180
181 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 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 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 if let Err(e) = self.cleanup_deleted_files(&seen_files) {
226 if self.verbose {
227 eprintln!("Warning: cleanup failed: {}", e);
228 }
229 }
230
231 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 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 pub fn index_parallel(&mut self) -> io::Result<IndexResult> {
265 let start = Instant::now();
266
267 let entries = discover_files(&self.root, &self.walker_config)?;
269
270 let files_skipped = AtomicUsize::new(0);
272 let files_failed = AtomicUsize::new(0);
273
274 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 if !CodeParser::is_supported_static(&entry.relative_path) {
282 files_skipped.fetch_add(1, Ordering::Relaxed);
283 return None;
284 }
285
286 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 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 let parsed_files: Vec<ParsedFile> = files_to_index
316 .par_iter()
317 .filter_map(|(entry, rel_path, content, hash)| {
318 let mut parser = CodeParser::new();
320
321 if verbose {
322 eprintln!("Indexing: {}", rel_path);
323 }
324
325 let parse_result = parser.parse(&entry.absolute_path, content)?;
327
328 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 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 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 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 if let Err(e) = self.cleanup_deleted_files(&seen_files) {
387 if self.verbose {
388 eprintln!("Warning: cleanup failed: {}", e);
389 }
390 }
391
392 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 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 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 if !self.parser.is_supported(path) {
436 return Ok(false);
437 }
438
439 let content = fs::read_to_string(&abs_path)?;
441 let hash = compute_hash(&content);
442
443 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 let parse_result = self
455 .parser
456 .parse(&abs_path, &content)
457 .ok_or_else(|| io::Error::other("Parse failed"))?;
458
459 self.store_file(&rel_path, &content, &hash, &parse_result)?;
461
462 self.db.clear_symbol_rank().map_err(db_error)?;
465
466 Ok(true)
467 }
468
469 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 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 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 let id_map = self.store_symbols(rel_path, &parse_result.symbols)?;
512
513 self.store_edges(rel_path, &parse_result.edges, &id_map)?;
515
516 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 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 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 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 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 if deleted_any {
621 self.db.clear_symbol_rank().map_err(db_error)?;
622 }
623
624 Ok(())
625 }
626
627 pub fn database(&self) -> &Database {
629 &self.db
630 }
631}
632
633fn 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
641fn 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
650pub 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
665pub 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 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 let file_filter = FileFilter::new(&root, &walker_config)?;
689
690 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 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 loop {
715 match rx.recv() {
716 Ok(Ok(events)) => {
717 let mut reindex_needed = false;
718
719 for event in events {
720 if matches!(
723 event.kind,
724 DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
725 ) {
726 let path = &event.path;
727
728 if path.starts_with(root.join(super::CTX_DIR)) {
730 continue;
731 }
732
733 let lang = Language::from_path(path);
735 if lang == Language::Unknown {
736 continue;
737 }
738
739 if !file_filter.should_include(path) {
742 continue;
743 }
744
745 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 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 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 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 }
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!(); }
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); }
843
844 #[test]
845 fn test_index_simple_project() {
846 let temp = TempDir::new().unwrap();
847 let root = temp.path();
848
849 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 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); let stats = indexer.database().get_stats().unwrap();
877 assert_eq!(stats.files, 1);
878 assert!(stats.symbols >= 2);
879 }
880
881 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 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 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 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 assert!(pair.a.id < pair.b.id);
964
965 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 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 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 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_no_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 assert!(indexer.database().get_fingerprints(0).unwrap().is_empty());
1064 }
1065}