cargo_coupling/
workspace.rs1use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use cargo_metadata::{Metadata, MetadataCommand, PackageId};
10use thiserror::Error;
11
12#[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#[derive(Debug, Clone)]
27pub struct CrateInfo {
28 pub name: String,
30 pub id: PackageId,
32 pub src_path: PathBuf,
34 pub manifest_path: PathBuf,
36 pub dependencies: Vec<String>,
38 pub dev_dependencies: Vec<String>,
40 pub is_workspace_member: bool,
42}
43
44#[derive(Debug)]
46pub struct WorkspaceInfo {
47 pub root: PathBuf,
49 pub crates: HashMap<String, CrateInfo>,
51 pub members: Vec<String>,
53 pub dependency_graph: HashMap<String, HashSet<String>>,
55 pub reverse_deps: HashMap<String, HashSet<String>>,
57}
58
59impl WorkspaceInfo {
60 pub fn from_path(path: &Path) -> Result<Self, WorkspaceError> {
62 let manifest_path = find_cargo_toml(path)?;
64
65 let metadata = MetadataCommand::new()
67 .manifest_path(&manifest_path)
68 .exec()?;
69
70 Self::from_metadata(metadata)
71 }
72
73 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 let workspace_member_ids: HashSet<_> = metadata.workspace_members.iter().collect();
84
85 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 let src_path = package
96 .manifest_path
97 .parent()
98 .map(|p| p.as_std_path().join("src"))
99 .unwrap_or_default();
100
101 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 dependency_graph
114 .entry(package_name.clone())
115 .or_default()
116 .insert(dep.name.clone());
117
118 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 pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
149 self.crates.get(name)
150 }
151
152 pub fn is_workspace_member(&self, name: &str) -> bool {
154 self.members.contains(&name.to_string())
155 }
156
157 pub fn get_dependencies(&self, name: &str) -> Option<&HashSet<String>> {
159 self.dependency_graph.get(name)
160 }
161
162 pub fn get_dependents(&self, name: &str) -> Option<&HashSet<String>> {
164 self.reverse_deps.get(name)
165 }
166
167 pub fn crate_distance(&self, from: &str, to: &str) -> Option<usize> {
170 if from == to {
171 return Some(0);
172 }
173
174 if self
176 .dependency_graph
177 .get(from)
178 .is_some_and(|deps| deps.contains(to))
179 {
180 return Some(1);
181 }
182
183 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(¤t) {
189 continue;
190 }
191 visited.insert(current.clone());
192
193 if let Some(deps) = self.dependency_graph.get(¤t) {
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 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
233fn 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
252pub 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 Some(current_crate.to_string())
270 }
271 first_segment => {
272 let normalized = first_segment.replace('-', "_");
275
276 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 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 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 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 assert_eq!(
322 resolve_crate_from_path("crate::models::User", "my-app", &workspace),
323 Some("my-app".to_string())
324 );
325
326 assert_eq!(
328 resolve_crate_from_path("my_lib::utils", "my-app", &workspace),
329 Some("my-lib".to_string())
330 );
331
332 assert_eq!(
334 resolve_crate_from_path("serde::Serialize", "my-app", &workspace),
335 Some("serde".to_string())
336 );
337 }
338}