Skip to main content

nusy_codegraph/
crate_graph.rs

1//! Build a crate-level dependency graph from a Cargo workspace.
2//!
3//! [`build_crate_graph`] walks `crates/*/Cargo.toml`, parses each manifest, and
4//! produces two Arrow [`RecordBatch`] tables:
5//!
6//! - **CrateNode** — one row per workspace member crate
7//! - **CrateEdge** — one row per dependency relationship
8//!
9//! [`topo_sort_crates`] runs Kahn's algorithm over the workspace-internal edges and
10//! returns crate names in dependency-first (build) order.
11//!
12//! # Example
13//!
14//! ```no_run
15//! use nusy_codegraph::crate_graph::build_crate_graph;
16//! use std::path::Path;
17//!
18//! let graph = build_crate_graph(Path::new(".")).unwrap();
19//! println!("{} crates, {} edges",
20//!     graph.crate_nodes.num_rows(),
21//!     graph.crate_edges.num_rows());
22//! ```
23
24use 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
31/// The two Arrow tables that describe a workspace's crate dependency graph.
32pub struct CrateGraph {
33    /// CrateNode RecordBatch — one row per workspace member.
34    pub crate_nodes: RecordBatch,
35    /// CrateEdge RecordBatch — one row per dependency relationship.
36    pub crate_edges: RecordBatch,
37}
38
39// ─── build_crate_graph ───────────────────────────────────────────────────────
40
41/// Build a [`CrateGraph`] by scanning all `Cargo.toml` files under `workspace_root`.
42///
43/// Algorithm:
44/// 1. Parse the workspace root `Cargo.toml` to get shared dep versions.
45/// 2. Glob `crates/*/Cargo.toml` for workspace members.
46/// 3. Parse each member manifest.
47/// 4. Build CrateNode records for workspace members.
48/// 5. Build CrateEdge records for all dependencies (runtime, dev, build).
49pub fn build_crate_graph(workspace_root: &Path) -> Result<CrateGraph, String> {
50    // ── Step 1: workspace-level dep versions ─────────────────────────────────
51    let ws_toml = workspace_root.join("Cargo.toml");
52    let ws_deps = parse_workspace_dependencies(&ws_toml).unwrap_or_default();
53
54    // Also store workspace package version under sentinel "" key so version.workspace = true
55    // in [package] can be resolved.
56    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    // ── Step 2: find workspace member Cargo.toml files ───────────────────────
62    let member_tomls = find_member_cargo_tomls(workspace_root)?;
63    let member_names: HashSet<String> = member_tomls
64        .iter()
65        .filter_map(|p| {
66            // Peek at name without full parse
67            quick_read_crate_name(p)
68        })
69        .collect();
70
71    // ── Step 3: parse each member ─────────────────────────────────────────────
72    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                // Log but continue — a single bad Cargo.toml shouldn't abort the whole build
78                eprintln!("warn: skipping {}: {e}", toml_path.display());
79            }
80        }
81    }
82
83    // ── Step 4: build CrateNode rows ─────────────────────────────────────────
84    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    // ── Step 5: build CrateEdge rows ─────────────────────────────────────────
111    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            // Only include deps that are workspace members OR external (include all)
122            // We include ALL to give the full picture; topo_sort only uses workspace edges
123            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            // For path/workspace deps pointing to internal crates, use the canonical name
131            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    // ── Step 6: add external crates to CrateNode (workspace_member = false) ──
163    // Collect unique external targets (those not in the workspace) from edge_targets.
164    let workspace_names: HashSet<&str> = manifests.iter().map(|m| m.name.as_str()).collect();
165
166    // Map external name → best version_req seen (first occurrence wins)
167    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        // Append external rows to a new CrateNode batch that extends the workspace rows.
179        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
223// ─── topo_sort_crates ────────────────────────────────────────────────────────
224
225/// Topologically sort workspace crate names using Kahn's algorithm.
226///
227/// Only workspace-internal edges (path/workspace source kind) are used — external
228/// crates.io deps are ignored for ordering.  The result is in dependency-first
229/// order: if crate A depends on crate B, B appears before A.
230///
231/// Returns `Err` if a cycle is detected (should not happen in a valid workspace).
232pub 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    // Collect workspace member names from CrateNode table (workspace_member = true only)
238    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    // Build adjacency map for workspace-internal edges only
260    // adjacency[source] = set of targets
261    let mut adjacency: HashMap<String, HashSet<String>> = HashMap::new();
262    // in_degree[crate] = number of workspace crates that depend on it
263    let mut in_degree: HashMap<String, usize> = HashMap::new();
264
265    // Initialize all workspace members
266    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        // Only use workspace-internal edges (path or workspace source kind)
299        if source_kind != "path" && source_kind != "workspace" {
300            continue;
301        }
302        // Skip dev deps for build order (they don't affect compilation order)
303        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        // Only include edges where both ends are workspace members
311        if !workspace_members.contains(&src) || !workspace_members.contains(&tgt) {
312            continue;
313        }
314
315        // src depends on tgt → tgt must come before src
316        // Kahn's: edge is tgt → src in the "comes before" sense
317        // We build: adjacency[tgt].insert(src), in_degree[src] += 1
318        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    // Kahn's algorithm
328    let mut queue: VecDeque<String> = in_degree
329        .iter()
330        .filter(|(_, deg)| **deg == 0)
331        .map(|(name, _)| name.clone())
332        .collect();
333
334    // Sort for determinism
335    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
368// ─── Helpers ─────────────────────────────────────────────────────────────────
369
370/// Find all `Cargo.toml` files for workspace members by walking `crates/*/Cargo.toml`.
371///
372/// Falls back to scanning any `*/Cargo.toml` one level below `workspace_root` if the
373/// `crates/` subdirectory does not exist.
374fn 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
402/// Quickly read the `[package].name` from a Cargo.toml without full parsing.
403fn 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
412/// Read `[workspace.package].version` from the workspace root Cargo.toml.
413fn 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
423/// Resolve the target crate name for a dependency.
424///
425/// For path dependencies (`../nusy-arrow-core`), the actual crate name
426/// may differ from the key in Cargo.toml. We try to read the name from
427/// the target's Cargo.toml. Falls back to the dep's recorded `name` field.
428fn 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        // Path is relative to the declaring crate, but we don't have that context here.
435        // We look for <workspace_root>/crates/<dep.name>/Cargo.toml as a heuristic.
436        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// ─── Tests ───────────────────────────────────────────────────────────────────
448
449#[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 1: build_crate_graph succeeds on real workspace ─────────────────
466
467    #[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        // Should have at least several crates
473        assert!(
474            graph.crate_nodes.num_rows() >= 5,
475            "expected >= 5 crate nodes, got {}",
476            graph.crate_nodes.num_rows()
477        );
478        // Should have edges
479        assert!(graph.crate_edges.num_rows() > 0, "expected > 0 crate edges");
480        // Schema column counts
481        assert_eq!(graph.crate_nodes.num_columns(), 5);
482        assert_eq!(graph.crate_edges.num_columns(), 7);
483    }
484
485    // ── Test 2: nusy-arrow-core is a workspace member ─────────────────────────
486
487    #[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 3: nusy-codegraph depends on nusy-arrow-core ─────────────────────
515
516    #[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 4: topo_sort puts nusy-arrow-core before nusy-codegraph ──────────
544
545    #[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 5: no cycles in NuSy workspace ───────────────────────────────────
565
566    #[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        // topo_sort_crates returns only workspace members; count them separately
580        // (graph.crate_nodes includes external crates added in EX-3171)
581        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 6: topo_sort detects cycle ───────────────────────────────────────
599
600    #[test]
601    fn test_topo_sort_detects_cycle() {
602        use crate::crate_schema::{crate_edge_schema, crate_node_schema};
603
604        // Build a minimal CrateGraph with a cycle: A → B → A
605        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        // A depends on B, B depends on A — cycle
621        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}