Files
storkit/src-tauri/src/commands/search.rs
2026-01-27 15:28:09 +00:00

309 lines
11 KiB
Rust

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> {
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<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.
/// Search files implementation (pure function for testing)
pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<SearchResult>, 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<Vec<SearchResult>, 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<PathBuf>) -> 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);
}
}