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(&current_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)