luff 0.2.1

Print files with formatting
Documentation
//! Environment-dependent tests for DirectoryWalker (streaming mode)
//!
//! These tests manipulate environment variables and require unsafe code,
//! so they're isolated in integration tests (which don't inherit the
//! forbid(unsafe_code) from lib.rs).
//!
//! Requires the `cli` feature (for `Args`, `Config::from_args`, etc.).
#![cfg(feature = "cli")]

use clap::Parser;
use luff::walker::WalkerItem;
use luff::{cli::Args, config::Config, walker::Walker};
use serial_test::serial;
use std::env;
use std::fs;
use std::time::Duration;
use tempfile::TempDir;

/// Helper to create test files
fn create_test_file(dir: &TempDir, name: &str) -> std::path::PathBuf {
    let path = dir.path().join(name);
    fs::write(&path, "content").unwrap();
    path
}

#[test]
#[serial]
fn test_streaming_walker_memory_efficient() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();

    // Create 1000 files
    for i in 0..1000 {
        let _ = create_test_file(&temp, &format!("file_{i}.txt"));
    }

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let walker = Walker::from_dir(&config).unwrap();

    // Verify no upfront allocation (size_hint is not exact)
    // Memory usage should remain constant regardless of directory size
    let mut count = 0;
    for item in walker {
        let entry = match item {
            luff::walker::WalkerItem::Entry(e) => e,
            luff::walker::WalkerItem::Error(_) => continue,
        };
        // Skip directories to only count files
        if entry.is_dir {
            continue;
        }
        assert!(!entry.path.to_string_lossy().is_empty());
        count += 1;
    }

    assert_eq!(count, 1000);
}

#[test]
#[serial]
fn test_max_files_limit_enforced() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();

    // Create 20 files
    for i in 0..20 {
        let _ = create_test_file(&temp, &format!("file_{i}.txt"));
    }

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test", "--max-files", "10"]);
    let config = Config::from_args(&args).unwrap();

    let walker = Walker::from_dir(&config).unwrap();
    let entries: Vec<_> = walker
        .filter_map(|item| item.into_entry())
        .filter(|e| !e.is_dir) // Skip directories
        .collect();

    assert_eq!(entries.len(), 10);
}

#[test]
#[serial]
fn test_output_file_protection_streaming() {
    // Set a high threshold to ensure the test doesn't flake on slow machines
    // We want the file to be considered "recent" (created < threshold ago)
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::set_var("LUFF_OUTPUT_PROTECTION_MS", "5000");
    }

    let temp = TempDir::new().unwrap();
    let output_file = temp.path().join("output.txt");

    // Simulate shell redirect by creating empty file immediately
    fs::write(&output_file, "").unwrap();

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let walker = Walker::from_dir(&config).unwrap();
    let entries: Vec<_> = walker
        .filter_map(|item| item.into_entry())
        .filter(|e| !e.is_dir) // Skip directories, only count files
        .collect();

    // Should skip the output file (recent empty file protection)
    // Root directory entry is also filtered out, so we expect 0 files
    assert_eq!(entries.len(), 0);

    // SAFETY: ""
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }
}

#[test]
#[serial]
fn test_streamer_never_panics_single_file() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();
    let _ = create_test_file(&temp, "file.txt");

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let walker = Walker::from_dir(&config).unwrap();

    // Count only files, not directories
    let count = walker
        .filter_map(|item| item.into_entry())
        .filter(|e| !e.is_dir) // Skip root directory entry
        .count();

    assert_eq!(count, 1);
}

#[test]
#[serial]
fn test_streamer_never_panics_empty_directory() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let walker = Walker::from_dir(&config).unwrap();

    // Count only files, not directories
    let count = walker
        .filter_map(|item| item.into_entry())
        .filter(|e| !e.is_dir) // Skip root directory entry
        .count();

    assert_eq!(count, 0);
}

/// Mirror of the constants from `dir.rs` so the integration test can
/// independently compute expected safety-cap values.
const TOTAL_ENTRIES_MINIMUM: usize = 10_000;
const TOTAL_ENTRIES_SAFETY_FACTOR: usize = 2;

