luff 0.2.1

Print files with formatting
Documentation
//! Shared walk builder configuration
//!
//! Centralises `WalkBuilder` setup so that `DirectoryWalker` and
//! `FileListWalker` stay in sync.  Any change to filtering, gitignore
//! handling, or depth logic only needs to happen here.

use crate::config::Config;
use ignore::WalkBuilder;
use log::debug;
use std::path::Path;

/// Create a fully-configured `WalkBuilder` for the given root.
///
/// Applies:
/// - Hidden-file filtering (based on `config.include_dotfiles()`)
/// - Gitignore rules (based on `config.respect_gitignore()`)
/// - Max-depth limiting
/// - Custom ignore patterns (directory, file, extension, and glob)
///
/// Both `DirectoryWalker` and `FileListWalker` delegate to this function
/// so that filter behaviour is defined in exactly one place.
pub fn configured_walk_builder(root: &Path, config: &Config) -> WalkBuilder {
    let mut builder = WalkBuilder::new(root);

    let _ = builder
        .hidden(!config.include_dotfiles())
        .git_ignore(config.respect_gitignore())
        .git_global(config.respect_gitignore())
        .git_exclude(config.respect_gitignore())
        .require_git(false);

    // Adjust depth to match user expectations.
    // Use saturating_add to prevent overflow when max_depth is usize::MAX.
    if config.max_depth() > 0 {
        let _ = builder.max_depth(Some(config.max_depth().saturating_add(1)));
    }

    // Clone patterns for filter closure
    let patterns = config.patterns().clone();
    // Capture root for relative path calculation in filter
    let filter_root = root.to_path_buf();

    // Apply custom ignore patterns
    let _ = builder.filter_entry(move |entry| {
        let file_name = entry.file_name().to_str().unwrap_or("");
        let path = entry.path();

        // Calculate relative path for glob matching.
        // We match against relative path because user globs are relative
        // (e.g. "target/**").  If strip_prefix fails (shouldn't happen
        // for children), we use the full path.
        let relative_path = path.strip_prefix(&filter_root).unwrap_or(path);

        // Check glob patterns for all entries
        if patterns.should_ignore_glob(relative_path) {
            debug!("Skipping path (glob match): {}", path.display());
            return false;
        }

        // Cache file type to avoid redundant calls. The `ignore` crate
        // populates this from the initial `readdir` result, so it's
        // cheap, but binding it once makes the intent explicit and
        // guards against future cost changes.
        let file_type = entry.file_type();
        let is_dir = file_type.as_ref().is_some_and(std::fs::FileType::is_dir);
        let is_file = file_type.as_ref().is_some_and(std::fs::FileType::is_file);

        if is_dir && patterns.should_ignore_directory(file_name) {
            debug!("Skipping directory: {}", path.display());
            return false;
        }

        if is_file {
            if patterns.should_ignore_file(file_name) {
                debug!("Skipping file: {}", path.display());
                return false;
            }

            // Filter binary/non-text extensions at walk time to avoid
            // unnecessary downstream I/O and processing.
            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                if patterns.should_ignore_extension(ext) {
                    debug!("Skipping file (extension): {}", path.display());
                    return false;
                }
            }
        }

        true
    });

    builder
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::Config;
    use serial_test::serial;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    #[serial]
    fn test_configured_builder_respects_hidden() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().canonicalize().unwrap();

        fs::write(root.join(".hidden"), "h").unwrap();
        fs::write(root.join("visible.txt"), "v").unwrap();

        // Default config hides dotfiles
        let config = Config::new_for_test(root.clone());
        let walk = configured_walk_builder(&root, &config);

        let names: Vec<String> = walk
            .build()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
            .map(|e| e.file_name().to_str().unwrap_or_default().to_string())
            .collect();

        assert!(names.contains(&"visible.txt".to_string()));
        // .hidden should be excluded by default
        assert!(!names.contains(&".hidden".to_string()));
    }

    #[test]
    #[serial]
    fn test_configured_builder_filters_binary_extensions() {
        let temp = TempDir::new().unwrap();
        let root = temp.path().canonicalize().unwrap();

        fs::write(root.join("image.png"), "fake png").unwrap();
        fs::write(root.join("archive.zip"), "fake zip").unwrap();
        fs::write(root.join("source.rs"), "fn main() {}").unwrap();
        fs::write(root.join("readme.txt"), "hello").unwrap();

        let config = Config::new_for_test(root.clone());
        let walk = configured_walk_builder(&root, &config);

        let names: Vec<String> = walk
            .build()
            .filter_map(Result::ok)
            .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
            .map(|e| e.file_name().to_str().unwrap_or_default().to_string())
            .collect();

        assert!(names.contains(&"source.rs".to_string()));
        assert!(names.contains(&"readme.txt".to_string()));
        // Binary extensions should be filtered at walk time
        assert!(!names.contains(&"image.png".to_string()));
        assert!(!names.contains(&"archive.zip".to_string()));
    }
}