use std::path::{Path as StdPath, PathBuf};
#[derive(Debug, Clone)]
pub(crate) struct WalkOptions {
pub(crate) hidden: bool,
pub(crate) respect_ignore: bool,
}
impl Default for WalkOptions {
fn default() -> Self {
Self {
hidden: false,
respect_ignore: true,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct Walked {
pub(crate) files: Vec<PathBuf>,
pub(crate) unreadable: Vec<(PathBuf, String)>,
}
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(files: &[PathBuf]) -> Vec<String> {
files
.iter()
.map(|path| {
path.file_name()
.expect("a file name")
.to_string_lossy()
.into_owned()
})
.collect()
}
fn files(inputs: &[PathBuf], options: &WalkOptions) -> Vec<PathBuf> {
collect(inputs, options).expect("walks").files
}
#[test]
fn a_named_file_is_the_whole_walk() {
let tree = TempTree::new("walk-one");
let file = tree.write("a.json", "{}");
assert_eq!(names(&files(&[file], &WalkOptions::default())), ["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 = files(&[tree.path().to_path_buf()], &WalkOptions::default());
let again = files(&[tree.path().to_path_buf()], &WalkOptions::default());
assert_eq!(names(&first), ["a.json", "m.json", "z.json"]);
assert_eq!(first, again);
}
#[test]
fn files_of_every_extension_are_walked() {
let tree = TempTree::new("walk-any");
for name in ["a.json", "main.tf", "access.log.1", "Makefile"] {
tree.write(name, "x");
}
let walked = files(&[tree.path().to_path_buf()], &WalkOptions::default());
assert_eq!(walked.len(), 4);
}
#[test]
fn ignored_files_are_skipped_and_read_on_request() {
let tree = TempTree::new("walk-ignore");
tree.mkdir(".git");
tree.write(".gitignore", "ignored.log\n");
tree.write("ignored.log", "10.0.0.1\n");
tree.write("kept.log", "10.0.0.2\n");
let walked = files(&[tree.path().to_path_buf()], &WalkOptions::default());
assert!(names(&walked).contains(&"kept.log".to_string()));
assert!(!names(&walked).contains(&"ignored.log".to_string()));
let all = files(
&[tree.path().to_path_buf()],
&WalkOptions {
respect_ignore: false,
..WalkOptions::default()
},
);
assert!(names(&all).contains(&"ignored.log".to_string()));
}
#[test]
fn hidden_files_are_read_on_request() {
let tree = TempTree::new("walk-hidden");
tree.write(".hidden.json", "{}");
let default = files(&[tree.path().to_path_buf()], &WalkOptions::default());
assert!(default.is_empty());
let all = files(
&[tree.path().to_path_buf()],
&WalkOptions {
hidden: true,
..WalkOptions::default()
},
);
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 = files(&[file], &WalkOptions::default());
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 = files(&[file.clone(), file], &WalkOptions::default());
assert_eq!(walked.len(), 1);
}
#[cfg(unix)]
#[test]
fn a_directory_the_walk_cannot_read_is_carried_and_never_fatal() {
use std::os::unix::fs::PermissionsExt as _;
let tree = TempTree::new("walk-locked");
tree.write("kept.log", "10.0.0.1\n");
let locked = tree.mkdir("locked");
tree.write("locked/hidden.log", "10.0.0.2\n");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("permissions");
let walked = collect(&[tree.path().to_path_buf()], &WalkOptions::default());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
.expect("permissions");
let walked = walked.expect("an unreadable directory is not a malformed question");
assert!(names(&walked.files).contains(&"kept.log".to_string()));
assert!(
walked.unreadable.len() == 1
|| names(&walked.files).contains(&"hidden.log".to_string()),
"{walked:?}"
);
}
}