1use crate::error::{RailError, RailResult};
23use cargo_metadata::DependencyKind;
24use petgraph::Direction;
25use petgraph::algo::toposort;
26use petgraph::graph::{DiGraph, NodeIndex};
27use std::collections::{HashMap, HashSet};
28use std::path::{Path, PathBuf};
29use std::sync::RwLock;
30
31#[derive(Debug, Clone)]
33pub struct PackageNode {
34 pub name: String,
36 pub manifest_path: PathBuf,
38 pub is_workspace_member: bool,
40}
41
42pub struct WorkspaceGraph {
46 graph: DiGraph<PackageNode, DependencyKind>,
50
51 name_to_node: HashMap<String, NodeIndex>,
53
54 workspace_members: HashSet<String>,
56
57 sorted_members: Vec<String>,
59
60 workspace_root: PathBuf,
62
63 path_cache: RwLock<Option<HashMap<PathBuf, String>>>,
68}
69
70impl WorkspaceGraph {
71 pub fn from_metadata(metadata: &cargo_metadata::Metadata) -> RailResult<Self> {
78 let mut graph = DiGraph::new();
80 let mut name_to_node = HashMap::new();
81 let mut id_to_node = HashMap::new();
82 let mut workspace_members = HashSet::new();
83
84 let workspace_pkg_ids: HashSet<_> = metadata.workspace_packages().iter().map(|pkg| pkg.id.clone()).collect();
86
87 for package in &metadata.packages {
89 let crate_name = package.name.as_ref().to_string();
90
91 let node = PackageNode {
92 name: crate_name.clone(),
93 manifest_path: package.manifest_path.clone().into_std_path_buf(),
94 is_workspace_member: workspace_pkg_ids.contains(&package.id),
95 };
96
97 let node_idx = graph.add_node(node);
98 name_to_node.insert(package.name.as_ref().to_string(), node_idx);
99 id_to_node.insert(package.id.clone(), node_idx);
100
101 if workspace_pkg_ids.contains(&package.id) {
102 workspace_members.insert(package.name.as_ref().to_string());
103 }
104 }
105
106 for package in &metadata.packages {
108 let from_idx = id_to_node[&package.id];
109
110 for dep in &package.dependencies {
111 if let Some(to_idx) = name_to_node.get(dep.name.as_str()) {
113 graph.add_edge(from_idx, *to_idx, dep.kind);
114 }
115 }
116 }
117
118 let mut sorted_members: Vec<String> = workspace_members.iter().cloned().collect();
120 sorted_members.sort();
121
122 let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
124
125 let graph = Self {
126 graph,
127 name_to_node,
128 workspace_members,
129 sorted_members,
130 workspace_root,
131 path_cache: RwLock::new(None),
132 };
133
134 graph.build_path_cache();
136
137 Ok(graph)
138 }
139
140 pub fn workspace_members(&self) -> &[String] {
142 &self.sorted_members
143 }
144
145 pub fn transitive_dependents(&self, crate_name: &str) -> RailResult<Vec<String>> {
152 let start_node = self.find_node(crate_name)?;
153
154 let mut visited = HashSet::new();
156 let mut stack = vec![start_node];
157 let mut dependents = HashSet::new();
158
159 while let Some(node_idx) = stack.pop() {
160 if !visited.insert(node_idx) {
161 continue;
162 }
163
164 for neighbor_idx in self.graph.neighbors_directed(node_idx, Direction::Incoming) {
166 let neighbor = &self.graph[neighbor_idx];
167
168 if neighbor.is_workspace_member && neighbor_idx != start_node {
170 dependents.insert(neighbor.name.clone());
171 }
172
173 stack.push(neighbor_idx);
174 }
175 }
176
177 let mut result: Vec<_> = dependents.into_iter().collect();
178 result.sort();
179 Ok(result)
180 }
181
182 pub fn transitive_dependents_of_set(&self, crate_names: &HashSet<String>) -> RailResult<HashSet<String>> {
192 if crate_names.is_empty() {
193 return Ok(HashSet::new());
194 }
195
196 let start_nodes: Vec<NodeIndex> = crate_names
198 .iter()
199 .filter_map(|name| self.name_to_node.get(name).copied())
200 .collect();
201
202 if start_nodes.is_empty() {
203 return Ok(HashSet::new());
204 }
205
206 let mut visited = HashSet::new();
208 let mut stack = start_nodes;
209 let mut dependents = HashSet::new();
210
211 let start_node_set: HashSet<NodeIndex> = stack.iter().copied().collect();
213
214 while let Some(node_idx) = stack.pop() {
215 if !visited.insert(node_idx) {
216 continue;
217 }
218
219 for neighbor_idx in self.graph.neighbors_directed(node_idx, Direction::Incoming) {
221 let neighbor = &self.graph[neighbor_idx];
222
223 if neighbor.is_workspace_member && !start_node_set.contains(&neighbor_idx) {
225 dependents.insert(neighbor.name.clone());
226 }
227
228 stack.push(neighbor_idx);
229 }
230 }
231
232 Ok(dependents)
233 }
234
235 pub fn publish_order(&self) -> RailResult<Vec<String>> {
248 let mut subgraph = DiGraph::<&PackageNode, DependencyKind>::new();
252 let mut name_to_subgraph_idx = HashMap::new();
253
254 for (name, &idx) in &self.name_to_node {
256 let node = &self.graph[idx];
257 if node.is_workspace_member {
258 let subgraph_idx = subgraph.add_node(node);
259 name_to_subgraph_idx.insert(name.clone(), subgraph_idx);
260 }
261 }
262
263 for (from_name, &from_subgraph_idx) in &name_to_subgraph_idx {
267 let from_graph_idx = self.name_to_node[from_name];
268
269 for neighbor_graph_idx in self.graph.neighbors_directed(from_graph_idx, Direction::Outgoing) {
271 let neighbor_node = &self.graph[neighbor_graph_idx];
272
273 if neighbor_node.is_workspace_member
275 && let Some(&to_subgraph_idx) = name_to_subgraph_idx.get(&neighbor_node.name)
276 && let Some(edge) = self.graph.find_edge(from_graph_idx, neighbor_graph_idx)
277 && let Some(&kind) = self.graph.edge_weight(edge)
278 {
279 if kind != DependencyKind::Development && from_name != &neighbor_node.name {
282 subgraph.add_edge(from_subgraph_idx, to_subgraph_idx, kind);
283 }
284 }
285 }
286 }
287
288 let sorted = toposort(&subgraph, None).map_err(|cycle| {
290 let node = subgraph[cycle.node_id()];
291 RailError::message(format!(
292 "Circular dependency detected involving workspace crate: '{}'. This should not happen in a valid Cargo workspace.",
293 node.name
294 ))
295 })?;
296
297 let result: Vec<String> = sorted.into_iter().map(|idx| subgraph[idx].name.clone()).collect();
299
300 Ok(result)
301 }
302
303 pub fn file_to_crate(&self, file_path: &Path) -> Option<String> {
314 if Self::should_ignore_path(file_path) {
316 return None;
317 }
318
319 let relative_path = self.to_workspace_relative(file_path)?;
321
322 let cache = self.path_cache.read().ok()?;
324 let cache_ref = cache.as_ref()?;
325
326 let mut current = relative_path.as_path();
328
329 if let Some(parent) = current.parent() {
331 if let Some(crate_name) = cache_ref.get(parent) {
332 return Some(crate_name.clone());
333 }
334 current = parent;
335 }
336
337 while let Some(parent) = current.parent() {
339 if let Some(crate_name) = cache_ref.get(parent) {
340 return Some(crate_name.clone());
341 }
342 current = parent;
343 }
344
345 cache_ref.get(Path::new("")).cloned()
347 }
348
349 fn to_workspace_relative(&self, path: &Path) -> Option<PathBuf> {
356 if path.is_absolute() {
357 path.strip_prefix(&self.workspace_root).ok().map(PathBuf::from)
359 } else {
360 Some(path.to_path_buf())
362 }
363 }
364
365 pub fn files_to_crates(&self, file_paths: &[impl AsRef<Path>]) -> HashSet<String> {
367 file_paths
368 .iter()
369 .filter_map(|p| self.file_to_crate(p.as_ref()))
370 .collect()
371 }
372
373 fn find_node(&self, crate_name: &str) -> RailResult<NodeIndex> {
375 self.name_to_node.get(crate_name).copied().ok_or_else(|| {
376 RailError::message(format!(
377 "Crate '{}' not found. Available workspace crates: {}",
378 crate_name,
379 self.workspace_members().join(", ")
380 ))
381 })
382 }
383
384 fn should_ignore_path(path: &Path) -> bool {
386 path.components().any(|component| {
387 if let std::path::Component::Normal(name) = component {
388 let name_str = name.to_string_lossy();
389 matches!(
391 name_str.as_ref(),
392 ".git" | ".jj" | ".hg" | ".svn" |
393 "target" | "node_modules" |
395 ".cache" | "tmp" | ".tmp"
397 )
398 } else {
399 false
400 }
401 })
402 }
403
404 fn build_path_cache(&self) {
409 let mut cache = HashMap::new();
410
411 for crate_name in &self.workspace_members {
412 if let Some(node_idx) = self.name_to_node.get(crate_name) {
413 let node = &self.graph[*node_idx];
414
415 if let Some(crate_root) = node.manifest_path.parent() {
417 let relative_root = if crate_root.is_absolute() {
419 crate_root
420 .strip_prefix(&self.workspace_root)
421 .map(PathBuf::from)
422 .unwrap_or_else(|_| crate_root.to_path_buf())
423 } else {
424 crate_root.to_path_buf()
425 };
426
427 cache.insert(relative_root, crate_name.clone());
428 }
429 }
430 }
431
432 if let Ok(mut guard) = self.path_cache.write() {
435 *guard = Some(cache);
436 }
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn test_should_ignore_path() {
446 assert!(
448 WorkspaceGraph::should_ignore_path(Path::new(".git")),
449 ".git should be ignored"
450 );
451 assert!(
452 WorkspaceGraph::should_ignore_path(Path::new(".git/objects")),
453 ".git/objects should be ignored"
454 );
455 assert!(
456 WorkspaceGraph::should_ignore_path(Path::new("foo/.git/bar")),
457 "nested .git should be ignored"
458 );
459
460 assert!(
461 WorkspaceGraph::should_ignore_path(Path::new(".jj")),
462 ".jj should be ignored"
463 );
464 assert!(
465 WorkspaceGraph::should_ignore_path(Path::new(".jj/repo")),
466 ".jj/repo should be ignored"
467 );
468 assert!(
469 WorkspaceGraph::should_ignore_path(Path::new("src/.jj/file")),
470 "nested .jj should be ignored"
471 );
472
473 assert!(
474 WorkspaceGraph::should_ignore_path(Path::new(".hg")),
475 ".hg should be ignored"
476 );
477 assert!(
478 WorkspaceGraph::should_ignore_path(Path::new(".svn")),
479 ".svn should be ignored"
480 );
481
482 assert!(
484 WorkspaceGraph::should_ignore_path(Path::new("target")),
485 "target should be ignored"
486 );
487 assert!(
488 WorkspaceGraph::should_ignore_path(Path::new("target/debug")),
489 "target/debug should be ignored"
490 );
491 assert!(
492 WorkspaceGraph::should_ignore_path(Path::new("node_modules")),
493 "node_modules should be ignored"
494 );
495
496 assert!(
498 WorkspaceGraph::should_ignore_path(Path::new(".cache")),
499 ".cache should be ignored"
500 );
501 assert!(
502 WorkspaceGraph::should_ignore_path(Path::new("tmp")),
503 "tmp should be ignored"
504 );
505 assert!(
506 WorkspaceGraph::should_ignore_path(Path::new(".tmp")),
507 ".tmp should be ignored"
508 );
509
510 assert!(
512 !WorkspaceGraph::should_ignore_path(Path::new("src")),
513 "src should not be ignored"
514 );
515 assert!(
516 !WorkspaceGraph::should_ignore_path(Path::new("src/main.rs")),
517 "src/main.rs should not be ignored"
518 );
519 assert!(
520 !WorkspaceGraph::should_ignore_path(Path::new("Cargo.toml")),
521 "Cargo.toml should not be ignored"
522 );
523 assert!(
524 !WorkspaceGraph::should_ignore_path(Path::new("crates/foo/src/lib.rs")),
525 "crate files should not be ignored"
526 );
527 assert!(
528 !WorkspaceGraph::should_ignore_path(Path::new("README.md")),
529 "README.md should not be ignored"
530 );
531 }
532}