use std::collections::HashSet;
use std::path::{Path, PathBuf};
use toml::Value;
pub fn extract_workspace_deps(
dependencies: Option<&Value>,
member_names: &HashSet<String>,
) -> Vec<String> {
let mut deps = vec![];
if let Some(Value::Table(table)) = dependencies {
for (dep_name, val) in table {
let is_member = member_names.contains(dep_name) && {
match val {
Value::Table(t) => {
t.contains_key("path")
|| t.get("workspace")
.is_some_and(|v| v.as_bool() == Some(true))
}
_ => false,
}
};
if is_member {
deps.push(dep_name.clone());
}
}
}
deps.sort();
deps
}
fn expand_glob(root: &Path, pattern: &str) -> Vec<String> {
if pattern.contains('*') {
let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
let dir = root.join(prefix);
if let Ok(entries) = std::fs::read_dir(&dir) {
return entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
e.path()
.strip_prefix(root)
.ok()
.map(|p| p.to_string_lossy().replace('\\', "/"))
})
.collect();
}
vec![]
} else {
vec![pattern.to_string()]
}
}
fn read_toml(path: PathBuf) -> Option<Value> {
let content = std::fs::read_to_string(path).ok()?;
toml::from_str::<Value>(&content).ok()
}
fn package_name(doc: &Value) -> Option<String> {
doc.get("package")
.and_then(Value::as_table)
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.map(str::to_string)
}
pub fn discover_cargo_workspace_member_names(base_dir: &Path) -> HashSet<String> {
let Some(root) = read_toml(base_dir.join("Cargo.toml")) else {
return HashSet::new();
};
let Some(ws_table) = root.get("workspace").and_then(Value::as_table) else {
return package_name(&root).into_iter().collect();
};
let Some(members) = ws_table.get("members").and_then(Value::as_array) else {
return package_name(&root).into_iter().collect();
};
let excluded: HashSet<String> = ws_table
.get("exclude")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(Value::as_str)
.flat_map(|pattern| expand_glob(base_dir, pattern))
.collect()
})
.unwrap_or_default();
let mut names = HashSet::new();
for pattern in members.iter().filter_map(Value::as_str) {
for member_path in expand_glob(base_dir, pattern) {
if excluded.contains(&member_path) {
continue;
}
let cargo_path = base_dir.join(&member_path).join("Cargo.toml");
if let Some(cargo) = read_toml(cargo_path)
&& let Some(name) = package_name(&cargo)
{
names.insert(name);
}
}
}
names
}
pub fn derive_depends_on_from_cargo_toml(
crate_dir: &Path,
member_names: &HashSet<String>,
) -> Vec<String> {
let Some(doc) = read_toml(crate_dir.join("Cargo.toml")) else {
return vec![];
};
let mut deps: HashSet<String> = extract_workspace_deps(doc.get("dependencies"), member_names)
.into_iter()
.collect();
deps.extend(extract_workspace_deps(
doc.get("build-dependencies"),
member_names,
));
if let Some(Value::Table(targets)) = doc.get("target") {
for target_table in targets.values() {
deps.extend(extract_workspace_deps(
target_table.get("dependencies"),
member_names,
));
}
}
let mut deps: Vec<String> = deps.into_iter().collect();
deps.sort();
deps
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn write(dir: &Path, rel: &str, body: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, body).unwrap();
}
#[test]
fn extract_workspace_deps_matches_path_and_workspace_true_members() {
let deps: Value = toml::from_str(
r#"
local-path = { path = "../local-path" }
local-inherited.workspace = true
external = "1.0"
not-a-member.workspace = true
"#,
)
.unwrap();
let members: HashSet<String> = ["local-path", "local-inherited"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(
extract_workspace_deps(Some(&deps), &members),
vec!["local-inherited".to_string(), "local-path".to_string()]
);
}
#[test]
fn extract_workspace_deps_ignores_workspace_true_dep_not_a_known_member() {
let deps: Value = toml::from_str(r#"serde.workspace = true"#).unwrap();
let members: HashSet<String> = HashSet::new();
assert!(extract_workspace_deps(Some(&deps), &members).is_empty());
}
#[test]
fn discover_cargo_workspace_member_names_reads_declared_members() {
let root = tempdir().unwrap();
write(
root.path(),
"Cargo.toml",
r#"
[workspace]
members = ["crates/a", "crates/b"]
"#,
);
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n",
);
write(
root.path(),
"crates/b/Cargo.toml",
"[package]\nname = \"b\"\n",
);
let names = discover_cargo_workspace_member_names(root.path());
assert_eq!(names, ["a", "b"].into_iter().map(String::from).collect());
}
#[test]
fn discover_cargo_workspace_member_names_honors_workspace_exclude() {
let root = tempdir().unwrap();
write(
root.path(),
"Cargo.toml",
"[workspace]\nmembers = [\"crates/*\"]\nexclude = [\"crates/broken\"]\n",
);
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n",
);
write(
root.path(),
"crates/broken/Cargo.toml",
"[package]\nname = \"broken\"\n",
);
let names = discover_cargo_workspace_member_names(root.path());
assert_eq!(names, HashSet::from(["a".to_string()]));
}
#[test]
fn discover_cargo_workspace_member_names_expands_glob_members() {
let root = tempdir().unwrap();
write(
root.path(),
"Cargo.toml",
"[workspace]\nmembers = [\"crates/*\"]\n",
);
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n",
);
write(
root.path(),
"crates/b/Cargo.toml",
"[package]\nname = \"b\"\n",
);
let names = discover_cargo_workspace_member_names(root.path());
assert_eq!(names, ["a", "b"].into_iter().map(String::from).collect());
}
#[test]
fn discover_cargo_workspace_member_names_single_crate_yields_own_name_only() {
let root = tempdir().unwrap();
write(root.path(), "Cargo.toml", "[package]\nname = \"solo\"\n");
let names = discover_cargo_workspace_member_names(root.path());
assert_eq!(names, HashSet::from(["solo".to_string()]));
}
#[test]
fn discover_cargo_workspace_member_names_missing_cargo_toml_is_empty() {
let root = tempdir().unwrap();
assert!(discover_cargo_workspace_member_names(root.path()).is_empty());
}
#[test]
fn derive_depends_on_from_cargo_toml_reads_intra_workspace_deps() {
let root = tempdir().unwrap();
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n[dependencies]\nb.workspace = true\nserde = \"1\"\n",
);
let members: HashSet<String> = ["a", "b"].into_iter().map(String::from).collect();
let deps = derive_depends_on_from_cargo_toml(&root.path().join("crates/a"), &members);
assert_eq!(deps, vec!["b".to_string()]);
}
#[test]
fn derive_depends_on_from_cargo_toml_reads_build_dependencies() {
let root = tempdir().unwrap();
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n[build-dependencies]\nbuild-helper.workspace = true\n",
);
let members: HashSet<String> = ["a", "build-helper"]
.into_iter()
.map(String::from)
.collect();
let deps = derive_depends_on_from_cargo_toml(&root.path().join("crates/a"), &members);
assert_eq!(deps, vec!["build-helper".to_string()]);
}
#[test]
fn derive_depends_on_from_cargo_toml_reads_target_cfg_dependencies() {
let root = tempdir().unwrap();
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n\
[target.'cfg(windows)'.dependencies]\nwin-helper.workspace = true\n",
);
let members: HashSet<String> = ["a", "win-helper"].into_iter().map(String::from).collect();
let deps = derive_depends_on_from_cargo_toml(&root.path().join("crates/a"), &members);
assert_eq!(deps, vec!["win-helper".to_string()]);
}
#[test]
fn derive_depends_on_from_cargo_toml_excludes_dev_dependencies() {
let root = tempdir().unwrap();
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n[dev-dependencies]\ntest-helper.workspace = true\n",
);
let members: HashSet<String> = ["a", "test-helper"].into_iter().map(String::from).collect();
let deps = derive_depends_on_from_cargo_toml(&root.path().join("crates/a"), &members);
assert!(deps.is_empty());
}
#[test]
fn derive_depends_on_from_cargo_toml_merges_and_dedupes_all_tables() {
let root = tempdir().unwrap();
write(
root.path(),
"crates/a/Cargo.toml",
"[package]\nname = \"a\"\n\
[dependencies]\nshared.workspace = true\nb.workspace = true\n\
[build-dependencies]\nshared.workspace = true\n\
[target.'cfg(unix)'.dependencies]\nc.workspace = true\n",
);
let members: HashSet<String> = ["a", "b", "c", "shared"]
.into_iter()
.map(String::from)
.collect();
let deps = derive_depends_on_from_cargo_toml(&root.path().join("crates/a"), &members);
assert_eq!(
deps,
vec!["b".to_string(), "c".to_string(), "shared".to_string()]
);
}
}