use crate::slog; use crate::state::SessionState; use ignore::WalkBuilder; use serde::Serialize; use std::fs; use std::path::PathBuf; #[derive(Serialize, Debug, poem_openapi::Object)] pub struct SearchResult { pub path: String, pub matches: usize, } fn get_project_root(state: &SessionState) -> Result { state.get_project_root() } pub async fn search_files( query: String, state: &SessionState, ) -> Result, String> { let root = get_project_root(state)?; search_files_impl(query, root).await } pub async fn search_files_impl(query: String, root: PathBuf) -> Result, String> { let root_clone = root.clone(); let results = tokio::task::spawn_blocking(move || { let mut matches = Vec::new(); 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(); if let Ok(content) = fs::read_to_string(path) && content.contains(&query) { let relative = path .strip_prefix(&root_clone) .unwrap_or(path) .to_string_lossy() .to_string(); matches.push(SearchResult { path: relative, matches: 1, }); } } Err(err) => slog!("Error walking dir: {}", err), } } matches }) .await .map_err(|e| format!("Search task failed: {e}"))?; Ok(results) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn setup_project(files: &[(&str, &str)]) -> TempDir { let dir = TempDir::new().unwrap(); for (path, content) in files { let full = dir.path().join(path); if let Some(parent) = full.parent() { fs::create_dir_all(parent).unwrap(); } fs::write(full, content).unwrap(); } dir } #[tokio::test] async fn finds_files_matching_query() { let dir = setup_project(&[ ("hello.txt", "hello world"), ("goodbye.txt", "goodbye world"), ]); let results = search_files_impl("hello".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "hello.txt"); } #[tokio::test] async fn returns_empty_for_no_matches() { let dir = setup_project(&[("file.txt", "some content")]); let results = search_files_impl("nonexistent".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert!(results.is_empty()); } #[tokio::test] async fn searches_nested_directories() { let dir = setup_project(&[ ("top.txt", "needle"), ("sub/deep.txt", "needle in haystack"), ("sub/other.txt", "no match here"), ]); let results = search_files_impl("needle".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert_eq!(results.len(), 2); let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect(); assert!(paths.contains(&"top.txt")); assert!(paths.contains(&"sub/deep.txt")); } #[tokio::test] async fn skips_directories_only_matches_files() { let dir = setup_project(&[("sub/file.txt", "content")]); let results = search_files_impl("content".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "sub/file.txt"); } #[tokio::test] async fn respects_gitignore() { let dir = setup_project(&[ (".gitignore", "ignored/\n"), ("kept.txt", "search term"), ("ignored/hidden.txt", "search term"), ]); // Initialize a git repo so .gitignore is respected std::process::Command::new("git") .args(["init"]) .current_dir(dir.path()) .output() .unwrap(); let results = search_files_impl("search term".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "kept.txt"); } #[tokio::test] async fn search_files_with_session_state() { let dir = setup_project(&[("found.txt", "target_text")]); let state = SessionState::default(); *state.project_root.lock().unwrap() = Some(dir.path().to_path_buf()); let results = search_files("target_text".to_string(), &state).await.unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "found.txt"); } #[tokio::test] async fn search_files_errors_without_project_root() { let state = SessionState::default(); let result = search_files("query".to_string(), &state).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("No project")); } #[test] fn search_result_serializes_and_debugs() { let sr = SearchResult { path: "src/main.rs".to_string(), matches: 3, }; let json = serde_json::to_string(&sr).unwrap(); assert!(json.contains("src/main.rs")); assert!(json.contains("3")); let debug = format!("{sr:?}"); assert!(debug.contains("SearchResult")); assert!(debug.contains("src/main.rs")); } #[tokio::test] async fn skips_binary_files() { let dir = TempDir::new().unwrap(); // Write a file with invalid UTF-8 bytes let binary_path = dir.path().join("binary.bin"); fs::write(&binary_path, [0xFF, 0xFE, 0x00, 0x01]).unwrap(); // Write a valid text file with the search term fs::write(dir.path().join("text.txt"), "findme").unwrap(); let results = search_files_impl("findme".to_string(), dir.path().to_path_buf()) .await .unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].path, "text.txt"); } }