1use crate::cargo_parser::{CrateManifest, DependencySource, parse_workspace_dependencies};
25use crate::crate_schema::{crate_edge_schema, crate_node_schema};
26use arrow::array::{BooleanArray, RecordBatch, StringArray};
27use std::collections::{HashMap, HashSet, VecDeque};
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30
31pub struct CrateGraph {
33 pub crate_nodes: RecordBatch,
35 pub crate_edges: RecordBatch,
37}
38
39pub fn build_crate_graph(workspace_root: &Path) -> Result<CrateGraph, String> {
50 let ws_toml = workspace_root.join("Cargo.toml");
52 let ws_deps = parse_workspace_dependencies(&ws_toml).unwrap_or_default();
53
54 let ws_pkg_version =
57 read_workspace_package_version(&ws_toml).unwrap_or_else(|| "0.0.0".to_string());
58 let mut ws_deps = ws_deps;
59 ws_deps.insert(String::new(), ws_pkg_version.clone());
60
61 let member_tomls = find_member_cargo_tomls(workspace_root)?;
63 let member_names: HashSet<String> = member_tomls
64 .iter()
65 .filter_map(|p| {
66 quick_read_crate_name(p)
68 })
69 .collect();
70
71 let mut manifests: Vec<CrateManifest> = Vec::with_capacity(member_tomls.len());
73 for toml_path in &member_tomls {
74 match CrateManifest::from_path_with_workspace(toml_path, &ws_deps, true) {
75 Ok(m) => manifests.push(m),
76 Err(e) => {
77 eprintln!("warn: skipping {}: {e}", toml_path.display());
79 }
80 }
81 }
82
83 let mut node_ids: Vec<&str> = Vec::with_capacity(manifests.len());
85 let mut node_versions: Vec<&str> = Vec::with_capacity(manifests.len());
86 let mut node_workspace_members: Vec<bool> = Vec::with_capacity(manifests.len());
87 let mut node_descriptions: Vec<Option<&str>> = Vec::with_capacity(manifests.len());
88 let mut node_editions: Vec<&str> = Vec::with_capacity(manifests.len());
89
90 for m in &manifests {
91 node_ids.push(m.name.as_str());
92 node_versions.push(m.version.as_str());
93 node_workspace_members.push(m.workspace_member);
94 node_descriptions.push(m.description.as_deref());
95 node_editions.push(m.edition.as_str());
96 }
97
98 let crate_nodes = RecordBatch::try_new(
99 crate_node_schema(),
100 vec![
101 Arc::new(StringArray::from(node_ids)),
102 Arc::new(StringArray::from(node_versions)),
103 Arc::new(BooleanArray::from(node_workspace_members)),
104 Arc::new(StringArray::from(node_descriptions)),
105 Arc::new(StringArray::from(node_editions)),
106 ],
107 )
108 .map_err(|e| format!("Arrow error building CrateNodes: {e}"))?;
109
110 let mut edge_sources: Vec<String> = Vec::new();
112 let mut edge_targets: Vec<String> = Vec::new();
113 let mut edge_version_reqs: Vec<String> = Vec::new();
114 let mut edge_optionals: Vec<bool> = Vec::new();
115 let mut edge_dev_deps: Vec<bool> = Vec::new();
116 let mut edge_build_deps: Vec<bool> = Vec::new();
117 let mut edge_source_kinds: Vec<String> = Vec::new();
118
119 for m in &manifests {
120 for dep in m.all_dependencies() {
121 let source_kind = match &dep.source {
124 DependencySource::Workspace => "workspace",
125 DependencySource::CratesIo => "crates_io",
126 DependencySource::Git { .. } => "git",
127 DependencySource::Path { .. } => "path",
128 };
129
130 let target_name = resolve_dep_name(dep, &member_names, workspace_root);
132
133 edge_sources.push(m.name.clone());
134 edge_targets.push(target_name);
135 edge_version_reqs.push(dep.version_req.clone());
136 edge_optionals.push(dep.optional);
137 edge_dev_deps.push(dep.dev);
138 edge_build_deps.push(dep.build);
139 edge_source_kinds.push(source_kind.to_string());
140 }
141 }
142
143 let edge_source_refs: Vec<&str> = edge_sources.iter().map(|s| s.as_str()).collect();
144 let edge_target_refs: Vec<&str> = edge_targets.iter().map(|s| s.as_str()).collect();
145 let edge_version_req_refs: Vec<&str> = edge_version_reqs.iter().map(|s| s.as_str()).collect();
146 let edge_source_kind_refs: Vec<&str> = edge_source_kinds.iter().map(|s| s.as_str()).collect();
147
148 let crate_edges = RecordBatch::try_new(
149 crate_edge_schema(),
150 vec![
151 Arc::new(StringArray::from(edge_source_refs)),
152 Arc::new(StringArray::from(edge_target_refs)),
153 Arc::new(StringArray::from(edge_version_req_refs)),
154 Arc::new(BooleanArray::from(edge_optionals)),
155 Arc::new(BooleanArray::from(edge_dev_deps)),
156 Arc::new(BooleanArray::from(edge_build_deps)),
157 Arc::new(StringArray::from(edge_source_kind_refs)),
158 ],
159 )
160 .map_err(|e| format!("Arrow error building CrateEdges: {e}"))?;
161
162 let workspace_names: HashSet<&str> = manifests.iter().map(|m| m.name.as_str()).collect();
165
166 let mut external_versions: std::collections::BTreeMap<String, String> =
168 std::collections::BTreeMap::new();
169 for (i, target) in edge_targets.iter().enumerate() {
170 if !workspace_names.contains(target.as_str()) {
171 external_versions
172 .entry(target.clone())
173 .or_insert_with(|| edge_version_reqs[i].clone());
174 }
175 }
176
177 if !external_versions.is_empty() {
178 let mut all_ids: Vec<String> = manifests.iter().map(|m| m.name.clone()).collect();
180 let mut all_versions: Vec<String> = manifests.iter().map(|m| m.version.clone()).collect();
181 let mut all_ws: Vec<bool> = manifests.iter().map(|_| true).collect();
182 let mut all_desc: Vec<Option<String>> =
183 manifests.iter().map(|m| m.description.clone()).collect();
184 let mut all_ed: Vec<String> = manifests.iter().map(|m| m.edition.clone()).collect();
185
186 for (name, ver) in &external_versions {
187 all_ids.push(name.clone());
188 all_versions.push(ver.clone());
189 all_ws.push(false);
190 all_desc.push(None);
191 all_ed.push(String::new());
192 }
193
194 let id_refs: Vec<&str> = all_ids.iter().map(|s| s.as_str()).collect();
195 let ver_refs: Vec<&str> = all_versions.iter().map(|s| s.as_str()).collect();
196 let desc_refs: Vec<Option<&str>> = all_desc.iter().map(|o| o.as_deref()).collect();
197 let ed_refs: Vec<&str> = all_ed.iter().map(|s| s.as_str()).collect();
198
199 let crate_nodes = RecordBatch::try_new(
200 crate_node_schema(),
201 vec![
202 Arc::new(StringArray::from(id_refs)),
203 Arc::new(StringArray::from(ver_refs)),
204 Arc::new(BooleanArray::from(all_ws)),
205 Arc::new(StringArray::from(desc_refs)),
206 Arc::new(StringArray::from(ed_refs)),
207 ],
208 )
209 .map_err(|e| format!("Arrow error building CrateNodes (with external): {e}"))?;
210
211 return Ok(CrateGraph {
212 crate_nodes,
213 crate_edges,
214 });
215 }
216
217 Ok(CrateGraph {
218 crate_nodes,
219 crate_edges,
220 })
221}
222
223pub fn topo_sort_crates(graph: &CrateGraph) -> Result<Vec<String>, String> {
233 use crate::crate_schema::crate_edge_col;
234 use arrow::array::Array;
235 use arrow::array::{BooleanArray, StringArray};
236
237 use crate::crate_schema::crate_node_col;
239
240 let id_col = graph
241 .crate_nodes
242 .column(crate_node_col::ID)
243 .as_any()
244 .downcast_ref::<StringArray>()
245 .ok_or("CrateNode id column is not StringArray")?;
246
247 let wm_col = graph
248 .crate_nodes
249 .column(crate_node_col::WORKSPACE_MEMBER)
250 .as_any()
251 .downcast_ref::<BooleanArray>()
252 .ok_or("CrateNode workspace_member column is not BooleanArray")?;
253
254 let workspace_members: HashSet<String> = (0..id_col.len())
255 .filter(|&i| wm_col.value(i))
256 .map(|i| id_col.value(i).to_string())
257 .collect();
258
259 let mut adjacency: HashMap<String, HashSet<String>> = HashMap::new();
262 let mut in_degree: HashMap<String, usize> = HashMap::new();
264
265 for name in &workspace_members {
267 adjacency.entry(name.clone()).or_default();
268 in_degree.entry(name.clone()).or_insert(0);
269 }
270
271 let source_col = graph
272 .crate_edges
273 .column(crate_edge_col::SOURCE)
274 .as_any()
275 .downcast_ref::<StringArray>()
276 .ok_or("CrateEdge source column is not StringArray")?;
277 let target_col = graph
278 .crate_edges
279 .column(crate_edge_col::TARGET)
280 .as_any()
281 .downcast_ref::<StringArray>()
282 .ok_or("CrateEdge target column is not StringArray")?;
283 let source_kind_col = graph
284 .crate_edges
285 .column(crate_edge_col::SOURCE_KIND)
286 .as_any()
287 .downcast_ref::<StringArray>()
288 .ok_or("CrateEdge source_kind column is not StringArray")?;
289 let dev_dep_col = graph
290 .crate_edges
291 .column(crate_edge_col::DEV_DEP)
292 .as_any()
293 .downcast_ref::<BooleanArray>()
294 .ok_or("CrateEdge dev_dep column is not BooleanArray")?;
295
296 for i in 0..source_col.len() {
297 let source_kind = source_kind_col.value(i);
298 if source_kind != "path" && source_kind != "workspace" {
300 continue;
301 }
302 if dev_dep_col.value(i) {
304 continue;
305 }
306
307 let src = source_col.value(i).to_string();
308 let tgt = target_col.value(i).to_string();
309
310 if !workspace_members.contains(&src) || !workspace_members.contains(&tgt) {
312 continue;
313 }
314
315 if adjacency
319 .entry(tgt.clone())
320 .or_default()
321 .insert(src.clone())
322 {
323 *in_degree.entry(src).or_insert(0) += 1;
324 }
325 }
326
327 let mut queue: VecDeque<String> = in_degree
329 .iter()
330 .filter(|(_, deg)| **deg == 0)
331 .map(|(name, _)| name.clone())
332 .collect();
333
334 let mut sorted_queue: Vec<String> = queue.drain(..).collect();
336 sorted_queue.sort();
337 let mut queue: VecDeque<String> = sorted_queue.into_iter().collect();
338
339 let mut result: Vec<String> = Vec::with_capacity(workspace_members.len());
340
341 while let Some(node) = queue.pop_front() {
342 result.push(node.clone());
343
344 if let Some(successors) = adjacency.get(&node) {
345 let mut succ_sorted: Vec<String> = successors.iter().cloned().collect();
346 succ_sorted.sort();
347 for succ in succ_sorted {
348 let deg = in_degree.entry(succ.clone()).or_insert(0);
349 *deg -= 1;
350 if *deg == 0 {
351 queue.push_back(succ);
352 }
353 }
354 }
355 }
356
357 if result.len() != workspace_members.len() {
358 return Err(format!(
359 "cycle detected in workspace dependency graph: processed {} of {} crates",
360 result.len(),
361 workspace_members.len()
362 ));
363 }
364
365 Ok(result)
366}
367
368fn find_member_cargo_tomls(workspace_root: &Path) -> Result<Vec<PathBuf>, String> {
375 let crates_dir = workspace_root.join("crates");
376
377 let search_dir = if crates_dir.is_dir() {
378 crates_dir
379 } else {
380 workspace_root.to_path_buf()
381 };
382
383 let mut tomls: Vec<PathBuf> = Vec::new();
384
385 let entries = std::fs::read_dir(&search_dir)
386 .map_err(|e| format!("cannot read {}: {e}", search_dir.display()))?;
387
388 for entry in entries.flatten() {
389 let path = entry.path();
390 if path.is_dir() {
391 let toml = path.join("Cargo.toml");
392 if toml.exists() {
393 tomls.push(toml);
394 }
395 }
396 }
397
398 tomls.sort();
399 Ok(tomls)
400}
401
402fn quick_read_crate_name(path: &Path) -> Option<String> {
404 let raw = std::fs::read_to_string(path).ok()?;
405 let doc: toml::Value = raw.parse().ok()?;
406 doc.get("package")?
407 .get("name")?
408 .as_str()
409 .map(|s| s.to_string())
410}
411
412fn read_workspace_package_version(ws_toml: &Path) -> Option<String> {
414 let raw = std::fs::read_to_string(ws_toml).ok()?;
415 let doc: toml::Value = raw.parse().ok()?;
416 doc.get("workspace")?
417 .get("package")?
418 .get("version")?
419 .as_str()
420 .map(|s| s.to_string())
421}
422
423fn resolve_dep_name(
429 dep: &crate::cargo_parser::CrateDependency,
430 _member_names: &HashSet<String>,
431 workspace_root: &Path,
432) -> String {
433 if let DependencySource::Path { .. } = &dep.source {
434 let candidate = workspace_root
437 .join("crates")
438 .join(&dep.name)
439 .join("Cargo.toml");
440 if let Some(actual_name) = quick_read_crate_name(&candidate) {
441 return actual_name;
442 }
443 }
444 dep.name.clone()
445}
446
447#[cfg(test)]
450mod tests {
451 use super::*;
452 use crate::crate_schema::{crate_edge_col, crate_node_col};
453 use arrow::array::Array;
454 use arrow::array::{BooleanArray, StringArray};
455
456 fn workspace_root() -> PathBuf {
457 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
458 .parent()
459 .unwrap()
460 .parent()
461 .unwrap()
462 .to_path_buf()
463 }
464
465 #[test]
468 fn test_build_crate_graph_on_workspace() {
469 let graph = build_crate_graph(&workspace_root())
470 .expect("build_crate_graph should succeed on NuSy workspace");
471
472 assert!(
474 graph.crate_nodes.num_rows() >= 5,
475 "expected >= 5 crate nodes, got {}",
476 graph.crate_nodes.num_rows()
477 );
478 assert!(graph.crate_edges.num_rows() > 0, "expected > 0 crate edges");
480 assert_eq!(graph.crate_nodes.num_columns(), 5);
482 assert_eq!(graph.crate_edges.num_columns(), 7);
483 }
484
485 #[test]
488 fn test_nusy_arrow_core_is_workspace_member() {
489 let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
490
491 let id_col = graph
492 .crate_nodes
493 .column(crate_node_col::ID)
494 .as_any()
495 .downcast_ref::<StringArray>()
496 .unwrap();
497 let wm_col = graph
498 .crate_nodes
499 .column(crate_node_col::WORKSPACE_MEMBER)
500 .as_any()
501 .downcast_ref::<BooleanArray>()
502 .unwrap();
503
504 let row = (0..id_col.len())
505 .find(|&i| id_col.value(i) == "nusy-arrow-core")
506 .expect("nusy-arrow-core should be in CrateNode table");
507
508 assert!(
509 wm_col.value(row),
510 "nusy-arrow-core should have workspace_member=true"
511 );
512 }
513
514 #[test]
517 fn test_nusy_codegraph_depends_on_nusy_arrow_core() {
518 let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
519
520 let source_col = graph
521 .crate_edges
522 .column(crate_edge_col::SOURCE)
523 .as_any()
524 .downcast_ref::<StringArray>()
525 .unwrap();
526 let target_col = graph
527 .crate_edges
528 .column(crate_edge_col::TARGET)
529 .as_any()
530 .downcast_ref::<StringArray>()
531 .unwrap();
532
533 let has_edge = (0..source_col.len()).any(|i| {
534 source_col.value(i) == "nusy-codegraph" && target_col.value(i) == "nusy-arrow-core"
535 });
536
537 assert!(
538 has_edge,
539 "expected nusy-codegraph → nusy-arrow-core edge in CrateEdge table"
540 );
541 }
542
543 #[test]
546 fn test_topo_sort_core_before_codegraph() {
547 let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
548
549 let order =
550 topo_sort_crates(&graph).expect("topo_sort_crates should succeed with no cycles");
551
552 let core_pos = order.iter().position(|n| n == "nusy-arrow-core");
553 let codegraph_pos = order.iter().position(|n| n == "nusy-codegraph");
554
555 let core_pos = core_pos.expect("nusy-arrow-core should be in topo sort result");
556 let codegraph_pos = codegraph_pos.expect("nusy-codegraph should be in topo sort result");
557
558 assert!(
559 core_pos < codegraph_pos,
560 "nusy-arrow-core (pos {core_pos}) should come before nusy-codegraph (pos {codegraph_pos}) in topo sort"
561 );
562 }
563
564 #[test]
567 fn test_no_cycles_in_nusy_workspace() {
568 let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
569
570 let result = topo_sort_crates(&graph);
571 assert!(
572 result.is_ok(),
573 "topo_sort_crates should not detect cycles: {:?}",
574 result.err()
575 );
576
577 let order = result.unwrap();
578
579 use arrow::array::BooleanArray;
582 let wm_col = graph
583 .crate_nodes
584 .column(crate::crate_schema::crate_node_col::WORKSPACE_MEMBER)
585 .as_any()
586 .downcast_ref::<BooleanArray>()
587 .expect("workspace_member must be BooleanArray");
588 let ws_count = (0..wm_col.len()).filter(|&i| wm_col.value(i)).count();
589
590 assert_eq!(
591 order.len(),
592 ws_count,
593 "topo sort should return all {ws_count} workspace crates (total rows incl. external: {})",
594 graph.crate_nodes.num_rows()
595 );
596 }
597
598 #[test]
601 fn test_topo_sort_detects_cycle() {
602 use crate::crate_schema::{crate_edge_schema, crate_node_schema};
603
604 let node_schema = crate_node_schema();
606 let edge_schema = crate_edge_schema();
607
608 let nodes = RecordBatch::try_new(
609 node_schema,
610 vec![
611 Arc::new(StringArray::from(vec!["crate-a", "crate-b"])),
612 Arc::new(StringArray::from(vec!["0.1.0", "0.1.0"])),
613 Arc::new(BooleanArray::from(vec![true, true])),
614 Arc::new(StringArray::from(vec![None::<&str>, None::<&str>])),
615 Arc::new(StringArray::from(vec!["2024", "2024"])),
616 ],
617 )
618 .unwrap();
619
620 let edges = RecordBatch::try_new(
622 edge_schema,
623 vec![
624 Arc::new(StringArray::from(vec!["crate-a", "crate-b"])),
625 Arc::new(StringArray::from(vec!["crate-b", "crate-a"])),
626 Arc::new(StringArray::from(vec!["*", "*"])),
627 Arc::new(BooleanArray::from(vec![false, false])),
628 Arc::new(BooleanArray::from(vec![false, false])),
629 Arc::new(BooleanArray::from(vec![false, false])),
630 Arc::new(StringArray::from(vec!["path", "path"])),
631 ],
632 )
633 .unwrap();
634
635 let graph = CrateGraph {
636 crate_nodes: nodes,
637 crate_edges: edges,
638 };
639
640 let result = topo_sort_crates(&graph);
641 assert!(
642 result.is_err(),
643 "topo_sort should return Err when a cycle exists"
644 );
645 assert!(
646 result.unwrap_err().contains("cycle"),
647 "error message should mention 'cycle'"
648 );
649 }
650}