Skip to main content

nusy_codegraph/
ingest_pipeline.rs

1//! High-level ingestion pipeline — workspace-wide self-ingest and graph coherence verification.
2//!
3//! Provides:
4//! - `ingest_workspace()`: walk all crates in a Cargo workspace and ingest them
5//! - `verify_graph()`: check graph coherence (no dangling edges, no duplicate IDs)
6//! - `write_graph_parquet()`: persist CodeNode + CodeEdge batches to Parquet files
7
8use crate::ingest::{IngestResult, ingest_directory};
9use arrow::array::{Array, RecordBatch, StringArray};
10use std::collections::{HashMap, HashSet};
11use std::path::{Path, PathBuf};
12
13/// Workspace-wide ingestion result.
14#[derive(Debug)]
15pub struct WorkspaceIngestResult {
16    /// Per-crate ingestion results, keyed by crate name.
17    pub crates: HashMap<String, IngestResult>,
18    /// Files that failed to parse across all crates.
19    pub errors: Vec<(PathBuf, String)>,
20    /// Crate directories that could not be opened.
21    pub crate_errors: Vec<(String, String)>,
22}
23
24impl WorkspaceIngestResult {
25    /// Total node count across all crates.
26    pub fn total_nodes(&self) -> usize {
27        self.crates.values().map(|r| r.nodes.len()).sum()
28    }
29
30    /// Total edge count across all crates.
31    pub fn total_edges(&self) -> usize {
32        self.crates.values().map(|r| r.edges.len()).sum()
33    }
34
35    /// Total parse error count.
36    pub fn total_errors(&self) -> usize {
37        self.errors.len()
38    }
39
40    /// Build a merged CodeNodes RecordBatch (all crates combined).
41    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    /// Build a merged CodeEdges RecordBatch (all crates combined).
51    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    /// Human-readable summary.
61    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
78/// Ingest all crates in a Cargo workspace.
79///
80/// Discovers crates by reading `<workspace_root>/Cargo.toml`, collects all `.rs`
81/// files from each crate's `src/` and `tests/` directories, and ingests them
82/// in a **single pass** from `workspace_root` as the root. This ensures all
83/// `CodeNode` IDs are workspace-relative (e.g. `crates/nusy-arrow-core/src/lib.rs::foo`)
84/// and therefore globally unique across the merged graph.
85///
86/// If `workspace_root` is not a workspace root (no `[workspace]` key), falls back
87/// to ingesting the directory as a single crate.
88pub 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    // Collect all source files from all crates — paths are absolute
94    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        // Fall back: treat workspace_root itself as a single crate
109        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    // Single ingest pass from workspace_root → globally unique workspace-relative IDs
132    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            // SCIP pass: extract high-fidelity call edges via rust-analyzer.
138            // Gracefully skipped if rust-analyzer is not installed.
139            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
169/// Recursively collect all `.rs` files under a directory.
170fn 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
189/// Discover all crate directories in a Cargo workspace.
190///
191/// Reads `<workspace_root>/Cargo.toml` and expands the `[workspace] members`
192/// globs into concrete crate directories. Returns `(crate_name, crate_dir)` pairs.
193pub 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    // Extract workspace members
204    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        // Not a workspace root — treat the directory itself as a single crate
219        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    // Track seen crate directories to avoid duplicates (e.g., workspace Cargo.toml
225    // may list the same crate twice)
226    let mut seen_dirs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
227
228    for member_pattern in &members {
229        // Expand simple glob patterns (e.g., "crates/*")
230        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                // Sort for deterministic ordering
238                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
264/// Read the `[package] name` from a crate's `Cargo.toml`.
265fn 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/// Graph coherence violations found by `verify_graph()`.
276#[derive(Debug, Default)]
277pub struct GraphViolations {
278    /// Edges whose `source_id` has no corresponding CodeNode.
279    pub dangling_sources: Vec<(String, String)>, // (edge_source_id, predicate)
280    /// Edges whose `target_id` has no corresponding CodeNode (excluding ext: refs).
281    pub dangling_targets: Vec<(String, String)>, // (edge_target_id, predicate)
282    /// Duplicate CodeNode IDs.
283    pub duplicate_node_ids: Vec<String>,
284}
285
286impl GraphViolations {
287    /// True if no violations were found.
288    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    /// Human-readable violation report.
295    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
332/// Verify graph coherence of merged CodeNode + CodeEdge RecordBatches.
333///
334/// Checks:
335/// - No duplicate `node_id` values in the CodeNodes batch
336/// - No dangling edge sources (every `source_id` exists in CodeNodes)
337/// - No dangling `target_id` values — `ext:` refs are excluded (they are
338///   intentional unresolved external references)
339pub 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    // Build node ID set and check for duplicates
345    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    // Check edges for dangling sources and targets
361    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            // Exclude ext: references — they are intentional unresolved external references
387            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
398/// Write CodeNodes and CodeEdges RecordBatches to Parquet files.
399///
400/// Creates two files:
401/// - `<output_dir>/nodes.parquet`
402/// - `<output_dir>/edges.parquet`
403pub 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
430/// Load CodeNodes from a Parquet graph directory produced by `write_graph_parquet()`.
431///
432/// Reads `<dir>/nodes.parquet`, deserialises each row into a `CodeNode`, then
433/// groups the nodes by crate prefix (derived from `file_path`) to produce a
434/// `WorkspaceIngestResult` identical in shape to what `ingest_workspace()` returns.
435///
436/// Nodes whose `file_path` does not start with `crates/<name>/` are grouped under
437/// the synthetic crate name `"_root"`.
438///
439/// Returns `Err` if the Parquet file cannot be opened or read.
440pub 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    // Helper: cast any array (including dictionaries) to a plain Utf8 StringArray
463    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        // Column indices match `code_nodes_schema()`:
481        // 0=id, 1=kind, 2=parent_id, 3=name, 4=signature, 5=docstring,
482        // 6=body_hash, 7=body(LargeUtf8), 8=embedding, 9=loc, 10=cyclomatic,
483        // 11=coverage, 12=last_modified, 13=start_line, 14=end_line,
484        // 15=start_col, 16=end_col, 17=file_path, 18=byte_offset
485        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        // body is LargeUtf8 — cast to Utf8 via cast kernel
490        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    // Group by crate name derived from file_path prefix "crates/<name>/"
526    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        // Navigate from crates/nusy-codegraph/ up two levels to repo root
565        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        // NuSy has 10+ crates
578        assert!(
579            crates.len() >= 8,
580            "expected at least 8 workspace crates, got {}",
581            crates.len()
582        );
583        // nusy-arrow-core and nusy-codegraph should be among them
584        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        // Single-pass ingest from workspace root stores results under "workspace" key
611        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        // The parser assigns IDs like `rust_const:path.rs::NAME` which can collide
628        // for same-named constants in different files. Accept a small duplicate rate
629        // (< 2% of total nodes) as a known parser limitation.
630        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        // Dangling sources/targets are warnings, not hard failures in test
645        // (cross-crate edges point to nodes in other crates' batches)
646    }
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(), // duplicate
664                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}