#[test]
#[serial]
fn test_size_hint_consistency() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();
    let max_files: usize = 75;

    // flat directory, no subdirs
    for i in 0..max_files {
        let _ = create_test_file(&temp, &format!("file_{i}.txt"));
    }

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test", "--max-files", &max_files.to_string()]);
    let config = Config::from_args(&args).unwrap();

    let mut walker = Walker::from_dir(&config).unwrap();

    // Compute the expected max_total_entries using the same formula as
    // DirectoryWalker::new (see dir.rs constants).
    let expected_max_total = max_files
        .saturating_mul(TOTAL_ENTRIES_SAFETY_FACTOR)
        .max(TOTAL_ENTRIES_MINIMUM);

    // Verify initial size_hint before consuming anything.
    let (lower_init, upper_init) = walker.size_hint();
    assert_eq!(lower_init, 0, "Lower bound should always be 0 (unknown)");
    assert_eq!(
        upper_init,
        Some(expected_max_total),
        "Initial upper bound should equal max_total_entries"
    );

    // Consume some entries and track ALL yielded entries (files + dirs),
    // because size_hint is based on total_yielded, not files_processed.
    let target_calls = 25;
    let mut entries_yielded: usize = 0;
    let mut files_processed: usize = 0;

    for _ in 0..target_calls {
        match walker.next() {
            Some(WalkerItem::Entry(entry)) => {
                entries_yielded += 1;
                if !entry.is_dir {
                    files_processed += 1;
                }
            }
            Some(WalkerItem::Error(_)) => {
                // Errors are yielded by the iterator but do NOT
                // increment total_yielded inside DirectoryWalker,
                // so we don't count them here.
            }
            None => break,
        }
    }

    let (lower, upper) = walker.size_hint();

    // Basic validity: lower <= upper
    assert!(
        lower <= upper.unwrap_or(usize::MAX),
        "Lower bound ({lower}) must be <= upper bound ({upper:?})"
    );

    // Upper bound should be max_total_entries minus entries actually
    // yielded (files + directories).
    assert_eq!(
        upper,
        Some(expected_max_total - entries_yielded),
        "Upper bound should be max_total_entries minus total entries \
         yielded (got entries_yielded={entries_yielded}, \
         files_processed={files_processed})"
    );

    // Sanity: upper bound must have decreased from the initial value.
    assert!(
        upper.unwrap() < upper_init.unwrap(),
        "Upper bound should decrease after consuming entries"
    );
}

/// Verify that consuming ALL entries leaves size_hint at (0, Some(0))
/// when the walker is exhausted before hitting the safety cap.
#[test]
#[serial]
fn test_size_hint_exhausted_walker() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();

    // Small number of files so the walker exhausts naturally.
    for i in 0..5 {
        let _ = create_test_file(&temp, &format!("file_{i}.txt"));
    }

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let mut walker = Walker::from_dir(&config).unwrap();

    // Drain fully.
    let mut total = 0;
    for item in walker.by_ref() {
        if item.into_entry().is_some() {
            total += 1;
        }
    }

    assert!(total >= 5, "Should have yielded at least the 5 files");

    let (lower, upper) = walker.size_hint();
    assert_eq!(lower, 0);
    // After exhaustion the inner iterator returns None, but
    // max_total_entries - total_yielded may still be > 0 (safety cap is
    // much larger than actual entries). The important invariant is that
    // upper >= 0 and is Some.
    assert!(
        upper.is_some(),
        "Upper bound should still be Some after exhaustion"
    );
}

/// Verify that the upper bound monotonically decreases across every
/// `.next()` call that yields an entry.
#[test]
#[serial]
fn test_size_hint_monotonically_decreasing() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }

    let temp = TempDir::new().unwrap();

    for i in 0..10 {
        let _ = create_test_file(&temp, &format!("file_{i}.txt"));
    }

    env::set_current_dir(temp.path()).unwrap();
    let args = Args::parse_from(["test"]);
    let config = Config::from_args(&args).unwrap();

    let mut walker = Walker::from_dir(&config).unwrap();

    let mut prev_upper = walker.size_hint().1.unwrap();

    while let Some(item) = walker.next() {
        if item.into_entry().is_some() {
            let current_upper = walker.size_hint().1.unwrap();
            assert!(
                current_upper < prev_upper,
                "Upper bound must strictly decrease after yielding an \
                 entry: {current_upper} >= {prev_upper}"
            );
            prev_upper = current_upper;
        }
    }
}

#[test]
#[serial]
fn test_output_protection_threshold_parsing() {
    // SAFETY: marked with `#[serial]` to prevent concurrent execution
    unsafe {
        std::env::set_var("LUFF_OUTPUT_PROTECTION_MS", "500");
    }
    let threshold = luff::fs_utils::output_protection_threshold();
    assert_eq!(threshold, Duration::from_millis(500));

    // SAFETY: ""
    unsafe {
        std::env::remove_var("LUFF_OUTPUT_PROTECTION_MS");
    }
    let threshold_default = luff::fs_utils::output_protection_threshold();
    assert_eq!(threshold_default, Duration::from_millis(10));
}