use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use crate::entity::{EntityKey, Kind};
use crate::git;
#[derive(Debug, Clone)]
pub struct SetSpec {
pub name: String,
pub roots: Vec<PathBuf>,
pub include: Vec<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Discovery {
pub entities: Vec<EntityKey>,
pub directories_visited: usize,
pub abandoned: bool,
}
pub(crate) const ABANDON_AFTER: Duration = Duration::from_secs(30);
pub fn discover(spec: &SetSpec) -> Discovery {
walk(spec, ABANDON_AFTER, None)
}
pub(crate) fn discover_watched_with_deadline(
spec: &SetSpec,
progress: Arc<AtomicUsize>,
abandon_after: Duration,
) -> Discovery {
walk(spec, abandon_after, Some(&progress))
}
pub fn count(spec: &SetSpec) -> usize {
discover(spec).entities.len()
}
#[derive(Debug, Clone)]
pub(crate) struct DiscoveredEntity {
pub key: EntityKey,
pub kind: Kind,
pub common_dir: Arc<Path>,
pub repo: Option<Arc<gix::ThreadSafeRepository>>,
pub display_name_override: Option<Arc<str>>,
}
pub(crate) fn resolve(
spec: &SetSpec,
boundaries: &[EntityKey],
) -> (Vec<DiscoveredEntity>, Vec<(EntityKey, String)>) {
resolve_with_cache(spec, boundaries, &HashMap::new())
}
pub(crate) fn resolve_with_cache(
spec: &SetSpec,
boundaries: &[EntityKey],
cache: &HashMap<EntityKey, Arc<gix::ThreadSafeRepository>>,
) -> (Vec<DiscoveredEntity>, Vec<(EntityKey, String)>) {
let globs = Globs::compile(&spec.include, &spec.exclude);
let mut entities = Vec::with_capacity(boundaries.len());
let mut failures = Vec::new();
for key in boundaries {
let path = key.path();
let resolved = if let Some(cached) = cache.get(key) {
Ok((
git::resolve_from_open(cached.to_thread_local()),
Some(Arc::clone(cached)),
))
} else {
git::resolve_boundary(path).map(|resolved| (resolved, None))
};
let (kind, common_dir, submodules, repo) = match resolved {
Ok((resolved, cached_repo)) => {
let git::Resolved {
kind,
common_dir,
submodules,
repo,
} = resolved;
let repo = cached_repo.or_else(|| Some(Arc::new(repo)));
(kind, common_dir, submodules, repo)
}
Err(_) => (
Kind::Repo,
Arc::from(path.join(".git")),
Ok(Vec::new()),
None,
),
};
entities.push(DiscoveredEntity {
key: key.clone(),
kind,
common_dir: Arc::clone(&common_dir),
repo,
display_name_override: None,
});
match submodules {
Ok(submodules) => {
for submodule in submodules {
let submodule_path = path.join(&submodule.relative_path);
let submodule_path =
fs::canonicalize(&submodule_path).unwrap_or(submodule_path);
if !globs.admits(&submodule_path) {
continue;
}
let submodule_common_dir =
common_dir.join("modules").join(submodule.name.as_ref());
entities.push(DiscoveredEntity {
key: EntityKey::new(Arc::from(submodule_path.as_path())),
kind: Kind::Submodule,
common_dir: Arc::from(submodule_common_dir),
repo: None,
display_name_override: Some(Arc::from(
submodule.relative_path.to_string_lossy().as_ref(),
)),
});
}
}
Err(error) => failures.push((key.clone(), error.to_string())),
}
}
(entities, failures)
}
fn is_boundary(dir: &Path) -> bool {
dir.join(".git").exists()
}
struct Globs {
include: Vec<globset::GlobMatcher>,
exclude: Vec<globset::GlobMatcher>,
}
impl Globs {
fn compile(include: &[String], exclude: &[String]) -> Self {
let compile_all = |patterns: &[String]| {
patterns
.iter()
.filter_map(|pattern| globset::Glob::new(pattern).ok())
.map(|glob| glob.compile_matcher())
.collect()
};
Globs {
include: compile_all(include),
exclude: compile_all(exclude),
}
}
fn admits(&self, path: &Path) -> bool {
let included = self.include.is_empty() || self.include.iter().any(|m| m.is_match(path));
let excluded = self.exclude.iter().any(|m| m.is_match(path));
included && !excluded
}
}
fn walk(spec: &SetSpec, abandon_after: Duration, progress: Option<&AtomicUsize>) -> Discovery {
let globs = Globs::compile(&spec.include, &spec.exclude);
let mut entities = Vec::new();
let mut discovered_by_walk: HashSet<PathBuf> = HashSet::new();
let mut symlinks: Vec<PathBuf> = Vec::new();
let mut directories_visited = 0usize;
let started = Instant::now();
let mut abandoned = false;
'roots: for root in &spec.roots {
let mut stack = vec![root.clone()];
while let Some(dir) = stack.pop() {
directories_visited += 1;
if let Some(progress) = progress {
progress.store(directories_visited, Ordering::Relaxed);
}
if started.elapsed() >= abandon_after {
abandoned = true;
break 'roots;
}
if is_boundary(&dir) {
let canonical = fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
discovered_by_walk.insert(canonical.clone());
if globs.admits(&canonical) {
entities.push(EntityKey::new(Arc::from(canonical.as_path())));
}
continue;
}
let Ok(read_dir) = fs::read_dir(&dir) else {
continue;
};
for entry in read_dir.flatten() {
let entry_path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
symlinks.push(entry_path);
continue;
}
if file_type.is_dir() {
stack.push(entry_path);
}
}
}
}
for link in &symlinks {
resolve_symlink(link, &globs, &mut discovered_by_walk, &mut entities);
}
Discovery {
entities,
directories_visited,
abandoned,
}
}
fn resolve_symlink(
link: &Path,
globs: &Globs,
discovered_by_walk: &mut HashSet<PathBuf>,
entities: &mut Vec<EntityKey>,
) {
let Ok(metadata) = fs::metadata(link) else {
return;
};
if !metadata.is_dir() {
return;
}
let Ok(target) = fs::canonicalize(link) else {
return;
};
if !is_boundary(&target) {
return;
}
if discovered_by_walk.contains(&target) {
return;
}
discovered_by_walk.insert(target.clone());
if globs.admits(&target) {
entities.push(EntityKey::new(Arc::from(target.as_path())));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
fn init_repo(path: &Path) {
fs::create_dir_all(path).expect("create repo dir");
gix::init(path).expect("init repo");
}
fn spec(roots: Vec<PathBuf>) -> SetSpec {
SetSpec {
name: "test".to_string(),
roots,
include: Vec::new(),
exclude: Vec::new(),
}
}
fn root_of(dir: &tempfile::TempDir) -> PathBuf {
dir.path().canonicalize().expect("canonicalize temp dir")
}
fn paths(discovery: &Discovery) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = discovery
.entities
.iter()
.map(|key| key.path().to_path_buf())
.collect();
paths.sort();
paths
}
#[test]
fn a_lone_repo_at_the_root_is_discovered() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
let discovery = discover(&spec(vec![root_dir.clone()]));
assert_eq!(paths(&discovery), vec![repo]);
assert!(!discovery.abandoned);
}
#[test]
fn count_matches_a_set_against_real_disposable_repositories_with_commits() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo_a = root_dir.join("repo-a");
let repo_b = root_dir.join("repo-b");
init_repo(&repo_a);
init_repo(&repo_b);
crate::test_support::git(&repo_a, &["commit", "--allow-empty", "-m", "first"]);
crate::test_support::git(&repo_b, &["commit", "--allow-empty", "-m", "first"]);
fs::create_dir_all(root_dir.join("not-a-repo")).expect("create plain dir");
assert_eq!(count(&spec(vec![root_dir])), 2);
}
#[test]
fn the_walk_never_descends_past_a_repo_boundary() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
let nested = outer.join("vendor").join("nested-repo");
init_repo(&nested);
for i in 0..50 {
let leaf = nested.join(format!("dir-{i}")).join("a").join("b");
fs::create_dir_all(&leaf).expect("create decoy tree");
}
let discovery = discover(&spec(vec![root_dir.clone()]));
assert_eq!(paths(&discovery), vec![outer.clone()]);
assert!(
discovery.directories_visited <= 3,
"expected the walk to stop at the outer boundary, visited {} directories",
discovery.directories_visited
);
}
#[test]
fn a_nested_repo_is_reached_only_by_naming_it_as_its_own_root() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
let nested = outer.join("vendor").join("nested-repo");
init_repo(&nested);
let discovery = discover(&spec(vec![root_dir.clone(), nested.clone()]));
assert_eq!(paths(&discovery), {
let mut expected = vec![outer, nested];
expected.sort();
expected
});
}
#[test]
fn a_bare_repository_produces_no_row_with_no_exclusion_rule_of_its_own() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let bare = root_dir.join("bare.git");
fs::create_dir_all(&bare).expect("create bare dir");
gix::init_bare(&bare).expect("init bare repo");
let discovery = discover(&spec(vec![root_dir.clone()]));
assert!(paths(&discovery).is_empty());
}
#[test]
fn a_vendored_checkout_inside_another_working_tree_produces_no_row() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
let vendored = outer.join("vendor").join("some-lib");
init_repo(&vendored);
let discovery = discover(&spec(vec![root_dir.clone()]));
assert_eq!(paths(&discovery), vec![outer]);
}
#[test]
fn a_symlink_to_a_repo_is_followed_and_recorded() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let target = root_dir.join("target-repo");
init_repo(&target);
let root = root_dir.join("root");
fs::create_dir_all(&root).expect("create root");
symlink(&target, root.join("link")).expect("create symlink");
let discovery = discover(&spec(vec![root]));
let canonical_target = fs::canonicalize(&target).expect("canonicalize target");
assert_eq!(paths(&discovery), vec![canonical_target]);
}
#[test]
fn a_symlink_to_an_ordinary_directory_is_not_followed_or_recorded() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let target = root_dir.join("ordinary-dir");
fs::create_dir_all(&target).expect("create target");
let would_be_found = target.join("would-be-a-repo");
init_repo(&would_be_found);
let root = root_dir.join("root");
fs::create_dir_all(&root).expect("create root");
symlink(&target, root.join("link")).expect("create symlink");
let discovery = discover(&spec(vec![root]));
assert!(paths(&discovery).is_empty());
}
#[test]
fn a_self_referential_symlink_does_not_hang_or_panic() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let root = root_dir.join("root");
fs::create_dir_all(&root).expect("create root");
symlink(root.join("loop"), root.join("loop")).expect("create self-referential symlink");
let discovery = discover(&spec(vec![root]));
assert!(paths(&discovery).is_empty());
}
#[test]
fn a_symlink_resolving_to_an_already_discovered_repo_is_dropped_silently() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
symlink(&repo, root_dir.join("link-to-repo")).expect("create symlink");
let discovery = discover(&spec(vec![root_dir.clone()]));
assert_eq!(paths(&discovery), vec![repo]);
}
#[test]
fn glob_matching_is_case_sensitive_even_though_apfs_is_not() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("Node_Modules").join("some-lib");
init_repo(&repo);
let mut excluding_wrong_case = spec(vec![root_dir.clone()]);
excluding_wrong_case.exclude = vec!["**/node_modules/**".to_string()];
let discovery = discover(&excluding_wrong_case);
assert_eq!(
paths(&discovery),
vec![repo.clone()],
"a lowercase exclude glob must not match a differently-cased real path"
);
let mut excluding_right_case = spec(vec![root_dir.clone()]);
excluding_right_case.exclude = vec!["**/Node_Modules/**".to_string()];
let discovery = discover(&excluding_right_case);
assert!(
paths(&discovery).is_empty(),
"an exclude glob matching the real path's exact case must match"
);
}
#[test]
fn an_include_glob_bounds_what_is_discovered() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let kept = root_dir.join("kept-repo");
init_repo(&kept);
let dropped = root_dir.join("dropped-repo");
init_repo(&dropped);
let mut only_kept = spec(vec![root_dir.clone()]);
only_kept.include = vec!["**/kept-repo".to_string()];
let discovery = discover(&only_kept);
assert_eq!(paths(&discovery), vec![kept]);
}
#[test]
fn an_exclude_glob_beats_an_include_glob() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("both-match");
init_repo(&repo);
let mut set = spec(vec![root_dir.clone()]);
set.include = vec!["**/both-match".to_string()];
set.exclude = vec!["**/both-match".to_string()];
let discovery = discover(&set);
assert!(paths(&discovery).is_empty());
}
#[test]
fn overlapping_roots_are_not_deduplicated() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
let discovery = discover(&spec(vec![root_dir.clone(), repo.clone()]));
assert_eq!(discovery.entities.len(), 2);
assert!(paths(&discovery).iter().all(|p| p == &repo));
}
#[test]
fn the_walk_abandons_after_its_deadline_and_reports_what_it_found() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
for i in 0..20 {
fs::create_dir_all(root_dir.join(format!("plain-{i}"))).expect("create plain dir");
}
let discovery = walk(&spec(vec![root_dir.clone()]), Duration::ZERO, None);
assert!(discovery.abandoned);
assert!(discovery.directories_visited >= 1);
}
#[test]
fn a_watched_walk_publishes_its_running_directory_count() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
for i in 0..5 {
fs::create_dir_all(root_dir.join(format!("plain-{i}"))).expect("create plain dir");
}
let progress = Arc::new(AtomicUsize::new(0));
let discovery = discover_watched_with_deadline(
&spec(vec![root_dir.clone()]),
Arc::clone(&progress),
ABANDON_AFTER,
);
assert_eq!(
progress.load(Ordering::Relaxed),
discovery.directories_visited
);
assert!(progress.load(Ordering::Relaxed) > 0);
}
#[test]
fn a_missing_root_is_not_an_error_and_finds_nothing() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let missing = root_dir.join("does-not-exist");
let discovery = discover(&spec(vec![missing]));
assert!(paths(&discovery).is_empty());
assert!(!discovery.abandoned);
}
fn write_gitmodules(repo: &Path, name: &str, path: &str) {
fs::write(
repo.join(".gitmodules"),
format!(
"[submodule \"{name}\"]\n\tpath = {path}\n\turl = https://example.com/{name}.git\n"
),
)
.expect("write .gitmodules");
}
fn resolved_paths(entities: &[DiscoveredEntity]) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = entities
.iter()
.map(|entity| entity.key.path().to_path_buf())
.collect();
paths.sort();
paths
}
#[test]
fn resolve_hands_back_the_thread_safe_handle_it_already_opened() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, _) = resolve(&set, &discovery.entities);
let entity = entities
.iter()
.find(|entity| entity.key.path() == repo)
.expect("repo entity present");
let repo_handle = entity
.repo
.as_ref()
.expect("resolve should hand back the handle it opened");
assert_eq!(
repo_handle.to_thread_local().head_id().ok(),
gix::open(&repo).unwrap().head_id().ok()
);
}
#[test]
fn resolve_with_cache_reuses_an_already_open_handle_by_pointer_identity() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let cached = Arc::new(git::open_thread_safe(&repo).expect("open repo"));
let mut cache = HashMap::new();
cache.insert(
EntityKey::new(Arc::from(repo.as_path())),
Arc::clone(&cached),
);
let (entities, _) = resolve_with_cache(&set, &discovery.entities, &cache);
let entity = entities
.iter()
.find(|entity| entity.key.path() == repo)
.expect("repo entity present");
let handle = entity
.repo
.as_ref()
.expect("a cached boundary must still carry a handle");
assert!(
Arc::ptr_eq(handle, &cached),
"resolve_with_cache must hand back the very same Arc it was given, not a freshly opened one"
);
}
#[test]
fn resolve_with_cache_still_resolves_a_boundary_absent_from_the_cache() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let repo = root_dir.join("repo");
init_repo(&repo);
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, _) = resolve_with_cache(&set, &discovery.entities, &HashMap::new());
let entity = entities
.iter()
.find(|entity| entity.key.path() == repo)
.expect("repo entity present");
assert!(matches!(entity.kind, Kind::Repo));
assert!(
entity.repo.is_some(),
"an uncached boundary must still be opened and carry a handle"
);
}
#[test]
fn a_submodule_carries_no_cached_repository_handle() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
write_gitmodules(&outer, "lib", "vendor/lib");
fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, _) = resolve(&set, &discovery.entities);
let submodule = entities
.iter()
.find(|entity| matches!(entity.kind, Kind::Submodule))
.expect("submodule entity present");
assert!(submodule.repo.is_none());
}
#[test]
fn a_submodules_display_name_is_its_relative_path_not_its_basename() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
write_gitmodules(&outer, "lib", "vendor/lib");
fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, _) = resolve(&set, &discovery.entities);
let submodule = entities
.iter()
.find(|entity| matches!(entity.kind, Kind::Submodule))
.expect("submodule entity present");
assert_eq!(
submodule.display_name_override.as_deref(),
Some("vendor/lib"),
"expected the declared relative path, not the basename `lib`"
);
let parent = entities
.iter()
.find(|entity| entity.key.path() == outer)
.expect("parent entity present");
assert_eq!(
parent.display_name_override, None,
"a walked boundary keeps the generic basename fallback"
);
}
#[test]
fn a_submodule_is_found_by_reading_gitmodules_with_the_walk_never_descending_to_it() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
write_gitmodules(&outer, "lib", "vendor/lib");
let submodule_dir = outer.join("vendor").join("lib");
for i in 0..50 {
let leaf = submodule_dir.join(format!("dir-{i}")).join("a").join("b");
fs::create_dir_all(&leaf).expect("create decoy tree inside the submodule");
}
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
assert!(
discovery.directories_visited <= 2,
"expected the walk to stop at the outer boundary without looking inside \
the submodule's directory, visited {} directories",
discovery.directories_visited
);
let (entities, failures) = resolve(&set, &discovery.entities);
assert!(failures.is_empty());
let submodule_path = fs::canonicalize(&submodule_dir).expect("submodule dir exists");
assert_eq!(resolved_paths(&entities), {
let mut expected = vec![outer.clone(), submodule_path.clone()];
expected.sort();
expected
});
let submodule = entities
.iter()
.find(|entity| entity.key.path() == submodule_path)
.expect("submodule entity present");
assert!(matches!(submodule.kind, Kind::Submodule));
let parent = entities
.iter()
.find(|entity| entity.key.path() == outer)
.expect("parent entity present");
assert!(matches!(parent.kind, Kind::Repo));
}
#[test]
fn a_submodule_is_always_returned_even_though_nothing_here_can_hide_it() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
write_gitmodules(&outer, "lib", "vendor/lib");
fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, _) = resolve(&set, &discovery.entities);
assert!(
entities
.iter()
.any(|entity| matches!(entity.kind, Kind::Submodule)),
"a discovered Submodule must be present in the returned list with no way to suppress it"
);
}
#[test]
fn an_exclude_glob_covering_a_submodules_path_excludes_it() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
write_gitmodules(&outer, "lib", "vendor/lib");
fs::create_dir_all(outer.join("vendor").join("lib")).expect("create submodule dir");
let mut set = spec(vec![root_dir.clone()]);
set.exclude = vec!["**/vendor/**".to_string()];
let discovery = discover(&set);
let (entities, failures) = resolve(&set, &discovery.entities);
assert!(failures.is_empty());
assert!(
!entities
.iter()
.any(|entity| matches!(entity.kind, Kind::Submodule)),
"an exclude glob covering the submodule's own path must keep it out of the result"
);
assert!(
entities.iter().any(|entity| entity.key.path() == outer),
"the exclude glob targets only the submodule's path, not its parent"
);
}
#[test]
fn an_unparseable_gitmodules_file_is_reported_as_a_failure_with_no_submodule_rows() {
let dir = tempfile::tempdir().expect("temp dir");
let root_dir = root_of(&dir);
let outer = root_dir.join("outer");
init_repo(&outer);
fs::write(
outer.join(".gitmodules"),
"[submodule \"lib\"\n\tpath = lib\n",
)
.expect("write malformed .gitmodules");
let set = spec(vec![root_dir.clone()]);
let discovery = discover(&set);
let (entities, failures) = resolve(&set, &discovery.entities);
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].0.path(), outer);
assert!(
!entities
.iter()
.any(|entity| matches!(entity.kind, Kind::Submodule))
);
assert!(entities.iter().any(|entity| entity.key.path() == outer));
}
}