use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub file: PathBuf,
pub line: usize,
pub token: String,
pub text: String,
}
impl std::fmt::Display for Hit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}: `{}` — {}", self.file.display(), self.line, self.token, self.text)
}
}
fn rayon_call_tokens() -> Vec<String> {
[
concat!("use ", "rayon"),
concat!("extern crate ", "rayon"),
concat!("rayon", "::"),
concat!(".", "par_iter("),
concat!(".", "into_par_iter("),
concat!(".", "par_iter_mut("),
concat!(".", "par_bridge("),
concat!(".", "par_chunks("),
concat!(".", "par_chunks_mut("),
concat!(".", "par_sort("),
concat!(".", "par_sort_by("),
concat!(".", "par_sort_unstable("),
concat!(".", "par_extend("),
concat!("Parallel", "Iterator"),
concat!("IntoParallel", "Iterator"),
concat!("ThreadPool", "Builder"),
]
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn rayon_dep_key() -> String {
concat!("ray", "on").to_string()
}
fn strip_line_comment(line: &str) -> &str {
match line.find("//") {
Some(i) => &line[..i],
None => line,
}
}
pub fn workspace_root(start: &Path) -> PathBuf {
let mut found = start.to_path_buf();
let mut cursor = Some(start);
while let Some(dir) = cursor {
let manifest = dir.join("Cargo.toml");
if std::fs::read_to_string(&manifest)
.map(|text| text.lines().any(|l| l.trim() == "[workspace]"))
.unwrap_or(false)
{
found = dir.to_path_buf();
}
cursor = dir.parent();
}
found
}
pub fn workspace_manifests(root: &Path) -> Vec<PathBuf> {
let mut out = vec![root.join("Cargo.toml")];
for member in first_party_dirs(root) {
let m = root.join(&member).join("Cargo.toml");
if m.exists() {
out.push(m);
}
}
out
}
pub fn workspace_members(root: &Path) -> Vec<String> {
manifest_string_array(root, "members")
}
pub fn workspace_excludes(root: &Path) -> Vec<String> {
manifest_string_array(root, "exclude")
}
pub fn first_party_dirs(root: &Path) -> Vec<String> {
let mut out = workspace_members(root);
for e in workspace_excludes(root) {
if !out.contains(&e) {
out.push(e);
}
}
out
}
fn manifest_string_array(root: &Path, key: &str) -> Vec<String> {
let Ok(text) = std::fs::read_to_string(root.join("Cargo.toml")) else {
return Vec::new();
};
let mut cursor = 0usize;
let mut start = None;
let mut in_workspace = false;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
in_workspace = trimmed.starts_with("[workspace]");
} else if in_workspace {
if let Some(rest) = trimmed.strip_prefix(key) {
if rest.trim_start().starts_with('=') {
start = Some(cursor + (line.len() - trimmed.len()));
break;
}
}
}
cursor += line.len();
}
let Some(start) = start else { return Vec::new() };
let rest: String = text[start..]
.lines()
.map(|l| l.split('#').next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
let Some(open) = rest.find('[') else { return Vec::new() };
let Some(close) = rest[open..].find(']') else { return Vec::new() };
rest[open + 1..open + close]
.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn source_roots(root: &Path) -> Vec<PathBuf> {
const SUBDIRS: &[&str] = &["src", "tests", "benches", "examples"];
let mut dirs: Vec<PathBuf> = Vec::new();
let push_for = |base: PathBuf, dirs: &mut Vec<PathBuf>| {
for sub in SUBDIRS {
let p = base.join(sub);
if p.is_dir() {
dirs.push(p);
}
}
};
push_for(root.to_path_buf(), &mut dirs);
for member in first_party_dirs(root) {
push_for(root.join(member), &mut dirs);
}
dirs
}
fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
if path.file_name().map(|n| n == "target").unwrap_or(false) {
continue;
}
rs_files(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Scan {
pub hits: Vec<Hit>,
pub scanned: usize,
}
pub fn scan_source(root: &Path) -> Scan {
let tokens = rayon_call_tokens();
let mut files = Vec::new();
for dir in source_roots(root) {
rs_files(&dir, &mut files);
}
let mut hits = Vec::new();
for file in &files {
let Ok(text) = std::fs::read_to_string(file) else { continue };
for (i, raw) in text.lines().enumerate() {
let code = strip_line_comment(raw);
for tok in &tokens {
if code.contains(tok.as_str()) {
hits.push(Hit {
file: file.clone(),
line: i + 1,
token: tok.clone(),
text: code.trim().to_string(),
});
}
}
}
}
Scan { hits, scanned: files.len() }
}
pub fn scan_manifests(root: &Path) -> Scan {
let key = rayon_dep_key();
let manifests = workspace_manifests(root);
let mut hits = Vec::new();
for manifest in &manifests {
let Ok(text) = std::fs::read_to_string(manifest) else { continue };
let mut in_deps = false;
for (i, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.starts_with('[') {
in_deps = line.contains("dependencies]");
continue;
}
if !in_deps {
continue;
}
let code = strip_line_comment(line);
let name = code.split('=').next().unwrap_or("").trim().trim_matches('"');
if name == key {
hits.push(Hit {
file: manifest.clone(),
line: i + 1,
token: key.clone(),
text: code.trim().to_string(),
});
}
}
}
Scan { hits, scanned: manifests.len() }
}
pub fn cargo_metadata(root: &Path) -> Result<serde_json::Value, String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let out = Command::new(cargo)
.args(["metadata", "--format-version", "1", "--offline", "--locked"])
.current_dir(root)
.output()
.map_err(|e| format!("could not run `cargo metadata` in {}: {e}", root.display()))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(format!(
"`cargo metadata --offline --locked` failed in {} ({}):\n{}",
root.display(),
out.status,
stderr.lines().take(8).collect::<Vec<_>>().join("\n"),
));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata is not JSON: {e}"))
}
fn id_to_name(md: &serde_json::Value) -> std::collections::HashMap<String, String> {
md["packages"]
.as_array()
.map(|ps| {
ps.iter()
.filter_map(|p| {
Some((p["id"].as_str()?.to_string(), p["name"].as_str()?.to_string()))
})
.collect()
})
.unwrap_or_default()
}
fn edge_kinds(dep: &serde_json::Value) -> Vec<String> {
dep["dep_kinds"]
.as_array()
.map(|ks| {
ks.iter()
.map(|k| k["kind"].as_str().unwrap_or("normal").to_string())
.collect()
})
.unwrap_or_default()
}
fn chain_to(
md: &serde_json::Value,
target_name: &str,
require_non_normal: bool,
) -> Option<Vec<String>> {
let names = id_to_name(md);
let nodes: std::collections::HashMap<&str, &serde_json::Value> = md["resolve"]["nodes"]
.as_array()?
.iter()
.filter_map(|n| Some((n["id"].as_str()?, n)))
.collect();
let members: Vec<&str> =
md["workspace_members"].as_array()?.iter().filter_map(|m| m.as_str()).collect();
let mut seen: std::collections::HashSet<(&str, bool)> = std::collections::HashSet::new();
let mut queue: VecDeque<(&str, bool, Vec<String>)> = VecDeque::new();
for m in members {
if seen.insert((m, false)) {
let label = names.get(m).cloned().unwrap_or_else(|| m.to_string());
queue.push_back((m, false, vec![label]));
}
}
while let Some((id, crossed, path)) = queue.pop_front() {
if names.get(id).map(|n| n == target_name).unwrap_or(false)
&& path.len() > 1
&& (crossed || !require_non_normal)
{
return Some(path);
}
let Some(node) = nodes.get(id) else { continue };
let Some(deps) = node["deps"].as_array() else { continue };
for dep in deps {
let kinds = edge_kinds(dep);
let has_normal = kinds.iter().any(|k| k == "normal");
let has_other = kinds.iter().any(|k| k == "dev" || k == "build");
let Some(pkg) = dep["pkg"].as_str() else { continue };
let mut onward: Vec<bool> = Vec::new();
if has_normal {
onward.push(crossed);
}
if require_non_normal && has_other {
onward.push(true);
}
for next_crossed in onward {
if !seen.insert((pkg, next_crossed)) {
continue;
}
let mut next = path.clone();
next.push(names.get(pkg).cloned().unwrap_or_else(|| pkg.to_string()));
queue.push_back((pkg, next_crossed, next));
}
}
}
None
}
pub fn overapproximated_normal_chain(md: &serde_json::Value) -> Option<Vec<String>> {
chain_to(md, &rayon_dep_key(), false)
}
pub fn unshipped_rayon_chain(md: &serde_json::Value) -> Option<Vec<String>> {
chain_to(md, &rayon_dep_key(), true)
}
pub fn rayon_tree(root: &Path, edges: &str, extra: &[&str]) -> Result<String, String> {
dep_tree(root, &rayon_dep_key(), edges, extra)
}
pub fn dep_tree(root: &Path, krate: &str, edges: &str, extra: &[&str]) -> Result<String, String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
cmd.args(["tree", "-i", krate, "--target", "all", "-e", edges])
.args(["--offline", "--locked"])
.args(extra)
.current_dir(root);
let out = cmd
.output()
.map_err(|e| format!("could not run `cargo tree` in {}: {e}", root.display()))?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
return Err(format!(
"`cargo tree -e {edges} -i {krate} --target all {}` failed in {} ({}):\n{}",
extra.join(" "),
root.display(),
out.status,
stderr.lines().take(8).collect::<Vec<_>>().join("\n"),
));
}
Ok(if stdout.trim().is_empty() { stderr } else { stdout })
}
pub fn tree_found_nothing(out: &str) -> bool {
out.trim().is_empty() || out.contains("nothing to print")
}
pub fn tree_chain_to_first_party(out: &str, root: &Path) -> Option<Vec<String>> {
let here = format!("({}", root.display());
let mut stack: Vec<String> = Vec::new();
for line in out.lines() {
let prefix: String =
line.chars().take_while(|c| matches!(c, '│' | ' ' | '├' | '└' | '─')).collect();
let body = &line[prefix.len()..];
if body.is_empty() || body.starts_with('[') {
continue;
}
let depth = prefix.chars().count() / 4;
let Some(name) = body.split_whitespace().next().map(str::to_string) else { continue };
stack.truncate(depth);
stack.push(name);
if body.contains(&here) {
let mut chain = stack.clone();
chain.reverse();
return Some(chain);
}
}
None
}
pub fn shipped_rayon_tree(root: &Path) -> Result<Option<Vec<String>>, String> {
shipped_rayon_tree_with(root, &[])
}
pub fn shipped_rayon_tree_with(
root: &Path,
features: &[&str],
) -> Result<Option<Vec<String>>, String> {
let out = rayon_tree(root, "normal", features)?;
if tree_found_nothing(&out) {
return Ok(None);
}
Ok(Some(tree_chain_to_first_party(&out, root).unwrap_or_else(|| {
out.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect()
})))
}
pub fn only_feature(name: &str) -> Vec<String> {
vec!["--no-default-features".into(), "--features".into(), name.into()]
}
pub fn rayon_tree_liveness_chain(root: &Path) -> Result<Option<Vec<String>>, String> {
let out = rayon_tree(root, "normal,dev,build", &[])?;
if tree_found_nothing(&out) {
return Ok(None);
}
Ok(tree_chain_to_first_party(&out, root))
}
pub fn rayon_tree_all_features_chain(root: &Path) -> Result<Option<Vec<String>>, String> {
let out = rayon_tree(root, "normal", &["--all-features"])?;
if tree_found_nothing(&out) {
return Ok(None);
}
Ok(tree_chain_to_first_party(&out, root))
}
pub fn workspace_features(md: &serde_json::Value, pkg: &str) -> Option<Vec<String>> {
let names = id_to_name(md);
let mut out: Vec<String> = Vec::new();
let mut found = false;
for node in md["resolve"]["nodes"].as_array()? {
let id = node["id"].as_str()?;
if names.get(id).map(|n| n != pkg).unwrap_or(true) {
continue;
}
found = true;
if let Some(fs) = node["features"].as_array() {
out.extend(fs.iter().filter_map(|f| f.as_str().map(str::to_string)));
}
}
if !found {
return None;
}
out.sort();
out.dedup();
Some(out)
}
#[derive(Debug, Clone)]
pub struct GatedTestFile {
pub file: PathBuf,
pub feature: String,
pub tests: usize,
pub whole_file: bool,
}
pub fn gated_test_files(crate_dir: &Path) -> Vec<GatedTestFile> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(crate_dir.join("tests")) else { return out };
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else { continue };
let lines: Vec<&str> = text.lines().collect();
let is_test_attr =
|l: &str| l.trim().starts_with("#[test]") || l.trim().starts_with("#[tokio::test");
let crate_feats: Vec<String> = lines
.iter()
.filter(|l| l.trim_start().starts_with("#![cfg("))
.flat_map(|l| required_features_of_cfg(l))
.collect();
if !crate_feats.is_empty() {
let tests = lines.iter().filter(|l| is_test_attr(l)).count();
let mut seen: Vec<String> = Vec::new();
for f in crate_feats {
if seen.contains(&f) {
continue;
}
seen.push(f.clone());
out.push(GatedTestFile { file: path.clone(), feature: f, tests, whole_file: true });
}
continue;
}
let mut per_feature: Vec<(String, usize)> = Vec::new();
for (i, l) in lines.iter().enumerate() {
if !is_test_attr(l) {
continue;
}
let mut feats: Vec<String> = Vec::new();
let mut j = i;
while j > 0 && lines[j - 1].trim_start().starts_with("#[") {
j -= 1;
feats.extend(required_features_of_cfg(lines[j]));
}
let mut k = i + 1;
while k < lines.len() && lines[k].trim_start().starts_with("#[") {
feats.extend(required_features_of_cfg(lines[k]));
k += 1;
}
feats.sort();
feats.dedup();
for f in feats {
match per_feature.iter_mut().find(|(g, _)| *g == f) {
Some((_, n)) => *n += 1,
None => per_feature.push((f, 1)),
}
}
}
for (feature, tests) in per_feature {
out.push(GatedTestFile { file: path.clone(), feature, tests, whole_file: false });
}
}
out.sort_by(|a, b| (&a.file, &a.feature).cmp(&(&b.file, &b.feature)));
out
}
pub fn any_feature_cfgs(crate_dir: &Path) -> Vec<(PathBuf, String)> {
let mut out = Vec::new();
let Ok(rd) = std::fs::read_dir(crate_dir.join("tests")) else { return out };
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else { continue };
for l in text.lines() {
let t = l.trim_start();
if (t.starts_with("#[cfg(") || t.starts_with("#![cfg(")) && t.contains("any(") && t.contains("feature") {
out.push((path.clone(), t.to_string()));
}
}
}
out
}
fn required_features_of_cfg(line: &str) -> Vec<String> {
let t = line.trim();
if !(t.starts_with("#[cfg(") || t.starts_with("#![cfg(")) {
return Vec::new();
}
let mut out = Vec::new();
let bytes = t.as_bytes();
let mut depth_stack: Vec<bool> = Vec::new(); let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'(' {
let before = &t[..i];
let neg = before.ends_with("not") || before.ends_with("any");
depth_stack.push(neg);
i += 1;
continue;
}
if bytes[i] == b')' {
depth_stack.pop();
i += 1;
continue;
}
if t[i..].starts_with("feature") {
let rest = t[i + "feature".len()..].trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('"') {
if let Some(end) = rest.find('"') {
let relaxed = depth_stack.iter().skip(1).any(|n| *n);
if !relaxed {
out.push(rest[..end].to_string());
}
}
}
}
i += "feature".len();
continue;
}
i += 1;
}
out
}
fn attr_changes_existence(name: &str) -> bool {
matches!(name, "cfg" | "cfg_attr") || attr_is_test(name)
}
fn attr_is_test(name: &str) -> bool {
let last = name.rsplit("::").next().unwrap_or(name);
last == "test"
|| last.ends_with("_test")
|| matches!(last, "bench" | "rstest" | "proptest" | "test_case" | "quickcheck")
|| matches!(name, "ignore" | "should_panic")
}
fn attr_is_non_repeatable(name: &str) -> bool {
let last = name.rsplit("::").next().unwrap_or(name);
matches!(
last,
"test"
| "bench"
| "rstest"
| "ignore"
| "should_panic"
| "no_mangle"
| "global_allocator"
| "panic_handler"
| "proc_macro"
| "proc_macro_derive"
| "proc_macro_attribute"
)
}
fn attr_path(line: &str) -> Option<String> {
let t = line.trim_start();
let rest = t.strip_prefix("#[").or_else(|| t.strip_prefix("#!["))?;
let mut out = String::new();
for c in rest.chars() {
if c.is_alphanumeric() || c == '_' || c == ':' {
out.push(c);
} else if c.is_whitespace() && out.is_empty() {
continue;
} else {
break;
}
}
if out.is_empty() { None } else { Some(out) }
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Piece {
Attr(usize, String, String),
Doc(usize),
}
fn parse_block(lines: &[&str], start: usize) -> (Vec<Piece>, usize) {
let mut pieces = Vec::new();
let mut i = start;
while i < lines.len() {
let t = lines[i].trim_start();
if t.starts_with("#[") {
let mut depth: i32 = 0;
let mut end = i;
let mut tail_is_clean = false;
'outer: for (k, l) in lines.iter().enumerate().skip(i) {
for (ci, c) in l.char_indices() {
if c == '[' {
depth += 1;
} else if c == ']' {
depth -= 1;
if depth == 0 {
end = k;
let tail = l[ci + 1..].trim();
tail_is_clean = tail.is_empty() || tail.starts_with("//");
break 'outer;
}
}
}
}
if !tail_is_clean {
break;
}
let text: String = lines[i..=end].join(" ").split_whitespace().collect::<Vec<_>>().join(" ");
let path = attr_path(t).unwrap_or_default();
pieces.push(Piece::Attr(i + 1, path, text));
i = end + 1;
continue;
}
if t.starts_with("///") {
pieces.push(Piece::Doc(i + 1));
i += 1;
continue;
}
if t.starts_with("//") || t.is_empty() {
i += 1;
continue;
}
break;
}
(pieces, i)
}
fn cfg_test_mod_ranges(lines: &[&str]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
for (i, l) in lines.iter().enumerate() {
if !l.trim_start().starts_with("#[cfg(test)]") {
continue;
}
let (_, item) = parse_block(lines, i);
let Some(head) = lines.get(item) else { continue };
let h = head.trim_start();
if !(h.starts_with("mod ") || h.starts_with("pub mod ")) {
continue;
}
let mut depth: i32 = 0;
let mut opened = false;
for (k, l) in lines.iter().enumerate().skip(item) {
depth += l.matches('{').count() as i32 - l.matches('}').count() as i32;
if l.contains('{') {
opened = true;
}
if opened && depth <= 0 {
out.push((item, k));
break;
}
}
}
out
}
fn zero_arg_fn_name(line: &str) -> Option<&str> {
let t = line.trim_start();
let t = t.strip_prefix("pub ").unwrap_or(t).trim_start();
let t = t.strip_prefix("async ").unwrap_or(t).trim_start();
let t = t.strip_prefix("unsafe ").unwrap_or(t).trim_start();
let rest = t.strip_prefix("fn ")?;
let name_end = rest.find(|c: char| !(c.is_alphanumeric() || c == '_'))?;
let (name, tail) = rest.split_at(name_end);
let tail = tail.trim_start();
if !tail.starts_with("()") {
return None;
}
if name.is_empty() { None } else { Some(name) }
}
pub fn attribute_hits(path: &Path, text: &str) -> Vec<Hit> {
let lines: Vec<&str> = text.lines().collect();
let mut hits = Vec::new();
let p = path.to_string_lossy().replace('\\', "/");
let is_support = ["/tests/", "/benches/"].iter().any(|d| {
p.rsplit_once(d).is_some_and(|(_, rest)| rest.contains('/'))
});
let is_test_surface = p.contains("/tests/") || p.contains("/benches/");
let test_mods = cfg_test_mod_ranges(&lines);
let mut i = 0usize;
while i < lines.len() {
let t = lines[i].trim_start();
let starts_block = t.starts_with("#[") || t.starts_with("///");
if !starts_block {
if let Some(name) = zero_arg_fn_name(lines[i]) {
maybe_orphan_test(
path, &lines, text, i, name, &[], is_support, is_test_surface, &test_mods,
&mut hits,
);
}
i += 1;
continue;
}
let (pieces, item) = parse_block(&lines, i);
if pieces.is_empty() {
i += 1;
continue;
}
let item_line = lines.get(item).copied().unwrap_or("<end of file>");
let mut by_name: Vec<(usize, &str)> = Vec::new();
let mut by_text: Vec<(usize, &str)> = Vec::new();
for piece in &pieces {
let Piece::Attr(ln, name, txt) = piece else { continue };
if attr_is_non_repeatable(name) {
if let Some((first, _)) = by_name.iter().find(|(_, n)| *n == name) {
hits.push(Hit {
file: path.to_path_buf(),
line: *ln,
token: format!("duplicate #[{name}]"),
text: format!(
"`{item_line}` carries #[{name}] twice (also line {first}) — the first \
one lost its item: whatever it was written for is now unattributed"
),
});
}
by_name.push((*ln, name));
}
if let Some((first, _)) = by_text.iter().find(|(_, x)| *x == txt) {
hits.push(Hit {
file: path.to_path_buf(),
line: *ln,
token: format!("duplicate {txt}"),
text: format!(
"`{item_line}` carries the identical attribute twice (also line {first}) — \
the first one was written for a different item"
),
});
}
by_text.push((*ln, txt));
}
let mut gate: Option<(usize, &str)> = None;
for piece in &pieces {
match piece {
Piece::Attr(ln, name, txt) if attr_changes_existence(name) => {
gate = Some((*ln, txt));
}
Piece::Doc(ln) => {
if let Some((gl, gtxt)) = gate {
hits.push(Hit {
file: path.to_path_buf(),
line: gl,
token: format!("gate above a doc comment {gtxt}"),
text: format!(
"{gtxt} at line {gl} sits ABOVE the doc comment at line {ln}, and \
therefore gates `{item_line}` — check that is the item it was \
written for, then put the gate directly on it, below its doc"
),
});
break;
}
}
_ => {}
}
}
if let Some(name) = zero_arg_fn_name(item_line) {
maybe_orphan_test(
path, &lines, text, item, name, &pieces, is_support, is_test_surface, &test_mods,
&mut hits,
);
}
i = item + 1;
}
hits
}
#[allow(clippy::too_many_arguments)]
fn maybe_orphan_test(
path: &Path,
lines: &[&str],
text: &str,
item: usize,
name: &str,
pieces: &[Piece],
is_support: bool,
is_test_surface: bool,
test_mods: &[(usize, usize)],
hits: &mut Vec<Hit>,
) {
if is_support {
return;
}
if !is_test_surface && !test_mods.iter().any(|(a, b)| item >= *a && item <= *b) {
return;
}
if name.matches('_').count() < 2 {
return;
}
for piece in pieces {
let Piece::Attr(_, attr_name, txt) = piece else { continue };
if attr_is_test(attr_name) || txt.contains("dead_code") {
return;
}
}
let mentions = text.match_indices(name).filter(|(i, _)| {
let before = text[..*i].chars().next_back();
let after = text[i + name.len()..].chars().next();
!before.is_some_and(|c| c.is_alphanumeric() || c == '_')
&& !after.is_some_and(|c| c.is_alphanumeric() || c == '_')
});
if mentions.count() > 1 {
return;
}
let _ = lines;
hits.push(Hit {
file: path.to_path_buf(),
line: item + 1,
token: "test function with no #[test]".to_string(),
text: format!(
"`fn {name}()` sits in a test scope, is named like a test, carries no test \
attribute and is called by nothing — it does not run, and the suite is green"
),
});
}
pub fn scan_attributes(root: &Path) -> Scan {
let mut files = Vec::new();
for dir in source_roots(root) {
rs_files(&dir, &mut files);
}
let mut hits = Vec::new();
for file in &files {
let Ok(text) = std::fs::read_to_string(file) else { continue };
hits.extend(attribute_hits(file, &text));
}
Scan { hits, scanned: files.len() }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EscapingDep {
pub name: String,
pub manifest: PathBuf,
pub declared: String,
pub resolved: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SiblingState {
Missing,
Untracked,
Detached { head: String },
NoOriginHead,
Drifted { head: String, expected: String, behind: usize, ahead: usize },
Aligned { head: String, dirty: usize },
}
impl SiblingState {
pub fn is_trustworthy(&self) -> bool {
matches!(self, SiblingState::Aligned { dirty: 0, .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sibling {
pub dep: EscapingDep,
pub checkout: Option<PathBuf>,
pub state: SiblingState,
}
impl Sibling {
pub fn describe(&self) -> String {
let where_ = self.checkout.clone().unwrap_or_else(|| self.dep.resolved.clone());
match &self.state {
SiblingState::Missing => {
format!("{} — MISSING — {} declares path {:?}, nothing is there", self.dep.name, self.dep.manifest.display(), self.dep.declared)
}
SiblingState::Untracked => {
format!("{} — UNTRACKED — {} is in no git checkout, so no ref pins it", self.dep.name, where_.display())
}
SiblingState::Detached { head } => {
format!("{} — DETACHED — {} is at {head} with no branch; nothing says which ref that is", self.dep.name, where_.display())
}
SiblingState::NoOriginHead => {
format!("{} — NO-ORIGIN-HEAD — {} cannot name its own canonical branch", self.dep.name, where_.display())
}
SiblingState::Drifted { head, expected, behind, ahead } => {
format!(
"{} — DRIFTED — {} is at {head}, origin/HEAD is {expected} (behind {behind}, ahead {ahead}); this build links THAT, not the ref you think",
self.dep.name,
where_.display()
)
}
SiblingState::Aligned { head, dirty } => {
format!("{} — DIRTY — {} is at origin/HEAD {head} but carries {dirty} uncommitted change(s)", self.dep.name, where_.display())
}
}
}
}
pub fn declared_path_deps(root: &Path) -> Vec<EscapingDep> {
let mut out = Vec::new();
for manifest in workspace_manifests(root) {
let Ok(text) = std::fs::read_to_string(&manifest) else { continue };
let dir = manifest.parent().unwrap_or(root).to_path_buf();
for (name, declared) in path_deps_in_manifest(&text) {
let resolved = normalise(&dir.join(&declared));
out.push(EscapingDep { name, manifest: manifest.clone(), declared, resolved });
}
}
out
}
pub fn escaping_path_deps(root: &Path) -> Vec<EscapingDep> {
let root = normalise(root);
declared_path_deps(&root).into_iter().filter(|d| !d.resolved.starts_with(&root)).collect()
}
pub fn path_deps_in_manifest(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut section_key: Option<String> = None;
for line in text.lines() {
let t = line.trim();
if t.starts_with('#') {
continue;
}
if t.starts_with('[') {
let head = t.trim_start_matches('[').trim_end_matches(']');
let last = head.rsplit('.').next().unwrap_or("").trim().trim_matches('"');
section_key = match last {
"dependencies" | "dev-dependencies" | "build-dependencies" | "crates-io"
| "workspace" | "package" | "features" | "lib" | "bin" | "patch" => None,
other if other.is_empty() => None,
other => Some(other.to_string()),
};
continue;
}
let Some(value) = quoted_value_of(t, "path") else { continue };
let name = match t.split_once('=') {
Some((k, rest)) if rest.trim_start().starts_with('{') => {
k.trim().trim_matches('"').to_string()
}
_ => section_key.clone().unwrap_or_else(|| "<unnamed>".to_string()),
};
out.push((name, value));
}
out
}
fn quoted_value_of(line: &str, key: &str) -> Option<String> {
let mut from = 0usize;
while let Some(i) = line[from..].find(key) {
let at = from + i;
let before_ok = at == 0 || !matches!(line.as_bytes()[at - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.');
let rest = &line[at + key.len()..];
let after = rest.trim_start();
if before_ok && after.starts_with('=') {
let v = after[1..].trim_start();
if let Some(stripped) = v.strip_prefix('"') {
if let Some(end) = stripped.find('"') {
return Some(stripped[..end].to_string());
}
}
}
from = at + key.len();
}
None
}
fn normalise(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !out.pop() && !out.has_root() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn checkout_root(p: &Path) -> Option<PathBuf> {
let mut cursor = Some(p);
while let Some(dir) = cursor {
if dir.join(".git").exists() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
fn git(repo: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git").arg("-C").arg(repo).args(args).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn checkout_state(repo: &Path) -> SiblingState {
let branch = git(repo, &["symbolic-ref", "--quiet", "--short", "HEAD"]);
let head = git(repo, &["rev-parse", "HEAD"]).unwrap_or_default();
let Some(origin_head) = git(repo, &["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]) else {
return SiblingState::NoOriginHead;
};
let expected = git(repo, &["rev-parse", &origin_head]).unwrap_or_default();
let dirty = git(repo, &["status", "--porcelain"])
.map(|s| s.lines().filter(|l| !l.trim().is_empty()).count())
.unwrap_or(0);
if head == expected && !head.is_empty() {
return SiblingState::Aligned { head: short(&head), dirty };
}
if branch.is_none() {
return SiblingState::Detached { head: short(&head) };
}
let (ahead, behind) = git(repo, &["rev-list", "--left-right", "--count", &format!("HEAD...{origin_head}")])
.and_then(|s| {
let mut it = s.split_whitespace();
Some((it.next()?.parse().ok()?, it.next()?.parse().ok()?))
})
.unwrap_or((0usize, 0usize));
SiblingState::Drifted { head: short(&head), expected: short(&expected), behind, ahead }
}
fn short(sha: &str) -> String {
sha.chars().take(12).collect()
}
pub fn sibling_survey(root: &Path) -> Vec<Sibling> {
let mut seen: Vec<PathBuf> = Vec::new();
let mut out = Vec::new();
for dep in escaping_path_deps(root) {
let checkout = checkout_root(&dep.resolved);
let state = match &checkout {
_ if !dep.resolved.exists() => SiblingState::Missing,
None => SiblingState::Untracked,
Some(repo) => {
if seen.contains(repo) {
continue;
}
seen.push(repo.clone());
checkout_state(repo)
}
};
out.push(Sibling { dep, checkout, state });
}
out
}
pub fn sibling_offenders(root: &Path) -> Vec<Sibling> {
sibling_survey(root).into_iter().filter(|s| !s.state.is_trustworthy()).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unresolvable {
pub root: PathBuf,
pub reason: String,
}
impl Unresolvable {
#[must_use]
pub fn describe(&self) -> String {
format!("{} does not resolve: {}", self.root.display(), self.reason)
}
}
pub fn resolves(root: &Path) -> Result<(), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let out = Command::new(cargo)
.args(["metadata", "--no-deps", "--format-version", "1"])
.current_dir(root)
.output()
.map_err(|e| format!("could not run `cargo metadata` in {}: {e}", root.display()))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let reason = stderr
.lines()
.find(|l| l.trim_start().starts_with("error"))
.or_else(|| stderr.lines().find(|l| !l.trim().is_empty()))
.unwrap_or("cargo metadata failed with no output")
.trim()
.to_string();
Err(reason)
}
pub fn unresolvable(roots: &[PathBuf]) -> Vec<Unresolvable> {
roots
.iter()
.filter(|r| r.join("Cargo.toml").is_file())
.filter_map(|r| resolves(r).err().map(|reason| Unresolvable { root: r.clone(), reason }))
.collect()
}
#[cfg(test)]
mod resolution_tests {
use super::*;
fn repo(dir: &Path, manifest: &str) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("Cargo.toml"), manifest).unwrap();
std::fs::write(dir.join("src/lib.rs"), "").unwrap();
dir.to_path_buf()
}
#[test]
fn a_feature_naming_a_feature_that_does_not_exist_does_not_resolve() {
let tmp = std::env::temp_dir().join(format!("law-resolves-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let good = repo(
&tmp.join("good"),
"[package]\nname = \"good\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
);
assert!(resolves(&good).is_ok(), "a plain repo resolves");
let bad = repo(
&tmp.join("bad"),
"[package]\nname = \"bad\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
[features]\nwarehouse-sql = [\"nope/sql\"]\n",
);
let err = resolves(&bad).expect_err("a feature naming a missing dependency cannot resolve");
assert!(
err.to_lowercase().contains("error"),
"the reason is cargo's own first line, kept verbatim: {err}"
);
let bad_list = unresolvable(&[good.clone(), bad.clone()]);
assert_eq!(bad_list.len(), 1, "one of the two does not resolve: {bad_list:?}");
assert_eq!(bad_list[0].root, bad);
assert!(bad_list[0].describe().contains("does not resolve"));
let plain = tmp.join("not-a-repo");
std::fs::create_dir_all(&plain).unwrap();
assert!(unresolvable(&[plain]).is_empty(), "a non-repo directory is not a failure");
let _ = std::fs::remove_dir_all(&tmp);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cfg_names_the_features_it_requires_and_no_others() {
let f = |l: &str| required_features_of_cfg(l);
assert_eq!(f(r#"#![cfg(feature = "wgpu")]"#), ["wgpu"]);
assert_eq!(f(r#"#[cfg(feature = "wgpu")]"#), ["wgpu"]);
assert_eq!(
f(r#"#![cfg(all(feature = "gfx-v2-gpu", not(target_arch = "wasm32")))]"#),
["gfx-v2-gpu"]
);
assert_eq!(f(r#"#![cfg(all(feature = "a", feature = "b"))]"#), ["a", "b"]);
assert!(f(r#"#![cfg(not(feature = "wgpu"))]"#).is_empty());
assert!(f(r#"#[cfg(any(feature = "a", feature = "b"))]"#).is_empty());
assert!(f(r#"#![cfg(not(target_arch = "wasm32"))]"#).is_empty());
assert!(f(r#"#[test]"#).is_empty());
assert!(f(r#"// #![cfg(feature = "wgpu")]"#).is_empty());
}
#[test]
fn the_scanner_finds_per_fn_gates_as_well_as_whole_file_ones() {
let dir = std::env::temp_dir().join(format!("facett_law_gated_{}", std::process::id()));
let tests = dir.join("tests");
std::fs::create_dir_all(&tests).expect("scratch dir");
std::fs::write(
tests.join("whole.rs"),
"#![cfg(all(feature = \"gfx\", not(target_arch = \"wasm32\")))]\n\
#[test]\nfn a() {}\n#[test]\nfn b() {}\n",
)
.unwrap();
std::fs::write(
tests.join("partial.rs"),
"#[test]\nfn cpu_one() {}\n\
#[cfg(feature = \"wgpu\")]\n#[test]\nfn gpu_before() {}\n\
#[test]\n#[cfg(feature = \"wgpu\")]\nfn gpu_after() {}\n\
#[test]\nfn cpu_two() {}\n",
)
.unwrap();
std::fs::write(tests.join("plain.rs"), "#[test]\nfn only() {}\n").unwrap();
let found = gated_test_files(&dir);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(found.len(), 2, "one whole-file entry and one per-fn entry: {found:#?}");
let whole = found.iter().find(|g| g.whole_file).expect("the crate-level cfg was seen");
assert_eq!(whole.feature, "gfx", "an `all(feature = …, not(…))` still names its feature");
assert_eq!(whole.tests, 2, "the whole target's fns go silent");
let partial = found.iter().find(|g| !g.whole_file).expect("the per-fn cfgs were seen");
assert_eq!(partial.feature, "wgpu");
assert_eq!(
partial.tests, 2,
"both `#[cfg]`-before-`#[test]` and `#[test]`-before-`#[cfg]` are gated fns"
);
assert!(
found.iter().all(|g| !g.file.ends_with("plain.rs")),
"an ungated file must not be reported as gated — that would make the guard cry wolf"
);
}
#[test]
fn comment_stripping_keeps_code_and_drops_prose() {
assert_eq!(strip_line_comment("let x = 1; // rayon::scope is banned").trim(), "let x = 1;");
assert_eq!(strip_line_comment("//! never rayon").trim(), "");
assert_eq!(strip_line_comment("let y = 2;").trim(), "let y = 2;");
}
#[test]
fn the_forbidden_tokens_are_not_spelled_literally_in_this_file() {
let me = include_str!("law.rs");
for tok in rayon_call_tokens() {
let literal_uses = me
.lines()
.filter(|l| !strip_line_comment(l).trim().is_empty())
.filter(|l| strip_line_comment(l).contains(&tok))
.count();
assert_eq!(literal_uses, 0, "`{tok}` appears literally in live code in law.rs");
}
}
#[test]
fn both_gate_shapes_are_read_and_a_non_cfg_line_is_not() {
assert_eq!(required_features_of_cfg("#![cfg(feature = \"wgpu\")]"), ["wgpu"]);
assert_eq!(required_features_of_cfg(" #![cfg(feature=\"gpu\")] "), ["gpu"]);
assert_eq!(required_features_of_cfg("#[cfg(feature = \"wgpu\")]"), ["wgpu"]);
assert!(required_features_of_cfg("fn main() {}").is_empty());
}
#[test]
fn the_word_members_in_a_header_comment_is_not_the_members_key() {
let dir = scratch("header-comment");
std::fs::write(
dir.join("Cargo.toml"),
"# korp is the workspace ROOT. The `crates/*` leaves are NOT pulled in\n\
# as members — they are self-rooting.\n\
[workspace]\n\
resolver = \"3\"\n\
members = [\"xtask\"]\n\
exclude = [\"crates/korp-ontology\", \"crates/korp-demo\"]\n\
\n\
[package]\n\
name = \"korp\"\n\
exclude = [\"docs/*\"]\n",
)
.unwrap();
assert_eq!(workspace_members(&dir), vec!["xtask".to_string()]);
assert_eq!(
workspace_excludes(&dir),
vec!["crates/korp-ontology".to_string(), "crates/korp-demo".to_string()]
);
assert_eq!(
first_party_dirs(&dir),
vec![
"xtask".to_string(),
"crates/korp-ontology".to_string(),
"crates/korp-demo".to_string()
]
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_trailing_toml_comment_inside_the_array_is_not_a_member() {
let dir = scratch("array-comment");
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"a\", # the first one\n \"b\",\n]\n",
)
.unwrap();
assert_eq!(workspace_members(&dir), vec!["a".to_string(), "b".to_string()]);
std::fs::remove_dir_all(&dir).ok();
}
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("facett-law-{tag}-{}-{:?}", std::process::id(), std::thread::current().id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
const KORP_ALL_FEATURES_TREE: &str = "\
rayon v1.12.0
├── av-scenechange v0.14.1
│ └── rav1e v0.8.1
│ └── ravif v0.13.0
│ └── image v0.25.10
│ ├── arboard v3.6.1
│ │ └── egui-winit v0.35.0
│ │ └── eframe v0.35.0
│ │ ├── korp v0.1.0 (/home/rickard/scratch/lawfix/korp)
│ │ └── nornir-robotui v0.3.0
│ │ └── korp v0.1.0 (/home/rickard/scratch/lawfix/korp)
│ ├── dify v0.8.0
│ │ └── egui_kittest v0.35.0
│ │ └── nornir-robotui v0.3.0 (*)
│ ├── eframe v0.35.0 (*)
│ ├── egui_kittest v0.35.0 (*)
│ ├── facett-about v0.1.13 (/home/rickard/scratch/lawfix/facett/facett-about)
│ │ └── korp v0.1.0 (/home/rickard/scratch/lawfix/korp)
│ ├── korp v0.1.0 (/home/rickard/scratch/lawfix/korp)
│ ├── nornir-robotui v0.3.0 (*)
│ └── webp v0.3.1
│ └── nornir-robotui v0.3.0 (*)
├── dify v0.8.0 (*)
├── image v0.25.10 (*)
├── maybe-rayon v0.1.1
│ └── rav1e v0.8.1 (*)
└── ravif v0.13.0 (*)
";
#[test]
fn the_tree_parser_reads_a_real_violation_chain_all_the_way_to_us() {
assert!(!tree_found_nothing(KORP_ALL_FEATURES_TREE));
let korp = Path::new("/home/rickard/scratch/lawfix/korp");
let chain = tree_chain_to_first_party(KORP_ALL_FEATURES_TREE, korp)
.expect("the chain reaches korp and must be readable");
assert_eq!(
chain,
vec![
"korp",
"eframe",
"egui-winit",
"arboard",
"image",
"ravif",
"rav1e",
"av-scenechange",
"rayon"
]
);
let facett = Path::new("/home/rickard/scratch/lawfix/facett");
let via_facett = tree_chain_to_first_party(KORP_ALL_FEATURES_TREE, facett)
.expect("facett-about is first-party to the facett workspace");
assert_eq!(
via_facett,
vec!["facett-about", "image", "ravif", "rav1e", "av-scenechange", "rayon"]
);
assert_eq!(tree_chain_to_first_party(KORP_ALL_FEATURES_TREE, Path::new("/nowhere")), None);
}
#[test]
fn nothing_to_print_is_the_clean_answer_and_empty_is_not_silently_the_same() {
let clean = "warning: nothing to print.\n\nTo find dependencies that require \
specific target platforms, try to use option `--target all` first.\n";
assert!(tree_found_nothing(clean));
assert!(tree_found_nothing(" \n"));
assert!(!tree_found_nothing(KORP_ALL_FEATURES_TREE));
assert_eq!(tree_chain_to_first_party(clean, Path::new("/home/rickard/scratch/lawfix/korp")), None);
}
#[test]
fn members_parse_from_a_multi_line_array() {
let dir = scratch("multiline");
std::fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\n \"a\",\n \"b\",\n]\n",
)
.unwrap();
assert_eq!(workspace_members(&dir), vec!["a".to_string(), "b".to_string()]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_manifest_names_the_paths_it_escapes_through_and_no_others() {
let text = r#"
[package]
name = "consumer"
# edda = { path = "../commented-out" }
[dependencies]
edda = { path = "../edda" }
facett-core = { version = "0.1", path = "../facett/facett-core" }
in-repo = { path = "crates/in-repo" }
plain = "1.0"
[dependencies.knut]
version = "0.2"
path = "../knut"
[target.'cfg(unix)'.dependencies.tunnr]
path = "../tunnr"
[patch.crates-io]
znippy = { path = "../znippy" }
[build]
rustc-path = "/usr/bin/rustc"
"#;
let got = path_deps_in_manifest(text);
assert_eq!(
got,
vec![
("edda".to_string(), "../edda".to_string()),
("facett-core".to_string(), "../facett/facett-core".to_string()),
("in-repo".to_string(), "crates/in-repo".to_string()),
("knut".to_string(), "../knut".to_string()),
("tunnr".to_string(), "../tunnr".to_string()),
("znippy".to_string(), "../znippy".to_string()),
],
"the commented line, the plain version dep and rustc-path must not appear"
);
}
#[test]
fn a_path_that_does_not_exist_still_normalises() {
assert_eq!(normalise(Path::new("/a/b/../c/./d")), PathBuf::from("/a/c/d"));
assert_eq!(normalise(Path::new("/a/../../b")), PathBuf::from("/b"));
}
fn gitx(repo: &Path, args: &[&str]) {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.env("GIT_AUTHOR_NAME", "law fixture")
.env("GIT_AUTHOR_EMAIL", "law@example.invalid")
.env("GIT_COMMITTER_NAME", "law fixture")
.env("GIT_COMMITTER_EMAIL", "law@example.invalid")
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?} in {} failed: {}",
repo.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn write(p: &Path, text: &str) {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, text).unwrap();
}
#[test]
fn a_sibling_parked_off_origin_head_is_named_before_the_build_can_lie_about_it() {
let root = scratch("siblings");
std::fs::remove_dir_all(&root).ok();
std::fs::create_dir_all(&root).unwrap();
let upstream = root.join("upstream-sib");
std::fs::create_dir_all(&upstream).unwrap();
gitx(&upstream, &["init", "-q", "-b", "main"]);
write(&upstream.join("Cargo.toml"), "[package]\nname = \"sib\"\nversion = \"0.1.0\"\n");
write(&upstream.join("src/lib.rs"), "pub fn v() -> u32 { 1 }\n");
gitx(&upstream, &["add", "Cargo.toml", "src/lib.rs"]);
gitx(&upstream, &["commit", "-qm", "sib v1"]);
let sib = root.join("sib");
std::fs::remove_dir_all(&sib).ok();
gitx(&root, &["clone", "-q", upstream.to_str().unwrap(), "sib"]);
write(&upstream.join("src/lib.rs"), "pub fn v() -> u32 { 2 }\npub fn added() {}\n");
gitx(&upstream, &["commit", "-qam", "sib v2"]);
gitx(&sib, &["fetch", "-q", "origin"]);
let consumer = root.join("consumer");
write(
&consumer.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/app\"]\n\n[package]\nname = \"consumer\"\n\n\
[dependencies]\nsib = { path = \"../sib\" }\napp = { path = \"crates/app\" }\n",
);
write(&consumer.join("crates/app/Cargo.toml"), "[package]\nname = \"app\"\n");
let escaping = escaping_path_deps(&consumer);
assert_eq!(escaping.len(), 1, "only `../sib` leaves the consumer repo, got {escaping:?}");
assert_eq!(escaping[0].name, "sib");
assert_eq!(escaping[0].resolved, normalise(&root.join("sib")));
let bad = sibling_offenders(&consumer);
assert_eq!(bad.len(), 1, "the stale sibling must be reported, got {bad:?}");
match &bad[0].state {
SiblingState::Drifted { behind, ahead, .. } => {
assert_eq!((*behind, *ahead), (1, 0), "one commit behind origin/HEAD, none ahead");
}
other => panic!("expected Drifted, got {other:?}"),
}
assert!(
bad[0].describe().contains("DRIFTED"),
"the one-liner must say which way it failed: {}",
bad[0].describe()
);
assert_eq!(bad[0].checkout.as_deref(), Some(normalise(&root.join("sib")).as_path()));
gitx(&sib, &["merge", "-q", "--ff-only", "origin/main"]);
let good = sibling_offenders(&consumer);
assert!(good.is_empty(), "a fast-forwarded sibling is clean, got {good:?}");
assert!(matches!(
checkout_state(&sib),
SiblingState::Aligned { dirty: 0, .. }
));
gitx(&sib, &["checkout", "-q", "--detach", "origin/main"]);
assert!(
sibling_offenders(&consumer).is_empty(),
"a worktree detached AT origin/HEAD is the sanctioned lane shape"
);
gitx(&sib, &["checkout", "-q", "--detach", "origin/main~1"]);
let det = sibling_offenders(&consumer);
assert_eq!(det.len(), 1);
assert!(matches!(det[0].state, SiblingState::Detached { .. }), "{}", det[0].describe());
gitx(&sib, &["checkout", "-q", "main"]);
write(&sib.join("src/lib.rs"), "pub fn v() -> u32 { 99 }\n");
let dirty = sibling_offenders(&consumer);
assert_eq!(dirty.len(), 1, "uncommitted source under an aligned ref is a different build");
assert!(dirty[0].describe().contains("DIRTY"), "{}", dirty[0].describe());
std::fs::remove_dir_all(&sib).unwrap();
let gone = sibling_offenders(&consumer);
assert_eq!(gone.len(), 1);
assert_eq!(gone[0].state, SiblingState::Missing, "{}", gone[0].describe());
std::fs::remove_dir_all(&root).ok();
}
}