use std::path::{Path as StdPath, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct WalkOptions {
pub(crate) hidden: bool,
pub(crate) respect_ignore: bool,
}
#[derive(Debug, Default)]
pub(crate) struct Walked {
pub(crate) files: Vec<PathBuf>,
pub(crate) unreadable: Vec<(PathBuf, String)>,
}
impl Default for WalkOptions {
fn default() -> Self {
Self {
hidden: false,
respect_ignore: true,
}
}
}
pub(crate) fn collect(inputs: &[PathBuf], options: &WalkOptions) -> Result<Walked, String> {
let mut walked = Walked::default();
for input in inputs {
let metadata =
std::fs::metadata(input).map_err(|error| format!("{}: {error}", input.display()))?;
if metadata.is_file() {
walked.files.push(input.clone());
continue;
}
let found = walk_directory(input, options);
walked.files.extend(found.files);
walked.unreadable.extend(found.unreadable);
}
walked.files.sort();
walked.files.dedup();
walked.unreadable.sort();
walked.unreadable.dedup();
Ok(walked)
}
fn walk_directory(root: &StdPath, options: &WalkOptions) -> Walked {
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(!options.hidden)
.git_ignore(options.respect_ignore)
.git_global(options.respect_ignore)
.git_exclude(options.respect_ignore)
.ignore(options.respect_ignore)
.parents(options.respect_ignore)
.follow_links(false);
let mut walked = Walked::default();
for entry in builder.build() {
match entry {
Ok(entry) if entry.file_type().is_some_and(|kind| kind.is_file()) => {
walked.files.push(entry.into_path());
}
Ok(_) => {}
Err(error) => walked.unreadable.push(refusal(&error, root)),
}
}
walked
}
fn refusal(error: &ignore::Error, root: &StdPath) -> (PathBuf, String) {
let path = match error {
ignore::Error::WithPath { path, .. } => path.clone(),
ignore::Error::Loop { child, .. } => child.clone(),
_ => root.to_path_buf(),
};
let reason = error
.io_error()
.map_or_else(|| error.to_string(), std::string::ToString::to_string);
(path, reason)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn names(walked: &Walked) -> Vec<String> {
walked
.files
.iter()
.map(|path| {
path.file_name()
.expect("a file name")
.to_string_lossy()
.into_owned()
})
.collect()
}
#[test]
fn a_named_file_is_the_whole_walk() {
let tree = TempTree::new("walk-one");
let file = tree.write("a.json", "{}");
assert_eq!(
names(&collect(&[file], &WalkOptions::default()).expect("walks")),
["a.json"]
);
}
#[test]
fn a_directory_is_walked_in_a_stable_order() {
let tree = TempTree::new("walk-order");
for name in ["z.json", "a.json", "m.json"] {
tree.write(name, "{}");
}
let first = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
let again = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert_eq!(names(&first), ["a.json", "m.json", "z.json"]);
assert_eq!(first.files, again.files);
}
#[test]
fn files_of_every_extension_are_walked() {
let tree = TempTree::new("walk-any");
for name in ["a.json", "b.ts", "c.py", "Makefile"] {
tree.write(name, "x");
}
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert_eq!(walked.files.len(), 4);
}
#[test]
fn ignored_files_are_skipped() {
let tree = TempTree::new("walk-ignore");
tree.mkdir(".git");
tree.write(".gitignore", "ignored.ts\n");
tree.write("ignored.ts", "const a = 1;");
tree.write("kept.ts", "const b = 2;");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(names(&walked).contains(&"kept.ts".to_string()));
assert!(!names(&walked).contains(&"ignored.ts".to_string()));
}
#[test]
fn ignored_files_are_read_on_request() {
let tree = TempTree::new("walk-noignore");
tree.mkdir(".git");
tree.write(".gitignore", "ignored.ts\n");
tree.write("ignored.ts", "const a = 1;");
let walked = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
respect_ignore: false,
..WalkOptions::default()
},
)
.expect("walks");
assert!(names(&walked).contains(&"ignored.ts".to_string()));
}
#[test]
fn hidden_files_are_read_on_request() {
let tree = TempTree::new("walk-hidden");
tree.write(".hidden.json", "{}");
let default =
collect(&[tree.path().to_path_buf()], &WalkOptions::default()).expect("walks");
assert!(default.files.is_empty());
let all = collect(
&[tree.path().to_path_buf()],
&WalkOptions {
hidden: true,
..WalkOptions::default()
},
)
.expect("walks");
assert_eq!(names(&all), [".hidden.json"]);
}
#[test]
fn an_explicitly_named_file_beats_the_ignore_rules() {
let tree = TempTree::new("walk-explicit");
tree.mkdir(".git");
tree.write(".gitignore", ".hidden.json\n");
let file = tree.write(".hidden.json", "{}");
let walked = collect(&[file], &WalkOptions::default()).expect("walks");
assert_eq!(names(&walked), [".hidden.json"]);
}
#[test]
fn a_missing_input_is_refused_by_name() {
let tree = TempTree::new("walk-missing");
let error =
collect(&[tree.path().join("nope")], &WalkOptions::default()).expect_err("a refusal");
assert!(error.contains("nope"), "{error}");
}
#[test]
fn the_same_file_named_twice_is_read_once() {
let tree = TempTree::new("walk-dedupe");
let file = tree.write("a.json", "{}");
let walked = collect(&[file.clone(), file], &WalkOptions::default()).expect("walks");
assert_eq!(walked.files.len(), 1);
}
#[cfg(unix)]
#[test]
fn a_directory_the_filesystem_refuses_is_carried_not_fatal() {
use std::os::unix::fs::PermissionsExt;
let tree = TempTree::new("walk-refused");
tree.write("kept.json", "{\"a\":1}");
let locked = tree.mkdir("locked");
tree.write("locked/inner.json", "{\"b\":2}");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("a locked directory");
let refused = std::fs::read_dir(&locked).is_err();
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default());
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
let walked = walked.expect("an unreadable entry is not a refusal of the whole walk");
assert!(
names(&walked).contains(&"kept.json".to_string()),
"the rest of the tree is still walked"
);
if !refused {
eprintln!(
"SKIPPED a_directory_the_filesystem_refuses_is_carried_not_fatal: \
this user reads a mode-000 directory"
);
return;
}
assert_eq!(walked.unreadable.len(), 1, "{:?}", walked.unreadable);
assert_eq!(walked.unreadable[0].0, locked);
}
}