lumin 0.1.16

A library for searching and displaying local files
Documentation
use anyhow::Result;
use defer::defer;
use lumin::traverse::{TraverseOptions, traverse_directory};
use serial_test::serial;
use std::path::Path;

mod test_helpers;
use test_helpers::{TEST_DIR, TestEnvironment};

/// Tests for the traverse functionality
#[cfg(test)]
mod traverse_tests {
    use super::*;

    /// Test directory traversal with default options
    #[test]
    #[serial]
    fn test_traverse_default_options() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        let options = TraverseOptions::default();

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find multiple files
        assert!(!results.is_empty());

        // Should find Rust files
        assert!(results.iter().any(|r| r.file_type == "rs"));

        // Should find Markdown files
        assert!(results.iter().any(|r| r.file_type == "md"));

        // Should find Python files
        assert!(results.iter().any(|r| r.file_type == "py"));

        // The current implementation may or may not exclude binary files automatically,
        // depending on how the infer crate classifies them, so skip this check
        // and test it explicitly in test_traverse_include_binary

        // Should not find files in .hidden directory (respects gitignore)
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().contains(".hidden"))
        );

        // Should not find temporary files (respects gitignore)
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".tmp"))
        );

        // Should not find log files (respects gitignore)
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".log"))
        );

        Ok(())
    }

    /// Test traversal including binary files
    #[test]
    #[serial]
    fn test_traverse_include_binary() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        let mut options = TraverseOptions::default();
        options.only_text_files = false;

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find binary files
        assert!(results.iter().any(|r| r.file_type == "jpg"
            || r.file_type == "png"
            || r.file_path.to_string_lossy().contains("binary_executable")));

        Ok(())
    }

    /// Test that the iterator skips files in .hidden directory by default
    #[test]
    #[serial]
    fn test_traverse_respect_gitignore() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        // First, make sure the .hidden directory exists and contains files
        let hidden_path = Path::new(TEST_DIR).join(".hidden");
        assert!(
            hidden_path.exists(),
            "Test setup error: .hidden directory doesn't exist"
        );
        assert!(
            std::fs::read_dir(hidden_path)?.next().is_some(),
            "Test setup error: .hidden directory is empty"
        );

        // Test with default options (should respect gitignore)
        let options = TraverseOptions::default();

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should NOT find files in .hidden directory
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().contains(".hidden")),
            "Found .hidden files when respecting gitignore"
        );

        // Should NOT find temporary files
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".tmp")),
            "Found .tmp files when respecting gitignore"
        );

        // Should NOT find log files
        assert!(
            !results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".log")),
            "Found .log files when respecting gitignore"
        );

        Ok(())
    }

    /// Test traversal without respecting gitignore
    #[test]
    #[serial]
    fn test_traverse_ignore_gitignore() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        // Configure traversal to ignore gitignore
        let mut options = TraverseOptions::default();
        options.respect_gitignore = false;

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find files in .hidden directory
        assert!(
            results
                .iter()
                .any(|r| r.file_path.to_string_lossy().contains(".hidden")),
            "Did not find .hidden files when ignoring gitignore"
        );

        // Should find temporary files
        assert!(
            results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".tmp")),
            "Did not find .tmp files when ignoring gitignore"
        );

        // Should find log files
        assert!(
            results
                .iter()
                .any(|r| r.file_path.to_string_lossy().ends_with(".log")),
            "Did not find .log files when ignoring gitignore"
        );

        Ok(())
    }

    /// Test the is_hidden method
    #[test]
    #[serial]
    fn test_is_hidden() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        let mut options = TraverseOptions::default();
        options.respect_gitignore = false; // To include hidden files

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Files in .hidden directory should be marked as hidden
        for result in &results {
            if result.file_path.to_string_lossy().contains(".hidden") {
                assert!(
                    result.is_hidden(),
                    "File in .hidden directory not marked as hidden"
                );
            }
        }

        Ok(())
    }

    /// Test traversal with case-sensitive option
    #[test]
    #[serial]
    fn test_traverse_case_sensitive() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        let mut options = TraverseOptions::default();
        options.case_sensitive = true;

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should still find files regardless of case sensitivity
        assert!(!results.is_empty());

        Ok(())
    }

    /// Test traversal with pattern matching
    #[test]
    #[serial]
    fn test_traverse_with_pattern() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        // Test with glob pattern matching .rs files
        let mut options = TraverseOptions::default();
        options.pattern = Some("**/*.rs".to_string());

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find Rust files only
        assert!(!results.is_empty());
        assert!(results.iter().all(|r| r.file_type == "rs"));

        // Test with glob pattern matching .md files
        let mut options = TraverseOptions::default();
        options.pattern = Some("**/*.md".to_string());

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find Markdown files only
        assert!(!results.is_empty());
        assert!(results.iter().all(|r| r.file_type == "md"));

        // Test with glob pattern matching files in specific directory
        let mut options = TraverseOptions::default();
        options.pattern = Some("**/docs/**".to_string());

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find files only in docs directory
        assert!(!results.is_empty());
        assert!(
            results
                .iter()
                .all(|r| r.file_path.to_string_lossy().contains("/docs/"))
        );

        // Test with plain text substring matching (non-glob pattern)
        let mut options = TraverseOptions::default();
        options.pattern = Some("README".to_string()); // Use a filename we know exists

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find files with "README" in the path
        assert!(!results.is_empty());
        assert!(
            results
                .iter()
                .any(|r| r.file_path.to_string_lossy().contains("README"))
        );

        // Test with plain text substring matching (case insensitive)
        let mut options = TraverseOptions::default();
        options.pattern = Some("contributing".to_string()); // Different pattern for case insensitive test
        options.case_sensitive = false;

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should find files with "CONTRIBUTING" in the path (case insensitive)
        assert!(!results.is_empty());
        assert!(results.iter().any(|r| {
            r.file_path
                .to_string_lossy()
                .to_lowercase()
                .contains("contributing")
        }));

        Ok(())
    }

    /// Test traversal with prefix pattern matching
    #[test]
    #[serial]
    fn test_traverse_with_prefix_pattern() -> Result<()> {
        let _env = TestEnvironment::setup()?;

        // Create test files for prefix matching
        let prefix_files = [
            format!("{}/test_prefix_file1.txt", TEST_DIR),
            format!("{}/test_prefix_file2.md", TEST_DIR),
            format!("{}/other_file.txt", TEST_DIR),
            format!("{}/docs/test_prefix_file3.txt", TEST_DIR),
        ];

        // Create the test files
        for file_path in &prefix_files {
            let path = Path::new(file_path);
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            std::fs::write(path, format!("Test content for {}", file_path))?;
        }

        // Cleanup function to remove the files after the test
        let _cleanup = defer::defer(|| {
            for file_path in &prefix_files {
                let _ = std::fs::remove_file(file_path);
            }
        });

        // Test root-level prefix matching
        let mut options = TraverseOptions::default();
        options.pattern = Some("test_prefix_*".to_string());

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should only match prefix files at the root level
        assert_eq!(
            results.len(),
            2,
            "Should match exactly 2 files at the root level"
        );

        assert!(results.iter().any(|r| {
            r.file_path
                .to_string_lossy()
                .ends_with("test_prefix_file1.txt")
        }));
        assert!(results.iter().any(|r| {
            r.file_path
                .to_string_lossy()
                .ends_with("test_prefix_file2.md")
        }));
        assert!(!results.iter().any(|r| {
            r.file_path
                .to_string_lossy()
                .contains("docs/test_prefix_file3.txt")
        }));

        // Test recursive prefix matching
        let mut options = TraverseOptions::default();
        options.pattern = Some("**/test_prefix_*".to_string());

        let results = traverse_directory(Path::new(TEST_DIR), &options)?;

        // Should match all 3 prefix files in any directory
        assert_eq!(
            results.len(),
            3,
            "Should match all 3 files with the prefix in any directory"
        );

        assert!(results.iter().any(|r| {
            r.file_path
                .to_string_lossy()
                .contains("docs/test_prefix_file3.txt")
        }));

        Ok(())
    }
}