Wrote some tests.

This commit is contained in:
Dave
2026-01-27 14:45:28 +00:00
parent 8b14aa1f6f
commit 97b0ce1b58
12 changed files with 2029 additions and 97 deletions

View File

@@ -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);
}
}