Skip to main content

cargo_coupling/
workspace.rs

1//! Workspace analysis using cargo metadata
2//!
3//! This module uses `cargo metadata` to understand the project structure,
4//! including workspace members, dependencies, and module organization.
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use cargo_metadata::{Metadata, MetadataCommand, PackageId, TargetKind};
10use thiserror::Error;
11
12/// Errors that can occur during workspace analysis
13#[derive(Error, Debug)]
14pub enum WorkspaceError {
15    #[error("Failed to run cargo metadata: {0}")]
16    MetadataError(#[from] cargo_metadata::Error),
17
18    #[error("Package not found: {0}")]
19    PackageNotFound(String),
20
21    #[error("Invalid manifest path: {0}")]
22    InvalidManifest(String),
23}
24
25/// Information about a crate in the workspace
26#[derive(Debug, Clone)]
27pub struct CrateInfo {
28    /// Crate name
29    pub name: String,
30    /// Package ID for dependency resolution
31    pub id: PackageId,
32    /// Kept as `<manifest_dir>/src` for backward compatibility; internals use `source_roots`.
33    pub src_path: PathBuf,
34    /// Directories containing analyzable target roots.
35    pub source_roots: Vec<PathBuf>,
36    /// Target root source files, such as lib.rs or main.rs equivalents.
37    pub crate_roots: Vec<PathBuf>,
38    /// Path to Cargo.toml
39    pub manifest_path: PathBuf,
40    /// Direct dependencies (crate names)
41    pub dependencies: Vec<String>,
42    /// Dev dependencies
43    pub dev_dependencies: Vec<String>,
44    /// Is this a workspace member?
45    pub is_workspace_member: bool,
46}
47
48/// Information about the entire workspace
49#[derive(Debug)]
50pub struct WorkspaceInfo {
51    /// Root directory of the workspace
52    pub root: PathBuf,
53    /// All crates in the workspace
54    pub crates: HashMap<String, CrateInfo>,
55    /// Workspace members (crate names)
56    pub members: Vec<String>,
57    /// Dependency graph: crate name -> dependencies
58    pub dependency_graph: HashMap<String, HashSet<String>>,
59    /// Reverse dependency graph: crate name -> dependents
60    pub reverse_deps: HashMap<String, HashSet<String>>,
61}
62
63impl WorkspaceInfo {
64    /// Analyze a workspace from a path
65    pub fn from_path(path: &Path) -> Result<Self, WorkspaceError> {
66        // Find Cargo.toml
67        let manifest_path = find_cargo_toml(path)?;
68
69        // Run cargo metadata
70        let metadata = MetadataCommand::new()
71            .manifest_path(&manifest_path)
72            .exec()?;
73
74        Self::from_metadata(metadata)
75    }
76
77    /// Create workspace info from cargo metadata
78    pub fn from_metadata(metadata: Metadata) -> Result<Self, WorkspaceError> {
79        let root = metadata.workspace_root.as_std_path().to_path_buf();
80
81        let mut crates = HashMap::new();
82        let mut members = Vec::new();
83        let mut dependency_graph: HashMap<String, HashSet<String>> = HashMap::new();
84        let mut reverse_deps: HashMap<String, HashSet<String>> = HashMap::new();
85
86        // Collect workspace members
87        let workspace_member_ids: HashSet<_> = metadata.workspace_members.iter().collect();
88
89        // Process all packages
90        for package in &metadata.packages {
91            let is_workspace_member = workspace_member_ids.contains(&package.id);
92            let package_name = package.name.to_string();
93
94            if is_workspace_member {
95                members.push(package_name.clone());
96            }
97
98            let manifest_dir = package
99                .manifest_path
100                .parent()
101                .map(|p| p.as_std_path().to_path_buf())
102                .unwrap_or_default();
103            let fallback_src_path = manifest_dir.join("src");
104            let crate_roots = package
105                .targets
106                .iter()
107                .filter(|target| target.kind.iter().any(is_analyzable_target_kind))
108                .map(|target| target.src_path.as_std_path().to_path_buf())
109                .collect::<Vec<_>>();
110            let mut source_roots = crate_roots
111                .iter()
112                .filter_map(|src_path| src_path.parent().map(Path::to_path_buf))
113                .collect::<Vec<_>>();
114
115            source_roots = dedupe_contained_roots(source_roots);
116
117            if source_roots.is_empty() && fallback_src_path.exists() {
118                source_roots.push(fallback_src_path.clone());
119            }
120            let src_path = fallback_src_path.clone();
121
122            // Collect dependencies
123            let mut deps = Vec::new();
124            let mut dev_deps = Vec::new();
125
126            for dep in &package.dependencies {
127                if dep.kind == cargo_metadata::DependencyKind::Development {
128                    dev_deps.push(dep.name.clone());
129                } else {
130                    deps.push(dep.name.clone());
131                }
132
133                // Build dependency graph
134                dependency_graph
135                    .entry(package_name.clone())
136                    .or_default()
137                    .insert(dep.name.clone());
138
139                // Build reverse dependency graph
140                reverse_deps
141                    .entry(dep.name.clone())
142                    .or_default()
143                    .insert(package_name.clone());
144            }
145
146            let crate_info = CrateInfo {
147                name: package_name.clone(),
148                id: package.id.clone(),
149                src_path,
150                source_roots,
151                crate_roots: dedupe_paths(crate_roots),
152                manifest_path: package.manifest_path.as_std_path().to_path_buf(),
153                dependencies: deps,
154                dev_dependencies: dev_deps,
155                is_workspace_member,
156            };
157
158            crates.insert(package_name, crate_info);
159        }
160
161        Ok(Self {
162            root,
163            crates,
164            members,
165            dependency_graph,
166            reverse_deps,
167        })
168    }
169
170    /// Get a crate by name
171    pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
172        self.crates.get(name)
173    }
174
175    /// Check if a crate is a workspace member
176    pub fn is_workspace_member(&self, name: &str) -> bool {
177        self.members.contains(&name.to_string())
178    }
179
180    /// Get direct dependencies of a crate
181    pub fn get_dependencies(&self, name: &str) -> Option<&HashSet<String>> {
182        self.dependency_graph.get(name)
183    }
184
185    /// Get crates that depend on this crate
186    pub fn get_dependents(&self, name: &str) -> Option<&HashSet<String>> {
187        self.reverse_deps.get(name)
188    }
189
190    /// Calculate the distance between two crates
191    /// Returns None if there's no path, 0 if same crate, 1 for direct dep, etc.
192    pub fn crate_distance(&self, from: &str, to: &str) -> Option<usize> {
193        if from == to {
194            return Some(0);
195        }
196
197        // Direct dependency check
198        if self
199            .dependency_graph
200            .get(from)
201            .is_some_and(|deps| deps.contains(to))
202        {
203            return Some(1);
204        }
205
206        // BFS for longer paths
207        let mut visited = HashSet::new();
208        let mut queue = vec![(from.to_string(), 0usize)];
209
210        while let Some((current, dist)) = queue.pop() {
211            if visited.contains(&current) {
212                continue;
213            }
214            visited.insert(current.clone());
215
216            if let Some(deps) = self.dependency_graph.get(&current) {
217                for dep in deps {
218                    if dep == to {
219                        return Some(dist + 1);
220                    }
221                    if !visited.contains(dep) {
222                        queue.push((dep.clone(), dist + 1));
223                    }
224                }
225            }
226        }
227
228        None
229    }
230
231    /// Get all source files for workspace members
232    pub fn get_all_source_files(&self) -> Vec<PathBuf> {
233        let mut files = Vec::new();
234
235        for member in &self.members {
236            if let Some(crate_info) = self.crates.get(member) {
237                for source_root in &crate_info.source_roots {
238                    if !source_root.exists() {
239                        continue;
240                    }
241
242                    for entry in walkdir::WalkDir::new(source_root)
243                        .follow_links(true)
244                        .into_iter()
245                        .filter_map(|e| e.ok())
246                    {
247                        let path = entry.path();
248                        if path.extension().is_some_and(|ext| ext == "rs") {
249                            files.push(path.to_path_buf());
250                        }
251                    }
252                }
253            }
254        }
255
256        files
257    }
258}
259
260fn is_analyzable_target_kind(kind: &TargetKind) -> bool {
261    matches!(
262        kind,
263        TargetKind::Lib
264            | TargetKind::RLib
265            | TargetKind::DyLib
266            | TargetKind::CDyLib
267            | TargetKind::StaticLib
268            | TargetKind::ProcMacro
269            | TargetKind::Bin
270    )
271}
272
273fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
274    let mut seen = HashSet::new();
275    let mut deduped = Vec::new();
276
277    for path in paths {
278        if seen.insert(path.clone()) {
279            deduped.push(path);
280        }
281    }
282
283    deduped
284}
285
286fn dedupe_contained_roots(roots: Vec<PathBuf>) -> Vec<PathBuf> {
287    let mut roots = dedupe_paths(roots);
288    roots.sort_by_key(|root| root.components().count());
289
290    let mut kept: Vec<PathBuf> = Vec::new();
291    for root in roots {
292        if kept.iter().any(|kept_root| root.starts_with(kept_root)) {
293            continue;
294        }
295        kept.push(root);
296    }
297
298    kept
299}
300
301/// Find Cargo.toml by walking up from the given path
302fn find_cargo_toml(start: &Path) -> Result<PathBuf, WorkspaceError> {
303    let mut current = if start.is_file() {
304        start.parent().map(|p| p.to_path_buf())
305    } else {
306        Some(start.to_path_buf())
307    };
308
309    while let Some(dir) = current {
310        let cargo_toml = dir.join("Cargo.toml");
311        if cargo_toml.exists() {
312            return Ok(cargo_toml);
313        }
314        current = dir.parent().map(|p| p.to_path_buf());
315    }
316
317    Err(WorkspaceError::InvalidManifest(start.display().to_string()))
318}
319
320/// Resolve a module path to a crate name
321/// e.g., "crate::models::user" in package "my-app" -> "my-app"
322/// e.g., "serde::Serialize" -> "serde"
323pub fn resolve_crate_from_path(
324    use_path: &str,
325    current_crate: &str,
326    workspace: &WorkspaceInfo,
327) -> Option<String> {
328    let parts: Vec<&str> = use_path.split("::").collect();
329
330    if parts.is_empty() {
331        return None;
332    }
333
334    match parts[0] {
335        "crate" | "self" | "super" => {
336            // Internal reference to current crate
337            Some(current_crate.to_string())
338        }
339        first_segment => {
340            // Check if it's a known crate
341            // Convert hyphens to underscores for crate names
342            let normalized = first_segment.replace('-', "_");
343
344            // Check workspace members first
345            for member in &workspace.members {
346                let member_normalized = member.replace('-', "_");
347                if member_normalized == normalized {
348                    return Some(member.clone());
349                }
350            }
351
352            // Check all crates
353            for name in workspace.crates.keys() {
354                let name_normalized = name.replace('-', "_");
355                if name_normalized == normalized {
356                    return Some(name.clone());
357                }
358            }
359
360            // Assume it's an external crate
361            Some(first_segment.to_string())
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn test_find_cargo_toml() {
372        // Test in the current project
373        let result = find_cargo_toml(Path::new("."));
374        assert!(result.is_ok());
375        assert!(result.unwrap().ends_with("Cargo.toml"));
376    }
377
378    #[test]
379    fn test_resolve_crate_from_path() {
380        let workspace = WorkspaceInfo {
381            root: PathBuf::new(),
382            crates: HashMap::new(),
383            members: vec!["my-app".to_string(), "my-lib".to_string()],
384            dependency_graph: HashMap::new(),
385            reverse_deps: HashMap::new(),
386        };
387
388        // Internal reference
389        assert_eq!(
390            resolve_crate_from_path("crate::models::User", "my-app", &workspace),
391            Some("my-app".to_string())
392        );
393
394        // Workspace member reference
395        assert_eq!(
396            resolve_crate_from_path("my_lib::utils", "my-app", &workspace),
397            Some("my-lib".to_string())
398        );
399
400        // External crate
401        assert_eq!(
402            resolve_crate_from_path("serde::Serialize", "my-app", &workspace),
403            Some("serde".to_string())
404        );
405    }
406}