use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use camino::Utf8PathBuf;
use crate::coverage::to_db_relative;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct LineRange {
pub start: i64,
pub end: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShaRelation {
Equal,
Reachable { commits_ahead: u32 },
Missing,
}
pub struct ProjectRoot {
pub workspace_root: PathBuf,
pub manifest_paths: Vec<PathBuf>,
pub metadata: serde_json::Value,
}
pub fn canonicalize_no_verbatim(path: &Path) -> Result<PathBuf> {
#[cfg(windows)]
{
Ok(path.to_path_buf())
}
#[cfg(not(windows))]
{
path.canonicalize()
.with_context(|| format!("failed to canonicalize {}", path.display()))
}
}
pub fn find_project_root() -> Result<ProjectRoot> {
let output = Command::new("cargo")
.args(["metadata", "--no-deps", "--format-version=1"])
.output()
.context("failed to run cargo metadata")?;
if !output.status.success() {
bail!(
"cargo metadata failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let meta: serde_json::Value =
serde_json::from_slice(&output.stdout).context("failed to parse cargo metadata")?;
let workspace_root = meta["workspace_root"]
.as_str()
.context("cargo metadata missing workspace_root")?;
let workspace_root = PathBuf::from(workspace_root);
let mut manifest_paths: Vec<PathBuf> = meta["packages"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|p| p.get("manifest_path").and_then(|m| m.as_str()))
.map(PathBuf::from)
.collect()
})
.unwrap_or_default();
let root_manifest = workspace_root.join("Cargo.toml");
if root_manifest.exists() && !manifest_paths.contains(&root_manifest) {
manifest_paths.push(root_manifest);
}
manifest_paths.sort();
Ok(ProjectRoot {
workspace_root,
manifest_paths,
metadata: meta,
})
}
impl ProjectRoot {
pub fn crate_root_sentinels_by_binary_id(
&self,
) -> Result<BTreeMap<String, BTreeSet<Utf8PathBuf>>> {
let root = &self.workspace_root;
let Some(packages) = self.metadata.get("packages").and_then(|v| v.as_array()) else {
return Ok(BTreeMap::new());
};
let workspace_names: BTreeSet<&str> = packages
.iter()
.filter_map(|p| p.get("name").and_then(|v| v.as_str()))
.collect();
let mut lib_src: BTreeMap<&str, Utf8PathBuf> = BTreeMap::new();
let mut targets_by_pkg: BTreeMap<&str, Vec<TestTarget>> = BTreeMap::new();
let mut deps_by_pkg: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for pkg in packages {
let Some(name) = pkg.get("name").and_then(|v| v.as_str()) else {
continue;
};
if let Some(target_arr) = pkg.get("targets").and_then(|v| v.as_array()) {
for target in target_arr {
if let Some(parsed) = parse_target(target, root) {
if matches!(parsed.kind, TargetKind::Lib) {
lib_src.insert(name, parsed.src_path.clone());
}
if parsed.is_test_runnable() {
targets_by_pkg.entry(name).or_default().push(parsed);
}
}
}
}
if let Some(deps) = pkg.get("dependencies").and_then(|v| v.as_array()) {
let mut workspace_deps = BTreeSet::new();
for dep in deps {
let Some(dep_name) = dep.get("name").and_then(|v| v.as_str()) else {
continue;
};
let kind = dep.get("kind").and_then(|v| v.as_str());
if kind == Some("build") {
continue;
}
if !workspace_names.contains(dep_name) {
continue;
}
workspace_deps.insert(dep_name);
}
deps_by_pkg.insert(name, workspace_deps);
}
}
let transitive_deps: BTreeMap<&str, BTreeSet<&str>> = workspace_names
.iter()
.map(|p| (*p, transitive_closure(p, &deps_by_pkg)))
.collect();
let mut out: BTreeMap<String, BTreeSet<Utf8PathBuf>> = BTreeMap::new();
for (pkg_name, targets) in &targets_by_pkg {
for target in targets {
let binary_id = build_binary_id(pkg_name, target.kind, &target.name);
let mut sentinels = BTreeSet::new();
sentinels.insert(target.src_path.clone());
if !matches!(target.kind, TargetKind::Lib) {
if let Some(lib) = lib_src.get(pkg_name) {
sentinels.insert(lib.clone());
}
}
if let Some(deps) = transitive_deps.get(pkg_name) {
for dep in deps {
if let Some(lib) = lib_src.get(dep) {
sentinels.insert(lib.clone());
}
}
}
out.insert(binary_id, sentinels);
}
}
Ok(out)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetKind {
Lib,
ProcMacro,
Bin,
Test,
}
#[derive(Debug)]
struct TestTarget {
name: String,
kind: TargetKind,
src_path: Utf8PathBuf,
}
impl TestTarget {
fn is_test_runnable(&self) -> bool {
matches!(
self.kind,
TargetKind::Lib | TargetKind::ProcMacro | TargetKind::Bin | TargetKind::Test
)
}
}
fn parse_target(target: &serde_json::Value, root: &Path) -> Option<TestTarget> {
let is_test = target.get("test").and_then(|v| v.as_bool()).unwrap_or(false);
if !is_test {
return None;
}
let kinds: Vec<&str> = target
.get("kind")
.and_then(|v| v.as_array())
.map(|ks| ks.iter().filter_map(|k| k.as_str()).collect())
.unwrap_or_default();
let kind = if kinds.contains(&"lib") {
TargetKind::Lib
} else if kinds.contains(&"proc-macro") {
TargetKind::ProcMacro
} else if kinds.contains(&"bin") {
TargetKind::Bin
} else if kinds.contains(&"test") {
TargetKind::Test
} else {
return None;
};
let name = target.get("name").and_then(|v| v.as_str())?.to_string();
let abs = target.get("src_path").and_then(|v| v.as_str())?;
let rel = Path::new(abs).strip_prefix(root).ok()?;
let src_path = to_db_relative(rel)?;
Some(TestTarget { name, kind, src_path })
}
fn build_binary_id(package: &str, kind: TargetKind, target_name: &str) -> String {
match kind {
TargetKind::Lib | TargetKind::ProcMacro => package.to_string(),
TargetKind::Test => format!("{package}::{target_name}"),
TargetKind::Bin => format!("{package}::bin/{target_name}"),
}
}
fn transitive_closure<'a>(
start: &'a str,
deps_by_pkg: &BTreeMap<&'a str, BTreeSet<&'a str>>,
) -> BTreeSet<&'a str> {
let mut out: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> = deps_by_pkg
.get(start)
.map(|s| s.iter().copied().collect())
.unwrap_or_default();
while let Some(p) = stack.pop() {
if p == start || !out.insert(p) {
continue;
}
if let Some(next) = deps_by_pkg.get(p) {
for n in next {
if *n != start && !out.contains(n) {
stack.push(n);
}
}
}
}
out
}
pub fn git_changed_files(project_root: &Path) -> Result<Vec<String>> {
let mut files = Vec::new();
for args in [
vec!["diff", "--no-color", "--no-ext-diff", "--name-only", "-z"],
vec![
"diff",
"--no-color",
"--no-ext-diff",
"--name-only",
"--cached",
"-z",
],
vec!["ls-files", "-z", "--others", "--exclude-standard"],
] {
for path in run_git(project_root, &args)? {
if !files.contains(&path) {
files.push(path);
}
}
}
files.retain(|f| !f.ends_with(".profraw"));
files.sort();
files.dedup();
Ok(files)
}
pub fn git_working_tree_dirty(project_root: &Path) -> Result<bool> {
let lines = run_git(project_root, &["status", "--porcelain=v1", "-z"])?;
Ok(!lines.is_empty())
}
pub fn git_head_sha(project_root: &Path) -> Result<String> {
let lines = run_git(project_root, &["rev-parse", "HEAD"])?;
let sha = lines
.into_iter()
.next()
.context("git rev-parse HEAD returned no output")?
.trim()
.to_string();
if sha.is_empty() {
bail!("git rev-parse HEAD returned an empty sha");
}
Ok(sha)
}
pub fn relation_to_head(project_root: &Path, sha: &str) -> Result<ShaRelation> {
let head = git_head_sha(project_root)?;
if head == sha {
return Ok(ShaRelation::Equal);
}
let exists = Command::new("git")
.args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
.current_dir(project_root)
.output()
.with_context(|| format!("failed to run git cat-file -e {sha}"))?;
if !exists.status.success() {
return Ok(ShaRelation::Missing);
}
let lines = run_git(
project_root,
&["rev-list", "--count", &format!("{sha}..HEAD")],
)?;
let count = lines
.into_iter()
.next()
.context("git rev-list --count returned no output")?
.trim()
.parse::<u32>()
.context("git rev-list --count returned non-numeric output")?;
Ok(ShaRelation::Reachable { commits_ahead: count })
}
pub fn git_changed_line_ranges(
project_root: &Path,
collect_sha: &str,
) -> Result<BTreeMap<String, Vec<LineRange>>> {
let output = Command::new("git")
.args([
"diff",
"-U0",
"--no-color",
"--no-ext-diff",
"--no-renames",
"--src-prefix=a/",
"--dst-prefix=b/",
collect_sha,
])
.current_dir(project_root)
.output()
.context("failed to run git diff -U0")?;
if !output.status.success() {
let code = output
.status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string());
bail!(
"git diff -U0 {} failed (exit {}): {}",
collect_sha,
code,
String::from_utf8_lossy(&output.stderr).trim()
);
}
let stdout = std::str::from_utf8(&output.stdout)
.context("git diff stdout was not valid UTF-8")?;
parse_unified_diff(stdout)
}
pub fn git_added_files_since(
project_root: &Path,
collect_sha: &str,
) -> Result<Vec<String>> {
let mut out = run_git(
project_root,
&[
"diff",
"--name-only",
"--no-color",
"--no-renames",
"--diff-filter=A",
"-z",
collect_sha,
],
)?;
out.sort();
out.dedup();
Ok(out)
}
fn parse_unified_diff(diff: &str) -> Result<BTreeMap<String, Vec<LineRange>>> {
let mut map: BTreeMap<String, Vec<LineRange>> = BTreeMap::new();
let mut current_file: Option<String> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("--- ") {
current_file = parse_diff_path(rest);
} else if line.starts_with("@@ ") {
let Some(file) = current_file.clone() else { continue };
if file == "/dev/null" {
continue;
}
let Some(range) = parse_hunk_header(line) else {
continue;
};
map.entry(file).or_default().push(range);
}
}
for ranges in map.values_mut() {
ranges.sort_by_key(|r| (r.start, r.end));
let mut merged: Vec<LineRange> = Vec::with_capacity(ranges.len());
for r in ranges.drain(..) {
match merged.last_mut() {
Some(prev) if r.start <= prev.end + 1 => {
prev.end = prev.end.max(r.end);
}
_ => merged.push(r),
}
}
*ranges = merged;
}
Ok(map)
}
fn parse_diff_path(rest: &str) -> Option<String> {
let path = rest.split('\t').next().unwrap_or(rest);
if path == "/dev/null" {
return Some("/dev/null".to_string());
}
path.strip_prefix("a/").map(String::from)
}
fn parse_hunk_header(line: &str) -> Option<LineRange> {
let inner = line.strip_prefix("@@ ")?;
let end_idx = inner.find(" @@")?;
let body = &inner[..end_idx];
let mut parts = body.split_whitespace();
let old = parts.next()?;
let _new = parts.next()?;
let old = old.strip_prefix('-')?;
let (start, count) = match old.split_once(',') {
Some((s, c)) => (s.parse::<i64>().ok()?, c.parse::<i64>().ok()?),
None => (old.parse::<i64>().ok()?, 1),
};
if count == 0 {
Some(LineRange {
start,
end: start,
})
} else {
Some(LineRange {
start,
end: start + count - 1,
})
}
}
fn run_git(project_root: &Path, args: &[&str]) -> Result<Vec<String>> {
let output = Command::new("git")
.args(args)
.current_dir(project_root)
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if !output.status.success() {
let code = output
.status
.code()
.map_or_else(|| "signal".to_string(), |c| c.to_string());
bail!(
"git {} failed (exit {}): {}",
args.join(" "),
code,
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(output
.stdout
.split(|&b| b == 0)
.filter(|s| !s.is_empty())
.map(|s| String::from_utf8_lossy(s).into_owned())
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn init_repo(dir: &Path) -> Result<()> {
let run = |args: &[&str]| -> Result<()> {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.with_context(|| format!("git {}", args.join(" ")))?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
};
run(&["init", "-q", "-b", "main"])?;
run(&["config", "user.email", "test@example.com"])?;
run(&["config", "user.name", "Test"])?;
std::fs::write(dir.join("README.md"), b"hello\n")?;
run(&["add", "README.md"])?;
run(&["commit", "-q", "-m", "init"])?;
Ok(())
}
#[test]
fn working_tree_happy_path() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
std::fs::write(dir.path().join("new.txt"), b"x")?;
let files = git_changed_files(dir.path())?;
assert!(
files.iter().any(|f| f == "new.txt"),
"expected new.txt in {files:?}"
);
Ok(())
}
#[test]
fn awkward_filename_round_trips() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let awkward = "a b — weird-name.txt";
std::fs::write(dir.path().join(awkward), b"x")?;
let files = git_changed_files(dir.path())?;
assert!(
files.iter().any(|f| f == awkward),
"expected verbatim {awkward:?} in {files:?}"
);
Ok(())
}
#[test]
fn working_tree_dirty_distinguishes_states() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
assert!(!git_working_tree_dirty(dir.path())?);
std::fs::write(dir.path().join("new.txt"), b"x")?;
assert!(git_working_tree_dirty(dir.path())?);
std::fs::remove_file(dir.path().join("new.txt"))?;
assert!(!git_working_tree_dirty(dir.path())?);
std::fs::write(dir.path().join("README.md"), b"changed\n")?;
assert!(git_working_tree_dirty(dir.path())?);
Ok(())
}
#[test]
fn line_ranges_modify_in_place() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let lines: String = (1..=10).map(|i| format!("line {i}\n")).collect();
std::fs::write(dir.path().join("a.txt"), &lines)?;
Command::new("git")
.args(["add", "a.txt"])
.current_dir(dir.path())
.output()?;
Command::new("git")
.args(["commit", "-q", "-m", "add a"])
.current_dir(dir.path())
.output()?;
let modified: String = (1..=10)
.map(|i| if i == 5 { "modified\n".into() } else { format!("line {i}\n") })
.collect();
std::fs::write(dir.path().join("a.txt"), &modified)?;
let head = git_head_sha(dir.path())?;
let map = git_changed_line_ranges(dir.path(), &head)?;
let ranges = map.get("a.txt").expect("a.txt should appear");
assert_eq!(ranges, &vec![LineRange { start: 5, end: 5 }]);
Ok(())
}
#[test]
fn line_ranges_pure_insertion_is_single_line() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let lines: String = (1..=5).map(|i| format!("line {i}\n")).collect();
std::fs::write(dir.path().join("a.txt"), &lines)?;
Command::new("git")
.args(["add", "a.txt"])
.current_dir(dir.path())
.output()?;
Command::new("git")
.args(["commit", "-q", "-m", "add a"])
.current_dir(dir.path())
.output()?;
let modified = "line 1\nline 2\nline 3\nINSERTED A\nINSERTED B\nline 4\nline 5\n";
std::fs::write(dir.path().join("a.txt"), modified)?;
let head = git_head_sha(dir.path())?;
let map = git_changed_line_ranges(dir.path(), &head)?;
let ranges = map.get("a.txt").expect("a.txt should appear");
assert_eq!(ranges, &vec![LineRange { start: 3, end: 3 }]);
Ok(())
}
#[test]
fn parse_hunk_header_variants() {
assert_eq!(
parse_hunk_header("@@ -10,3 +20,1 @@"),
Some(LineRange { start: 10, end: 12 })
);
assert_eq!(
parse_hunk_header("@@ -7 +7 @@ fn foo()"),
Some(LineRange { start: 7, end: 7 })
);
assert_eq!(
parse_hunk_header("@@ -5,0 +6,2 @@"),
Some(LineRange { start: 5, end: 5 })
);
}
fn git(dir: &Path, args: &[&str]) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("git {} failed to spawn: {e}", args.join(" ")));
assert!(
out.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr),
);
}
#[test]
fn relation_to_head_equal_when_unchanged() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let head = git_head_sha(dir.path())?;
assert_eq!(relation_to_head(dir.path(), &head)?, ShaRelation::Equal);
Ok(())
}
#[test]
fn relation_to_head_ancestor_counts_commits_ahead() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let collect_sha = git_head_sha(dir.path())?;
for i in 1..=3 {
let name = format!("f{i}.txt");
std::fs::write(dir.path().join(&name), b"x")?;
git(dir.path(), &["add", &name]);
git(dir.path(), &["commit", "-q", "-m", &format!("c{i}")]);
}
assert_eq!(
relation_to_head(dir.path(), &collect_sha)?,
ShaRelation::Reachable { commits_ahead: 3 }
);
Ok(())
}
#[test]
fn relation_to_head_reachable_after_reset() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let init_sha = git_head_sha(dir.path())?;
std::fs::write(dir.path().join("a.txt"), b"x")?;
git(dir.path(), &["add", "a.txt"]);
git(dir.path(), &["commit", "-q", "-m", "B"]);
let b_sha = git_head_sha(dir.path())?;
git(dir.path(), &["reset", "--hard", "-q", &init_sha]);
let rel = relation_to_head(dir.path(), &b_sha)?;
assert!(
matches!(rel, ShaRelation::Reachable { .. }),
"expected Reachable, got {rel:?}"
);
Ok(())
}
#[test]
fn relation_to_head_missing_when_sha_absent() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
assert_eq!(
relation_to_head(dir.path(), "deadbeef00000000000000000000000000000000")?,
ShaRelation::Missing
);
Ok(())
}
#[test]
fn bad_sha_errors_loudly() -> Result<()> {
let dir = tempfile::tempdir()?;
init_repo(dir.path())?;
let err = git_changed_line_ranges(dir.path(), "deadbeef0000000000000000000000000000")
.expect_err("bad sha must error");
let msg = format!("{err:#}");
assert!(
msg.contains("git diff"),
"error should name the failing command: {msg}"
);
Ok(())
}
#[test]
fn binary_id_matches_nextest_format() {
assert_eq!(
build_binary_id("foo-lib", TargetKind::Lib, "foo_lib"),
"foo-lib"
);
assert_eq!(
build_binary_id("foo-derive", TargetKind::ProcMacro, "derive"),
"foo-derive"
);
assert_eq!(
build_binary_id("foo-lib", TargetKind::Test, "foo_test"),
"foo-lib::foo_test"
);
assert_eq!(
build_binary_id("foo-lib", TargetKind::Bin, "foo_bin"),
"foo-lib::bin/foo_bin"
);
}
#[test]
fn transitive_closure_walks_dep_graph() {
let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
deps.insert("a", ["b", "d"].into_iter().collect());
deps.insert("b", ["c"].into_iter().collect());
deps.insert("c", BTreeSet::new());
deps.insert("d", BTreeSet::new());
deps.insert("e", BTreeSet::new());
assert_eq!(
transitive_closure("a", &deps),
["b", "c", "d"].into_iter().collect()
);
assert_eq!(transitive_closure("b", &deps), ["c"].into_iter().collect());
assert_eq!(transitive_closure("c", &deps), BTreeSet::new());
assert_eq!(transitive_closure("e", &deps), BTreeSet::new());
assert_eq!(transitive_closure("zzz", &deps), BTreeSet::new());
}
#[test]
fn transitive_closure_handles_cycles() {
let mut deps: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
deps.insert("a", ["b"].into_iter().collect());
deps.insert("b", ["a"].into_iter().collect());
assert_eq!(transitive_closure("a", &deps), ["b"].into_iter().collect());
}
#[test]
fn sentinels_per_target_with_path_dep_chain() -> Result<()> {
let dir = tempfile::tempdir()?;
let root = canonicalize_no_verbatim(dir.path())?;
let math_lib = root.join("math/src/lib.rs");
let math_int = root.join("math/tests/integration.rs");
let strings_lib = root.join("strings/src/lib.rs");
let utils_lib = root.join("utils/src/lib.rs");
for f in [&math_lib, &math_int, &strings_lib, &utils_lib] {
std::fs::create_dir_all(f.parent().unwrap())?;
std::fs::write(f, b"")?;
}
let metadata = serde_json::json!({
"workspace_root": root.to_string_lossy(),
"packages": [
{
"name": "math",
"manifest_path": root.join("math/Cargo.toml").to_string_lossy(),
"targets": [
{"name": "math", "kind": ["lib"], "test": true,
"src_path": math_lib.to_string_lossy()},
{"name": "integration", "kind": ["test"], "test": true,
"src_path": math_int.to_string_lossy()},
],
"dependencies": [
{"name": "strings", "kind": null, "source": null},
{"name": "utils", "kind": "build", "source": null},
],
},
{
"name": "strings",
"manifest_path": root.join("strings/Cargo.toml").to_string_lossy(),
"targets": [
{"name": "strings", "kind": ["lib"], "test": true,
"src_path": strings_lib.to_string_lossy()},
],
"dependencies": [
{"name": "utils", "kind": null, "source": null},
],
},
{
"name": "utils",
"manifest_path": root.join("utils/Cargo.toml").to_string_lossy(),
"targets": [
{"name": "utils", "kind": ["lib"], "test": true,
"src_path": utils_lib.to_string_lossy()},
],
"dependencies": [],
},
],
});
let project = ProjectRoot {
workspace_root: root.clone(),
manifest_paths: vec![],
metadata,
};
let map = project.crate_root_sentinels_by_binary_id()?;
let p = |s: &str| Utf8PathBuf::from(s);
assert_eq!(
map.get("math").unwrap(),
&[p("math/src/lib.rs"), p("strings/src/lib.rs"), p("utils/src/lib.rs")]
.into_iter()
.collect::<BTreeSet<_>>(),
);
assert_eq!(
map.get("math::integration").unwrap(),
&[
p("math/src/lib.rs"),
p("math/tests/integration.rs"),
p("strings/src/lib.rs"),
p("utils/src/lib.rs"),
]
.into_iter()
.collect::<BTreeSet<_>>(),
);
assert_eq!(
map.get("strings").unwrap(),
&[p("strings/src/lib.rs"), p("utils/src/lib.rs")]
.into_iter()
.collect::<BTreeSet<_>>(),
);
assert_eq!(
map.get("utils").unwrap(),
&[p("utils/src/lib.rs")].into_iter().collect::<BTreeSet<_>>(),
);
Ok(())
}
}