fast-fs 0.2.1

High-speed async file system traversal library with batteries-included file browser component
Documentation
// <FILE>crates/fast-fs/src/reader/fnc_read_dir_recursive.rs</FILE> - <DESC>Recursive directory reader with filtering support</DESC>
// <VERS>VERSION: 2.1.0</VERS>
// <WCTX>Implementing nav module PRD - extending FileEntry</WCTX>
// <CLOG>Updated FileEntry::new calls to include is_symlink and is_readonly</CLOG>

//! Recursive directory reader with filtering options
use super::fnc_read_dir::read_dir;
use crate::filter::fnc_apply_filters::apply_filters;
use crate::{Error, FileEntry, GitignoreMatcher, Result, TraversalOptions};
use ignore::WalkBuilder;
use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// Recursively read directory with filtering options
///
/// This function traverses a directory tree and applies configured filters including:
/// - Depth limiting (controlled by `max_depth` option)
/// - Gitignore pattern matching (respects .gitignore files)
/// - Hidden file filtering (configurable via `include_hidden`)
/// - Extension filtering (only include specified file types)
/// - Symlink loop detection (prevents infinite recursion)
///
/// # Arguments
///
/// * `path` - Root directory to traverse
/// * `options` - Traversal options (filtering, depth, etc.)
///
/// # Returns
///
/// Vector of FileEntry for all matching files and directories
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), fast_fs::Error> {
/// use fast_fs::{read_dir_recursive, TraversalOptions};
///
/// let options = TraversalOptions::default()
///     .with_max_depth(3)
///     .with_extensions(&["rs"]);
///
/// let entries = read_dir_recursive("/project/src", options).await?;
/// # Ok(())
/// # }
/// ```
pub async fn read_dir_recursive(
    path: impl AsRef<Path>,
    options: TraversalOptions,
) -> Result<Vec<FileEntry>> {
    let path = path.as_ref().to_path_buf();

    // When gitignore is enabled, use the ignore crate's WalkBuilder which
    // properly handles nested .gitignore files with correct path contexts
    if options.gitignore {
        return read_dir_with_walkbuilder(&path, &options).await;
    }

    // Fallback to our custom implementation when gitignore is disabled
    let mut all_entries = Vec::new();
    let mut visited = HashSet::new();

    // Canonicalize root to ensure stable tracking
    if let Ok(canon) = path.canonicalize() {
        visited.insert(canon);
    }

    // Determine max depth (None means unlimited, represented as usize::MAX)
    let max_depth = options.max_depth.unwrap_or(usize::MAX);

    read_recursive_inner(
        &path,
        &options,
        None, // No gitignore matcher needed when gitignore is disabled
        max_depth,
        0,
        &mut all_entries,
        &mut visited,
    )
    .await?;

    Ok(all_entries)
}

/// Use the ignore crate's WalkBuilder for proper nested gitignore support
async fn read_dir_with_walkbuilder(
    path: &Path,
    options: &TraversalOptions,
) -> Result<Vec<FileEntry>> {
    let path = path.to_path_buf();
    let max_depth = options.max_depth;
    let include_hidden = options.include_hidden;
    let extensions: Vec<String> = options.extensions.clone();

    // Run WalkBuilder in a blocking task since it's synchronous
    let entries = tokio::task::spawn_blocking(move || {
        let mut builder = WalkBuilder::new(&path);

        // Configure WalkBuilder
        builder
            .hidden(!include_hidden) // WalkBuilder's hidden() means "skip hidden"
            .git_ignore(true)
            .git_global(true)
            .git_exclude(true)
            .ignore(true); // Also respect .ignore files

        if let Some(depth) = max_depth {
            builder.max_depth(Some(depth + 1)); // WalkBuilder depth is 1-based for contents
        }

        let mut results = Vec::new();

        for entry in builder.build() {
            let entry = match entry {
                Ok(e) => e,
                Err(_) => continue, // Skip entries with errors
            };

            let entry_path = entry.path().to_path_buf();

            // Skip the root directory itself
            if entry_path == path {
                continue;
            }

            // Get metadata for file entry construction
            let metadata = match entry.metadata() {
                Ok(m) => m,
                Err(_) => continue,
            };

            let is_dir = metadata.is_dir();

            // Apply extension filter (only for files)
            if !is_dir && !extensions.is_empty() {
                let has_ext = entry_path
                    .extension()
                    .and_then(|e| e.to_str())
                    .map(|ext| {
                        let ext_lower = ext.to_lowercase();
                        extensions
                            .iter()
                            .any(|allowed| allowed.to_lowercase() == ext_lower)
                    })
                    .unwrap_or(false);

                if !has_ext {
                    continue;
                }
            }

            let is_symlink = entry.file_type().map(|ft| ft.is_symlink()).unwrap_or(false);
            let is_readonly = metadata.permissions().readonly();
            let file_entry = FileEntry::new(
                entry_path,
                is_dir,
                if is_dir { 0 } else { metadata.len() },
                metadata.modified().ok(),
                is_symlink,
                is_readonly,
            );

            results.push(file_entry);
        }

        results
    })
    .await
    .map_err(|e| Error::Io(std::io::Error::other(e)))?;

    Ok(entries)
}

fn read_recursive_inner<'a>(
    path: &'a Path,
    options: &'a TraversalOptions,
    gitignore: Option<&'a GitignoreMatcher>,
    max_depth: usize,
    current_depth: usize,
    entries: &'a mut Vec<FileEntry>,
    visited: &'a mut HashSet<PathBuf>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
    Box::pin(async move {
        if current_depth > max_depth {
            return Ok(());
        }

        // Tolerate errors in subdirectories (don't fail the whole tree)
        let dir_entries = match read_dir(path).await {
            Ok(e) => e,
            Err(_) => return Ok(()),
        };

        for entry in dir_entries {
            // Apply filters to determine if this entry should be included
            let filter_result = apply_filters(&entry, options, gitignore);

            if !filter_result.is_pass() {
                // Entry filtered out - skip it and don't recurse into it if it's a directory
                continue;
            }

            let is_dir = entry.is_dir;
            let entry_path = entry.path.clone();
            entries.push(entry);

            if is_dir && current_depth < max_depth {
                // Check for loops
                if let Ok(canon) = entry_path.canonicalize() {
                    if visited.contains(&canon) {
                        continue;
                    }
                    visited.insert(canon);
                }

                read_recursive_inner(
                    &entry_path,
                    options,
                    gitignore,
                    max_depth,
                    current_depth + 1,
                    entries,
                    visited,
                )
                .await?;
            }
        }

        Ok(())
    })
}

// <FILE>crates/fast-fs/src/reader/fnc_read_dir_recursive.rs</FILE>
// <VERS>END OF VERSION: 2.1.0</VERS>