fast-fs 0.2.1

High-speed async file system traversal library with batteries-included file browser component
Documentation
// <FILE>tests/reader/test_fnc_read_dir_stream.rs</FILE> - <DESC>Tests for streaming directory traversal</DESC>
// <VERS>VERSION: 1.0.1</VERS>
// <WCTX>Implementing streaming directory reading for fast-fs</WCTX>
// <CLOG>Fixed unused imports and read_dir_recursive call signature</CLOG>

use fast_fs::{read_dir_stream, TraversalOptions};
use futures::StreamExt;
use std::collections::HashSet;
use tempfile::TempDir;
use tokio::fs;

/// Test that stream yields entries from a directory
#[tokio::test]
async fn test_stream_yields_entries() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create test files
    fs::write(root.join("file1.txt"), "content1").await.unwrap();
    fs::write(root.join("file2.txt"), "content2").await.unwrap();
    fs::create_dir(root.join("subdir")).await.unwrap();
    fs::write(root.join("subdir/file3.txt"), "content3")
        .await
        .unwrap();

    let options = TraversalOptions::default().with_max_depth(10);
    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut entries = Vec::new();
    while let Some(result) = stream.next().await {
        entries.push(result.unwrap());
    }

    // Should have 3 files and 1 directory
    assert_eq!(entries.len(), 4);

    let paths: HashSet<String> = entries
        .iter()
        .map(|e| e.name.clone())
        .collect();

    assert!(paths.contains("file1.txt"));
    assert!(paths.contains("file2.txt"));
    assert!(paths.contains("subdir"));
    assert!(paths.contains("file3.txt"));
}

/// Test that stream respects TraversalOptions filtering
#[tokio::test]
async fn test_stream_respects_options() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create mixed files
    fs::write(root.join("file.rs"), "rust").await.unwrap();
    fs::write(root.join("file.py"), "python").await.unwrap();
    fs::write(root.join("file.txt"), "text").await.unwrap();
    fs::write(root.join(".hidden"), "secret").await.unwrap();

    // Filter to only .rs files, exclude hidden
    let options = TraversalOptions::default()
        .with_extensions(&["rs"])
        .with_max_depth(10);

    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut entries = Vec::new();
    while let Some(result) = stream.next().await {
        entries.push(result.unwrap());
    }

    // Should only have file.rs
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].name, "file.rs");
}

/// Test that stream matches batch traversal results
#[tokio::test]
async fn test_stream_matches_batch() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create nested structure
    fs::write(root.join("a.txt"), "a").await.unwrap();
    fs::create_dir(root.join("dir1")).await.unwrap();
    fs::write(root.join("dir1/b.txt"), "b").await.unwrap();
    fs::create_dir(root.join("dir1/dir2")).await.unwrap();
    fs::write(root.join("dir1/dir2/c.txt"), "c").await.unwrap();

    let options = TraversalOptions::default().with_max_depth(10);

    // Get streaming results
    let mut stream = Box::pin(read_dir_stream(root, options.clone()));
    let mut stream_entries = Vec::new();
    while let Some(result) = stream.next().await {
        stream_entries.push(result.unwrap());
    }

    // Get batch results
    use fast_fs::read_dir_recursive;
    let options = TraversalOptions::default()
        .with_max_depth(10)
        .with_gitignore(false);
    let batch_entries = read_dir_recursive(root, options).await.unwrap();

    // Convert to sets for comparison (order may differ)
    let stream_paths: HashSet<String> = stream_entries
        .iter()
        .map(|e| e.path.to_string_lossy().to_string())
        .collect();

    let batch_paths: HashSet<String> = batch_entries
        .iter()
        .map(|e| e.path.to_string_lossy().to_string())
        .collect();

    assert_eq!(stream_paths, batch_paths);
    assert_eq!(stream_entries.len(), batch_entries.len());
}

/// Test that stream works with empty directory
#[tokio::test]
async fn test_stream_empty_directory() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    let options = TraversalOptions::default().with_max_depth(10);
    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut count = 0;
    while let Some(result) = stream.next().await {
        result.unwrap();
        count += 1;
    }

    assert_eq!(count, 0);
}

/// Test that stream respects max_depth
#[tokio::test]
async fn test_stream_respects_max_depth() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create deep structure
    fs::create_dir(root.join("level1")).await.unwrap();
    fs::write(root.join("level1/file1.txt"), "1").await.unwrap();
    fs::create_dir(root.join("level1/level2")).await.unwrap();
    fs::write(root.join("level1/level2/file2.txt"), "2")
        .await
        .unwrap();
    fs::create_dir(root.join("level1/level2/level3"))
        .await
        .unwrap();
    fs::write(root.join("level1/level2/level3/file3.txt"), "3")
        .await
        .unwrap();

    // Max depth 1 should only get level1 and file1.txt
    let options = TraversalOptions::default().with_max_depth(1);
    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut entries = Vec::new();
    while let Some(result) = stream.next().await {
        entries.push(result.unwrap());
    }

    // Should have: level1/, level1/file1.txt, and level1/level2/ (but not contents of level2/)
    assert_eq!(entries.len(), 3);

    let names: HashSet<String> = entries.iter().map(|e| e.name.clone()).collect();
    assert!(names.contains("level1"));
    assert!(names.contains("file1.txt"));
    assert!(names.contains("level2"));
    assert!(!names.contains("file2.txt"));
    assert!(!names.contains("file3.txt"));
}

/// Test that stream handles gitignore filtering
#[tokio::test]
async fn test_stream_gitignore_filtering() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create .gitignore
    fs::write(root.join(".gitignore"), "*.log\nignored/\n")
        .await
        .unwrap();

    // Create files
    fs::write(root.join("keep.txt"), "keep").await.unwrap();
    fs::write(root.join("remove.log"), "remove").await.unwrap();
    fs::create_dir(root.join("ignored")).await.unwrap();
    fs::write(root.join("ignored/file.txt"), "ignored")
        .await
        .unwrap();

    let options = TraversalOptions::default()
        .with_gitignore(true)
        .with_max_depth(10);

    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut entries = Vec::new();
    while let Some(result) = stream.next().await {
        entries.push(result.unwrap());
    }

    let names: HashSet<String> = entries.iter().map(|e| e.name.clone()).collect();

    // Should include .gitignore and keep.txt, but not remove.log or ignored/
    assert!(names.contains(".gitignore") || !names.contains(".gitignore")); // .gitignore itself might be included
    assert!(names.contains("keep.txt"));
    assert!(!names.contains("remove.log"));
    assert!(!names.contains("ignored"));
    assert!(!names.contains("file.txt"));
}

/// Test that stream can be consumed slowly (backpressure)
#[tokio::test]
async fn test_stream_backpressure() {
    let temp = TempDir::new().unwrap();
    let root = temp.path();

    // Create many files
    for i in 0..100 {
        fs::write(root.join(format!("file{}.txt", i)), format!("content{}", i))
            .await
            .unwrap();
    }

    let options = TraversalOptions::default().with_max_depth(10);
    let mut stream = Box::pin(read_dir_stream(root, options));

    let mut count = 0;
    while let Some(result) = stream.next().await {
        result.unwrap();
        count += 1;
        // Simulate slow consumer
        tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
    }

    assert_eq!(count, 100);
}

// <FILE>tests/reader/test_fnc_read_dir_stream.rs</FILE>
// <VERS>END OF VERSION: 1.0.1</VERS>