Intermediate Projects
File Search Utility
A file search utility (modeled after Unix find and fd) traverses a directory tree while filtering entries by filename substring/glob pattern, file extension, entry type (file vs directory), and byte size bounds (min_size..=max_size).
File Search Pipeline:
Root Directory (`Path`)
|
v Iterative Work Queue (`VecDeque<(PathBuf, usize)>`)
[ Check Depth Limit & Hidden Dot-File Rules ]
|
v Inspect `DirEntry::file_type()` (Avoids `stat` syscall unless needed!)
[ Apply `SearchCriteria` Filters ]
├── Name substring / extension match
├── Entry kind (`FilesOnly`, `DirsOnly`, `Any`)
└── Size range (`min_bytes`, `max_bytes` via `entry.metadata()?.len()`)
|
v
Sorted `Vec<SearchMatch>`
Architecture of the Search Filter
| Component | Type | Responsibility |
|---|---|---|
TargetKind |
Enum (Any, File, Directory) |
Filters results to regular files, directories, or both. |
SearchCriteria |
Struct | Holds optional name substring, extension filter, max recursion depth, hidden-file toggle, and size bounds. |
SearchMatch |
Struct | Captures the matched PathBuf, depth, is_dir, and size_bytes. |
Complete Runnable Implementation
use std::collections::VecDeque;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
Any,
File,
Directory,
}
#[derive(Debug, Clone)]
pub struct SearchCriteria {
pub name_contains: Option<String>,
pub extension: Option<String>,
pub kind: TargetKind,
pub min_bytes: Option<u64>,
pub max_bytes: Option<u64>,
pub max_depth: usize,
pub include_hidden: bool,
}
impl Default for SearchCriteria {
fn default() -> Self {
Self {
name_contains: None,
extension: None,
kind: TargetKind::Any,
min_bytes: None,
max_bytes: None,
max_depth: usize::MAX,
include_hidden: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMatch {
pub path: PathBuf,
pub depth: usize,
pub is_dir: bool,
pub size_bytes: u64,
}
pub fn search_directory(
root: impl AsRef<Path>,
criteria: &SearchCriteria,
) -> io::Result<Vec<SearchMatch>> {
let mut matches = Vec::new();
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new();
queue.push_back((root.as_ref().to_path_buf(), 0));
while let Some((current_dir, depth)) = queue.pop_front() {
if depth >= criteria.max_depth {
continue;
}
let entries = match fs::read_dir(¤t_dir) {
Ok(iter) => iter,
Err(_) => continue, // Skip unreadable directories gracefully
};
for entry_res in entries {
let entry = entry_res?;
let file_name_os = entry.file_name();
let file_name = file_name_os.to_string_lossy();
// 1. Skip hidden dotfiles (`.git`, `.DS_Store`) unless `include_hidden` is true:
if !criteria.include_hidden && file_name.starts_with('.') {
continue;
}
let file_type = entry.file_type()?;
let path = entry.path();
let next_depth = depth + 1;
// Enqueue subdirectories (do not follow symlinks to prevent infinite loops):
if file_type.is_dir() && !file_type.is_symlink() {
queue.push_back((path.clone(), next_depth));
}
// 2. Check TargetKind filter:
let kind_ok = match criteria.kind {
TargetKind::Any => true,
TargetKind::File => file_type.is_file(),
TargetKind::Directory => file_type.is_dir(),
};
if !kind_ok {
continue;
}
// 3. Check filename substring filter:
if let Some(sub) = &criteria.name_contains {
if !file_name.contains(sub.as_str()) {
continue;
}
}
// 4. Check file extension filter:
if let Some(ext) = &criteria.extension {
let actual_ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !actual_ext.eq_ignore_ascii_case(ext) {
continue;
}
}
// 5. Query metadata only after cheap name/type checks pass:
let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
if criteria.min_bytes.is_some_and(|min| size_bytes < min) {
continue;
}
if criteria.max_bytes.is_some_and(|max| size_bytes > max) {
continue;
}
matches.push(SearchMatch {
path,
depth: next_depth,
is_dir: file_type.is_dir(),
size_bytes,
});
}
}
matches.sort_unstable_by(|a, b| a.path.cmp(&b.path));
Ok(matches)
}
fn main() -> io::Result<()> {
let base = env::temp_dir().join("rust_ch67_search_demo");
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(base.join("src/net"))?;
fs::create_dir_all(base.join(".hidden"))?;
fs::write(base.join("Cargo.toml"), "[package]\nname = \"demo\"\n")?;
fs::write(base.join("src/lib.rs"), "pub mod net;\n")?;
fs::write(base.join("src/net/server.rs"), "pub fn start_server() {}\n")?;
fs::write(base.join(".hidden/secret.rs"), "// hidden\n")?;
let criteria = SearchCriteria {
extension: Some("rs".to_string()),
kind: TargetKind::File,
include_hidden: false,
..SearchCriteria::default()
};
let found = search_directory(&base, &criteria)?;
assert_eq!(found.len(), 2); // Finds `lib.rs` and `server.rs`, skipping `.hidden/secret.rs`!
for item in &found {
let rel = item.path.strip_prefix(&base).unwrap();
println!(
"[depth={}] {:<20} ({} bytes)",
item.depth,
rel.display(),
item.size_bytes
);
}
fs::remove_dir_all(&base)?;
Ok(())
}
[depth=2] src/lib.rs (13 bytes)
[depth=3] src/net/server.rs (25 bytes)