1use crate::edges::{NameResolver, extract_edges};
25use crate::parser::{ParseResult, parse_python_file};
26use crate::python_parser::{PythonParseResult, PythonParser};
27use crate::rust_parser::parse_rust_file;
28use crate::schema::{
29 CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
30 build_code_nodes_batch,
31};
32use arrow::array::RecordBatch;
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Language {
39 Generic,
43 Python,
47}
48
49#[derive(Debug, thiserror::Error)]
51pub enum IngestError {
52 #[error("IO error: {0}")]
53 Io(#[from] std::io::Error),
54
55 #[error("Parse error: {0}")]
56 Parse(#[from] crate::parser::ParseError),
57
58 #[error("Python parser error: {0}")]
59 PythonParse(#[from] crate::python_parser::PythonParserError),
60
61 #[error("Arrow error: {0}")]
62 Arrow(#[from] arrow::error::ArrowError),
63
64 #[error("Directory not found: {0}")]
65 DirNotFound(String),
66}
67
68pub type Result<T> = std::result::Result<T, IngestError>;
69
70#[derive(Debug)]
72pub struct IngestResult {
73 pub nodes: Vec<CodeNode>,
75 pub edges: Vec<CodeEdge>,
77 pub parse_results: Vec<ParseResult>,
79 pub errors: Vec<(PathBuf, String)>,
81 pub source_texts: HashMap<String, String>,
83}
84
85impl IngestResult {
86 pub fn nodes_batch(&self) -> std::result::Result<RecordBatch, arrow::error::ArrowError> {
88 build_code_nodes_batch(&self.nodes)
89 }
90
91 pub fn edges_batch(&self) -> std::result::Result<RecordBatch, arrow::error::ArrowError> {
93 build_code_edges_batch(&self.edges)
94 }
95
96 pub fn summary(&self) -> String {
98 let mut s = String::new();
99
100 s.push_str("=== CodeGraph Ingestion Summary ===\n");
102 s.push_str(&format!("Total nodes: {}\n", self.nodes.len()));
103 for kind in CodeNodeKind::ALL {
104 let count = self.nodes.iter().filter(|n| n.kind == kind).count();
105 if count > 0 {
106 s.push_str(&format!(" {}: {}\n", kind.as_str(), count));
107 }
108 }
109
110 s.push_str(&format!("Total edges: {}\n", self.edges.len()));
112 for pred in CodeEdgePredicate::ALL {
113 let count = self.edges.iter().filter(|e| e.predicate == pred).count();
114 if count > 0 {
115 s.push_str(&format!(" {}: {}\n", pred.as_str(), count));
116 }
117 }
118
119 let unresolved = self
121 .edges
122 .iter()
123 .filter(|e| e.target_id.starts_with("ext:"))
124 .count();
125 s.push_str(&format!("Unresolved references: {}\n", unresolved));
126
127 if !self.errors.is_empty() {
129 s.push_str(&format!("Parse errors: {}\n", self.errors.len()));
130 for (path, err) in &self.errors {
131 s.push_str(&format!(" {}: {}\n", path.display(), err));
132 }
133 }
134
135 s
136 }
137}
138
139pub fn ingest_directory(root: &Path) -> Result<IngestResult> {
144 if !root.is_dir() {
145 return Err(IngestError::DirNotFound(root.display().to_string()));
146 }
147
148 let source_files = collect_source_files(root)?;
149
150 ingest_files(root, &source_files)
151}
152
153pub fn ingest_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
157 let mut all_parse_results = Vec::new();
158 let mut errors = Vec::new();
159 let mut source_texts = HashMap::new();
160
161 for file_path in files {
162 let rel_path = file_path
163 .strip_prefix(root)
164 .unwrap_or(file_path)
165 .to_path_buf();
166
167 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
168
169 match std::fs::read_to_string(file_path) {
170 Ok(source) => {
171 let path_str = rel_path.display().to_string();
172 source_texts.insert(path_str, source.clone());
173
174 let parse_result = match ext {
175 "py" => parse_python_file(&rel_path, &source),
176 "rs" => parse_rust_file(&rel_path, &source),
177 _ => continue, };
179
180 match parse_result {
181 Ok(result) => {
182 all_parse_results.push(result);
183 }
184 Err(e) => {
185 errors.push((file_path.clone(), e.to_string()));
186 }
187 }
188 }
189 Err(e) => {
190 errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
191 }
192 }
193 }
194
195 let all_nodes: Vec<CodeNode> = all_parse_results
197 .iter()
198 .flat_map(|r| r.nodes.clone())
199 .collect();
200
201 let resolver = NameResolver::from_nodes(&all_nodes);
203 let mut edges = extract_edges(&all_parse_results, &resolver);
204
205 let call_edges =
208 crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts, Some(root));
209 edges.extend(call_edges);
210
211 if let Some(mut module_resolver) = crate::module_resolver::RustModuleResolver::from_crate(root)
213 {
214 module_resolver.index_nodes(&all_nodes);
215 let cross_edges =
216 crate::edges::extract_cross_file_edges(&all_parse_results, &module_resolver);
217 edges.extend(cross_edges);
218 }
219
220 Ok(IngestResult {
221 nodes: all_nodes,
222 edges,
223 parse_results: all_parse_results,
224 errors,
225 source_texts,
226 })
227}
228
229pub fn ingest_python_directory(root: &Path) -> Result<IngestResult> {
238 if !root.is_dir() {
239 return Err(IngestError::DirNotFound(root.display().to_string()));
240 }
241 let py_files = collect_python_files(root)?;
242 ingest_python_files(root, &py_files)
243}
244
245pub fn ingest_python_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
249 let mut parser = PythonParser::new()?;
250
251 let mut all_parse_results: Vec<ParseResult> = Vec::new();
252 let mut py_results: Vec<PythonParseResult> = Vec::new();
253 let mut errors = Vec::new();
254 let mut source_texts = HashMap::new();
255
256 for file_path in files {
257 let rel_path = file_path
258 .strip_prefix(root)
259 .unwrap_or(file_path)
260 .to_path_buf();
261
262 match std::fs::read_to_string(file_path) {
263 Ok(source) => {
264 let path_str = rel_path.display().to_string();
265 source_texts.insert(path_str, source.clone());
266
267 match parser.parse_file(&rel_path, &source) {
268 Ok(result) => {
269 py_results.push(result);
270 }
271 Err(e) => {
272 errors.push((file_path.clone(), e.to_string()));
273 }
274 }
275 }
276 Err(e) => {
277 errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
278 }
279 }
280 }
281
282 let all_nodes: Vec<CodeNode> = py_results.iter().flat_map(|r| r.nodes.clone()).collect();
284
285 for py_result in &py_results {
288 let parse_result = ParseResult {
290 nodes: py_result.nodes.clone(),
291 imports: py_result.imports.clone(),
292 };
293 all_parse_results.push(parse_result);
294 }
295
296 let resolver = NameResolver::from_nodes(&all_nodes);
297 let mut edges = extract_edges(&all_parse_results, &resolver);
298 let call_edges =
300 crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts, None);
301 edges.extend(call_edges);
302
303 Ok(IngestResult {
304 nodes: all_nodes,
305 edges,
306 parse_results: all_parse_results,
307 errors,
308 source_texts,
309 })
310}
311
312fn collect_python_files(dir: &Path) -> Result<Vec<PathBuf>> {
314 let mut files = Vec::new();
315 collect_source_files_recursive(dir, &mut files)?;
316 files.retain(|f| f.extension().is_some_and(|e| e == "py"));
318 files.sort();
319 Ok(files)
320}
321
322fn collect_source_files(dir: &Path) -> Result<Vec<PathBuf>> {
324 let mut files = Vec::new();
325 collect_source_files_recursive(dir, &mut files)?;
326 files.sort();
327 Ok(files)
328}
329
330fn collect_source_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
331 for entry in std::fs::read_dir(dir)? {
332 let entry = entry?;
333 let path = entry.path();
334
335 if path.is_dir() {
336 let name = path
338 .file_name()
339 .map(|n| n.to_string_lossy().to_string())
340 .unwrap_or_default();
341 if name.starts_with('.')
342 || name == "__pycache__"
343 || name == "node_modules"
344 || name == ".git"
345 || name == "venv"
346 || name == ".venv"
347 || name == "target"
348 {
349 continue;
350 }
351 collect_source_files_recursive(&path, files)?;
352 } else if path
353 .extension()
354 .is_some_and(|ext| ext == "py" || ext == "rs")
355 {
356 files.push(path);
357 }
358 }
359 Ok(())
360}
361
362pub fn nodes_in_file<'a>(nodes: &'a [CodeNode], file_path: &str) -> Vec<&'a CodeNode> {
364 nodes.iter().filter(|n| n.id.contains(file_path)).collect()
365}
366
367pub fn callers_of<'a>(edges: &'a [CodeEdge], target_name: &str) -> Vec<&'a CodeEdge> {
372 edges
373 .iter()
374 .filter(|e| {
375 if e.predicate != CodeEdgePredicate::Calls {
376 return false;
377 }
378 if let Some(last_segment) = e.target_id.rsplit("::").next() {
380 last_segment == target_name
381 } else {
382 e.target_id.ends_with(target_name)
384 }
385 })
386 .collect()
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn test_ingest_files_from_source() {
395 let dir = tempfile::tempdir().expect("create temp dir");
397 let file_a = dir.path().join("module_a.py");
398 let file_b = dir.path().join("module_b.py");
399
400 std::fs::write(
401 &file_a,
402 r#"
403"""Module A."""
404
405from module_b import helper
406
407class Processor:
408 """Processes data."""
409 def process(self, data):
410 """Process data."""
411 return helper(data)
412
413def standalone():
414 """Standalone function."""
415 return 42
416"#,
417 )
418 .expect("write a");
419
420 std::fs::write(
421 &file_b,
422 r#"
423"""Module B."""
424
425def helper(x):
426 """Help with x."""
427 return x * 2
428
429def another():
430 """Another function."""
431 return helper(10)
432"#,
433 )
434 .expect("write b");
435
436 let result = ingest_directory(dir.path()).expect("ingest should succeed");
437
438 assert!(
440 result.nodes.len() >= 8,
441 "Expected >= 8 nodes, got {}",
442 result.nodes.len()
443 );
444
445 assert!(!result.edges.is_empty(), "Should have edges");
447
448 let containment = result
450 .edges
451 .iter()
452 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
453 .count();
454 assert!(
455 containment >= 4,
456 "Expected >= 4 containment edges, got {}",
457 containment
458 );
459
460 let imports = result
462 .edges
463 .iter()
464 .filter(|e| e.predicate == CodeEdgePredicate::Imports)
465 .count();
466 assert!(imports >= 1, "Expected >= 1 import edge, got {}", imports);
467
468 let summary = result.summary();
470 assert!(summary.contains("Total nodes:"));
471 assert!(summary.contains("Total edges:"));
472
473 assert!(result.errors.is_empty(), "Should have no parse errors");
475 }
476
477 #[test]
478 fn test_ingest_builds_record_batches() {
479 let dir = tempfile::tempdir().expect("create temp dir");
480 std::fs::write(
481 dir.path().join("test.py"),
482 r#"
483def foo():
484 """A function."""
485 return 1
486
487def bar():
488 """Another function."""
489 return foo()
490"#,
491 )
492 .expect("write");
493
494 let result = ingest_directory(dir.path()).expect("ingest");
495 let nodes_batch = result.nodes_batch().expect("nodes batch");
496 let edges_batch = result.edges_batch().expect("edges batch");
497
498 assert!(nodes_batch.num_rows() > 0);
499 assert_eq!(nodes_batch.num_columns(), 19);
500 assert!(edges_batch.num_rows() > 0);
501 assert_eq!(edges_batch.num_columns(), 5);
502 }
503
504 #[test]
505 fn test_ingest_dir_not_found() {
506 let result = ingest_directory(Path::new("/nonexistent/path"));
507 assert!(result.is_err());
508 }
509
510 #[test]
511 fn test_ingest_skips_pycache() {
512 let dir = tempfile::tempdir().expect("create temp dir");
513 let pycache = dir.path().join("__pycache__");
514 std::fs::create_dir(&pycache).expect("create pycache");
515 std::fs::write(pycache.join("cached.py"), "x = 1").expect("write cached");
516 std::fs::write(dir.path().join("real.py"), "def foo(): pass").expect("write real");
517
518 let result = ingest_directory(dir.path()).expect("ingest");
519
520 let file_nodes: Vec<_> = result
522 .nodes
523 .iter()
524 .filter(|n| n.kind == CodeNodeKind::File)
525 .collect();
526 assert_eq!(file_nodes.len(), 1, "Should only parse real.py");
527 }
528
529 #[test]
530 fn test_nodes_in_file_query() {
531 let nodes = vec![
532 CodeNode {
533 id: "func:brain/utils.py::helper".to_string(),
534 kind: CodeNodeKind::Function,
535 parent_id: None,
536 name: "helper".to_string(),
537 signature: None,
538 docstring: None,
539 body_hash: None,
540 body: None,
541 loc: None,
542 cyclomatic_complexity: None,
543 coverage_pct: None,
544 last_modified: None,
545 ..Default::default()
546 },
547 CodeNode {
548 id: "func:brain/main.py::main".to_string(),
549 kind: CodeNodeKind::Function,
550 parent_id: None,
551 name: "main".to_string(),
552 signature: None,
553 docstring: None,
554 body_hash: None,
555 body: None,
556 loc: None,
557 cyclomatic_complexity: None,
558 coverage_pct: None,
559 last_modified: None,
560 ..Default::default()
561 },
562 ];
563
564 let utils_nodes = nodes_in_file(&nodes, "brain/utils.py");
565 assert_eq!(utils_nodes.len(), 1);
566 assert_eq!(utils_nodes[0].name, "helper");
567 }
568
569 #[test]
570 fn test_callers_of_query() {
571 let edges = vec![
572 CodeEdge {
573 source_id: "func:a.py::caller".to_string(),
574 target_id: "func:b.py::target".to_string(),
575 predicate: CodeEdgePredicate::Calls,
576 weight: Some(1.0),
577 commit_id: None,
578 },
579 CodeEdge {
580 source_id: "func:a.py::other".to_string(),
581 target_id: "func:c.py::unrelated".to_string(),
582 predicate: CodeEdgePredicate::Calls,
583 weight: Some(1.0),
584 commit_id: None,
585 },
586 ];
587
588 let callers = callers_of(&edges, "target");
589 assert_eq!(callers.len(), 1);
590 assert_eq!(callers[0].source_id, "func:a.py::caller");
591 }
592
593 #[test]
594 fn test_ingest_handles_syntax_errors_gracefully() {
595 let dir = tempfile::tempdir().expect("create temp dir");
596 std::fs::write(dir.path().join("good.py"), "def foo(): pass").expect("write good");
597 std::fs::write(dir.path().join("weird.py"), "def (: pass").expect("write weird");
600
601 let result = ingest_directory(dir.path()).expect("ingest should succeed");
602 assert!(!result.nodes.is_empty());
604 }
605
606 #[test]
607 fn test_ingest_rust_files() {
608 let dir = tempfile::tempdir().expect("create temp dir");
609 std::fs::create_dir(dir.path().join("src")).expect("mkdir");
610 std::fs::write(
611 dir.path().join("src/lib.rs"),
612 r#"
613use std::collections::HashMap;
614
615pub struct Config {
616 pub name: String,
617}
618
619impl Config {
620 pub fn new(name: &str) -> Self {
621 Config { name: name.to_string() }
622 }
623}
624
625pub fn process(input: &str) -> String {
626 input.to_uppercase()
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 #[test]
634 fn test_process() {
635 assert_eq!(process("hello"), "HELLO");
636 }
637}
638"#,
639 )
640 .expect("write rust");
641
642 let result = ingest_directory(dir.path()).expect("ingest should succeed");
643
644 let rust_nodes: Vec<_> = result
646 .nodes
647 .iter()
648 .filter(|n| n.kind.is_rust_specific())
649 .collect();
650 assert!(
651 rust_nodes.len() >= 5,
652 "Expected >= 5 Rust-specific nodes, got {} (kinds: {:?})",
653 rust_nodes.len(),
654 rust_nodes
655 .iter()
656 .map(|n| (n.kind, &n.name))
657 .collect::<Vec<_>>()
658 );
659
660 for node in &rust_nodes {
662 if !matches!(node.kind, CodeNodeKind::RustUse) {
663 assert!(
664 node.start_line.is_some() && node.start_line.unwrap() > 0,
665 "Rust node {} should have start_line > 0",
666 node.id
667 );
668 }
669 }
670
671 let containment = result
673 .edges
674 .iter()
675 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
676 .count();
677 assert!(
678 containment >= 3,
679 "Expected >= 3 containment edges, got {}",
680 containment
681 );
682
683 assert!(
685 result.errors.is_empty(),
686 "Should have no parse errors: {:?}",
687 result.errors
688 );
689 }
690
691 #[test]
692 fn test_ingest_mixed_py_and_rs() {
693 let dir = tempfile::tempdir().expect("create temp dir");
694 std::fs::write(dir.path().join("app.py"), "def main():\n pass\n").expect("write py");
695 std::fs::write(dir.path().join("lib.rs"), "fn helper() -> i32 { 42 }\n").expect("write rs");
696
697 let result = ingest_directory(dir.path()).expect("ingest");
698
699 let file_nodes: Vec<_> = result
701 .nodes
702 .iter()
703 .filter(|n| n.kind == CodeNodeKind::File)
704 .collect();
705 assert_eq!(file_nodes.len(), 2, "Should have 2 file nodes");
706
707 let py_func = result
709 .nodes
710 .iter()
711 .find(|n| n.kind == CodeNodeKind::Function && n.name == "main");
712 assert!(py_func.is_some(), "Should have Python function main");
713
714 let rs_func = result
716 .nodes
717 .iter()
718 .find(|n| n.kind == CodeNodeKind::RustFn && n.name == "helper");
719 assert!(rs_func.is_some(), "Should have Rust function helper");
720 }
721
722 #[test]
723 fn test_ingest_skips_target_dir() {
724 let dir = tempfile::tempdir().expect("create temp dir");
725 let target = dir.path().join("target");
726 std::fs::create_dir(&target).expect("create target");
727 std::fs::write(target.join("build.rs"), "fn main() {}").expect("write target file");
728 std::fs::write(dir.path().join("real.rs"), "fn real() {}").expect("write real");
729
730 let result = ingest_directory(dir.path()).expect("ingest");
731
732 let file_nodes: Vec<_> = result
733 .nodes
734 .iter()
735 .filter(|n| n.kind == CodeNodeKind::File)
736 .collect();
737 assert_eq!(
738 file_nodes.len(),
739 1,
740 "Should only parse real.rs, not target/build.rs"
741 );
742 }
743
744 #[test]
745 fn test_collect_source_files_sorted() {
746 let dir = tempfile::tempdir().expect("create temp dir");
747 std::fs::write(dir.path().join("z.py"), "x=1").expect("write");
748 std::fs::write(dir.path().join("a.py"), "x=1").expect("write");
749 std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
750 std::fs::write(dir.path().join("sub/m.py"), "x=1").expect("write");
751
752 let files = collect_source_files(dir.path()).expect("collect");
753 assert_eq!(files.len(), 3);
754 let names: Vec<_> = files
756 .iter()
757 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
758 .collect();
759 assert!(names.windows(2).all(|w| w[0] <= w[1]));
760 }
761}