Skip to main content

cargo_capsec/
discovery.rs

1//! Workspace and source file discovery.
2//!
3//! Uses `cargo metadata` to enumerate all crates in a workspace, then recursively
4//! walks each crate's `src/` directory to find `.rs` source files. Also detects
5//! `build.rs` files at the crate root.
6//!
7//! By default, only workspace-local crates are returned (`--no-deps` mode for speed).
8//! With `include_deps = true`, all packages from `cargo metadata` are returned,
9//! including registry dependencies whose source is cached in `~/.cargo/registry/src/`.
10
11use crate::config::Classification;
12use serde::Deserialize;
13use std::collections::{HashMap, VecDeque};
14use std::path::{Path, PathBuf};
15
16/// Metadata about a crate discovered in the workspace.
17#[derive(Debug, Clone)]
18pub struct CrateInfo {
19    /// Crate name (e.g., `"serde"`, `"my-app"`).
20    pub name: String,
21    /// Crate version (e.g., `"1.0.228"`).
22    pub version: String,
23    /// Path to the crate's `src/` directory.
24    pub source_dir: PathBuf,
25    /// `true` if this is a registry dependency (has a `source` field in cargo metadata),
26    /// `false` if it's a local workspace member or path dependency.
27    pub is_dependency: bool,
28    /// Classification from `[package.metadata.capsec]` in the crate's Cargo.toml.
29    /// `None` if not specified.
30    pub classification: Option<Classification>,
31    /// Opaque package ID from `cargo metadata` (for linking to the resolve graph).
32    /// Only populated when `include_deps` is true.
33    pub package_id: Option<String>,
34}
35
36#[derive(Deserialize)]
37struct CargoMetadata {
38    packages: Vec<Package>,
39    workspace_root: String,
40    /// The resolved dependency graph. Present when `cargo metadata` is run
41    /// without `--no-deps`; `None` otherwise.
42    resolve: Option<Resolve>,
43}
44
45#[derive(Deserialize)]
46struct Package {
47    name: String,
48    version: String,
49    id: String,
50    manifest_path: String,
51    source: Option<String>,
52    #[serde(default)]
53    metadata: Option<serde_json::Value>,
54    #[serde(default)]
55    targets: Vec<Target>,
56}
57
58#[derive(Deserialize)]
59struct Target {
60    kind: Vec<String>,
61    #[allow(dead_code)]
62    name: String,
63    #[allow(dead_code)]
64    src_path: String,
65}
66
67/// The resolved dependency graph from `cargo metadata`.
68#[derive(Deserialize)]
69struct Resolve {
70    nodes: Vec<ResolveNode>,
71}
72
73/// A single node in the resolved dependency graph.
74#[derive(Deserialize)]
75struct ResolveNode {
76    /// Opaque package ID (matches `Package::id`).
77    id: String,
78    /// Resolved dependencies with extern crate names and dependency kinds.
79    #[serde(default)]
80    deps: Vec<NodeDep>,
81}
82
83/// A resolved dependency edge — which package this node depends on and how.
84#[derive(Deserialize)]
85struct NodeDep {
86    /// The extern crate name as seen in Rust source (underscored, handles renames).
87    name: String,
88    /// The package ID of the dependency (matches `Package::id`).
89    pkg: String,
90    /// Dependency kinds (normal, dev, build).
91    #[serde(default)]
92    dep_kinds: Vec<DepKindInfo>,
93}
94
95/// Metadata about the kind of a dependency edge.
96#[derive(Deserialize)]
97struct DepKindInfo {
98    /// `null` = normal, `"dev"` = dev-dependency, `"build"` = build-dependency.
99    kind: Option<String>,
100}
101
102/// Extracts `classification` from `package.metadata.capsec.classification` JSON value.
103fn extract_classification(metadata: &Option<serde_json::Value>) -> Option<Classification> {
104    let capsec = metadata.as_ref()?.get("capsec")?;
105    let class_str = capsec.get("classification")?.as_str()?;
106    match class_str {
107        "pure" => Some(Classification::Pure),
108        "resource" => Some(Classification::Resource),
109        other => {
110            eprintln!(
111                "Warning: unknown classification '{other}' in [package.metadata.capsec], ignoring (valid: pure, resource)"
112            );
113            None
114        }
115    }
116}
117
118/// Normalizes a Cargo package name to its Rust crate name by replacing hyphens
119/// with underscores. Cargo allows `serde-json` in `Cargo.toml`, but Rust source
120/// always uses `serde_json`.
121#[must_use]
122pub fn normalize_crate_name(name: &str) -> String {
123    name.replace('-', "_")
124}
125
126/// Returns true if a package is a proc-macro crate (compile-time code, not runtime).
127fn is_proc_macro(pkg: &Package) -> bool {
128    pkg.targets
129        .iter()
130        .any(|t| t.kind.contains(&"proc-macro".to_string()))
131}
132
133/// Information about a dependency edge in the resolved graph.
134#[derive(Debug, Clone)]
135pub struct DepEdge {
136    /// Normalized extern crate name (underscored, handles renames).
137    #[allow(dead_code)]
138    pub extern_name: String,
139    /// Package ID of the dependency.
140    pub pkg_id: String,
141}
142
143/// Produces a topological ordering of package IDs from leaves (no dependencies)
144/// to roots (workspace crates). Dev-dependencies are filtered out to avoid cycles.
145///
146/// Returns `Err` if a cycle is detected (should not happen in a valid Cargo graph
147/// with dev-deps removed, but handled gracefully).
148pub fn topological_order(resolve: &[(String, Vec<DepEdge>)]) -> Result<Vec<String>, String> {
149    let num_nodes = resolve.len();
150
151    // Build index: pkg_id -> index
152    let id_to_idx: HashMap<&str, usize> = resolve
153        .iter()
154        .enumerate()
155        .map(|(i, (id, _))| (id.as_str(), i))
156        .collect();
157
158    // Build adjacency list and in-degree counts.
159    // Edge: node -> dependency (we want leaves first, so edges point from
160    // dependents to dependencies).
161    let mut in_degree = vec![0usize; num_nodes];
162    let mut dependents: Vec<Vec<usize>> = vec![vec![]; num_nodes];
163
164    for (idx, (_id, deps)) in resolve.iter().enumerate() {
165        for dep in deps {
166            if let Some(&dep_idx) = id_to_idx.get(dep.pkg_id.as_str()) {
167                // idx depends on dep_idx.
168                // In our topo sort, dep_idx must come before idx.
169                // So dep_idx -> idx is a "dependent" edge.
170                dependents[dep_idx].push(idx);
171                in_degree[idx] += 1;
172            }
173            // Ignore deps not in the resolve set (e.g., filtered out proc-macros).
174        }
175    }
176
177    // Kahn's algorithm: start with leaves (in_degree == 0).
178    let mut queue: VecDeque<usize> = in_degree
179        .iter()
180        .enumerate()
181        .filter(|&(_, &d)| d == 0)
182        .map(|(i, _)| i)
183        .collect();
184
185    let mut order = Vec::with_capacity(num_nodes);
186
187    while let Some(node) = queue.pop_front() {
188        order.push(resolve[node].0.clone());
189        for &dependent in &dependents[node] {
190            in_degree[dependent] -= 1;
191            if in_degree[dependent] == 0 {
192                queue.push_back(dependent);
193            }
194        }
195    }
196
197    if order.len() == num_nodes {
198        Ok(order)
199    } else {
200        Err(format!(
201            "Cycle detected in dependency graph ({} of {} nodes processed)",
202            order.len(),
203            num_nodes
204        ))
205    }
206}
207
208/// Result of extracting the dependency graph: the graph itself and a name lookup map.
209pub type DepGraphResult = (Vec<(String, Vec<DepEdge>)>, HashMap<String, String>);
210
211/// Extracts the resolved dependency graph from `CargoMetadata`, filtering out
212/// dev-dependencies and optionally proc-macro crates.
213///
214/// Returns a list of `(package_id, Vec<DepEdge>)` suitable for `topological_order()`.
215pub fn extract_dep_graph(
216    metadata_json: &[u8],
217    exclude_proc_macros: bool,
218) -> Result<DepGraphResult, String> {
219    let metadata: CargoMetadata = serde_json::from_slice(metadata_json)
220        .map_err(|e| format!("Failed to parse cargo metadata: {e}"))?;
221
222    let resolve = metadata
223        .resolve
224        .ok_or("No resolve field in cargo metadata (was --no-deps used?)")?;
225
226    // Build a set of proc-macro package IDs to exclude.
227    let proc_macro_ids: std::collections::HashSet<&str> = if exclude_proc_macros {
228        metadata
229            .packages
230            .iter()
231            .filter(|p| is_proc_macro(p))
232            .map(|p| p.id.as_str())
233            .collect()
234    } else {
235        std::collections::HashSet::new()
236    };
237
238    // Map package ID -> normalized crate name for callers.
239    let id_to_name: HashMap<String, String> = metadata
240        .packages
241        .iter()
242        .map(|p| (p.id.clone(), normalize_crate_name(&p.name)))
243        .collect();
244
245    let mut graph = Vec::new();
246
247    for node in &resolve.nodes {
248        if proc_macro_ids.contains(node.id.as_str()) {
249            continue;
250        }
251
252        let deps: Vec<DepEdge> = node
253            .deps
254            .iter()
255            .filter(|d| {
256                // Exclude dev-dependencies (can create cycles).
257                !d.dep_kinds
258                    .iter()
259                    .all(|dk| dk.kind.as_deref() == Some("dev"))
260            })
261            .filter(|d| !proc_macro_ids.contains(d.pkg.as_str()))
262            .map(|d| DepEdge {
263                extern_name: normalize_crate_name(&d.name),
264                pkg_id: d.pkg.clone(),
265            })
266            .collect();
267
268        graph.push((node.id.clone(), deps));
269    }
270
271    Ok((graph, id_to_name))
272}
273
274/// Returns workspace member package IDs in topological order (leaves first).
275///
276/// Filters the resolve graph to only workspace-member nodes and edges,
277/// then calls `topological_order()`. Crates with no intra-workspace
278/// dependencies come first. Returns `None` if topo sort fails.
279pub fn workspace_topological_order(
280    workspace_crates: &[CrateInfo],
281    resolve_graph: &[(String, Vec<DepEdge>)],
282) -> Option<Vec<String>> {
283    let ws_pkg_ids: std::collections::HashSet<String> = workspace_crates
284        .iter()
285        .filter_map(|c| c.package_id.clone())
286        .collect();
287
288    if ws_pkg_ids.is_empty() {
289        return None;
290    }
291
292    // Filter resolve graph to workspace-member-only nodes and edges
293    let ws_graph: Vec<(String, Vec<DepEdge>)> = resolve_graph
294        .iter()
295        .filter(|(id, _)| ws_pkg_ids.contains(id))
296        .map(|(id, deps)| {
297            let ws_deps: Vec<DepEdge> = deps
298                .iter()
299                .filter(|d| ws_pkg_ids.contains(&d.pkg_id))
300                .cloned()
301                .collect();
302            (id.clone(), ws_deps)
303        })
304        .collect();
305
306    topological_order(&ws_graph).ok()
307}
308
309/// Result of workspace discovery: crates and the resolved workspace root.
310pub struct DiscoveryResult {
311    /// All discovered crates.
312    pub crates: Vec<CrateInfo>,
313    /// The Cargo workspace root (from `cargo metadata`).
314    pub workspace_root: PathBuf,
315    /// Resolved dependency graph (only populated when `include_deps` is true).
316    pub resolve_graph: Option<Vec<(String, Vec<DepEdge>)>>,
317}
318
319/// Discovers all crates in a Cargo workspace by running `cargo metadata`.
320///
321/// When `include_deps` is `false` (default), passes `--no-deps` for speed — only
322/// workspace members and path dependencies appear. When `true`, all transitive
323/// dependencies with cached source are included.
324///
325/// Returns both the discovered crates and the resolved workspace root path.
326pub fn discover_crates(
327    workspace_root: &Path,
328    include_deps: bool,
329    spawn_cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::Spawn>,
330    _fs_cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
331) -> Result<DiscoveryResult, String> {
332    // Use --no-deps by default for speed (avoids resolving 300+ transitive deps).
333    // Drop it when --include-deps is set so path dependencies and registry crates appear.
334    let mut args = vec!["metadata", "--format-version=1"];
335    if !include_deps {
336        args.push("--no-deps");
337    }
338
339    let output = capsec_std::process::command("cargo", spawn_cap)
340        .map_err(|e| format!("Failed to create command: {e}"))?
341        .args(&args)
342        .current_dir(workspace_root)
343        .output()
344        .map_err(|e| format!("Failed to run cargo metadata: {e}"))?;
345
346    if !output.status.success() {
347        let stderr = String::from_utf8_lossy(&output.stderr);
348        return Err(format!("cargo metadata failed: {stderr}"));
349    }
350
351    let metadata: CargoMetadata = serde_json::from_slice(&output.stdout)
352        .map_err(|e| format!("Failed to parse cargo metadata: {e}"))?;
353
354    let resolved_root = PathBuf::from(&metadata.workspace_root);
355
356    let mut crates = Vec::new();
357
358    for package in &metadata.packages {
359        let manifest_dir = Path::new(&package.manifest_path)
360            .parent()
361            .unwrap_or(Path::new("."))
362            .to_path_buf();
363
364        let src_dir = manifest_dir.join("src");
365
366        if src_dir.exists() {
367            crates.push(CrateInfo {
368                name: package.name.clone(),
369                version: package.version.clone(),
370                source_dir: src_dir,
371                is_dependency: package.source.is_some(),
372                classification: extract_classification(&package.metadata),
373                package_id: if include_deps {
374                    Some(package.id.clone())
375                } else {
376                    None
377                },
378            });
379        }
380    }
381
382    // Extract the resolve graph when available (for topological ordering)
383    let resolve_graph = if include_deps {
384        extract_dep_graph(&output.stdout, true)
385            .ok()
386            .map(|(graph, _)| graph)
387    } else {
388        None
389    };
390
391    Ok(DiscoveryResult {
392        crates,
393        workspace_root: resolved_root,
394        resolve_graph,
395    })
396}
397
398/// Recursively discovers all `.rs` source files in a directory.
399///
400/// Skips `target/` and hidden directories. Also checks for `build.rs` at the
401/// crate root (the parent of the `src/` directory).
402pub fn discover_source_files(
403    dir: &Path,
404    cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
405) -> Vec<PathBuf> {
406    let mut files = Vec::new();
407    discover_recursive(dir, &mut files, cap);
408
409    // Also check for build.rs at the crate root (parent of src/)
410    if let Some(crate_root) = dir.parent() {
411        let build_rs = crate_root.join("build.rs");
412        if build_rs.exists() {
413            files.push(build_rs);
414        }
415    }
416
417    files
418}
419
420fn discover_recursive(
421    dir: &Path,
422    files: &mut Vec<PathBuf>,
423    cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
424) {
425    let entries = match capsec_std::fs::read_dir(dir, cap) {
426        Ok(e) => e,
427        Err(_) => return,
428    };
429
430    for entry in entries.flatten() {
431        let path = entry.path();
432        if path.is_dir() {
433            let name = path.file_name().unwrap_or_default().to_str().unwrap_or("");
434            if name != "target" && !name.starts_with('.') {
435                discover_recursive(&path, files, cap);
436            }
437        } else if path.extension().is_some_and(|e| e == "rs") {
438            files.push(path);
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn discover_source_files_finds_rs_files() {
449        let root = capsec_core::root::test_root();
450        let cap = root.grant::<capsec_core::permission::FsRead>();
451        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
452        let files = discover_source_files(&dir, &cap);
453        assert!(!files.is_empty());
454        assert!(
455            files
456                .iter()
457                .all(|f| f.extension().unwrap_or_default() == "rs")
458        );
459    }
460
461    #[test]
462    fn normalize_crate_name_replaces_hyphens() {
463        assert_eq!(normalize_crate_name("serde-json"), "serde_json");
464        assert_eq!(normalize_crate_name("serde_json"), "serde_json");
465        assert_eq!(normalize_crate_name("my-cool-crate"), "my_cool_crate");
466        assert_eq!(normalize_crate_name("plain"), "plain");
467    }
468
469    fn make_graph(edges: &[(&str, &[&str])]) -> Vec<(String, Vec<DepEdge>)> {
470        edges
471            .iter()
472            .map(|(id, deps)| {
473                let dep_edges = deps
474                    .iter()
475                    .map(|d| DepEdge {
476                        extern_name: d.to_string(),
477                        pkg_id: d.to_string(),
478                    })
479                    .collect();
480                (id.to_string(), dep_edges)
481            })
482            .collect()
483    }
484
485    #[test]
486    fn topo_sort_single_node() {
487        let graph = make_graph(&[("a", &[])]);
488        let order = topological_order(&graph).unwrap();
489        assert_eq!(order, vec!["a"]);
490    }
491
492    #[test]
493    fn topo_sort_linear_chain() {
494        // a -> b -> c (a depends on b, b depends on c)
495        let graph = make_graph(&[("a", &["b"]), ("b", &["c"]), ("c", &[])]);
496        let order = topological_order(&graph).unwrap();
497        // c must come before b, b before a
498        let pos = |id: &str| order.iter().position(|x| x == id).unwrap();
499        assert!(pos("c") < pos("b"));
500        assert!(pos("b") < pos("a"));
501    }
502
503    #[test]
504    fn topo_sort_diamond() {
505        //   a
506        //  / \
507        // b   c
508        //  \ /
509        //   d
510        let graph = make_graph(&[("a", &["b", "c"]), ("b", &["d"]), ("c", &["d"]), ("d", &[])]);
511        let order = topological_order(&graph).unwrap();
512        let pos = |id: &str| order.iter().position(|x| x == id).unwrap();
513        assert!(pos("d") < pos("b"));
514        assert!(pos("d") < pos("c"));
515        assert!(pos("b") < pos("a"));
516        assert!(pos("c") < pos("a"));
517    }
518
519    #[test]
520    fn topo_sort_cycle_detected() {
521        // a -> b -> a (cycle)
522        let graph = make_graph(&[("a", &["b"]), ("b", &["a"])]);
523        let result = topological_order(&graph);
524        assert!(result.is_err());
525        assert!(result.unwrap_err().contains("Cycle detected"));
526    }
527
528    #[test]
529    fn topo_sort_ignores_unknown_deps() {
530        // a depends on "missing" which is not in the graph — should be ignored
531        let graph = make_graph(&[("a", &["missing"]), ("b", &[])]);
532        let order = topological_order(&graph).unwrap();
533        assert_eq!(order.len(), 2);
534    }
535
536    #[test]
537    fn extract_dep_graph_filters_dev_deps() {
538        let metadata_json = serde_json::json!({
539            "packages": [
540                {
541                    "name": "app",
542                    "version": "0.1.0",
543                    "id": "app 0.1.0",
544                    "manifest_path": "/fake/app/Cargo.toml",
545                    "source": null,
546                    "targets": [{"kind": ["lib"], "name": "app", "src_path": "/fake/app/src/lib.rs"}]
547                },
548                {
549                    "name": "helper",
550                    "version": "1.0.0",
551                    "id": "helper 1.0.0",
552                    "manifest_path": "/fake/helper/Cargo.toml",
553                    "source": "registry+https://github.com/rust-lang/crates.io-index",
554                    "targets": [{"kind": ["lib"], "name": "helper", "src_path": "/fake/helper/src/lib.rs"}]
555                },
556                {
557                    "name": "test-util",
558                    "version": "0.1.0",
559                    "id": "test-util 0.1.0",
560                    "manifest_path": "/fake/test-util/Cargo.toml",
561                    "source": "registry+https://github.com/rust-lang/crates.io-index",
562                    "targets": [{"kind": ["lib"], "name": "test_util", "src_path": "/fake/test-util/src/lib.rs"}]
563                }
564            ],
565            "workspace_root": "/fake",
566            "workspace_members": ["app 0.1.0"],
567            "resolve": {
568                "nodes": [
569                    {
570                        "id": "app 0.1.0",
571                        "deps": [
572                            {
573                                "name": "helper",
574                                "pkg": "helper 1.0.0",
575                                "dep_kinds": [{"kind": null, "target": null}]
576                            },
577                            {
578                                "name": "test_util",
579                                "pkg": "test-util 0.1.0",
580                                "dep_kinds": [{"kind": "dev", "target": null}]
581                            }
582                        ]
583                    },
584                    {
585                        "id": "helper 1.0.0",
586                        "deps": []
587                    },
588                    {
589                        "id": "test-util 0.1.0",
590                        "deps": []
591                    }
592                ],
593                "root": "app 0.1.0"
594            }
595        });
596
597        let json_bytes = serde_json::to_vec(&metadata_json).unwrap();
598        let (graph, id_to_name) = extract_dep_graph(&json_bytes, false).unwrap();
599
600        // app should have only "helper" as a dep (test-util is dev-only)
601        let app_node = graph.iter().find(|(id, _)| id == "app 0.1.0").unwrap();
602        assert_eq!(app_node.1.len(), 1);
603        assert_eq!(app_node.1[0].extern_name, "helper");
604
605        // id_to_name should normalize
606        assert_eq!(id_to_name.get("test-util 0.1.0").unwrap(), "test_util");
607
608        // topo sort should work
609        let order = topological_order(&graph).unwrap();
610        assert_eq!(order.len(), 3);
611    }
612
613    #[test]
614    fn extract_dep_graph_excludes_proc_macros() {
615        let metadata_json = serde_json::json!({
616            "packages": [
617                {
618                    "name": "app",
619                    "version": "0.1.0",
620                    "id": "app 0.1.0",
621                    "manifest_path": "/fake/app/Cargo.toml",
622                    "source": null,
623                    "targets": [{"kind": ["lib"], "name": "app", "src_path": "/fake/app/src/lib.rs"}]
624                },
625                {
626                    "name": "my-derive",
627                    "version": "1.0.0",
628                    "id": "my-derive 1.0.0",
629                    "manifest_path": "/fake/my-derive/Cargo.toml",
630                    "source": "registry+https://github.com/rust-lang/crates.io-index",
631                    "targets": [{"kind": ["proc-macro"], "name": "my_derive", "src_path": "/fake/my-derive/src/lib.rs"}]
632                }
633            ],
634            "workspace_root": "/fake",
635            "workspace_members": ["app 0.1.0"],
636            "resolve": {
637                "nodes": [
638                    {
639                        "id": "app 0.1.0",
640                        "deps": [
641                            {
642                                "name": "my_derive",
643                                "pkg": "my-derive 1.0.0",
644                                "dep_kinds": [{"kind": null, "target": null}]
645                            }
646                        ]
647                    },
648                    {
649                        "id": "my-derive 1.0.0",
650                        "deps": []
651                    }
652                ],
653                "root": "app 0.1.0"
654            }
655        });
656
657        let json_bytes = serde_json::to_vec(&metadata_json).unwrap();
658        let (graph, _) = extract_dep_graph(&json_bytes, true).unwrap();
659
660        // my-derive should be excluded as a proc-macro
661        assert_eq!(graph.len(), 1); // only "app" remains
662        let app_node = &graph[0];
663        assert_eq!(app_node.0, "app 0.1.0");
664        assert!(app_node.1.is_empty()); // dep on proc-macro filtered out
665    }
666
667    #[test]
668    fn workspace_topo_order_basic() {
669        let ws_crates = vec![
670            CrateInfo {
671                name: "app".to_string(),
672                version: "0.1.0".to_string(),
673                source_dir: PathBuf::from("/fake/app/src"),
674                is_dependency: false,
675                classification: None,
676                package_id: Some("app 0.1.0".to_string()),
677            },
678            CrateInfo {
679                name: "core-lib".to_string(),
680                version: "0.1.0".to_string(),
681                source_dir: PathBuf::from("/fake/core-lib/src"),
682                is_dependency: false,
683                classification: None,
684                package_id: Some("core-lib 0.1.0".to_string()),
685            },
686        ];
687        let graph = vec![
688            (
689                "app 0.1.0".to_string(),
690                vec![DepEdge {
691                    extern_name: "core_lib".to_string(),
692                    pkg_id: "core-lib 0.1.0".to_string(),
693                }],
694            ),
695            ("core-lib 0.1.0".to_string(), vec![]),
696        ];
697        let order = workspace_topological_order(&ws_crates, &graph).unwrap();
698        let pos = |id: &str| order.iter().position(|x| x == id).unwrap();
699        assert!(
700            pos("core-lib 0.1.0") < pos("app 0.1.0"),
701            "core-lib should come before app"
702        );
703    }
704
705    #[test]
706    fn workspace_topo_order_independent() {
707        let ws_crates = vec![
708            CrateInfo {
709                name: "a".to_string(),
710                version: "0.1.0".to_string(),
711                source_dir: PathBuf::from("/fake/a/src"),
712                is_dependency: false,
713                classification: None,
714                package_id: Some("a 0.1.0".to_string()),
715            },
716            CrateInfo {
717                name: "b".to_string(),
718                version: "0.1.0".to_string(),
719                source_dir: PathBuf::from("/fake/b/src"),
720                is_dependency: false,
721                classification: None,
722                package_id: Some("b 0.1.0".to_string()),
723            },
724        ];
725        let graph = vec![
726            ("a 0.1.0".to_string(), vec![]),
727            ("b 0.1.0".to_string(), vec![]),
728        ];
729        let order = workspace_topological_order(&ws_crates, &graph).unwrap();
730        assert_eq!(order.len(), 2);
731    }
732}