use std::collections::HashMap;
use std::path::{Path, PathBuf};
use camino::{Utf8Path, Utf8PathBuf};
use cargo_metadata::{Metadata, MetadataCommand};
use thiserror::Error;
use crate::crate_name::CrateName;
use crate::file_path::WorkspaceFilePath;
use crate::resolver::{CrateLayout, WorkspaceType};
pub trait WorkspaceMetadataProvider: Send + Sync {
fn crate_for_file(&self, path: &WorkspaceFilePath) -> Option<CrateName>;
fn all_crates(&self) -> Vec<CrateName>;
fn crate_root(&self, crate_name: &CrateName) -> Option<PathBuf>;
fn workspace_root(&self) -> &Path;
fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout>;
}
#[derive(Debug, Error)]
pub enum MetadataError {
#[error("manifest not found: {0}")]
ManifestNotFound(PathBuf),
#[error("cargo metadata failed: {0}")]
CargoMetadata(#[from] cargo_metadata::Error),
#[error("path is outside workspace: {0}")]
OutsideWorkspace(PathBuf),
}
#[derive(Debug, Clone)]
pub struct CrateInfo {
pub name: String,
pub module_name: String,
pub manifest_path: Utf8PathBuf,
pub src_path: Utf8PathBuf,
pub is_workspace_member: bool,
pub entry_points: Vec<TargetInfo>,
}
#[derive(Debug, Clone)]
pub struct TargetInfo {
pub name: String,
pub kind: TargetKind,
pub src_path: Utf8PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
Lib,
Bin,
Example,
Test,
Bench,
Other,
}
impl TargetKind {
fn from_cargo_kinds(kinds: &[cargo_metadata::TargetKind]) -> Self {
use cargo_metadata::TargetKind as CK;
for kind in kinds {
match kind {
CK::Lib | CK::RLib | CK::DyLib | CK::CDyLib | CK::StaticLib | CK::ProcMacro => {
return Self::Lib
}
CK::Bin => return Self::Bin,
CK::Example => return Self::Example,
CK::Test => return Self::Test,
CK::Bench => return Self::Bench,
_ => {}
}
}
Self::Other
}
}
#[derive(Debug)]
pub struct CargoMetadataProvider {
workspace_root: Utf8PathBuf,
crates: HashMap<String, CrateInfo>,
path_to_crate: Vec<(Utf8PathBuf, String)>,
workspace_type: WorkspaceType,
}
impl CargoMetadataProvider {
pub fn from_manifest(manifest_path: impl AsRef<Path>) -> Result<Self, MetadataError> {
let manifest_path = manifest_path.as_ref();
if !manifest_path.exists() {
return Err(MetadataError::ManifestNotFound(manifest_path.to_path_buf()));
}
let metadata = MetadataCommand::new()
.manifest_path(manifest_path)
.no_deps() .exec()?;
Self::from_metadata(metadata)
}
pub fn from_directory(dir: impl AsRef<Path>) -> Result<Self, MetadataError> {
let dir = dir.as_ref();
let manifest_path = dir.join("Cargo.toml");
Self::from_manifest(manifest_path)
}
pub fn from_metadata(metadata: Metadata) -> Result<Self, MetadataError> {
let workspace_root = metadata.workspace_root.clone();
let workspace_members: std::collections::HashSet<_> =
metadata.workspace_members.iter().collect();
let mut crates = HashMap::new();
let mut path_to_crate = Vec::new();
for pkg in &metadata.packages {
let is_member = workspace_members.contains(&pkg.id);
let manifest_dir = pkg.manifest_path.parent().unwrap_or(&pkg.manifest_path);
let src_path_absolute = manifest_dir.join("src");
let src_path = src_path_absolute
.strip_prefix(&workspace_root)
.unwrap_or(&src_path_absolute)
.to_path_buf();
let entry_points: Vec<TargetInfo> = pkg
.targets
.iter()
.map(|target| {
let target_src_relative = target
.src_path
.strip_prefix(&workspace_root)
.unwrap_or(&target.src_path)
.to_path_buf();
TargetInfo {
name: target.name.clone(),
kind: TargetKind::from_cargo_kinds(&target.kind),
src_path: target_src_relative,
}
})
.collect();
let info = CrateInfo {
name: pkg.name.clone(),
module_name: pkg.name.replace('-', "_"),
manifest_path: pkg.manifest_path.clone(),
src_path: src_path.clone(),
is_workspace_member: is_member,
entry_points,
};
if is_member {
path_to_crate.push((src_path, info.module_name.clone()));
}
crates.insert(pkg.name.clone(), info);
}
path_to_crate.sort_by_key(|b| std::cmp::Reverse(b.0.as_str().len()));
let workspace_type = if workspace_members.len() > 1 {
WorkspaceType::Workspace
} else if let Some(pkg) = metadata
.packages
.iter()
.find(|p| workspace_members.contains(&p.id))
{
let manifest_dir = pkg.manifest_path.parent().unwrap_or(&pkg.manifest_path);
if manifest_dir == workspace_root {
WorkspaceType::Crate
} else {
WorkspaceType::Workspace
}
} else {
WorkspaceType::Workspace
};
Ok(Self {
workspace_root,
crates,
path_to_crate,
workspace_type,
})
}
pub fn get_crate(&self, name: &str) -> Option<&CrateInfo> {
self.crates.get(name)
}
pub fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout> {
let module_name = crate_name.to_module_name();
let info = self
.crates
.values()
.find(|c| c.module_name == module_name && c.is_workspace_member)?;
let src_path = info.src_path.as_str();
if src_path == "src" {
return Some(CrateLayout::Root);
}
if let Some(rest) = src_path.strip_prefix("crates/") {
if let Some(crate_dir) = rest.strip_suffix("/src") {
return Some(CrateLayout::InCrates {
crate_dir_name: crate_dir.to_string(),
});
}
}
if let Some(prefix) = src_path.strip_suffix("/src") {
return Some(CrateLayout::Custom {
prefix: PathBuf::from(prefix),
});
}
Some(CrateLayout::Custom {
prefix: PathBuf::from(src_path),
})
}
pub fn workspace_type(&self) -> WorkspaceType {
self.workspace_type
}
pub fn members(&self) -> impl Iterator<Item = &CrateInfo> {
self.crates.values().filter(|c| c.is_workspace_member)
}
pub fn is_in_workspace(&self, path: impl AsRef<Path>) -> bool {
let path = path.as_ref();
path.to_str()
.map(|s| Utf8Path::new(s).starts_with(&self.workspace_root))
.unwrap_or(false)
}
pub fn module_path_for_file(&self, file_path: impl AsRef<Path>) -> Option<String> {
let file_path = file_path.as_ref();
let file_path_str = file_path.to_str()?;
let file_path = Utf8Path::new(file_path_str);
let file_path = if file_path.is_relative() {
self.workspace_root.join(file_path)
} else {
file_path.to_path_buf()
};
for (src_path, _) in &self.path_to_crate {
if file_path.starts_with(src_path) {
let relative = file_path.strip_prefix(src_path).ok()?;
let relative_str = relative.as_str();
let module_path = relative_str.trim_end_matches(".rs");
let module_path = module_path.replace('/', "::");
let module_path = if module_path == "lib" || module_path.is_empty() {
String::new()
} else if module_path.ends_with("::mod") {
module_path.trim_end_matches("::mod").to_string()
} else {
module_path
};
return Some(module_path);
}
}
None
}
pub fn symbol_path_for_file(&self, file_path: impl AsRef<Path>) -> Option<String> {
let file_path = file_path.as_ref();
let file_path_str = file_path.to_str()?;
let utf8_path = Utf8Path::new(file_path_str);
let canonical_path = if utf8_path.is_relative() {
self.workspace_root.join(utf8_path)
} else {
utf8_path.to_path_buf()
};
for (src_path, module_name) in &self.path_to_crate {
if canonical_path.starts_with(src_path) {
let module_path = self.module_path_for_file(file_path)?;
return if module_path.is_empty() {
Some(module_name.clone())
} else {
Some(format!("{}::{}", module_name, module_path))
};
}
}
None
}
fn crate_name_for_path(&self, file_path: &Path) -> Option<&str> {
let file_path_str = file_path.to_str()?;
let file_path = Utf8Path::new(file_path_str);
let file_path_absolute = if file_path.is_relative() {
self.workspace_root.join(file_path)
} else {
file_path.to_path_buf()
};
for (src_path, module_name) in &self.path_to_crate {
let src_path_absolute = self.workspace_root.join(src_path);
if file_path_absolute.starts_with(&src_path_absolute) {
return Some(module_name.as_str());
}
}
None
}
}
impl WorkspaceMetadataProvider for CargoMetadataProvider {
fn crate_for_file(&self, path: &WorkspaceFilePath) -> Option<CrateName> {
let absolute = path.to_absolute();
let module_name = self.crate_name_for_path(&absolute)?;
Some(CrateName::new_unchecked(module_name))
}
fn all_crates(&self) -> Vec<CrateName> {
self.crates
.values()
.filter(|c| c.is_workspace_member)
.map(|c| CrateName::new_unchecked(&c.module_name))
.collect()
}
fn crate_root(&self, crate_name: &CrateName) -> Option<PathBuf> {
let module_name = crate_name.to_module_name();
for info in self.crates.values() {
if info.module_name == module_name {
return info
.manifest_path
.parent()
.map(|p| PathBuf::from(p.as_str()));
}
}
self.crates
.get(crate_name.as_str())
.and_then(|info| info.manifest_path.parent())
.map(|p| PathBuf::from(p.as_str()))
}
fn workspace_root(&self) -> &Path {
self.workspace_root.as_std_path()
}
fn crate_layout(&self, crate_name: &CrateName) -> Option<CrateLayout> {
CargoMetadataProvider::crate_layout(self, crate_name)
}
}
#[cfg(any(test, feature = "test-utils"))]
#[derive(Debug, Clone)]
pub struct MockMetadataProvider {
workspace_root: PathBuf,
crate_name: CrateName,
}
#[cfg(any(test, feature = "test-utils"))]
impl MockMetadataProvider {
pub fn new(workspace_root: impl Into<PathBuf>, crate_name: impl AsRef<str>) -> Self {
Self {
workspace_root: workspace_root.into(),
crate_name: CrateName::new_unchecked(crate_name.as_ref()),
}
}
}
#[cfg(any(test, feature = "test-utils"))]
impl WorkspaceMetadataProvider for MockMetadataProvider {
fn crate_for_file(&self, _path: &WorkspaceFilePath) -> Option<CrateName> {
Some(self.crate_name.clone())
}
fn all_crates(&self) -> Vec<CrateName> {
vec![self.crate_name.clone()]
}
fn crate_root(&self, _crate_name: &CrateName) -> Option<PathBuf> {
Some(self.workspace_root.clone())
}
fn workspace_root(&self) -> &Path {
&self.workspace_root
}
fn crate_layout(&self, _crate_name: &CrateName) -> Option<CrateLayout> {
Some(CrateLayout::Root)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolver::CrateLayout;
#[test]
fn test_crate_layout_root_crate() {
let provider = create_test_provider_with_src_path("my_crate", "src");
let crate_name = CrateName::new_unchecked("my_crate");
let layout = provider.crate_layout(&crate_name);
assert_eq!(layout, Some(CrateLayout::Root));
}
#[test]
fn test_crate_layout_in_crates_directory() {
let provider = create_test_provider_with_src_path("my_crate", "crates/my-crate/src");
let crate_name = CrateName::new_unchecked("my_crate");
let layout = provider.crate_layout(&crate_name);
assert_eq!(
layout,
Some(CrateLayout::InCrates {
crate_dir_name: "my-crate".to_string()
})
);
}
#[test]
fn test_crate_layout_custom_path() {
let provider = create_test_provider_with_src_path("core", "packages/core/src");
let crate_name = CrateName::new_unchecked("core");
let layout = provider.crate_layout(&crate_name);
assert_eq!(
layout,
Some(CrateLayout::Custom {
prefix: PathBuf::from("packages/core")
})
);
}
#[test]
fn test_crate_layout_unknown_crate_returns_none() {
let provider = create_test_provider_with_src_path("my_crate", "src");
let crate_name = CrateName::new_unchecked("unknown_crate");
let layout = provider.crate_layout(&crate_name);
assert_eq!(layout, None);
}
fn create_test_provider_with_src_path(
module_name: &str,
src_path: &str,
) -> CargoMetadataProvider {
let workspace_root = Utf8PathBuf::from("/workspace");
let mut crates = HashMap::new();
let info = CrateInfo {
name: module_name.replace('_', "-"),
module_name: module_name.to_string(),
manifest_path: Utf8PathBuf::from(format!(
"/workspace/{}/Cargo.toml",
src_path.trim_end_matches("/src")
)),
src_path: Utf8PathBuf::from(src_path),
is_workspace_member: true,
entry_points: vec![],
};
crates.insert(info.name.clone(), info.clone());
let path_to_crate = vec![(Utf8PathBuf::from(src_path), module_name.to_string())];
CargoMetadataProvider {
workspace_root,
crates,
path_to_crate,
workspace_type: WorkspaceType::Workspace,
}
}
#[test]
fn test_crate_info() {
let info = CrateInfo {
name: "ryo-app".to_string(),
module_name: "ryo_app".to_string(),
manifest_path: Utf8PathBuf::from("/test/Cargo.toml"),
src_path: Utf8PathBuf::from("/test/src"),
is_workspace_member: true,
entry_points: vec![TargetInfo {
name: "ryo_app".to_string(),
kind: TargetKind::Lib,
src_path: Utf8PathBuf::from("/test/src/lib.rs"),
}],
};
assert_eq!(info.module_name, "ryo_app");
assert!(info.is_workspace_member);
assert_eq!(info.entry_points.len(), 1);
}
#[test]
fn test_mock_provider() {
let provider = MockMetadataProvider::new("/workspace", "mylib");
let path = WorkspaceFilePath::new_for_test("src/lib.rs", "/workspace", "mylib");
assert_eq!(provider.crate_for_file(&path).unwrap().as_str(), "mylib");
assert_eq!(provider.workspace_root(), Path::new("/workspace"));
assert_eq!(provider.all_crates().len(), 1);
}
}