use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::process::Command;
type Graph = BTreeMap<String, BTreeSet<String>>;
fn workspace_root() -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}
fn metadata() -> serde_json::Value {
let out = Command::new(env!("CARGO"))
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--offline",
])
.current_dir(workspace_root())
.output()
.expect("`cargo metadata` must be runnable; this gate may not skip");
assert!(
out.status.success(),
"`cargo metadata` failed, so the registry graph could not be checked:\n{}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("`cargo metadata` emits JSON")
}
fn siblings() -> Vec<(String, Vec<(String, bool, bool)>)> {
let meta = metadata();
let packages = meta["packages"].as_array().expect("packages is an array");
let members: BTreeSet<&str> = packages
.iter()
.map(|p| p["name"].as_str().expect("a package has a name"))
.collect();
packages
.iter()
.map(|p| {
let name = p["name"].as_str().expect("a package has a name").to_owned();
let deps = p["dependencies"]
.as_array()
.expect("dependencies is an array")
.iter()
.filter_map(|d| {
let dep = d["name"].as_str().expect("a dependency has a name");
if !members.contains(dep) {
return None;
}
let is_dev = d["kind"].as_str() == Some("dev");
let versioned = d["req"].as_str() != Some("*");
Some((dep.to_owned(), is_dev, versioned))
})
.collect();
(name, deps)
})
.collect()
}
fn registry_graph() -> Graph {
siblings()
.into_iter()
.map(|(name, deps)| {
let edges = deps
.into_iter()
.filter(|&(_, is_dev, versioned)| !is_dev || versioned)
.map(|(dep, _, _)| dep)
.collect();
(name, edges)
})
.collect()
}
fn find_cycle(graph: &Graph) -> Option<Vec<String>> {
fn walk(
graph: &Graph,
node: &str,
path: &mut Vec<String>,
visiting: &mut BTreeSet<String>,
done: &mut BTreeSet<String>,
) -> Option<Vec<String>> {
if done.contains(node) {
return None;
}
if visiting.contains(node) {
let start = path
.iter()
.position(|n| n == node)
.expect("a node being visited is on the path");
let mut cycle = path[start..].to_vec();
cycle.push(node.to_owned());
return Some(cycle);
}
visiting.insert(node.to_owned());
path.push(node.to_owned());
for next in graph.get(node).into_iter().flatten() {
if let Some(cycle) = walk(graph, next, path, visiting, done) {
return Some(cycle);
}
}
path.pop();
visiting.remove(node);
done.insert(node.to_owned());
None
}
let mut visiting = BTreeSet::new();
let mut done = BTreeSet::new();
for node in graph.keys() {
let mut path = Vec::new();
if let Some(cycle) = walk(graph, node, &mut path, &mut visiting, &mut done) {
return Some(cycle);
}
}
None
}
#[test]
fn no_sibling_dev_dependency_carries_a_version() {
let offenders: Vec<String> = siblings()
.into_iter()
.flat_map(|(name, deps)| {
deps.into_iter()
.filter(|&(_, is_dev, versioned)| is_dev && versioned)
.map(move |(dep, _, _)| {
let mut s = String::new();
s.push_str(&name);
s.push_str("'s [dev-dependencies] on ");
s.push_str(&dep);
s
})
})
.collect();
assert!(
offenders.is_empty(),
"a workspace sibling in [dev-dependencies] must be declared path-only \
(`{{ path = \"../<name>\" }}`), never `{{ workspace = true }}` — the \
shared [workspace.dependencies] entry injects a version, cargo then \
keeps the dev-dep in the published manifest, and it has to resolve on \
crates.io. That is how blue stalled every release from v0.0.13 to \
v0.0.19. Offending declarations:\n {}",
offenders.join("\n ")
);
}
#[test]
fn the_registry_dependency_graph_is_acyclic() {
let graph = registry_graph();
assert_eq!(
graph.len(),
13,
"every workspace member must be in the graph; if this count is wrong \
the rest of this test is measuring the wrong thing"
);
if let Some(cycle) = find_cycle(&graph) {
let rendered: Vec<&str> = cycle.iter().map(String::as_str).collect();
panic!(
"blue's registry dependency graph has a cycle, so no publish order \
exists and `rust-workspace-publish` will abort with \"no progress \
this pass\":\n {}",
rendered.join(" -> ")
);
}
}