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) => eprintln!("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"); } }