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 = crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts);
207 edges.extend(call_edges);
208
209 if let Some(mut module_resolver) = crate::module_resolver::RustModuleResolver::from_crate(root)
211 {
212 module_resolver.index_nodes(&all_nodes);
213 let cross_edges =
214 crate::edges::extract_cross_file_edges(&all_parse_results, &module_resolver);
215 edges.extend(cross_edges);
216 }
217
218 Ok(IngestResult {
219 nodes: all_nodes,
220 edges,
221 parse_results: all_parse_results,
222 errors,
223 source_texts,
224 })
225}
226
227pub fn ingest_python_directory(root: &Path) -> Result<IngestResult> {
236 if !root.is_dir() {
237 return Err(IngestError::DirNotFound(root.display().to_string()));
238 }
239 let py_files = collect_python_files(root)?;
240 ingest_python_files(root, &py_files)
241}
242
243pub fn ingest_python_files(root: &Path, files: &[PathBuf]) -> Result<IngestResult> {
247 let mut parser = PythonParser::new()?;
248
249 let mut all_parse_results: Vec<ParseResult> = Vec::new();
250 let mut py_results: Vec<PythonParseResult> = Vec::new();
251 let mut errors = Vec::new();
252 let mut source_texts = HashMap::new();
253
254 for file_path in files {
255 let rel_path = file_path
256 .strip_prefix(root)
257 .unwrap_or(file_path)
258 .to_path_buf();
259
260 match std::fs::read_to_string(file_path) {
261 Ok(source) => {
262 let path_str = rel_path.display().to_string();
263 source_texts.insert(path_str, source.clone());
264
265 match parser.parse_file(&rel_path, &source) {
266 Ok(result) => {
267 py_results.push(result);
268 }
269 Err(e) => {
270 errors.push((file_path.clone(), e.to_string()));
271 }
272 }
273 }
274 Err(e) => {
275 errors.push((file_path.clone(), format!("{}: {}", file_path.display(), e)));
276 }
277 }
278 }
279
280 let all_nodes: Vec<CodeNode> = py_results.iter().flat_map(|r| r.nodes.clone()).collect();
282
283 for py_result in &py_results {
286 let parse_result = ParseResult {
288 nodes: py_result.nodes.clone(),
289 imports: py_result.imports.clone(),
290 };
291 all_parse_results.push(parse_result);
292 }
293
294 let resolver = NameResolver::from_nodes(&all_nodes);
295 let mut edges = extract_edges(&all_parse_results, &resolver);
296 let call_edges = crate::edges::extract_call_edges(&all_parse_results, &resolver, &source_texts);
297 edges.extend(call_edges);
298
299 Ok(IngestResult {
300 nodes: all_nodes,
301 edges,
302 parse_results: all_parse_results,
303 errors,
304 source_texts,
305 })
306}
307
308fn collect_python_files(dir: &Path) -> Result<Vec<PathBuf>> {
310 let mut files = Vec::new();
311 collect_source_files_recursive(dir, &mut files)?;
312 files.retain(|f| f.extension().is_some_and(|e| e == "py"));
314 files.sort();
315 Ok(files)
316}
317
318fn collect_source_files(dir: &Path) -> Result<Vec<PathBuf>> {
320 let mut files = Vec::new();
321 collect_source_files_recursive(dir, &mut files)?;
322 files.sort();
323 Ok(files)
324}
325
326fn collect_source_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
327 for entry in std::fs::read_dir(dir)? {
328 let entry = entry?;
329 let path = entry.path();
330
331 if path.is_dir() {
332 let name = path
334 .file_name()
335 .map(|n| n.to_string_lossy().to_string())
336 .unwrap_or_default();
337 if name.starts_with('.')
338 || name == "__pycache__"
339 || name == "node_modules"
340 || name == ".git"
341 || name == "venv"
342 || name == ".venv"
343 || name == "target"
344 {
345 continue;
346 }
347 collect_source_files_recursive(&path, files)?;
348 } else if path
349 .extension()
350 .is_some_and(|ext| ext == "py" || ext == "rs")
351 {
352 files.push(path);
353 }
354 }
355 Ok(())
356}
357
358pub fn nodes_in_file<'a>(nodes: &'a [CodeNode], file_path: &str) -> Vec<&'a CodeNode> {
360 nodes.iter().filter(|n| n.id.contains(file_path)).collect()
361}
362
363pub fn callers_of<'a>(edges: &'a [CodeEdge], target_name: &str) -> Vec<&'a CodeEdge> {
368 edges
369 .iter()
370 .filter(|e| {
371 if e.predicate != CodeEdgePredicate::Calls {
372 return false;
373 }
374 if let Some(last_segment) = e.target_id.rsplit("::").next() {
376 last_segment == target_name
377 } else {
378 e.target_id.ends_with(target_name)
380 }
381 })
382 .collect()
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn test_ingest_files_from_source() {
391 let dir = tempfile::tempdir().expect("create temp dir");
393 let file_a = dir.path().join("module_a.py");
394 let file_b = dir.path().join("module_b.py");
395
396 std::fs::write(
397 &file_a,
398 r#"
399"""Module A."""
400
401from module_b import helper
402
403class Processor:
404 """Processes data."""
405 def process(self, data):
406 """Process data."""
407 return helper(data)
408
409def standalone():
410 """Standalone function."""
411 return 42
412"#,
413 )
414 .expect("write a");
415
416 std::fs::write(
417 &file_b,
418 r#"
419"""Module B."""
420
421def helper(x):
422 """Help with x."""
423 return x * 2
424
425def another():
426 """Another function."""
427 return helper(10)
428"#,
429 )
430 .expect("write b");
431
432 let result = ingest_directory(dir.path()).expect("ingest should succeed");
433
434 assert!(
436 result.nodes.len() >= 8,
437 "Expected >= 8 nodes, got {}",
438 result.nodes.len()
439 );
440
441 assert!(!result.edges.is_empty(), "Should have edges");
443
444 let containment = result
446 .edges
447 .iter()
448 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
449 .count();
450 assert!(
451 containment >= 4,
452 "Expected >= 4 containment edges, got {}",
453 containment
454 );
455
456 let imports = result
458 .edges
459 .iter()
460 .filter(|e| e.predicate == CodeEdgePredicate::Imports)
461 .count();
462 assert!(imports >= 1, "Expected >= 1 import edge, got {}", imports);
463
464 let summary = result.summary();
466 assert!(summary.contains("Total nodes:"));
467 assert!(summary.contains("Total edges:"));
468
469 assert!(result.errors.is_empty(), "Should have no parse errors");
471 }
472
473 #[test]
474 fn test_ingest_builds_record_batches() {
475 let dir = tempfile::tempdir().expect("create temp dir");
476 std::fs::write(
477 dir.path().join("test.py"),
478 r#"
479def foo():
480 """A function."""
481 return 1
482
483def bar():
484 """Another function."""
485 return foo()
486"#,
487 )
488 .expect("write");
489
490 let result = ingest_directory(dir.path()).expect("ingest");
491 let nodes_batch = result.nodes_batch().expect("nodes batch");
492 let edges_batch = result.edges_batch().expect("edges batch");
493
494 assert!(nodes_batch.num_rows() > 0);
495 assert_eq!(nodes_batch.num_columns(), 19);
496 assert!(edges_batch.num_rows() > 0);
497 assert_eq!(edges_batch.num_columns(), 5);
498 }
499
500 #[test]
501 fn test_ingest_dir_not_found() {
502 let result = ingest_directory(Path::new("/nonexistent/path"));
503 assert!(result.is_err());
504 }
505
506 #[test]
507 fn test_ingest_skips_pycache() {
508 let dir = tempfile::tempdir().expect("create temp dir");
509 let pycache = dir.path().join("__pycache__");
510 std::fs::create_dir(&pycache).expect("create pycache");
511 std::fs::write(pycache.join("cached.py"), "x = 1").expect("write cached");
512 std::fs::write(dir.path().join("real.py"), "def foo(): pass").expect("write real");
513
514 let result = ingest_directory(dir.path()).expect("ingest");
515
516 let file_nodes: Vec<_> = result
518 .nodes
519 .iter()
520 .filter(|n| n.kind == CodeNodeKind::File)
521 .collect();
522 assert_eq!(file_nodes.len(), 1, "Should only parse real.py");
523 }
524
525 #[test]
526 fn test_nodes_in_file_query() {
527 let nodes = vec![
528 CodeNode {
529 id: "func:brain/utils.py::helper".to_string(),
530 kind: CodeNodeKind::Function,
531 parent_id: None,
532 name: "helper".to_string(),
533 signature: None,
534 docstring: None,
535 body_hash: None,
536 body: None,
537 loc: None,
538 cyclomatic_complexity: None,
539 coverage_pct: None,
540 last_modified: None,
541 ..Default::default()
542 },
543 CodeNode {
544 id: "func:brain/main.py::main".to_string(),
545 kind: CodeNodeKind::Function,
546 parent_id: None,
547 name: "main".to_string(),
548 signature: None,
549 docstring: None,
550 body_hash: None,
551 body: None,
552 loc: None,
553 cyclomatic_complexity: None,
554 coverage_pct: None,
555 last_modified: None,
556 ..Default::default()
557 },
558 ];
559
560 let utils_nodes = nodes_in_file(&nodes, "brain/utils.py");
561 assert_eq!(utils_nodes.len(), 1);
562 assert_eq!(utils_nodes[0].name, "helper");
563 }
564
565 #[test]
566 fn test_callers_of_query() {
567 let edges = vec![
568 CodeEdge {
569 source_id: "func:a.py::caller".to_string(),
570 target_id: "func:b.py::target".to_string(),
571 predicate: CodeEdgePredicate::Calls,
572 weight: Some(1.0),
573 commit_id: None,
574 },
575 CodeEdge {
576 source_id: "func:a.py::other".to_string(),
577 target_id: "func:c.py::unrelated".to_string(),
578 predicate: CodeEdgePredicate::Calls,
579 weight: Some(1.0),
580 commit_id: None,
581 },
582 ];
583
584 let callers = callers_of(&edges, "target");
585 assert_eq!(callers.len(), 1);
586 assert_eq!(callers[0].source_id, "func:a.py::caller");
587 }
588
589 #[test]
590 fn test_ingest_handles_syntax_errors_gracefully() {
591 let dir = tempfile::tempdir().expect("create temp dir");
592 std::fs::write(dir.path().join("good.py"), "def foo(): pass").expect("write good");
593 std::fs::write(dir.path().join("weird.py"), "def (: pass").expect("write weird");
596
597 let result = ingest_directory(dir.path()).expect("ingest should succeed");
598 assert!(!result.nodes.is_empty());
600 }
601
602 #[test]
603 fn test_ingest_rust_files() {
604 let dir = tempfile::tempdir().expect("create temp dir");
605 std::fs::create_dir(dir.path().join("src")).expect("mkdir");
606 std::fs::write(
607 dir.path().join("src/lib.rs"),
608 r#"
609use std::collections::HashMap;
610
611pub struct Config {
612 pub name: String,
613}
614
615impl Config {
616 pub fn new(name: &str) -> Self {
617 Config { name: name.to_string() }
618 }
619}
620
621pub fn process(input: &str) -> String {
622 input.to_uppercase()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628
629 #[test]
630 fn test_process() {
631 assert_eq!(process("hello"), "HELLO");
632 }
633}
634"#,
635 )
636 .expect("write rust");
637
638 let result = ingest_directory(dir.path()).expect("ingest should succeed");
639
640 let rust_nodes: Vec<_> = result
642 .nodes
643 .iter()
644 .filter(|n| n.kind.is_rust_specific())
645 .collect();
646 assert!(
647 rust_nodes.len() >= 5,
648 "Expected >= 5 Rust-specific nodes, got {} (kinds: {:?})",
649 rust_nodes.len(),
650 rust_nodes
651 .iter()
652 .map(|n| (n.kind, &n.name))
653 .collect::<Vec<_>>()
654 );
655
656 for node in &rust_nodes {
658 if !matches!(node.kind, CodeNodeKind::RustUse) {
659 assert!(
660 node.start_line.is_some() && node.start_line.unwrap() > 0,
661 "Rust node {} should have start_line > 0",
662 node.id
663 );
664 }
665 }
666
667 let containment = result
669 .edges
670 .iter()
671 .filter(|e| e.predicate == CodeEdgePredicate::Contains)
672 .count();
673 assert!(
674 containment >= 3,
675 "Expected >= 3 containment edges, got {}",
676 containment
677 );
678
679 assert!(
681 result.errors.is_empty(),
682 "Should have no parse errors: {:?}",
683 result.errors
684 );
685 }
686
687 #[test]
688 fn test_ingest_mixed_py_and_rs() {
689 let dir = tempfile::tempdir().expect("create temp dir");
690 std::fs::write(dir.path().join("app.py"), "def main():\n pass\n").expect("write py");
691 std::fs::write(dir.path().join("lib.rs"), "fn helper() -> i32 { 42 }\n").expect("write rs");
692
693 let result = ingest_directory(dir.path()).expect("ingest");
694
695 let file_nodes: Vec<_> = result
697 .nodes
698 .iter()
699 .filter(|n| n.kind == CodeNodeKind::File)
700 .collect();
701 assert_eq!(file_nodes.len(), 2, "Should have 2 file nodes");
702
703 let py_func = result
705 .nodes
706 .iter()
707 .find(|n| n.kind == CodeNodeKind::Function && n.name == "main");
708 assert!(py_func.is_some(), "Should have Python function main");
709
710 let rs_func = result
712 .nodes
713 .iter()
714 .find(|n| n.kind == CodeNodeKind::RustFn && n.name == "helper");
715 assert!(rs_func.is_some(), "Should have Rust function helper");
716 }
717
718 #[test]
719 fn test_ingest_skips_target_dir() {
720 let dir = tempfile::tempdir().expect("create temp dir");
721 let target = dir.path().join("target");
722 std::fs::create_dir(&target).expect("create target");
723 std::fs::write(target.join("build.rs"), "fn main() {}").expect("write target file");
724 std::fs::write(dir.path().join("real.rs"), "fn real() {}").expect("write real");
725
726 let result = ingest_directory(dir.path()).expect("ingest");
727
728 let file_nodes: Vec<_> = result
729 .nodes
730 .iter()
731 .filter(|n| n.kind == CodeNodeKind::File)
732 .collect();
733 assert_eq!(
734 file_nodes.len(),
735 1,
736 "Should only parse real.rs, not target/build.rs"
737 );
738 }
739
740 #[test]
741 fn test_collect_source_files_sorted() {
742 let dir = tempfile::tempdir().expect("create temp dir");
743 std::fs::write(dir.path().join("z.py"), "x=1").expect("write");
744 std::fs::write(dir.path().join("a.py"), "x=1").expect("write");
745 std::fs::create_dir(dir.path().join("sub")).expect("mkdir");
746 std::fs::write(dir.path().join("sub/m.py"), "x=1").expect("write");
747
748 let files = collect_source_files(dir.path()).expect("collect");
749 assert_eq!(files.len(), 3);
750 let names: Vec<_> = files
752 .iter()
753 .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
754 .collect();
755 assert!(names.windows(2).all(|w| w[0] <= w[1]));
756 }
757}