//! Integration tests for search operations (ls, glob, grep). //! //! These tests verify: //! - Directory listing with filtering //! - Glob pattern matching //! - Regex content search with context //! - Result limits enforcement //! - Binary file detection //! - Windows path support use dirigent_tools::config::SearchConfig; use dirigent_tools::search::{ glob_search, grep_search, ls, FileKind, GlobRequest, GrepRequest, LsRequest, }; use std::fs; use std::io::Write; use tempfile::TempDir; /// Helper to create test directory structure fn create_test_structure() -> TempDir { let temp_dir = TempDir::new().unwrap(); let base = temp_dir.path(); // Create directory structure fs::create_dir(base.join("src")).unwrap(); fs::create_dir(base.join("tests")).unwrap(); fs::create_dir(base.join("target")).unwrap(); // Should be excluded by default fs::create_dir(base.join(".git")).unwrap(); // Should be excluded by default // Create some files fs::write(base.join("README.md"), "# Test Project\n").unwrap(); fs::write(base.join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap(); fs::write( base.join("src/main.rs"), "fn main() {\n println!(\"Hello, world!\");\n}\n", ) .unwrap(); fs::write( base.join("src/lib.rs"), "pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n", ) .unwrap(); fs::write( base.join("tests/integration.rs"), "#[test]\nfn test_add() {\n assert_eq!(add(2, 2), 4);\n}\n", ) .unwrap(); // Create a file in excluded directory (should not appear in searches) fs::write(base.join("target/debug.log"), "debug info\n").unwrap(); temp_dir } #[tokio::test] async fn test_ls_basic() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); let request = LsRequest { path: base.to_string_lossy().to_string(), }; let response = ls(request, &config).await.unwrap(); // Should have files and directories, but not .git or target (excluded by default) assert!(response.entries.len() >= 4); // README, Cargo.toml, src, tests // Check we have both files and directories let has_file = response.entries.iter().any(|e| e.kind == FileKind::File); let has_dir = response.entries.iter().any(|e| e.kind == FileKind::Dir); assert!(has_file); assert!(has_dir); // Check that excluded directories are not present let has_target = response .entries .iter() .any(|e| e.path.file_name().unwrap() == "target"); let has_git = response .entries .iter() .any(|e| e.path.file_name().unwrap() == ".git"); assert!(!has_target, "target/ should be excluded by default"); assert!(!has_git, ".git/ should be excluded by default"); } #[tokio::test] async fn test_ls_file_sizes() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); let request = LsRequest { path: base.to_string_lossy().to_string(), }; let response = ls(request, &config).await.unwrap(); // Files should have sizes, directories should not for entry in &response.entries { match entry.kind { FileKind::File => assert!(entry.size.is_some(), "Files should have sizes"), FileKind::Dir => assert!(entry.size.is_none(), "Directories should not have sizes"), FileKind::Symlink => {} // Symlinks may or may not have sizes } } } #[tokio::test] async fn test_glob_basic() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Search for all Rust files let request = GlobRequest { path: base.to_string_lossy().to_string(), pattern: "**/*.rs".to_string(), exclude: None, max_results: None, }; let response = glob_search(request, &config).await.unwrap(); // Should find main.rs, lib.rs, and integration.rs (3 files) assert_eq!(response.matches.len(), 3); assert!(!response.truncated); // Verify all matches end with .rs for path in &response.matches { assert!(path.extension().unwrap() == "rs"); } } #[tokio::test] async fn test_glob_pattern_variations() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Test single-level wildcard let request = GlobRequest { path: base.to_string_lossy().to_string(), pattern: "*.md".to_string(), exclude: None, max_results: None, }; let response = glob_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 1); // README.md assert!(!response.truncated); // Test specific directory let request = GlobRequest { path: base.to_string_lossy().to_string(), pattern: "src/*.rs".to_string(), exclude: None, max_results: None, }; let response = glob_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 2); // main.rs, lib.rs assert!(!response.truncated); } #[tokio::test] async fn test_glob_max_results() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Limit to 2 results let request = GlobRequest { path: base.to_string_lossy().to_string(), pattern: "**/*.rs".to_string(), exclude: None, max_results: Some(2), }; let response = glob_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 2); assert!(response.truncated); // Should be truncated since there are 3 .rs files } #[tokio::test] async fn test_glob_exclude_patterns() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Search for all .rs files but exclude tests let request = GlobRequest { path: base.to_string_lossy().to_string(), pattern: "**/*.rs".to_string(), exclude: Some(vec!["**/tests/**".to_string()]), max_results: None, }; let response = glob_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 2); // Only main.rs and lib.rs, not integration.rs assert!(!response.truncated); } #[tokio::test] async fn test_grep_basic() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Search for "main" in all files let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: "main".to_string(), file_pattern: None, ignore_case: false, context_before: 0, context_after: 0, max_results: None, }; let response = grep_search(request, &config).await.unwrap(); // Should find "main" in src/main.rs assert!(response.matches.len() >= 1); assert!(!response.truncated); // Verify line numbers are 1-indexed for m in &response.matches { assert!(m.line_number > 0); } } #[tokio::test] async fn test_grep_case_insensitive() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Search for "HELLO" (case insensitive) let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: "HELLO".to_string(), file_pattern: Some("**/*.rs".to_string()), ignore_case: true, context_before: 0, context_after: 0, max_results: None, }; let response = grep_search(request, &config).await.unwrap(); // Should find "Hello" in main.rs assert!(response.matches.len() >= 1); } #[tokio::test] async fn test_grep_context_lines() { let temp_dir = create_test_structure(); let base = temp_dir.path(); // Create a file with more content fs::write( base.join("src/context_test.rs"), "line 1\nline 2\nMATCH HERE\nline 4\nline 5\n", ) .unwrap(); let config = SearchConfig::default(); // Search with context let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: "MATCH".to_string(), file_pattern: Some("**/context_test.rs".to_string()), ignore_case: false, context_before: 2, context_after: 2, max_results: None, }; let response = grep_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 1); let m = &response.matches[0]; // Should have 2 lines before and 2 lines after assert_eq!(m.context_before.len(), 2); assert_eq!(m.context_after.len(), 2); assert_eq!(m.context_before[0], "line 1"); assert_eq!(m.context_before[1], "line 2"); assert_eq!(m.context_after[0], "line 4"); assert_eq!(m.context_after[1], "line 5"); } #[tokio::test] async fn test_grep_max_results() { let temp_dir = create_test_structure(); let base = temp_dir.path(); // Create a file with multiple matches fs::write( base.join("many_matches.txt"), "match\nmatch\nmatch\nmatch\nmatch\n", ) .unwrap(); let config = SearchConfig::default(); // Limit to 3 results let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: "match".to_string(), file_pattern: None, ignore_case: false, context_before: 0, context_after: 0, max_results: Some(3), }; let response = grep_search(request, &config).await.unwrap(); assert_eq!(response.matches.len(), 3); assert!(response.truncated); } #[tokio::test] async fn test_grep_binary_file_skip() { let temp_dir = TempDir::new().unwrap(); let base = temp_dir.path(); // Create a binary file with null bytes let binary_path = base.join("binary.bin"); let mut file = fs::File::create(&binary_path).unwrap(); file.write_all(&[0x00, 0x01, 0x02, 0x03]).unwrap(); // Create a text file fs::write(base.join("text.txt"), "some text\n").unwrap(); let config = SearchConfig::default(); // Search for any character let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: ".".to_string(), file_pattern: None, ignore_case: false, context_before: 0, context_after: 0, max_results: None, }; let response = grep_search(request, &config).await.unwrap(); // Should only find matches in text.txt, not binary.bin assert!(response.matches.len() > 0); for m in &response.matches { assert!( m.path.to_string_lossy().contains("text.txt"), "Should not match binary files" ); } } #[tokio::test] async fn test_grep_regex_patterns() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Test regex with character classes let request = GrepRequest { path: base.to_string_lossy().to_string(), pattern: r"\d+".to_string(), // Match numbers file_pattern: None, ignore_case: false, context_before: 0, context_after: 0, max_results: None, }; let response = grep_search(request, &config).await.unwrap(); // Should find numbers in test files (e.g., "2, 2" in assert) assert!(response.matches.len() > 0); } #[cfg(windows)] #[tokio::test] async fn test_windows_paths() { let temp_dir = create_test_structure(); let base = temp_dir.path(); let config = SearchConfig::default(); // Test with Windows path separators let path_str = base.to_string_lossy().to_string(); // Test ls let request = LsRequest { path: path_str.clone() }; let response = ls(request, &config).await.unwrap(); assert!(response.entries.len() > 0); // Test glob let request = GlobRequest { path: path_str.clone(), pattern: "**\\*.rs".to_string(), // Windows-style pattern exclude: None, max_results: None, }; let response = glob_search(request, &config).await.unwrap(); assert!(response.matches.len() > 0); }