Skip to main content

nusy_codegraph/
topo_sort.rs

1//! Topological sorters for the NuSy crate dependency graph and function call graph.
2//!
3//! Provides two flavours of Kahn's BFS topological sort:
4//!
5//! - **Crate-level** — order workspace crates for compilation.
6//!   - [`sort_crates`] — flattened build order (Vec<String>)
7//!   - [`sort_crates_parallel`] — layered order; crates in the same layer may build concurrently
8//!
9//! - **Function-level** (intra-crate) — order functions by call dependency.
10//!   - [`sort_functions_in_crate`] — flattened call order (Vec<String> of node IDs)
11//!   - [`sort_functions_parallel`] — layered call order
12//!
13//! All algorithms use named column constants (never magic indices) and return
14//! `Err(String)` for cycle detection or schema mismatches.
15
16use crate::crate_graph::CrateGraph;
17use crate::crate_schema::{crate_edge_col, crate_node_col};
18use crate::schema::{edge_col, node_col};
19use arrow::array::{Array, BooleanArray, RecordBatch, StringArray};
20use std::collections::{HashMap, HashSet, VecDeque};
21
22// ─── Crate-level sort ────────────────────────────────────────────────────────
23
24/// Topologically sort workspace crate names using Kahn's algorithm.
25///
26/// Returns crate names in build order — every dependency appears before its
27/// dependents. Only workspace-internal path/workspace edges are considered;
28/// crates.io deps are ignored. Returns `Err` on cycle detection.
29pub fn sort_crates(graph: &CrateGraph) -> Result<Vec<String>, String> {
30    crate::crate_graph::topo_sort_crates(graph)
31}
32
33/// Return independent parallel build layers for workspace crates.
34///
35/// Layer 0 has no intra-workspace dependencies; layer N depends only on
36/// crates already in layers 0..N-1. All crates within a layer may be
37/// compiled concurrently.
38pub fn sort_crates_parallel(graph: &CrateGraph) -> Result<Vec<Vec<String>>, String> {
39    let id_col = graph
40        .crate_nodes
41        .column(crate_node_col::ID)
42        .as_any()
43        .downcast_ref::<StringArray>()
44        .ok_or("CrateNode id column is not StringArray")?;
45
46    let wm_col = graph
47        .crate_nodes
48        .column(crate_node_col::WORKSPACE_MEMBER)
49        .as_any()
50        .downcast_ref::<BooleanArray>()
51        .ok_or("CrateNode workspace_member column is not BooleanArray")?;
52
53    let workspace_members: HashSet<String> = (0..id_col.len())
54        .filter(|&i| wm_col.value(i))
55        .map(|i| id_col.value(i).to_string())
56        .collect();
57
58    build_parallel_layers(&workspace_members, |layers| {
59        wire_crate_edges(graph, &workspace_members, layers)
60    })
61}
62
63// ─── Function-level sort ─────────────────────────────────────────────────────
64
65/// Topologically sort functions within a crate by call dependency order.
66///
67/// Filters `nodes` to those whose `file_path` is under `crates/<crate_name>/`,
68/// then walks `Calls` and `Uses` edges within that set (ignoring cross-crate
69/// calls). Returns node IDs (column 0 = ID) in order: callees before callers.
70///
71/// Pass the full workspace `code_nodes` and `code_edges` RecordBatches from
72/// [`crate::ingest`]. Returns `Err` on cycle detection or schema mismatch.
73pub fn sort_functions_in_crate(
74    crate_name: &str,
75    nodes: &RecordBatch,
76    edges: &RecordBatch,
77) -> Result<Vec<String>, String> {
78    let (node_ids, adjacency, in_degree) = build_function_graph(crate_name, nodes, edges)?;
79    kahn_sort(node_ids, adjacency, in_degree)
80}
81
82/// Return independent parallel execution layers for functions within a crate.
83///
84/// Layer 0 has no intra-crate call dependencies; layer N is called only by
85/// functions in layers N+1 and above. Functions in the same layer may
86/// (conceptually) execute concurrently.
87pub fn sort_functions_parallel(
88    crate_name: &str,
89    nodes: &RecordBatch,
90    edges: &RecordBatch,
91) -> Result<Vec<Vec<String>>, String> {
92    let (node_ids, adjacency, in_degree) = build_function_graph(crate_name, nodes, edges)?;
93    kahn_layers(node_ids, adjacency, in_degree)
94}
95
96// ─── Parallelism statistics ───────────────────────────────────────────────────
97
98/// Statistics about the parallel layer decomposition of a workspace.
99#[derive(Debug)]
100pub struct ParallelismStats {
101    /// Number of parallel layers (critical path length in layers).
102    pub layer_count: usize,
103    /// Maximum number of crates in any single layer.
104    pub max_layer_width: usize,
105    /// Crates on the critical path (longest dependency chain), ordered.
106    pub critical_path: Vec<String>,
107}
108
109/// Compute parallelism statistics for the workspace crate build graph.
110pub fn crate_parallelism_stats(graph: &CrateGraph) -> Result<ParallelismStats, String> {
111    let layers = sort_crates_parallel(graph)?;
112    let max_layer_width = layers.iter().map(|l| l.len()).max().unwrap_or(0);
113    let layer_count = layers.len();
114
115    // Critical path: longest chain — find source and sink with maximum
116    // depth-first path length over the DAG.
117    let id_col = graph
118        .crate_nodes
119        .column(crate_node_col::ID)
120        .as_any()
121        .downcast_ref::<StringArray>()
122        .ok_or("CrateNode id column is not StringArray")?;
123
124    let wm_col = graph
125        .crate_nodes
126        .column(crate_node_col::WORKSPACE_MEMBER)
127        .as_any()
128        .downcast_ref::<BooleanArray>()
129        .ok_or("CrateNode workspace_member column is not BooleanArray")?;
130
131    let workspace_members: HashSet<String> = (0..id_col.len())
132        .filter(|&i| wm_col.value(i))
133        .map(|i| id_col.value(i).to_string())
134        .collect();
135
136    // Build reverse adjacency (target → sources) for critical path tracing
137    let mut fwd: HashMap<String, Vec<String>> = HashMap::new();
138    for name in &workspace_members {
139        fwd.entry(name.clone()).or_default();
140    }
141    wire_crate_edges(graph, &workspace_members, &mut fwd);
142
143    // Compute longest path to each node (in topo order)
144    let flat = sort_crates(graph)?;
145    let mut dist: HashMap<String, usize> = HashMap::new();
146    let mut prev: HashMap<String, String> = HashMap::new();
147    for name in &flat {
148        dist.insert(name.clone(), 0);
149    }
150    for name in &flat {
151        let d = dist[name];
152        for nbr in fwd.get(name).into_iter().flatten() {
153            if workspace_members.contains(nbr) {
154                let entry = dist.entry(nbr.clone()).or_insert(0);
155                if d + 1 > *entry {
156                    *entry = d + 1;
157                    prev.insert(nbr.clone(), name.clone());
158                }
159            }
160        }
161    }
162
163    // Trace back from deepest node
164    let sink = dist
165        .iter()
166        .max_by_key(|(_, v)| *v)
167        .map(|(k, _)| k.clone())
168        .unwrap_or_default();
169
170    let mut path = Vec::new();
171    let mut cur = sink.clone();
172    loop {
173        path.push(cur.clone());
174        match prev.get(&cur) {
175            Some(p) => cur = p.clone(),
176            None => break,
177        }
178    }
179    path.reverse();
180
181    Ok(ParallelismStats {
182        layer_count,
183        max_layer_width,
184        critical_path: path,
185    })
186}
187
188// ─── Internal helpers ────────────────────────────────────────────────────────
189
190/// Wire workspace-internal crate edges into `adjacency[source] = Vec<target>`.
191fn wire_crate_edges(
192    graph: &CrateGraph,
193    workspace_members: &HashSet<String>,
194    adjacency: &mut HashMap<String, Vec<String>>,
195) {
196    let source_col = graph
197        .crate_edges
198        .column(crate_edge_col::SOURCE)
199        .as_any()
200        .downcast_ref::<StringArray>();
201    let target_col = graph
202        .crate_edges
203        .column(crate_edge_col::TARGET)
204        .as_any()
205        .downcast_ref::<StringArray>();
206    let sk_col = graph
207        .crate_edges
208        .column(crate_edge_col::SOURCE_KIND)
209        .as_any()
210        .downcast_ref::<StringArray>();
211    let dev_col = graph
212        .crate_edges
213        .column(crate_edge_col::DEV_DEP)
214        .as_any()
215        .downcast_ref::<BooleanArray>();
216
217    let (Some(src), Some(tgt), Some(sk), Some(dev)) = (source_col, target_col, sk_col, dev_col)
218    else {
219        return;
220    };
221
222    for i in 0..src.len() {
223        let kind = sk.value(i);
224        if kind != "path" && kind != "workspace" {
225            continue;
226        }
227        if dev.value(i) {
228            continue;
229        }
230        let s = src.value(i).to_string();
231        let t = tgt.value(i).to_string();
232        if workspace_members.contains(&s) && workspace_members.contains(&t) {
233            // Edge direction for Kahn's: dependency → dependent.
234            // s (source in Cargo) depends on t (target), so t must build before s.
235            // Add t → s so that when t is processed, s's in-degree decrements.
236            adjacency.entry(t).or_default().push(s);
237        }
238    }
239}
240
241/// Generic Kahn's algorithm returning a flattened topological order.
242fn kahn_sort(
243    nodes: HashSet<String>,
244    adjacency: HashMap<String, Vec<String>>,
245    in_degree: HashMap<String, usize>,
246) -> Result<Vec<String>, String> {
247    let layers = kahn_layers(nodes, adjacency, in_degree)?;
248    Ok(layers.into_iter().flatten().collect())
249}
250
251/// Generic Kahn's algorithm returning parallel layers.
252fn kahn_layers(
253    nodes: HashSet<String>,
254    adjacency: HashMap<String, Vec<String>>,
255    in_degree: HashMap<String, usize>,
256) -> Result<Vec<Vec<String>>, String> {
257    let mut in_deg = in_degree;
258    let mut adj = adjacency;
259
260    let mut queue: VecDeque<String> = nodes
261        .iter()
262        .filter(|n| in_deg.get(*n).copied().unwrap_or(0) == 0)
263        .cloned()
264        .collect();
265    // Sort for determinism
266    let mut queue_vec: Vec<String> = queue.drain(..).collect();
267    queue_vec.sort();
268    let mut queue: VecDeque<String> = queue_vec.into();
269
270    let mut layers: Vec<Vec<String>> = Vec::new();
271    let mut visited = 0usize;
272
273    while !queue.is_empty() {
274        let mut current_layer: Vec<String> = queue.drain(..).collect();
275        current_layer.sort();
276        visited += current_layer.len();
277
278        let mut next_layer_candidates: Vec<String> = Vec::new();
279        for node in &current_layer {
280            for nbr in adj.remove(node).unwrap_or_default() {
281                let deg = in_deg.entry(nbr.clone()).or_insert(0);
282                if *deg > 0 {
283                    *deg -= 1;
284                }
285                if *deg == 0 {
286                    next_layer_candidates.push(nbr);
287                }
288            }
289        }
290        next_layer_candidates.sort();
291        // Dedup in case multiple sources feed same target in same layer
292        next_layer_candidates.dedup();
293        layers.push(current_layer);
294        queue.extend(next_layer_candidates);
295    }
296
297    if visited < nodes.len() {
298        return Err(format!(
299            "cyclic dependency detected: {} nodes in cycle(s) — {} sorted of {}",
300            nodes.len() - visited,
301            visited,
302            nodes.len()
303        ));
304    }
305
306    Ok(layers)
307}
308
309/// (node_ids, adjacency, in_degree) triple returned by `build_function_graph`.
310type FunctionGraph = (
311    HashSet<String>,
312    HashMap<String, Vec<String>>,
313    HashMap<String, usize>,
314);
315
316/// Build the function-level graph for a given crate.
317///
318/// Returns (node_ids, adjacency, in_degree) ready for Kahn's algorithm.
319fn build_function_graph(
320    crate_name: &str,
321    nodes: &RecordBatch,
322    edges: &RecordBatch,
323) -> Result<FunctionGraph, String> {
324    let id_col = nodes
325        .column(node_col::ID)
326        .as_any()
327        .downcast_ref::<StringArray>()
328        .ok_or("CodeNode id column is not StringArray")?;
329
330    let file_col = nodes
331        .column(node_col::FILE_PATH)
332        .as_any()
333        .downcast_ref::<StringArray>()
334        .ok_or("CodeNode file_path column is not StringArray")?;
335
336    // The file_path for crate nodes starts with `crates/<crate_name>/`
337    let crate_prefix = format!("crates/{}/", crate_name);
338
339    let mut node_ids: HashSet<String> = HashSet::new();
340    let mut id_set: HashSet<String> = HashSet::new();
341    for i in 0..id_col.len() {
342        let fp = file_col.value(i);
343        if fp.starts_with(&crate_prefix) {
344            let id = id_col.value(i).to_string();
345            node_ids.insert(id.clone());
346            id_set.insert(id);
347        }
348    }
349
350    // Build adjacency from "calls" and "uses" edges within the same crate
351    let src_col = edges
352        .column(edge_col::SOURCE_ID)
353        .as_any()
354        .downcast_ref::<StringArray>()
355        .ok_or("CodeEdge source_id column is not StringArray")?;
356    let tgt_col = edges
357        .column(edge_col::TARGET_ID)
358        .as_any()
359        .downcast_ref::<StringArray>()
360        .ok_or("CodeEdge target_id column is not StringArray")?;
361    // Predicate column may be Dictionary<Int8, Utf8> (from build_code_edges_batch) or plain Utf8
362    // (from raw ingest). Cast to Utf8 to handle both.
363    let pred_array = arrow::compute::cast(
364        edges.column(edge_col::PREDICATE),
365        &arrow::datatypes::DataType::Utf8,
366    )
367    .map_err(|e| format!("Failed to cast predicate column to Utf8: {e}"))?;
368    let pred_col = pred_array
369        .as_any()
370        .downcast_ref::<StringArray>()
371        .ok_or("CodeEdge predicate column is not StringArray after cast")?;
372
373    let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
374    let mut in_degree: HashMap<String, usize> = HashMap::new();
375    for id in &node_ids {
376        adjacency.entry(id.clone()).or_default();
377        in_degree.entry(id.clone()).or_insert(0);
378    }
379
380    for i in 0..src_col.len() {
381        let pred = pred_col.value(i);
382        // Only consider call-dependency edges for build ordering
383        if pred != "calls" && pred != "uses" {
384            continue;
385        }
386        let src = src_col.value(i).to_string();
387        let tgt = tgt_col.value(i).to_string();
388        if id_set.contains(&src) && id_set.contains(&tgt) && src != tgt {
389            // src (caller) depends on tgt (callee).
390            // Callee must come BEFORE caller in build order.
391            // Add tgt → src so that once tgt is processed, src's in-degree decrements.
392            adjacency.entry(tgt).or_default().push(src.clone());
393            *in_degree.entry(src).or_insert(0) += 1;
394        }
395    }
396
397    Ok((node_ids, adjacency, in_degree))
398}
399
400/// Helper used by `sort_crates_parallel` — builds layer structure from a
401/// workspace member set by calling the provided edge-wiring closure.
402fn build_parallel_layers(
403    workspace_members: &HashSet<String>,
404    wire: impl Fn(&mut HashMap<String, Vec<String>>),
405) -> Result<Vec<Vec<String>>, String> {
406    let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
407    let mut in_degree: HashMap<String, usize> = HashMap::new();
408
409    for name in workspace_members {
410        adjacency.entry(name.clone()).or_default();
411        in_degree.entry(name.clone()).or_insert(0);
412    }
413
414    wire(&mut adjacency);
415
416    // Recompute in_degree from adjacency (wire may have added edges)
417    let mut in_deg: HashMap<String, usize> = HashMap::new();
418    for name in workspace_members {
419        in_deg.entry(name.clone()).or_insert(0);
420    }
421    for targets in adjacency.values() {
422        for t in targets {
423            if workspace_members.contains(t) {
424                *in_deg.entry(t.clone()).or_insert(0) += 1;
425            }
426        }
427    }
428
429    kahn_layers(workspace_members.clone(), adjacency, in_deg)
430}
431
432// ─── Tests ───────────────────────────────────────────────────────────────────
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::crate_graph::build_crate_graph;
438    use std::path::PathBuf;
439
440    fn workspace_root() -> PathBuf {
441        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
442            .parent()
443            .unwrap()
444            .parent()
445            .unwrap()
446            .to_path_buf()
447    }
448
449    #[test]
450    fn test_sort_crates_wraps_topo_sort() {
451        let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
452        let order = sort_crates(&graph).expect("no cycles expected");
453        assert!(!order.is_empty(), "should return at least one crate");
454        // nusy-arrow-core is a foundational crate — must precede nusy-being
455        let core_pos = order.iter().position(|n| n == "nusy-arrow-core");
456        let being_pos = order.iter().position(|n| n == "nusy-being");
457        if let (Some(c), Some(b)) = (core_pos, being_pos) {
458            assert!(c < b, "nusy-arrow-core must come before nusy-being");
459        }
460    }
461
462    #[test]
463    fn test_sort_crates_parallel_layers_are_independent() {
464        let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
465        let layers = sort_crates_parallel(&graph).expect("no cycles expected");
466
467        assert!(!layers.is_empty(), "should have at least one layer");
468
469        // All layers together cover exactly the workspace members (no extras, no missing)
470        let total: usize = layers.iter().map(|l| l.len()).sum();
471        assert!(total > 0, "at least some workspace crates returned");
472        let flat: HashSet<String> = layers.iter().flatten().cloned().collect();
473        assert_eq!(
474            flat.len(),
475            total,
476            "no duplicates across layers: {} unique, {} total",
477            flat.len(),
478            total
479        );
480
481        // Independence check: for every workspace dependency edge dep→dependent,
482        // dep must be in a strictly EARLIER layer than dependent.
483        let layer_index: HashMap<String, usize> = layers
484            .iter()
485            .enumerate()
486            .flat_map(|(i, layer)| layer.iter().map(move |name| (name.clone(), i)))
487            .collect();
488
489        use crate::crate_schema::crate_edge_col;
490        use arrow::array::StringArray;
491        let src_col = graph
492            .crate_edges
493            .column(crate_edge_col::SOURCE)
494            .as_any()
495            .downcast_ref::<StringArray>()
496            .expect("source column is StringArray");
497        let tgt_col = graph
498            .crate_edges
499            .column(crate_edge_col::TARGET)
500            .as_any()
501            .downcast_ref::<StringArray>()
502            .expect("target column is StringArray");
503        let dev_col = graph
504            .crate_edges
505            .column(crate_edge_col::DEV_DEP)
506            .as_any()
507            .downcast_ref::<BooleanArray>()
508            .expect("dev_dep column is BooleanArray");
509
510        // CrateEdge direction: source = dependent (declares the dep), target = dependency (built first)
511        // Skip dev-deps — they don't affect compilation order (e.g., test-only deps)
512        for i in 0..src_col.len() {
513            if dev_col.value(i) {
514                continue; // dev-dependencies don't constrain build order
515            }
516            let dependent = src_col.value(i); // the crate that declares this dependency
517            let dep = tgt_col.value(i); // the dependency (must build first)
518            // Only check workspace→workspace edges
519            let (Some(&dep_layer), Some(&dependent_layer)) =
520                (layer_index.get(dep), layer_index.get(dependent))
521            else {
522                continue; // external or missing — skip
523            };
524            assert!(
525                dep_layer < dependent_layer,
526                "layer independence violated: '{dependent}' depends on '{dep}' but '{dep}' is in layer {dep_layer} and '{dependent}' is in layer {dependent_layer}; dependency must be in earlier layer"
527            );
528        }
529    }
530
531    #[test]
532    fn test_sort_crates_parallel_nusy_arrow_core_in_first_layers() {
533        let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
534        let layers = sort_crates_parallel(&graph).expect("no cycles");
535
536        // nusy-arrow-core has no workspace dependencies — must appear in layer 0 or 1
537        let core_layer = layers
538            .iter()
539            .position(|l| l.contains(&"nusy-arrow-core".to_string()));
540        assert!(
541            core_layer.is_some(),
542            "nusy-arrow-core must appear in parallel layers"
543        );
544        assert!(
545            core_layer.unwrap() <= 1,
546            "nusy-arrow-core is a root crate, should be in layer 0 or 1 (got {})",
547            core_layer.unwrap()
548        );
549    }
550
551    #[test]
552    fn test_sort_crates_parallel_count_matches_flat_sort() {
553        let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
554        let flat = sort_crates(&graph).expect("no cycles");
555        let layers = sort_crates_parallel(&graph).expect("no cycles");
556        let parallel_total: usize = layers.iter().map(|l| l.len()).sum();
557        assert_eq!(
558            flat.len(),
559            parallel_total,
560            "flat sort and parallel sort must cover the same crates"
561        );
562    }
563
564    #[test]
565    fn test_detects_cyclic_dependency() {
566        use crate::crate_schema::{crate_edge_schema, crate_node_schema};
567        use arrow::array::{BooleanArray, RecordBatch, StringArray};
568        use std::sync::Arc;
569
570        // Build a synthetic graph with cycle: A → B → A
571        let nodes = RecordBatch::try_new(
572            crate_node_schema(),
573            vec![
574                Arc::new(StringArray::from(vec!["crate-a", "crate-b"])),
575                Arc::new(StringArray::from(vec!["0.1.0", "0.1.0"])),
576                Arc::new(BooleanArray::from(vec![true, true])),
577                Arc::new(StringArray::from(vec![None::<&str>, None])),
578                Arc::new(StringArray::from(vec!["2021", "2021"])),
579            ],
580        )
581        .expect("build test nodes");
582
583        let edges = RecordBatch::try_new(
584            crate_edge_schema(),
585            vec![
586                Arc::new(StringArray::from(vec!["crate-a", "crate-b"])),
587                Arc::new(StringArray::from(vec!["crate-b", "crate-a"])),
588                Arc::new(StringArray::from(vec!["*", "*"])),
589                Arc::new(BooleanArray::from(vec![false, false])),
590                Arc::new(BooleanArray::from(vec![false, false])),
591                Arc::new(BooleanArray::from(vec![false, false])),
592                Arc::new(StringArray::from(vec!["path", "path"])),
593            ],
594        )
595        .expect("build test edges");
596
597        let graph = CrateGraph {
598            crate_nodes: nodes,
599            crate_edges: edges,
600        };
601
602        let result = sort_crates(&graph);
603        assert!(result.is_err(), "cycle should be detected");
604        let err = result.unwrap_err();
605        assert!(
606            err.contains("cycle") || err.contains("cyclic"),
607            "error message should mention cycle: {err}"
608        );
609    }
610
611    #[test]
612    fn test_parallelism_stats() {
613        let graph = build_crate_graph(&workspace_root()).expect("build_crate_graph should succeed");
614        let stats = crate_parallelism_stats(&graph).expect("stats should succeed");
615
616        // NuSy workspace expected characteristics:
617        assert!(
618            stats.layer_count >= 3,
619            "expected >= 3 layers, got {}",
620            stats.layer_count
621        );
622        assert!(
623            stats.max_layer_width >= 2,
624            "expected max_layer_width >= 2, got {}",
625            stats.max_layer_width
626        );
627        assert!(
628            !stats.critical_path.is_empty(),
629            "critical path should not be empty"
630        );
631        assert!(
632            stats.critical_path.len() >= 2,
633            "critical path should span at least 2 crates"
634        );
635    }
636
637    // ─── Function-level sort tests ────────────────────────────────────────────
638
639    #[test]
640    fn test_sort_functions_callee_before_caller() {
641        use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
642        use crate::schema::{build_code_edges_batch, build_code_nodes_batch};
643
644        // A calls B → B (callee) must appear BEFORE A (caller) in build order
645        let nodes = build_code_nodes_batch(&[
646            CodeNode {
647                id: "fn_a".to_string(),
648                kind: CodeNodeKind::Function,
649                name: "a".to_string(),
650                file_path: Some("crates/tc/src/lib.rs".to_string()),
651                ..Default::default()
652            },
653            CodeNode {
654                id: "fn_b".to_string(),
655                kind: CodeNodeKind::Function,
656                name: "b".to_string(),
657                file_path: Some("crates/tc/src/lib.rs".to_string()),
658                ..Default::default()
659            },
660        ])
661        .expect("build nodes");
662
663        // Edge: fn_a (caller/source) calls fn_b (callee/target)
664        let edges = build_code_edges_batch(&[CodeEdge {
665            source_id: "fn_a".to_string(),
666            target_id: "fn_b".to_string(),
667            predicate: CodeEdgePredicate::Calls,
668            weight: None,
669            commit_id: None,
670        }])
671        .expect("build edges");
672
673        let order = sort_functions_in_crate("tc", &nodes, &edges).expect("no cycle");
674
675        assert!(
676            order.contains(&"fn_a".to_string()),
677            "fn_a must be in sort output"
678        );
679        assert!(
680            order.contains(&"fn_b".to_string()),
681            "fn_b must be in sort output"
682        );
683
684        let pos_a = order.iter().position(|id| id == "fn_a").unwrap();
685        let pos_b = order.iter().position(|id| id == "fn_b").unwrap();
686        assert!(
687            pos_b < pos_a,
688            "callee fn_b (pos {pos_b}) must come before caller fn_a (pos {pos_a})"
689        );
690    }
691
692    #[test]
693    fn test_sort_functions_parallel() {
694        use crate::schema::build_code_nodes_batch;
695        use crate::schema::{CodeNode, CodeNodeKind, code_edges_schema};
696        use arrow::array::RecordBatch;
697        use std::sync::Arc;
698
699        // Two independent functions in the same crate — no edges between them.
700        // Both must appear in the output.
701        let nodes = build_code_nodes_batch(&[
702            CodeNode {
703                id: "fn_x".to_string(),
704                kind: CodeNodeKind::Function,
705                name: "x".to_string(),
706                file_path: Some("crates/ind/src/lib.rs".to_string()),
707                ..Default::default()
708            },
709            CodeNode {
710                id: "fn_y".to_string(),
711                kind: CodeNodeKind::Function,
712                name: "y".to_string(),
713                file_path: Some("crates/ind/src/lib.rs".to_string()),
714                ..Default::default()
715            },
716        ])
717        .expect("build nodes");
718
719        // Empty edges batch — no call relationships
720        let edges = RecordBatch::new_empty(Arc::new(code_edges_schema()));
721
722        let layers = sort_functions_parallel("ind", &nodes, &edges).expect("no cycle");
723
724        let all_ids: Vec<String> = layers.iter().flatten().cloned().collect();
725        assert!(
726            all_ids.contains(&"fn_x".to_string()),
727            "fn_x must appear in parallel output"
728        );
729        assert!(
730            all_ids.contains(&"fn_y".to_string()),
731            "fn_y must appear in parallel output"
732        );
733        // Both are independent so they should land in the same (first) layer
734        assert_eq!(
735            layers.len(),
736            1,
737            "two independent functions should be in one layer"
738        );
739        assert_eq!(layers[0].len(), 2, "both functions in layer 0");
740    }
741
742    #[test]
743    fn test_sort_functions_parallel_callee_in_earlier_layer() {
744        use crate::schema::{CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind};
745        use crate::schema::{build_code_edges_batch, build_code_nodes_batch};
746
747        // Call chain: root → helper → leaf
748        // Expected layers: [leaf], [helper], [root]
749        let nodes = build_code_nodes_batch(&[
750            CodeNode {
751                id: "fn_root".to_string(),
752                kind: CodeNodeKind::Function,
753                name: "root".to_string(),
754                file_path: Some("crates/tc2/src/lib.rs".to_string()),
755                ..Default::default()
756            },
757            CodeNode {
758                id: "fn_helper".to_string(),
759                kind: CodeNodeKind::Function,
760                name: "helper".to_string(),
761                file_path: Some("crates/tc2/src/lib.rs".to_string()),
762                ..Default::default()
763            },
764            CodeNode {
765                id: "fn_leaf".to_string(),
766                kind: CodeNodeKind::Function,
767                name: "leaf".to_string(),
768                file_path: Some("crates/tc2/src/lib.rs".to_string()),
769                ..Default::default()
770            },
771        ])
772        .expect("build nodes");
773
774        let edges = build_code_edges_batch(&[
775            CodeEdge {
776                source_id: "fn_root".to_string(),
777                target_id: "fn_helper".to_string(),
778                predicate: CodeEdgePredicate::Calls,
779                weight: None,
780                commit_id: None,
781            },
782            CodeEdge {
783                source_id: "fn_helper".to_string(),
784                target_id: "fn_leaf".to_string(),
785                predicate: CodeEdgePredicate::Calls,
786                weight: None,
787                commit_id: None,
788            },
789        ])
790        .expect("build edges");
791
792        let layers = sort_functions_parallel("tc2", &nodes, &edges).expect("no cycle");
793
794        let layer_index: HashMap<String, usize> = layers
795            .iter()
796            .enumerate()
797            .flat_map(|(i, layer)| layer.iter().map(move |id| (id.clone(), i)))
798            .collect();
799
800        let root_layer = *layer_index.get("fn_root").expect("fn_root in layers");
801        let helper_layer = *layer_index.get("fn_helper").expect("fn_helper in layers");
802        let leaf_layer = *layer_index.get("fn_leaf").expect("fn_leaf in layers");
803
804        assert!(
805            leaf_layer < helper_layer,
806            "leaf (layer {leaf_layer}) must be in earlier layer than helper (layer {helper_layer})"
807        );
808        assert!(
809            helper_layer < root_layer,
810            "helper (layer {helper_layer}) must be in earlier layer than root (layer {root_layer})"
811        );
812    }
813}