use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::path::{Path, PathBuf};
use eyre::{Context, Result, bail};
use glob::{MatchOptions, Pattern};
use serde::Deserialize;
use super::{
ProjectId, WorkspaceDiscoveryContext, WorkspaceProject, WorkspaceProvenance, WorkspaceProvider,
};
const CARGO_TOML: &str = "Cargo.toml";
#[derive(Debug, Default)]
pub struct CargoWorkspaceProvider;
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
struct CargoManifest {
package: Option<CargoPackage>,
workspace: Option<CargoWorkspace>,
dependencies: DependencyMap,
#[serde(rename = "dev-dependencies")]
dev_dependencies: DependencyMap,
#[serde(rename = "build-dependencies")]
build_dependencies: DependencyMap,
target: BTreeMap<String, TargetDependencies>,
}
#[derive(Clone, Debug, Deserialize)]
struct CargoPackage {
name: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
struct CargoWorkspace {
members: Vec<String>,
exclude: Vec<String>,
dependencies: DependencyMap,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(default)]
struct TargetDependencies {
dependencies: DependencyMap,
#[serde(rename = "dev-dependencies")]
dev_dependencies: DependencyMap,
#[serde(rename = "build-dependencies")]
build_dependencies: DependencyMap,
}
type DependencyMap = BTreeMap<String, toml::Value>;
struct PackageManifest {
root: PathBuf,
canonical_root: PathBuf,
manifest: CargoManifest,
id: ProjectId,
}
impl WorkspaceProvider for CargoWorkspaceProvider {
fn id(&self) -> &str {
"cargo"
}
fn discover(&self, workspace_root: &Path) -> Result<Vec<WorkspaceProject>> {
let context = WorkspaceDiscoveryContext::new();
self.discover_with_context(workspace_root, &context)
}
fn discover_with_context(
&self,
workspace_root: &Path,
context: &WorkspaceDiscoveryContext,
) -> Result<Vec<WorkspaceProject>> {
let root_manifest_path = workspace_root.join(CARGO_TOML);
if !context.is_file(&root_manifest_path) {
return Ok(Vec::new());
}
let root_manifest = read_manifest(context, &root_manifest_path)?;
let Some(workspace) = root_manifest.workspace.as_ref() else {
return Ok(Vec::new());
};
let canonical_root = context.canonicalize(workspace_root).wrap_err_with(|| {
format!(
"failed to resolve Cargo workspace root {}",
workspace_root.display()
)
})?;
let excludes = workspace
.exclude
.iter()
.map(|exclude| {
Pattern::new(exclude).wrap_err_with(|| {
format!("invalid Cargo workspace exclude pattern {exclude:?}")
})
})
.collect::<Result<Vec<_>>>()?;
let mut roots = discover_members(
workspace_root,
&canonical_root,
&workspace.members,
&excludes,
context,
)?;
if root_manifest.package.is_some() && !is_excluded(Path::new("."), &excludes) {
roots.insert(canonical_root.clone());
}
let mut queue = roots.iter().cloned().collect::<VecDeque<_>>();
let mut manifests = BTreeMap::new();
while let Some(package_root) = queue.pop_front() {
if manifests.contains_key(&package_root) {
continue;
}
let relative = relative_root(&canonical_root, &package_root)?;
let manifest_path = package_root.join(CARGO_TOML);
let manifest = if package_root == canonical_root {
root_manifest.clone()
} else {
read_manifest(context, &manifest_path)?
};
let package = manifest.package.as_ref().ok_or_else(|| {
eyre::eyre!(
"Cargo workspace member at {} is missing [package]",
relative.display()
)
})?;
let id = ProjectId::new(self.id(), &package.name)?;
for (dependency, base) in
dependency_values(&manifest, workspace, &package_root, &canonical_root)
{
let Some(path) = dependency_path(dependency) else {
continue;
};
let candidate = base.join(path);
if !context.is_file(&candidate.join(CARGO_TOML)) {
continue;
}
let candidate = context.canonicalize(&candidate).wrap_err_with(|| {
format!(
"failed to resolve Cargo path dependency {}",
candidate.display()
)
})?;
let Ok(relative) = candidate.strip_prefix(&canonical_root) else {
continue;
};
let relative = normalize_relative(relative);
if !is_excluded(&relative, &excludes) && roots.insert(candidate.clone()) {
queue.push_back(candidate);
}
}
manifests.insert(
package_root.clone(),
PackageManifest {
root: relative,
canonical_root: package_root,
manifest,
id,
},
);
}
let ids_by_root = manifests
.values()
.map(|package| (package.canonical_root.clone(), package.id.clone()))
.collect::<BTreeMap<_, _>>();
manifests
.into_values()
.map(|package| {
let source = package.root.join(CARGO_TOML);
let mut dependencies = BTreeSet::new();
for (dependency, base) in dependency_values(
&package.manifest,
workspace,
&package.canonical_root,
&canonical_root,
) {
let Some(path) = dependency_path(dependency) else {
continue;
};
let candidate = base.join(path);
if !context.is_file(&candidate.join(CARGO_TOML)) {
continue;
}
let candidate = context.canonicalize(&candidate).wrap_err_with(|| {
format!(
"failed to resolve Cargo path dependency {}",
candidate.display()
)
})?;
if let Some(dependency_id) = ids_by_root.get(&candidate)
&& dependency_id != &package.id
{
dependencies.insert(dependency_id.clone());
}
}
let provenance = WorkspaceProvenance {
provider: Some(self.id().to_string()),
source: Some(source.clone()),
};
let mut project = WorkspaceProject::new(package.id, package.root);
project.dependency_provenance = dependencies
.iter()
.cloned()
.map(|dependency| (dependency, provenance.clone()))
.collect();
project.dependencies = dependencies;
project.provenance = provenance;
project
.metadata
.insert("workspace_source".to_string(), CARGO_TOML.to_string());
Ok(project)
})
.collect()
}
}
fn read_manifest(context: &WorkspaceDiscoveryContext, path: &Path) -> Result<CargoManifest> {
let contents = context
.read_to_string(path)
.wrap_err_with(|| format!("failed to read Cargo manifest {}", path.display()))?;
toml::from_str(&contents)
.wrap_err_with(|| format!("failed to parse Cargo manifest {}", path.display()))
}
fn discover_members(
pattern_root: &Path,
canonical_root: &Path,
members: &[String],
excludes: &[Pattern],
context: &WorkspaceDiscoveryContext,
) -> Result<BTreeSet<PathBuf>> {
let options = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
let escaped_root = Pattern::escape(&pattern_root.to_string_lossy());
let mut roots = BTreeSet::new();
for member in members {
if Path::new(member).is_absolute() {
bail!("Cargo workspace member pattern {member:?} must be relative");
}
let pattern = format!("{escaped_root}/{member}");
for candidate in glob::glob_with(&pattern, options)
.wrap_err_with(|| format!("invalid Cargo workspace member pattern {member:?}"))?
{
let candidate = candidate.wrap_err_with(|| {
format!("failed to evaluate Cargo workspace member pattern {member:?}")
})?;
if !context.is_file(&candidate.join(CARGO_TOML)) {
continue;
}
let candidate = context.canonicalize(&candidate).wrap_err_with(|| {
format!(
"failed to resolve Cargo workspace member {}",
candidate.display()
)
})?;
let relative = relative_root(canonical_root, &candidate)?;
if !is_excluded(&relative, excludes) {
roots.insert(candidate);
}
}
}
Ok(roots)
}
fn relative_root(workspace_root: &Path, package_root: &Path) -> Result<PathBuf> {
let relative = package_root.strip_prefix(workspace_root).map_err(|_| {
eyre::eyre!(
"Cargo workspace member {} is outside workspace root {}",
package_root.display(),
workspace_root.display()
)
})?;
Ok(normalize_relative(relative))
}
fn normalize_relative(path: &Path) -> PathBuf {
if path.as_os_str().is_empty() {
PathBuf::from(".")
} else {
path.to_path_buf()
}
}
fn is_excluded(relative: &Path, excludes: &[Pattern]) -> bool {
let options = MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: true,
};
relative.ancestors().any(|candidate| {
excludes
.iter()
.any(|exclude| exclude.matches_path_with(candidate, options))
})
}
fn dependency_values<'a>(
manifest: &'a CargoManifest,
workspace: &'a CargoWorkspace,
package_root: &'a Path,
workspace_root: &'a Path,
) -> Vec<(&'a toml::Value, &'a Path)> {
manifest
.dependencies
.iter()
.chain(&manifest.dev_dependencies)
.chain(&manifest.build_dependencies)
.chain(manifest.target.values().flat_map(|target| {
target
.dependencies
.iter()
.chain(&target.dev_dependencies)
.chain(&target.build_dependencies)
}))
.filter_map(|(name, dependency)| {
if dependency
.as_table()
.and_then(|table| table.get("workspace"))
.and_then(toml::Value::as_bool)
== Some(true)
{
workspace
.dependencies
.get(name)
.map(|dependency| (dependency, workspace_root))
} else {
Some((dependency, package_root))
}
})
.collect()
}
fn dependency_path(dependency: &toml::Value) -> Option<&Path> {
dependency.as_table()?.get("path")?.as_str().map(Path::new)
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn write(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
fn package(path: &Path, name: &str, extra: &str) {
write(
&path.join(CARGO_TOML),
&format!("[package]\nname = {name:?}\nversion = \"0.1.0\"\n{extra}"),
);
}
#[test]
fn discovers_members_root_package_and_excludes() {
let temp = tempdir().unwrap();
write(
&temp.path().join(CARGO_TOML),
"[package]\nname = \"root\"\nversion = \"0.1.0\"\n\
[workspace]\nmembers = [\"crates/*\", \"crates/*/*\"]\nexclude = [\"crates/skip\"]\n",
);
package(&temp.path().join("crates/app"), "app", "");
package(&temp.path().join("crates/skip"), "skip", "");
package(&temp.path().join("crates/skip/nested"), "nested", "");
let projects = CargoWorkspaceProvider.discover(temp.path()).unwrap();
let summary = projects
.iter()
.map(|project| (project.id.as_str(), project.root.as_path()))
.collect::<Vec<_>>();
assert_eq!(
summary,
vec![
("cargo:root", Path::new(".")),
("cargo:app", Path::new("crates/app"))
]
);
assert!(projects.iter().all(|project| {
project.metadata.get("workspace_source").map(String::as_str) == Some(CARGO_TOML)
}));
}
#[test]
fn infers_all_path_dependency_kinds_and_implicit_members() {
let temp = tempdir().unwrap();
write(
&temp.path().join(CARGO_TOML),
"[workspace]\nmembers = [\"crates/app\", \"crates/core\"]\n\
[workspace.dependencies]\nshared = { path = \"crates/shared\" }\n",
);
package(
&temp.path().join("crates/app"),
"app",
"[dependencies]\nrenamed = { package = \"core\", path = \"../core\" }\n\
[dev-dependencies]\nshared = { workspace = true }\n\
[build-dependencies]\nbuild-helper = { path = \"../build-helper\" }\n\
[target.'cfg(unix)'.dependencies]\ntarget-helper = { path = \"../target-helper\" }\n\
[target.'cfg(windows)'.dev-dependencies]\nexternal = \"1\"\n",
);
for name in ["core", "shared", "build-helper", "target-helper"] {
package(&temp.path().join(format!("crates/{name}")), name, "");
}
let projects = CargoWorkspaceProvider.discover(temp.path()).unwrap();
let app = projects
.iter()
.find(|project| project.id.as_str() == "cargo:app")
.unwrap();
assert_eq!(
app.dependencies,
["build-helper", "core", "shared", "target-helper"]
.into_iter()
.map(|name| ProjectId::new("cargo", name).unwrap())
.collect()
);
assert_eq!(projects.len(), 5);
assert!(app.dependencies.iter().all(|dependency| {
app.dependency_provenance[dependency].source
== Some(PathBuf::from("crates/app/Cargo.toml"))
}));
}
#[test]
fn ignores_manifests_without_a_workspace() {
let temp = tempdir().unwrap();
package(temp.path(), "standalone", "");
assert!(
CargoWorkspaceProvider
.discover(temp.path())
.unwrap()
.is_empty()
);
}
#[test]
fn rejects_members_outside_the_workspace() {
let temp = tempdir().unwrap();
let outside = tempdir().unwrap();
package(outside.path(), "outside", "");
write(
&temp.path().join(CARGO_TOML),
&format!(
"[workspace]\nmembers = [{:?}]\n",
outside.path().display().to_string()
),
);
let error = CargoWorkspaceProvider.discover(temp.path()).unwrap_err();
assert!(error.to_string().contains("must be relative"));
}
}