Wrote some tests.
This commit is contained in:
@@ -11,23 +11,47 @@ use tauri::State;
|
||||
|
||||
/// Helper to get the root path (cloned) without joining
|
||||
fn get_project_root(state: &State<'_, SessionState>) -> Result<PathBuf, String> {
|
||||
let root_guard = state.project_root.lock().map_err(|e| e.to_string())?;
|
||||
let root = root_guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| "No project is currently open.".to_string())?;
|
||||
Ok(root.clone())
|
||||
state.inner().get_project_root()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Commands
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[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.
|
||||
#[tauri::command]
|
||||
pub async fn search_files(
|
||||
query: String,
|
||||
@@ -80,3 +104,262 @@ pub async fn search_files(
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user