use crate::error::{RailError, RailResult};
use cargo_metadata::DependencyKind;
use petgraph::Direction;
use petgraph::algo::toposort;
use petgraph::graph::{DiGraph, NodeIndex};
use rustc_hash::FxHashMap;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
#[derive(Debug, Clone)]
pub struct PackageNode {
pub name: String,
pub manifest_path: PathBuf,
pub is_workspace_member: bool,
}
pub struct WorkspaceGraph {
graph: DiGraph<PackageNode, DependencyKind>,
name_to_node: FxHashMap<String, NodeIndex>,
workspace_members: HashSet<String>,
sorted_members: Vec<String>,
workspace_root: PathBuf,
path_cache: RwLock<Option<FxHashMap<PathBuf, String>>>,
}
impl WorkspaceGraph {
pub fn from_metadata(metadata: &cargo_metadata::Metadata) -> RailResult<Self> {
let mut graph = DiGraph::new();
let mut name_to_node = FxHashMap::default();
let mut id_to_node = FxHashMap::default();
let mut workspace_members = HashSet::new();
let workspace_pkg_ids: HashSet<_> = metadata.workspace_packages().iter().map(|pkg| pkg.id.clone()).collect();
for package in &metadata.packages {
let crate_name = package.name.as_ref().to_string();
let node = PackageNode {
name: crate_name.clone(),
manifest_path: package.manifest_path.clone().into_std_path_buf(),
is_workspace_member: workspace_pkg_ids.contains(&package.id),
};
let node_idx = graph.add_node(node);
name_to_node.insert(package.name.as_ref().to_string(), node_idx);
id_to_node.insert(package.id.clone(), node_idx);
if workspace_pkg_ids.contains(&package.id) {
workspace_members.insert(package.name.as_ref().to_string());
}
}
for package in &metadata.packages {
let from_idx = id_to_node[&package.id];
for dep in &package.dependencies {
if let Some(to_idx) = name_to_node.get(dep.name.as_str()) {
graph.add_edge(from_idx, *to_idx, dep.kind);
}
}
}
let mut sorted_members: Vec<String> = workspace_members.iter().cloned().collect();
sorted_members.sort();
let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
let graph = Self {
graph,
name_to_node,
workspace_members,
sorted_members,
workspace_root,
path_cache: RwLock::new(None),
};
graph.build_path_cache();
Ok(graph)
}
pub fn workspace_members(&self) -> &[String] {
&self.sorted_members
}
pub fn transitive_dependents(&self, crate_name: &str) -> RailResult<Vec<String>> {
let start_node = self.find_node(crate_name)?;
let mut visited = HashSet::new();
let mut stack = vec![start_node];
let mut dependents = HashSet::new();
while let Some(node_idx) = stack.pop() {
if !visited.insert(node_idx) {
continue;
}
for neighbor_idx in self.graph.neighbors_directed(node_idx, Direction::Incoming) {
let neighbor = &self.graph[neighbor_idx];
if neighbor.is_workspace_member && neighbor_idx != start_node && !dependents.contains(&neighbor.name) {
dependents.insert(neighbor.name.clone());
}
stack.push(neighbor_idx);
}
}
let mut result: Vec<_> = dependents.into_iter().collect();
result.sort();
Ok(result)
}
pub fn transitive_dependents_of_set(&self, crate_names: &HashSet<String>) -> RailResult<HashSet<String>> {
if crate_names.is_empty() {
return Ok(HashSet::new());
}
let start_nodes: Vec<NodeIndex> = crate_names
.iter()
.filter_map(|name| self.name_to_node.get(name).copied())
.collect();
if start_nodes.is_empty() {
return Ok(HashSet::new());
}
let mut visited = HashSet::new();
let mut stack = start_nodes;
let mut dependents = HashSet::new();
let start_node_set: HashSet<NodeIndex> = stack.iter().copied().collect();
while let Some(node_idx) = stack.pop() {
if !visited.insert(node_idx) {
continue;
}
for neighbor_idx in self.graph.neighbors_directed(node_idx, Direction::Incoming) {
let neighbor = &self.graph[neighbor_idx];
if neighbor.is_workspace_member
&& !start_node_set.contains(&neighbor_idx)
&& !dependents.contains(&neighbor.name)
{
dependents.insert(neighbor.name.clone());
}
stack.push(neighbor_idx);
}
}
Ok(dependents)
}
pub fn publish_order(&self) -> RailResult<Vec<String>> {
let mut subgraph = DiGraph::<&PackageNode, DependencyKind>::new();
let mut name_to_subgraph_idx = FxHashMap::default();
for (name, &idx) in &self.name_to_node {
let node = &self.graph[idx];
if node.is_workspace_member {
let subgraph_idx = subgraph.add_node(node);
name_to_subgraph_idx.insert(name.clone(), subgraph_idx);
}
}
for (from_name, &from_subgraph_idx) in &name_to_subgraph_idx {
let from_graph_idx = self.name_to_node[from_name];
for neighbor_graph_idx in self.graph.neighbors_directed(from_graph_idx, Direction::Outgoing) {
let neighbor_node = &self.graph[neighbor_graph_idx];
if neighbor_node.is_workspace_member
&& let Some(&to_subgraph_idx) = name_to_subgraph_idx.get(&neighbor_node.name)
&& let Some(edge) = self.graph.find_edge(from_graph_idx, neighbor_graph_idx)
&& let Some(&kind) = self.graph.edge_weight(edge)
{
if kind != DependencyKind::Development && from_name != &neighbor_node.name {
subgraph.add_edge(from_subgraph_idx, to_subgraph_idx, kind);
}
}
}
}
let sorted = toposort(&subgraph, None).map_err(|cycle| {
let node = subgraph[cycle.node_id()];
RailError::message(format!(
"Circular dependency detected involving workspace crate: '{}'. This should not happen in a valid Cargo workspace.",
node.name
))
})?;
let result: Vec<String> = sorted.into_iter().map(|idx| subgraph[idx].name.clone()).collect();
Ok(result)
}
pub fn file_to_crate(&self, file_path: &Path) -> Option<String> {
if Self::should_ignore_path(file_path) {
return None;
}
let relative_path = self.to_workspace_relative(file_path)?;
let cache = self.path_cache.read().ok()?;
let cache_ref = cache.as_ref()?;
let mut current = relative_path.as_path();
if let Some(parent) = current.parent() {
if let Some(crate_name) = cache_ref.get(parent) {
return Some(crate_name.clone());
}
current = parent;
}
while let Some(parent) = current.parent() {
if let Some(crate_name) = cache_ref.get(parent) {
return Some(crate_name.clone());
}
current = parent;
}
cache_ref.get(Path::new("")).cloned()
}
fn to_workspace_relative(&self, path: &Path) -> Option<PathBuf> {
if path.is_absolute() {
path.strip_prefix(&self.workspace_root).ok().map(PathBuf::from)
} else {
Some(path.to_path_buf())
}
}
pub fn files_to_crates(&self, file_paths: &[impl AsRef<Path>]) -> HashSet<String> {
file_paths
.iter()
.filter_map(|p| self.file_to_crate(p.as_ref()))
.collect()
}
fn find_node(&self, crate_name: &str) -> RailResult<NodeIndex> {
self.name_to_node.get(crate_name).copied().ok_or_else(|| {
RailError::message(format!(
"Crate '{}' not found. Available workspace crates: {}",
crate_name,
self.workspace_members().join(", ")
))
})
}
fn should_ignore_path(path: &Path) -> bool {
path.components().any(|component| {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
matches!(
name_str.as_ref(),
".git" | ".jj" | ".hg" | ".svn" |
"target" | "node_modules" |
".cache" | "tmp" | ".tmp"
)
} else {
false
}
})
}
fn build_path_cache(&self) {
let mut cache = FxHashMap::default();
for crate_name in &self.workspace_members {
if let Some(node_idx) = self.name_to_node.get(crate_name) {
let node = &self.graph[*node_idx];
if let Some(crate_root) = node.manifest_path.parent() {
let relative_root = if crate_root.is_absolute() {
crate_root
.strip_prefix(&self.workspace_root)
.map(PathBuf::from)
.unwrap_or_else(|_| crate_root.to_path_buf())
} else {
crate_root.to_path_buf()
};
cache.insert(relative_root, crate_name.clone());
}
}
}
if let Ok(mut guard) = self.path_cache.write() {
*guard = Some(cache);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_ignore_path() {
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".git")),
".git should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".git/objects")),
".git/objects should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("foo/.git/bar")),
"nested .git should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".jj")),
".jj should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".jj/repo")),
".jj/repo should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("src/.jj/file")),
"nested .jj should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".hg")),
".hg should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".svn")),
".svn should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("target")),
"target should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("target/debug")),
"target/debug should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("node_modules")),
"node_modules should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".cache")),
".cache should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new("tmp")),
"tmp should be ignored"
);
assert!(
WorkspaceGraph::should_ignore_path(Path::new(".tmp")),
".tmp should be ignored"
);
assert!(
!WorkspaceGraph::should_ignore_path(Path::new("src")),
"src should not be ignored"
);
assert!(
!WorkspaceGraph::should_ignore_path(Path::new("src/main.rs")),
"src/main.rs should not be ignored"
);
assert!(
!WorkspaceGraph::should_ignore_path(Path::new("Cargo.toml")),
"Cargo.toml should not be ignored"
);
assert!(
!WorkspaceGraph::should_ignore_path(Path::new("crates/foo/src/lib.rs")),
"crate files should not be ignored"
);
assert!(
!WorkspaceGraph::should_ignore_path(Path::new("README.md")),
"README.md should not be ignored"
);
}
}