2025-12-24 16:59:14 +00:00
|
|
|
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<PathBuf, String> {
|
2026-01-27 14:45:28 +00:00
|
|
|
state.inner().get_project_root()
|
2025-12-24 16:59:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// Commands
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
|
2026-01-27 14:45:28 +00:00
|
|
|
#[derive(Serialize, Debug)]
|
2025-12-24 16:59:14 +00:00
|
|
|
pub struct SearchResult {
|
|
|
|
|
path: String, // Relative path
|
|
|
|
|
matches: usize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-27 14:45:28 +00:00
|
|
|
/// 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<SearchResult>` 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.
|
2025-12-24 16:59:14 +00:00
|
|
|
#[tauri::command]
|
|
|
|
|
pub async fn search_files(
|
|
|
|
|
query: String,
|
|
|
|
|
state: State<'_, SessionState>,
|
|
|
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
|
|
|
let root = get_project_root(&state)?;
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-01-27 14:45:28 +00:00
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 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<PathBuf>) -> SessionState {
|
|
|
|
|
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
|
|
|
|
SessionState {
|
|
|
|
|
project_root: Mutex::new(root),
|
|
|
|
|
cancel_tx,
|
|
|
|
|
cancel_rx,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Helper to call search_files logic directly for testing
|
|
|
|
|
async fn call_search_files(
|
|
|
|
|
query: String,
|
|
|
|
|
state: &SessionState,
|
|
|
|
|
) -> Result<Vec<SearchResult>, String> {
|
|
|
|
|
let root = state.get_project_root()?;
|
|
|
|
|
let root_clone = root.clone();
|
|
|
|
|
|
|
|
|
|
let results = tauri::async_runtime::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) {
|
|
|
|
|
if !content.contains(&query) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_search_files_no_project_open() {
|
|
|
|
|
let state = create_test_state(None);
|
|
|
|
|
|
|
|
|
|
let result = call_search_files("test".to_string(), &state).await;
|
|
|
|
|
|
|
|
|
|
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 results = call_search_files("test".to_string(), &state).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 results = call_search_files("hello".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("nonexistent".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("hello".to_string(), &state)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert_eq!(results.len(), 0);
|
|
|
|
|
|
|
|
|
|
// Search for correct case - should match
|
|
|
|
|
let results = call_search_files("Hello".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("match".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("searchterm".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("searchable".to_string(), &state)
|
|
|
|
|
.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 results = call_search_files("".to_string(), &state).await.unwrap();
|
|
|
|
|
|
|
|
|
|
// Empty string is contained in all strings, so should match
|
|
|
|
|
assert_eq!(results.len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn test_get_project_root_helper() {
|
|
|
|
|
// Test with no root set
|
|
|
|
|
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.");
|
|
|
|
|
|
|
|
|
|
// Test with root set
|
|
|
|
|
let temp_dir = TempDir::new().unwrap();
|
|
|
|
|
let path = temp_dir.path().to_path_buf();
|
|
|
|
|
let state = create_test_state(Some(path.clone()));
|
|
|
|
|
let result = state.get_project_root();
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
assert_eq!(result.unwrap(), path);
|
|
|
|
|
}
|
|
|
|
|
}
|