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 result.elapsed_ms = start.elapsed().as_millis();
246 Ok(result)
247 }
248
249 pub fn index_parallel(&mut self) -> io::Result<IndexResult> {
255 let start = Instant::now();
256
257 let entries = discover_files(&self.root, &self.walker_config)?;
259
260 let files_skipped = AtomicUsize::new(0);
262 let files_failed = AtomicUsize::new(0);
263
264 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 if !CodeParser::is_supported_static(&entry.relative_path) {
272 files_skipped.fetch_add(1, Ordering::Relaxed);
273 return None;
274 }
275
276 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 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 let parsed_files: Vec<ParsedFile> = files_to_index
306 .par_iter()
307 .filter_map(|(entry, rel_path, content, hash)| {
308 let mut parser = CodeParser::new();
310
311 if verbose {
312 eprintln!("Indexing: {}", rel_path);
313 }
314
315 let parse_result = parser.parse(&entry.absolute_path, content)?;
317
318 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 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 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 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 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 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 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 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 if let Err(e) = self.cleanup_deleted_files(&seen_files) {
433 if self.verbose {
434 eprintln!("Warning: cleanup failed: {}", e);
435 }
436 }
437
438 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 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 if !self.parser.is_supported(path) {
472 return Ok(false);
473 }
474
475 let content = fs::read_to_string(&abs_path)?;
477 let hash = compute_hash(&content);
478
479 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 let parse_result = self
491 .parser
492 .parse(&abs_path, &content)
493 .ok_or_else(|| io::Error::other("Parse failed"))?;
494
495 self.store_file(&rel_path, &content, &hash, &parse_result)?;
497
498 Ok(true)
499 }
500
501 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 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 let id_map = self.store_symbols(rel_path, &parse_result.symbols)?;
528
529 self.store_edges(rel_path, &parse_result.edges, &id_map)?;
531
532 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 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 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 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 pub fn database(&self) -> &Database {
615 &self.db
616 }
617}
618
619fn 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
627fn 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
636pub 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
654pub 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 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 let file_filter = FileFilter::new(&root, &walker_config)?;
678
679 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 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 loop {
704 match rx.recv() {
705 Ok(Ok(events)) => {
706 let mut reindex_needed = false;
707
708 for event in events {
709 if matches!(
712 event.kind,
713 DebouncedEventKind::Any | DebouncedEventKind::AnyContinuous
714 ) {
715 let path = &event.path;
716
717 if path.starts_with(root.join(super::CTX_DIR)) {
719 continue;
720 }
721
722 let lang = Language::from_path(path);
724 if lang == Language::Unknown {
725 continue;
726 }
727
728 if !file_filter.should_include(path) {
731 continue;
732 }
733
734 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 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 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 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 }
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!(); }
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); }
829
830 #[test]
831 fn test_index_simple_project() {
832 let temp = TempDir::new().unwrap();
833 let root = temp.path();
834
835 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 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); let stats = indexer.database().get_stats().unwrap();
863 assert_eq!(stats.files, 1);
864 assert!(stats.symbols >= 2);
865 }
866}