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};
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    /// Path to the crate's source directory
33    pub src_path: PathBuf,
34    /// Path to Cargo.toml
35    pub manifest_path: PathBuf,
36    /// Direct dependencies (crate names)
37    pub dependencies: Vec<String>,
38    /// Dev dependencies
39    pub dev_dependencies: Vec<String>,
40    /// Is this a workspace member?
41    pub is_workspace_member: bool,
42}
43
44/// Information about the entire workspace
45#[derive(Debug)]
46pub struct WorkspaceInfo {
47    /// Root directory of the workspace
48    pub root: PathBuf,
49    /// All crates in the workspace
50    pub crates: HashMap<String, CrateInfo>,
51    /// Workspace members (crate names)
52    pub members: Vec<String>,
53    /// Dependency graph: crate name -> dependencies
54    pub dependency_graph: HashMap<String, HashSet<String>>,
55    /// Reverse dependency graph: crate name -> dependents
56    pub reverse_deps: HashMap<String, HashSet<String>>,
57}
58
59impl WorkspaceInfo {
60    /// Analyze a workspace from a path
61    pub fn from_path(path: &Path) -> Result<Self, WorkspaceError> {
62        // Find Cargo.toml
63        let manifest_path = find_cargo_toml(path)?;
64
65        // Run cargo metadata
66        let metadata = MetadataCommand::new()
67            .manifest_path(&manifest_path)
68            .exec()?;
69
70        Self::from_metadata(metadata)
71    }
72
73    /// Create workspace info from cargo metadata
74    pub fn from_metadata(metadata: Metadata) -> Result<Self, WorkspaceError> {
75        let root = metadata.workspace_root.as_std_path().to_path_buf();
76
77        let mut crates = HashMap::new();
78        let mut members = Vec::new();
79        let mut dependency_graph: HashMap<String, HashSet<String>> = HashMap::new();
80        let mut reverse_deps: HashMap<String, HashSet<String>> = HashMap::new();
81
82        // Collect workspace members
83        let workspace_member_ids: HashSet<_> = metadata.workspace_members.iter().collect();
84
85        // Process all packages
86        for package in &metadata.packages {
87            let is_workspace_member = workspace_member_ids.contains(&package.id);
88            let package_name = package.name.to_string();
89
90            if is_workspace_member {
91                members.push(package_name.clone());
92            }
93
94            // Get source directory
95            let src_path = package
96                .manifest_path
97                .parent()
98                .map(|p| p.as_std_path().join("src"))
99                .unwrap_or_default();
100
101            // Collect dependencies
102            let mut deps = Vec::new();
103            let mut dev_deps = Vec::new();
104
105            for dep in &package.dependencies {
106                if dep.kind == cargo_metadata::DependencyKind::Development {
107                    dev_deps.push(dep.name.clone());
108                } else {
109                    deps.push(dep.name.clone());
110                }
111
112                // Build dependency graph
113                dependency_graph
114                    .entry(package_name.clone())
115                    .or_default()
116                    .insert(dep.name.clone());
117
118                // Build reverse dependency graph
119                reverse_deps
120                    .entry(dep.name.clone())
121                    .or_default()
122                    .insert(package_name.clone());
123            }
124
125            let crate_info = CrateInfo {
126                name: package_name.clone(),
127                id: package.id.clone(),
128                src_path,
129                manifest_path: package.manifest_path.as_std_path().to_path_buf(),
130                dependencies: deps,
131                dev_dependencies: dev_deps,
132                is_workspace_member,
133            };
134
135            crates.insert(package_name, crate_info);
136        }
137
138        Ok(Self {
139            root,
140            crates,
141            members,
142            dependency_graph,
143            reverse_deps,
144        })
145    }
146
147    /// Get a crate by name
148    pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
149        self.crates.get(name)
150    }
151
152    /// Check if a crate is a workspace member
153    pub fn is_workspace_member(&self, name: &str) -> bool {
154        self.members.contains(&name.to_string())
155    }
156
157    /// Get direct dependencies of a crate
158    pub fn get_dependencies(&self, name: &str) -> Option<&HashSet<String>> {
159        self.dependency_graph.get(name)
160    }
161
162    /// Get crates that depend on this crate
163    pub fn get_dependents(&self, name: &str) -> Option<&HashSet<String>> {
164        self.reverse_deps.get(name)
165    }
166
167    /// Calculate the distance between two crates
168    /// Returns None if there's no path, 0 if same crate, 1 for direct dep, etc.
169    pub fn crate_distance(&self, from: &str, to: &str) -> Option<usize> {
170        if from == to {
171            return Some(0);
172        }
173
174        // Direct dependency check
175        if self
176            .dependency_graph
177            .get(from)
178            .is_some_and(|deps| deps.contains(to))
179        {
180            return Some(1);
181        }
182
183        // BFS for longer paths
184        let mut visited = HashSet::new();
185        let mut queue = vec![(from.to_string(), 0usize)];
186
187        while let Some((current, dist)) = queue.pop() {
188            if visited.contains(&current) {
189                continue;
190            }
191            visited.insert(current.clone());
192
193            if let Some(deps) = self.dependency_graph.get(&current) {
194                for dep in deps {
195                    if dep == to {
196                        return Some(dist + 1);
197                    }
198                    if !visited.contains(dep) {
199                        queue.push((dep.clone(), dist + 1));
200                    }
201                }
202            }
203        }
204
205        None
206    }
207
208    /// Get all source files for workspace members
209    pub fn get_all_source_files(&self) -> Vec<PathBuf> {
210        let mut files = Vec::new();
211
212        for member in &self.members {
213            if let Some(crate_info) = self.crates.get(member)
214                && crate_info.src_path.exists()
215            {
216                for entry in walkdir::WalkDir::new(&crate_info.src_path)
217                    .follow_links(true)
218                    .into_iter()
219                    .filter_map(|e| e.ok())
220                {
221                    let path = entry.path();
222                    if path.extension().is_some_and(|ext| ext == "rs") {
223                        files.push(path.to_path_buf());
224                    }
225                }
226            }
227        }
228
229        files
230    }
231}
232
233/// Find Cargo.toml by walking up from the given path
234fn find_cargo_toml(start: &Path) -> Result<PathBuf, WorkspaceError> {
235    let mut current = if start.is_file() {
236        start.parent().map(|p| p.to_path_buf())
237    } else {
238        Some(start.to_path_buf())
239    };
240
241    while let Some(dir) = current {
242        let cargo_toml = dir.join("Cargo.toml");
243        if cargo_toml.exists() {
244            return Ok(cargo_toml);
245        }
246        current = dir.parent().map(|p| p.to_path_buf());
247    }
248
249    Err(WorkspaceError::InvalidManifest(start.display().to_string()))
250}
251
252/// Resolve a module path to a crate name
253/// e.g., "crate::models::user" in package "my-app" -> "my-app"
254/// e.g., "serde::Serialize" -> "serde"
255pub fn resolve_crate_from_path(
256    use_path: &str,
257    current_crate: &str,
258    workspace: &WorkspaceInfo,
259) -> Option<String> {
260    let parts: Vec<&str> = use_path.split("::").collect();
261
262    if parts.is_empty() {
263        return None;
264    }
265
266    match parts[0] {
267        "crate" | "self" | "super" => {
268            // Internal reference to current crate
269            Some(current_crate.to_string())
270        }
271        first_segment => {
272            // Check if it's a known crate
273            // Convert hyphens to underscores for crate names
274            let normalized = first_segment.replace('-', "_");
275
276            // Check workspace members first
277            for member in &workspace.members {
278                let member_normalized = member.replace('-', "_");
279                if member_normalized == normalized {
280                    return Some(member.clone());
281                }
282            }
283
284            // Check all crates
285            for name in workspace.crates.keys() {
286                let name_normalized = name.replace('-', "_");
287                if name_normalized == normalized {
288                    return Some(name.clone());
289                }
290            }
291
292            // Assume it's an external crate
293            Some(first_segment.to_string())
294        }
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_find_cargo_toml() {
304        // Test in the current project
305        let result = find_cargo_toml(Path::new("."));
306        assert!(result.is_ok());
307        assert!(result.unwrap().ends_with("Cargo.toml"));
308    }
309
310    #[test]
311    fn test_resolve_crate_from_path() {
312        let workspace = WorkspaceInfo {
313            root: PathBuf::new(),
314            crates: HashMap::new(),
315            members: vec!["my-app".to_string(), "my-lib".to_string()],
316            dependency_graph: HashMap::new(),
317            reverse_deps: HashMap::new(),
318        };
319
320        // Internal reference
321        assert_eq!(
322            resolve_crate_from_path("crate::models::User", "my-app", &workspace),
323            Some("my-app".to_string())
324        );
325
326        // Workspace member reference
327        assert_eq!(
328            resolve_crate_from_path("my_lib::utils", "my-app", &workspace),
329            Some("my-lib".to_string())
330        );
331
332        // External crate
333        assert_eq!(
334            resolve_crate_from_path("serde::Serialize", "my-app", &workspace),
335            Some("serde".to_string())
336        );
337    }
338}