use crate::state::SessionState; use ignore::WalkBuilder; use serde::Serialize; use std::fs; use std::path::PathBuf; use tauri::State; // ----------------------------------------------------------------------------- // Helper Functions // ----------------------------------------------------------------------------- /// Helper to get the root path (cloned) without joining fn get_project_root(state: &State<'_, SessionState>) -> Result { state.inner().get_project_root() } // ----------------------------------------------------------------------------- // Commands // ----------------------------------------------------------------------------- #[derive(Serialize, Debug)] pub struct SearchResult { path: String, // Relative path matches: usize, } /// Searches for files containing the specified query string within the current project. /// /// This command performs a case-sensitive substring search across all files in the project, /// respecting `.gitignore` rules by default. The search is executed on a blocking thread /// to avoid blocking the async runtime. /// /// # Arguments /// /// * `query` - The search string to look for in file contents /// * `state` - The session state containing the project root path /// /// # Returns /// /// Returns a `Vec` containing: /// - `path`: The relative path of each matching file /// - `matches`: The number of matches (currently simplified to 1 per file) /// /// # Errors /// /// Returns an error if: /// - No project is currently open /// - The project root lock cannot be acquired /// - The search task fails to execute /// /// # Note /// /// This is a naive implementation that reads entire files into memory. /// For production use, consider using streaming/buffered reads or the `grep-searcher` crate. /// Search files implementation (pure function for testing) pub async fn search_files_impl(query: String, root: PathBuf) -> Result, String> { let root_clone = root.clone(); // Run computationally expensive search on a blocking thread let results = tauri::async_runtime::spawn_blocking(move || { let mut matches = Vec::new(); // default to respecting .gitignore let walker = WalkBuilder::new(&root_clone).git_ignore(true).build(); for result in walker { match result { Ok(entry) => { if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) { continue; } let path = entry.path(); // Try to read file // Note: This is a naive implementation reading whole files into memory. // For production, we should stream/buffer reads or use grep-searcher. if let Ok(content) = fs::read_to_string(path) { // Simple substring search (case-sensitive) if content.contains(&query) { // Compute relative path for display let relative = path .strip_prefix(&root_clone) .unwrap_or(path) .to_string_lossy() .to_string(); matches.push(SearchResult { path: relative, matches: 1, // Simplified count for now }); } } } Err(err) => eprintln!("Error walking dir: {}", err), } } matches }) .await .map_err(|e| format!("Search task failed: {}", e))?; Ok(results) } #[tauri::command] pub async fn search_files( query: String, state: State<'_, SessionState>, ) -> Result, String> { let root = get_project_root(&state)?; search_files_impl(query, root).await } // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::fs; use std::sync::Mutex; use tempfile::TempDir; /// Helper to create a test SessionState with a given root path fn create_test_state(root: Option) -> SessionState { let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false); SessionState { project_root: Mutex::new(root), cancel_tx, cancel_rx, } } #[tokio::test] async fn test_search_files_no_project_open() { let state = create_test_state(None); let result = state.get_project_root(); assert!(result.is_err()); assert_eq!(result.unwrap_err(), "No project is currently open."); } #[tokio::test] async fn test_search_files_finds_matching_file() { let temp_dir = TempDir::new().unwrap(); let test_file = temp_dir.path().join("test.txt"); fs::write(&test_file, "This is a test file with some content").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("test".to_string(), root).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "test.txt"); assert_eq!(results[0].matches, 1); } #[tokio::test] async fn test_search_files_multiple_matches() { let temp_dir = TempDir::new().unwrap(); // Create multiple files with matching content fs::write(temp_dir.path().join("file1.txt"), "hello world").unwrap(); fs::write(temp_dir.path().join("file2.txt"), "hello again").unwrap(); fs::write(temp_dir.path().join("file3.txt"), "goodbye").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("hello".to_string(), root).await.unwrap(); assert_eq!(results.len(), 2); let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); assert!(paths.contains(&"file1.txt")); assert!(paths.contains(&"file2.txt")); } #[tokio::test] async fn test_search_files_no_matches() { let temp_dir = TempDir::new().unwrap(); fs::write(temp_dir.path().join("test.txt"), "This is some content").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("nonexistent".to_string(), root) .await .unwrap(); assert_eq!(results.len(), 0); } #[tokio::test] async fn test_search_files_case_sensitive() { let temp_dir = TempDir::new().unwrap(); fs::write(temp_dir.path().join("test.txt"), "Hello World").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); // Search for lowercase - should not match let root = state.get_project_root().unwrap(); let results = search_files_impl("hello".to_string(), root.clone()) .await .unwrap(); assert_eq!(results.len(), 0); // Search for correct case - should match let results = search_files_impl("Hello".to_string(), root).await.unwrap(); assert_eq!(results.len(), 1); } #[tokio::test] async fn test_search_files_nested_directories() { let temp_dir = TempDir::new().unwrap(); // Create nested directory structure let nested_dir = temp_dir.path().join("subdir"); fs::create_dir(&nested_dir).unwrap(); fs::write(temp_dir.path().join("root.txt"), "match").unwrap(); fs::write(nested_dir.join("nested.txt"), "match").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("match".to_string(), root).await.unwrap(); assert_eq!(results.len(), 2); let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); assert!(paths.contains(&"root.txt")); assert!(paths.contains(&"subdir/nested.txt") || paths.contains(&"subdir\\nested.txt")); } #[tokio::test] async fn test_search_files_respects_gitignore() { let temp_dir = TempDir::new().unwrap(); // Initialize git repo (required for ignore crate to respect .gitignore) std::process::Command::new("git") .args(["init"]) .current_dir(temp_dir.path()) .output() .unwrap(); // Create .gitignore fs::write(temp_dir.path().join(".gitignore"), "ignored.txt\n").unwrap(); // Create files fs::write(temp_dir.path().join("included.txt"), "searchterm").unwrap(); fs::write(temp_dir.path().join("ignored.txt"), "searchterm").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("searchterm".to_string(), root) .await .unwrap(); // Should find the non-ignored file, but not the ignored one // The gitignore file itself might be included let has_included = results.iter().any(|r| r.path == "included.txt"); let has_ignored = results.iter().any(|r| r.path == "ignored.txt"); assert!(has_included, "included.txt should be found"); assert!( !has_ignored, "ignored.txt should NOT be found (it's in .gitignore)" ); } #[tokio::test] async fn test_search_files_skips_binary_files() { let temp_dir = TempDir::new().unwrap(); // Create a text file fs::write(temp_dir.path().join("text.txt"), "searchable").unwrap(); // Create a binary file (will fail to read as UTF-8) fs::write(temp_dir.path().join("binary.bin"), [0xFF, 0xFE, 0xFD]).unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("searchable".to_string(), root) .await .unwrap(); // Should only find the text file assert_eq!(results.len(), 1); assert_eq!(results[0].path, "text.txt"); } #[tokio::test] async fn test_search_files_empty_query() { let temp_dir = TempDir::new().unwrap(); fs::write(temp_dir.path().join("test.txt"), "content").unwrap(); let state = create_test_state(Some(temp_dir.path().to_path_buf())); let root = state.get_project_root().unwrap(); let results = search_files_impl("".to_string(), root).await.unwrap(); // Empty string is contained in all strings, so should match assert_eq!(results.len(), 1); } }