use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use rayon::prelude::*;
use skif_config::{Ceiling, Config, ConfigCache, ConfigLoader, ConfigOverrides, ConfigSource, Origins};
use skif_core::FileSystem;
use skif_workspace::{
DiscoverOptions, RootKind, Workspace, claim_roots, declared_member_dirs, declares_workspace, repository_root,
scan_candidates,
};
use crate::error::Error;
use crate::root::{Located, config_ceiling, is_enclosed, locate, resolve_config};
#[derive(Debug)]
#[non_exhaustive]
pub struct Target {
pub root: PathBuf,
pub config: Config,
pub config_file: Option<PathBuf>,
pub sources: Vec<ConfigSource>,
pub origins: Origins,
pub workspace: Workspace,
}
impl Target {
#[must_use]
pub fn config_path(&self) -> Option<PathBuf> {
let file = self.config_file.as_ref()?;
let base: Vec<Component<'_>> = self.root.components().collect();
let target: Vec<Component<'_>> = file.components().collect();
let common = base
.iter()
.zip(&target)
.take_while(|(left, right)| left == right)
.count();
if common == 0 {
return Some(file.clone());
}
let mut path: PathBuf = std::iter::repeat_n("..", base.len() - common).collect();
path.extend(&target[common..]);
Some(path)
}
}
pub fn load_target(
root: &Path,
ceiling: &Ceiling,
fs: &dyn FileSystem,
overrides: &ConfigOverrides,
cache: &ConfigCache,
) -> Result<Target, Error> {
let mut resolved = resolve_config(root, ceiling, fs, overrides, cache)?;
let workspace = Workspace::discover_with_fs(root, &resolved.config, fs)?;
if !overrides.no_config {
let members: Vec<PathBuf> = workspace.member_dirs().into_iter().collect();
ConfigLoader::new(fs, cache).add_package_files(&mut resolved, root, &members)?;
}
let file = resolved.nearest_file().map(Path::to_path_buf);
Ok(Target {
root: root.to_path_buf(),
config: resolved.config,
config_file: file,
sources: resolved.sources,
origins: resolved.origins,
workspace,
})
}
fn load_in_repository(
root: &Path,
fs: &dyn FileSystem,
overrides: &ConfigOverrides,
cache: &ConfigCache,
) -> Result<Target, Error> {
load_target(root, &Ceiling::Vcs, fs, overrides, cache)
}
pub fn find_target(start: &Path, fs: &dyn FileSystem, overrides: &ConfigOverrides) -> Result<Target, Error> {
let cached = crate::cache::per_call(fs);
let fs: &dyn FileSystem = &cached;
let Located { project, ancestors } = locate(start, fs)?;
let cache = ConfigCache::new();
if declares_workspace(fs, &project) {
return load_in_repository(&project, fs, overrides, &cache);
}
for ancestor in ancestors {
match load_in_repository(&ancestor, fs, overrides, &cache) {
Ok(target) if target.workspace.claimed_dirs().contains(&project) => return Ok(target),
Err(error) if declared_member_dirs(fs, &ancestor).contains(&project) => return Err(error),
_ => {}
}
}
let mut target = load_in_repository(&project, fs, overrides, &cache)?;
if !declares_workspace(fs, &project) {
target
.workspace
.set_nested(is_enclosed(&project, &config_ceiling(&project, fs), fs));
}
Ok(target)
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RootFailure {
pub root: PathBuf,
pub error: Error,
}
#[derive(Debug, Default)]
#[non_exhaustive]
pub struct Discovery {
pub targets: Vec<Target>,
pub failures: Vec<RootFailure>,
pub truncated: Vec<PathBuf>,
}
pub fn discover_targets(start: &Path, fs: &dyn FileSystem, overrides: &ConfigOverrides) -> Result<Discovery, Error> {
let cached = crate::cache::per_call(fs);
let fs: &dyn FileSystem = &cached;
if !start.has_root() {
return Err(Error::RelativePath(start.to_path_buf()));
}
let dir = if fs.is_dir(start) {
start
} else {
start.parent().unwrap_or(start)
};
let cache = ConfigCache::new();
let ceiling = Ceiling::VcsOr(dir.to_path_buf());
let enclosing = |root: &Path| repository_ceiling(root, dir, fs);
let candidates = scan_candidates(dir, fs, &discover_options(dir, fs, overrides, &cache));
let mut workspaces: BTreeMap<PathBuf, Result<Target, Error>> = candidates
.workspaces
.par_iter()
.map(|root| (root.clone(), load_target(root, &ceiling, fs, overrides, &cache)))
.collect();
let members: BTreeMap<PathBuf, BTreeSet<PathBuf>> = workspaces
.iter()
.map(|(root, loaded)| {
let dirs = match loaded {
Ok(target) => target.workspace.claimed_dirs(),
Err(_) => declared_member_dirs(fs, root),
};
(root.clone(), dirs)
})
.collect();
let claimed = claim_roots(fs, &candidates, &members);
let slots: Vec<(PathBuf, Option<Result<Target, Error>>)> = claimed
.iter()
.map(|root| (root.path().to_path_buf(), workspaces.remove(root.path())))
.collect();
let loaded: Vec<(PathBuf, Result<Target, Error>)> = slots
.into_par_iter()
.map(|(root, found)| {
let result = found.unwrap_or_else(|| load_target(&root, &ceiling, fs, overrides, &cache));
(root, result)
})
.collect();
let mut discovery = Discovery {
truncated: candidates.truncated,
..Discovery::default()
};
let root_dirs: BTreeSet<&Path> = claimed.iter().map(RootKind::path).collect();
for (root, result) in loaded {
match result {
Ok(mut target) => {
let nested = !candidates.workspaces.contains(&root)
&& (root.ancestors().skip(1).any(|ancestor| root_dirs.contains(ancestor))
|| is_enclosed(&root, &enclosing(&root), fs));
target.workspace.set_nested(nested);
target.workspace.ignore_dirs(&candidates.workspaces);
discovery.targets.push(target);
}
Err(error) => discovery.failures.push(RootFailure { root, error }),
}
}
Ok(discovery)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UnappliedReason {
NotAMember,
NotLoaded,
}
impl UnappliedReason {
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::NotAMember => "not in the root or a workspace member directory",
Self::NotLoaded => "in a workspace member directory but not loaded for this run",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct UnappliedConfig {
pub path: PathBuf,
pub reason: UnappliedReason,
}
#[must_use]
pub fn unapplied_config_files(target: &Target, other_roots: &[&Path], fs: &dyn FileSystem) -> Vec<UnappliedConfig> {
let candidates = scan_candidates(&target.root, fs, &unapplied_scan_options());
unapplied_among(target, other_roots, candidates.config_files)
}
#[must_use]
pub fn unapplied_config_files_all(targets: &[Target], fs: &dyn FileSystem) -> Vec<Vec<UnappliedConfig>> {
let roots: Vec<&Path> = targets.iter().map(|target| target.root.as_path()).collect();
let mut scans: BTreeMap<&Path, BTreeSet<PathBuf>> = BTreeMap::new();
targets
.iter()
.map(|target| {
let outermost = roots
.iter()
.copied()
.filter(|root| target.root.starts_with(root))
.min_by_key(|root| root.components().count())
.unwrap_or(target.root.as_path());
let files = scans
.entry(outermost)
.or_insert_with(|| scan_candidates(outermost, fs, &unapplied_scan_options()).config_files);
let below = files.iter().filter(|file| file.starts_with(&target.root)).cloned();
unapplied_among(target, &roots, below)
})
.collect()
}
fn unapplied_scan_options() -> DiscoverOptions {
DiscoverOptions::default().with_ignore(Vec::new())
}
fn unapplied_among(
target: &Target,
other_roots: &[&Path],
config_files: impl IntoIterator<Item = PathBuf>,
) -> Vec<UnappliedConfig> {
let members = target.workspace.member_dirs();
config_files
.into_iter()
.filter_map(|path| {
let dir = path.parent()?;
let owned_elsewhere = other_roots
.iter()
.any(|other| *other != target.root && other.starts_with(&target.root) && dir.starts_with(other));
if dir == target.root || owned_elsewhere || target.sources.iter().any(|source| source.path == path) {
return None;
}
let reason = if members.contains(dir) {
UnappliedReason::NotLoaded
} else {
UnappliedReason::NotAMember
};
Some(UnappliedConfig { path, reason })
})
.collect()
}
fn discover_options(
start: &Path,
fs: &dyn FileSystem,
overrides: &ConfigOverrides,
cache: &ConfigCache,
) -> DiscoverOptions {
match resolve_config(start, &Ceiling::Vcs, fs, overrides, cache) {
Ok(resolved) => DiscoverOptions::default().with_ignore(resolved.config.workspace.discover_ignore),
Err(_) => DiscoverOptions::default(),
}
}
fn repository_ceiling(root: &Path, start: &Path, fs: &dyn FileSystem) -> PathBuf {
repository_root(fs, root).unwrap_or_else(|| start.to_path_buf())
}
pub fn discover_all(start: &Path, fs: &dyn FileSystem, overrides: &ConfigOverrides) -> Result<Vec<Target>, Error> {
let discovery = discover_targets(start, fs, overrides)?;
match discovery.failures.into_iter().next() {
Some(failure) => Err(failure.error),
None => Ok(discovery.targets),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use skif_core::MemoryFs;
use skif_workspace::PackageManager;
use super::*;
fn fs() -> MemoryFs {
MemoryFs::new()
.with_file("/r/skif.toml", "[lint]\nfail-on-warnings = true\n")
.with_file("/r/a/package.json", r#"{"name": "a", "version": "1.0.0"}"#)
.with_file("/r/b/skif.toml", "")
.with_file("/r/b/package.json", r#"{"name": "b", "version": "1.0.0"}"#)
}
#[test]
fn every_config_up_to_the_start_directory_contributes() {
let targets = discover_all(Path::new("/r"), &fs(), &ConfigOverrides::default()).unwrap();
let roots: Vec<_> = targets.iter().map(|target| target.root.clone()).collect();
assert_eq!(roots, [PathBuf::from("/r/a"), PathBuf::from("/r/b")]);
assert!(targets[0].config.lint.fail_on_warnings);
assert_eq!(targets[0].config_path(), Some(PathBuf::from("../skif.toml")));
assert_eq!(targets[0].sources.len(), 1);
assert!(targets[1].config.lint.fail_on_warnings);
assert_eq!(targets[1].config_path(), Some(PathBuf::from("skif.toml")));
assert_eq!(targets[1].sources.len(), 2);
}
#[test]
fn config_above_the_start_directory_is_not_inherited() {
let targets = discover_all(Path::new("/r/a"), &fs(), &ConfigOverrides::default()).unwrap();
assert_eq!(targets.len(), 1);
assert!(!targets[0].config.lint.fail_on_warnings);
assert_eq!(targets[0].config_path(), None);
}
#[test]
fn an_explicit_configuration_outside_the_root_is_named_relative_to_it() {
let fs = MemoryFs::new()
.with_file("/r/a/package.json", r#"{"name": "a", "version": "1.0.0"}"#)
.with_file("/etc/org/skif.toml", "[lint]\nfail-on-warnings = true\n");
let overrides = ConfigOverrides::default().with_config_file(Some(PathBuf::from("/etc/org/skif.toml")));
let target = load_target(Path::new("/r/a"), &Ceiling::Vcs, &fs, &overrides, &ConfigCache::new()).unwrap();
assert_eq!(target.config_file, Some(PathBuf::from("/etc/org/skif.toml")));
assert_eq!(target.config_path(), Some(PathBuf::from("../../etc/org/skif.toml")));
}
#[test]
fn relative_start_is_rejected() {
assert!(matches!(
discover_all(Path::new("r"), &fs(), &ConfigOverrides::default()),
Err(Error::RelativePath(_))
));
}
fn names(discovery: &Discovery) -> Vec<String> {
discovery
.targets
.iter()
.map(|target| target.root.display().to_string().replace('\\', "/"))
.collect()
}
fn discover(fs: &MemoryFs, start: &str) -> Discovery {
discover_targets(Path::new(start), fs, &ConfigOverrides::default()).unwrap()
}
fn packages_of(target: &Target) -> Vec<String> {
target
.workspace
.iter()
.filter(|(id, _)| !target.workspace.is_ignored(*id))
.filter_map(|(_, package)| package.name.clone())
.collect()
}
fn divergent_declarations() -> MemoryFs {
MemoryFs::new()
.with_file("/r/package.json", r#"{"name": "root", "workspaces": ["npmpkgs/*"]}"#)
.with_file("/r/pnpm-workspace.yaml", "packages:\n - 'yarnpkgs/*'\n")
.with_file("/r/skif.toml", "[workspace]\npackage-manager = \"npm\"\n")
.with_file("/r/npmpkgs/x/package.json", r#"{"name": "x"}"#)
.with_file("/r/yarnpkgs/y/package.json", r#"{"name": "y"}"#)
}
#[test]
fn members_are_claimed_with_the_root_s_own_configuration() {
let discovery = discover(&divergent_declarations(), "/r");
assert_eq!(names(&discovery), ["/r", "/r/yarnpkgs/y"]);
assert_eq!(packages_of(&discovery.targets[0]), ["root", "x"]);
assert_eq!(packages_of(&discovery.targets[1]), ["y"]);
}
#[test]
fn a_nested_workspace_is_a_root_and_its_members_are_linted_once() {
let fs = MemoryFs::new()
.with_file("/r/package.json", r#"{"name": "root", "workspaces": ["packages/*"]}"#)
.with_file(
"/r/packages/inner/package.json",
r#"{"name": "inner", "workspaces": ["x/*"]}"#,
)
.with_file("/r/packages/inner/x/y/package.json", r#"{"name": "y"}"#)
.with_file("/r/packages/plain/package.json", r#"{"name": "plain"}"#);
let discovery = discover(&fs, "/r");
assert_eq!(names(&discovery), ["/r", "/r/packages/inner"]);
assert_eq!(packages_of(&discovery.targets[0]), ["root", "plain"]);
assert_eq!(packages_of(&discovery.targets[1]), ["inner", "y"]);
let direct = find_target(Path::new("/r/packages/inner"), &fs, &ConfigOverrides::default()).unwrap();
assert_eq!(direct.root, Path::new("/r/packages/inner"));
assert_eq!(packages_of(&direct), ["inner", "y"]);
}
#[test]
fn a_root_that_fails_is_reported_and_the_others_still_load() {
let fs = MemoryFs::new()
.with_file("/r/bad/package.json", r#"{"name": "#)
.with_file("/r/good/package.json", r#"{"name": "good", "version": "1.0.0"}"#)
.with_file("/r/ws/pnpm-workspace.yaml", "packages:\n - 'p/*'\n")
.with_file("/r/ws/p/m/package.json", r#"{"name": "m"}"#);
let discovery = discover(&fs, "/r");
assert_eq!(names(&discovery), ["/r/good", "/r/ws"]);
let failed: Vec<_> = discovery.failures.iter().map(|failure| failure.root.clone()).collect();
assert_eq!(failed, [PathBuf::from("/r/bad")]);
assert!(discover_all(Path::new("/r"), &fs, &ConfigOverrides::default()).is_err());
}
#[test]
fn the_loaded_workspace_carries_its_own_members_and_configuration() {
let discovery = discover(&divergent_declarations(), "/r");
let root = &discovery.targets[0];
assert_eq!(root.workspace.member_dirs().len(), 1);
assert_eq!(root.config_path(), Some(PathBuf::from("skif.toml")));
}
fn nested_project_in_a_workspace_tree() -> MemoryFs {
MemoryFs::new()
.with_file("/r/.git/HEAD", "ref")
.with_file("/r/I/skif.toml", "[lint]\nfail-on-warnings = true\n")
.with_file("/r/I/package.json", r#"{"name": "i", "workspaces": ["pkgs/*"]}"#)
.with_file("/r/I/pkgs/a/package.json", r#"{"name": "a"}"#)
.with_file("/r/I/examples/e/package.json", r#"{"name": "e"}"#)
}
#[test]
fn a_project_gets_the_same_configuration_whichever_way_it_is_reached() {
let fs = nested_project_in_a_workspace_tree();
let overrides = ConfigOverrides::default();
let discovery = discover(&fs, "/r/I");
let by_discovery = discovery
.targets
.iter()
.find(|target| target.root == Path::new("/r/I/examples/e"))
.unwrap();
let direct = find_target(Path::new("/r/I/examples/e"), &fs, &overrides).unwrap();
assert!(by_discovery.config.lint.fail_on_warnings);
assert_eq!(direct.config, by_discovery.config);
assert_eq!(direct.config_path(), Some(PathBuf::from("../../skif.toml")));
}
#[test]
fn the_configuration_search_climbs_to_the_repository_root_and_no_higher() {
let fs = nested_project_in_a_workspace_tree()
.with_file("/skif.toml", "[lint]\nfail-on-warnings = true\n")
.with_file("/r/skif.toml", "")
.with_file("/r/solo/package.json", r#"{"name": "solo"}"#);
let found = find_target(Path::new("/r/solo"), &fs, &ConfigOverrides::default()).unwrap();
assert!(!found.config.lint.fail_on_warnings);
assert_eq!(found.config_file, Some(PathBuf::from("/r/skif.toml")));
assert_eq!(found.config_path(), Some(PathBuf::from("../skif.toml")));
}
#[test]
fn find_target_returns_the_ancestor_that_lists_the_project() {
let fs = divergent_declarations();
let target = find_target(Path::new("/r/npmpkgs/x"), &fs, &ConfigOverrides::default()).unwrap();
assert_eq!(target.root, Path::new("/r"));
let own = find_target(Path::new("/r/yarnpkgs/y"), &fs, &ConfigOverrides::default()).unwrap();
assert_eq!(own.root, Path::new("/r/yarnpkgs/y"));
}
#[test]
fn an_ancestor_that_lists_the_project_but_cannot_load_is_an_error() {
let fs = MemoryFs::new()
.with_file("/r/package.json", r#"{"workspaces": ["p/*"]"#)
.with_file("/r/pnpm-workspace.yaml", "packages:\n - 'p/*'\n")
.with_file("/r/p/m/package.json", r#"{"name": "m"}"#);
let error = find_target(Path::new("/r/p/m"), &fs, &ConfigOverrides::default()).unwrap_err();
assert!(matches!(error, Error::Workspace(_)), "{error}");
}
#[test]
fn both_ancestor_searches_name_an_ancestor_whose_configuration_is_broken() {
let fs = MemoryFs::new()
.with_file("/r/package.json", r#"{"workspaces": ["p/*"]}"#)
.with_file("/r/skif.toml", "[lint\n")
.with_file("/r/p/m/package.json", r#"{"name": "m"}"#);
let start = Path::new("/r/p/m");
assert_eq!(crate::find_workspace_root(start, &fs).unwrap(), Path::new("/r"));
let error = find_target(start, &fs, &ConfigOverrides::default()).unwrap_err();
assert!(matches!(error, Error::Config(_)), "{error}");
}
#[test]
fn unapplied_files_of_all_targets_match_the_per_target_answers() {
let fs = MemoryFs::new()
.with_file("/r/package.json", r#"{"name": "root", "workspaces": ["packages/*"]}"#)
.with_file("/r/packages/a/package.json", r#"{"name": "a"}"#)
.with_file("/r/packages/a/skif.toml", "")
.with_file("/r/stray/skif.toml", "")
.with_file("/r/inner/pnpm-workspace.yaml", "packages:\n - 'q/*'\n")
.with_file("/r/inner/q/n/package.json", r#"{"name": "n"}"#)
.with_file("/r/inner/q/n/skif.toml", "")
.with_file("/r/inner/loose/skif.toml", "");
let targets = discover(&fs, "/r").targets;
assert_eq!(names(&discover(&fs, "/r")), ["/r", "/r/inner"]);
let roots: Vec<&Path> = targets.iter().map(|target| target.root.as_path()).collect();
let expected: Vec<Vec<UnappliedConfig>> = targets
.iter()
.map(|target| unapplied_config_files(target, &roots, &fs))
.collect();
assert_eq!(unapplied_config_files_all(&targets, &fs), expected);
let paths =
|files: &[UnappliedConfig]| -> Vec<PathBuf> { files.iter().map(|file| file.path.clone()).collect() };
assert_eq!(paths(&expected[0]), [PathBuf::from("/r/stray/skif.toml")]);
assert_eq!(paths(&expected[1]), [PathBuf::from("/r/inner/loose/skif.toml")]);
}
#[test]
fn only_standalone_packages_below_another_root_are_nested() {
let fs = MemoryFs::new()
.with_file("/s/a/package.json", r#"{"name": "a"}"#)
.with_file("/s/b/package.json", r#"{"name": "b"}"#)
.with_file("/s/b/inner/package.json", r#"{"name": "inner"}"#)
.with_file("/s/w/package.json", r#"{"name": "w", "workspaces": ["p/*"]}"#)
.with_file("/s/w/p/m/package.json", r#"{"name": "m"}"#)
.with_file("/s/w/examples/e/package.json", r#"{"name": "e"}"#)
.with_file("/s/w/examples/e/deeper/pnpm-workspace.yaml", "packages:\n - 'q/*'\n")
.with_file("/s/w/examples/e/deeper/q/n/package.json", r#"{"name": "n"}"#);
let discovery = discover(&fs, "/s");
let nested: Vec<_> = discovery
.targets
.iter()
.map(|target| {
(
target.root.display().to_string().replace('\\', "/"),
target.workspace.is_nested(),
)
})
.collect();
assert_eq!(
nested,
[
("/s/a".to_owned(), false),
("/s/b".to_owned(), false),
("/s/b/inner".to_owned(), true),
("/s/w".to_owned(), false),
("/s/w/examples/e".to_owned(), true),
("/s/w/examples/e/deeper".to_owned(), false),
]
);
}
#[test]
fn a_single_root_is_never_nested() {
let fs = MemoryFs::new()
.with_file("/r/package.json", r#"{"name": "r"}"#)
.with_file("/r/inner/package.json", r#"{"name": "inner"}"#);
let target = find_target(Path::new("/r/inner"), &fs, &ConfigOverrides::default()).unwrap();
assert!(!target.workspace.is_nested());
}
#[test]
fn a_workspace_file_without_a_root_manifest_is_a_workspace_and_not_a_failure() {
let fs = MemoryFs::new()
.with_file("/r/ws/pnpm-workspace.yaml", "packages:\n - 'p/*'\n")
.with_file("/r/ws/p/m/package.json", r#"{"name": "m"}"#);
let discovery = discover(&fs, "/r");
assert!(discovery.failures.is_empty(), "{:?}", discovery.failures);
assert_eq!(names(&discovery), ["/r/ws"]);
assert!(!discovery.targets[0].workspace.has_root_manifest());
assert_eq!(packages_of(&discovery.targets[0]), ["m"]);
let direct = find_target(Path::new("/r/ws"), &fs, &ConfigOverrides::default()).unwrap();
assert_eq!(direct.root, Path::new("/r/ws"));
assert_eq!(packages_of(&direct), ["m"]);
}
#[test]
fn a_rush_workspace_is_one_root_with_its_projects_as_members() {
let fs = MemoryFs::new()
.with_file(
"/r/rush.json",
r#"{"npmVersion": "10.0.0", "projects": [{"packageName": "a", "projectFolder": "apps/a"}]}"#,
)
.with_file("/r/apps/a/package.json", r#"{"name": "a"}"#)
.with_file("/r/apps/a/examples/e/package.json", r#"{"name": "e"}"#);
let discovery = discover(&fs, "/r");
assert_eq!(names(&discovery), ["/r"]);
assert_eq!(packages_of(&discovery.targets[0]), ["a"]);
assert_eq!(
discovery.targets[0].workspace.package_manager,
Some(PackageManager::Npm)
);
}
#[test]
fn fixture_directories_are_skipped_unless_the_configuration_says_otherwise() {
let tree = MemoryFs::new()
.with_file("/r/real/package.json", r#"{"name": "real"}"#)
.with_file("/r/real/fixtures/f/package.json", r#"{"name": "f"}"#)
.with_file("/r/examples/e/package.json", r#"{"name": "e"}"#);
assert_eq!(names(&discover(&tree, "/r")), ["/r/examples/e", "/r/real"]);
let replaced = tree
.clone()
.with_file("/r/skif.toml", "[workspace]\ndiscover-ignore = [\"examples\"]\n");
assert_eq!(names(&discover(&replaced, "/r")), ["/r/real", "/r/real/fixtures/f"]);
let none = tree.with_file("/r/skif.toml", "[workspace]\ndiscover-ignore = []\n");
assert_eq!(
names(&discover(&none, "/r")),
["/r/examples/e", "/r/real", "/r/real/fixtures/f"]
);
}
}