src/test_select.rs
//! **Which tests a commit can break — the half that needs no graph.**
//!
//! Incremental tests (owner 2026-10-17: "yes do incremental tests and
//! coverage"): on a commit, run only the tests whose reach holds a changed
//! function. The code room answers the reach (`Test.Affected`,
//! [`crate::code_room::reader`]); this module answers what a COMMIT changed
//! and what that means before any graph is asked:
//!
//! * [`commit_diff`] — the files two commits differ in, and per Rust file the
//! lines of the NEWER side a hunk touched (a pure removal touches the two
//! lines around it), read in-process through gix;
//! * [`classify_path`] — the paths the graph cannot see through: a manifest,
//! a lock file, a build script, a toolchain pin, cargo / nextest config —
//! every test runs ([`PathRule::All`]);
//! * [`line_kind`] — a changed line no item of the room holds: blank, a
//! comment, a `use` / `mod` declaration (the graph's own diff carries what
//! they move), an attribute, a `cfg` (every test runs), or code the graph
//! does not know;
//! * [`identifiers`] / [`mentions`] — where a name is used AS A VALUE (`.map(f)`,
//! `Type::f` passed on): the producer records no call through a fn pointer,
//! so the selection widens over such a use;
//! * [`runner_name`] — the name cargo test / nextest print for a test (its
//! path without the crate), the spelling a `--skip` takes.
//!
//! The rule everywhere: when the answer cannot be sure, MORE tests run,
//! never fewer — and the one line that says why travels with the answer.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result, anyhow};
/// What happened to a file between the two commits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileChange {
Added,
Deleted,
Modified,
}
/// One file the two commits differ in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiff {
/// Repo-relative, `/`-separated.
pub path: String,
pub change: FileChange,
/// 1-based lines of the NEWER side a hunk touched, ascending, merged
/// (`(start, end)` inclusive). A pure removal touches the line before
/// and the line after it. Empty for a deleted file and a non-Rust file.
pub lines: Vec<(u32, u32)>,
}
impl FileDiff {
/// Every touched line, one by one.
pub fn each_line(&self) -> impl Iterator<Item = u32> + '_ {
self.lines.iter().flat_map(|&(a, b)| a..=b)
}
}
/// The tree of one commit, for reading its files' text.
pub struct Snapshot {
repo: gix::Repository,
tree: gix::ObjectId,
/// Every `.rs` blob of the tree, path → text — read once, on first ask.
rust: std::sync::OnceLock<BTreeMap<String, String>>,
/// Every `Cargo.toml` of the tree, path → text — read once, on first ask.
manifests: std::sync::OnceLock<BTreeMap<String, String>>,
}
impl Snapshot {
/// The tree of `sha` in the repo at `dir`.
pub fn at(dir: &Path, sha: &str) -> Result<Self> {
let repo = gix::open(dir).with_context(|| format!("open {}", dir.display()))?;
let tree = tree_of(&repo, sha)?;
Ok(Self { repo, tree, rust: std::sync::OnceLock::new(), manifests: std::sync::OnceLock::new() })
}
/// The text of `path` in this tree (`None` = not there, or not text).
pub fn text(&self, path: &str) -> Option<String> {
if let Some(t) = self.rust.get().and_then(|m| m.get(path)) {
return Some(t.clone());
}
let tree = self.repo.find_tree(self.tree).ok()?;
let entry = tree.lookup_entry_by_path(path).ok()??;
let blob = entry.object().ok()?;
String::from_utf8(blob.data.clone()).ok()
}
/// Every `.rs` file of the tree, path → text.
pub fn rust_files(&self) -> &BTreeMap<String, String> {
self.rust.get_or_init(|| self.blobs(|p| p.ends_with(".rs")))
}
/// Every `Cargo.toml` of the tree, path → text.
pub fn manifests(&self) -> &BTreeMap<String, String> {
self.manifests.get_or_init(|| self.blobs(|p| p == "Cargo.toml" || p.ends_with("/Cargo.toml")))
}
/// The packages of the tree (every manifest with a `[package]`).
pub fn packages(&self) -> Packages {
Packages::from_manifests(self.manifests())
}
fn blobs(&self, want: impl Fn(&str) -> bool) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let Ok(tree) = self.repo.find_tree(self.tree) else { return out };
let mut rec = gix::traverse::tree::Recorder::default();
if tree.traverse().breadthfirst(&mut rec).is_err() {
return out;
}
for e in rec.records {
let path = e.filepath.to_string();
if !e.mode.is_blob() || !want(&path) {
continue;
}
if let Ok(obj) = self.repo.find_object(e.oid)
&& let Ok(text) = String::from_utf8(obj.data.clone())
{
out.insert(path, text);
}
}
out
}
}
/// **The cargo packages of a tree**: manifest dir (repo-relative, `""` =
/// the root) → package name. A file belongs to the package of its nearest
/// ancestor dir that holds one.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Packages {
pub by_dir: BTreeMap<String, String>,
}
impl Packages {
/// From `Cargo.toml` path → text (a manifest without `[package]` — a
/// virtual workspace root — names no package; one that does not parse
/// is read for its `name = "…"` line, else left out).
pub fn from_manifests(manifests: &BTreeMap<String, String>) -> Self {
let mut by_dir = BTreeMap::new();
for (path, text) in manifests {
let dir = path.strip_suffix("Cargo.toml").unwrap_or("").trim_end_matches('/').to_string();
let name = match text.parse::<toml::Table>() {
Ok(t) => t.get("package").and_then(|p| p.get("name")).and_then(|n| n.as_str()).map(str::to_string),
Err(_) => text.split("[package]").nth(1).and_then(|rest| {
rest.lines().take_while(|l| !l.trim_start().starts_with('[')).find_map(|l| {
let (k, v) = l.split_once('=')?;
(k.trim() == "name").then(|| v.trim().trim_matches('"').to_string())
})
}),
};
if let Some(n) = name {
by_dir.insert(dir, n);
}
}
Self { by_dir }
}
/// The package `path` belongs to: `(dir, name)` of its nearest ancestor
/// manifest dir.
pub fn of_file(&self, path: &str) -> Option<(&str, &str)> {
let mut dir = path;
loop {
dir = match dir.rfind('/') {
Some(i) => &dir[..i],
None if !dir.is_empty() => "",
None => return None,
};
if let Some((d, n)) = self.by_dir.get_key_value(dir) {
return Some((d.as_str(), n.as_str()));
}
if dir.is_empty() {
return None;
}
}
}
/// The manifest dir of the package named `name` (`-` and `_` alike).
pub fn dir_of(&self, name: &str) -> Option<&str> {
let want = norm_crate(name);
self.by_dir.iter().find(|(_, n)| norm_crate(n) == want).map(|(d, _)| d.as_str())
}
}
/// A crate / package name with `-` read as `_` (cargo's two spellings).
pub fn norm_crate(name: &str) -> String {
name.replace('-', "_")
}
/// Is `path` a file of a target that is no library — a binary
/// (`src/main.rs`, `src/bin/…`), an example, a bench, an integration test,
/// a build script — of the package in `dir`? Nothing links such a file into
/// another crate: only a process (a test that runs the target) reaches it.
pub fn is_non_lib_target(dir: &str, path: &str) -> bool {
let rel = if dir.is_empty() { path } else { path.strip_prefix(dir).and_then(|r| r.strip_prefix('/')).unwrap_or(path) };
rel == "src/main.rs" || rel == "build.rs" || ["src/bin/", "examples/", "benches/", "tests/"].iter().any(|p| rel.starts_with(p))
}
/// One `[[package]]` of a `Cargo.lock`.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct LockPkg {
pub name: String,
pub version: String,
/// `None` = a path package (a workspace member or a path dependency).
pub source: Option<String>,
pub checksum: Option<String>,
/// The names it depends on (the `dependencies` entries' first word).
pub deps: Vec<String>,
}
/// **Parse a `Cargo.lock`.**
pub fn parse_lock(text: &str) -> Result<Vec<LockPkg>> {
let t: toml::Table = text.parse().context("Cargo.lock is no TOML")?;
let Some(pkgs) = t.get("package").and_then(|p| p.as_array()) else { return Ok(Vec::new()) };
let s = |v: &toml::Value, k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string);
let mut out = Vec::with_capacity(pkgs.len());
for p in pkgs {
let name = s(p, "name").ok_or_else(|| anyhow!("a Cargo.lock package without a name"))?;
let deps = p
.get("dependencies")
.and_then(|d| d.as_array())
.map(|d| d.iter().filter_map(|x| x.as_str()).filter_map(|x| x.split_whitespace().next()).map(str::to_string).collect())
.unwrap_or_default();
out.push(LockPkg { name, version: s(p, "version").unwrap_or_default(), source: s(p, "source"), checksum: s(p, "checksum"), deps });
}
Ok(out)
}
/// **The packages a lock change moved**: every name whose entries (version,
/// source, checksum, dependencies) differ between `before` and `after` —
/// added, removed, or resolved otherwise.
pub fn lock_moved(before: &[LockPkg], after: &[LockPkg]) -> BTreeSet<String> {
let by = |l: &[LockPkg]| -> BTreeMap<String, BTreeSet<LockPkg>> {
l.iter().fold(BTreeMap::new(), |mut m, p| {
let mut p = p.clone();
p.deps.sort();
m.entry(p.name.clone()).or_default().insert(p);
m
})
};
let (a, b) = (by(before), by(after));
a.keys().chain(b.keys()).filter(|n| a.get(*n) != b.get(*n)).cloned().collect()
}
/// **Every package that depends on one of `moved`**, directly or not, by
/// the dependency lists of `locks` (all read together: a package that
/// depended on it in either lock counts) — `moved` included.
pub fn lock_dependents(locks: &[&[LockPkg]], moved: &BTreeSet<String>) -> BTreeSet<String> {
let mut rev: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for l in locks {
for p in l.iter() {
for d in &p.deps {
rev.entry(d.as_str()).or_default().insert(p.name.as_str());
}
}
}
let mut out: BTreeSet<String> = moved.clone();
let mut todo: Vec<String> = moved.iter().cloned().collect();
while let Some(n) = todo.pop() {
for u in rev.get(n.as_str()).into_iter().flatten() {
if out.insert(u.to_string()) {
todo.push(u.to_string());
}
}
}
out
}
/// **The dependency lists of a tree's manifests**, as lock entries: per
/// package its name and the (real, `package = …` read) names of its
/// dependencies, dev-dependencies and build-dependencies, target-specific
/// ones included — what [`lock_dependents`] walks when no lock says it.
pub fn manifest_deps(manifests: &BTreeMap<String, String>) -> Vec<LockPkg> {
let mut out = Vec::new();
for text in manifests.values() {
let Ok(t) = text.parse::<toml::Table>() else { continue };
let Some(name) = t.get("package").and_then(|p| p.get("name")).and_then(|n| n.as_str()) else { continue };
let mut deps: Vec<String> = Vec::new();
let mut read = |tab: &toml::Table| {
for key in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(d) = tab.get(key).and_then(|d| d.as_table()) {
for (k, v) in d {
deps.push(v.get("package").and_then(|p| p.as_str()).unwrap_or(k).to_string());
}
}
}
};
read(&t);
if let Some(targets) = t.get("target").and_then(|x| x.as_table()) {
for v in targets.values() {
if let Some(tab) = v.as_table() {
read(tab);
}
}
}
out.push(LockPkg { name: name.to_string(), version: String::new(), source: None, checksum: None, deps });
}
out
}
/// **Where a string literal of `files` names one of `terms`** as a whole
/// word (a neighbour that is no letter, digit, `_` or `-`): `(file, line)`.
/// What reaches a target across a process boundary (`cargo build` in its
/// dir, `cargo run -p` it, a path to its binary) names it in a string.
pub fn named_in_strings(files: &BTreeMap<String, String>, terms: &[String]) -> Vec<(String, u32)> {
let mut out: BTreeSet<(String, u32)> = BTreeSet::new();
let word = |c: char| c == '_' || c == '-' || c.is_alphanumeric();
for (path, text) in files {
if !terms.iter().any(|t| text.contains(t.as_str())) {
continue;
}
for (line, lit) in string_literals(text) {
for t in terms {
for (at, _) in lit.match_indices(t.as_str()) {
let before = lit[..at].chars().next_back();
let after = lit[at + t.len()..].chars().next();
if before.is_some_and(word) || after.is_some_and(word) {
continue;
}
out.insert((path.clone(), line + lit[..at].matches('\n').count() as u32));
}
}
}
}
out.into_iter().collect()
}
/// Every string literal of `text` (plain and raw; comments skipped): the
/// 1-based line it starts on and its contents as written.
pub fn string_literals(text: &str) -> Vec<(u32, &str)> {
let b = text.as_bytes();
let (mut i, mut line) = (0usize, 1u32);
let mut out = Vec::new();
let is_cont = |c: u8| c == b'_' || c.is_ascii_alphanumeric();
let mut raw_at = usize::MAX;
while i < b.len() {
match b[i] {
b'\n' => {
line += 1;
i += 1;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut depth = 1;
i += 2;
while i < b.len() && depth > 0 {
if b[i] == b'\n' {
line += 1;
}
if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
depth += 1;
i += 1;
} else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
depth -= 1;
i += 1;
}
i += 1;
}
}
b'"' => {
let (start, at) = (i + 1, line);
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
if b.get(i + 1) == Some(&b'\n') {
line += 1;
}
i += 1;
} else if b[i] == b'\n' {
line += 1;
}
i += 1;
}
out.push((at, &text[start..i.min(b.len())]));
i += 1;
}
b'r' if b.get(i + 1).is_some_and(|n| *n == b'#' || *n == b'"') && (i == 0 || i == raw_at || !is_cont(b[i - 1])) => {
let mut j = i + 1;
let mut hashes = 0;
while b.get(j) == Some(&b'#') {
hashes += 1;
j += 1;
}
if b.get(j) != Some(&b'"') {
i += 1;
continue;
}
j += 1;
let (start, at) = (j, line);
let mut end = b.len();
while j < b.len() {
if b[j] == b'"' && b.len() >= j + 1 + hashes && b[j + 1..j + 1 + hashes].iter().all(|x| *x == b'#') {
end = j;
j += 1 + hashes;
break;
}
if b[j] == b'\n' {
line += 1;
}
j += 1;
}
out.push((at, &text[start..end.min(b.len())]));
i = j;
}
b'\'' => {
if b.get(i + 2) == Some(&b'\'') {
i += 3;
} else if b.get(i + 1) == Some(&b'\\') {
i += 2;
while i < b.len() && b[i] != b'\'' {
i += 1;
}
i += 1;
} else {
i += 1;
while i < b.len() && is_cont(b[i]) {
i += 1;
}
}
}
c if is_cont(c) => {
// a word; `br"…"` hands its `r` to the raw arm (`b"…"` reads as a plain string)
let s = i;
while i < b.len() && is_cont(b[i]) {
i += 1;
}
if &b[s..i] == b"br" && matches!(b.get(i), Some(b'#') | Some(b'"')) {
i -= 1;
raw_at = i;
}
}
_ => i += 1,
}
}
out
}
/// What kind of item a brace block opens.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockKind {
Impl,
Trait,
Mod,
Fn,
/// A struct / enum / union body, an `extern` block, a macro body …
OtherItem,
/// A block inside an expression or a statement (no item header).
Expr,
}
/// One `{ … }` block of a source text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Block {
pub kind: BlockKind,
/// 1-based line of the header's first token (attributes left out).
pub header: u32,
/// The lines of the `{` and the `}` (the last line when it never closes).
pub open: u32,
pub close: u32,
}
/// **The blocks of `text` that hold `line`**, innermost first. A lexer for
/// one purpose (comments, strings and char literals skipped), not Rust's.
pub fn enclosing_blocks(text: &str, line: u32) -> Vec<Block> {
let b = text.as_bytes();
let (mut i, mut ln) = (0usize, 1u32);
// per open block: (kind, header line, open line)
let mut stack: Vec<(BlockKind, u32, u32)> = Vec::new();
let mut out: Vec<Block> = Vec::new();
// where the current header began: the byte after the last `;` `{` `}`
let mut head_from = 0usize;
let is_cont = |c: u8| c == b'_' || c.is_ascii_alphanumeric();
while i < b.len() {
let c = b[i];
match c {
b'\n' => {
ln += 1;
i += 1;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut depth = 1;
i += 2;
while i < b.len() && depth > 0 {
if b[i] == b'\n' {
ln += 1;
}
if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
depth += 1;
i += 1;
} else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
depth -= 1;
i += 1;
}
i += 1;
}
}
b'"' => {
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
// an escaped newline (a string continued on the next line) is a line too
if b.get(i + 1) == Some(&b'\n') {
ln += 1;
}
i += 1;
} else if b[i] == b'\n' {
ln += 1;
}
i += 1;
}
i += 1;
}
b'r' if b.get(i + 1).is_some_and(|n| *n == b'#' || *n == b'"') && (i == 0 || !is_cont(b[i - 1])) => {
let mut j = i + 1;
let mut hashes = 0;
while b.get(j) == Some(&b'#') {
hashes += 1;
j += 1;
}
if b.get(j) != Some(&b'"') {
i += 1;
continue;
}
j += 1;
while j < b.len() {
if b[j] == b'\n' {
ln += 1;
}
if b[j] == b'"' && b.len() >= j + 1 + hashes && b[j + 1..j + 1 + hashes].iter().all(|x| *x == b'#') {
j += 1 + hashes;
break;
}
j += 1;
}
i = j;
}
b'\'' => {
if b.get(i + 2) == Some(&b'\'') {
i += 3;
} else if b.get(i + 1) == Some(&b'\\') {
i += 2;
while i < b.len() && b[i] != b'\'' {
i += 1;
}
i += 1;
} else {
i += 1;
while i < b.len() && is_cont(b[i]) {
i += 1;
}
}
}
b'{' => {
let (kind, header) = block_header(text, head_from, i, ln);
stack.push((kind, header, ln));
i += 1;
head_from = i;
}
b'}' => {
if let Some((kind, header, open)) = stack.pop()
&& header.min(open) <= line
&& line <= ln
{
out.push(Block { kind, header, open, close: ln });
}
i += 1;
head_from = i;
}
b';' => {
i += 1;
head_from = i;
}
_ => i += 1,
}
}
// blocks that never close hold everything after them
while let Some((kind, header, open)) = stack.pop() {
if header.min(open) <= line {
out.push(Block { kind, header, open, close: ln });
}
}
out.sort_by_key(|b| std::cmp::Reverse((b.header.min(b.open), u32::MAX - b.close)));
out
}
/// What the header `text[from..to]` (before a `{` on line `open_line`)
/// opens, and the line its first token is on (attributes and doc comments
/// left out).
fn block_header(text: &str, from: usize, to: usize, open_line: u32) -> (BlockKind, u32) {
let raw = &text[from..to];
// the line `from` is on
let mut line = open_line - raw.matches('\n').count() as u32;
let mut rest = raw;
// skip whitespace, comments, attributes (balanced brackets)
loop {
let trimmed = rest.trim_start();
line += rest[..rest.len() - trimmed.len()].matches('\n').count() as u32;
rest = trimmed;
if rest.starts_with("//") {
let end = rest.find('\n').unwrap_or(rest.len());
rest = &rest[end..];
continue;
}
if rest.starts_with('#') {
let mut depth = 0i32;
let mut end = rest.len();
for (k, ch) in rest.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
end = k + 1;
break;
}
}
_ => {}
}
}
line += rest[..end].matches('\n').count() as u32;
rest = &rest[end..];
continue;
}
break;
}
let words: Vec<&str> = rest.split(|c: char| !(c == '_' || c.is_alphanumeric() || c == '!')).filter(|w| !w.is_empty()).take(8).collect();
let mut k = 0;
while k < words.len() && matches!(words[k], "pub" | "crate" | "super" | "self" | "in" | "unsafe" | "default" | "async" | "const" | "extern" | "C" | "auto") {
k += 1;
}
let kind = match words.get(k).copied() {
Some("impl") => BlockKind::Impl,
Some("trait") => BlockKind::Trait,
Some("mod") => BlockKind::Mod,
Some("fn") => BlockKind::Fn,
Some("struct" | "enum" | "union" | "macro_rules!") => BlockKind::OtherItem,
_ if words.first() == Some(&"extern") => BlockKind::OtherItem,
_ => BlockKind::Expr,
};
(kind, line)
}
/// What a `cfg` attribute line gates.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CfgTarget {
/// `#![cfg(…)]`: the whole module (file) it stands in.
Inner,
/// The item that starts on this line.
Item(u32),
/// `mod name;`: the module's own file.
ModFile(String),
/// A `use` / `extern crate`.
Use,
/// A statement / expression inside a body: the item that holds it.
Body,
}
/// **What the `cfg` attribute on `line` (1-based) of `lines` gates**: the
/// first line after it (and after the attributes and comments that follow
/// it) that is not one.
pub fn cfg_target(lines: &[&str], line: u32) -> CfgTarget {
let at = lines.get(line as usize - 1).copied().unwrap_or("").trim();
if at.starts_with("#![") {
return CfgTarget::Inner;
}
// the attribute's text may close on this line with the item after it
let mut depth = 0i32;
let mut after: Option<(&str, u32)> = None;
let mut n = line;
let mut cur = at;
'scan: loop {
let mut rest_from = None;
for (k, ch) in cur.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
rest_from = Some(k + 1);
}
}
_ => {}
}
}
if depth <= 0 {
depth = 0;
if let Some(r) = rest_from {
let rest = cur[r..].trim();
if !rest.is_empty() && !rest.starts_with("//") && !rest.starts_with('#') {
after = Some((rest, n));
break 'scan;
}
}
// the next line that is neither blank, a comment nor an attribute
loop {
n += 1;
let Some(l) = lines.get(n as usize - 1) else { break 'scan };
let t = l.trim();
if t.is_empty() || t.starts_with("//") {
continue;
}
if t.starts_with('#') {
cur = t;
continue 'scan;
}
after = Some((t, n));
break 'scan;
}
}
n += 1;
let Some(l) = lines.get(n as usize - 1) else { break };
cur = l.trim();
}
let Some((t, n)) = after else { return CfgTarget::Body };
let bare = strip_vis(t);
if bare.starts_with("use ") || bare.starts_with("extern crate ") {
return CfgTarget::Use;
}
if let Some(m) = bare.strip_prefix("mod ")
&& let Some(name) = m.trim().strip_suffix(';')
{
return CfgTarget::ModFile(name.trim().to_string());
}
let first = bare.split(|c: char| !(c == '_' || c.is_alphanumeric() || c == '!')).find(|w| !w.is_empty()).unwrap_or("");
if matches!(first, "fn" | "async" | "const" | "unsafe" | "extern" | "static" | "struct" | "enum" | "union" | "trait" | "impl" | "type" | "mod" | "macro_rules!" | "default")
|| t.starts_with("pub ")
|| t.starts_with("pub(")
{
return CfgTarget::Item(n);
}
CfgTarget::Body
}
fn tree_of(repo: &gix::Repository, sha: &str) -> Result<gix::ObjectId> {
let id = repo.rev_parse_single(sha).map_err(|e| anyhow!("`{sha}` names no commit here: {e}"))?;
let commit = id.object().context("read the commit")?.try_into_commit().map_err(|e| anyhow!("`{sha}` is no commit: {e}"))?;
Ok(commit.tree_id().map_err(|e| anyhow!("the tree of `{sha}`: {e}"))?.detach())
}
/// **What changed from `from` to `to`** in the repo at `dir`: every file
/// added, deleted or modified (a rename is a deletion and an addition), with
/// the touched lines of each `.rs` file's newer side.
pub fn commit_diff(dir: &Path, from: &str, to: &str) -> Result<Vec<FileDiff>> {
let repo = gix::open(dir).with_context(|| format!("open {}", dir.display()))?;
let (a, b) = (repo.find_tree(tree_of(&repo, from)?)?, repo.find_tree(tree_of(&repo, to)?)?);
let changes = repo.diff_tree_to_tree(&a, &b, gix::diff::Options::default()).context("tree diff")?;
let mut out = Vec::new();
for c in changes {
use gix::object::tree::diff::ChangeDetached as C;
let (path, change, old, new, blob) = match &c {
C::Addition { location, entry_mode, id, .. } => (location.to_string(), FileChange::Added, None, Some(*id), entry_mode.is_blob()),
C::Deletion { location, entry_mode, id, .. } => (location.to_string(), FileChange::Deleted, Some(*id), None, entry_mode.is_blob()),
C::Modification { location, entry_mode, previous_id, id, .. } => (location.to_string(), FileChange::Modified, Some(*previous_id), Some(*id), entry_mode.is_blob()),
C::Rewrite { location, entry_mode, id, .. } => (location.to_string(), FileChange::Added, None, Some(*id), entry_mode.is_blob()),
};
if !blob {
continue;
}
let lines = if path.ends_with(".rs") && change != FileChange::Deleted {
let read = |id: Option<gix::ObjectId>| -> Result<Vec<u8>> {
match id {
Some(id) => Ok(repo.find_object(id).with_context(|| format!("blob of {path}"))?.data.clone()),
None => Ok(Vec::new()),
}
};
touched_lines(&read(old)?, &read(new)?)
} else {
Vec::new()
};
out.push(FileDiff { path, change, lines });
}
out.sort_by(|x, y| x.path.cmp(&y.path));
Ok(out)
}
/// The newer side's touched lines of a line diff of `before` → `after`
/// (1-based, inclusive, merged). A pure removal touches the line before the
/// gap and the line after it.
pub fn touched_lines(before: &[u8], after: &[u8]) -> Vec<(u32, u32)> {
use gix::diff::blob::{Algorithm, Diff, InternedInput};
let input = InternedInput::new(before, after);
let mut diff = Diff::compute(Algorithm::Histogram, &input);
diff.postprocess_lines(&input);
let total = after.split(|b| *b == b'\n').count() as u32;
let mut ranges: Vec<(u32, u32)> = Vec::new();
for h in diff.hunks() {
let (a, b) = (h.after.start, h.after.end);
let r = if a < b { (a + 1, b) } else { (a.max(1), (a + 1).min(total.max(1))) };
ranges.push(r);
}
merge(ranges)
}
fn merge(mut r: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
r.sort_unstable();
let mut out: Vec<(u32, u32)> = Vec::with_capacity(r.len());
for (a, b) in r {
match out.last_mut() {
Some(last) if a <= last.1 + 1 => last.1 = last.1.max(b),
_ => out.push((a, b.max(a))),
}
}
out
}
/// What a changed path means for the selection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathRule {
/// Rust source: its hunks are placed on the room's items.
Rust,
/// The graph cannot see through it: every test runs, and why.
All(String),
/// Not Rust: inert only if no `.rs` file names it; a doc-like file that
/// no `.rs` file names is inert, anything else that none names runs all
/// (a test may read it at run time).
Other { doc_like: bool },
}
/// **Classify a changed path** (repo-relative).
pub fn classify_path(path: &str) -> PathRule {
let name = path.rsplit('/').next().unwrap_or(path);
let all = |what: &str| PathRule::All(format!("{path} changed — {what}: the graph cannot see what it changes, every test runs"));
if name == "Cargo.toml" {
return all("a manifest (features, dependencies, targets)");
}
if name == "Cargo.lock" {
return all("the lock file (a dependency moved)");
}
if name == "build.rs" {
return all("a build script");
}
if name.starts_with("rust-toolchain") {
return all("the toolchain pin");
}
if path.starts_with(".cargo/") || path.contains("/.cargo/") {
return all("cargo configuration");
}
if path.starts_with(".config/") && name.starts_with("nextest") {
return all("the nextest configuration");
}
if name.ends_with(".rs") {
return PathRule::Rust;
}
let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
let doc_like = matches!(ext.as_str(), "md" | "markdown" | "txt" | "rst" | "adoc" | "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "pdf" | "drawio")
|| name.starts_with("LICENSE")
|| name.starts_with("COPYING")
|| matches!(name, ".gitignore" | ".gitattributes" | ".mailmap")
|| path.starts_with(".github/")
|| path.starts_with(".forgejo/")
|| path.starts_with(".gitea/")
|| path.starts_with(".nornir/");
PathRule::Other { doc_like }
}
/// What a changed line that no item of the room holds is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
Blank,
Comment,
/// `use …;` / `pub use …;` / `extern crate …;` — a name moved; the
/// graph's own diff (an edge that now points elsewhere) carries it.
Use,
/// `mod x;` — the module's items are positioned in their own file.
ModDecl,
/// An attribute line (`#[…]`) that is not a `cfg`.
Attr,
/// A `cfg` (`#[cfg(…)]`, `#![cfg(…)]`, `#[cfg_attr(…)]`): the set of
/// compiled items moves with it.
Cfg,
/// A brace or a lone delimiter (the end of an item the room spans
/// differently).
Delimiter,
/// Anything else: code the graph does not place.
Code,
}
/// **Classify one source line** that no item holds.
pub fn line_kind(text: &str) -> LineKind {
let t = text.trim();
if t.is_empty() {
return LineKind::Blank;
}
if t.starts_with("//") || t.starts_with("/*") || t.starts_with('*') || t.ends_with("*/") {
return LineKind::Comment;
}
if t.starts_with("#[cfg") || t.starts_with("#![cfg") || t.contains("cfg_attr(") || t.starts_with("#[cfg_attr") {
return LineKind::Cfg;
}
if t.starts_with("#[") || t.starts_with("#![") {
return LineKind::Attr;
}
let bare = strip_vis(t);
if bare.starts_with("use ") || bare.starts_with("extern crate ") {
return LineKind::Use;
}
if (bare.starts_with("mod ") && bare.ends_with(';')) || bare.starts_with("mod ") && !bare.contains('{') {
return LineKind::ModDecl;
}
if t.chars().all(|c| matches!(c, '{' | '}' | '(' | ')' | '[' | ']' | ';' | ',' | ' ')) {
return LineKind::Delimiter;
}
LineKind::Code
}
fn strip_vis(t: &str) -> &str {
let t = t.trim_start();
if let Some(rest) = t.strip_prefix("pub") {
let rest = rest.trim_start();
if let Some(r) = rest.strip_prefix('(')
&& let Some(close) = r.find(')')
{
return r[close + 1..].trim_start();
}
return rest;
}
t
}
/// One identifier of a source text and what surrounds it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ident<'a> {
pub name: &'a str,
/// 1-based line.
pub line: u32,
/// The path segment before it (`Point` in `Point::new`), if any.
pub qualifier: Option<&'a str>,
/// The next non-space character after it.
pub next: Option<char>,
/// The word before it (`fn` in `fn new`), if any.
pub prev_word: Option<&'a str>,
}
/// **Every identifier of `text`** outside comments and string literals, in
/// order. A lexer for one purpose (finding where a name is used), not Rust's.
pub fn identifiers(text: &str) -> Vec<Ident<'_>> {
let b = text.as_bytes();
let mut out: Vec<Ident<'_>> = Vec::new();
let (mut i, mut line) = (0usize, 1u32);
let is_start = |c: u8| c == b'_' || c.is_ascii_alphabetic();
let is_cont = |c: u8| c == b'_' || c.is_ascii_alphanumeric();
while i < b.len() {
let c = b[i];
match c {
b'\n' => {
line += 1;
i += 1;
}
b'/' if b.get(i + 1) == Some(&b'/') => {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
}
b'/' if b.get(i + 1) == Some(&b'*') => {
let mut depth = 1;
i += 2;
while i < b.len() && depth > 0 {
if b[i] == b'\n' {
line += 1;
}
if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
depth += 1;
i += 1;
} else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') {
depth -= 1;
i += 1;
}
i += 1;
}
}
b'"' => {
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
// an escaped newline (a string continued on the next line) is a line too
if b.get(i + 1) == Some(&b'\n') {
line += 1;
}
i += 1;
} else if b[i] == b'\n' {
line += 1;
}
i += 1;
}
i += 1;
}
b'r' if b.get(i + 1).is_some_and(|n| *n == b'#' || *n == b'"') && (i == 0 || !is_cont(b[i - 1])) => {
// raw string r#"…"#
let mut j = i + 1;
let mut hashes = 0;
while b.get(j) == Some(&b'#') {
hashes += 1;
j += 1;
}
if b.get(j) != Some(&b'"') {
i += 1;
continue;
}
j += 1;
loop {
if j >= b.len() {
break;
}
if b[j] == b'\n' {
line += 1;
}
if b[j] == b'"' && b[j + 1..].iter().take(hashes).filter(|x| **x == b'#').count() == hashes && b.len() >= j + 1 + hashes {
j += 1 + hashes;
break;
}
j += 1;
}
i = j;
}
b'\'' => {
// a char literal ('a', '\n') — a lifetime ('a) is skipped as one too
if b.get(i + 2) == Some(&b'\'') {
i += 3;
} else if b.get(i + 1) == Some(&b'\\') {
i += 2;
while i < b.len() && b[i] != b'\'' {
i += 1;
}
i += 1;
} else {
i += 1;
while i < b.len() && is_cont(b[i]) {
i += 1;
}
}
}
c if is_start(c) => {
let s = i;
while i < b.len() && is_cont(b[i]) {
i += 1;
}
let name = &text[s..i];
let next = text[i..].chars().find(|c| !c.is_whitespace());
let before = text[..s].trim_end();
let qualifier = before.strip_suffix("::").map(|q| {
let q = q.trim_end();
&q[word_start(q)..]
});
let prev_word = (!before.ends_with("::")).then(|| {
&before[word_start(before)..]
});
out.push(Ident { name, line, qualifier: qualifier.filter(|q| !q.is_empty()), next, prev_word: prev_word.filter(|w| !w.is_empty()) });
}
c if c.is_ascii_digit() => {
while i < b.len() && is_cont(b[i]) {
i += 1;
}
}
_ => i += 1,
}
}
out
}
/// Where the trailing identifier of `s` starts: just past the last character
/// that is no identifier character (its whole width — a `─` is three bytes).
fn word_start(s: &str) -> usize {
s.char_indices()
.rev()
.find(|(_, c)| !(*c == '_' || c.is_ascii_alphanumeric()))
.map_or(0, |(p, c)| p + c.len_utf8())
}
/// A name to look for as a VALUE (a fn passed on, not called), and what
/// qualifies it when it is not unique.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Wanted {
pub name: String,
/// The name of its parent (type, trait or module): `Point` for
/// `Point::new`. A use qualified by it counts always.
pub parent: String,
/// No other fn of the repo has this name: an unqualified use counts too.
pub unique: bool,
}
/// **Where a wanted fn is used as a value** in `files` (path → text): an
/// identifier equal to its name that is not called (`f(` / `f::<T>(`),
/// not a macro (`f!`), and not its own definition (`fn f`). Returns
/// `(file, line, name)`.
pub fn mentions(files: &BTreeMap<String, String>, wanted: &[Wanted]) -> Vec<(String, u32, String)> {
let by_name: BTreeMap<&str, Vec<&Wanted>> = wanted.iter().fold(BTreeMap::new(), |mut m, w| {
m.entry(w.name.as_str()).or_default().push(w);
m
});
let mut out: BTreeSet<(String, u32, String)> = BTreeSet::new();
for (path, text) in files {
// the cheap prefilter (few names): a file that holds none of them
if by_name.len() <= 32 && !by_name.keys().any(|n| text.contains(n)) {
continue;
}
for id in identifiers(text) {
let Some(ws) = by_name.get(id.name) else { continue };
if matches!(id.next, Some('(') | Some('!')) || id.prev_word == Some("fn") {
continue;
}
// `f::<T>(…)` is a call too
if id.next == Some(':') && text_after_is_turbofish_call(text, &id) {
continue;
}
if ws.iter().any(|w| w.unique || id.qualifier == Some(w.parent.as_str())) {
out.insert((path.clone(), id.line, id.name.to_string()));
}
}
}
out.into_iter().collect()
}
fn text_after_is_turbofish_call(text: &str, id: &Ident<'_>) -> bool {
// find the identifier's line and look for `name::<…>(`
let line = text.lines().nth(id.line as usize - 1).unwrap_or("");
line.contains(&format!("{}::<", id.name)) && line.split(&format!("{}::<", id.name)).skip(1).all(|rest| {
let mut depth = 1i32;
let mut it = rest.chars();
for c in it.by_ref() {
match c {
'<' => depth += 1,
'>' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
}
it.as_str().trim_start().starts_with('(')
})
}
/// **The name a test runner prints for a test**: its path inside its crate
/// — the module path without the crate segment, then the name
/// (`cart::tests::adds_up`; a test at the crate root is its bare name). The
/// spelling `--skip <name> --exact` matches.
pub fn runner_name(module_path: &str, name: &str) -> String {
match module_path.split_once("::") {
Some((_, m)) if !m.is_empty() => format!("{m}::{name}"),
_ => name.to_string(),
}
}
/// Does a test's own file spawn the crate's binary (`CARGO_BIN_EXE_…`,
/// `assert_cmd`, `cargo_bin(`)? Then its reach crosses a process boundary
/// the graph does not walk: it runs whenever any Rust code changed.
pub fn spawns_binary(text: &str) -> bool {
text.contains("CARGO_BIN_EXE_") || text.contains("assert_cmd") || text.contains("cargo_bin(") || text.contains("escargot")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blocks_name_their_kind_header_and_braces() {
let src = "//! doc\nimpl<T> Tr for X<T>\nwhere T: Copy {\n fn f() {\n let s = \"{\";\n }\n}\n\n#[cfg(test)]\n// a comment\nmod tests {\n #[test]\n fn t() { if true { } }\n}\n";
let at = |l: u32| enclosing_blocks(src, l).iter().map(|b| (b.kind, b.header, b.open, b.close)).collect::<Vec<_>>();
// the where clause: inside the impl's header, before its brace
assert_eq!(at(3), vec![(BlockKind::Impl, 2, 3, 7)]);
assert_eq!(at(5), vec![(BlockKind::Fn, 4, 4, 6), (BlockKind::Impl, 2, 3, 7)]);
assert_eq!(at(11), vec![(BlockKind::Mod, 11, 11, 14)]);
assert_eq!(at(13)[0].0, BlockKind::Expr);
assert_eq!(at(13)[1].0, BlockKind::Fn);
assert!(at(8).is_empty());
// a string continued over a newline keeps the line count
let cont = "fn a() {\n let s = \"x\\\n y\";\n}\nimpl Z {\n}\n";
assert_eq!(enclosing_blocks(cont, 5)[0].kind, BlockKind::Impl);
}
#[test]
fn a_cfg_stands_on_what_follows_it() {
let lines = [
"#[cfg(feature = \"x\")]",
"/// doc",
"#[inline]",
"",
"pub(crate) async fn go() {}",
"#[cfg(test)]",
"mod tests;",
"#[cfg_attr(",
" x, derive(Debug)",
")]",
"struct S;",
"#![cfg(unix)]",
" #[cfg(feature = \"on\")]",
" {",
"#[cfg(x)]",
"use a::b;",
"#[cfg(x)] fn same_line() {}",
];
assert_eq!(cfg_target(&lines, 1), CfgTarget::Item(5));
assert_eq!(cfg_target(&lines, 6), CfgTarget::ModFile("tests".into()));
assert_eq!(cfg_target(&lines, 8), CfgTarget::Item(11));
assert_eq!(cfg_target(&lines, 12), CfgTarget::Inner);
assert_eq!(cfg_target(&lines, 13), CfgTarget::Body);
assert_eq!(cfg_target(&lines, 15), CfgTarget::Use);
assert_eq!(cfg_target(&lines, 17), CfgTarget::Item(17));
}
#[test]
fn a_file_belongs_to_the_nearest_package_above_it() {
let manifests: BTreeMap<String, String> = [
("Cargo.toml", "[workspace]\nmembers = [\"crates/x\"]\n[package]\nname = \"root\"\n"),
("crates/x/Cargo.toml", "[package]\nname = \"x-y\"\nversion = \"1.0.0\"\n[dependencies]\nroot = { path = \"../..\" }\n[dev-dependencies]\nz = { package = \"zed\", version = \"1\" }\n"),
("ws/Cargo.toml", "[workspace]\n"),
]
.into_iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect();
let p = Packages::from_manifests(&manifests);
assert_eq!(p.of_file("crates/x/src/a/b.rs"), Some(("crates/x", "x-y")));
assert_eq!(p.of_file("src/lib.rs"), Some(("", "root")));
// a virtual manifest is no package: the root's is the next one up
assert_eq!(p.of_file("ws/src/lib.rs"), Some(("", "root")));
assert_eq!(p.dir_of("x_y"), Some("crates/x"));
assert!(is_non_lib_target("crates/x", "crates/x/examples/e.rs"));
assert!(is_non_lib_target("crates/x", "crates/x/src/bin/tool.rs"));
assert!(is_non_lib_target("", "tests/it.rs"));
assert!(!is_non_lib_target("crates/x", "crates/x/src/lib.rs"));
let deps = manifest_deps(&manifests);
let x = deps.iter().find(|d| d.name == "x-y").unwrap();
assert_eq!(x.deps, vec!["root".to_string(), "zed".to_string()]);
// root changed → x-y depends on it
assert_eq!(lock_dependents(&[&deps], &["root".to_string()].into()), ["root".to_string(), "x-y".to_string()].into());
}
#[test]
fn a_lock_change_reaches_the_packages_that_depend_on_what_moved() {
let lock = |zed: &str, yon: &str| {
format!(
"version = 4\n\n[[package]]\nname = \"a\"\nversion = \"0.1.0\"\ndependencies = [\"zed\"]\n\n[[package]]\nname = \"b-c\"\nversion = \"0.2.0\"\ndependencies = [\"a\", \"yon 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)\"]\n\n[[package]]\nname = \"lone\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"yon\"\nversion = \"{yon}\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n\n[[package]]\nname = \"zed\"\nversion = \"{zed}\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n"
)
};
let reach = |a: &str, b: &str| -> BTreeSet<String> {
let (a, b) = (parse_lock(a).unwrap(), parse_lock(b).unwrap());
lock_dependents(&[&a, &b], &lock_moved(&a, &b))
};
let base = lock("1.0.0", "1.0.0");
assert!(reach(&base, &base).is_empty());
assert_eq!(reach(&base, &lock("1.0.0", "1.0.1")), ["b-c".to_string(), "yon".to_string()].into());
assert_eq!(reach(&base, &lock("1.1.0", "1.0.0")), ["a".to_string(), "b-c".to_string(), "zed".to_string()].into());
// a package that went: who depended on it before is reached
let gone = base.replace("dependencies = [\"zed\"]\n", "").replace("[[package]]\nname = \"zed\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", "");
assert_eq!(reach(&base, &gone), ["a".to_string(), "b-c".to_string(), "zed".to_string()].into());
assert!(parse_lock("[[package]\nname=").is_err());
}
#[test]
fn a_string_that_names_a_package_is_found() {
let files: BTreeMap<String, String> = [(
"src/f.rs".to_string(),
"fn dir() -> String {\n concat!(env!(\"X\"), \"/crates/mimir-driver\").into()\n}\n// crates/mimir-driver in a comment\nfn no() { \"mimir-driver-2\"; }\nfn raw() { br#\"run mimir-driver\"#; }\n".to_string(),
)]
.into();
let terms = vec!["crates/mimir-driver".to_string(), "mimir-driver".to_string()];
assert_eq!(named_in_strings(&files, &terms), vec![("src/f.rs".to_string(), 2), ("src/f.rs".to_string(), 6)]);
}
#[test]
fn a_hunk_touches_its_new_lines_and_a_removal_the_lines_around_it() {
let before = "a\nb\nc\nd\ne\n";
// c changed, e removed
let after = "a\nb\nC\nd\n";
assert_eq!(touched_lines(before.as_bytes(), after.as_bytes()), vec![(3, 5)]);
// only a removal between b and d
assert_eq!(touched_lines(b"a\nb\nc\nd\n", b"a\nb\nd\n"), vec![(2, 3)]);
// an added line
assert_eq!(touched_lines(b"a\nb\n", b"a\nx\nb\n"), vec![(2, 2)]);
assert!(touched_lines(b"a\n", b"a\n").is_empty());
}
#[test]
fn manifests_lock_build_scripts_and_toolchains_run_every_test() {
for p in ["Cargo.toml", "crates/a/Cargo.toml", "Cargo.lock", "a/build.rs", "rust-toolchain.toml", ".cargo/config.toml", ".config/nextest.toml"] {
assert!(matches!(classify_path(p), PathRule::All(_)), "{p}");
}
assert_eq!(classify_path("src/lib.rs"), PathRule::Rust);
assert_eq!(classify_path("README.md"), PathRule::Other { doc_like: true });
assert_eq!(classify_path("tests/fixtures/a.json"), PathRule::Other { doc_like: false });
let PathRule::All(why) = classify_path("Cargo.toml") else { unreachable!() };
assert!(why.starts_with("Cargo.toml changed"), "{why}");
}
#[test]
fn unplaced_lines_are_told_apart() {
assert_eq!(line_kind(" "), LineKind::Blank);
assert_eq!(line_kind("// note"), LineKind::Comment);
assert_eq!(line_kind("pub(crate) use a::b;"), LineKind::Use);
assert_eq!(line_kind("mod inner;"), LineKind::ModDecl);
assert_eq!(line_kind("#[cfg(feature = \"x\")]"), LineKind::Cfg);
assert_eq!(line_kind("#![cfg_attr(docsrs, feature(doc_cfg))]"), LineKind::Cfg);
assert_eq!(line_kind("#[derive(Debug)]"), LineKind::Attr);
assert_eq!(line_kind("}"), LineKind::Delimiter);
assert_eq!(line_kind("static X: u8 = 1;"), LineKind::Code);
}
#[test]
fn a_fn_used_as_a_value_is_found_and_a_call_is_not() {
let src = "fn helper(x: u8) -> u8 { x }\n\
fn a() { helper(1); }\n\
fn b() { [1u8].map(helper); }\n\
fn c() { let s = \"helper\"; } // helper\n\
fn d() { [1].map(Point::new); [2].map(Other::new); new(); }\n\
fn e() { helper::<u8>(1); }\n";
let files: BTreeMap<String, String> = [("src/lib.rs".to_string(), src.to_string())].into();
let wanted = [
Wanted { name: "helper".into(), parent: "lib".into(), unique: true },
Wanted { name: "new".into(), parent: "Point".into(), unique: false },
];
let got = mentions(&files, &wanted);
assert_eq!(got, vec![("src/lib.rs".to_string(), 3, "helper".to_string()), ("src/lib.rs".to_string(), 5, "new".to_string())]);
}
/// A name right after a non-ASCII character (a box-drawing `─` in a
/// banner, an `é` in a path segment) is read, not a panic on a char
/// boundary — measured on nornir's own sources, 2026-10-17.
#[test]
fn a_name_after_a_non_ascii_character_is_read() {
let ids = identifiers("─f\nv─x::g\n");
let got: Vec<_> = ids.iter().map(|i| (i.name, i.qualifier, i.prev_word)).collect();
assert_eq!(got, vec![("f", None, None), ("v", None, Some("f")), ("x", None, None), ("g", Some("x"), None)]);
}
#[test]
fn the_runner_prints_a_test_without_its_crate() {
assert_eq!(runner_name("a::tests", "adds"), "tests::adds");
assert_eq!(runner_name("cart::checkout::tests", "pays"), "checkout::tests::pays");
assert_eq!(runner_name("ingest", "works"), "works");
assert_eq!(runner_name("", "works"), "works");
}
#[test]
fn a_scratch_repo_diff_names_files_and_touched_lines() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
crate::gitio::init(d).unwrap();
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::write(d.join("src/lib.rs"), "fn a() {\n 1;\n}\n\nfn b() {\n 2;\n}\n").unwrap();
std::fs::write(d.join("README.md"), "x\n").unwrap();
let c1 = crate::gitio::commit_all(d, "c1").unwrap();
std::fs::write(d.join("src/lib.rs"), "fn a() {\n 1;\n}\n\nfn b() {\n 3;\n}\n").unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\n").unwrap();
std::fs::remove_file(d.join("README.md")).unwrap();
let c2 = crate::gitio::commit_all(d, "c2").unwrap();
let diff = commit_diff(d, &c1, &c2).unwrap();
let got: Vec<(&str, FileChange, Vec<(u32, u32)>)> = diff.iter().map(|f| (f.path.as_str(), f.change, f.lines.clone())).collect();
assert_eq!(
got,
vec![("Cargo.toml", FileChange::Added, vec![]), ("README.md", FileChange::Deleted, vec![]), ("src/lib.rs", FileChange::Modified, vec![(6, 6)])]
);
let snap = Snapshot::at(d, &c2).unwrap();
assert!(snap.text("src/lib.rs").unwrap().contains("3;"));
assert_eq!(snap.rust_files().keys().collect::<Vec<_>>(), vec!["src/lib.rs"]);
}
}