Files
storkit/server/src/io/search.rs
dave f610ef6046 Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:07:07 +00:00

219 lines
6.4 KiB
Rust

use crate::slog;
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<PathBuf, String> {
state.get_project_root()
}
pub async fn search_files(
query: String,
state: &SessionState,
) -> Result<Vec<SearchResult>, String> {
let root = get_project_root(state)?;
search_files_impl(query, root).await
}
pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<SearchResult>, 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) => slog!("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");
}
#[tokio::test]
async fn search_files_with_session_state() {
let dir = setup_project(&[("found.txt", "target_text")]);
let state = SessionState::default();
*state.project_root.lock().unwrap() = Some(dir.path().to_path_buf());
let results = search_files("target_text".to_string(), &state).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "found.txt");
}
#[tokio::test]
async fn search_files_errors_without_project_root() {
let state = SessionState::default();
let result = search_files("query".to_string(), &state).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("No project"));
}
#[test]
fn search_result_serializes_and_debugs() {
let sr = SearchResult {
path: "src/main.rs".to_string(),
matches: 3,
};
let json = serde_json::to_string(&sr).unwrap();
assert!(json.contains("src/main.rs"));
assert!(json.contains("3"));
let debug = format!("{sr:?}");
assert!(debug.contains("SearchResult"));
assert!(debug.contains("src/main.rs"));
}
#[tokio::test]
async fn skips_binary_files() {
let dir = TempDir::new().unwrap();
// Write a file with invalid UTF-8 bytes
let binary_path = dir.path().join("binary.bin");
fs::write(&binary_path, [0xFF, 0xFE, 0x00, 0x01]).unwrap();
// Write a valid text file with the search term
fs::write(dir.path().join("text.txt"), "findme").unwrap();
let results = search_files_impl("findme".to_string(), dir.path().to_path_buf())
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].path, "text.txt");
}
}