use std::ffi::{OsStr, OsString};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::is_ignored_dir;
#[derive(Debug)]
pub struct WalkEntry {
path: PathBuf,
file_type: fs::FileType,
}
impl WalkEntry {
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn into_path(self) -> PathBuf {
self.path
}
#[must_use]
pub fn file_name(&self) -> &OsStr {
self.path.file_name().unwrap_or_else(|| OsStr::new(""))
}
#[must_use]
pub fn is_dir(&self) -> bool {
self.file_type.is_dir()
}
#[must_use]
pub fn is_file(&self) -> bool {
self.file_type.is_file()
}
#[must_use]
pub fn file_type(&self) -> fs::FileType {
self.file_type
}
}
#[derive(Debug)]
enum Pending {
Descend(PathBuf),
Item(io::Result<WalkEntry>),
}
#[derive(Debug)]
pub struct Walk {
stack: Vec<Pending>,
also_pruned: Vec<OsString>,
allowed: Vec<OsString>,
watch_extensions: Vec<OsString>,
pruned_with_sources: Vec<PathBuf>,
}
const PRUNED_SCAN_MAX_DEPTH: usize = 3;
const PRUNED_SCAN_MAX_ENTRIES: usize = 256;
impl Walk {
#[must_use]
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
stack: vec![Pending::Descend(root.into())],
also_pruned: Vec::new(),
allowed: Vec::new(),
watch_extensions: Vec::new(),
pruned_with_sources: Vec::new(),
}
}
#[must_use]
pub fn prune_also<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.also_pruned.extend(names.into_iter().map(Into::into));
self
}
#[must_use]
pub fn allow<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.allowed.extend(names.into_iter().map(Into::into));
self
}
#[must_use]
pub fn warn_on_pruned_sources<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.watch_extensions
.extend(extensions.into_iter().map(Into::into));
self
}
#[must_use]
pub fn pruned_with_sources(&self) -> &[PathBuf] {
&self.pruned_with_sources
}
fn is_pruned(&self, name: &OsStr) -> bool {
if self.allowed.iter().any(|allowed| allowed == name) {
return false;
}
is_ignored_dir(name) || self.also_pruned.iter().any(|pruned| pruned == name)
}
fn contains_watched_extension_within_budget(&self, dir: &Path) -> bool {
let mut stack = vec![(dir.to_path_buf(), 1_usize)];
let mut visited = 0_usize;
while let Some((current, depth)) = stack.pop() {
let Ok(entries) = fs::read_dir(¤t) else {
continue;
};
for entry in entries.flatten() {
visited += 1;
if visited > PRUNED_SCAN_MAX_ENTRIES {
return false;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_file() {
let matches = entry
.path()
.extension()
.is_some_and(|ext| self.watch_extensions.iter().any(|w| w == ext));
if matches {
return true;
}
} else if file_type.is_dir() && depth < PRUNED_SCAN_MAX_DEPTH {
stack.push((entry.path(), depth + 1));
}
}
}
false
}
fn children(&mut self, dir: &Path) -> io::Result<Vec<Pending>> {
let mut entries = fs::read_dir(dir)?.collect::<io::Result<Vec<_>>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
let mut pending = Vec::with_capacity(entries.len());
for entry in entries {
match entry.file_type() {
Ok(file_type) => {
if file_type.is_dir() && self.is_pruned(&entry.file_name()) {
if !self.watch_extensions.is_empty()
&& self.contains_watched_extension_within_budget(&entry.path())
{
self.pruned_with_sources.push(entry.path());
}
continue;
}
pending.push(Pending::Item(Ok(WalkEntry {
path: entry.path(),
file_type,
})));
}
Err(err) => pending.push(Pending::Item(Err(err))),
}
}
Ok(pending)
}
}
impl Iterator for Walk {
type Item = io::Result<WalkEntry>;
fn next(&mut self) -> Option<Self::Item> {
loop {
match self.stack.pop()? {
Pending::Item(Ok(entry)) => {
if entry.is_dir() {
self.stack.push(Pending::Descend(entry.path.clone()));
}
return Some(Ok(entry));
}
Pending::Item(Err(err)) => return Some(Err(err)),
Pending::Descend(dir) => match self.children(&dir) {
Ok(children) => self.stack.extend(children.into_iter().rev()),
Err(err) => return Some(Err(err)),
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let dir = std::env::temp_dir().join(format!(
"brink-walk-test-{label}-{}-{n}-{nanos}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn relative(root: &Path, walk: Walk) -> Vec<String> {
walk.map(|entry| {
let entry = entry.expect("entry reads");
entry
.path()
.strip_prefix(root)
.expect("entry is under root")
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
})
.collect()
}
fn write(path: PathBuf, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("mkdir parent");
}
fs::write(path, contents).expect("write fixture file");
}
#[test]
fn walk_prunes_ignored_dirs_by_construction() {
let root = temp_dir("prune");
write(root.join("main.ink"), "main");
write(root.join("target/stray.ink"), "stray");
write(root.join("target/debug/build.ink"), "build");
write(root.join(".git/HEAD"), "ref");
write(root.join(".git/objects/pack.ink"), "pack");
write(root.join("node_modules/pkg/index.ink"), "pkg");
write(root.join("src/nested/deep/target/out.ink"), "deep");
write(root.join("src/nested/deep/keep.ink"), "keep");
assert_eq!(
relative(&root, Walk::new(&root)),
vec![
"main.ink",
"src",
"src/nested",
"src/nested/deep",
"src/nested/deep/keep.ink",
],
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_prunes_by_exact_directory_name_only() {
let root = temp_dir("prune-exact");
write(root.join("targets/a.ink"), "a");
write(root.join("target.brink"), "not a dir");
write(root.join("my-node_modules/b.ink"), "b");
assert_eq!(
relative(&root, Walk::new(&root)),
vec![
"my-node_modules",
"my-node_modules/b.ink",
"target.brink",
"targets",
"targets/a.ink",
],
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_never_prunes_its_own_root() {
let wrapper = temp_dir("prune-root");
let root = wrapper.join("node_modules/vendor-ink");
write(root.join("main.ink"), "main");
write(root.join("target/debug/build.ink"), "build");
assert_eq!(relative(&root, Walk::new(&root)), vec!["main.ink"]);
fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
}
#[test]
fn walk_is_pre_order_and_sorted_despite_hostile_creation_order() {
let root = temp_dir("order");
write(root.join("z.ink"), "z");
write(root.join("b/z.ink"), "bz");
write(root.join("b/a.ink"), "ba");
write(root.join("a.ink"), "a");
write(root.join("b/c/inner.ink"), "inner");
assert_eq!(
relative(&root, Walk::new(&root)),
vec![
"a.ink",
"b",
"b/a.ink",
"b/c",
"b/c/inner.ink",
"b/z.ink",
"z.ink",
],
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_prune_also_narrows_on_top_of_the_standing_policy() {
let root = temp_dir("prune-also");
write(root.join("case/story.ink"), "story");
write(root.join("case/oracle/e0.oracle.json"), "{}");
write(root.join("case/target/out.ink"), "out");
assert_eq!(
relative(&root, Walk::new(&root).prune_also(["oracle"])),
vec!["case", "case/story.ink"],
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_allow_unprunes_a_named_standing_policy_entry() {
let root = temp_dir("allow");
write(root.join("main.ink"), "main");
write(root.join("node_modules/vendor-ink/lib.ink"), "vendored");
write(root.join("target/stray.ink"), "stray");
assert_eq!(
relative(&root, Walk::new(&root).allow(["node_modules"])),
vec![
"main.ink",
"node_modules",
"node_modules/vendor-ink",
"node_modules/vendor-ink/lib.ink",
],
"node_modules/ must be un-pruned by `allow`, target/ must stay pruned"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_pruned_with_sources_reports_only_directories_shallowly_holding_watched_files() {
let root = temp_dir("pruned-with-sources");
write(root.join("main.ink"), "main");
write(root.join("node_modules/stray.ink"), "stray");
write(root.join(".git/HEAD"), "ref");
let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
let yielded: Vec<String> = relative_lossy(&root, walk.by_ref());
assert_eq!(
yielded,
vec!["main.ink"],
"reporting a pruned directory must not change what is yielded"
);
let pruned: Vec<String> = walk
.pruned_with_sources()
.iter()
.map(|p| {
p.strip_prefix(&root)
.expect("pruned path is under root")
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
pruned,
vec!["node_modules"],
"only node_modules/ shallowly holds a watched .ink file; .git/ (HEAD, no \
extension) must not be reported"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_pruned_with_sources_detects_a_file_nested_one_level_inside_the_pruned_directory() {
let root = temp_dir("pruned-with-sources-nested");
write(root.join("main.ink"), "main");
write(root.join("node_modules/pkg/nested.ink"), "nested");
let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
let _: Vec<String> = relative_lossy(&root, walk.by_ref());
let pruned: Vec<String> = walk
.pruned_with_sources()
.iter()
.map(|p| {
p.strip_prefix(&root)
.expect("pruned path is under root")
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
pruned,
vec!["node_modules"],
"node_modules/pkg/nested.ink is within the bounded scan and must be found, got {:?}",
walk.pruned_with_sources()
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_pruned_with_sources_is_bounded_by_depth() {
let root = temp_dir("pruned-with-sources-too-deep");
write(root.join("main.ink"), "main");
write(root.join("node_modules/a/b/c/d/too-deep.ink"), "deep");
let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
let _: Vec<String> = relative_lossy(&root, walk.by_ref());
assert!(
walk.pruned_with_sources().is_empty(),
"a watched file past PRUNED_SCAN_MAX_DEPTH must not be found, got {:?}",
walk.pruned_with_sources()
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_pruned_with_sources_ignores_a_directory_named_with_a_watched_extension() {
let root = temp_dir("pruned-with-sources-dir-name");
write(root.join("main.ink"), "main");
fs::create_dir_all(root.join("node_modules/vendor.ink")).expect("mkdir vendor.ink dir");
let mut walk = Walk::new(&root).warn_on_pruned_sources(["ink"]);
let _: Vec<String> = relative_lossy(&root, walk.by_ref());
assert!(
walk.pruned_with_sources().is_empty(),
"a directory named vendor.ink/ must not trip the diagnostic, got {:?}",
walk.pruned_with_sources()
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_pruned_with_sources_is_empty_when_never_requested() {
let root = temp_dir("pruned-with-sources-opt-in");
write(root.join("main.ink"), "main");
write(root.join("node_modules/stray.ink"), "stray");
let mut walk = Walk::new(&root);
let _: Vec<String> = relative_lossy(&root, walk.by_ref());
assert!(walk.pruned_with_sources().is_empty());
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[test]
fn walk_of_a_missing_root_yields_one_error_then_ends() {
let wrapper = temp_dir("missing");
let root = wrapper.join("nope");
let mut walk = Walk::new(&root);
let first = walk.next().expect("one item");
assert_eq!(
first.expect_err("missing root is an error").kind(),
io::ErrorKind::NotFound
);
assert!(
walk.next().is_none(),
"the walk must not loop after an error"
);
assert_eq!(Walk::new(&root).flatten().count(), 0);
fs::remove_dir_all(&wrapper).expect("cleanup temp dir");
}
#[cfg(unix)]
#[test]
fn walk_continues_past_an_unreadable_subdirectory() {
use std::os::unix::fs::PermissionsExt;
let root = temp_dir("unreadable");
write(root.join("a/keep.ink"), "keep");
fs::create_dir_all(root.join("b")).expect("mkdir b");
write(root.join("c/also-keep.ink"), "also");
fs::set_permissions(root.join("b"), fs::Permissions::from_mode(0o000))
.expect("chmod b unreadable");
let entries: Vec<String> = relative_lossy(&root, Walk::new(&root));
fs::set_permissions(root.join("b"), fs::Permissions::from_mode(0o755))
.expect("restore b permissions");
assert_eq!(
entries,
vec!["a", "a/keep.ink", "b", "c", "c/also-keep.ink"],
"the unreadable branch is skipped, later siblings still walked"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
#[cfg(unix)]
#[test]
fn walk_does_not_descend_into_a_symlinked_directory_but_admits_a_symlinked_file() {
use std::os::unix::fs::symlink;
let root = temp_dir("symlink");
write(root.join("real/nested.ink"), "nested");
write(root.join("real-file.ink"), "real");
symlink(root.join("real"), root.join("link-dir")).expect("symlink dir");
symlink(root.join("real-file.ink"), root.join("link-file.ink")).expect("symlink file");
let entries: Vec<(String, bool, bool)> = Walk::new(&root)
.map(|entry| {
let entry = entry.expect("entry reads");
(
entry
.path()
.strip_prefix(&root)
.expect("entry is under root")
.to_string_lossy()
.into_owned(),
entry.is_dir(),
entry.is_file(),
)
})
.collect();
assert_eq!(
entries,
vec![
("link-dir".to_string(), false, false),
("link-file.ink".to_string(), false, false),
("real".to_string(), true, false),
("real/nested.ink".to_string(), false, true),
("real-file.ink".to_string(), false, true),
],
"the symlinked directory is yielded once and never descended into; \
the symlinked file is still yielded, with is_dir()==false"
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
fn relative_lossy(
root: &Path,
walk: impl Iterator<Item = io::Result<WalkEntry>>,
) -> Vec<String> {
walk.flatten()
.map(|entry| {
entry
.path()
.strip_prefix(root)
.expect("entry is under root")
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
})
.collect()
}
#[test]
fn walk_entry_reports_kind_and_file_name() {
let root = temp_dir("entry-kind");
write(root.join("dir/file.ink"), "f");
let entries: Vec<(String, bool, bool)> = Walk::new(&root)
.map(|entry| {
let entry = entry.expect("entry reads");
(
entry.file_name().to_string_lossy().into_owned(),
entry.is_dir(),
entry.is_file(),
)
})
.collect();
assert_eq!(
entries,
vec![
("dir".to_string(), true, false),
("file.ink".to_string(), false, true),
],
);
fs::remove_dir_all(&root).expect("cleanup temp dir");
}
}