use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use cargo_metadata::{Metadata, MetadataCommand, PackageId, TargetKind};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WorkspaceError {
#[error("Failed to run cargo metadata: {0}")]
MetadataError(#[from] cargo_metadata::Error),
#[error("Package not found: {0}")]
PackageNotFound(String),
#[error("Invalid manifest path: {0}")]
InvalidManifest(String),
}
#[derive(Debug, Clone)]
pub struct CrateInfo {
pub name: String,
pub id: PackageId,
pub src_path: PathBuf,
pub source_roots: Vec<PathBuf>,
pub crate_roots: Vec<PathBuf>,
pub manifest_path: PathBuf,
pub dependencies: Vec<String>,
pub dev_dependencies: Vec<String>,
pub is_workspace_member: bool,
}
#[derive(Debug)]
pub struct WorkspaceInfo {
pub root: PathBuf,
pub crates: HashMap<String, CrateInfo>,
pub members: Vec<String>,
pub dependency_graph: HashMap<String, HashSet<String>>,
pub reverse_deps: HashMap<String, HashSet<String>>,
}
impl WorkspaceInfo {
pub fn from_path(path: &Path) -> Result<Self, WorkspaceError> {
let manifest_path = find_cargo_toml(path)?;
let metadata = MetadataCommand::new()
.manifest_path(&manifest_path)
.exec()?;
Self::from_metadata(metadata)
}
pub fn from_metadata(metadata: Metadata) -> Result<Self, WorkspaceError> {
let root = metadata.workspace_root.as_std_path().to_path_buf();
let mut crates = HashMap::new();
let mut members = Vec::new();
let mut dependency_graph: HashMap<String, HashSet<String>> = HashMap::new();
let mut reverse_deps: HashMap<String, HashSet<String>> = HashMap::new();
let workspace_member_ids: HashSet<_> = metadata.workspace_members.iter().collect();
for package in &metadata.packages {
let is_workspace_member = workspace_member_ids.contains(&package.id);
let package_name = package.name.to_string();
if is_workspace_member {
members.push(package_name.clone());
}
let manifest_dir = package
.manifest_path
.parent()
.map(|p| p.as_std_path().to_path_buf())
.unwrap_or_default();
let fallback_src_path = manifest_dir.join("src");
let crate_roots = package
.targets
.iter()
.filter(|target| target.kind.iter().any(is_analyzable_target_kind))
.map(|target| target.src_path.as_std_path().to_path_buf())
.collect::<Vec<_>>();
let mut source_roots = crate_roots
.iter()
.filter_map(|src_path| src_path.parent().map(Path::to_path_buf))
.collect::<Vec<_>>();
source_roots = dedupe_contained_roots(source_roots);
if source_roots.is_empty() && fallback_src_path.exists() {
source_roots.push(fallback_src_path.clone());
}
let src_path = fallback_src_path.clone();
let mut deps = Vec::new();
let mut dev_deps = Vec::new();
for dep in &package.dependencies {
if dep.kind == cargo_metadata::DependencyKind::Development {
dev_deps.push(dep.name.clone());
} else {
deps.push(dep.name.clone());
}
dependency_graph
.entry(package_name.clone())
.or_default()
.insert(dep.name.clone());
reverse_deps
.entry(dep.name.clone())
.or_default()
.insert(package_name.clone());
}
let crate_info = CrateInfo {
name: package_name.clone(),
id: package.id.clone(),
src_path,
source_roots,
crate_roots: dedupe_paths(crate_roots),
manifest_path: package.manifest_path.as_std_path().to_path_buf(),
dependencies: deps,
dev_dependencies: dev_deps,
is_workspace_member,
};
crates.insert(package_name, crate_info);
}
Ok(Self {
root,
crates,
members,
dependency_graph,
reverse_deps,
})
}
pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
self.crates.get(name)
}
pub fn is_workspace_member(&self, name: &str) -> bool {
self.members.contains(&name.to_string())
}
pub fn get_dependencies(&self, name: &str) -> Option<&HashSet<String>> {
self.dependency_graph.get(name)
}
pub fn get_dependents(&self, name: &str) -> Option<&HashSet<String>> {
self.reverse_deps.get(name)
}
pub fn crate_distance(&self, from: &str, to: &str) -> Option<usize> {
if from == to {
return Some(0);
}
if self
.dependency_graph
.get(from)
.is_some_and(|deps| deps.contains(to))
{
return Some(1);
}
let mut visited = HashSet::new();
let mut queue = vec![(from.to_string(), 0usize)];
while let Some((current, dist)) = queue.pop() {
if visited.contains(¤t) {
continue;
}
visited.insert(current.clone());
if let Some(deps) = self.dependency_graph.get(¤t) {
for dep in deps {
if dep == to {
return Some(dist + 1);
}
if !visited.contains(dep) {
queue.push((dep.clone(), dist + 1));
}
}
}
}
None
}
pub fn get_all_source_files(&self) -> Vec<PathBuf> {
let mut files = Vec::new();
for member in &self.members {
if let Some(crate_info) = self.crates.get(member) {
for source_root in &crate_info.source_roots {
if !source_root.exists() {
continue;
}
for entry in walkdir::WalkDir::new(source_root)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "rs") {
files.push(path.to_path_buf());
}
}
}
}
}
files
}
}
fn is_analyzable_target_kind(kind: &TargetKind) -> bool {
matches!(
kind,
TargetKind::Lib
| TargetKind::RLib
| TargetKind::DyLib
| TargetKind::CDyLib
| TargetKind::StaticLib
| TargetKind::ProcMacro
| TargetKind::Bin
)
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut deduped = Vec::new();
for path in paths {
if seen.insert(path.clone()) {
deduped.push(path);
}
}
deduped
}
fn dedupe_contained_roots(roots: Vec<PathBuf>) -> Vec<PathBuf> {
let mut roots = dedupe_paths(roots);
roots.sort_by_key(|root| root.components().count());
let mut kept: Vec<PathBuf> = Vec::new();
for root in roots {
if kept.iter().any(|kept_root| root.starts_with(kept_root)) {
continue;
}
kept.push(root);
}
kept
}
fn find_cargo_toml(start: &Path) -> Result<PathBuf, WorkspaceError> {
let mut current = if start.is_file() {
start.parent().map(|p| p.to_path_buf())
} else {
Some(start.to_path_buf())
};
while let Some(dir) = current {
let cargo_toml = dir.join("Cargo.toml");
if cargo_toml.exists() {
return Ok(cargo_toml);
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(WorkspaceError::InvalidManifest(start.display().to_string()))
}
pub fn resolve_crate_from_path(
use_path: &str,
current_crate: &str,
workspace: &WorkspaceInfo,
) -> Option<String> {
let parts: Vec<&str> = use_path.split("::").collect();
if parts.is_empty() {
return None;
}
match parts[0] {
"crate" | "self" | "super" => {
Some(current_crate.to_string())
}
first_segment => {
let normalized = first_segment.replace('-', "_");
for member in &workspace.members {
let member_normalized = member.replace('-', "_");
if member_normalized == normalized {
return Some(member.clone());
}
}
for name in workspace.crates.keys() {
let name_normalized = name.replace('-', "_");
if name_normalized == normalized {
return Some(name.clone());
}
}
Some(first_segment.to_string())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_cargo_toml() {
let result = find_cargo_toml(Path::new("."));
assert!(result.is_ok());
assert!(result.unwrap().ends_with("Cargo.toml"));
}
#[test]
fn test_resolve_crate_from_path() {
let workspace = WorkspaceInfo {
root: PathBuf::new(),
crates: HashMap::new(),
members: vec!["my-app".to_string(), "my-lib".to_string()],
dependency_graph: HashMap::new(),
reverse_deps: HashMap::new(),
};
assert_eq!(
resolve_crate_from_path("crate::models::User", "my-app", &workspace),
Some("my-app".to_string())
);
assert_eq!(
resolve_crate_from_path("my_lib::utils", "my-app", &workspace),
Some("my-lib".to_string())
);
assert_eq!(
resolve_crate_from_path("serde::Serialize", "my-app", &workspace),
Some("serde".to_string())
);
}
}