cargo_coupling/
workspace.rs1use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use cargo_metadata::{Metadata, MetadataCommand, PackageId, TargetKind};
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 source_roots: Vec<PathBuf>,
36 pub crate_roots: Vec<PathBuf>,
38 pub manifest_path: PathBuf,
40 pub dependencies: Vec<String>,
42 pub dev_dependencies: Vec<String>,
44 pub is_workspace_member: bool,
46}
47
48#[derive(Debug)]
50pub struct WorkspaceInfo {
51 pub root: PathBuf,
53 pub crates: HashMap<String, CrateInfo>,
55 pub members: Vec<String>,
57 pub dependency_graph: HashMap<String, HashSet<String>>,
59 pub reverse_deps: HashMap<String, HashSet<String>>,
61}
62
63impl WorkspaceInfo {
64 pub fn from_path(path: &Path) -> Result<Self, WorkspaceError> {
66 let manifest_path = find_cargo_toml(path)?;
68
69 let metadata = MetadataCommand::new()
71 .manifest_path(&manifest_path)
72 .exec()?;
73
74 Self::from_metadata(metadata)
75 }
76
77 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 let workspace_member_ids: HashSet<_> = metadata.workspace_members.iter().collect();
88
89 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 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 dependency_graph
135 .entry(package_name.clone())
136 .or_default()
137 .insert(dep.name.clone());
138
139 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 pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
172 self.crates.get(name)
173 }
174
175 pub fn is_workspace_member(&self, name: &str) -> bool {
177 self.members.contains(&name.to_string())
178 }
179
180 pub fn get_dependencies(&self, name: &str) -> Option<&HashSet<String>> {
182 self.dependency_graph.get(name)
183 }
184
185 pub fn get_dependents(&self, name: &str) -> Option<&HashSet<String>> {
187 self.reverse_deps.get(name)
188 }
189
190 pub fn crate_distance(&self, from: &str, to: &str) -> Option<usize> {
193 if from == to {
194 return Some(0);
195 }
196
197 if self
199 .dependency_graph
200 .get(from)
201 .is_some_and(|deps| deps.contains(to))
202 {
203 return Some(1);
204 }
205
206 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(¤t) {
212 continue;
213 }
214 visited.insert(current.clone());
215
216 if let Some(deps) = self.dependency_graph.get(¤t) {
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 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
301fn 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
320pub 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 Some(current_crate.to_string())
338 }
339 first_segment => {
340 let normalized = first_segment.replace('-', "_");
343
344 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 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 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 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 assert_eq!(
390 resolve_crate_from_path("crate::models::User", "my-app", &workspace),
391 Some("my-app".to_string())
392 );
393
394 assert_eq!(
396 resolve_crate_from_path("my_lib::utils", "my-app", &workspace),
397 Some("my-lib".to_string())
398 );
399
400 assert_eq!(
402 resolve_crate_from_path("serde::Serialize", "my-app", &workspace),
403 Some("serde".to_string())
404 );
405 }
406}