1use crate::ingest::{IngestResult, ingest_directory};
9use arrow::array::{Array, RecordBatch, StringArray};
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13#[derive(Debug)]
15pub struct WorkspaceIngestResult {
16 pub crates: HashMap<String, IngestResult>,
18 pub errors: Vec<(PathBuf, String)>,
20 pub crate_errors: Vec<(String, String)>,
22}
23
24impl WorkspaceIngestResult {
25 pub fn total_nodes(&self) -> usize {
27 self.crates.values().map(|r| r.nodes.len()).sum()
28 }
29
30 pub fn total_edges(&self) -> usize {
32 self.crates.values().map(|r| r.edges.len()).sum()
33 }
34
35 pub fn total_errors(&self) -> usize {
37 self.errors.len()
38 }
39
40 pub fn merged_nodes_batch(&self) -> Result<RecordBatch, arrow::error::ArrowError> {
42 let all_nodes: Vec<crate::schema::CodeNode> = self
43 .crates
44 .values()
45 .flat_map(|r| r.nodes.iter().cloned())
46 .collect();
47 crate::schema::build_code_nodes_batch(&all_nodes)
48 }
49
50 pub fn merged_edges_batch(&self) -> Result<RecordBatch, arrow::error::ArrowError> {
52 let all_edges: Vec<crate::schema::CodeEdge> = self
53 .crates
54 .values()
55 .flat_map(|r| r.edges.iter().cloned())
56 .collect();
57 crate::schema::build_code_edges_batch(&all_edges)
58 }
59
60 pub fn summary(&self) -> String {
62 let mut s = String::new();
63 s.push_str("=== Workspace Ingest Summary ===\n");
64 s.push_str(&format!("Crates ingested: {}\n", self.crates.len()));
65 s.push_str(&format!("Total CodeNodes: {}\n", self.total_nodes()));
66 s.push_str(&format!("Total CodeEdges: {}\n", self.total_edges()));
67 s.push_str(&format!("Parse errors: {}\n", self.total_errors()));
68 if !self.crate_errors.is_empty() {
69 s.push_str(&format!("Crate errors: {}\n", self.crate_errors.len()));
70 for (name, err) in &self.crate_errors {
71 s.push_str(&format!(" {name}: {err}\n"));
72 }
73 }
74 s
75 }
76}
77
78pub fn ingest_workspace(workspace_root: &Path) -> WorkspaceIngestResult {
89 let crate_dirs = discover_workspace_crates(workspace_root);
90
91 let mut crate_errors = Vec::new();
92
93 let mut all_files: Vec<PathBuf> = Vec::new();
95 for (crate_name, crate_dir) in &crate_dirs {
96 for subdir in &["src", "tests"] {
97 let dir = crate_dir.join(subdir);
98 if dir.is_dir() {
99 match collect_rs_files_recursive(&dir) {
100 Ok(files) => all_files.extend(files),
101 Err(e) => crate_errors.push((format!("{crate_name}/{subdir}"), e.to_string())),
102 }
103 }
104 }
105 }
106
107 if all_files.is_empty() && crate_dirs.is_empty() {
108 match ingest_directory(workspace_root) {
110 Ok(mut result) => {
111 let errors = std::mem::take(&mut result.errors);
112 let mut crates = HashMap::new();
113 crates.insert("workspace".to_string(), result);
114 return WorkspaceIngestResult {
115 crates,
116 errors,
117 crate_errors,
118 };
119 }
120 Err(e) => {
121 crate_errors.push(("workspace".to_string(), e.to_string()));
122 return WorkspaceIngestResult {
123 crates: HashMap::new(),
124 errors: Vec::new(),
125 crate_errors,
126 };
127 }
128 }
129 }
130
131 let mut crates = HashMap::new();
133 let mut errors = Vec::new();
134
135 match crate::ingest::ingest_files(workspace_root, &all_files) {
136 Ok(mut result) => {
137 let scip_result =
140 crate::scip_calls::extract_scip_call_edges(workspace_root, &result.nodes);
141 if !scip_result.edges.is_empty() {
142 tracing::info!(
143 "SCIP: {} call edges ({} resolved, {} unresolved)",
144 scip_result.edges.len(),
145 scip_result.symbols_resolved,
146 scip_result.unresolved_references,
147 );
148 result.edges.extend(scip_result.edges);
149 }
150 for w in &scip_result.warnings {
151 tracing::warn!("SCIP: {w}");
152 }
153
154 errors.append(&mut result.errors);
155 crates.insert("workspace".to_string(), result);
156 }
157 Err(e) => {
158 crate_errors.push(("workspace".to_string(), e.to_string()));
159 }
160 }
161
162 WorkspaceIngestResult {
163 crates,
164 errors,
165 crate_errors,
166 }
167}
168
169fn collect_rs_files_recursive(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
171 let mut files = Vec::new();
172 collect_rs_recursive(dir, &mut files)?;
173 Ok(files)
174}
175
176fn collect_rs_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
177 for entry in std::fs::read_dir(dir)? {
178 let entry = entry?;
179 let path = entry.path();
180 if path.is_dir() {
181 collect_rs_recursive(&path, files)?;
182 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
183 files.push(path);
184 }
185 }
186 Ok(())
187}
188
189pub fn discover_workspace_crates(workspace_root: &Path) -> Vec<(String, PathBuf)> {
194 let cargo_toml = workspace_root.join("Cargo.toml");
195 let Ok(content) = std::fs::read_to_string(&cargo_toml) else {
196 return vec![];
197 };
198
199 let Ok(doc) = content.parse::<toml::Value>() else {
200 return vec![];
201 };
202
203 let members: Vec<String> = doc
205 .get("workspace")
206 .and_then(|w| w.get("members"))
207 .and_then(|m| m.as_array())
208 .map(|arr| {
209 arr.iter()
210 .filter_map(|v| v.as_str().map(String::from))
211 .collect()
212 })
213 .unwrap_or_default();
214
215 let mut result = Vec::new();
216
217 if members.is_empty() {
218 let name = extract_crate_name(workspace_root).unwrap_or_else(|| "unknown".into());
220 result.push((name, workspace_root.to_path_buf()));
221 return result;
222 }
223
224 let mut seen_dirs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
227
228 for member_pattern in &members {
229 if member_pattern.ends_with("/*") {
231 let parent = workspace_root.join(&member_pattern[..member_pattern.len() - 2]);
232 if let Ok(entries) = std::fs::read_dir(&parent) {
233 let mut dirs: Vec<_> = entries
234 .filter_map(|e| e.ok())
235 .filter(|e| e.path().is_dir())
236 .collect();
237 dirs.sort_by_key(|e| e.file_name());
239 for entry in dirs {
240 let crate_dir = entry.path();
241 if seen_dirs.contains(&crate_dir) {
242 continue;
243 }
244 if let Some(name) = extract_crate_name(&crate_dir) {
245 seen_dirs.insert(crate_dir.clone());
246 result.push((name, crate_dir));
247 }
248 }
249 }
250 } else {
251 let crate_dir = workspace_root.join(member_pattern);
252 if crate_dir.is_dir() && !seen_dirs.contains(&crate_dir) {
253 let name = extract_crate_name(&crate_dir)
254 .unwrap_or_else(|| member_pattern.replace('/', "-"));
255 seen_dirs.insert(crate_dir.clone());
256 result.push((name, crate_dir));
257 }
258 }
259 }
260
261 result
262}
263
264fn extract_crate_name(crate_dir: &Path) -> Option<String> {
266 let cargo_toml = crate_dir.join("Cargo.toml");
267 let content = std::fs::read_to_string(cargo_toml).ok()?;
268 let doc: toml::Value = content.parse().ok()?;
269 doc.get("package")
270 .and_then(|p| p.get("name"))
271 .and_then(|n| n.as_str())
272 .map(String::from)
273}
274
275#[derive(Debug, Default)]
277pub struct GraphViolations {
278 pub dangling_sources: Vec<(String, String)>, pub dangling_targets: Vec<(String, String)>, pub duplicate_node_ids: Vec<String>,
284}
285
286impl GraphViolations {
287 pub fn is_clean(&self) -> bool {
289 self.dangling_sources.is_empty()
290 && self.dangling_targets.is_empty()
291 && self.duplicate_node_ids.is_empty()
292 }
293
294 pub fn report(&self) -> String {
296 if self.is_clean() {
297 return "Graph coherence: PASS — no violations found.\n".into();
298 }
299 let mut s = String::new();
300 s.push_str("Graph coherence: VIOLATIONS FOUND\n");
301 if !self.duplicate_node_ids.is_empty() {
302 s.push_str(&format!(
303 " Duplicate node IDs: {}\n",
304 self.duplicate_node_ids.len()
305 ));
306 for id in self.duplicate_node_ids.iter().take(10) {
307 s.push_str(&format!(" {id}\n"));
308 }
309 }
310 if !self.dangling_sources.is_empty() {
311 s.push_str(&format!(
312 " Dangling edge sources: {}\n",
313 self.dangling_sources.len()
314 ));
315 for (src, pred) in self.dangling_sources.iter().take(10) {
316 s.push_str(&format!(" [{pred}] source={src}\n"));
317 }
318 }
319 if !self.dangling_targets.is_empty() {
320 s.push_str(&format!(
321 " Dangling edge targets (non-ext:): {}\n",
322 self.dangling_targets.len()
323 ));
324 for (tgt, pred) in self.dangling_targets.iter().take(10) {
325 s.push_str(&format!(" [{pred}] target={tgt}\n"));
326 }
327 }
328 s
329 }
330}
331
332pub fn verify_graph(nodes: &RecordBatch, edges: &RecordBatch) -> GraphViolations {
340 use crate::schema::{edge_col, node_col};
341
342 let mut violations = GraphViolations::default();
343
344 let node_id_col = nodes
346 .column(node_col::ID)
347 .as_any()
348 .downcast_ref::<StringArray>();
349
350 let mut node_ids: HashSet<String> = HashSet::new();
351 if let Some(ids) = node_id_col {
352 for i in 0..ids.len() {
353 let id = ids.value(i).to_string();
354 if !node_ids.insert(id.clone()) {
355 violations.duplicate_node_ids.push(id);
356 }
357 }
358 }
359
360 let source_col = edges
362 .column(edge_col::SOURCE_ID)
363 .as_any()
364 .downcast_ref::<StringArray>();
365 let target_col = edges
366 .column(edge_col::TARGET_ID)
367 .as_any()
368 .downcast_ref::<StringArray>();
369 let pred_col = edges
370 .column(edge_col::PREDICATE)
371 .as_any()
372 .downcast_ref::<StringArray>();
373
374 if let (Some(sources), Some(targets), Some(preds)) = (source_col, target_col, pred_col) {
375 for i in 0..sources.len() {
376 let src = sources.value(i);
377 let tgt = targets.value(i);
378 let pred = preds.value(i);
379
380 if !src.is_empty() && !node_ids.contains(src) {
381 violations
382 .dangling_sources
383 .push((src.to_string(), pred.to_string()));
384 }
385
386 if !tgt.starts_with("ext:") && !tgt.is_empty() && !node_ids.contains(tgt) {
388 violations
389 .dangling_targets
390 .push((tgt.to_string(), pred.to_string()));
391 }
392 }
393 }
394
395 violations
396}
397
398pub fn write_graph_parquet(
404 nodes: &RecordBatch,
405 edges: &RecordBatch,
406 output_dir: &Path,
407) -> Result<(), String> {
408 use parquet::arrow::ArrowWriter;
409 use std::fs::File;
410
411 std::fs::create_dir_all(output_dir).map_err(|e| e.to_string())?;
412
413 let nodes_path = output_dir.join("nodes.parquet");
414 let nodes_file = File::create(&nodes_path).map_err(|e| e.to_string())?;
415 let mut nodes_writer =
416 ArrowWriter::try_new(nodes_file, nodes.schema(), None).map_err(|e| e.to_string())?;
417 nodes_writer.write(nodes).map_err(|e| e.to_string())?;
418 nodes_writer.close().map_err(|e| e.to_string())?;
419
420 let edges_path = output_dir.join("edges.parquet");
421 let edges_file = File::create(&edges_path).map_err(|e| e.to_string())?;
422 let mut edges_writer =
423 ArrowWriter::try_new(edges_file, edges.schema(), None).map_err(|e| e.to_string())?;
424 edges_writer.write(edges).map_err(|e| e.to_string())?;
425 edges_writer.close().map_err(|e| e.to_string())?;
426
427 Ok(())
428}
429
430pub fn load_nodes_from_parquet(dir: &Path) -> Result<WorkspaceIngestResult, String> {
441 use arrow::array::{Array, StringArray};
442 use arrow::compute::cast;
443 use arrow::datatypes::DataType;
444 use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
445 use std::fs::File;
446
447 use crate::schema::{CodeNode, CodeNodeKind};
448
449 let nodes_path = dir.join("nodes.parquet");
450 let file = File::open(&nodes_path).map_err(|e| {
451 format!(
452 "load_nodes_from_parquet: cannot open {}: {e}",
453 nodes_path.display()
454 )
455 })?;
456 let builder = ParquetRecordBatchReaderBuilder::try_new(file)
457 .map_err(|e| format!("load_nodes_from_parquet: parquet open: {e}"))?;
458 let mut reader = builder
459 .build()
460 .map_err(|e| format!("load_nodes_from_parquet: build reader: {e}"))?;
461
462 fn to_string_array(col: &dyn Array) -> StringArray {
464 let utf8 = cast(col, &DataType::Utf8).expect("cast to Utf8");
465 utf8.as_any()
466 .downcast_ref::<StringArray>()
467 .expect("StringArray after cast")
468 .clone()
469 }
470
471 let mut all_nodes: Vec<CodeNode> = Vec::new();
472
473 for batch in &mut reader {
474 let batch = batch.map_err(|e| format!("load_nodes_from_parquet: read batch: {e}"))?;
475 let nrows = batch.num_rows();
476 if nrows == 0 {
477 continue;
478 }
479
480 let ids = to_string_array(batch.column(0).as_ref());
486 let kinds = to_string_array(batch.column(1).as_ref());
487 let names = to_string_array(batch.column(3).as_ref());
488
489 let body_large = batch.column(7).as_ref();
491 let body_utf8 = cast(body_large, &DataType::Utf8)
492 .map_err(|e| format!("load_nodes_from_parquet: cast body: {e}"))?;
493 let bodies = body_utf8
494 .as_any()
495 .downcast_ref::<StringArray>()
496 .ok_or("load_nodes_from_parquet: body cast failed")?;
497
498 let file_paths = to_string_array(batch.column(17).as_ref());
499
500 for i in 0..nrows {
501 if ids.is_null(i) {
502 continue;
503 }
504 let kind = CodeNodeKind::parse(kinds.value(i)).unwrap_or(CodeNodeKind::Function);
505 let node = CodeNode {
506 id: ids.value(i).to_string(),
507 kind,
508 name: names.value(i).to_string(),
509 body: if bodies.is_null(i) {
510 None
511 } else {
512 Some(bodies.value(i).to_string())
513 },
514 file_path: if file_paths.is_null(i) {
515 None
516 } else {
517 Some(file_paths.value(i).to_string())
518 },
519 ..Default::default()
520 };
521 all_nodes.push(node);
522 }
523 }
524
525 let mut crates: HashMap<String, IngestResult> = HashMap::new();
527 for node in all_nodes {
528 let crate_name = node
529 .file_path
530 .as_deref()
531 .and_then(|fp| {
532 let stripped = fp.strip_prefix("crates/")?;
533 let slash = stripped.find('/')?;
534 Some(stripped[..slash].to_string())
535 })
536 .unwrap_or_else(|| "_root".to_string());
537
538 crates
539 .entry(crate_name)
540 .or_insert_with(|| IngestResult {
541 nodes: Vec::new(),
542 edges: Vec::new(),
543 parse_results: Vec::new(),
544 errors: Vec::new(),
545 source_texts: HashMap::new(),
546 })
547 .nodes
548 .push(node);
549 }
550
551 Ok(WorkspaceIngestResult {
552 crates,
553 errors: Vec::new(),
554 crate_errors: Vec::new(),
555 })
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use std::path::PathBuf;
562
563 fn workspace_root() -> PathBuf {
564 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
566 .parent()
567 .expect("crates/")
568 .parent()
569 .expect("workspace root")
570 .to_path_buf()
571 }
572
573 #[test]
574 fn test_discover_workspace_crates() {
575 let root = workspace_root();
576 let crates = discover_workspace_crates(&root);
577 assert!(
579 crates.len() >= 8,
580 "expected at least 8 workspace crates, got {}",
581 crates.len()
582 );
583 let names: Vec<&str> = crates.iter().map(|(n, _)| n.as_str()).collect();
585 assert!(
586 names.contains(&"nusy-arrow-core"),
587 "nusy-arrow-core must be in workspace crates"
588 );
589 assert!(
590 names.contains(&"nusy-codegraph"),
591 "nusy-codegraph must be in workspace crates"
592 );
593 }
594
595 #[test]
596 fn test_ingest_workspace_produces_nodes() {
597 let root = workspace_root();
598 let result = ingest_workspace(&root);
599
600 assert!(
601 result.total_nodes() >= 1_000,
602 "expected >= 1000 CodeNodes from workspace, got {}",
603 result.total_nodes()
604 );
605 assert!(
606 result.total_edges() >= 2_000,
607 "expected >= 2000 CodeEdges from workspace, got {}",
608 result.total_edges()
609 );
610 assert!(!result.crates.is_empty(), "crates map must not be empty");
612 }
613
614 #[test]
615 fn test_verify_graph_clean() {
616 let root = workspace_root();
617 let result = ingest_workspace(&root);
618
619 let nodes = result
620 .merged_nodes_batch()
621 .expect("nodes batch should build");
622 let edges = result
623 .merged_edges_batch()
624 .expect("edges batch should build");
625 let violations = verify_graph(&nodes, &edges);
626
627 let total_nodes = nodes.num_rows();
631 let dup_count = violations.duplicate_node_ids.len();
632 let dup_rate = if total_nodes > 0 {
633 dup_count as f64 / total_nodes as f64
634 } else {
635 0.0
636 };
637 assert!(
638 dup_rate < 0.02,
639 "duplicate node ID rate {:.1}% ({}/{}) exceeds 2% threshold",
640 dup_rate * 100.0,
641 dup_count,
642 total_nodes
643 );
644 }
647
648 #[test]
649 fn test_verify_detects_duplicate_nodes() {
650 use crate::schema::{
651 CodeNode, CodeNodeKind, build_code_edges_batch, build_code_nodes_batch,
652 };
653
654 let nodes = vec![
655 CodeNode {
656 id: "dup::foo".into(),
657 kind: CodeNodeKind::Function,
658 name: "foo".into(),
659 file_path: Some("lib.rs".into()),
660 ..Default::default()
661 },
662 CodeNode {
663 id: "dup::foo".into(), kind: CodeNodeKind::Function,
665 name: "foo".into(),
666 file_path: Some("lib.rs".into()),
667 ..Default::default()
668 },
669 ];
670 let nodes_batch = build_code_nodes_batch(&nodes).expect("build nodes");
671 let edges_batch = build_code_edges_batch(&[]).expect("build empty edges");
672 let violations = verify_graph(&nodes_batch, &edges_batch);
673
674 assert_eq!(
675 violations.duplicate_node_ids.len(),
676 1,
677 "should detect the 1 duplicate ID"
678 );
679 assert_eq!(violations.duplicate_node_ids[0], "dup::foo");
680 }
681
682 #[test]
683 fn test_write_and_verify_parquet() {
684 use crate::schema::{
685 CodeNode, CodeNodeKind, build_code_edges_batch, build_code_nodes_batch,
686 };
687 use tempfile::TempDir;
688
689 let tmp = TempDir::new().expect("tempdir");
690 let nodes = vec![CodeNode {
691 id: "test::bar".into(),
692 kind: CodeNodeKind::Function,
693 name: "bar".into(),
694 file_path: Some("test.rs".into()),
695 ..Default::default()
696 }];
697 let nodes_batch = build_code_nodes_batch(&nodes).expect("build");
698 let edges_batch = build_code_edges_batch(&[]).expect("build");
699
700 write_graph_parquet(&nodes_batch, &edges_batch, tmp.path())
701 .expect("write parquet should succeed");
702
703 assert!(
704 tmp.path().join("nodes.parquet").exists(),
705 "nodes.parquet must exist"
706 );
707 assert!(
708 tmp.path().join("edges.parquet").exists(),
709 "edges.parquet must exist"
710 );
711 }
712}